Public Scan Landing Page — Design Spec

Created: Sun 14 Jun 2026 08:41:41 CEST Updated: Sun 14 Jun 2026 09:02 CEST - Added invalid/unverifiable-tag (genuineness) page and class-pointer-aware onboarding; both prepared via clearly-marked stubs Document Version: 1.1 - Genuineness + class-pointer preparation Security Classification: Internal Technical Documentation Target Audience: Frontend Developers, Backend Developers, iOS Developers Author: Paul Wisén


1. Overview

When a physical object carrying a Plings identifier is scanned without the native app (ordinary phone camera, shared link, e-mail), the Gateway (s.plings.io) 302-redirects the browser to a public landing page on plings.io. This document specifies that page, the data it consumes, and the scan-logging + geolocation contract that backs it.

Scope of this delivery: the browser/camera path (scenario 2). The native iOS app (scenario 1) is a separate consumer of the same backend contract — it is not built here, but the scan-logging and geolocation contract is defined so the app can follow it.

Guiding principles

  • One GraphQL interface. The page reads a curated, public slice of object data via a new unauthenticated query. Sensitive logic stays behind the GraphQL layer (per CLAUDE.md).
  • The Gateway stays a redirector. No HTML rendering moves into the Gateway.
  • The native app and the browser share one scan-logging contract, differing only in when the position is supplied.
  • Follow the existing Plings-Web design system (Tailwind + shadcn/ui tokens in src/index.css). No new visual language.

Two scan paths (context)

Path Reaches the API via GPS timing
1. Native iOS app Calls GraphQL directly (bypasses Gateway), authenticated with a user JWT App has GPS at scan time
2. Camera → public page (this delivery) Via Gateway 302-redirect No GPS server-side; requested in the browser after redirect

2. Pages and states

Destinations and states: known object (/o/:oid), unknown-but-valid tag (/welcome, optionally enriched by a class pointer), and invalid/unverifiable tag (/scan-error).

2.1 Known object — plings.io/o/:oid

A single, status-aware public page. Common content, ordered top→bottom: image + status badge → name → class → (optional status notice) → coarse location block (coarse map + timestamp) → action buttons → “Ägs av …” (owner organization + ✓ verified) → footer “Identifierad av Plings”.

Status-specific behaviour:

Status Notice Primary CTA Secondary
Active (normal) Öppna i Plings-appen Ladda ner appen · text-link: Anmäl upphittat / kontakta ägaren
Lost Red “Markerad som borttappad…” Jag har hittat den — kontakta ägaren Öppna i appen
For sale / rent / lend “Affären sker i Plings-appen” Öppna i Plings-appen Ladda ner appen · Kontakta ägaren
  • “Anmäl upphittat / kontakta ägaren” is available on every object, not only Lost. On Lost it is the prominent primary action; otherwise a secondary/text action.
  • Commerce statuses (sale/rent/lend) render on the same page for v1 — the action is “open in app” (the transaction lives in the app; on-chain commerce is future, out of scope).

2.2 Unknown (but valid) tag — plings.io/welcome

The tag is format-valid but no object exists yet (a Plings-marked product not yet created). Not an error — an onboarding page: Plings logo, “Den här saken är Plings-märkt”, a short explanation of Plings, a 3-step list (download → re-scan in app → create the object), primary “Ladda ner appen & skapa objektet”, secondary “Har du redan appen? Öppna den”, and the tag’s i/p carried through so the app can claim that specific tag.

Class-pointer enrichment (prepared, not fully built — see §2.5). The scan URL may carry a class pointer cp (forwarded by the Gateway as cptr). If cp resolves to a known class, the unknown-tag page becomes class-aware: instead of a generic “we don’t know this yet”, it says “Vi vet vad det här är” and shows general class information (e.g. “Mountainbike · Trek Marlin 7”

  • class image/description), with a richer CTA — “Skapa objektet med alla dess egenskaper i appen” — because the app can pre-fill the object from the class blueprint. This matches Example 3 (“manufacturer pre-printed tag → claim flow”) in url-structure.md.

The class system is not built yet, so this branch is prepared (stubbed) now and completed later. When cp is absent or does not resolve, the page falls back to the generic onboarding above.

2.3 “Open in app” behaviour

Attempt a deep link into the native app (custom scheme / universal link carrying i/p/oid); if no app handles it within a short timeout, fall back to the App Store / Play Store. v1 may ship a simple store-link fallback and refine the universal-link handshake when the app exists.

2.4 Invalid / unverifiable tag — plings.io/scan-error

A scanned code can fail in two ways:

  1. Format-invalid — fails the Gateway firewall’s parameter validation. The Gateway already 302-redirects to scan-error?reason=invalid&details=<token> with a fixed token (bad_instance_key, bad_path, …). This delivery designs that page (it has no real route in Plings-Web today).
  2. Unverifiable / not genuine — well-formed but the code cannot be verified as authentic (it was not generated and placed by a source that knows the object’s genuineness). True genuineness verification belongs to the HD-wallet / manufacturer-verification layer (Ed25519 manufacturer signatures, key_delegation_level none→verification→full — see docs/database/hd-wallet-schema.md and docs/security/). That cryptographic check is prepared, not fully built here (see §2.5); for now the page handles a reason=unverified case.

