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.
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.
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.
| Area | Status | Where it lives today |
|---|---|---|
| Gift / share flow (backend) | Partial | POST /slices/share → IssuedslicesService.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. |
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.jsonarrays, 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-svgis 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 viaInvite) 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/decodeIdis 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/:tokenroute — today's closest analog,/slice-details?id=, is query-param based and tied to the oldencodeIdscheme. - Fix or replace the dead
Share.share()code ingift-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).
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.
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.
3.3Non-functional requirements
| Concern | Target | Notes |
|---|---|---|
| Tap-to-render latency | < 2s to first paint of the Slice preview | On a bump flow, latency reads as "broken," not "slow" — budget the web renderer like a product surface, not a fallback. |
| Token confidentiality | Unguessable, single-purpose | See §5 — opaque token, not a self-describing JWT, so a captured URL reveals nothing about the Slice. |
| Offline / poor connectivity | Graceful degrade, not a blank screen | Web renderer ships a lightweight skeleton + retry; App Clip caches its own last-fetched state for the session. |
| Attribution | Channel-level, not just install-level | Tag 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 defaults | HCE and background NFC reads are OS-scheduled, not app-polled — no custom background service needed on either side. |
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 → Receiver | Channel | Receiver outcome (no app) |
|---|---|---|
| Android → Android | HCE → NDEF Type-4 tag | System dispatch → Chrome → PWA install offer |
| Android → iOS | HCE → NDEF Type-4 tag | Background tag read → notification → App Clip if registered, else Safari |
| iOS → iOS | Share sheet → AirDrop | App Clip if registered, else Safari. QR if AirDrop unavailable. |
| iOS → Android | Share sheet → AirDrop↔Quick Share partial coverage | Chrome → 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.
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.
| Field | Type | Notes |
|---|---|---|
token | 12-char base62 | Primary key. Random, not sequential — no slice_id-derivable structure. |
slice_id | ref | The Slice being shared. |
issuer_user_id | ref | Sender — for revocation and attribution. |
channel | enum | nfc · airdrop · qr — set at mint time, used for attribution only. |
state | enum | active · redeemed · expired · revoked |
expires_at | timestamp | Short TTL — see §10. Independent of the Slice's own validity window. |
view_count | int | Incremented on every GET /s/:token. Viewing is idempotent; redeeming is not. |
redeemed_at / redeemed_by | timestamp / ref | Set exactly once, under a row lock — see §10. |
Backend API
| Endpoint | Purpose |
|---|---|
POST /slices/:id/share-links | Mint a token for a given channel. Caller: Skypie app, authenticated. New — sits beside the existing POST /slices/share, doesn't replace it. |
GET /s/:token | Resolve — public, unauthenticated. New route; can reuse fetchPublicSliceDetails()'s response shape once it's keyed by token instead of encoded id. |
POST /s/:token/redeem | Accept the Slice. Requires the receiver to authenticate (or create an account) at this step, not before. |
GET /.well-known/apple-app-site-association | Already exists — add /s/* to applinks and a new appclips key. |
GET /.well-known/assetlinks.json | Already 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. |
{
"channel": "nfc",
"token": "b7Tq2mXk9pLr",
"url": "https://skypie.app/s/b7Tq2mXk9pLr",
"expires_at": "2026-09-04T18:47:00Z"
}
{
"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
}
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.
{
"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 met —
SkyPie.xcodeprojships a liveapplication-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-appmeta tag onslice-detailsalready 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.
Android Implementation
8.1HCE service — emulate a Type-4 NDEF tag
<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>
<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
[{
"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.
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.
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.
| Platform | Renderer serves |
|---|---|
| iOS | Already 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. |
| Android | Already 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. |
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/:tokenis idempotent and safe to hit repeatedly (preview, retries, App Clip re-launch).POST /s/:token/redeemmust 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
encodeIdscheme in §5. - Rate limiting. Per-token and per-IP limits on
/redeemto blunt brute-force guessing of the 12-char token space.@nestjs/throttleris 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.
Rollout plan
Foundation
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.
Android sender
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.
iOS App Clip
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.
iOS sender + PWA polish
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.
Enhancements
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.
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.