Share-Safe Scan URL — Plings-Web Implementation Plan
Share-Safe Scan URL — Plings-Web Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Created: Sun 19 Jul 2026 10:11:31 CEST
Spec: docs/specs/2026-07-19-share-safe-scan-url-design.md
Goal: The URL a user can copy or share after a scan is always the clean plings.io/o/{oid} — never a scan URL — plus a visible “Dela” button that makes right-sharing the easiest action.
Architecture: ObjectLanding captures sid once into component state, then immediately history.replaceStates the address bar to the clean path (bypassing the router — no re-render). /welcome scrubs only sid (keeps ikey/path/cptr for the claim flow). A share button uses the Web Share API with clipboard fallback. Docs + ADR ship in the same PR.
Tech Stack: React 18, react-router, Apollo Client, shadcn/ui (Button), Vite. No unit-test runner in this repo — the gate is npx tsc --noEmit + npm run build + manual click-through.
Global Constraints
- The clean share URL is
${window.location.origin}/o/{oid}(origin-relative so previews/localhost work; production =https://plings.io/o/{oid}). sidmust be captured once, before any URL mutation — the geo back-fill must not lose it or re-fire on URL changes.- Use
window.history.replaceStatedirectly, NOTsetSearchParams— the router API triggers a re-render that would re-run effects. - All user-facing copy in plain Swedish (no system terms).
- Work in a worktree off
origin/dev(git -C Plings-Web fetch origin && git -C Plings-Web worktree add ../Plings-worktrees/Plings-Web/share-safe-scan-url -b task/share-safe-scan-url origin/dev), thennpm install. - Merge order: Plings-API merges first (single-use
sidguard), then this repo. Verify the API PR is merged before merging this one.
Task 1: ObjectLanding — capture sid once, scrub the URL immediately
Files:
- Modify:
src/pages/scan/ObjectLanding.tsx:1-54
Interfaces:
- Consumes: nothing new.
-
Produces:
sid: stringnow lives in component state (captured from the initial URL); the address bar is/o/{oid}from first paint. Task 3’s share button relies on the clean-URL invariant this task establishes. - Step 1: Replace the
sidderivation and add the scrub effect
In src/pages/scan/ObjectLanding.tsx, change the imports and the top of the component:
import { useParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
(useSearchParams is no longer imported — nothing else in the file uses it.)
Replace
const [sp] = useSearchParams();
const sid = sp.get('sid') ?? '';
with
// Capture the scan event id ONCE from the initial URL. The scrub effect below
// rewrites the address bar, so this must not be derived from live URL state.
const [sid] = useState(() => new URLSearchParams(window.location.search).get('sid') ?? '');
// Share-safe URL (spec 2026-07-19): scrub the scan parameters (ikey/sid/src/…)
// from the address bar immediately — before the geo prompt is even answered —
// so anything the user copies or shares is the clean share URL, never a scan
// URL. window.history directly (not the router API) => no re-render.
useEffect(() => {
if (window.location.search) {
window.history.replaceState(null, '', `/o/${oid}`);
}
}, [oid]);
The existing geo useEffect (if (!sid || …)) is unchanged — it already depends on sid, which is now stable state.
- Step 2: Type-check and build
Run: npx tsc --noEmit && npm run build
Expected: both succeed, no unused-import warnings for useSearchParams.
- Step 3: Manual verification (local)
Run npm run dev:local and open http://localhost:8080/o/<real-oid>?ikey=x&sid=y&src=scan:
- Address bar becomes
http://localhost:8080/o/<real-oid>immediately. - The page renders the object; reloading the clean URL renders identically.
-
With a real fresh
sid, the geolocation prompt still appears and “Senast sedd” still back-fills (the capturedsidsurvives the scrub). - Step 4: Commit
git add src/pages/scan/ObjectLanding.tsx
git commit -m "feat: scrub scan params from landing URL — copyable URL is always the clean share URL"
Task 2: /welcome — scrub sid only
Files:
- Modify:
src/pages/scan/Welcome.tsx:1-21
Interfaces:
- Consumes: nothing from Task 1.
-
Produces:
/welcomeURLs keepikey/path/cptr(the claim flow needs them) but dropsid. - Step 1: Add the scrub effect
In src/pages/scan/Welcome.tsx, add useEffect to the react import and insert after the cptr line:
import { useEffect } from 'react';
// Share-safe URL (spec 2026-07-19): drop the scan event id from the address
// bar. ikey/path/cptr stay — the claim flow needs them; sharing an unclaimed
// tag's URL is semantically the same as letting someone scan the unclaimed tag.
useEffect(() => {
const url = new URL(window.location.href);
if (url.searchParams.has('sid')) {
url.searchParams.delete('sid');
window.history.replaceState(null, '', url.toString());
}
}, []);
Note: Welcome never reads sid (only the Gateway puts it there), so no capture is needed.
- Step 2: Type-check and build
Run: npx tsc --noEmit && npm run build
Expected: both succeed.
- Step 3: Manual verification
Open http://localhost:8080/welcome?ikey=x&path=1.1.1.1.1&sid=y&src=scan:
- Address bar becomes
…/welcome?ikey=x&path=1.1.1.1.1&src=scan(onlysidgone). -
Both buttons still open the app hand-off with
ikey/pathintact. - Step 4: Commit
git add src/pages/scan/Welcome.tsx
git commit -m "feat: drop sid from /welcome URL (claim params stay)"
Task 3: “Dela” button on the landing card
Files:
- Modify:
src/pages/scan/ObjectLanding.tsx(the button stack, lines ~101-114, plus component state)
Interfaces:
- Consumes: the clean-URL invariant from Task 1.
-
Produces: a share action
share(): Promise<void>local to the component; no exports. - Step 1: Add share state and handler
Inside ObjectLanding (after the open handler):
const [copied, setCopied] = useState(false);
const share = async () => {
const url = `${window.location.origin}/o/${obj.id}`;
if (navigator.share) {
try { await navigator.share({ title: obj.name, url }); } catch { /* user cancelled */ }
} else {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
- Step 2: Add the button to BOTH branches of the button stack
The stack branches on isLost; the Dela button goes at the bottom of each branch (an outline button, consistent with the existing style):
<Button variant="outline" className="w-full" onClick={share}>
{copied ? 'Länk kopierad ✓' : 'Dela'}
</Button>
For the isLost branch it goes after “Öppna i appen”; for the normal branch after “Ladda ner appen” (above the disabled “Anmäl som upphittat” link-button).
- Step 3: Type-check, build, verify
Run: npx tsc --noEmit && npm run build
Expected: both succeed.
Manual: on desktop (no navigator.share) clicking Dela shows “Länk kopierad ✓” for 2 s and the clipboard holds http://localhost:8080/o/<oid>; on a phone the OS share sheet opens with the clean URL.
- Step 4: Commit
git add src/pages/scan/ObjectLanding.tsx
git commit -m "feat: Dela button on scan landing — Web Share API with clipboard fallback"
Task 4: Docs + ADR + ROADMAP (same-PR gates)
Files:
- Modify:
docs/core-systems/s-plings-io/url-structure.md(thesidrow + Standard Redirect Parameters section + timestamp header) - Create:
internal/decisions/ADR-NNNN-shared-link-counts-as-scan-clean-share-url.md(assign NNNN from the liveinternal/decisions/INDEX.mdat commit time — do NOT pre-assign) - Modify:
internal/decisions/INDEX.md(one new row) - Modify:
ROADMAP.md(repo root)
Interfaces:
- Consumes: the behaviour shipped in Tasks 1–3 and the API PR’s guard.
-
Produces: the durable decision record other sessions read before touching scan/share code.
- Step 1: Update
url-structure.md
In the Standard Redirect Parameters table, change the sid row description to:
| `sid` | Scan Event ID | When scan event is logged | Neo4j scan event node ID; the browser landing page uses it to back-fill geolocation. **Single-use with a 15-minute TTL** — the back-fill only succeeds while the event has no coordinates and is <15 min old. The landing page scrubs `sid` (and all scan params) from the address bar on arrival, so shared/copied URLs are clean share URLs. | `scan_789xyz` |
Add a short subsection after the table:
### Share URLs vs scan URLs
The tag URL (`s.plings.io?…`) and the enriched redirect URL are **scan URLs** — they
participate in scan logging. The **share URL** is the clean `plings.io/o/{oid}` with no
parameters: opening it never creates a ScanEvent and never prompts for location. The
landing page rewrites the address bar to the share URL immediately on arrival
(`history.replaceState`), and its "Dela" button shares it explicitly, so the URL people
naturally copy is never scan-coupled. Policy: possession of the tag URL still counts as
a scan (the Hittat flow requires an account-less stranger to be treated as standing at
the tag) — see the ADR and the design spec
`docs/specs/2026-07-19-share-safe-scan-url-design.md`.
Add an **Updated**: header line (use date "+%a %d %b %Y %H:%M:%S %Z") and bump the Document Version line.
- Step 2: Write the ADR (full tier, Nygard)
Check the highest number in internal/decisions/INDEX.md, take the next one. Content:
# ADR-NNNN — Shared-link opens count as scans; the share URL is the clean /o/{oid}; sid is single-use + TTL
**Status**: Accepted
**Scope**: [Web,API,Gateway,iOS]
**Date**: 2026-07-19
## Context
Users share the URL they have after scanning a tag. Both the tag URL and the
post-redirect landing URL were scan-coupled: the landing URL carried a reusable `sid`,
so anyone opening a shared copy could (via the geo prompt) overwrite the object's
"Senast sedd" position; the tag URL creates a new ScanEvent on every open. A physical
scan and a shared-link click are identical HTTP requests — the server cannot reliably
distinguish them (Referer/Sec-Fetch heuristics are unreliable; interaction gates add
friction to every genuine scan, against the fewest-actions north star).
## Decision
1. Possession of the tag URL counts as a scan — accepted for now (the Hittat flow
requires an account-less stranger to be treated as standing at the tag). Position is
only written after an explicit geolocation consent.
2. The share URL is the clean `plings.io/o/{oid}`. The landing page scrubs all scan
params from the address bar on arrival and offers a "Dela" button; the iOS app's
Dela shares the same clean URL. Right-sharing is the easiest action.
3. `recordScanLocation` is single-use per ScanEvent with a 15-minute TTL
(`e.latitude IS NULL AND e.timestamp > datetime() - duration('PT15M')`).
## Consequences
- A shared/copied URL can no longer move an object or re-seed anchors.
- Shared tag URLs still create ScanEvents (accepted noise). Future hardening is parked
in `Plings-API/ROADMAP.md` Backlog: geo-plausibility filtering and device trust via a
nearby-known-tag challenge.
- The genuine flow is unaffected: the geo prompt is answered seconds after the scan.
## Links
- Spec: `docs/specs/2026-07-19-share-safe-scan-url-design.md`
- Relates: ADR-0027 (rate-limit recordScanLocation in the resolver), ADR-0073 (derived positions)
Add the INDEX.md row following the existing format, with scope [Web,API,Gateway,iOS].
- Step 3: Update
ROADMAP.md
Under Done (fill PR number at merge time):
- Share-safe scan URL: landing page scrubs scan params (`/o/{oid}` clean from first paint), `/welcome` drops `sid`, Dela button (Web Share API + clipboard fallback) — spec `docs/specs/2026-07-19-share-safe-scan-url-design.md`, PR #NN, 2026-07-19
- Step 4: Include the spec + this plan in the branch
The approved spec (docs/specs/2026-07-19-share-safe-scan-url-design.md) and this plan file were written in the local checkout; add them to the branch so they publish with the PR (plan-docs-publish-via-branch).
git add docs/specs/2026-07-19-share-safe-scan-url-design.md docs/plans/2026-07-19-share-safe-scan-url.md docs/core-systems/s-plings-io/url-structure.md internal/decisions/ADR-*.md internal/decisions/INDEX.md ROADMAP.md
git commit -m "docs: share-safe scan URL — spec, plan, sid semantics, ADR, roadmap"
Task 5: Verify, ship
- Step 1:
npx tsc --noEmit && npm run build— green. - Step 2: quality-control-enforcer review (scan-routing-adjacent + public page change).
- Step 3: Confirm the Plings-API PR is merged, then push, PR against
dev(NEVERmain), merge, fast-forward localdev, reap the worktree.