linkitylink 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js ADDED
@@ -0,0 +1,2914 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Linkitylink - Privacy-First Link Page Service
5
+ *
6
+ * Creates beautiful SVG link pages from user-provided link data
7
+ * Public access via emojicode - no authentication required for viewers
8
+ *
9
+ * Flow:
10
+ * 1. User creates link page via POST /create or web interface
11
+ * 2. BDO made public → generates shareable emojicode
12
+ * 3. Anyone can view: linkitylink?emojicode=šŸ˜€šŸ”—šŸ’ŽšŸŒŸ...
13
+ * 4. Linkitylink renders links into beautiful SVG layouts
14
+ */
15
+
16
+ import express from 'express';
17
+ import session from 'express-session';
18
+ import store from 'memorystore';
19
+ import fountLib from 'fount-js';
20
+ import bdoLib from 'bdo-js';
21
+ import addieLib from 'addie-js';
22
+ import sessionless from 'sessionless-node';
23
+ import fetch from 'node-fetch';
24
+ import { fileURLToPath } from 'url';
25
+ import { dirname, join } from 'path';
26
+
27
+ // Import relevantBDOs middleware
28
+ import {
29
+ relevantBDOsMiddleware,
30
+ getRelevantBDOs,
31
+ setRelevantBDOs,
32
+ clearRelevantBDOs,
33
+ toStripeMetadata,
34
+ logRelevantBDOs,
35
+ configureBdoLib,
36
+ fetchAndExtractPayees,
37
+ logPayees
38
+ } from './lib/relevant-bdos-middleware.js';
39
+
40
+ // Import app handoff module
41
+ import {
42
+ createPendingHandoff,
43
+ getPendingHandoff,
44
+ verifyAuthSequence,
45
+ associateAppCredentials,
46
+ completeHandoff,
47
+ getHandoffForApp,
48
+ getHandoffStats
49
+ } from './lib/app-handoff.js';
50
+
51
+ const __filename = fileURLToPath(import.meta.url);
52
+ const __dirname = dirname(__filename);
53
+
54
+ const MemoryStore = store(session);
55
+
56
+ // Stats file path
57
+ const STATS_FILE = join(__dirname, 'data', 'stats.json');
58
+
59
+ const app = express();
60
+ const PORT = process.env.PORT || 3010;
61
+
62
+ // Configuration (defaults to dev environment)
63
+ const FOUNT_BASE_URL = process.env.FOUNT_BASE_URL || 'https://dev.fount.allyabase.com/';
64
+ const BDO_BASE_URL = process.env.BDO_BASE_URL || 'https://dev.bdo.allyabase.com';
65
+ const ADDIE_BASE_URL = process.env.ADDIE_BASE_URL || 'https://dev.addie.allyabase.com';
66
+ const ENABLE_APP_PURCHASE = process.env.ENABLE_APP_PURCHASE === 'true';
67
+
68
+ // Configure SDKs
69
+ fountLib.baseURL = FOUNT_BASE_URL.endsWith('/') ? FOUNT_BASE_URL : `${FOUNT_BASE_URL}/`;
70
+ bdoLib.baseURL = BDO_BASE_URL.endsWith('/') ? BDO_BASE_URL : `${BDO_BASE_URL}/`;
71
+ addieLib.baseURL = ADDIE_BASE_URL.endsWith('/') ? ADDIE_BASE_URL : `${ADDIE_BASE_URL}/`;
72
+
73
+ // Configure relevantBDOs middleware with bdo-js instance
74
+ configureBdoLib(bdoLib);
75
+
76
+ console.log('šŸ”— Linkitylink - Privacy-First Link Pages');
77
+ console.log('========================================');
78
+ console.log(`šŸ“ Port: ${PORT}`);
79
+ console.log(`šŸ“ Fount URL: ${fountLib.baseURL}`);
80
+ console.log(`šŸ“ BDO URL: ${bdoLib.baseURL}`);
81
+ console.log(`šŸ“ Addie URL: ${ADDIE_BASE_URL}`);
82
+ console.log(`šŸ“ App Purchase: ${ENABLE_APP_PURCHASE ? 'Enabled' : 'Disabled'}`);
83
+ console.log('šŸ“ Architecture: Server returns identifiers only (clients construct URLs)');
84
+
85
+ // Configuration endpoint - allows dynamic base URL configuration
86
+ app.post('/config', express.json(), (req, res) => {
87
+ try {
88
+ const { fountURL, bdoURL, addieURL } = req.body;
89
+
90
+ if (fountURL) {
91
+ fountLib.baseURL = fountURL.endsWith('/') ? fountURL : `${fountURL}/`;
92
+ console.log(`šŸ”§ Fount URL updated: ${fountLib.baseURL}`);
93
+ }
94
+
95
+ if (bdoURL) {
96
+ bdoLib.baseURL = bdoURL.endsWith('/') ? bdoURL : `${bdoURL}/`;
97
+ configureBdoLib(bdoLib);
98
+ console.log(`šŸ”§ BDO URL updated: ${bdoLib.baseURL}`);
99
+ }
100
+
101
+ if (addieURL) {
102
+ addieLib.baseURL = addieURL.endsWith('/') ? addieURL : `${addieURL}/`;
103
+ console.log(`šŸ”§ Addie URL updated: ${addieLib.baseURL}`);
104
+ }
105
+
106
+ res.json({
107
+ success: true,
108
+ config: {
109
+ fountURL: fountLib.baseURL,
110
+ bdoURL: bdoLib.baseURL,
111
+ addieURL: addieLib.baseURL
112
+ }
113
+ });
114
+ } catch (error) {
115
+ console.error('āŒ Configuration error:', error);
116
+ res.status(500).json({
117
+ success: false,
118
+ error: error.message
119
+ });
120
+ }
121
+ });
122
+
123
+ // Get current configuration
124
+ app.get('/config', (req, res) => {
125
+ res.json({
126
+ fountURL: fountLib.baseURL,
127
+ bdoURL: bdoLib.baseURL,
128
+ addieURL: addieLib.baseURL
129
+ });
130
+ });
131
+
132
+ // Session middleware - gives users persistent sessions
133
+ app.use(session({
134
+ store: new MemoryStore({
135
+ checkPeriod: 86400000 // prune expired entries every 24h
136
+ }),
137
+ resave: false,
138
+ saveUninitialized: false,
139
+ secret: 'linkitylink-privacy-first-links-2025',
140
+ cookie: {
141
+ maxAge: 31536000000, // 1 year (basically never expire)
142
+ httpOnly: true,
143
+ secure: process.env.NODE_ENV === 'production'
144
+ }
145
+ }));
146
+
147
+ // Middleware
148
+ app.use(express.static(join(__dirname, 'public')));
149
+ app.use(express.json());
150
+ app.use(relevantBDOsMiddleware); // Extract relevantBDOs from requests and store in session
151
+
152
+ // ========== STATS TRACKING ==========
153
+
154
+ /**
155
+ * Load stats from disk
156
+ */
157
+ async function loadStats() {
158
+ try {
159
+ const fs = await import('fs/promises');
160
+
161
+ // Ensure data directory exists
162
+ const dataDir = join(__dirname, 'data');
163
+ try {
164
+ await fs.access(dataDir);
165
+ } catch {
166
+ await fs.mkdir(dataDir, { recursive: true });
167
+ }
168
+
169
+ // Try to read stats file
170
+ try {
171
+ const data = await fs.readFile(STATS_FILE, 'utf-8');
172
+ return JSON.parse(data);
173
+ } catch {
174
+ // File doesn't exist, return default stats
175
+ return {
176
+ totalSales: 0,
177
+ createdAt: new Date().toISOString(),
178
+ lastUpdated: new Date().toISOString()
179
+ };
180
+ }
181
+ } catch (error) {
182
+ console.error('āŒ Error loading stats:', error);
183
+ return {
184
+ totalSales: 0,
185
+ createdAt: new Date().toISOString(),
186
+ lastUpdated: new Date().toISOString()
187
+ };
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Save stats to disk
193
+ */
194
+ async function saveStats(stats) {
195
+ try {
196
+ const fs = await import('fs/promises');
197
+ const writeFileAtomic = (await import('write-file-atomic')).default;
198
+
199
+ stats.lastUpdated = new Date().toISOString();
200
+
201
+ await writeFileAtomic(STATS_FILE, JSON.stringify(stats, null, 2));
202
+ console.log(`šŸ“Š Stats saved: ${stats.totalSales} total sales`);
203
+ } catch (error) {
204
+ console.error('āŒ Error saving stats:', error);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Increment total sales counter
210
+ */
211
+ async function incrementSales() {
212
+ try {
213
+ const stats = await loadStats();
214
+ stats.totalSales += 1;
215
+ await saveStats(stats);
216
+ console.log(`šŸŽ‰ Sale #${stats.totalSales} recorded!`);
217
+ return stats.totalSales;
218
+ } catch (error) {
219
+ console.error('āŒ Error incrementing sales:', error);
220
+ return null;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * View linkitylink by emojicode
226
+ * Route: /view/:emojicode
227
+ */
228
+ app.get('/view/:emojicode', async (req, res) => {
229
+ try {
230
+ const { emojicode } = req.params;
231
+
232
+ console.log(`šŸ˜€ Fetching Linkitylink by emojicode: ${emojicode}`);
233
+
234
+ let links = [];
235
+ let userName = 'My Links';
236
+
237
+ try {
238
+ // Fetch Linkitylink BDO by emojicode
239
+ const linkHubBDO = await bdoLib.getBDOByEmojicode(emojicode);
240
+
241
+ console.log('šŸ“¦ Linkitylink BDO fetched:', JSON.stringify(linkHubBDO).substring(0, 200));
242
+
243
+ // Extract links from BDO data
244
+ const bdoData = linkHubBDO.bdo || linkHubBDO;
245
+ if (bdoData.links && Array.isArray(bdoData.links)) {
246
+ links = bdoData.links;
247
+ console.log(`šŸ”— Found ${links.length} links in Linkitylink BDO`);
248
+ } else {
249
+ console.log('āš ļø No links array found in Linkitylink BDO');
250
+ }
251
+
252
+ // Get user name from BDO
253
+ userName = bdoData.title || bdoData.name || 'My Links';
254
+
255
+ } catch (error) {
256
+ console.error('āŒ Failed to fetch Linkitylink BDO by emojicode:', error.message);
257
+ // Continue with empty links - will show demo
258
+ }
259
+
260
+ // If no links, show demo links
261
+ if (links.length === 0) {
262
+ links = getDemoLinks();
263
+ userName = 'Demo Links';
264
+ }
265
+
266
+ // Limit to 20 links
267
+ const displayLinks = links.slice(0, 20);
268
+
269
+ // Generate HTML page
270
+ const html = generateLinkitylinkPage(displayLinks, userName, false, null);
271
+
272
+ res.send(html);
273
+
274
+ } catch (error) {
275
+ console.error('āŒ Server error:', error);
276
+ res.status(500).send('Error loading linkitylink');
277
+ }
278
+ });
279
+
280
+ /**
281
+ * Main route - Landing page or tapestry display
282
+ *
283
+ * No query params: Show landing page
284
+ * Query params (Method 1 - Emojicode):
285
+ * - emojicode: 8-emoji identifier for Linkitylink BDO
286
+ *
287
+ * Query params (Method 2 - Legacy Authentication):
288
+ * - pubKey: User's public key
289
+ * - timestamp: Request timestamp
290
+ * - signature: Sessionless signature (timestamp + pubKey)
291
+ */
292
+ app.get('/', async (req, res) => {
293
+ try {
294
+ const { emojicode, pubKey, timestamp, signature } = req.query;
295
+
296
+ // Debug logging
297
+ console.log('šŸ” GET / query params:', { emojicode: emojicode ? emojicode.substring(0, 20) + '...' : 'none', pubKey: pubKey ? pubKey.substring(0, 16) + '...' : 'none' });
298
+
299
+ // If no query parameters, serve landing page
300
+ if (!emojicode && !pubKey && !timestamp && !signature) {
301
+ const fs = await import('fs/promises');
302
+ const landingPage = await fs.readFile(join(__dirname, 'public', 'index.html'), 'utf-8');
303
+ return res.send(landingPage);
304
+ }
305
+
306
+ let links = [];
307
+ let userName = 'Anonymous';
308
+ let authenticated = false;
309
+
310
+ // Method 1: Fetch by emojicode (PUBLIC - no auth required)
311
+ if (emojicode) {
312
+ console.log(`šŸ˜€ Fetching Linkitylink by emojicode: ${emojicode}`);
313
+
314
+ try {
315
+ // Fetch Linkitylink BDO by emojicode
316
+ const linkHubBDO = await bdoLib.getBDOByEmojicode(emojicode);
317
+
318
+ console.log('šŸ“¦ Linkitylink BDO fetched:', JSON.stringify(linkHubBDO).substring(0, 200));
319
+
320
+ // Extract links from BDO data
321
+ const bdoData = linkHubBDO.bdo || linkHubBDO;
322
+ if (bdoData.links && Array.isArray(bdoData.links)) {
323
+ links = bdoData.links;
324
+ console.log(`šŸ”— Found ${links.length} links in Linkitylink BDO`);
325
+ } else {
326
+ console.log('āš ļø No links array found in Linkitylink BDO');
327
+ }
328
+
329
+ // Get user name from BDO
330
+ userName = bdoData.title || bdoData.name || 'My Links';
331
+ authenticated = false; // Public access via emojicode
332
+
333
+ } catch (error) {
334
+ console.error('āŒ Failed to fetch Linkitylink BDO by emojicode:', error.message);
335
+ // Continue with empty links array
336
+ }
337
+ }
338
+ // Method 2: Legacy authentication (for backward compatibility)
339
+ else if (pubKey && timestamp && signature) {
340
+ console.log(`šŸ” Authenticating request for pubKey: ${pubKey.substring(0, 16)}...`);
341
+
342
+ // Verify signature
343
+ const message = timestamp + pubKey;
344
+ const isValid = sessionless.verifySignature(signature, message, pubKey);
345
+
346
+ if (isValid) {
347
+ console.log('āœ… Signature valid, fetching user BDO...');
348
+ authenticated = true;
349
+
350
+ try {
351
+ // Fetch user's Fount BDO (which contains carrierBag)
352
+ const userBDO = await fountLib.getBDO(pubKey);
353
+
354
+ console.log('šŸ“¦ User BDO fetched:', JSON.stringify(userBDO).substring(0, 200));
355
+
356
+ // Extract carrierBag from BDO
357
+ const bdo = userBDO.bdo || userBDO;
358
+ const carrierBag = bdo.carrierBag || bdo.data?.carrierBag;
359
+
360
+ if (carrierBag && carrierBag.links) {
361
+ links = carrierBag.links;
362
+ console.log(`šŸ”— Found ${links.length} links in carrierBag`);
363
+ } else {
364
+ console.log('āš ļø No links collection found in carrierBag');
365
+ }
366
+
367
+ // Try to get user name from BDO
368
+ userName = bdo.name || bdo.title || 'My Links';
369
+
370
+ } catch (error) {
371
+ console.error('āŒ Failed to fetch user BDO:', error.message);
372
+ // Continue with empty links array
373
+ }
374
+ } else {
375
+ console.log('āŒ Invalid signature');
376
+ }
377
+ } else {
378
+ console.log('ā„¹ļø No emojicode or authentication provided, showing demo');
379
+ }
380
+
381
+ // If no links, show demo links
382
+ if (links.length === 0) {
383
+ links = getDemoLinks();
384
+ userName = 'Demo Links';
385
+ }
386
+
387
+ // Limit to 20 links
388
+ const displayLinks = links.slice(0, 20);
389
+
390
+ // Generate HTML page
391
+ const html = generateLinkitylinkPage(displayLinks, userName, authenticated, pubKey);
392
+
393
+ res.send(html);
394
+
395
+ } catch (error) {
396
+ console.error('āŒ Server error:', error);
397
+ res.status(500).send(`
398
+ <!DOCTYPE html>
399
+ <html>
400
+ <head>
401
+ <title>Linkitylink Error</title>
402
+ <meta charset="UTF-8">
403
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
404
+ <style>
405
+ body {
406
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
407
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
408
+ color: white;
409
+ display: flex;
410
+ align-items: center;
411
+ justify-content: center;
412
+ min-height: 100vh;
413
+ margin: 0;
414
+ padding: 20px;
415
+ }
416
+ .error {
417
+ background: rgba(255,255,255,0.1);
418
+ padding: 40px;
419
+ border-radius: 20px;
420
+ text-align: center;
421
+ }
422
+ </style>
423
+ </head>
424
+ <body>
425
+ <div class="error">
426
+ <h1>āš ļø Error</h1>
427
+ <p>${error.message}</p>
428
+ </div>
429
+ </body>
430
+ </html>
431
+ `);
432
+ }
433
+ });
434
+
435
+ // In-memory mapping of pubKey to metadata for alphanumeric URLs
436
+ const bdoMetadataMap = new Map();
437
+
438
+ // Persistence tracking
439
+ let mappingsDirty = false;
440
+ let mappingsCounter = 0;
441
+ let lastBDOBackup = Date.now();
442
+
443
+ // File paths
444
+ const MAPPINGS_FILE = join(__dirname, 'alphanumeric-mappings.json');
445
+
446
+ /**
447
+ * Load alphanumeric mappings from filesystem on startup
448
+ */
449
+ async function loadMappings() {
450
+ try {
451
+ const fs = await import('fs/promises');
452
+ const data = await fs.readFile(MAPPINGS_FILE, 'utf-8');
453
+ const mappings = JSON.parse(data);
454
+
455
+ for (const [pubKey, metadata] of Object.entries(mappings)) {
456
+ bdoMetadataMap.set(pubKey, metadata);
457
+ }
458
+
459
+ console.log(`šŸ“‚ Loaded ${bdoMetadataMap.size} alphanumeric mappings from filesystem`);
460
+ } catch (err) {
461
+ if (err.code === 'ENOENT') {
462
+ console.log('šŸ“ No existing mappings file, starting fresh');
463
+ } else {
464
+ console.error('āŒ Error loading mappings:', err.message);
465
+ }
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Save alphanumeric mappings to filesystem (batched)
471
+ */
472
+ async function saveMappingsToFilesystem() {
473
+ try {
474
+ const fs = await import('fs/promises');
475
+ const mappings = Object.fromEntries(bdoMetadataMap);
476
+ await fs.writeFile(MAPPINGS_FILE, JSON.stringify(mappings, null, 2), 'utf-8');
477
+
478
+ mappingsDirty = false;
479
+ console.log(`šŸ’¾ Saved ${bdoMetadataMap.size} alphanumeric mappings to filesystem`);
480
+ } catch (err) {
481
+ console.error('āŒ Error saving mappings to filesystem:', err.message);
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Backup alphanumeric mappings to BDO service (hourly)
487
+ */
488
+ async function backupMappingsToBDO() {
489
+ try {
490
+ console.log('ā˜ļø Backing up alphanumeric mappings to BDO service...');
491
+
492
+ const mappings = Object.fromEntries(bdoMetadataMap);
493
+
494
+ // Generate temporary keys for backup BDO
495
+ const saveKeys = (keys) => { backupKeys = keys; };
496
+ const getKeys = () => backupKeys;
497
+ let backupKeys = null;
498
+
499
+ const keys = await sessionless.generateKeys(saveKeys, getKeys);
500
+
501
+ // Create backup BDO
502
+ const backupBDO = {
503
+ title: 'Linkitylink Alphanumeric Mappings Backup',
504
+ type: 'linkitylink-backup',
505
+ mappings: mappings,
506
+ mappingCount: bdoMetadataMap.size,
507
+ backedUpAt: new Date().toISOString()
508
+ };
509
+
510
+ const hash = 'Linkitylink-System';
511
+ await bdoLib.createUser(hash, backupBDO, saveKeys, getKeys);
512
+
513
+ lastBDOBackup = Date.now();
514
+ console.log(`ā˜ļø Backed up ${bdoMetadataMap.size} mappings to BDO service`);
515
+ } catch (err) {
516
+ console.error('āŒ Error backing up to BDO:', err.message);
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Mark mappings as dirty and potentially trigger save
522
+ */
523
+ function markMappingsDirty() {
524
+ mappingsDirty = true;
525
+ mappingsCounter++;
526
+
527
+ // Batch save every 10 creates
528
+ if (mappingsCounter >= 10) {
529
+ mappingsCounter = 0;
530
+ saveMappingsToFilesystem();
531
+ }
532
+ }
533
+
534
+ // Periodic save timer (every 10 minutes if dirty)
535
+ setInterval(() => {
536
+ if (mappingsDirty) {
537
+ console.log('ā° 10-minute timer: Saving dirty mappings...');
538
+ saveMappingsToFilesystem();
539
+ }
540
+ }, 10 * 60 * 1000); // 10 minutes
541
+
542
+ // Hourly BDO backup timer
543
+ setInterval(() => {
544
+ const hoursSinceBackup = (Date.now() - lastBDOBackup) / (1000 * 60 * 60);
545
+ if (hoursSinceBackup >= 1 && bdoMetadataMap.size > 0) {
546
+ console.log('ā° 1-hour timer: Backing up to BDO...');
547
+ backupMappingsToBDO();
548
+ }
549
+ }, 60 * 60 * 1000); // 1 hour
550
+
551
+ // Load mappings on startup
552
+ loadMappings();
553
+
554
+ /**
555
+ * Alphanumeric path route - /t/:identifier
556
+ * Provides shareable alphanumeric URLs using pubKey (first 16 chars)
557
+ */
558
+ app.get('/t/:identifier', async (req, res) => {
559
+ try {
560
+ const { identifier } = req.params;
561
+
562
+ console.log(`šŸ”— Fetching Linkitylink by identifier: ${identifier}`);
563
+
564
+ // Look up full pubKey from identifier
565
+ let pubKey = null;
566
+ for (const [key, metadata] of bdoMetadataMap.entries()) {
567
+ if (key.startsWith(identifier)) {
568
+ pubKey = key;
569
+ break;
570
+ }
571
+ }
572
+
573
+ if (!pubKey) {
574
+ throw new Error('Tapestry not found. Identifier may have expired.');
575
+ }
576
+
577
+ // Get emojicode from metadata
578
+ const metadata = bdoMetadataMap.get(pubKey);
579
+ const emojicode = metadata.emojicode;
580
+
581
+ console.log(`šŸ”— Found emojicode: ${emojicode}`);
582
+
583
+ let links = [];
584
+ let userName = 'Anonymous';
585
+
586
+ try {
587
+ // Fetch BDO by emojicode (same as emojicode route)
588
+ const linkHubBDO = await bdoLib.getBDOByEmojicode(emojicode);
589
+
590
+ console.log('šŸ“¦ Linkitylink BDO fetched:', JSON.stringify(linkHubBDO).substring(0, 200));
591
+
592
+ // Extract links from BDO data
593
+ const bdoData = linkHubBDO.bdo || linkHubBDO;
594
+
595
+ if (bdoData.links && Array.isArray(bdoData.links)) {
596
+ links = bdoData.links;
597
+ console.log(`šŸ”— Found ${links.length} links in Linkitylink BDO`);
598
+ } else {
599
+ console.log('āš ļø No links array found in Linkitylink BDO');
600
+ }
601
+
602
+ // Get user name from BDO
603
+ userName = bdoData.title || bdoData.name || 'My Links';
604
+
605
+ } catch (error) {
606
+ console.error('āŒ Failed to fetch Linkitylink BDO:', error.message);
607
+ // Continue with empty links array
608
+ }
609
+
610
+ // If no links, show demo links
611
+ if (links.length === 0) {
612
+ links = getDemoLinks();
613
+ userName = 'Demo Links';
614
+ }
615
+
616
+ // Limit to 20 links
617
+ const displayLinks = links.slice(0, 20);
618
+
619
+ // Generate HTML page
620
+ const html = generateLinkitylinkPage(displayLinks, userName, false, null);
621
+
622
+ res.send(html);
623
+
624
+ } catch (error) {
625
+ console.error('āŒ Server error:', error);
626
+ res.status(500).send(`
627
+ <!DOCTYPE html>
628
+ <html>
629
+ <head>
630
+ <title>Linkitylink Error</title>
631
+ <meta charset="UTF-8">
632
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
633
+ <style>
634
+ body {
635
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
636
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
637
+ color: white;
638
+ display: flex;
639
+ align-items: center;
640
+ justify-content: center;
641
+ min-height: 100vh;
642
+ margin: 0;
643
+ padding: 20px;
644
+ }
645
+ .error {
646
+ background: rgba(255,255,255,0.1);
647
+ padding: 40px;
648
+ border-radius: 20px;
649
+ text-align: center;
650
+ }
651
+ </style>
652
+ </head>
653
+ <body>
654
+ <div class="error">
655
+ <h1>āš ļø Error</h1>
656
+ <p>${error.message}</p>
657
+ </div>
658
+ </body>
659
+ </html>
660
+ `);
661
+ }
662
+ });
663
+
664
+ /**
665
+ * Generate the main Linkitylink HTML page
666
+ */
667
+ function generateLinkitylinkPage(links, userName, authenticated, pubKey) {
668
+ const linkCount = links.length;
669
+ const svgTemplate = chooseSVGTemplate(linkCount);
670
+
671
+ return `<!DOCTYPE html>
672
+ <html lang="en">
673
+ <head>
674
+ <meta charset="UTF-8">
675
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
676
+ <title>${userName} - Linkitylink</title>
677
+ <style>
678
+ * {
679
+ margin: 0;
680
+ padding: 0;
681
+ box-sizing: border-box;
682
+ }
683
+
684
+ body {
685
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
686
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
687
+ min-height: 100vh;
688
+ padding: 40px 20px;
689
+ display: flex;
690
+ flex-direction: column;
691
+ align-items: center;
692
+ }
693
+
694
+ .header {
695
+ text-align: center;
696
+ margin-bottom: 40px;
697
+ }
698
+
699
+ .header h1 {
700
+ color: white;
701
+ font-size: 2.5rem;
702
+ margin-bottom: 10px;
703
+ text-shadow: 0 2px 10px rgba(0,0,0,0.2);
704
+ }
705
+
706
+ .header .badge {
707
+ display: inline-block;
708
+ background: rgba(255,255,255,0.2);
709
+ color: white;
710
+ padding: 5px 15px;
711
+ border-radius: 20px;
712
+ font-size: 0.9rem;
713
+ margin-top: 10px;
714
+ }
715
+
716
+ .links-container {
717
+ max-width: 600px;
718
+ width: 100%;
719
+ margin-bottom: 40px;
720
+ }
721
+
722
+ .link-card {
723
+ background: white;
724
+ border-radius: 12px;
725
+ padding: 20px;
726
+ margin-bottom: 15px;
727
+ box-shadow: 0 4px 20px rgba(0,0,0,0.1);
728
+ transition: transform 0.2s, box-shadow 0.2s;
729
+ cursor: pointer;
730
+ text-decoration: none;
731
+ display: block;
732
+ }
733
+
734
+ .link-card:hover {
735
+ transform: translateY(-2px);
736
+ box-shadow: 0 6px 30px rgba(0,0,0,0.15);
737
+ }
738
+
739
+ .link-card .title {
740
+ font-size: 1.2rem;
741
+ font-weight: 600;
742
+ color: #333;
743
+ margin-bottom: 5px;
744
+ }
745
+
746
+ .link-card .url {
747
+ font-size: 0.9rem;
748
+ color: #666;
749
+ word-break: break-all;
750
+ }
751
+
752
+ .svg-container {
753
+ max-width: 800px;
754
+ width: 100%;
755
+ margin-bottom: 40px;
756
+ background: white;
757
+ border-radius: 20px;
758
+ padding: 30px;
759
+ box-shadow: 0 10px 40px rgba(0,0,0,0.2);
760
+ }
761
+
762
+ .cta-container {
763
+ text-align: center;
764
+ background: rgba(255,255,255,0.1);
765
+ border-radius: 20px;
766
+ padding: 40px;
767
+ max-width: 600px;
768
+ width: 100%;
769
+ backdrop-filter: blur(10px);
770
+ }
771
+
772
+ .cta-container h2 {
773
+ color: white;
774
+ margin-bottom: 20px;
775
+ font-size: 1.8rem;
776
+ }
777
+
778
+ .cta-container p {
779
+ color: rgba(255,255,255,0.9);
780
+ margin-bottom: 25px;
781
+ font-size: 1.1rem;
782
+ line-height: 1.6;
783
+ }
784
+
785
+ .cta-button {
786
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
787
+ color: white;
788
+ padding: 15px 40px;
789
+ border-radius: 30px;
790
+ text-decoration: none;
791
+ font-weight: 600;
792
+ font-size: 1.1rem;
793
+ display: inline-block;
794
+ transition: transform 0.2s, box-shadow 0.2s;
795
+ box-shadow: 0 4px 20px rgba(16, 185, 129, 0.3);
796
+ }
797
+
798
+ .cta-button:hover {
799
+ transform: translateY(-2px);
800
+ box-shadow: 0 6px 30px rgba(16, 185, 129, 0.4);
801
+ }
802
+
803
+ .footer {
804
+ margin-top: 40px;
805
+ text-align: center;
806
+ color: rgba(255,255,255,0.7);
807
+ font-size: 0.9rem;
808
+ }
809
+ </style>
810
+ </head>
811
+ <body>
812
+ <div class="header">
813
+ <h1>${userName}</h1>
814
+ ${authenticated ? '<div class="badge">šŸ” Authenticated</div>' : '<div class="badge">šŸ‘ļø Demo Mode</div>'}
815
+ </div>
816
+
817
+ <div class="svg-container">
818
+ ${svgTemplate(links)}
819
+ </div>
820
+
821
+ <div class="cta-container">
822
+ <h2>✨ Weave Your Own Linkitylink</h2>
823
+ <p>Cast the Linkitylink enchantment to create your mystical link tapestry. Visit The Enchantment Emporium in The Advancement app.</p>
824
+ <a href="#purchase" class="cta-button" onclick="handlePurchase()">
825
+ Visit The Enchantment Emporium
826
+ </a>
827
+ <p style="font-size: 0.9rem; margin-top: 20px; opacity: 0.8;">
828
+ ✨ Privacy-first • šŸ” Cryptographically secure • šŸŽØ Mystically beautiful
829
+ </p>
830
+ </div>
831
+
832
+ <div class="footer">
833
+ <p>Woven by <strong>Planet Nine</strong></p>
834
+ <p style="margin-top: 5px;">The Enchantment Emporium • Linkitylink Tapestries</p>
835
+ </div>
836
+
837
+ <script>
838
+ function handlePurchase() {
839
+ // TODO: Implement Enchantment Emporium integration
840
+ alert('Visit The Enchantment Emporium in The Advancement app to cast the Linkitylink enchantment!');
841
+ console.log('Redirecting to Enchantment Emporium');
842
+
843
+ // Future implementation:
844
+ // 1. Deep link to The Advancement app
845
+ // 2. Open Enchantment Emporium
846
+ // 3. Show Linkitylink enchantment
847
+ // 4. Guide user through enchantment casting
848
+ }
849
+
850
+ // Make links clickable
851
+ document.querySelectorAll('.link-card').forEach(card => {
852
+ card.addEventListener('click', function(e) {
853
+ const url = this.dataset.url;
854
+ if (url) {
855
+ window.open(url, '_blank');
856
+ }
857
+ });
858
+ });
859
+ </script>
860
+ </body>
861
+ </html>`;
862
+ }
863
+
864
+ /**
865
+ * Get social media icon path for SVG
866
+ */
867
+ function getSocialIcon(type) {
868
+ const iconType = type.toUpperCase();
869
+ const icons = {
870
+ INSTAGRAM: 'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z',
871
+ TIKTOK: 'M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-5.2 1.74 2.89 2.89 0 012.31-4.64 2.93 2.93 0 01.88.13V9.4a6.84 6.84 0 00-1-.05A6.33 6.33 0 005 20.1a6.34 6.34 0 0010.86-4.43v-7a8.16 8.16 0 004.77 1.52v-3.4a4.85 4.85 0 01-1-.1z',
872
+ YOUTUBE: 'M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z',
873
+ TWITTER: 'M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z',
874
+ FACEBOOK: 'M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z',
875
+ LINKEDIN: 'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z',
876
+ GITHUB: 'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'
877
+ };
878
+ return icons[iconType] || icons.INSTAGRAM; // Default to Instagram if unknown
879
+ }
880
+
881
+ /**
882
+ * Generate SoMa (Social Media) section with icons
883
+ */
884
+ function generateSoMaSection(socialLinks, yPosition) {
885
+ if (!socialLinks || socialLinks.length === 0) return '';
886
+
887
+ const iconSize = 32;
888
+ const iconSpacing = 50;
889
+ const startX = 350 - ((socialLinks.length * iconSpacing) / 2);
890
+
891
+ const socialIcons = socialLinks.map((link, index) => {
892
+ const x = startX + (index * iconSpacing);
893
+ const iconPath = getSocialIcon(link.title);
894
+ const url = escapeXML(link.url || '#');
895
+
896
+ return `
897
+ <a href="${url}" target="_blank">
898
+ <g transform="translate(${x}, ${yPosition})">
899
+ <circle cx="16" cy="16" r="18" fill="rgba(167, 139, 250, 0.1)"
900
+ stroke="#a78bfa" stroke-width="1" opacity="0.6"/>
901
+ <path d="${iconPath}" fill="#a78bfa" opacity="0.8"
902
+ transform="scale(0.65) translate(4, 4)"
903
+ style="filter: drop-shadow(0 0 4px #a78bfa);"/>
904
+ </g>
905
+ </a>`;
906
+ }).join('\n');
907
+
908
+ return `
909
+ <text x="350" y="${yPosition - 15}" fill="#a78bfa" font-size="16" font-weight="bold"
910
+ text-anchor="middle" opacity="0.7"
911
+ style="filter: drop-shadow(0 0 6px #a78bfa);">
912
+ SoMa:
913
+ </text>
914
+ ${socialIcons}`;
915
+ }
916
+
917
+ /**
918
+ * Choose SVG template based on link count (regular links only)
919
+ */
920
+ function chooseSVGTemplate(linkCount) {
921
+ if (linkCount <= 6) {
922
+ return generateCompactSVG;
923
+ } else if (linkCount <= 13) {
924
+ return generateGridSVG;
925
+ } else {
926
+ return generateDenseSVG;
927
+ }
928
+ }
929
+
930
+ /**
931
+ * Template 1: Compact layout (1-6 links)
932
+ * Large cards, vertical stack - DARK MODE WITH GLOW
933
+ */
934
+ function generateCompactSVG(links) {
935
+ // Separate regular links from social links
936
+ const regularLinks = links.filter(link => !link.isSocial);
937
+ const socialLinks = links.filter(link => link.isSocial);
938
+
939
+ const baseLinkHeight = regularLinks.length * 110 + 60;
940
+ const somaHeight = socialLinks.length > 0 ? 100 : 0;
941
+ const height = Math.max(400, baseLinkHeight + somaHeight);
942
+
943
+ const linkElements = regularLinks.map((link, index) => {
944
+ const y = 60 + (index * 110);
945
+ const title = escapeXML(link.title || 'Untitled');
946
+ const url = escapeXML(link.url || '#');
947
+ const truncatedTitle = title.length > 30 ? title.substring(0, 30) + '...' : title;
948
+
949
+ // Magical glowing gradients
950
+ const gradients = [
951
+ ['#10b981', '#059669'], // Emerald glow
952
+ ['#3b82f6', '#2563eb'], // Sapphire glow
953
+ ['#8b5cf6', '#7c3aed'], // Amethyst glow
954
+ ['#ec4899', '#db2777'], // Ruby glow
955
+ ['#fbbf24', '#f59e0b'], // Topaz glow
956
+ ['#06b6d4', '#0891b2'] // Aquamarine glow
957
+ ];
958
+ const gradient = gradients[index % gradients.length];
959
+ const gradId = `grad${index}`;
960
+ const glowId = `glow${index}`;
961
+
962
+ return `
963
+ <defs>
964
+ <linearGradient id="${gradId}" x1="0%" y1="0%" x2="100%" y2="100%">
965
+ <stop offset="0%" style="stop-color:${gradient[0]};stop-opacity:1" />
966
+ <stop offset="100%" style="stop-color:${gradient[1]};stop-opacity:1" />
967
+ </linearGradient>
968
+ <filter id="${glowId}" x="-50%" y="-50%" width="200%" height="200%">
969
+ <feGaussianBlur stdDeviation="8" result="coloredBlur"/>
970
+ <feMerge>
971
+ <feMergeNode in="coloredBlur"/>
972
+ <feMergeNode in="SourceGraphic"/>
973
+ </feMerge>
974
+ </filter>
975
+ </defs>
976
+
977
+ <a href="${url}" target="_blank">
978
+ <g filter="url(#${glowId})">
979
+ <rect x="50" y="${y}" width="600" height="90" rx="15"
980
+ fill="url(#${gradId})" opacity="0.15"/>
981
+ <rect x="50" y="${y}" width="600" height="90" rx="15"
982
+ fill="none" stroke="url(#${gradId})" stroke-width="2" opacity="0.8"/>
983
+ </g>
984
+ <text x="90" y="${y + 40}" fill="${gradient[0]}" font-size="20" font-weight="bold"
985
+ style="filter: drop-shadow(0 0 8px ${gradient[0]});">${truncatedTitle}</text>
986
+ <text x="90" y="${y + 65}" fill="rgba(167, 139, 250, 0.7)" font-size="14">✨ Tap to open</text>
987
+ <text x="600" y="${y + 50}" fill="${gradient[0]}" font-size="30"
988
+ style="filter: drop-shadow(0 0 6px ${gradient[0]});">→</text>
989
+ </a>`;
990
+ }).join('\n');
991
+
992
+ return `
993
+ <svg width="700" height="${height}" viewBox="0 0 700 ${height}" xmlns="http://www.w3.org/2000/svg">
994
+ <defs>
995
+ <radialGradient id="bgGrad" cx="50%" cy="50%">
996
+ <stop offset="0%" style="stop-color:#1a0033;stop-opacity:1" />
997
+ <stop offset="100%" style="stop-color:#0a001a;stop-opacity:1" />
998
+ </radialGradient>
999
+ </defs>
1000
+
1001
+ <rect width="700" height="${height}" fill="url(#bgGrad)"/>
1002
+
1003
+ <!-- Magical particles -->
1004
+ <circle cx="100" cy="20" r="2" fill="#fbbf24" opacity="0.6">
1005
+ <animate attributeName="opacity" values="0.3;0.8;0.3" dur="3s" repeatCount="indefinite"/>
1006
+ </circle>
1007
+ <circle cx="600" cy="30" r="1.5" fill="#a78bfa" opacity="0.5">
1008
+ <animate attributeName="opacity" values="0.2;0.7;0.2" dur="4s" repeatCount="indefinite"/>
1009
+ </circle>
1010
+ <circle cx="350" cy="15" r="1" fill="#10b981" opacity="0.4">
1011
+ <animate attributeName="opacity" values="0.2;0.6;0.2" dur="5s" repeatCount="indefinite"/>
1012
+ </circle>
1013
+
1014
+ <text x="350" y="35" fill="#fbbf24" font-size="24" font-weight="bold" text-anchor="middle"
1015
+ style="filter: drop-shadow(0 0 10px #fbbf24);">
1016
+ ✨ My Links ✨
1017
+ </text>
1018
+
1019
+ ${linkElements}
1020
+
1021
+ <!-- Social Media Section (SoMa) -->
1022
+ ${socialLinks.length > 0 ? generateSoMaSection(socialLinks, baseLinkHeight + 50) : ''}
1023
+ </svg>`;
1024
+ }
1025
+
1026
+ /**
1027
+ * Template 2: Grid layout (7-13 links)
1028
+ * 2-column grid with medium cards - DARK MODE WITH GLOW
1029
+ */
1030
+ function generateGridSVG(links) {
1031
+ // Separate regular links from social links
1032
+ const regularLinks = links.filter(link => !link.isSocial);
1033
+ const socialLinks = links.filter(link => link.isSocial);
1034
+
1035
+ const rows = Math.ceil(regularLinks.length / 2);
1036
+ const baseLinkHeight = rows * 100 + 100;
1037
+ const somaHeight = socialLinks.length > 0 ? 100 : 0;
1038
+ const height = Math.max(400, baseLinkHeight + somaHeight);
1039
+
1040
+ const linkElements = regularLinks.map((link, index) => {
1041
+ const col = index % 2;
1042
+ const row = Math.floor(index / 2);
1043
+ const x = col === 0 ? 40 : 370;
1044
+ const y = 80 + (row * 100);
1045
+
1046
+ const title = escapeXML(link.title || 'Untitled');
1047
+ const url = escapeXML(link.url || '#');
1048
+ const truncatedTitle = title.length > 15 ? title.substring(0, 15) + '...' : title;
1049
+
1050
+ const gradients = [
1051
+ ['#10b981', '#059669'], // Emerald
1052
+ ['#3b82f6', '#2563eb'], // Sapphire
1053
+ ['#8b5cf6', '#7c3aed'], // Amethyst
1054
+ ['#ec4899', '#db2777'], // Ruby
1055
+ ['#fbbf24', '#f59e0b'], // Topaz
1056
+ ['#06b6d4', '#0891b2'] // Aquamarine
1057
+ ];
1058
+ const gradient = gradients[index % gradients.length];
1059
+ const gradId = `grad${index}`;
1060
+ const glowId = `glow${index}`;
1061
+
1062
+ return `
1063
+ <defs>
1064
+ <linearGradient id="${gradId}" x1="0%" y1="0%" x2="100%" y2="100%">
1065
+ <stop offset="0%" style="stop-color:${gradient[0]};stop-opacity:1" />
1066
+ <stop offset="100%" style="stop-color:${gradient[1]};stop-opacity:1" />
1067
+ </linearGradient>
1068
+ <filter id="${glowId}" x="-50%" y="-50%" width="200%" height="200%">
1069
+ <feGaussianBlur stdDeviation="6" result="coloredBlur"/>
1070
+ <feMerge>
1071
+ <feMergeNode in="coloredBlur"/>
1072
+ <feMergeNode in="SourceGraphic"/>
1073
+ </feMerge>
1074
+ </filter>
1075
+ </defs>
1076
+
1077
+ <a href="${url}" target="_blank">
1078
+ <g filter="url(#${glowId})">
1079
+ <rect x="${x}" y="${y}" width="290" height="80" rx="12"
1080
+ fill="url(#${gradId})" opacity="0.15"/>
1081
+ <rect x="${x}" y="${y}" width="290" height="80" rx="12"
1082
+ fill="none" stroke="url(#${gradId})" stroke-width="2" opacity="0.8"/>
1083
+ </g>
1084
+ <text x="${x + 20}" y="${y + 35}" fill="${gradient[0]}" font-size="16" font-weight="bold"
1085
+ style="filter: drop-shadow(0 0 6px ${gradient[0]});">${truncatedTitle}</text>
1086
+ <text x="${x + 20}" y="${y + 55}" fill="rgba(167, 139, 250, 0.7)" font-size="12">✨ Click</text>
1087
+ </a>`;
1088
+ }).join('\n');
1089
+
1090
+ return `
1091
+ <svg width="700" height="${height}" viewBox="0 0 700 ${height}" xmlns="http://www.w3.org/2000/svg">
1092
+ <defs>
1093
+ <radialGradient id="bgGrad" cx="50%" cy="50%">
1094
+ <stop offset="0%" style="stop-color:#1a0033;stop-opacity:1" />
1095
+ <stop offset="100%" style="stop-color:#0a001a;stop-opacity:1" />
1096
+ </radialGradient>
1097
+ </defs>
1098
+
1099
+ <rect width="700" height="${height}" fill="url(#bgGrad)"/>
1100
+
1101
+ <!-- Magical particles -->
1102
+ <circle cx="120" cy="25" r="2" fill="#fbbf24" opacity="0.6">
1103
+ <animate attributeName="opacity" values="0.3;0.8;0.3" dur="3s" repeatCount="indefinite"/>
1104
+ </circle>
1105
+ <circle cx="580" cy="35" r="1.5" fill="#a78bfa" opacity="0.5">
1106
+ <animate attributeName="opacity" values="0.2;0.7;0.2" dur="4s" repeatCount="indefinite"/>
1107
+ </circle>
1108
+ <circle cx="350" cy="20" r="1" fill="#10b981" opacity="0.4">
1109
+ <animate attributeName="opacity" values="0.2;0.6;0.2" dur="5s" repeatCount="indefinite"/>
1110
+ </circle>
1111
+ <circle cx="200" cy="30" r="1.5" fill="#ec4899" opacity="0.5">
1112
+ <animate attributeName="opacity" values="0.3;0.7;0.3" dur="3.5s" repeatCount="indefinite"/>
1113
+ </circle>
1114
+
1115
+ <text x="350" y="40" fill="#fbbf24" font-size="24" font-weight="bold" text-anchor="middle"
1116
+ style="filter: drop-shadow(0 0 10px #fbbf24);">
1117
+ ✨ My Links ✨
1118
+ </text>
1119
+
1120
+ ${linkElements}
1121
+
1122
+ <!-- Social Media Section (SoMa) -->
1123
+ ${socialLinks.length > 0 ? generateSoMaSection(socialLinks, baseLinkHeight + 20) : ''}
1124
+ </svg>`;
1125
+ }
1126
+
1127
+ /**
1128
+ * Template 3: Dense layout (14-20 links)
1129
+ * 3-column grid with compact cards - DARK MODE WITH GLOW
1130
+ */
1131
+ function generateDenseSVG(links) {
1132
+ // Separate regular links from social links
1133
+ const regularLinks = links.filter(link => !link.isSocial);
1134
+ const socialLinks = links.filter(link => link.isSocial);
1135
+
1136
+ const rows = Math.ceil(regularLinks.length / 3);
1137
+ const baseLinkHeight = rows * 80 + 100;
1138
+ const somaHeight = socialLinks.length > 0 ? 100 : 0;
1139
+ const height = Math.max(400, baseLinkHeight + somaHeight);
1140
+
1141
+ const linkElements = regularLinks.map((link, index) => {
1142
+ const col = index % 3;
1143
+ const row = Math.floor(index / 3);
1144
+ const x = 30 + (col * 220);
1145
+ const y = 80 + (row * 80);
1146
+
1147
+ const title = escapeXML(link.title || 'Untitled');
1148
+ const url = escapeXML(link.url || '#');
1149
+ const truncatedTitle = title.length > 12 ? title.substring(0, 12) + '...' : title;
1150
+
1151
+ const gradients = [
1152
+ ['#10b981', '#059669'], // Emerald
1153
+ ['#3b82f6', '#2563eb'], // Sapphire
1154
+ ['#8b5cf6', '#7c3aed'], // Amethyst
1155
+ ['#ec4899', '#db2777'], // Ruby
1156
+ ['#fbbf24', '#f59e0b'], // Topaz
1157
+ ['#06b6d4', '#0891b2'] // Aquamarine
1158
+ ];
1159
+ const gradient = gradients[index % gradients.length];
1160
+ const gradId = `grad${index}`;
1161
+ const glowId = `glow${index}`;
1162
+
1163
+ return `
1164
+ <defs>
1165
+ <linearGradient id="${gradId}" x1="0%" y1="0%" x2="100%" y2="100%">
1166
+ <stop offset="0%" style="stop-color:${gradient[0]};stop-opacity:1" />
1167
+ <stop offset="100%" style="stop-color:${gradient[1]};stop-opacity:1" />
1168
+ </linearGradient>
1169
+ <filter id="${glowId}" x="-50%" y="-50%" width="200%" height="200%">
1170
+ <feGaussianBlur stdDeviation="5" result="coloredBlur"/>
1171
+ <feMerge>
1172
+ <feMergeNode in="coloredBlur"/>
1173
+ <feMergeNode in="SourceGraphic"/>
1174
+ </feMerge>
1175
+ </filter>
1176
+ </defs>
1177
+
1178
+ <a href="${url}" target="_blank">
1179
+ <g filter="url(#${glowId})">
1180
+ <rect x="${x}" y="${y}" width="190" height="65" rx="10"
1181
+ fill="url(#${gradId})" opacity="0.15"/>
1182
+ <rect x="${x}" y="${y}" width="190" height="65" rx="10"
1183
+ fill="none" stroke="url(#${gradId})" stroke-width="2" opacity="0.8"/>
1184
+ </g>
1185
+ <text x="${x + 15}" y="${y + 30}" fill="${gradient[0]}" font-size="14" font-weight="bold"
1186
+ style="filter: drop-shadow(0 0 5px ${gradient[0]});">${truncatedTitle}</text>
1187
+ <text x="${x + 15}" y="${y + 48}" fill="rgba(167, 139, 250, 0.7)" font-size="11">✨</text>
1188
+ </a>`;
1189
+ }).join('\n');
1190
+
1191
+ return `
1192
+ <svg width="700" height="${height}" viewBox="0 0 700 ${height}" xmlns="http://www.w3.org/2000/svg">
1193
+ <defs>
1194
+ <radialGradient id="bgGrad" cx="50%" cy="50%">
1195
+ <stop offset="0%" style="stop-color:#1a0033;stop-opacity:1" />
1196
+ <stop offset="100%" style="stop-color:#0a001a;stop-opacity:1" />
1197
+ </radialGradient>
1198
+ </defs>
1199
+
1200
+ <rect width="700" height="${height}" fill="url(#bgGrad)"/>
1201
+
1202
+ <!-- Magical particles -->
1203
+ <circle cx="100" cy="25" r="2" fill="#fbbf24" opacity="0.6">
1204
+ <animate attributeName="opacity" values="0.3;0.8;0.3" dur="3s" repeatCount="indefinite"/>
1205
+ </circle>
1206
+ <circle cx="350" cy="20" r="1.5" fill="#a78bfa" opacity="0.5">
1207
+ <animate attributeName="opacity" values="0.2;0.7;0.2" dur="4s" repeatCount="indefinite"/>
1208
+ </circle>
1209
+ <circle cx="600" cy="30" r="1" fill="#10b981" opacity="0.4">
1210
+ <animate attributeName="opacity" values="0.2;0.6;0.2" dur="5s" repeatCount="indefinite"/>
1211
+ </circle>
1212
+ <circle cx="200" cy="35" r="1.5" fill="#ec4899" opacity="0.5">
1213
+ <animate attributeName="opacity" values="0.3;0.7;0.3" dur="3.5s" repeatCount="indefinite"/>
1214
+ </circle>
1215
+ <circle cx="500" cy="28" r="1" fill="#06b6d4" opacity="0.4">
1216
+ <animate attributeName="opacity" values="0.2;0.6;0.2" dur="4.5s" repeatCount="indefinite"/>
1217
+ </circle>
1218
+
1219
+ <text x="350" y="40" fill="#fbbf24" font-size="22" font-weight="bold" text-anchor="middle"
1220
+ style="filter: drop-shadow(0 0 10px #fbbf24);">
1221
+ ✨ My Links ✨
1222
+ </text>
1223
+
1224
+ ${linkElements}
1225
+
1226
+ <!-- Social Media Section (SoMa) -->
1227
+ ${socialLinks.length > 0 ? generateSoMaSection(socialLinks, baseLinkHeight + 10) : ''}
1228
+ </svg>`;
1229
+ }
1230
+
1231
+ /**
1232
+ * Get demo links for unauthenticated users
1233
+ */
1234
+ function getDemoLinks() {
1235
+ return [
1236
+ { title: 'GitHub', url: 'https://github.com/planet-nine-app' },
1237
+ { title: 'Planet Nine', url: 'https://planetnine.app' },
1238
+ { title: 'Documentation', url: 'https://docs.planetnine.app' },
1239
+ { title: 'Twitter', url: 'https://twitter.com/planetnine' },
1240
+ { title: 'Discord', url: 'https://discord.gg/planetnine' },
1241
+ { title: 'Blog', url: 'https://blog.planetnine.app' }
1242
+ ];
1243
+ }
1244
+
1245
+ /**
1246
+ * Escape XML special characters
1247
+ */
1248
+ function escapeXML(str) {
1249
+ return String(str)
1250
+ .replace(/&/g, '&amp;')
1251
+ .replace(/</g, '&lt;')
1252
+ .replace(/>/g, '&gt;')
1253
+ .replace(/"/g, '&quot;')
1254
+ .replace(/'/g, '&apos;');
1255
+ }
1256
+
1257
+ /**
1258
+ * Get or create user account
1259
+ * Returns: { uuid, pubKey, keys, carrierBag }
1260
+ */
1261
+ async function getOrCreateUser(req) {
1262
+ // Check if user already has account in session
1263
+ if (req.session.userUUID && req.session.userPubKey && req.session.userKeys && req.session.carrierBag) {
1264
+ console.log(`āœ… Existing user session: ${req.session.userUUID}`);
1265
+ return {
1266
+ uuid: req.session.userUUID,
1267
+ pubKey: req.session.userPubKey,
1268
+ keys: req.session.userKeys,
1269
+ carrierBag: req.session.carrierBag
1270
+ };
1271
+ }
1272
+
1273
+ // Create new user (session-based for now, Fount integration later)
1274
+ console.log('šŸ†• Creating new user session...');
1275
+
1276
+ // Generate sessionless keys for user
1277
+ let userKeys;
1278
+ const saveKeys = (keys) => { userKeys = keys; };
1279
+ const getKeys = () => userKeys;
1280
+
1281
+ const keys = await sessionless.generateKeys(saveKeys, getKeys);
1282
+ const pubKey = keys.pubKey;
1283
+
1284
+ console.log(`šŸ”‘ Generated user keys: ${pubKey.substring(0, 16)}...`);
1285
+
1286
+ // Generate a simple UUID for the user
1287
+ const userUUID = `user_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
1288
+
1289
+ // Initialize empty carrierBag
1290
+ const carrierBag = {
1291
+ linkitylink: [] // Will store tapestry references
1292
+ };
1293
+
1294
+ console.log(`āœ… User session created: ${userUUID}`);
1295
+
1296
+ // Store in session
1297
+ req.session.userUUID = userUUID;
1298
+ req.session.userPubKey = pubKey;
1299
+ req.session.userKeys = userKeys;
1300
+ req.session.carrierBag = carrierBag;
1301
+
1302
+ // Save session
1303
+ await new Promise((resolve, reject) => {
1304
+ req.session.save((err) => {
1305
+ if (err) reject(err);
1306
+ else resolve();
1307
+ });
1308
+ });
1309
+
1310
+ console.log(`āœ… User session saved`);
1311
+
1312
+ return { uuid: userUUID, pubKey, keys: userKeys, carrierBag };
1313
+ }
1314
+
1315
+ /**
1316
+ * Add tapestry reference to user's session carrierBag
1317
+ */
1318
+ async function addTapestryToUser(req, tapestryData) {
1319
+ try {
1320
+ console.log(`šŸ’¼ Adding tapestry to user session carrierBag...`);
1321
+
1322
+ // Get current carrierBag from session
1323
+ const carrierBag = req.session.carrierBag || { linkitylink: [] };
1324
+
1325
+ // Add tapestry reference
1326
+ if (!carrierBag.linkitylink) {
1327
+ carrierBag.linkitylink = [];
1328
+ }
1329
+
1330
+ carrierBag.linkitylink.unshift({ // Add to beginning
1331
+ bdoUUID: tapestryData.bdoUUID,
1332
+ emojicode: tapestryData.emojicode,
1333
+ pubKey: tapestryData.pubKey,
1334
+ title: tapestryData.title,
1335
+ linkCount: tapestryData.linkCount,
1336
+ createdAt: tapestryData.createdAt
1337
+ });
1338
+
1339
+ // Update session
1340
+ req.session.carrierBag = carrierBag;
1341
+
1342
+ // Save session
1343
+ await new Promise((resolve, reject) => {
1344
+ req.session.save((err) => {
1345
+ if (err) reject(err);
1346
+ else resolve();
1347
+ });
1348
+ });
1349
+
1350
+ console.log(`āœ… Tapestry added to carrierBag (${carrierBag.linkitylink.length} total)`);
1351
+ return { success: true };
1352
+
1353
+ } catch (error) {
1354
+ console.error('āŒ Failed to add tapestry to carrierBag:', error);
1355
+ return { success: false, error: error.message };
1356
+ }
1357
+ }
1358
+
1359
+ /**
1360
+ * GET /create - Serve create page
1361
+ */
1362
+ app.get('/create', async (req, res) => {
1363
+ const fs = await import('fs/promises');
1364
+ let createPage = await fs.readFile(join(__dirname, 'public', 'create.html'), 'utf-8');
1365
+
1366
+ // Inject app purchase configuration
1367
+ const configScript = `
1368
+ <script>
1369
+ window.LINKITYLINK_CONFIG = {
1370
+ enableAppPurchase: ${ENABLE_APP_PURCHASE}
1371
+ };
1372
+ </script>
1373
+ `;
1374
+
1375
+ // Insert config script before closing </head> tag
1376
+ createPage = createPage.replace('</head>', `${configScript}</head>`);
1377
+
1378
+ res.send(createPage);
1379
+ });
1380
+
1381
+ /**
1382
+ * POST /create - Create Linkitylink BDO
1383
+ *
1384
+ * Flow:
1385
+ * 1. Get or create user account (Fount user with session)
1386
+ * 2. Receive raw BDO data with links from client
1387
+ * 3. Generate composite SVG based on link count
1388
+ * 4. Add svgContent to BDO
1389
+ * 5. Create tapestry BDO in BDO service
1390
+ * 6. Add tapestry reference to user's carrierBag
1391
+ * 7. Return emojicode to client
1392
+ *
1393
+ * Body:
1394
+ * {
1395
+ * "title": "My Links",
1396
+ * "links": [{"title": "...", "url": "..."}, ...],
1397
+ * "source": "linktree" | "manual" (optional),
1398
+ * "sourceUrl": "https://..." (optional),
1399
+ * "style": "stunning" | "dazzling" | ... (optional),
1400
+ * "template": "Sunset" | "Ocean" | ... (optional)
1401
+ * }
1402
+ */
1403
+ app.post('/create', async (req, res) => {
1404
+ try {
1405
+ console.log('šŸŽØ Creating Linkitylink BDO...');
1406
+
1407
+ // Get or create user account
1408
+ const user = await getOrCreateUser(req);
1409
+
1410
+ const { title, links, source, sourceUrl, style, template } = req.body;
1411
+
1412
+ // Validate input
1413
+ if (!links || !Array.isArray(links) || links.length === 0) {
1414
+ return res.status(400).json({
1415
+ error: 'Missing or invalid links array'
1416
+ });
1417
+ }
1418
+
1419
+ console.log(`šŸ“Š Received ${links.length} links`);
1420
+ console.log(`šŸ“ Title: ${title || 'My Links'}`);
1421
+
1422
+ // Generate composite SVG
1423
+ const linkCount = links.length;
1424
+ const svgTemplate = chooseSVGTemplate(linkCount);
1425
+ const svgContent = svgTemplate(links);
1426
+
1427
+ console.log(`āœ… Generated SVG (${svgContent.length} characters)`);
1428
+
1429
+ // Build complete BDO with svgContent
1430
+ const linkitylinkBDO = {
1431
+ title: title || 'My Links',
1432
+ type: 'linkitylink',
1433
+ svgContent: svgContent, // Added by Linkitylink!
1434
+ links: links,
1435
+ createdAt: new Date().toISOString()
1436
+ };
1437
+
1438
+ // Add optional metadata
1439
+ if (source) linkitylinkBDO.source = source;
1440
+ if (sourceUrl) linkitylinkBDO.sourceUrl = sourceUrl;
1441
+
1442
+ // Generate temporary keys for BDO
1443
+ const saveKeys = (keys) => { tempKeys = keys; };
1444
+ const getKeys = () => tempKeys;
1445
+ let tempKeys = null;
1446
+
1447
+ const keys = await sessionless.generateKeys(saveKeys, getKeys);
1448
+ const pubKey = keys.pubKey;
1449
+
1450
+ console.log(`šŸ”‘ Generated keys: ${pubKey.substring(0, 16)}...`);
1451
+
1452
+ // Create BDO via bdo-js (handles signing automatically)
1453
+ const hash = 'Linkitylink';
1454
+ console.log(`🌐 Creating BDO with hash: ${hash}`);
1455
+
1456
+ const bdoUUID = await bdoLib.createUser(hash, linkitylinkBDO, saveKeys, getKeys);
1457
+ console.log(`āœ… BDO created: ${bdoUUID}`);
1458
+
1459
+ // Make BDO public to get emojicode (using bdo-js)
1460
+ console.log(`šŸŒ Making BDO public...`);
1461
+ const updatedBDO = await bdoLib.updateBDO(bdoUUID, hash, linkitylinkBDO, true);
1462
+ const emojicode = updatedBDO.emojiShortcode;
1463
+
1464
+ console.log(`āœ… Emojicode generated: ${emojicode}`);
1465
+
1466
+ // Store pubKey metadata for alphanumeric URL lookup
1467
+ bdoMetadataMap.set(pubKey, {
1468
+ uuid: bdoUUID,
1469
+ emojicode: emojicode,
1470
+ createdAt: new Date()
1471
+ });
1472
+ markMappingsDirty();
1473
+
1474
+ // Add tapestry to user's carrierBag
1475
+ await addTapestryToUser(req, {
1476
+ bdoUUID: bdoUUID,
1477
+ emojicode: emojicode,
1478
+ pubKey: pubKey,
1479
+ title: title || 'My Tapestry',
1480
+ linkCount: links.length,
1481
+ createdAt: new Date().toISOString()
1482
+ });
1483
+
1484
+ // Increment sales counter
1485
+ await incrementSales();
1486
+
1487
+ // Return identifiers only - let client construct URLs
1488
+ res.json({
1489
+ success: true,
1490
+ uuid: bdoUUID,
1491
+ pubKey: pubKey,
1492
+ emojicode: emojicode,
1493
+ userUUID: user.uuid // Include user UUID for reference
1494
+ });
1495
+
1496
+ } catch (error) {
1497
+ console.error('āŒ Error creating Linkitylink:', error);
1498
+ res.status(500).json({
1499
+ error: error.message
1500
+ });
1501
+ }
1502
+ });
1503
+
1504
+ /**
1505
+ * GET /stats - Get server statistics
1506
+ *
1507
+ * Returns total sales and other stats
1508
+ */
1509
+ app.get('/stats', async (req, res) => {
1510
+ try {
1511
+ const stats = await loadStats();
1512
+ res.json({
1513
+ success: true,
1514
+ totalSales: stats.totalSales,
1515
+ createdAt: stats.createdAt,
1516
+ lastUpdated: stats.lastUpdated
1517
+ });
1518
+ } catch (error) {
1519
+ console.error('āŒ Error loading stats:', error);
1520
+ res.status(500).json({
1521
+ success: false,
1522
+ error: 'Failed to load statistics'
1523
+ });
1524
+ }
1525
+ });
1526
+
1527
+ /**
1528
+ * GET /my-tapestries - Get user's tapestries
1529
+ *
1530
+ * Returns all tapestries created by the current user from session
1531
+ */
1532
+ app.get('/my-tapestries', async (req, res) => {
1533
+ try {
1534
+ // Check if user has session
1535
+ if (!req.session.userUUID) {
1536
+ return res.json({
1537
+ success: true,
1538
+ tapestries: [],
1539
+ message: 'No user session found'
1540
+ });
1541
+ }
1542
+
1543
+ console.log(`šŸ“‹ Fetching tapestries for user ${req.session.userUUID}`);
1544
+
1545
+ // Get carrierBag from session
1546
+ const carrierBag = req.session.carrierBag || {};
1547
+ const tapestries = carrierBag.linkitylink || [];
1548
+
1549
+ console.log(`āœ… Found ${tapestries.length} tapestries`);
1550
+
1551
+ res.json({
1552
+ success: true,
1553
+ tapestries: tapestries,
1554
+ userUUID: req.session.userUUID
1555
+ });
1556
+
1557
+ } catch (error) {
1558
+ console.error('āŒ Error fetching tapestries:', error);
1559
+ res.status(500).json({
1560
+ success: false,
1561
+ error: error.message
1562
+ });
1563
+ }
1564
+ });
1565
+
1566
+ /**
1567
+ * POST /parse-linktree - Parse links from a Linktree URL using lightweight HTTP fetch
1568
+ *
1569
+ * Linktree embeds all data in __NEXT_DATA__ script tag in initial HTML response
1570
+ * No browser needed - just fetch HTML and parse the JSON
1571
+ * Returns just the links array without creating any BDOs
1572
+ */
1573
+ app.post('/parse-linktree', async (req, res) => {
1574
+ try {
1575
+ const { url } = req.body;
1576
+
1577
+ console.log(`🌐 Parsing Linktree URL: ${url}`);
1578
+
1579
+ // Validate URL
1580
+ if (!url || !url.includes('linktr.ee')) {
1581
+ return res.status(400).json({
1582
+ success: false,
1583
+ error: 'Invalid Linktree URL. Please enter a linktr.ee URL.'
1584
+ });
1585
+ }
1586
+
1587
+ console.log('šŸ“„ Fetching Linktree page...');
1588
+
1589
+ // Fetch HTML with realistic User-Agent
1590
+ const response = await fetch(url, {
1591
+ headers: {
1592
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
1593
+ }
1594
+ });
1595
+
1596
+ if (!response.ok) {
1597
+ return res.status(400).json({
1598
+ success: false,
1599
+ error: `Failed to fetch Linktree page: ${response.statusText}`
1600
+ });
1601
+ }
1602
+
1603
+ const html = await response.text();
1604
+
1605
+ console.log('šŸ” Extracting __NEXT_DATA__ from HTML...');
1606
+
1607
+ // Parse __NEXT_DATA__ from HTML (Linktree embeds data in script tag)
1608
+ const nextDataMatch = html.match(/<script id="__NEXT_DATA__"[^>]*>(.*?)<\/script>/s);
1609
+
1610
+ if (!nextDataMatch) {
1611
+ return res.status(400).json({
1612
+ success: false,
1613
+ error: 'Could not find __NEXT_DATA__ in Linktree page. The page structure may have changed.'
1614
+ });
1615
+ }
1616
+
1617
+ const nextData = JSON.parse(nextDataMatch[1]);
1618
+ const pageProps = nextData.props?.pageProps?.account;
1619
+
1620
+ if (!pageProps || !pageProps.links) {
1621
+ return res.status(400).json({
1622
+ success: false,
1623
+ error: 'No links found on this Linktree page.'
1624
+ });
1625
+ }
1626
+
1627
+ // Extract regular links
1628
+ const links = pageProps.links.map(link => ({
1629
+ title: link.title,
1630
+ url: link.url
1631
+ }));
1632
+
1633
+ // Extract social links (Instagram, TikTok, YouTube, etc.)
1634
+ const socialLinks = (pageProps.socialLinks || []).map(social => ({
1635
+ title: social.type.charAt(0) + social.type.slice(1).toLowerCase(), // Capitalize type
1636
+ url: social.url,
1637
+ isSocial: true // Mark as social link
1638
+ }));
1639
+
1640
+ const username = pageProps.username || 'Unknown';
1641
+
1642
+ console.log(`āœ… Extracted ${links.length} links + ${socialLinks.length} social links from @${username}'s Linktree`);
1643
+
1644
+ res.json({
1645
+ success: true,
1646
+ links: [...links, ...socialLinks], // Combine regular and social links
1647
+ username: username,
1648
+ source: 'linktree'
1649
+ });
1650
+
1651
+ } catch (error) {
1652
+ console.error('āŒ Error parsing Linktree:', error);
1653
+
1654
+ // Make sure browser is closed
1655
+ if (browser) {
1656
+ try {
1657
+ await browser.close();
1658
+ } catch (closeError) {
1659
+ console.error('Error closing browser:', closeError);
1660
+ }
1661
+ }
1662
+
1663
+ res.status(500).json({
1664
+ success: false,
1665
+ error: 'Failed to parse Linktree page. Please try again.'
1666
+ });
1667
+ }
1668
+ });
1669
+
1670
+ /**
1671
+ * POST /create-payment-intent - Create Stripe payment intent via Addie
1672
+ *
1673
+ * Creates a $20 payment intent for Linkitylink tapestry purchase.
1674
+ * Fetches relevantBDOs and extracts their payees for payment splits.
1675
+ */
1676
+ app.post('/create-payment-intent', async (req, res) => {
1677
+ try {
1678
+ console.log('šŸ’³ Creating payment intent via Addie...');
1679
+
1680
+ // Get relevantBDOs using middleware helper (handles body + session)
1681
+ const relevantBDOs = getRelevantBDOs(req);
1682
+ logRelevantBDOs(relevantBDOs, 'šŸ“¦ relevantBDOs for payment');
1683
+
1684
+ // Fetch BDOs and extract payees
1685
+ const payees = await fetchAndExtractPayees(relevantBDOs);
1686
+ logPayees(payees, 'šŸ’° Payees from relevantBDOs');
1687
+
1688
+ // Get or create user session
1689
+ const user = await getOrCreateUser(req);
1690
+
1691
+ // Set up saveKeys/getKeys for addie-js (same pattern as bdo-js)
1692
+ const saveKeys = (keys) => { user.keys = keys; };
1693
+ const getKeys = () => user.keys;
1694
+
1695
+ // Create/get Addie user if needed
1696
+ if (!user.addieUUID) {
1697
+ console.log('šŸ“ Creating Addie user...');
1698
+
1699
+ // Create Addie user via addie-js SDK
1700
+ const addieUUID = await addieLib.createUser(saveKeys, getKeys);
1701
+ user.addieUUID = addieUUID;
1702
+
1703
+ // Save to session
1704
+ req.session.addieUUID = addieUUID;
1705
+ await new Promise((resolve, reject) => {
1706
+ req.session.save((err) => err ? reject(err) : resolve());
1707
+ });
1708
+
1709
+ console.log(`āœ… Addie user created: ${user.addieUUID}`);
1710
+ }
1711
+
1712
+ // Create payment intent via addie-js SDK
1713
+ const amount = 2000; // $20.00
1714
+ const currency = 'usd';
1715
+
1716
+ console.log(`šŸ’° Creating payment intent for $${amount/100}...`);
1717
+
1718
+ // Set up sessionless.getKeys for addie-js to use for signing
1719
+ sessionless.getKeys = getKeys;
1720
+
1721
+ // Convert relevantBDOs to Stripe metadata format (for record keeping)
1722
+ const stripeMetadata = toStripeMetadata(relevantBDOs);
1723
+
1724
+ let intentData;
1725
+
1726
+ // Use getPaymentIntent with payees if we have any, otherwise use without splits
1727
+ if (payees.length > 0) {
1728
+ console.log(`šŸ’° Creating payment intent WITH ${payees.length} payees...`);
1729
+ intentData = await addieLib.getPaymentIntent(
1730
+ user.addieUUID,
1731
+ 'stripe',
1732
+ amount,
1733
+ currency,
1734
+ payees
1735
+ );
1736
+ } else {
1737
+ console.log('šŸ’° Creating payment intent WITHOUT payees...');
1738
+ intentData = await addieLib.getPaymentIntentWithoutSplits(
1739
+ user.addieUUID,
1740
+ 'stripe',
1741
+ amount,
1742
+ currency
1743
+ );
1744
+ }
1745
+
1746
+ console.log(`āœ… Payment intent created`);
1747
+ if (Object.keys(stripeMetadata).length > 0) {
1748
+ console.log('šŸ“¦ Stripe metadata prepared:', Object.keys(stripeMetadata).length, 'keys');
1749
+ }
1750
+
1751
+ res.json({
1752
+ success: true,
1753
+ clientSecret: intentData.paymentIntent, // This is the client_secret
1754
+ publishableKey: intentData.publishableKey,
1755
+ customer: intentData.customer,
1756
+ ephemeralKey: intentData.ephemeralKey,
1757
+ payeesIncluded: payees.length // Let client know how many payees were included
1758
+ });
1759
+
1760
+ } catch (error) {
1761
+ console.error('āŒ Error creating payment intent:', error);
1762
+ res.status(500).json({
1763
+ success: false,
1764
+ error: error.message
1765
+ });
1766
+ }
1767
+ });
1768
+
1769
+ /**
1770
+ * POST /magic/spell/:spellName - MAGIC Protocol Endpoint
1771
+ *
1772
+ * Handles spell casting for Linkitylink creation with integrated payment processing.
1773
+ *
1774
+ * Available Spells:
1775
+ * - linkitylink: Create tapestry from carrierBag links
1776
+ * - glyphtree: Create tapestry from Linktree URL
1777
+ */
1778
+ app.post('/magic/spell/:spellName', async (req, res) => {
1779
+ try {
1780
+ const { spellName } = req.params;
1781
+ const { caster, payload } = req.body;
1782
+
1783
+ console.log(`✨ MAGIC: Casting spell "${spellName}"`);
1784
+
1785
+ // Validate caster authentication
1786
+ if (!caster || !caster.pubKey || !caster.timestamp || !caster.signature) {
1787
+ return res.status(403).json({
1788
+ success: false,
1789
+ error: 'Missing caster authentication'
1790
+ });
1791
+ }
1792
+
1793
+ // Verify caster signature (timestamp + pubKey)
1794
+ const message = caster.timestamp + caster.pubKey;
1795
+ const isValid = sessionless.verifySignature(caster.signature, message, caster.pubKey);
1796
+
1797
+ if (!isValid) {
1798
+ return res.status(403).json({
1799
+ success: false,
1800
+ error: 'Invalid caster signature'
1801
+ });
1802
+ }
1803
+
1804
+ // Route to spell resolver
1805
+ let result;
1806
+ if (spellName === 'linkitylink') {
1807
+ result = await resolveLinkitylinkSpell(caster, payload);
1808
+ } else if (spellName === 'glyphtree') {
1809
+ result = await resolveGlyphtreeSpell(caster, payload);
1810
+ } else if (spellName === 'submitLinkitylinkTemplate') {
1811
+ result = await resolveSubmitTemplateSpell(caster, payload);
1812
+ } else {
1813
+ return res.status(404).json({
1814
+ success: false,
1815
+ error: `Unknown spell: ${spellName}`
1816
+ });
1817
+ }
1818
+
1819
+ res.json(result);
1820
+
1821
+ } catch (error) {
1822
+ console.error('āŒ MAGIC spell error:', error);
1823
+ res.status(500).json({
1824
+ success: false,
1825
+ error: error.message
1826
+ });
1827
+ }
1828
+ });
1829
+
1830
+ /**
1831
+ * Resolve linkitylink spell
1832
+ * Creates tapestry from carrierBag links with payment processing
1833
+ */
1834
+ async function resolveLinkitylinkSpell(caster, payload) {
1835
+ console.log('šŸŽØ Resolving linkitylink spell...');
1836
+
1837
+ const { paymentMethod, links, title } = payload;
1838
+
1839
+ // Validate required spell components
1840
+ if (!links || !Array.isArray(links) || links.length === 0) {
1841
+ return { success: false, error: 'Missing or invalid links array' };
1842
+ }
1843
+
1844
+ if (!paymentMethod || (paymentMethod !== 'mp' && paymentMethod !== 'money')) {
1845
+ return { success: false, error: 'Invalid payment method (must be mp or money)' };
1846
+ }
1847
+
1848
+ // Process payment
1849
+ const paymentResult = await processSpellPayment(caster, paymentMethod, 100); // $1.00
1850
+ if (!paymentResult.success) {
1851
+ return paymentResult;
1852
+ }
1853
+
1854
+ // Generate SVG using existing template logic
1855
+ const linkCount = links.length;
1856
+ const svgTemplate = chooseSVGTemplate(linkCount);
1857
+ const svgContent = svgTemplate(links);
1858
+
1859
+ console.log(`āœ… Generated SVG (${svgContent.length} characters)`);
1860
+
1861
+ // Build complete BDO with svgContent
1862
+ const linkitylinkBDO = {
1863
+ title: title || 'My Linkitylink',
1864
+ type: 'linkitylink',
1865
+ svgContent: svgContent,
1866
+ links: links,
1867
+ source: 'emporium-spell',
1868
+ createdAt: new Date().toISOString()
1869
+ };
1870
+
1871
+ // Generate temporary keys for BDO
1872
+ const saveKeys = (keys) => { tempKeys = keys; };
1873
+ const getKeys = () => tempKeys;
1874
+ let tempKeys = null;
1875
+
1876
+ const keys = await sessionless.generateKeys(saveKeys, getKeys);
1877
+ const pubKey = keys.pubKey;
1878
+
1879
+ console.log(`šŸ”‘ Generated BDO keys: ${pubKey.substring(0, 16)}...`);
1880
+
1881
+ // Create BDO via bdo-js (handles signing automatically)
1882
+ const hash = 'Linkitylink';
1883
+ console.log(`🌐 Creating BDO with hash: ${hash}`);
1884
+
1885
+ const bdoUUID = await bdoLib.createUser(hash, linkitylinkBDO, saveKeys, getKeys);
1886
+ console.log(`āœ… BDO created: ${bdoUUID}`);
1887
+
1888
+ // Make BDO public to get emojicode
1889
+ console.log(`šŸŒ Making BDO public...`);
1890
+ const updatedBDO = await bdoLib.updateBDO(bdoUUID, hash, linkitylinkBDO, true);
1891
+ const emojicode = updatedBDO.emojiShortcode;
1892
+
1893
+ console.log(`āœ… Emojicode generated: ${emojicode}`);
1894
+
1895
+ // Save to carrierBag "store" collection
1896
+ const carrierBagResult = await saveToCarrierBag(caster.pubKey, 'store', {
1897
+ title: linkitylinkBDO.title,
1898
+ type: 'linkitylink',
1899
+ emojicode: emojicode,
1900
+ bdoPubKey: pubKey,
1901
+ createdAt: linkitylinkBDO.createdAt
1902
+ });
1903
+
1904
+ if (!carrierBagResult.success) {
1905
+ console.warn('āš ļø Failed to save to carrierBag, but spell succeeded');
1906
+ }
1907
+
1908
+ // Store pubKey metadata for alphanumeric URL lookup
1909
+ bdoMetadataMap.set(pubKey, {
1910
+ uuid: bdoUUID,
1911
+ emojicode: emojicode,
1912
+ createdAt: new Date()
1913
+ });
1914
+ markMappingsDirty();
1915
+
1916
+ // Return identifiers only - let client construct URLs
1917
+ return {
1918
+ success: true,
1919
+ uuid: bdoUUID,
1920
+ pubKey: pubKey,
1921
+ emojicode: emojicode,
1922
+ payment: paymentResult.payment
1923
+ };
1924
+ }
1925
+
1926
+ /**
1927
+ * Resolve glyphtree spell
1928
+ * Creates tapestry from Linktree URL with payment processing
1929
+ */
1930
+ async function resolveGlyphtreeSpell(caster, payload) {
1931
+ console.log('🌳 Resolving glyphtree spell...');
1932
+
1933
+ const { paymentMethod, linktreeUrl } = payload;
1934
+
1935
+ // Validate required spell components
1936
+ if (!linktreeUrl || !linktreeUrl.includes('linktr.ee')) {
1937
+ return { success: false, error: 'Invalid Linktree URL' };
1938
+ }
1939
+
1940
+ if (!paymentMethod || (paymentMethod !== 'mp' && paymentMethod !== 'money')) {
1941
+ return { success: false, error: 'Invalid payment method (must be mp or money)' };
1942
+ }
1943
+
1944
+ // Fetch and parse Linktree page
1945
+ console.log(`🌐 Fetching Linktree page: ${linktreeUrl}`);
1946
+
1947
+ const response = await fetch(linktreeUrl, {
1948
+ headers: {
1949
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
1950
+ }
1951
+ });
1952
+
1953
+ if (!response.ok) {
1954
+ return { success: false, error: `Failed to fetch Linktree page: ${response.statusText}` };
1955
+ }
1956
+
1957
+ const html = await response.text();
1958
+
1959
+ // Extract __NEXT_DATA__ from page
1960
+ const nextDataMatch = html.match(/<script id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/);
1961
+ if (!nextDataMatch) {
1962
+ return { success: false, error: 'Could not find __NEXT_DATA__ in Linktree page' };
1963
+ }
1964
+
1965
+ const nextData = JSON.parse(nextDataMatch[1]);
1966
+ const pageProps = nextData.props?.pageProps?.account;
1967
+
1968
+ if (!pageProps || !pageProps.links) {
1969
+ return { success: false, error: 'Invalid Linktree page structure' };
1970
+ }
1971
+
1972
+ // Extract regular links
1973
+ const links = pageProps.links.map(link => ({
1974
+ title: link.title,
1975
+ url: link.url
1976
+ }));
1977
+
1978
+ // Extract social links (Instagram, TikTok, YouTube, etc.)
1979
+ const socialLinks = (pageProps.socialLinks || []).map(social => ({
1980
+ title: social.type.charAt(0) + social.type.slice(1).toLowerCase(),
1981
+ url: social.url,
1982
+ isSocial: true
1983
+ }));
1984
+
1985
+ // Combine all links
1986
+ const allLinks = [...links, ...socialLinks];
1987
+
1988
+ const title = `${pageProps.username}'s Links` || 'Linktree Import';
1989
+
1990
+ console.log(`āœ… Extracted ${links.length} links + ${socialLinks.length} social links from Linktree`);
1991
+
1992
+ // Process payment
1993
+ const paymentResult = await processSpellPayment(caster, paymentMethod, 100); // $1.00
1994
+ if (!paymentResult.success) {
1995
+ return paymentResult;
1996
+ }
1997
+
1998
+ // Generate SVG using existing template logic
1999
+ const linkCount = allLinks.length;
2000
+ const svgTemplate = chooseSVGTemplate(linkCount);
2001
+ const svgContent = svgTemplate(allLinks);
2002
+
2003
+ console.log(`āœ… Generated SVG (${svgContent.length} characters)`);
2004
+
2005
+ // Build complete BDO with svgContent
2006
+ const linkitylinkBDO = {
2007
+ title: title,
2008
+ type: 'linkitylink',
2009
+ svgContent: svgContent,
2010
+ links: allLinks, // Include both regular and social links
2011
+ source: 'linktree',
2012
+ sourceUrl: linktreeUrl,
2013
+ createdAt: new Date().toISOString()
2014
+ };
2015
+
2016
+ // Generate temporary keys for BDO
2017
+ const saveKeys = (keys) => { tempKeys = keys; };
2018
+ const getKeys = () => tempKeys;
2019
+ let tempKeys = null;
2020
+
2021
+ const keys = await sessionless.generateKeys(saveKeys, getKeys);
2022
+ const pubKey = keys.pubKey;
2023
+
2024
+ console.log(`šŸ”‘ Generated BDO keys: ${pubKey.substring(0, 16)}...`);
2025
+
2026
+ // Create BDO via bdo-js (handles signing automatically)
2027
+ const hash = 'Linkitylink';
2028
+ console.log(`🌐 Creating BDO with hash: ${hash}`);
2029
+
2030
+ const bdoUUID = await bdoLib.createUser(hash, linkitylinkBDO, saveKeys, getKeys);
2031
+ console.log(`āœ… BDO created: ${bdoUUID}`);
2032
+
2033
+ // Make BDO public to get emojicode
2034
+ console.log(`šŸŒ Making BDO public...`);
2035
+ const updatedBDO = await bdoLib.updateBDO(bdoUUID, hash, linkitylinkBDO, true);
2036
+ const emojicode = updatedBDO.emojiShortcode;
2037
+
2038
+ console.log(`āœ… Emojicode generated: ${emojicode}`);
2039
+
2040
+ // Save to carrierBag "store" collection
2041
+ const carrierBagResult = await saveToCarrierBag(caster.pubKey, 'store', {
2042
+ title: linkitylinkBDO.title,
2043
+ type: 'linkitylink',
2044
+ emojicode: emojicode,
2045
+ bdoPubKey: pubKey,
2046
+ sourceUrl: linktreeUrl,
2047
+ createdAt: linkitylinkBDO.createdAt
2048
+ });
2049
+
2050
+ if (!carrierBagResult.success) {
2051
+ console.warn('āš ļø Failed to save to carrierBag, but spell succeeded');
2052
+ }
2053
+
2054
+ // Store pubKey metadata for alphanumeric URL lookup
2055
+ bdoMetadataMap.set(pubKey, {
2056
+ uuid: bdoUUID,
2057
+ emojicode: emojicode,
2058
+ createdAt: new Date()
2059
+ });
2060
+ markMappingsDirty();
2061
+
2062
+ // Return identifiers only - let client construct URLs
2063
+ return {
2064
+ success: true,
2065
+ uuid: bdoUUID,
2066
+ pubKey: pubKey,
2067
+ emojicode: emojicode,
2068
+ linkCount: links.length,
2069
+ payment: paymentResult.payment
2070
+ };
2071
+ }
2072
+
2073
+ /**
2074
+ * Process payment for spell casting
2075
+ *
2076
+ * Note: Word of power validation happens CLIENT-SIDE using SHA256 hash comparison.
2077
+ * This function does not validate or require word of power - that check is done
2078
+ * in the browser before the spell is cast.
2079
+ */
2080
+ async function processSpellPayment(caster, paymentMethod, amountCents) {
2081
+ console.log(`šŸ’° Processing ${paymentMethod} payment...`);
2082
+
2083
+ if (paymentMethod === 'mp') {
2084
+ // MP payment through Fount
2085
+ // TODO: Call Fount /resolve with deductMP spell
2086
+ // For now, return simulated success
2087
+ return {
2088
+ success: true,
2089
+ payment: {
2090
+ method: 'mp',
2091
+ amount: amountCents / 100,
2092
+ message: 'MP payment simulated (TODO: integrate with Fount)'
2093
+ }
2094
+ };
2095
+
2096
+ } else if (paymentMethod === 'money') {
2097
+ // Money payment through Addie
2098
+ // TODO: Call Addie /charge-with-saved-method
2099
+ // For now, return simulated success
2100
+ return {
2101
+ success: true,
2102
+ payment: {
2103
+ method: 'money',
2104
+ amount: amountCents / 100,
2105
+ message: 'Money payment simulated (TODO: integrate with Addie)'
2106
+ }
2107
+ };
2108
+
2109
+ } else {
2110
+ return {
2111
+ success: false,
2112
+ error: 'Unknown payment method'
2113
+ };
2114
+ }
2115
+ }
2116
+
2117
+ /**
2118
+ * Save item to user's carrierBag collection
2119
+ */
2120
+ async function saveToCarrierBag(userPubKey, collection, item) {
2121
+ console.log(`šŸ’¼ Saving to carrierBag collection: ${collection}`);
2122
+
2123
+ try {
2124
+ // Fetch user's Fount BDO (which contains carrierBag)
2125
+ const userBDO = await fountLib.getBDO(userPubKey);
2126
+ const bdo = userBDO.bdo || userBDO;
2127
+ const carrierBag = bdo.carrierBag || bdo.data?.carrierBag || {};
2128
+
2129
+ // Add item to collection
2130
+ if (!carrierBag[collection]) {
2131
+ carrierBag[collection] = [];
2132
+ }
2133
+ carrierBag[collection].push(item);
2134
+
2135
+ // Update carrierBag
2136
+ // TODO: This requires authentication - need to handle signing
2137
+ // For now, log success but don't actually update
2138
+ console.log(`āœ… Would save to carrierBag ${collection} collection`);
2139
+ console.log(` Item: ${JSON.stringify(item).substring(0, 100)}...`);
2140
+
2141
+ return { success: true };
2142
+
2143
+ } catch (error) {
2144
+ console.error('āŒ Failed to save to carrierBag:', error);
2145
+ return { success: false, error: error.message };
2146
+ }
2147
+ }
2148
+
2149
+ /**
2150
+ * Check if a user has admin nineum in the connected base's Fount instance
2151
+ *
2152
+ * @param {string} pubKey - User's public key
2153
+ * @returns {Promise<boolean>} - True if user has admin nineum
2154
+ */
2155
+ async function checkIsAdmin(pubKey) {
2156
+ try {
2157
+ console.log(`šŸ”‘ Checking admin status for pubKey: ${pubKey.substring(0, 16)}...`);
2158
+
2159
+ // Get user UUID from Fount using pubKey
2160
+ const userResponse = await fetch(`${FOUNT_BASE_URL}user/pubKey/${pubKey}`, {
2161
+ method: 'GET',
2162
+ headers: { 'Content-Type': 'application/json' }
2163
+ });
2164
+
2165
+ if (!userResponse.ok) {
2166
+ console.log(`āŒ User not found in Fount: ${userResponse.status}`);
2167
+ return false;
2168
+ }
2169
+
2170
+ const user = await userResponse.json();
2171
+ if (!user || !user.uuid) {
2172
+ console.log(`āŒ User response missing UUID`);
2173
+ return false;
2174
+ }
2175
+
2176
+ console.log(`āœ… Found user UUID: ${user.uuid}`);
2177
+
2178
+ // Check if user has admin nineum
2179
+ const nineumResponse = await fetch(`${FOUNT_BASE_URL}user/${user.uuid}/nineum/admin`, {
2180
+ method: 'GET',
2181
+ headers: { 'Content-Type': 'application/json' }
2182
+ });
2183
+
2184
+ if (!nineumResponse.ok) {
2185
+ console.log(`āŒ Failed to check admin nineum: ${nineumResponse.status}`);
2186
+ return false;
2187
+ }
2188
+
2189
+ const nineumData = await nineumResponse.json();
2190
+ const hasAdminNineum = nineumData && nineumData.count > 0;
2191
+
2192
+ console.log(`āœ… Admin check result: ${hasAdminNineum} (count: ${nineumData?.count || 0})`);
2193
+ return hasAdminNineum;
2194
+
2195
+ } catch (error) {
2196
+ console.error('āŒ Error checking admin status:', error);
2197
+ return false;
2198
+ }
2199
+ }
2200
+
2201
+ /**
2202
+ * Resolve submitLinkitylinkTemplate spell
2203
+ * Allows users to submit custom templates and earn when they're used
2204
+ *
2205
+ * Cost: 600 MP
2206
+ *
2207
+ * Payload:
2208
+ * {
2209
+ * paymentMethod: 'mp' | 'money',
2210
+ * template: {
2211
+ * name: 'Sunset Gradient',
2212
+ * colors: ['#ff6b6b', '#ee5a6f', '#feca57'],
2213
+ * linkColors: ['#10b981', '#3b82f6', '#8b5cf6', '#ec4899']
2214
+ * },
2215
+ * payeeQuadEmojicode: 'šŸ”—šŸ’ŽšŸŒŸšŸŽØšŸ‰šŸ“ŒšŸŒšŸ”‘'
2216
+ * }
2217
+ */
2218
+ async function resolveSubmitTemplateSpell(caster, payload) {
2219
+ console.log('šŸŽØ Resolving submitLinkitylinkTemplate spell...');
2220
+
2221
+ const { paymentMethod, template, payeeQuadEmojicode } = payload;
2222
+
2223
+ // Validate required components
2224
+ if (!template || !template.name || !template.colors || !template.linkColors) {
2225
+ return { success: false, error: 'Invalid template structure' };
2226
+ }
2227
+
2228
+ if (!Array.isArray(template.colors) || template.colors.length === 0) {
2229
+ return { success: false, error: 'Template colors must be a non-empty array' };
2230
+ }
2231
+
2232
+ if (!Array.isArray(template.linkColors) || template.linkColors.length === 0) {
2233
+ return { success: false, error: 'Template linkColors must be a non-empty array' };
2234
+ }
2235
+
2236
+ if (!payeeQuadEmojicode || payeeQuadEmojicode.length !== 8) {
2237
+ return { success: false, error: 'Invalid payeeQuadEmojicode (must be 8 emojis)' };
2238
+ }
2239
+
2240
+ if (!paymentMethod || (paymentMethod !== 'mp' && paymentMethod !== 'money')) {
2241
+ return { success: false, error: 'Invalid payment method (must be mp or money)' };
2242
+ }
2243
+
2244
+ // Process payment (600 MP)
2245
+ const paymentResult = await processSpellPayment(caster, paymentMethod, 600); // 600 MP
2246
+ if (!paymentResult.success) {
2247
+ return paymentResult;
2248
+ }
2249
+
2250
+ console.log(`āœ… Payment processed (600 MP)`);
2251
+
2252
+ // Build template BDO
2253
+ const templateBDO = {
2254
+ type: 'linkitylink-template',
2255
+ name: template.name,
2256
+ colors: template.colors,
2257
+ linkColors: template.linkColors,
2258
+ payeeEmojicode: payeeQuadEmojicode,
2259
+ creatorPubKey: caster.pubKey,
2260
+ submittedAt: new Date().toISOString(),
2261
+ status: 'pending' // Requires admin approval before showing to users
2262
+ };
2263
+
2264
+ // Generate temporary keys for template BDO
2265
+ const saveKeys = (keys) => { tempKeys = keys; };
2266
+ const getKeys = () => tempKeys;
2267
+ let tempKeys = null;
2268
+
2269
+ const keys = await sessionless.generateKeys(saveKeys, getKeys);
2270
+ const pubKey = keys.pubKey;
2271
+
2272
+ console.log(`šŸ”‘ Generated template BDO keys: ${pubKey.substring(0, 16)}...`);
2273
+
2274
+ // Create template BDO
2275
+ const hash = 'Linkitylink-Template';
2276
+ console.log(`🌐 Creating template BDO with hash: ${hash}`);
2277
+
2278
+ const bdoUUID = await bdoLib.createUser(hash, templateBDO, saveKeys, getKeys);
2279
+ console.log(`āœ… Template BDO created: ${bdoUUID}`);
2280
+
2281
+ // Make BDO public to get emojicode
2282
+ console.log(`šŸŒ Making template BDO public...`);
2283
+ const updatedBDO = await bdoLib.updateBDO(bdoUUID, hash, templateBDO, true);
2284
+ const emojicode = updatedBDO.emojiShortcode;
2285
+
2286
+ console.log(`āœ… Template emojicode: ${emojicode}`);
2287
+
2288
+ // Add template to BDO service index for querying
2289
+ try {
2290
+ const addToIndexURL = `${BDO_BASE_URL}/templates/${hash}/add`;
2291
+ const indexResponse = await fetch(addToIndexURL, {
2292
+ method: 'POST',
2293
+ headers: { 'Content-Type': 'application/json' },
2294
+ body: JSON.stringify({ emojicode })
2295
+ });
2296
+
2297
+ if (indexResponse.ok) {
2298
+ console.log(`āœ… Added template ${emojicode} to BDO index`);
2299
+ } else {
2300
+ console.warn(`āš ļø Failed to add template to index: ${indexResponse.status}`);
2301
+ }
2302
+ } catch (err) {
2303
+ console.warn(`āš ļø Failed to add template to index:`, err.message);
2304
+ }
2305
+
2306
+ // Save to creator's carrierBag
2307
+ const carrierBagResult = await saveToCarrierBag(caster.pubKey, 'linkitylinkTemplates', {
2308
+ title: template.name,
2309
+ type: 'linkitylink-template',
2310
+ emojicode: emojicode,
2311
+ bdoPubKey: pubKey,
2312
+ payeeQuadEmojicode: payeeQuadEmojicode,
2313
+ createdAt: templateBDO.submittedAt
2314
+ });
2315
+
2316
+ if (!carrierBagResult.success) {
2317
+ console.warn('āš ļø Failed to save template to carrierBag, but spell succeeded');
2318
+ }
2319
+
2320
+ // Return success
2321
+ return {
2322
+ success: true,
2323
+ uuid: bdoUUID,
2324
+ pubKey: pubKey,
2325
+ emojicode: emojicode,
2326
+ templateName: template.name,
2327
+ payment: paymentResult.payment,
2328
+ message: 'Template submitted successfully! You will earn a share when users purchase linkitylinks with your template.'
2329
+ };
2330
+ }
2331
+
2332
+ // Template cache with 5-minute TTL
2333
+ let templateCache = {
2334
+ templates: [],
2335
+ lastFetched: null,
2336
+ ttl: 5 * 60 * 1000 // 5 minutes
2337
+ };
2338
+
2339
+ /**
2340
+ * GET /templates - Get all user-submitted templates
2341
+ *
2342
+ * Fetches all linkitylink-template BDOs from the BDO service.
2343
+ * Returns active templates with their payee information for revenue sharing.
2344
+ * Caches results for 5 minutes to reduce BDO service load.
2345
+ */
2346
+ app.get('/templates', async (req, res) => {
2347
+ try {
2348
+ console.log('šŸŽØ Fetching user-submitted templates...');
2349
+
2350
+ // Check cache
2351
+ const now = Date.now();
2352
+ if (templateCache.lastFetched && (now - templateCache.lastFetched) < templateCache.ttl) {
2353
+ console.log(`āœ… Returning ${templateCache.templates.length} cached templates`);
2354
+ return res.json({
2355
+ success: true,
2356
+ templates: templateCache.templates,
2357
+ cached: true
2358
+ });
2359
+ }
2360
+
2361
+ // Query BDO service for all templates with hash 'Linkitylink-Template'
2362
+ const hash = 'Linkitylink-Template';
2363
+ const templatesURL = `${BDO_BASE_URL}/templates/${hash}`;
2364
+
2365
+ console.log(`šŸ“” Querying BDO service: ${templatesURL}`);
2366
+
2367
+ const response = await fetch(templatesURL);
2368
+
2369
+ if (!response.ok) {
2370
+ throw new Error(`BDO service returned ${response.status}`);
2371
+ }
2372
+
2373
+ const data = await response.json();
2374
+
2375
+ console.log(`āœ… Received ${data.count} templates from BDO service`);
2376
+
2377
+ // Filter for approved templates only (pending/rejected templates are hidden)
2378
+ const templates = data.templates
2379
+ .filter(t => t.status === 'approved')
2380
+ .map(t => ({
2381
+ name: t.name,
2382
+ colors: t.colors,
2383
+ linkColors: t.linkColors,
2384
+ emojicode: t.emojicode,
2385
+ payeeEmojicode: t.payeeEmojicode,
2386
+ creatorPubKey: t.creatorPubKey
2387
+ }));
2388
+
2389
+ // Update cache
2390
+ templateCache.templates = templates;
2391
+ templateCache.lastFetched = now;
2392
+
2393
+ res.json({
2394
+ success: true,
2395
+ templates: templates,
2396
+ count: templates.length
2397
+ });
2398
+
2399
+ } catch (error) {
2400
+ console.error('āŒ Error fetching templates:', error);
2401
+ res.status(500).json({
2402
+ success: false,
2403
+ error: error.message
2404
+ });
2405
+ }
2406
+ });
2407
+
2408
+ /**
2409
+ * GET /templates/pending - Get pending templates for admin moderation
2410
+ *
2411
+ * Admin-only endpoint. Returns all templates with status 'pending' awaiting approval.
2412
+ */
2413
+ app.get('/templates/pending', async (req, res) => {
2414
+ try {
2415
+ // Verify admin status
2416
+ const pubKey = req.query.pubKey;
2417
+ if (!pubKey) {
2418
+ return res.status(401).json({
2419
+ success: false,
2420
+ error: 'Missing pubKey parameter'
2421
+ });
2422
+ }
2423
+
2424
+ const isAdmin = await checkIsAdmin(pubKey);
2425
+ if (!isAdmin) {
2426
+ return res.status(403).json({
2427
+ success: false,
2428
+ error: 'Unauthorized - admin nineum required'
2429
+ });
2430
+ }
2431
+
2432
+ console.log('šŸŽØ Fetching pending templates for admin review...');
2433
+
2434
+ // Query BDO service for all templates
2435
+ const hash = 'Linkitylink-Template';
2436
+ const templatesURL = `${BDO_BASE_URL}/templates/${hash}`;
2437
+
2438
+ console.log(`šŸ“” Querying BDO service: ${templatesURL}`);
2439
+
2440
+ const response = await fetch(templatesURL);
2441
+
2442
+ if (!response.ok) {
2443
+ throw new Error(`BDO service returned ${response.status}`);
2444
+ }
2445
+
2446
+ const data = await response.json();
2447
+
2448
+ console.log(`āœ… Received ${data.count} total templates from BDO service`);
2449
+
2450
+ // Filter for pending templates only
2451
+ const pendingTemplates = data.templates
2452
+ .filter(t => t.status === 'pending')
2453
+ .map(t => ({
2454
+ name: t.name,
2455
+ colors: t.colors,
2456
+ linkColors: t.linkColors,
2457
+ emojicode: t.emojicode,
2458
+ payeeEmojicode: t.payeeEmojicode,
2459
+ creatorPubKey: t.creatorPubKey,
2460
+ submittedAt: t.submittedAt
2461
+ }));
2462
+
2463
+ console.log(`šŸ“‹ Returning ${pendingTemplates.length} pending templates`);
2464
+
2465
+ res.json({
2466
+ success: true,
2467
+ templates: pendingTemplates,
2468
+ count: pendingTemplates.length
2469
+ });
2470
+
2471
+ } catch (error) {
2472
+ console.error('āŒ Error fetching pending templates:', error);
2473
+ res.status(500).json({
2474
+ success: false,
2475
+ error: error.message
2476
+ });
2477
+ }
2478
+ });
2479
+
2480
+ /**
2481
+ * PUT /template/:emojicode/moderate - Approve or reject a template
2482
+ *
2483
+ * Admin-only endpoint. Updates template status to 'approved' or 'rejected'.
2484
+ *
2485
+ * Body: { pubKey: string, action: 'approve' | 'reject' }
2486
+ */
2487
+ app.put('/template/:emojicode/moderate', async (req, res) => {
2488
+ try {
2489
+ const { emojicode } = req.params;
2490
+ const { pubKey, action } = req.body;
2491
+
2492
+ // Validate inputs
2493
+ if (!pubKey) {
2494
+ return res.status(400).json({
2495
+ success: false,
2496
+ error: 'Missing pubKey in request body'
2497
+ });
2498
+ }
2499
+
2500
+ if (!action || !['approve', 'reject'].includes(action)) {
2501
+ return res.status(400).json({
2502
+ success: false,
2503
+ error: 'Invalid action - must be "approve" or "reject"'
2504
+ });
2505
+ }
2506
+
2507
+ // Verify admin status
2508
+ const isAdmin = await checkIsAdmin(pubKey);
2509
+ if (!isAdmin) {
2510
+ return res.status(403).json({
2511
+ success: false,
2512
+ error: 'Unauthorized - admin nineum required'
2513
+ });
2514
+ }
2515
+
2516
+ console.log(`šŸŽØ Moderating template ${emojicode}: ${action}`);
2517
+
2518
+ // Fetch template BDO via emojicode
2519
+ const templateBDO = await bdoLib.getBDOByEmojicode(emojicode);
2520
+
2521
+ if (!templateBDO || !templateBDO.bdo) {
2522
+ return res.status(404).json({
2523
+ success: false,
2524
+ error: 'Template not found'
2525
+ });
2526
+ }
2527
+
2528
+ const bdo = templateBDO.bdo;
2529
+
2530
+ // Verify it's a template
2531
+ if (bdo.type !== 'linkitylink-template') {
2532
+ return res.status(400).json({
2533
+ success: false,
2534
+ error: 'BDO is not a linkitylink template'
2535
+ });
2536
+ }
2537
+
2538
+ // Update status
2539
+ const newStatus = action === 'approve' ? 'approved' : 'rejected';
2540
+ bdo.status = newStatus;
2541
+ bdo.moderatedAt = new Date().toISOString();
2542
+ bdo.moderatedBy = pubKey;
2543
+
2544
+ // Update the BDO
2545
+ const hash = 'Linkitylink-Template';
2546
+ await bdoLib.updateBDO(templateBDO.uuid, hash, bdo, true); // Keep it public
2547
+
2548
+ console.log(`āœ… Template ${emojicode} ${newStatus}`);
2549
+
2550
+ // Clear cache so next request gets updated data
2551
+ templateCache.lastFetched = 0;
2552
+
2553
+ res.json({
2554
+ success: true,
2555
+ emojicode: emojicode,
2556
+ status: newStatus,
2557
+ message: `Template ${action}d successfully`
2558
+ });
2559
+
2560
+ } catch (error) {
2561
+ console.error('āŒ Error moderating template:', error);
2562
+ res.status(500).json({
2563
+ success: false,
2564
+ error: error.message
2565
+ });
2566
+ }
2567
+ });
2568
+
2569
+ // ============================================================================
2570
+ // App Handoff Endpoints - Web-to-App purchase flow
2571
+ // ============================================================================
2572
+
2573
+ /**
2574
+ * POST /handoff/create - Create a pending handoff for app purchase
2575
+ *
2576
+ * Creates a BDO (unpurchased) and initiates handoff to The Advancement app.
2577
+ * Returns a token and auth sequence for the color game.
2578
+ *
2579
+ * Body: {
2580
+ * title: "My Links",
2581
+ * links: [...],
2582
+ * relevantBDOs: { emojicodes: [...], pubKeys: [...] }
2583
+ * }
2584
+ */
2585
+ app.post('/handoff/create', async (req, res) => {
2586
+ try {
2587
+ console.log('šŸ“± Creating app handoff...');
2588
+
2589
+ const { bdoData, relevantBDOs, productType } = req.body;
2590
+
2591
+ // Extract links from bdoData or directly from body (backward compat)
2592
+ const links = bdoData?.links || req.body.links;
2593
+ const title = bdoData?.title || req.body.title;
2594
+
2595
+ // Validate
2596
+ if (!links || !Array.isArray(links) || links.length === 0) {
2597
+ return res.status(400).json({
2598
+ success: false,
2599
+ error: 'Missing or invalid links array'
2600
+ });
2601
+ }
2602
+
2603
+ // Generate SVG for the BDO
2604
+ const svgTemplate = chooseSVGTemplate(links.length);
2605
+ const svgContent = svgTemplate(links);
2606
+
2607
+ // Build BDO data (not yet saved to BDO service)
2608
+ const finalBdoData = {
2609
+ title: title || 'My Links',
2610
+ type: 'linkitylink',
2611
+ svgContent: svgContent,
2612
+ links: links,
2613
+ source: bdoData?.source || 'create-page',
2614
+ style: bdoData?.style,
2615
+ template: bdoData?.template,
2616
+ createdAt: new Date().toISOString(),
2617
+ status: 'pending_purchase'
2618
+ };
2619
+
2620
+ // Generate keys for the BDO (we'll need these for association later)
2621
+ let bdoKeys;
2622
+ const saveKeys = (keys) => { bdoKeys = keys; };
2623
+ const getKeys = () => bdoKeys;
2624
+
2625
+ const keys = await sessionless.generateKeys(saveKeys, getKeys);
2626
+ const bdoPubKey = keys.pubKey;
2627
+
2628
+ console.log(`šŸ”‘ Generated BDO keys: ${bdoPubKey.substring(0, 16)}...`);
2629
+
2630
+ // Store the keys in session for later use
2631
+ req.session.pendingBdoKeys = bdoKeys;
2632
+ await new Promise((resolve, reject) => {
2633
+ req.session.save((err) => err ? reject(err) : resolve());
2634
+ });
2635
+
2636
+ // Create pending handoff
2637
+ const handoff = createPendingHandoff({
2638
+ bdoData: finalBdoData,
2639
+ bdoPubKey,
2640
+ bdoEmojicode: null, // Not yet created
2641
+ relevantBDOs: relevantBDOs || { emojicodes: [], pubKeys: [] },
2642
+ productType: productType || 'linkitylink',
2643
+ webPrice: 2000, // $20.00
2644
+ appPrice: 1500 // $15.00 (25% discount)
2645
+ });
2646
+
2647
+ console.log(`āœ… Handoff created: ${handoff.token.substring(0, 8)}...`);
2648
+
2649
+ res.json({
2650
+ success: true,
2651
+ token: handoff.token,
2652
+ sequence: handoff.sequence,
2653
+ expiresAt: handoff.expiresAt,
2654
+ webPrice: 2000,
2655
+ appPrice: 1500,
2656
+ discount: 500,
2657
+ discountPercent: 25
2658
+ });
2659
+
2660
+ } catch (error) {
2661
+ console.error('āŒ Error creating handoff:', error);
2662
+ res.status(500).json({
2663
+ success: false,
2664
+ error: error.message
2665
+ });
2666
+ }
2667
+ });
2668
+
2669
+ /**
2670
+ * POST /handoff/:token/verify - Verify the auth sequence
2671
+ *
2672
+ * User has completed the color sequence game. Verify it.
2673
+ *
2674
+ * Body: { sequence: ['red', 'blue', 'green', 'yellow', 'purple'] }
2675
+ */
2676
+ app.post('/handoff/:token/verify', async (req, res) => {
2677
+ try {
2678
+ const { token } = req.params;
2679
+ const { sequence } = req.body;
2680
+
2681
+ console.log(`šŸ“± Verifying handoff sequence: ${token.substring(0, 8)}...`);
2682
+
2683
+ const result = verifyAuthSequence(token, sequence);
2684
+
2685
+ if (result.success) {
2686
+ console.log(`āœ… Sequence verified for: ${token.substring(0, 8)}...`);
2687
+ }
2688
+
2689
+ res.json(result);
2690
+
2691
+ } catch (error) {
2692
+ console.error('āŒ Error verifying sequence:', error);
2693
+ res.status(500).json({
2694
+ success: false,
2695
+ error: error.message
2696
+ });
2697
+ }
2698
+ });
2699
+
2700
+ /**
2701
+ * POST /handoff/:token/associate - Associate app credentials with handoff
2702
+ *
2703
+ * Called by The Advancement app after auth sequence is completed.
2704
+ * Links the app's pubKey to this handoff.
2705
+ *
2706
+ * Body: { pubKey, uuid, timestamp, signature }
2707
+ */
2708
+ app.post('/handoff/:token/associate', async (req, res) => {
2709
+ try {
2710
+ const { token } = req.params;
2711
+ const appCredentials = req.body;
2712
+
2713
+ console.log(`šŸ“± Associating app with handoff: ${token.substring(0, 8)}...`);
2714
+
2715
+ const result = associateAppCredentials(token, appCredentials);
2716
+
2717
+ if (result.success) {
2718
+ console.log(`āœ… App associated: ${appCredentials.pubKey?.substring(0, 16)}...`);
2719
+ }
2720
+
2721
+ res.json(result);
2722
+
2723
+ } catch (error) {
2724
+ console.error('āŒ Error associating app:', error);
2725
+ res.status(500).json({
2726
+ success: false,
2727
+ error: error.message
2728
+ });
2729
+ }
2730
+ });
2731
+
2732
+ /**
2733
+ * GET /handoff/:token/status - Get handoff status for polling
2734
+ *
2735
+ * Called by the web page to check if the app has completed the sequence.
2736
+ * Returns status flags for UI updates.
2737
+ */
2738
+ app.get('/handoff/:token/status', async (req, res) => {
2739
+ try {
2740
+ const { token } = req.params;
2741
+
2742
+ const handoff = getPendingHandoff(token);
2743
+
2744
+ if (!handoff) {
2745
+ return res.status(404).json({
2746
+ success: false,
2747
+ error: 'Handoff not found or expired'
2748
+ });
2749
+ }
2750
+
2751
+ res.json({
2752
+ success: true,
2753
+ sequenceCompleted: handoff.sequenceCompleted,
2754
+ appPubKey: handoff.appPubKey ? handoff.appPubKey.substring(0, 16) + '...' : null,
2755
+ completedAt: handoff.completedAt,
2756
+ emojicode: handoff.completedAt ? handoff.bdoEmojicode : null,
2757
+ bdoPubKey: handoff.completedAt ? handoff.bdoPubKey : null
2758
+ });
2759
+
2760
+ } catch (error) {
2761
+ console.error('āŒ Error getting handoff status:', error);
2762
+ res.status(500).json({
2763
+ success: false,
2764
+ error: error.message
2765
+ });
2766
+ }
2767
+ });
2768
+
2769
+ /**
2770
+ * GET /handoff/:token - Get handoff data for the app
2771
+ *
2772
+ * Called by The Advancement app to get BDO data for display.
2773
+ * Requires appPubKey query param for verification.
2774
+ */
2775
+ app.get('/handoff/:token', async (req, res) => {
2776
+ try {
2777
+ const { token } = req.params;
2778
+ const { appPubKey } = req.query;
2779
+
2780
+ console.log(`šŸ“± Getting handoff: ${token.substring(0, 8)}...`);
2781
+
2782
+ const result = getHandoffForApp(token, appPubKey);
2783
+
2784
+ res.json(result);
2785
+
2786
+ } catch (error) {
2787
+ console.error('āŒ Error getting handoff:', error);
2788
+ res.status(500).json({
2789
+ success: false,
2790
+ error: error.message
2791
+ });
2792
+ }
2793
+ });
2794
+
2795
+ /**
2796
+ * POST /handoff/:token/complete - Complete the handoff after purchase
2797
+ *
2798
+ * Called after successful payment in the app.
2799
+ * Creates the actual BDO and adds to carrierBag.
2800
+ *
2801
+ * Body: { appPubKey, paymentConfirmation }
2802
+ */
2803
+ app.post('/handoff/:token/complete', async (req, res) => {
2804
+ try {
2805
+ const { token } = req.params;
2806
+ const { appPubKey } = req.body;
2807
+
2808
+ console.log(`šŸ“± Completing handoff: ${token.substring(0, 8)}...`);
2809
+
2810
+ const handoff = getPendingHandoff(token);
2811
+
2812
+ if (!handoff) {
2813
+ return res.status(404).json({
2814
+ success: false,
2815
+ error: 'Handoff not found or expired'
2816
+ });
2817
+ }
2818
+
2819
+ if (handoff.appPubKey !== appPubKey) {
2820
+ return res.status(403).json({
2821
+ success: false,
2822
+ error: 'App not authorized for this handoff'
2823
+ });
2824
+ }
2825
+
2826
+ // Now actually create the BDO
2827
+ console.log('šŸŽØ Creating actual BDO...');
2828
+
2829
+ // Get keys from session if available, or generate new ones
2830
+ let bdoKeys = req.session.pendingBdoKeys;
2831
+ if (!bdoKeys) {
2832
+ const saveKeys = (keys) => { bdoKeys = keys; };
2833
+ const getKeys = () => bdoKeys;
2834
+ await sessionless.generateKeys(saveKeys, getKeys);
2835
+ }
2836
+
2837
+ const saveKeys = (keys) => { bdoKeys = keys; };
2838
+ const getKeys = () => bdoKeys;
2839
+ sessionless.getKeys = getKeys;
2840
+
2841
+ // Create BDO in BDO service
2842
+ const hash = 'Linkitylink';
2843
+ const bdoUUID = await bdoLib.createUser(hash, handoff.bdoData, saveKeys, getKeys);
2844
+ console.log(`āœ… BDO created: ${bdoUUID}`);
2845
+
2846
+ // Make BDO public
2847
+ const updatedBDO = await bdoLib.updateBDO(bdoUUID, hash, handoff.bdoData, true);
2848
+ const emojicode = updatedBDO.emojiShortcode;
2849
+ console.log(`āœ… Emojicode: ${emojicode}`);
2850
+
2851
+ // Store pubKey metadata for alphanumeric URL lookup
2852
+ bdoMetadataMap.set(handoff.bdoPubKey, {
2853
+ uuid: bdoUUID,
2854
+ emojicode: emojicode,
2855
+ createdAt: new Date(),
2856
+ purchasedVia: 'app-handoff',
2857
+ appPubKey: appPubKey
2858
+ });
2859
+ markMappingsDirty();
2860
+
2861
+ // Mark handoff as complete
2862
+ completeHandoff(token);
2863
+
2864
+ // Clean up session
2865
+ delete req.session.pendingBdoKeys;
2866
+ req.session.save(() => {});
2867
+
2868
+ res.json({
2869
+ success: true,
2870
+ uuid: bdoUUID,
2871
+ pubKey: handoff.bdoPubKey,
2872
+ emojicode: emojicode,
2873
+ message: 'BDO created and added to carrierBag'
2874
+ });
2875
+
2876
+ } catch (error) {
2877
+ console.error('āŒ Error completing handoff:', error);
2878
+ res.status(500).json({
2879
+ success: false,
2880
+ error: error.message
2881
+ });
2882
+ }
2883
+ });
2884
+
2885
+ /**
2886
+ * GET /handoff/stats - Get handoff statistics (debug)
2887
+ */
2888
+ app.get('/handoff-stats', async (req, res) => {
2889
+ res.json(getHandoffStats());
2890
+ });
2891
+
2892
+ // ============================================================================
2893
+
2894
+ // Start server
2895
+ app.listen(PORT, () => {
2896
+ console.log(`\nāœ… Linkitylink tapestry weaver active on port ${PORT}`);
2897
+ console.log(`🌐 View demo: http://localhost:${PORT}`);
2898
+ console.log(`\nšŸ“ Viewing Modes:`);
2899
+ console.log(` Demo tapestry: http://localhost:${PORT}`);
2900
+ console.log(` By emojicode: http://localhost:${PORT}/view/šŸ˜€šŸ”—šŸ’ŽšŸŒŸ...`);
2901
+ console.log(` By alphanumeric: http://localhost:${PORT}/t/abc123...`);
2902
+ console.log(` Legacy auth: http://localhost:${PORT}?pubKey=YOUR_PUBKEY&timestamp=TIMESTAMP&signature=SIGNATURE`);
2903
+ console.log(`\nšŸ“ Creation Endpoints:`);
2904
+ console.log(` POST /create - Create new Linkitylink with auto-generated SVG`);
2905
+ console.log(` POST /magic/spell/linkitylink - Cast linkitylink spell (carrierBag links)`);
2906
+ console.log(` POST /magic/spell/glyphtree - Cast glyphtree spell (Linktree URL)`);
2907
+ console.log(`\nšŸ“± App Handoff Endpoints:`);
2908
+ console.log(` POST /handoff/create - Start web-to-app handoff`);
2909
+ console.log(` POST /handoff/:token/verify - Verify auth sequence`);
2910
+ console.log(` POST /handoff/:token/associate - Associate app credentials`);
2911
+ console.log(` GET /handoff/:token - Get handoff data for app`);
2912
+ console.log(` POST /handoff/:token/complete - Complete purchase`);
2913
+ console.log('');
2914
+ });