Skypie · Slices

Tap-to-Share Blueprint

HLD and LLD for handing a Slice to someone with a physical tap, when the receiver has never installed the app. Grounded in the actual backend, frontend and skypie-native-app repos — not a clean-slate design.

StatusDraft for review
ScopeiOS 17+, Android 10+
ConstraintReceiver: zero install
Updated4 Sep 2026
01

Overview & the constraint that shapes everything

A Slice hand-off has to survive the case where the receiver has never heard of Skypie. That single constraint is why this can't be an app-to-app protocol, and why every design decision below routes through one idea: the tap doesn't move the Slice, it moves a URL that points at the Slice. Everything downstream — which radio carries the tap, which runtime renders the result — is about making that URL resolve into the best experience each OS allows without an install.

Three things changed the shape of the "best experience" answer since the last research pass: Android's own no-install runtime (Instant Apps) was discontinued in December 2025 with nothing replacing it; Apple's App Clips are unaffected and, as of 2026, are the richer of the two no-install tiers; and Google shipped AirDrop↔Quick Share interoperability, upgrading the iPhone-sender fallback on supported Android phones. This blueprint designs around that current state — and, per §2, around what's actually already built.

02

Current state vs. required state

This isn't a clean-slate build. Skypie already has a gift/share flow, fully verified Universal/App Links, and a public unauthenticated Slice page — audited directly against backend, frontend and skypie-native-app on 4 Sep 2026. What's missing is the physical tap channel itself and a token model secure enough to broadcast over the air.

Exists, reusable as-is Exists, needs rework Missing — net-new build
AreaStatusWhere it lives today
Gift / share flow (backend) Partial POST /slices/shareIssuedslicesService.shareSlice() — recipient found by email/phone, works without a recipient account. But the "link" is encodeId(sliceId): reversible base64, not signed, not expiring, not single-use.
Gift screen (mobile) Partial gift-page.tsx — email/phone + contact-picker flow works end to end. The Share.share() link-share button is commented out and missing its import — dead code, not shipped.
Universal Links / App Links Exists app.config.js associated domains, AASA + assetlinks.json under frontend/public/.well-known/, verified for /slice-details and /login on both skypie.com and testenv.skypie.com.
NFC (read or emit) Missing Zero packages, permissions, or entitlements in any of the three repos. Confirmed by full-repo search.
iOS App Clip Missing SkyPie.xcodeproj has one application-type target, no App Clip target. The parent app itself already ships — so the App Clip prerequisite from §12 is actually already satisfied.
Public web Slice page Exists frontend/src/pages/slice-details/index.page.tsx — SSR, unauthenticated, OG tags, app-open-with-store-fallback JS (deepLink.ts). This already is Tier 0 of the zero-install ladder in §9.
PWA installability Partial next-pwa + manifest.json exist, but scope/start_url point at /user/sign-in — "Add to Home Screen" from a shared tap installs the wrong entry point.
QR code rendering Exists react-native-qrcode-svg already a dependency, used today for POS/staff coupon redemption (QRModal.tsx) — same library, new use case for the tap fallback.
TODAY Gift screen email · phone · contacts encodeId link reversible, not signed slice-details page public, unauthenticated Manual "Open App" store-fallback JS THIS BLUEPRINT Share by Bump NEW trigger UI Signed token service NEW — opaque · TTL · revoke NFC · AirDrop · QR NEW physical channel slice-details reused as-is App Clip NEW
Two of four steps in the required chain are net-new (token service, physical channel); the other two — the public Slice page and, on iOS, half the resolution path — are either fully reused or extend something already shipped.

