Public Scan Landing Page — Design Spec
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:
- 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). - 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_levelnone→verification→full — seedocs/database/hd-wallet-schema.mdanddocs/security/). That cryptographic check is prepared, not fully built here (see §2.5); for now the page handles areason=unverifiedcase.
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!): PublicClassthat returnsnullfor now (the class system does not exist yet). The/welcomepage consumes it and renders the class-aware branch only when it returns data. Mark with aTODO(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=unverifiedscan-error case. Mark with aTODO(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
nullfor a missing/deleted object → page shows a friendly “hittades inte”. - The page resolves
:oidfrom 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 datainresolveIdentifier, 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_eventinPlings-API/app/scan_resolvers_fixed.pybuilds 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
resolveIdentifierinput fieldscanMetadata.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:
resolveIdentifiercreates the:ScanEvent, returns its real id.- The Gateway forwards that id as
sid=<scanEventId>on the redirect URL. - 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.lastSeenreturns 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 thepublicClassstub), and/scan-error(invalid/unverifiable, with genuineness messaging); public Apollo queries (publicObject,publicClassstub); geolocation consent +recordScanLocationcall; “open in app” deep-link with store fallback; all using existing Tailwind/shadcn tokens. - Plings-API — central scan-logging persistence (
:ScanEvent+SCANNEDin Neo4j, real id);publicObject(id)query;publicClass(classPointer)stub query;recordScanLocationmutation;contactOwner/reportFoundmutation; a stubbed genuineness-verification hook in the resolve path;resolveIdentifierreadsscanMetadata.geoLocation(native) and populatesObjectContext. - Plings-Gateway — forward
sidon 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.geoLocationat scan time via its directresolveIdentifiercall, following the same coarsening contract.
8. Error handling
- Invalid parameters → existing Gateway
scan-errorredirect (fixed detail tokens) → the designed/scan-errorpage (§2.4). - Unverifiable / not-genuine code →
scan-error?reason=unverified→ genuineness-explainer page (genuineness check itself stubbed for now, §2.5). - Object missing/deleted →
publicObjectreturnsnull→ friendly “hittades inte” page. - Unknown tag with a resolvable class pointer → class-aware
/welcomebranch (behind thepublicClassstub); 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),sidforwarded. - API — resolver tests:
publicObject(curated fields, null on missing),recordScanLocation(enriches event, coarsening rule), real:ScanEventpersistence (against Neo4j), native geo viaresolveIdentifier. - 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) + thepublicClassand genuineness-verification stubs (§2.5). Gateway routing convergence +sidforwarding. The location block is rendered only whenlastSeenis 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 3 —
contactOwner/reportFoundflow.
11. Out of scope
- Native iOS implementation (separate work; contract defined here).
- The class system itself —
publicClassis a returns-nullstub; the class-aware/welcomebranch 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-errorpage 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/:oidfor v1).
12. Documentation to update (ships with the code)
docs/core-systems/s-plings-io/url-structure.md— routing table (status convergence), newsidredirect parameter,reason=unverifiedscan-error case,cp/cptrpass-through to/welcome.docs/api/—publicObject,publicClass(stub),recordScanLocation,contactOwnercontracts; the genuineness-verification hook.docs/database/—:ScanEventpersistence now implemented; coarsening/privacy notes.docs/frontend/— the/o/:oid,/welcome(incl. class-aware branch), and/scan-errorpages (frontend/routing.mdroute inventory).docs/database/hd-wallet-schema.md/docs/security/— cross-reference from the genuineness messaging.