The page explains, in plain language, why this matters: Plings exists to guarantee the genuineness of physical objects, and that guarantee depends on the identifier having been generated and placed by a trusted source that knows the object’s authenticity. A code that cannot be verified should be treated with suspicion. The page links to a (future, not-yet-existing) authenticity explainer page on plings.io; the underlying concepts are already documented extensively in docs/database/hd-wallet-schema.md and docs/security/. Tone: explanatory and reassuring, not alarming — most failures are damaged/mistyped codes, not forgeries.

2.5 Stubs to build now (prepare, don’t finish)

To make future sessions easy to pick up, this delivery builds clearly-marked stubs for the two not-yet-built systems above, rather than leaving them undesigned:

  • Class lookup — a public query stub publicClass(classPointer: String!): PublicClass that returns null for now (the class system does not exist yet). The /welcome page consumes it and renders the class-aware branch only when it returns data. Mark with a TODO(class-system).
  • Genuineness verification — a backend hook (stub function) in the resolve path where the manufacturer/Ed25519 verification will live, returning “unknown/unverified” for now and feeding the reason=unverified scan-error case. Mark with a TODO(genuineness-verification) and link the HD-wallet docs.

Stubs must be obvious (named, commented, typed) so a later session can fill them in without re-deriving the design.


3. Data architecture

Approach A (selected): status-aware page + a new public GraphQL query.

3.1 New public query — publicObject(id: ID!)

Unauthenticated. Returns a curated, safe subset only — never the full ObjectInstance (which exposes spatial children, properties, etc. and is auth-gated). Proposed shape:

type PublicObject {
  id: ID!
  name: String!
  mainImageUrl: String
  status: String!                 # primary status used for the status-aware view
  statuses: [String!]!
  objectClass: PublicObjectClass  # name, category
  owner: PublicOwner              # organization name + verified flag (no PII)
  lastSeen: PublicLastSeen        # COARSE only — see §4
}

type PublicLastSeen {
  area: String        # human label, e.g. "Göteborg"
  coarseLatitude: Float
  coarseLongitude: Float
  at: String          # ISO timestamp of the scan
}

extend type Query {
  publicObject(id: ID!): PublicObject
}
  • Returns null for a missing/deleted object → page shows a friendly “hittades inte”.
  • The page resolves :oid from the URL and queries this directly — no auth, no Gateway key in the browser.
  • This also lets us finally populate the existing ObjectContext / fix the # TODO: Fetch actual object data in resolveIdentifier, reusing the same fetch path.

Rejected alternatives: B (push all fields through redirect URL params — ugly, no images, data in logs/history, can’t power a contact flow) and C (reuse authenticated object(id) and require login — defeats the purpose for strangers/finders and Lost & Found).


4. Scan logging & geolocation — shared contract for both paths

Current state (verified): scan logging does not persist today. log_scan_event in Plings-API/app/scan_resolvers_fixed.py builds a dict, writes one line to the API application logger (logging_config.logger → stdout/Vercel logs) and returns a mock id (scan_<timestamp>). The comment says # TODO: Insert into actual scan_events table. No INSERT exists in Neo4j or Postgres. Geolocation fields exist in the dict shape but are persisted nowhere. This delivery implements real persistence.

4.1 Persistence

Implement a single backend scan-logging function that persists a :ScanEvent node and the (:ScanEvent)-[:SCANNED]->(:PlingsIdentifier) relationship in Neo4j, per the schema in docs/database/neo4j-core-schema.md: timestamp, latitude, longitude, scanning_user, owner_at_time_of_scan (+ accuracy, transport, hashed IP). It returns a real scan-event id. Both scan paths use this one function.

4.2 How position is supplied per path

The position is stored and coarsened identically regardless of path; only when it is supplied differs:

  • Native (scenario 1): the app sends GPS at scan time in the already-existing resolveIdentifier input field scanMetadata.geoLocation { latitude, longitude, accuracy, consent } (Plings-API/app/graphql.py). One call, position included.
  • Browser (scenario 2): the Gateway cannot read GPS (server-side 302). So:
    1. resolveIdentifier creates the :ScanEvent, returns its real id.
    2. The Gateway forwards that id as sid=<scanEventId> on the redirect URL.
    3. The landing page requests geolocation consent (browser prompt); on grant it calls a new public, rate-limited mutation recordScanLocation(scanId: ID!, latitude: Float!, longitude: Float!, accuracy: Float) that enriches the same :ScanEvent. On deny/unavailable, the event simply has no position.

scanning_user is set for native (authenticated); null/anonymous for the browser path.