2.1What carries over

  • Universal/App Links are already verified end to end for skypie.com — adding a /s/* path is a one-line addition to the existing AASA/assetlinks.json arrays, not new infrastructure.
  • The public Slice page is already Tier 0 of the zero-install ladder (§9) — SSR'd, unauthenticated, with OG tags and app-open/store-fallback JS already written in deepLink.ts. The web-renderer work in this blueprint is extension, not a new page.
  • The App Clip parent-app prerequisite is already satisfied — the main SkyPie app ships. Adding an App Clip target is scoped work inside the existing Xcode project, not a sequencing blocker.
  • react-native-qrcode-svg is already a dependency — the QR fallback screen reuses a library the app already ships, just pointed at a share token instead of a coupon ID.
  • The shareSlice() recipient-lookup logic (email/phone, non-user handling via Invite) is a reasonable pattern to keep for bookkeeping "who was this Slice shared with" — only the link/token itself needs replacing.

2.2What has to be built

  • NFC, entirely from zero — no packages, permissions, or entitlements exist anywhere in the three repos today. This is the single largest net-new surface (§8).
  • A real share-token service (§5) — encodeId/decodeId is reversible base64, not a signature; it has no TTL and no single-use enforcement. Fine for today's low-sensitivity public fields (title/description/image only), but not fit to be broadcast over NFC/QR to a stranger, which this feature does by design.
  • A /s/:token route — today's closest analog, /slice-details?id=, is query-param based and tied to the old encodeId scheme.
  • Fix or replace the dead Share.share() code in gift-page.tsx — the iOS-sender share-sheet path (§7.3) is written but broken (missing import, commented out), not absent, so this is a repair, not a build.
  • A PWA manifest/scope fix for the share landing page — today's manifest sends "Add to Home Screen" into /user/sign-in, not back to the Slice.
  • The App Clip target itself — Xcode target, entitlements, and Advanced Experience registration in App Store Connect (§7.2).
03

System architecture (HLD)

3.1Components

One backend contract, three physical channels. The backend never needs to know which channel carried the tap — it only ever sees an HTTPS request for a token.

Skypie Backend Token · Redeem · Render /.well-known/aasa · assetlinks Sender phone has the app taps “Share by Bump” Receiver phone no app installed resolves the tapped URL mint signed link POST /slices/:id/share-links resolve token GET /s/:token NFC tap — HCE broadcasts a Type-4 NDEF tag Share sheet — AirDrop / Quick Share QR — always-on universal fallback
The backend issues and resolves one kind of object — a signed link — regardless of which of the three channels physically carried it. Channel choice is a client-side decision made at share time, driven by sender platform and receiver proximity, not a backend concern.

3.2Receiver resolution logic

The interesting part of "zero install" is what the OS does with the URL before anything hits our servers. This is the decision every receiving device makes, and it's the reason the iOS and Android implementation sections diverge so much below.

OS resolves tapped URL App already installed? yes Open native app no platform? iOS Advanced App Clip experience registered for this URL? yes Launch App Clip no Open Safari → web renderer Android Installed as a PWA already? yes Open installed PWA no Open Chrome → offers Add to Home Screen
Only the highlighted outcome — App Clip — requires anything beyond a correctly configured Universal/App Link. It's the single highest-leverage build item because it's the only branch that upgrades a stranger's first touch from "web page" to "native card."

3.3Non-functional requirements

ConcernTargetNotes
Tap-to-render latency< 2s to first paint of the Slice previewOn a bump flow, latency reads as "broken," not "slow" — budget the web renderer like a product surface, not a fallback.
Token confidentialityUnguessable, single-purposeSee §5 — opaque token, not a self-describing JWT, so a captured URL reveals nothing about the Slice.
Offline / poor connectivityGraceful degrade, not a blank screenWeb renderer ships a lightweight skeleton + retry; App Clip caches its own last-fetched state for the session.
AttributionChannel-level, not just install-levelTag the minted link with the channel it was minted for (nfc / airdrop / qr) so conversion can be split by physical channel, independent of App Clip's own analytics.
Battery / background cost~0 beyond OS defaultsHCE and background NFC reads are OS-scheduled, not app-polled — no custom background service needed on either side.
04

Channel matrix

All four sender/receiver combinations resolve through the same logic in §3.2 — what differs is which physical channel gets the URL from one phone to the other.

Sender → ReceiverChannelReceiver outcome (no app)
AndroidAndroidHCE → NDEF Type-4 tagSystem dispatch → Chrome → PWA install offer
AndroidiOSHCE → NDEF Type-4 tagBackground tag read → notification → App Clip if registered, else Safari
iOSiOSShare sheet → AirDropApp Clip if registered, else Safari. QR if AirDrop unavailable.
iOSAndroidShare sheet → AirDrop↔Quick Share partial coverageChrome → PWA install offer, on enrolled devices only. QR everywhere else.

4.1Detail: Android sender → iPhone receiver

This is the flow doing the most work in the whole design — an Android phone reaching an iPhone with zero cooperation from Apple. Worth tracing end to end.

Backend Android (sender) iPhone (receiver) iOS dispatcher 1. POST /share-links 2. 201 {url, exp:5m} 3. encode NDEF, start HostApduService (AID D276…0101) 4. NFC tap — RF, not network SELECT AID → Capability Container → read NDEF (URL) 5. background tag read → notification 6. check AASA: applinks vs appclips alt [appclips match] 7a. GET /s/:token (App Clip runtime) [no match] 7b. GET /s/:token (Safari) 8. 200 — Slice preview, either runtime
Steps 1–3 happen before any physical contact. Step 4 is the only RF hop — Android emulating a passive Type-4 tag is indistinguishable, over the air, from a real one, which is why iOS reads it without any cooperation from Apple. Steps 6–7 are what App Clip configuration in §7 controls.
05

Token & data model (LLD)

Decision: opaque server-side token, not a self-contained JWT — and not the existing encodeId scheme. encodeId is reversible base64 around a Mongo ObjectId: readable, not signed, no TTL. A JWT would fix the signing but bloats the NFC/QR payload and can't be revoked without a blocklist. An opaque random token with all state server-side gives revocation, single-use enforcement, and the shortest possible payload.

FieldTypeNotes
token12-char base62Primary key. Random, not sequential — no slice_id-derivable structure.
slice_idrefThe Slice being shared.
issuer_user_idrefSender — for revocation and attribution.
channelenumnfc · airdrop · qr — set at mint time, used for attribution only.
stateenumactive · redeemed · expired · revoked
expires_attimestampShort TTL — see §10. Independent of the Slice's own validity window.
view_countintIncremented on every GET /s/:token. Viewing is idempotent; redeeming is not.
redeemed_at / redeemed_bytimestamp / refSet exactly once, under a row lock — see §10.
Minted ttl starts Active GET /s/:token (repeatable) first POST /redeem Redeemed ttl elapsed Expired sender cancels Revoked
Viewing (rendering the Slice preview) and redeeming (accepting it) are deliberately different transitions — a receiver should be able to look before they accept, but the acceptance itself has to be exactly-once.
06

Backend API

EndpointPurpose
POST /slices/:id/share-linksMint a token for a given channel. Caller: Skypie app, authenticated. New — sits beside the existing POST /slices/share, doesn't replace it.
GET /s/:tokenResolve — public, unauthenticated. New route; can reuse fetchPublicSliceDetails()'s response shape once it's keyed by token instead of encoded id.
POST /s/:token/redeemAccept the Slice. Requires the receiver to authenticate (or create an account) at this step, not before.
GET /.well-known/apple-app-site-associationAlready exists — add /s/* to applinks and a new appclips key.
GET /.well-known/assetlinks.jsonAlready exists and correct — no change needed for the web path; Android App Link coverage for /s/* falls out of the existing wildcard-free path list once added.
POST /slices/svc_9f2e/share-links → 201
{
  "channel": "nfc",
  "token": "b7Tq2mXk9pLr",
  "url": "https://skypie.app/s/b7Tq2mXk9pLr",
  "expires_at": "2026-09-04T18:47:00Z"
}
GET /s/b7Tq2mXk9pLr → 200
{
  "state": "active",
  "slice": { "id": "svc_9f2e", "title": "Table for 2 — Lilia, Fri 8:30pm", "kind": "reservation" },
  "issuer": { "display_name": "Ankit" },
  "view_count": 1,
  "redeemable": true
}
07

iOS Implementation

7.1Associated domain — one AASA, two keys

The App Clip rides the same associated domain already verified in app.config.js and frontend/public/.well-known/apple-app-site-association — no new domain, no new hosting setup, just two additions to a file that already exists.

frontend/public/.well-known/apple-app-site-association
{
  "applinks": {
    "details": [{
      "appID": "Y4524DMM84.skypie",
      "components": [
        { "/": "/login/*" },
        { "/": "/slice-details/*" },
        { "/": "/slice-list" },
        { "/": "/s/*", "comment": "NEW — Slice share links" }
      ]
    }]
  },
  "appclips": {
    "apps": ["Y4524DMM84.skypie.Clip"]
  }
}

7.2App Clip target

  • The parent-app prerequisite is already metSkyPie.xcodeproj ships a live application-type target today. This is a new target added to that same project, not a new project.
  • Binary budget: 15MB thinned (iOS 16+), 10MB on older devices still in the fleet. Fetch the Slice content over the network on launch — ship a viewer, not a bundle.
  • Invocation types wired, in priority order: NFC tag (background, no unlock required), App Clip Code (QR-compatible artifact that also encodes NFC), Safari Smart App Banner (the existing apple-itunes-app meta tag on slice-details already proves this path is live — the App Clip just needs to register the same URL as an Advanced Experience), Messages link preview, Maps for venue-tied Slices.
  • Each Advanced App Clip Experience is registered in App Store Connect against a specific URL pattern (/s/*) — this is account configuration, not code.
  • Ephemeral by design — no durable local state. Authenticate off the token server-side; offer Sign in with Apple as the upgrade path so identity carries into the full app if they install it.

Entitlement clarification: com.apple.developer.nfc.readersession.formats is only needed if the app itself runs a foreground NFCTagReaderSession. The passive background URL detection this design relies on (§4.1, step 5) is a system-level OS behavior — it needs no app, no entitlement, no code on the receiving side.

7.3iOS as sender

Third-party apps can't emit NFC (unchanged platform restriction) — the send path is the system share sheet: UIActivityViewController with the URL, triggered off the bump-detection gesture. This is written but disabled in gift-page.tsx today (dead Share.share() call, missing import) — repairing and repointing it at the new token URL is most of this work. AirDrop reaches iPhones directly and, on enrolled Android phones, Quick Share; everything else needs the QR fallback screen.

08

Android Implementation

8.1HCE service — emulate a Type-4 NDEF tag

AndroidManifest.xml — new addition, no NFC entries exist today
<service
    android:name=".nfc.SliceHceService"
    android:exported="true"
    android:permission="android.permission.BIND_NFC_SERVICE">
    <intent-filter>
        <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE"/>
    </intent-filter>
    <meta-data
        android:name="android.nfc.cardemulation.host_apdu_service"
        android:resource="@xml/apduservice"/>
</service>
res/xml/apduservice.xml
<host-apdu-service
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/slice_tap"
    android:requireDeviceUnlock="false">
    <aid-group android:category="other">
        <!-- NFC Forum NDEF Application ID -->
        <aid-filter android:name="D2760000850101"/>
    </aid-group>
</host-apdu-service>

The service responds to SELECT / READ BINARY APDUs with a Capability Container and an NDEF file containing a single URI record (the minted skypie.app/s/:token link) — the exact exchange any Type-4 tag reader, including iPhone's background reader, expects.

8.2App Links

frontend/public/.well-known/assetlinks.json — already correct, extend the path list in app.config.js intentFilters
[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.infra.SkyPie",
    "sha256_cert_fingerprints": [""]
  }
}]

This file is already deployed and verified for /slice-details and /login. Adding /s/* is an intentFilters entry in app.config.js, not a new asset-links deployment.

8.3PWA — the actual "zero install" ceiling on Android

Since Instant Apps is gone, this is the whole Android answer. next-pwa and manifest.json already exist — they just need to stop pointing "Add to Home Screen" at /user/sign-in. Either widen the existing manifest's scope or ship a second manifest scoped to /s/ for the share landing page specifically.

/s/[token] — Web NFC receiving-station mode (optional, Chrome/Android only)
if ('NDEFReader' in window) {
  const reader = new NDEFReader();
  await reader.scan();
  reader.onreading = (event) => {
    const record = event.message.records.find(r => r.recordType === 'url');
    const url = new TextDecoder().decode(record.data);
    window.location.href = url; // hand off to the resolver
  };
}

Useful for a fixed device — a host stand or box-office tablet with this page pinned open — not for the cold-tap case, since the tab has to already be foreground.

8.4AID routing conflicts

If another installed app (transit card, a payment wallet) also registers against D2760000850101, Android's NFC service can surface an app picker instead of dispatching silently. Test on a stock, non-rooted phone with common finance/transit apps installed, not just a clean emulator — and have the UI auto-offer the QR fallback if the HCE session doesn't complete within a few seconds.

09

Web renderer

Reached whenever §3.2 falls through to a browser — the majority of Android traffic and the iOS traffic without a matching App Clip experience. frontend/src/pages/slice-details/index.page.tsx already does this job for the existing gift flow; extending it to read the new token format is the bulk of the work here, not building a new page.

PlatformRenderer serves
iOSAlready ships: Smart App Banner meta tag (apple-itunes-app) pointing at the App Store listing; Slice preview renders regardless of whether the banner is dismissed.
AndroidAlready ships next-pwa + manifest — needs the scope/start_url fix from §8.3 so "Add to Home Screen" lands back on the Slice, not the sign-in screen.
10

Security & abuse

  • TTL matches the UI session, not the Slice. A bump-minted token should expire in minutes (proposed: 5), independent of how long the underlying Slice itself is valid — a passerby scanning a phone left mid-flow shouldn't get a live link.
  • View vs. redeem. GET /s/:token is idempotent and safe to hit repeatedly (preview, retries, App Clip re-launch). POST /s/:token/redeem must take a row lock or use a conditional update (WHERE state = 'active') so two near-simultaneous redemption attempts can't both succeed.
  • Revocation. Sender can cancel an active token before redemption — this is why an opaque server-side token was chosen over both a JWT and the existing encodeId scheme in §5.
  • Rate limiting. Per-token and per-IP limits on /redeem to blunt brute-force guessing of the 12-char token space. @nestjs/throttler is already wired app-wide — this is a route-level policy, not new infrastructure.
  • NFC-specific. Mint a fresh token per "Share by Bump" tap rather than reusing a static NDEF payload — the HCE session is already ephemeral (ends when the sender leaves the screen), so token TTL should track that, not outlive it.
11

Rollout plan

00

Foundation

Both platforms

Signed links (§5), the /s/:token route, and extending the existing slice-details web renderer + AASA/assetlinks paths. Smaller than a from-scratch build since Universal/App Links and the public page already exist.

01

Android sender

Android

HCE broadcasting the NDEF Type-4 tag. Covers Android↔Android and Android→iPhone (landing in Safari until Phase 2).

Risk: AID routing conflicts on phones with wallet/transit apps — needs device-lab testing, not just emulator.

02

iOS App Clip

iOS

Upgrades Android→iPhone from Safari to a native card, and unlocks the same upgrade for AirDrop-delivered links. Highest-leverage phase for perceived quality — and unblocked, since the parent app already ships.

Risk: App Review timeline for the Clip is a separate submission from the main app's.

03

iOS sender + PWA polish

iOSAndroid

Repair the dead Share.share() call in gift-page.tsx so bump-detection can trigger the share sheet, with QR as the always-available fallback screen; fix the Android PWA manifest scope so install lands back on the Slice.

04

Enhancements

Optional

Web NFC "always listening" receiving station for fixed devices (host stand, box office); Nearby Connections / MultipeerConnectivity once both sides already have the app, for transfers richer than a URL.

12

Open risks & questions

Parent app dependency — resolved. App Clips require a real, App Store-listed parent app; the main SkyPie app already ships (SkyPie.xcodeproj, live App Store listing referenced in slice-details's smart-app-banner meta tag). Phase 2 is unblocked on this front.

AirDrop↔Quick Share coverage. Rolling out phone-by-phone through 2026 (Pixel 10 now, Pixel 9 and Samsung models added since). Re-check actual device coverage close to ship rather than designing against today's snapshot.

Token TTL policy. Five minutes is a proposal, not a decision — needs product input on how "live" a bump hand-off should feel.

Two share systems in parallel. POST /slices/share (email/phone gifting) and the new /s/:token bump path will coexist — decide whether encodeId links get migrated to the new token scheme too, or whether the two are allowed to diverge long-term.