s.plings.io Director Service - Implementation Guide
Created: Tue 29 Jul 2025 07:42:15 CEST
Document Version: 1.0 - Initial comprehensive director system specification
Security Classification: Critical Technical Documentation
Target Audience: System Architects, Full-stack Developers, Product Managers, DevOps Engineers
Author: Paul WisΓ©n
STATUS: π PROCESSING - This comprehensive document requires breakdown and integration into existing documentation structure. See analysis below.
Overview
The s.plings.io service is a lightweight, high-performance edge server dedicated to handling QR code and identifier scanning for the Plings ecosystem. It serves as the primary entry point for all physical-to-digital interactions.
Core Purpose
- URL Shortening: Reduces QR code size by 5 characters (critical for small labels)
- Scan Routing: Intelligently routes users based on context and authentication
- Event Logging: Records all scan events for analytics and Lost & Found functionality
- Performance: Sub-100ms response times globally via edge deployment
Architecture Decision
Separate Vercel Project selected for:
- Independent scaling and deployment
- Specialized edge function optimization
- Clean separation of concerns
- Future extensibility for different tag types
Why s.plings.io?
The Director provides four critical functions that would be impossible with direct QR β main app routing:
- Director Function π―
- Routes to different services based on object state
- Enables A/B testing without changing physical QR codes
- Handles service discovery and geographic routing
- Logger Function π
- Centralized scan analytics across all services
- Critical for Lost & Found functionality (GPS tracking)
- Works even if destination service is down
- Firewall Function π‘οΈ
- Rate limiting and DDoS protection
- Validates parameters before they hit main services
- Blocks malicious scans at the edge
- Freedom to Evolve π
- Add new services without reprinting QR codes
- Change routing logic instantly
- Adapt to new technologies
The Permanent QR Code Problem: Physical QR codes cannot be changed. With millions of stickers in circulation, we need the ability to evolve services without reprinting. The Director layer provides this flexibility.
Repository Structure
Repository Name: Plings-Director
URL: https://github.com/[org]/Plings-Director
Documentation: All documentation remains in Plings-Lovable-Frontend/docs/
Code Only: Repository contains only implementation code, no docs
Directory Structure
Plings-Director/
βββ api/
β βββ index.ts # Main route handler (edge function)
βββ lib/
β βββ verification.ts # Identifier verification logic
β βββ routing.ts # Routing decision logic
β βββ logging.ts # Scan event logging
β βββ security.ts # Rate limiting & validation
β βββ registry.ts # Manufacturer key registry
β βββ crypto.ts # Cryptographic utilities
βββ types/
β βββ index.ts # TypeScript type definitions
βββ tests/
β βββ verification.test.ts
β βββ routing.test.ts
β βββ integration.test.ts
βββ .env.example # Example environment variables
βββ .gitignore
βββ package.json
βββ tsconfig.json
βββ vercel.json # Vercel configuration
βββ README.md # Basic setup instructions only
Service Specification
- URL: https://s.plings.io
- Technology: Vercel Edge Functions
- Purpose: QR/NFC entry point with routing, logging, verification, and security
- Response Time Target: <100ms at 95th percentile
- Entry Point Format:
GET https://s.plings.io?t={type}&i={instance}&p={path}&cp={classpointer}
Technical Implementation
Core Architecture Philosophy
The Director is fundamentally a smart router - a collection of conditional logic that will evolve and expand over time. Its primary purpose is to make intelligent routing decisions based on multiple factors, and this logic will become more sophisticated as the Plings ecosystem grows.
Edge Function Structure
// /api/index.ts - Main entry point
import { VercelRequest, VercelResponse } from '@vercel/node';
export const config = {
runtime: 'edge',
regions: ['iad1', 'sfo1', 'sin1', 'syd1', 'hnd1', 'fra1'] // Global deployment
};
export default async function handler(req: VercelRequest) {
const start = Date.now();
try {
// 1. Extract and validate parameters
const params = extractParams(req.url);
if (!validateParams(params)) {
return redirectToError('invalid-params');
}
// 2. Security checks (rate limiting, etc.)
const securityCheck = await performSecurityChecks(req, params);
if (!securityCheck.passed) {
return redirectToError(securityCheck.reason, securityCheck.status);
}
// 3. Verify identifier
const verification = await verifyIdentifier(params);
if (!verification.isValid) {
await logFailedVerification(params, verification.reason);
return redirectToError('invalid-identifier');
}
// 4. Log scan event (async, don't await)
logScanEvent({
...params,
...verification,
timestamp: Date.now(),
headers: extractHeaders(req)
}).catch(console.error);
// 5. Resolve object (if exists)
const resolution = await resolveObject(params.i);
// 6. ROUTING LOGIC - THE HEART OF THE DIRECTOR
const targetUrl = await determineRoute(params, verification, resolution, req);
// 7. Redirect with enriched parameters
return Response.redirect(targetUrl, 302);
} catch (error) {
console.error('Director error:', error);
return redirectToError('system-error');
} finally {
console.log(`Scan handled in ${Date.now() - start}ms`);
}
}
Parameter Handling
interface IncomingParams {
t: 'q' | 'n' | 'r'; // Tag type
i: string; // Instance key (48 chars)
p: string; // HD wallet path
cp?: string; // Class pointer (11 chars)
// Future: tx, svc, price, dur for direct commerce
}
interface OutgoingParams {
oid?: string; // Object UUID (when exists)
ikey: string; // Original instance key
path: string; // Base58 HD path
class?: string; // Class UUID (when resolved)
cptr?: string; // Class pointer
src: 'scan'; // Source tracking
loc?: string; // GPS (if available)
ts?: number; // Timestamp
}
function enrichParameters(
incoming: IncomingParams,
verification: VerificationResult,
resolution: ObjectResolution
): URLSearchParams {
const params = new URLSearchParams({
ikey: incoming.i,
path: incoming.p,
src: 'scan'
});
if (incoming.cp) params.set('cptr', incoming.cp);
if (resolution.objectId) params.set('oid', resolution.objectId);
if (resolution.classId) params.set('class', resolution.classId);
return params;
}
Identifier Verification Implementation
Based on the cached public key strategy outlined in the System Overview, hereβs the detailed implementation:
// /api/scan/verification.ts
import { generateClassPointer } from './crypto';
interface VerificationResult {
isValid: boolean;
identifierType?: 'generic' | 'manufacturer';
manufacturerId?: number;
classId?: string;
reason?: string;
cachedVerification?: boolean;
}
// Manufacturer Registry - Multiple storage strategies
class ManufacturerRegistryImpl {
private cache = new Map<number, string>();
private lastUpdate = 0;
private readonly TTL = 3600000; // 1 hour
async getPublicKey(manufacturerId: number): Promise<string | null> {
// Check cache first
if (this.cache.has(manufacturerId) && Date.now() - this.lastUpdate < this.TTL) {
return this.cache.get(manufacturerId)!;
}
// Strategy 1: Edge Config (Vercel's solution for large configs)
try {
const edgeConfig = await import('@vercel/edge-config');
const publicKey = await edgeConfig.get(`manufacturer_${manufacturerId}_pubkey`);
if (publicKey) {
this.cache.set(manufacturerId, publicKey as string);
return publicKey as string;
}
} catch (e) {
console.log('Edge Config not available');
}
// Strategy 2: KV Store (Vercel KV or Upstash Redis)
try {
const kv = await import('@vercel/kv');
const publicKey = await kv.get(`mfr:pubkey:${manufacturerId}`);
if (publicKey) {
this.cache.set(manufacturerId, publicKey as string);
return publicKey as string;
}
} catch (e) {
console.log('KV store not available');
}
// Strategy 3: GraphQL API (with aggressive caching)
try {
const response = await fetch(process.env.PLINGS_API_ENDPOINT!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'max-age=3600', // 1 hour cache
},
body: JSON.stringify({
query: `
query GetManufacturerPublicKey($manufacturerId: Int!) {
manufacturer(id: $manufacturerId) {
publicKey
}
}
`,
variables: { manufacturerId }
})
});
if (response.ok) {
const { data } = await response.json();
if (data?.manufacturer?.publicKey) {
const publicKey = data.manufacturer.publicKey;
this.cache.set(manufacturerId, publicKey);
this.lastUpdate = Date.now();
return publicKey;
}
}
} catch (e) {
console.error('Failed to fetch public key from API:', e);
}
// Strategy 4: Fallback to essential manufacturers only
const essentialManufacturers: Record<number, string> = {
1: process.env.PLINGS_PUBLIC_KEY!, // Always need Plings key
// Only add top 5-10 manufacturers here
};
return essentialManufacturers[manufacturerId] || null;
}
// Batch load public keys on startup
async preloadEssentialKeys(): Promise<void> {
const essentialIds = [1, 3, 17, 42]; // Top manufacturers
try {
// Try to load from Edge Config
const edgeConfig = await import('@vercel/edge-config');
const keys = await edgeConfig.getAll();
Object.entries(keys).forEach(([key, value]) => {
const match = key.match(/^manufacturer_(\d+)_pubkey$/);
if (match) {
this.cache.set(parseInt(match[1]), value as string);
}
});
this.lastUpdate = Date.now();
} catch (e) {
// Fallback to loading essential keys individually
for (const id of essentialIds) {
await this.getPublicKey(id);
}
}
}
}
export const ManufacturerRegistry = new ManufacturerRegistryImpl();
// Initialize on cold start
ManufacturerRegistry.preloadEssentialKeys().catch(console.error);
export async function verifyIdentifier(
params: IncomingParams
): Promise<VerificationResult> {
const pathParts = params.p.split('.');
const manufacturerId = parseInt(pathParts[1]);
// Step 1: Get manufacturer public key
const manufacturerKey = await ManufacturerRegistry.getPublicKey(manufacturerId);
if (!manufacturerKey) {
return {
isValid: false,
reason: 'Unknown manufacturer',
manufacturerId
};
}
// Step 2: Verify class pointer if provided
if (params.cp) {
const pathToPointer = extractPathToPointer(params.p);
const expectedPointer = generateClassPointer(manufacturerKey, pathToPointer);
if (expectedPointer !== params.cp) {
return {
isValid: false,
reason: 'Invalid class pointer',
manufacturerId
};
}
}
// Step 3: For full instance verification, call API
// This is deferred until after routing decision for performance
return {
isValid: true,
identifierType: manufacturerId === 1 ? 'generic' : 'manufacturer',
manufacturerId,
cachedVerification: true
};
}
Scan Event Logging
// /api/scan/logging.ts
interface ScanEvent {
// Required fields
timestamp: number;
instanceKey: string;
path: string;
tagType: 'q' | 'n' | 'r';
// Verification results
isValid: boolean;
manufacturerId?: number;
classPointer?: string;
// Context
userAgent: string;
ipAddress: string;
referer?: string;
// Location (if available)
latitude?: number;
longitude?: number;
accuracy?: number;
// Routing decision
routedTo: string;
objectFound: boolean;
authRequired: boolean;
}
export async function logScanEvent(event: ScanEvent): Promise<void> {
// Use Vercel Edge Config or external service
// Fire and forget for performance
try {
// Option 1: Vercel Analytics
if (process.env.VERCEL_ANALYTICS_ID) {
await fetch('https://vitals.vercel-insights.com/v1/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
eventName: 'scan',
properties: event
})
});
}
// Option 2: Custom analytics endpoint
if (process.env.ANALYTICS_ENDPOINT) {
await fetch(process.env.ANALYTICS_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.ANALYTICS_API_KEY!
},
body: JSON.stringify(event)
});
}
} catch (error) {
// Log but don't throw - analytics shouldn't break routing
console.error('Failed to log scan event:', error);
}
}
Routing Logic - The Evolving Brain
// /lib/routing.ts - This will grow significantly over time
export async function determineRoute(
params: ScanParams,
verification: VerificationResult,
resolution: ObjectResolution | null,
request: Request
): Promise<string> {
// Build enriched parameters for downstream services
const outParams = enrichParameters(params, verification, resolution);
// CASE 1: Unknown identifier
if (!resolution || !resolution.exists) {
return routeUnknownIdentifier(params, verification, outParams);
}
// CASE 2: Known object - Complex routing begins here
const { object } = resolution;
// Priority 1: Lost items always take precedence
if (object.status?.includes('LOST')) {
return `https://plings.io/found/${object.id}?${outParams}`;
}
// Priority 2: Multi-status handling (object can be for sale, rent, AND lend)
const commerceRoute = determineCommerceRoute(object, params);
if (commerceRoute) {
return commerceRoute;
}
// Priority 3: Context-aware routing (future expansion)
const contextualRoute = await determineContextualRoute(object, params, request);
if (contextualRoute) {
return contextualRoute;
}
// Priority 4: Visibility and auth requirements
if (object.visibility === 'private' || resolution.requiresAuth) {
return routeRequiringAuth(object, outParams);
}
// Default: Public object view
return `https://plings.io/o/${object.id}?${outParams}`;
}
// Multi-status commerce routing (will expand significantly)
function determineCommerceRoute(object: ObjectData, params: ScanParams): string | null {
const statuses = object.status || [];
// Future: Complex logic for multi-status objects
// For now, simple priority order
if (statuses.includes('FOR_SALE')) {
return `https://market.plings.io/item/${object.id}`;
}
if (statuses.includes('FOR_RENT')) {
return `https://rent.plings.io/item/${object.id}`;
}
if (statuses.includes('LENDABLE')) {
return `https://lend.plings.io/item/${object.id}`;
}
return null;
}
// Context-aware routing (future expansion area)
async function determineContextualRoute(
object: ObjectData,
params: ScanParams,
request: Request
): Promise<string | null> {
// Future considerations:
// - Geographic location (scan at specific venue)
// - Time of day (business hours vs after hours)
// - Object class (special handling for certain types)
// - User agent (mobile vs desktop)
// - Previous scan history
// - A/B testing groups
// Example future logic:
// if (object.class === 'TOOL' && isAtHardwareStore(request)) {
// return `https://plings.io/compare-prices/${object.id}`;
// }
return null;
}
The Director as an Evolving Router
The Director is not a static system - itβs a living, growing router that will become more sophisticated over time. Think of it as the βbrainβ of the Plings scanning system that learns new routing patterns as the ecosystem expands.
Current State: Simple conditional routing based on object status and visibility Future State: Complex multi-factor decision engine considering context, location, time, user history, and business rules
Why This Architecture?
- Start Simple: Launch with basic routing rules that work
- Measure Everything: Learn from real-world scanning patterns
- Iterate Quickly: Add new routing logic without changing QR codes
- Stay Flexible: Adapt to new business requirements easily
- Scale Intelligently: Add complexity only where it adds value
Example: Multi-Status Object Routing Evolution
// Version 1 (MVP): Simple priority order
if (status.includes('FOR_SALE')) return market;
if (status.includes('FOR_RENT')) return rent;
if (status.includes('LENDABLE')) return lend;
// Version 2: Consider price ratios
const salePrice = object.metadata.salePrice;
const rentPrice = object.metadata.rentPriceDaily;
if (salePrice && rentPrice) {
const roi = rentPrice * 365 / salePrice;
if (roi > 0.15) return rent; // Better ROI on renting
return market;
}
// Version 3: Add location context
if (scanLocation.type === 'TOURIST_AREA') {
return rent; // Tourists prefer renting
}
if (scanLocation.type === 'RESIDENTIAL') {
return market; // Residents prefer buying
}
// Version 4: Machine learning model
const prediction = await routingModel.predict({
object,
location,
timeOfDay,
userAgent,
historicalConversions
});
return prediction.optimalRoute;
This evolutionary approach ensures the Director can grow with the Plings ecosystem while maintaining simplicity at each stage.
Configuration-Driven Routing
To manage growing complexity, implement configuration-driven routing:
// routing-config.ts - Externalize routing rules
export const ROUTING_RULES = {
statusPriority: ['LOST', 'EMERGENCY', 'FOR_SALE', 'FOR_RENT', 'LENDABLE'],
contextRules: [
{
condition: (obj) => obj.class === 'MEDICAL_DEVICE' && obj.status.includes('EMERGENCY'),
route: 'https://emergency.plings.io/device/',
priority: 1
},
{
condition: (obj) => obj.manufacturerId === 17 && obj.status.includes('RECALL'),
route: 'https://recall.plings.io/ikea/',
priority: 2
}
],
// A/B test configuration
experiments: {
'new-found-flow': {
enabled: true,
percentage: 50,
control: 'https://plings.io/found/',
variant: 'https://plings.io/found-v2/'
}
}
};
Security Implementation
// /api/scan/security.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
// Rate limiting configuration
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, '1 m'), // 100 requests per minute
analytics: true,
});
export const rateLimiter = {
async check(req: Request): Promise<{ allowed: boolean; remaining: number }> {
const ip = req.headers.get('x-forwarded-for') || 'unknown';
const { success, remaining } = await ratelimit.limit(ip);
return { allowed: success, remaining };
}
};
// Input validation
export function validateParams(params: any): params is IncomingParams {
// Required parameters
if (!params.t || !params.i || !params.p) return false;
// Type validation
if (!['q', 'n', 'r'].includes(params.t)) return false;
// Instance key format (48 chars, base58)
if (!/^[1-9A-HJ-NP-Za-km-z]{44,48}$/.test(params.i)) return false;
// Path format (dot-separated numbers/base58)
if (!/^[1-9A-HJ-NP-Za-km-z]+(\.[1-9A-HJ-NP-Za-km-z]+)*$/.test(params.p)) return false;
// Verify class pointer format (11 chars, base58)
if (params.cp && !/^[1-9A-HJ-NP-Za-km-z]{11}$/.test(params.cp)) return false;
return true;
}
// Error handling
export function redirectToError(reason: string, status = 302): Response {
const errorUrls: Record<string, string> = {
'invalid-params': 'https://plings.io/error/invalid-scan',
'rate-limit': 'https://plings.io/error/too-many-requests',
'invalid-identifier': 'https://plings.io/error/invalid-identifier',
'system-error': 'https://plings.io/error/system',
};
return Response.redirect(
errorUrls[reason] || errorUrls['system-error'],
status
);
}
Use Case Implementation Details
Important Context: Public Scanner Use Cases Only
The following use cases specifically cover public QR code scanning via phone camera apps by users who are not logged into Plings. These represent the entry points for new users and the public face of the Plings system.
For authenticated user workflows (object creation, inventory management, spatial relationships, etc.), see the main application documentation. The Director handles these authenticated flows by redirecting to the appropriate service after login.
UC1: Scanning Unknown Generic Tag (Public)
Scenario: Someone scans a generic Plings tag with their phone camera app
Flow:
1. User scans QR code with phone camera
2. Phone opens browser to s.plings.io
3. Director verifies identifier is valid Plings identifier
4. Log scan event (async)
5. Lookup instance key β NOT FOUND
6. Check class pointer β NONE (generic tag)
7. Route to: https://plings.io/welcome?ikey={instanceKey}&path={path}&src=scan
Frontend shows (public welcome page):
- "You've discovered a Plings tag!"
- Brief explanation of Plings system
- "This tag isn't registered to any object yet"
- [Join Plings Free] - Create account to use this tag
- [I Have an Account] - Log in option
- [Learn More] - What is Plings?
Required GraphQL Operations:
# NEW ENDPOINT NEEDED - Check if identifier exists
query ResolveIdentifier($instanceKey: String!, $path: String!) {
resolveIdentifier(instanceKey: $instanceKey, path: $path) {
exists
object {
id
name
visibility
status
owner {
id
username
}
}
identifierType # 'generic' | 'manufacturer'
manufacturerInfo {
id
name
classInfo
}
}
}
# EXISTING - For logging scan events
mutation CreateScanEvent($input: ScanEventInput!) {
createScanEvent(input: $input) {
id
timestamp
}
}
UC2: Scanning Known Public Object
Scenario: Someone scans an object that exists and is marked as publicly visible
Flow:
1. User scans QR code with phone camera
2. Phone opens browser to s.plings.io
3. Director verifies identifier
4. Log scan event (async)
5. Lookup instance key β FOUND
6. Check object visibility β PUBLIC
7. Route to: https://plings.io/o/{objectId}?ikey={instanceKey}&path={path}&src=scan
Frontend shows (public object page):
- Object name and main image
- Basic description (if public)
- "Owned by: [Organization Name]"
- [Found This Item?] - If you found this
- [Join Plings] - To manage your own objects
- NO private information displayed
UC3: Scanning Lost Item (Public)
Scenario: Someone finds a lost item and scans its QR code
Flow:
1. Finder scans QR code with phone camera
2. Phone opens browser to s.plings.io
3. Director verifies identifier
4. Log scan event WITH GPS (if permitted)
5. Lookup instance key β FOUND
6. Check status β Contains 'LOST'
7. Trigger owner notification (async)
8. Route to: https://plings.io/found/{objectId}?ikey={instanceKey}&path={path}&src=scan
Frontend shows (public found page per lost-item-workflow.md):
- "This item has been reported lost!"
- "Thank you for scanning it"
- Item name and photo (if public)
- Finder's fee amount (e.g., "$20 Reward")
- [I Found This Item] - Contact owner button
- Owner contact info (phone shown, email via app)
- "The owner has been notified"
- Instructions for safe return
- [Get Plings App] - Convert finder to user
Note: This aligns with the comprehensive lost item workflow at docs/use-cases/lost-item-workflow.md. The public found page serves as a critical user acquisition channel by introducing finders to the Plings system.
UC4: Scanning Manufacturer Product (Public)
Scenario: Customer scans QR code on a new product in store or at home
Flow:
1. Customer scans product QR with phone camera
2. Phone opens browser to s.plings.io
3. Director verifies identifier with manufacturer key
4. Verify class pointer is valid
5. Log scan event
6. Lookup instance key β NOT FOUND
7. Route to: https://plings.io/claim?ikey={instanceKey}&path={path}&cptr={classPointer}&mfr={manufacturerId}&src=scan
Frontend shows (public product page):
- Manufacturer logo and name
- Product information and image
- "Register for warranty and support"
- [Register This Product] - Requires account
- [Create Account] - New users
- [Log In] - Existing users
- Product specs and info (public)
UC5: Scanning Private/Restricted Object (Public)
Scenario: Someone scans an object marked as private or organization-only
Flow:
1. User scans QR code with phone camera
2. Phone opens browser to s.plings.io
3. Director verifies identifier
4. Log scan event
5. Lookup instance key β FOUND
6. Check visibility β PRIVATE/ORGANIZATION
7. Route to: https://plings.io/restricted?src=scan
Frontend shows (restricted page):
- "This is a private Plings object"
- No object details shown
- [Are You the Owner?] - Log in to view
- [Found This Item?] - Report if found
- [Learn About Plings] - General info
UC6: Scanning Object for Sale (Public)
Scenario: Potential buyer scans QR on item for sale
Flow:
1. Buyer scans QR code with phone camera
2. Phone opens browser to s.plings.io
3. Director verifies identifier
4. Log scan event
5. Lookup instance key β FOUND
6. Check status β Contains 'FOR_SALE'
7. Route to: https://market.plings.io/item?oid={objectId}&ikey={instanceKey}&path={path}&src=scan
Market.plings.io shows (public listing):
- Item photos and description
- Price and seller info (organization)
- [Contact Seller] - Requires account
- [Create Account to Buy] - New users
- Public questions/answers
- Similar items for sale
What Happens After Login?
Once a user creates an account or logs in, they gain access to the full Plings ecosystem:
- Create and manage objects
- Establish spatial relationships
- Transfer ownership
- Set privacy levels
- Use advanced features
The Director automatically handles authenticated users differently, routing them to appropriate management interfaces rather than these public pages.
Environment Configuration
# Vercel Environment Variables for s.plings.io
# Keep under 50 variables limit!
# Essential Configuration
PLINGS_PUBLIC_KEY=<base58_encoded_public_key> # Always required
PLINGS_API_ENDPOINT=https://api.plings.io/graphql
# Storage Solutions (choose one primary method)
# Option A: Vercel Edge Config
EDGE_CONFIG=https://edge-config.vercel.com/<token>
# Option B: KV Store
KV_REST_API_URL=https://<instance>.vercel.kv.io
KV_REST_API_TOKEN=<token>
# Option C: Upstash Redis (shared with rate limiting)
UPSTASH_REDIS_REST_URL=https://<instance>.upstash.io
UPSTASH_REDIS_REST_TOKEN=<token>
# Analytics & Monitoring
ANALYTICS_ENDPOINT=https://analytics.plings.io/v1/events
ANALYTICS_API_KEY=<secure_api_key>
SENTRY_DSN=<sentry_dsn_for_edge_functions>
# Feature Flags
ENABLE_INSTANCE_VERIFICATION=true
ENABLE_GPS_LOGGING=true
ENABLE_DEEP_LINKING=true
# Cache Configuration
EDGE_CACHE_TTL=300 # 5 minutes for object data
MANUFACTURER_CACHE_TTL=3600 # 1 hour for public keys
# Logging
LOG_LEVEL=info
Manufacturer Public Key Storage Solutions
Option A: Vercel Edge Config (Recommended)
// Store all manufacturer keys in Edge Config
// No environment variable limit, optimized for edge reading
{
"manufacturer_1_pubkey": "Base58PublicKeyForPlings",
"manufacturer_3_pubkey": "Base58PublicKeyForCocaCola",
"manufacturer_17_pubkey": "Base58PublicKeyForIKEA",
// ... hundreds more
}
Option B: Vercel KV Store
# Store keys in KV store via CLI or API
vercel kv set mfr:pubkey:1 "Base58PublicKeyForPlings"
vercel kv set mfr:pubkey:3 "Base58PublicKeyForCocaCola"
vercel kv set mfr:pubkey:17 "Base58PublicKeyForIKEA"
Option C: API Endpoint with CDN Caching
// Serve from API with aggressive caching
// /api/manufacturers/registry.json
{
"manufacturers": {
"1": { "name": "Plings", "publicKey": "..." },
"3": { "name": "Coca-Cola", "publicKey": "..." },
"17": { "name": "IKEA", "publicKey": "..." }
}
}
Deployment Configuration
Vercel Configuration (vercel.json)
{
"name": "plings-director",
"alias": ["s.plings.io"],
"regions": ["iad1", "sfo1", "sin1", "syd1", "hnd1", "fra1"],
"functions": {
"api/index.ts": {
"runtime": "edge",
"maxDuration": 5
}
},
"rewrites": [
{
"source": "/",
"destination": "/api/index"
}
],
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
}
]
}
]
}
Monitoring & Observability
Key Metrics to Track
// Custom metrics for monitoring
interface DirectorMetrics {
// Performance
responseTime: Histogram; // Target: <100ms p95
verificationTime: Histogram; // Target: <20ms
routingTime: Histogram; // Target: <10ms
// Volume
scanVolume: Counter; // Total scans
scanByType: Counter; // QR vs NFC vs RFID
scanByManufacturer: Counter; // Per manufacturer
scanByService: Counter; // Where routed
// Errors
verificationFailures: Counter; // Invalid identifiers
routingErrors: Counter; // Failed routing
rateLimitHits: Counter; // Rate limit rejections
// Cache Performance
cacheHitRate: Gauge; // Object cache hits
manufacturerCacheHits: Gauge; // Public key cache
}
// Monitoring implementation
export function recordMetrics(event: string, value: number, tags?: Record<string, string>) {
// Send to monitoring service
if (process.env.MONITORING_ENABLED) {
// DataDog, New Relic, or custom solution
}
}
Health Check Endpoint
// /api/health
export default async function healthCheck(req: Request) {
const checks = {
edge: 'ok',
redis: await checkRedis(),
api: await checkAPI(),
cache: await checkCache(),
};
const allHealthy = Object.values(checks).every(v => v === 'ok');
return new Response(JSON.stringify({
status: allHealthy ? 'healthy' : 'degraded',
timestamp: new Date().toISOString(),
checks,
region: process.env.VERCEL_REGION,
}), {
status: allHealthy ? 200 : 503,
headers: { 'Content-Type': 'application/json' }
});
}
Testing Strategy
Unit Tests
// __tests__/verification.test.ts
describe('Identifier Verification', () => {
it('should verify valid class pointer', async () => {
const params = {
t: 'q',
i: '4kyQCd5tMDjJVWJH5h95gUcjq3qTX2cj5nwjVyqBRwLo',
p: '2.17.3.2.5.100.5000',
cp: '4K7mX9abDcE'
};
const result = await verifyIdentifier(params);
expect(result.isValid).toBe(true);
expect(result.manufacturerId).toBe(17);
});
it('should reject invalid class pointer', async () => {
const params = {
t: 'q',
i: '4kyQCd5tMDjJVWJH5h95gUcjq3qTX2cj5nwjVyqBRwLo',
p: '2.17.3.2.5.100.5000',
cp: 'INVALID123'
};
const result = await verifyIdentifier(params);
expect(result.isValid).toBe(false);
expect(result.reason).toBe('Invalid class pointer');
});
});
Integration Tests
// __tests__/routing.integration.test.ts
describe('Director Routing Integration', () => {
it('should route FOR_SALE objects to marketplace', async () => {
// Mock object resolution
mockResolveObject({
exists: true,
object: {
id: 'obj-123',
status: ['FOR_SALE'],
visibility: 'public'
}
});
const response = await fetch('https://s.plings.io?t=q&i=TEST&p=2.1.1.1.1');
expect(response.status).toBe(302);
expect(response.headers.get('Location')).toContain('market.plings.io');
});
});
Frontend Requirements
New Routes and Pages
1. /o/{objectId} - Public Object View
Purpose: Display public information about an object to anyone who scans it
URL Structure: /o/{objectId} (using directory structure for cleaner URLs and better SEO)
Why directory instead of page?
- Cleaner URLs:
/o/abc-123vs/object?id=abc-123 - SEO Benefits: Search engines prefer directory structures
- Social Sharing: Cleaner URLs for sharing object links
- Future Expansion: Can add sub-routes like
/o/{id}/history,/o/{id}/services
Page Requirements:
interface PublicObjectPageProps {
// URL params
objectId: string;
// Query params from scan
ikey?: string; // Instance key (for verification)
path?: string; // HD path
src?: string; // Source (always 'scan' from Director)
}
// Display requirements
- Object name and main image
- Public description
- Owner organization (not personal info)
- Object status badges (if public)
- [Contact Owner] button (if enabled)
- [Report Found] if status includes 'lost'
- [View in App] call-to-action for non-users
- Share buttons for social media
2. /welcome - New User Onboarding
Purpose: Educate potential new users about Plings when they scan an unregistered tag
URL: /welcome?ikey={instanceKey}&path={path}&src=scan
Page Requirements:
- Hero section explaining βYouβve scanned a Plings tag!β
- What is Plings? (brief explanation)
- What can you do with this tag?
- [Create Free Account] - Primary CTA
- [Log In] - Secondary option
- [Learn More] - Link to full explanation
- Mobile-optimized design (most scans from phones)
3. /found/{objectId} - Lost Item Found Flow
Purpose: Handle the sensitive flow when someone finds a lost item
URL: /found/{objectId}?ikey={instanceKey}&path={path}&src=scan
Page Requirements (from lost-item-workflow.md):
π Item Found: [Item Name]
π Status: LOST - Reward Available
π° Finder's Fee: $20.00
π€ Owner Contact:
π Phone: (555) 123-4567
π§ Email: [Contact through app]
π Last Seen: [Location]
β° Lost Since: [Time ago]
π‘ First time using Plings?
This is a lost & found system that helps people
recover their belongings. You can help by contacting
the owner and earn a reward!
π± Get Plings App:
β’ Track your own items
β’ Earn rewards for helping others
β’ Join the community
[π Call Owner] [π± Get App] [β Close]
Key Features:
- Show reward amount prominently
- Display owner contact info (phone public, email via app)
- User acquisition messaging for non-users
- Clear instructions for return process
- Track conversion from finder to user
4. /claim - Manufacturer Product Registration
Purpose: Register/claim a manufacturerβs product
URL: /claim?ikey={instanceKey}&path={path}&cptr={classPointer}&mfr={manufacturerId}&src=scan
Page Requirements:
- Show manufacturer and product information
- Product image (from manufacturer class data)
- [Register This Product] - Primary CTA
- Benefits of registration (warranty, support, etc.)
- Require authentication to claim
- After claim: Option to add custom details
5. /scan-error - Error Pages
Purpose: User-friendly error messages for scan issues URLs:
/scan-error/invalid- Invalid identifier/scan-error/rate-limit- Too many scans/scan-error/system- System error
Page Requirements:
- Clear error message (non-technical)
- What went wrong explanation
- [Try Again] button
- [Get Help] link
- [Report Issue] for persistent problems
Authentication Flow Pages
/login with Scan Context
Enhanced Requirements:
// When redirected from scan requiring auth
interface LoginPageScanContext {
return: 'scan';
state: string; // Encoded scan parameters
}
// After successful login:
1. Decode state parameters
2. Call GraphQL to re-verify access
3. Redirect to appropriate view based on permissions
Mobile Optimization Requirements
Since most scans come from mobile devices:
- Touch-Optimized: Large buttons, easy tap targets
- Fast Loading: Minimize JavaScript, optimize images
- Offline Capable: Show cached data when possible
- Camera-First: Quick return to camera for multiple scans
- Progressive Enhancement: Work without JavaScript for basic viewing
URL Parameter Handling
All pages receiving Director parameters should:
- Validate Parameters: Ensure they match expected format
- Track Source: Record that visit came from scan
- Preserve Context: Maintain parameters through auth flows
- Handle Missing Object: Graceful fallback if object deleted
Analytics Requirements
Track on all Director-routed pages:
- Scan source (QR/NFC/RFID via
tparam) - Object/identifier type
- User journey (new vs returning)
- Conversion rates (sign-up, claim, found report)
Backend Requirements
New GraphQL Endpoints Needed
The following GraphQL operations need to be implemented in the backend to support the Director service:
# 1. Identifier Resolution - Check if identifier exists and get basic info
query ResolveIdentifier($instanceKey: String!, $path: String!) {
resolveIdentifier(instanceKey: $instanceKey, path: $path) {
exists: Boolean!
identifierType: String! # 'generic' | 'manufacturer'
# If object exists
object {
id: ID!
name: String!
visibility: String! # 'public' | 'private' | 'organization'
status: [String!] # Array of status keys
requiresAuth: Boolean!
owner {
id: ID!
username: String!
organizationName: String
}
}
# For manufacturer tags
manufacturerInfo {
id: Int!
name: String!
classId: String
className: String
classDescription: String
}
}
}
# 2. Manufacturer Public Key Query
query GetManufacturerPublicKey($manufacturerId: Int!) {
manufacturer(id: $manufacturerId) {
id: Int!
name: String!
publicKey: String! # Base58 encoded public key
}
}
# 3. Scan Event Logging
mutation CreateScanEvent($input: ScanEventInput!) {
createScanEvent(input: $input) {
id: ID!
timestamp: DateTime!
processed: Boolean!
}
}
input ScanEventInput {
instanceKey: String!
path: String!
tagType: String! # 'q' | 'n' | 'r'
classPointer: String
# Context
userAgent: String
ipAddress: String
referer: String
# Location (optional)
latitude: Float
longitude: Float
accuracy: Float
# Resolution results
objectFound: Boolean!
routedTo: String!
verificationResult: String
}
Implementation Priority
- Critical (Block Director launch):
resolveIdentifierquerymanufacturer.publicKeyfield
- Important (Can use workarounds initially):
createScanEventmutation (can log locally first)
- Nice to have:
- Batch identifier resolution
- Scan analytics queries
Troubleshooting Guide
Common Issues
- High Latency
- Check edge function region deployment
- Verify cache hit rates
- Monitor API response times
- Verification Failures
- Ensure manufacturer public keys are current
- Check class pointer generation algorithm
- Verify path format parsing
- Routing Errors
- Confirm object resolution API is accessible
- Check service URL configurations
- Verify parameter enrichment logic
- Rate Limiting Issues
- Review rate limit thresholds
- Check for bot traffic patterns
- Consider geographic rate limit variations
Future Enhancements
- Semi-Public Key Registry (Phase 2)
- Implement path registry for campaign-level public keys
- Enable deeper offline verification
- Further reduce API calls
- WebAssembly Crypto (Phase 3)
- Port verification algorithms to WASM
- Enable full offline verification
- Sub-10ms verification times
- Smart Routing (Future)
- ML-based routing predictions
- User preference learning
- Context-aware service selection
API Endpoints
Primary Scanner
GET https://s.plings.io?t={type}&i={instance}&p={path}&cp={classpointer}
Health Check
GET https://s.plings.io/health
Response: { status: "ok", latency: 12 }
Appendix A: Implementation Checklist
Repository Setup
- Create
Plings-DirectorGitHub repository - Initialize with TypeScript and Vercel Edge Functions template
- Link repository to Vercel project
- Configure s.plings.io domain in Vercel
Phase 1: MVP Launch
- Implement basic parameter extraction and validation
- Set up manufacturer public key registry (Edge Config/KV)
- Implement class pointer verification
- Create routing logic for all public use cases
- Set up basic scan event logging
- Implement rate limiting with Upstash Redis
- Configure error page redirects
- Set up monitoring and alerts
- Deploy to production regions
- Update documentation in Plings-Lovable-Frontend/docs
Phase 2: Enhanced Verification
- Implement semi-public key registry
- Add campaign-level key caching
- Enable partial offline verification
- Optimize cache strategies
- Add A/B testing framework
- Implement geographic routing
Phase 3: Advanced Features
- WebAssembly crypto implementation
- Full offline verification capability
- Smart routing with ML
- Deep linking optimization
- Advanced analytics integration
Appendix B: Reference Links
Documentation (in Plings-Lovable-Frontend)
- System Overview - Director Layer
- HD Wallet Architecture
- Plings Identifier Specification
- Lost Item Workflow
- GraphQL API Documentation
External Resources
This implementation guide is stored in Plings-Lovable-Frontend/docs/implementation/ while the actual code implementation resides in the Plings-Director repository. This separation ensures documentation remains centralized while code is independently deployable.