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}).
  • sid must be captured once, before any URL mutation — the geo back-fill must not lose it or re-fire on URL changes.
  • Use window.history.replaceState directly, NOT setSearchParams — 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), then npm install.
  • Merge order: Plings-API merges first (single-use sid guard), 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: string now 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 sid derivation 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 captured sid survives 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: /welcome URLs keep ikey/path/cptr (the claim flow needs them) but drop sid.

  • 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 (only sid gone).
  • Both buttons still open the app hand-off with ikey/path intact.

  • 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 (the sid row + Standard Redirect Parameters section + timestamp header)
  • Create: internal/decisions/ADR-NNNN-shared-link-counts-as-scan-clean-share-url.md (assign NNNN from the live internal/decisions/INDEX.md at 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 (NEVER main), merge, fast-forward local dev, reap the worktree.