4.3 Precision: exact private, coarse public

  • Store the most precise position the device reports, un-rounded (typical phone GPS accuracy is ~5–50 m; we store the reported value plus its accuracy, we do not fabricate precision).
  • Expose only a coarsened position publicly. publicObject.lastSeen returns rounded coordinates (coarse map / “senast sedd i "). The authenticated owner (in-app) may see the precise position. **Coarsening for public display: round to 2 decimal places (~1.1 km).**

4.4 Privacy / GDPR (must be addressed in implementation)

Storing precise scan positions — including scans by unknown finders — is personal data under GDPR. The implementation must cover: consent (the browser geolocation prompt / the app’s consent flag is the lawful basis), stated purpose, retention period, and access boundaries (owner sees precise, public sees coarse). This is a hard requirement, not optional polish.


5. Contact owner / report found

A public, rate-limited mutation (contactOwner / reportFound) that notifies the owner organization that someone scanned/found the object, without exposing any PII of either party. Available on all objects; primary action on Lost. (Heaviest piece — see phasing.)


6. End-to-end data flow (browser path)

Scan → s.plings.io
  → Gateway firewall (rate-limit → validate → sanitize)
  → resolveIdentifier (creates :ScanEvent, returns real id)
  → 302 to:
       known object  → plings.io/o/:oid?sid=…&src=scan
       unknown tag    → plings.io/welcome?i=…&p=…&src=scan
  → page loads → publicObject(oid)  [unknown: render onboarding]
  → page asks for location → on consent: recordScanLocation(sid, lat, lon, accuracy)
       (exact stored privately, coarse exposed publicly)

7. Components by repository

  • Plings-Web — routes /o/:oid (status-aware), /welcome (unknown-tag onboarding, with the class-aware branch behind the publicClass stub), and /scan-error (invalid/unverifiable, with genuineness messaging); public Apollo queries (publicObject, publicClass stub); geolocation consent + recordScanLocation call; “open in app” deep-link with store fallback; all using existing Tailwind/shadcn tokens.
  • Plings-API — central scan-logging persistence (:ScanEvent + SCANNED in Neo4j, real id); publicObject(id) query; publicClass(classPointer) stub query; recordScanLocation mutation; contactOwner/reportFound mutation; a stubbed genuineness-verification hook in the resolve path; resolveIdentifier reads scanMetadata.geoLocation (native) and populates ObjectContext.
  • Plings-Gateway — forward sid on the redirect; converge routing so all known objects land on /o/:oid (page branches on status), unknown → /welcome.
  • Plings-iOS (consumer, not built here) — sends scanMetadata.geoLocation at scan time via its direct resolveIdentifier call, following the same coarsening contract.

8. Error handling

  • Invalid parameters → existing Gateway scan-error redirect (fixed detail tokens) → the designed /scan-error page (§2.4).
  • Unverifiable / not-genuine code → scan-error?reason=unverified → genuineness-explainer page (genuineness check itself stubbed for now, §2.5).
  • Object missing/deleted → publicObject returns null → friendly “hittades inte” page.
  • Unknown tag with a resolvable class pointer → class-aware /welcome branch (behind the publicClass stub); otherwise generic onboarding.
  • Geolocation denied/unavailable → scan event saved without position; page renders normally.
  • Rate-limited public mutations → standard 429 handling.

9. Testing

  • Gateway — jest handler tests: new routing (all known statuses → /o/:oid), sid forwarded.
  • API — resolver tests: publicObject (curated fields, null on missing), recordScanLocation (enriches event, coarsening rule), real :ScanEvent persistence (against Neo4j), native geo via resolveIdentifier.
  • Web — component tests for the four states + geolocation consent (grant/deny), “open in app” fallback.

10. Phasing

  • Phase 1 — Real scan persistence + publicObject + /o/:oid (Active / Lost / For-sale views) + /welcome + /scan-error (genuineness messaging) + the publicClass and genuineness-verification stubs (§2.5). Gateway routing convergence + sid forwarding. The location block is rendered only when lastSeen is present (hidden otherwise); since geo collection is not yet built, it is typically absent in this phase.
  • Phase 2 — Geo collection (recordScanLocation, exact-private/coarse-public) + GDPR handling. Native contract documented for iOS.
  • Phase 3contactOwner / reportFound flow.

11. Out of scope

  • Native iOS implementation (separate work; contract defined here).
  • The class system itselfpublicClass is a returns-null stub; the class-aware /welcome branch is wired but inactive until the class system exists.
  • Cryptographic genuineness verification — the manufacturer/Ed25519 check is a stubbed hook feeding reason=unverified; full verification is future work in the HD-wallet layer. The /scan-error page and its messaging are in scope.
  • On-chain / Solana commerce flows (future vision per CLAUDE.md).
  • Hiding the owner organization name (possible future feature).
  • Dedicated market/rent/lend subdomains (commerce statuses render on /o/:oid for v1).

12. Documentation to update (ships with the code)

  • docs/core-systems/s-plings-io/url-structure.md — routing table (status convergence), new sid redirect parameter, reason=unverified scan-error case, cp/cptr pass-through to /welcome.
  • docs/api/publicObject, publicClass (stub), recordScanLocation, contactOwner contracts; the genuineness-verification hook.
  • docs/database/:ScanEvent persistence now implemented; coarsening/privacy notes.
  • docs/frontend/ — the /o/:oid, /welcome (incl. class-aware branch), and /scan-error pages (frontend/routing.md route inventory).
  • docs/database/hd-wallet-schema.md / docs/security/ — cross-reference from the genuineness messaging.