wiki-plugin-shoppe 0.0.22 → 0.0.23

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/server.js CHANGED
@@ -65,6 +65,12 @@ function getAddieUrl() {
65
65
  return `http://localhost:${process.env.ADDIE_PORT || 3005}`;
66
66
  }
67
67
 
68
+ function getLucilleUrl() {
69
+ const config = loadConfig();
70
+ if (config.lucilleUrl) return config.lucilleUrl.replace(/\/$/, '');
71
+ return `http://localhost:${process.env.LUCILLE_PORT || 5444}`;
72
+ }
73
+
68
74
  function loadBuyers() {
69
75
  if (!fs.existsSync(BUYERS_FILE)) return {};
70
76
  try { return JSON.parse(fs.readFileSync(BUYERS_FILE, 'utf8')); } catch { return {}; }
@@ -117,6 +123,7 @@ const EMOJI_PALETTE = [
117
123
  const BOOK_EXTS = new Set(['.epub', '.pdf', '.mobi', '.azw', '.azw3']);
118
124
  const MUSIC_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav']);
119
125
  const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']);
126
+ const VIDEO_EXTS = new Set(['.mp4', '.mov', '.mkv', '.webm', '.avi']);
120
127
 
121
128
  // ============================================================
122
129
  // MARKDOWN / FRONT MATTER UTILITIES
@@ -141,6 +148,147 @@ function escHtml(str) {
141
148
  return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
142
149
  }
143
150
 
151
+ // Extract kw:-prefixed entries from a Sanora product's tags string into a comma-separated keyword list.
152
+ function extractKeywords(product) {
153
+ return (product.tags || '').split(',')
154
+ .filter(t => t.startsWith('kw:'))
155
+ .map(t => t.slice(3).trim())
156
+ .join(', ');
157
+ }
158
+
159
+ // Append keyword tags (as kw:word entries) to a base tags string.
160
+ function buildTags(baseTags, keywords) {
161
+ const kwTags = (Array.isArray(keywords) ? keywords : [])
162
+ .map(kw => `kw:${kw.trim()}`).filter(Boolean);
163
+ if (!kwTags.length) return baseTags;
164
+ return baseTags + ',' + kwTags.join(',');
165
+ }
166
+
167
+ // ── Owner key pair (secp256k1 via sessionless) ───────────────────────────────
168
+
169
+ async function generateOwnerKeyPair() {
170
+ const keys = await sessionless.generateKeys(() => {}, () => null);
171
+ return { pubKey: keys.pubKey, privateKey: keys.privateKey };
172
+ }
173
+
174
+ // Validate owner signature embedded in a manifest.
175
+ // If the tenant has no ownerPubKey (registered before this feature), validation is skipped.
176
+ function validateOwnerSignature(manifest, tenant) {
177
+ if (!tenant.ownerPubKey) return; // legacy tenant — no signature required
178
+
179
+ if (!manifest.ownerPubKey || !manifest.timestamp || !manifest.signature) {
180
+ throw new Error(
181
+ 'Archive is missing owner signature fields. Sign it first:\n' +
182
+ ' node shoppe-sign.js'
183
+ );
184
+ }
185
+ if (manifest.ownerPubKey !== tenant.ownerPubKey) {
186
+ throw new Error('Owner public key does not match the registered key for this shoppe');
187
+ }
188
+ const age = Date.now() - parseInt(manifest.timestamp, 10);
189
+ if (isNaN(age) || age < 0 || age > 10 * 60 * 1000) {
190
+ throw new Error('Signature timestamp is invalid or expired — re-run: node shoppe-sign.js');
191
+ }
192
+ const message = manifest.timestamp + manifest.uuid;
193
+ if (!sessionless.verifySignature(manifest.signature, message, manifest.ownerPubKey)) {
194
+ throw new Error('Owner signature verification failed');
195
+ }
196
+ }
197
+
198
+ // Single-use bundle tokens: token → { uuid, expiresAt }
199
+ const bundleTokens = new Map();
200
+
201
+ // Build the starter bundle zip for a newly registered tenant.
202
+ function generateBundleBuffer(tenant, ownerPrivateKey, ownerPubKey, wikiOrigin) {
203
+ const SIGN_SCRIPT = fs.readFileSync(
204
+ path.join(__dirname, 'scripts', 'shoppe-sign.js')
205
+ );
206
+
207
+ const manifest = {
208
+ uuid: tenant.uuid,
209
+ emojicode: tenant.emojicode,
210
+ name: tenant.name,
211
+ wikiUrl: `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`
212
+ };
213
+
214
+ const keyData = { privateKey: ownerPrivateKey, pubKey: ownerPubKey };
215
+
216
+ const packageJson = JSON.stringify({
217
+ name: 'shoppe',
218
+ version: '1.0.0',
219
+ private: true,
220
+ description: 'Shoppe content folder',
221
+ dependencies: {
222
+ 'sessionless-node': '^0.9.12'
223
+ }
224
+ }, null, 2);
225
+
226
+ const readme = [
227
+ `# ${tenant.name} — Shoppe Starter`,
228
+ '',
229
+ '## First-time setup',
230
+ '',
231
+ '1. Install Node.js if needed: https://nodejs.org',
232
+ '2. Run: `npm install` (installs sessionless-node — one time only)',
233
+ '3. Run: `node shoppe-sign.js init`',
234
+ ' This moves your private key to ~/.shoppe/keys/ and removes it from this folder.',
235
+ '',
236
+ '## Adding content',
237
+ '',
238
+ 'Add your goods to the appropriate folders:',
239
+ '',
240
+ ' books/ → .epub / .pdf / .mobi (+ cover.jpg + info.json)',
241
+ ' music/ → album subfolders or standalone .mp3 files',
242
+ ' posts/ → numbered subfolders with post.md',
243
+ ' albums/ → photo album subfolders',
244
+ ' products/ → physical products with info.json',
245
+ ' videos/ → numbered subfolders with .mp4/.mov/.mkv + cover.jpg + info.json',
246
+ ' appointments/ → bookable services with info.json',
247
+ ' subscriptions/ → membership tiers with info.json',
248
+ '',
249
+ 'Each content folder can have an optional info.json:',
250
+ ' { "title": "…", "description": "…", "price": 0, "keywords": ["tag1","tag2"] }',
251
+ '',
252
+ '## Uploading',
253
+ '',
254
+ 'Run: `node shoppe-sign.js`',
255
+ '',
256
+ 'This signs your manifest and creates a ready-to-upload zip next to this folder.',
257
+ 'Drag that zip onto your wiki\'s shoppe plugin.',
258
+ '',
259
+ '## Re-uploading',
260
+ '',
261
+ 'Add or update content, then run `node shoppe-sign.js` again.',
262
+ 'Each upload overwrites existing items and adds new ones.',
263
+ '',
264
+ '## Viewing orders',
265
+ '',
266
+ 'Run: `node shoppe-sign.js orders`',
267
+ '',
268
+ 'Opens a signed link to your order dashboard (valid for 5 minutes).',
269
+ '',
270
+ '## Setting up payouts (Stripe)',
271
+ '',
272
+ 'Run: `node shoppe-sign.js payouts`',
273
+ '',
274
+ 'Opens Stripe Connect onboarding so you can receive payments.',
275
+ 'Do this once before your first sale.',
276
+ ].join('\n');
277
+
278
+ const zip = new AdmZip();
279
+ zip.addFile('manifest.json', Buffer.from(JSON.stringify(manifest, null, 2)));
280
+ zip.addFile('shoppe-key.json', Buffer.from(JSON.stringify(keyData, null, 2)));
281
+ zip.addFile('shoppe-sign.js', SIGN_SCRIPT);
282
+ zip.addFile('package.json', Buffer.from(packageJson));
283
+ zip.addFile('README.md', Buffer.from(readme));
284
+
285
+ for (const dir of ['books', 'music', 'posts', 'albums', 'products', 'appointments', 'subscriptions']) {
286
+ zip.addFile(`${dir}/.gitkeep`, Buffer.from(''));
287
+ }
288
+
289
+ return zip.toBuffer();
290
+ }
291
+
144
292
  function renderMarkdown(md) {
145
293
  // Process code blocks first to avoid mangling their contents
146
294
  const codeBlocks = [];
@@ -253,6 +401,16 @@ async function registerTenant(name) {
253
401
  console.warn('[shoppe] Could not create addie user (payouts unavailable):', err.message);
254
402
  }
255
403
 
404
+ // Create a dedicated Lucille user for video uploads
405
+ let lucilleKeys = null;
406
+ try {
407
+ lucilleKeys = await lucilleCreateUser();
408
+ } catch (err) {
409
+ console.warn('[shoppe] Could not create lucille user (video uploads unavailable):', err.message);
410
+ }
411
+
412
+ const ownerKeys = await generateOwnerKeyPair();
413
+
256
414
  const tenant = {
257
415
  uuid: sanoraUser.uuid,
258
416
  emojicode,
@@ -260,6 +418,8 @@ async function registerTenant(name) {
260
418
  keys,
261
419
  sanoraUser,
262
420
  addieKeys,
421
+ lucilleKeys,
422
+ ownerPubKey: ownerKeys.pubKey,
263
423
  createdAt: Date.now()
264
424
  };
265
425
 
@@ -267,7 +427,15 @@ async function registerTenant(name) {
267
427
  saveTenants(tenants);
268
428
 
269
429
  console.log(`[shoppe] Registered tenant: "${name}" ${emojicode} (${sanoraUser.uuid})`);
270
- return { uuid: sanoraUser.uuid, emojicode, name: tenant.name };
430
+ // ownerPrivateKey is returned once so the caller can include it in the starter bundle.
431
+ // It is NOT persisted server-side.
432
+ return {
433
+ uuid: sanoraUser.uuid,
434
+ emojicode,
435
+ name: tenant.name,
436
+ ownerPrivateKey: ownerKeys.privateKey,
437
+ ownerPubKey: ownerKeys.pubKey
438
+ };
271
439
  }
272
440
 
273
441
  function getTenantByIdentifier(identifier) {
@@ -390,6 +558,91 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
390
558
  return result;
391
559
  }
392
560
 
561
+ // ============================================================
562
+ // LUCILLE HELPERS
563
+ // ============================================================
564
+
565
+ async function lucilleCreateUser(lucilleUrl) {
566
+ const url = lucilleUrl || getLucilleUrl();
567
+ const keys = await sessionless.generateKeys(() => {}, () => null);
568
+ sessionless.getKeys = () => keys;
569
+ const timestamp = Date.now().toString();
570
+ const message = timestamp + keys.pubKey;
571
+ const signature = await sessionless.sign(message);
572
+
573
+ const resp = await fetch(`${url}/user/create`, {
574
+ method: 'PUT',
575
+ headers: { 'Content-Type': 'application/json' },
576
+ body: JSON.stringify({ timestamp, pubKey: keys.pubKey, signature })
577
+ });
578
+
579
+ const lucilleUser = await resp.json();
580
+ if (lucilleUser.error) throw new Error(`Lucille: ${lucilleUser.error}`);
581
+ return { uuid: lucilleUser.uuid, pubKey: keys.pubKey, privateKey: keys.privateKey };
582
+ }
583
+
584
+ async function lucilleGetVideos(lucilleUuid, lucilleUrl) {
585
+ const url = lucilleUrl || getLucilleUrl();
586
+ try {
587
+ const resp = await fetch(`${url}/videos/${lucilleUuid}`);
588
+ if (!resp.ok) return {};
589
+ return await resp.json();
590
+ } catch (err) {
591
+ return {};
592
+ }
593
+ }
594
+
595
+ async function lucilleRegisterVideo(tenant, title, description, tags, lucilleUrl) {
596
+ const url = lucilleUrl || getLucilleUrl();
597
+ const { lucilleKeys } = tenant;
598
+ if (!lucilleKeys) throw new Error('Tenant has no Lucille user — re-register to enable video uploads');
599
+ const timestamp = Date.now().toString();
600
+ sessionless.getKeys = () => lucilleKeys;
601
+ const signature = await sessionless.sign(timestamp + lucilleKeys.pubKey);
602
+
603
+ const resp = await fetch(
604
+ `${url}/user/${lucilleKeys.uuid}/video/${encodeURIComponent(title)}`,
605
+ {
606
+ method: 'PUT',
607
+ headers: { 'Content-Type': 'application/json' },
608
+ body: JSON.stringify({ timestamp, signature, description: description || '', tags: tags || [] })
609
+ }
610
+ );
611
+
612
+ const result = await resp.json();
613
+ if (result.error) throw new Error(`Lucille register video failed: ${result.error}`);
614
+ return result;
615
+ }
616
+
617
+ async function lucilleUploadVideo(tenant, title, fileBuffer, filename, lucilleUrl) {
618
+ const url = lucilleUrl || getLucilleUrl();
619
+ const { lucilleKeys } = tenant;
620
+ if (!lucilleKeys) throw new Error('Tenant has no Lucille user');
621
+ const timestamp = Date.now().toString();
622
+ sessionless.getKeys = () => lucilleKeys;
623
+ const signature = await sessionless.sign(timestamp + lucilleKeys.pubKey);
624
+
625
+ const form = new FormData();
626
+ form.append('video', fileBuffer, { filename, contentType: getMimeType(filename) });
627
+
628
+ const resp = await fetch(
629
+ `${url}/user/${lucilleKeys.uuid}/video/${encodeURIComponent(title)}/file`,
630
+ {
631
+ method: 'PUT',
632
+ headers: {
633
+ 'x-pn-timestamp': timestamp,
634
+ 'x-pn-signature': signature,
635
+ ...form.getHeaders()
636
+ },
637
+ body: form
638
+ }
639
+ );
640
+
641
+ const result = await resp.json();
642
+ if (result.error) throw new Error(`Lucille video upload failed: ${result.error}`);
643
+ return result;
644
+ }
645
+
393
646
  // ============================================================
394
647
  // ARCHIVE PROCESSING
395
648
  // ============================================================
@@ -433,7 +686,25 @@ async function processArchive(zipPath) {
433
686
  throw new Error('emojicode does not match registered tenant');
434
687
  }
435
688
 
436
- const results = { books: [], music: [], posts: [], albums: [], products: [], appointments: [], subscriptions: [], warnings: [] };
689
+ // Verify owner signature (required for tenants registered after signing support was added).
690
+ validateOwnerSignature(manifest, tenant);
691
+
692
+ // Store manifest-level keywords and per-category redirect URLs in the tenant record.
693
+ const tenantUpdates = {};
694
+ if (Array.isArray(manifest.keywords) && manifest.keywords.length > 0) {
695
+ tenantUpdates.keywords = manifest.keywords.join(', ');
696
+ }
697
+ if (manifest.redirects && typeof manifest.redirects === 'object') {
698
+ tenantUpdates.redirects = manifest.redirects;
699
+ }
700
+ if (Object.keys(tenantUpdates).length > 0) {
701
+ const tenants = loadTenants();
702
+ Object.assign(tenants[tenant.uuid], tenantUpdates);
703
+ saveTenants(tenants);
704
+ Object.assign(tenant, tenantUpdates);
705
+ }
706
+
707
+ const results = { books: [], music: [], posts: [], albums: [], products: [], videos: [], appointments: [], subscriptions: [], warnings: [] };
437
708
 
438
709
  function readInfo(entryPath) {
439
710
  const infoPath = path.join(entryPath, 'info.json');
@@ -461,7 +732,7 @@ async function processArchive(zipPath) {
461
732
  const description = info.description || '';
462
733
  const price = info.price || 0;
463
734
 
464
- await sanoraCreateProduct(tenant, title, 'book', description, price, 0, 'book');
735
+ await sanoraCreateProduct(tenant, title, 'book', description, price, 0, buildTags('book', info.keywords));
465
736
 
466
737
  // Cover image — use info.cover to pin a specific file, else first image found
467
738
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
@@ -503,7 +774,7 @@ async function processArchive(zipPath) {
503
774
  try {
504
775
  const description = info.description || `Album: ${albumTitle}`;
505
776
  const price = info.price || 0;
506
- await sanoraCreateProduct(tenant, albumTitle, 'music', description, price, 0, 'music,album');
777
+ await sanoraCreateProduct(tenant, albumTitle, 'music', description, price, 0, buildTags('music,album', info.keywords));
507
778
  const coverFile = info.cover ? (covers.find(f => f === info.cover) || covers[0]) : covers[0];
508
779
  if (coverFile) {
509
780
  const coverBuf = fs.readFileSync(path.join(entryPath, coverFile));
@@ -532,7 +803,7 @@ async function processArchive(zipPath) {
532
803
  const buf = fs.readFileSync(entryPath);
533
804
  const description = trackInfo.description || `Track: ${title}`;
534
805
  const price = trackInfo.price || 0;
535
- await sanoraCreateProduct(tenant, title, 'music', description, price, 0, 'music,track');
806
+ await sanoraCreateProduct(tenant, title, 'music', description, price, 0, buildTags('music,track', trackInfo.keywords));
536
807
  await sanoraUploadArtifact(tenant, title, buf, entry, 'audio');
537
808
  results.music.push({ title, type: 'track' });
538
809
  console.log(`[shoppe] 🎵 track: ${title}`);
@@ -572,7 +843,7 @@ async function processArchive(zipPath) {
572
843
  // Register the series itself as a parent product
573
844
  try {
574
845
  const description = info.description || `A ${subDirs.length}-part series`;
575
- await sanoraCreateProduct(tenant, seriesTitle, 'post-series', description, 0, 0, `post,series,order:${order}`);
846
+ await sanoraCreateProduct(tenant, seriesTitle, 'post-series', description, 0, 0, buildTags(`post,series,order:${order}`, info.keywords));
576
847
 
577
848
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
578
849
  if (covers.length > 0) {
@@ -613,7 +884,7 @@ async function processArchive(zipPath) {
613
884
  const description = partInfo.description || partFm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || resolvedTitle;
614
885
 
615
886
  await sanoraCreateProduct(tenant, productTitle, 'post', description, 0, 0,
616
- `post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`);
887
+ buildTags(`post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`, partInfo.keywords));
617
888
 
618
889
  await sanoraUploadArtifact(tenant, productTitle, mdBuf, partMdFiles[0], 'text');
619
890
 
@@ -654,7 +925,7 @@ async function processArchive(zipPath) {
654
925
  const firstLine = fm.body.split('\n').find(l => l.trim()).replace(/^#+\s*/, '');
655
926
  const description = info.description || fm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || firstLine || title;
656
927
 
657
- await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, `post,blog,order:${order}`);
928
+ await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, buildTags(`post,blog,order:${order}`, info.keywords));
658
929
  await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
659
930
 
660
931
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
@@ -728,7 +999,7 @@ async function processArchive(zipPath) {
728
999
  const price = info.price || 0;
729
1000
  const shipping = info.shipping || 0;
730
1001
 
731
- await sanoraCreateProduct(tenant, title, 'product', description, price, shipping, `product,physical,order:${order}`);
1002
+ await sanoraCreateProduct(tenant, title, 'product', description, price, shipping, buildTags(`product,physical,order:${order}`, info.keywords));
732
1003
 
733
1004
  // Hero image: prefer hero.jpg / hero.png, fall back to first image
734
1005
  const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
@@ -769,7 +1040,7 @@ async function processArchive(zipPath) {
769
1040
  renewalDays: info.renewalDays || 30
770
1041
  };
771
1042
 
772
- await sanoraCreateProduct(tenant, title, 'subscription', description, price, 0, 'subscription');
1043
+ await sanoraCreateProduct(tenant, title, 'subscription', description, price, 0, buildTags('subscription', info.keywords));
773
1044
 
774
1045
  // Upload tier metadata (benefits list, renewal period) as a JSON artifact
775
1046
  const tierBuf = Buffer.from(JSON.stringify(tierMeta));
@@ -800,6 +1071,87 @@ async function processArchive(zipPath) {
800
1071
  }
801
1072
  }
802
1073
 
1074
+ // ---- videos/ ----
1075
+ // Each subfolder is a video. Contains the video file, optional cover/poster image, and info.json.
1076
+ // info.json: { title, description, price, tags[] }
1077
+ // Video is uploaded to Lucille (DO Spaces + WebTorrent seeder); Sanora holds the catalog entry.
1078
+ //
1079
+ // The manifest may specify a lucilleUrl to override the plugin's global config — this lets
1080
+ // different shoppe tenants point to different Lucille instances.
1081
+ //
1082
+ // Deduplication: Lucille stores a SHA-256 contentHash for each uploaded file. Before uploading,
1083
+ // shoppe computes the local file's hash and skips the upload if it matches what Lucille has.
1084
+ const videosDir = path.join(root, 'videos');
1085
+ if (fs.existsSync(videosDir)) {
1086
+ const effectiveLucilleUrl = (manifest.lucilleUrl || '').replace(/\/$/, '') || null;
1087
+
1088
+ const videoFolders = fs.readdirSync(videosDir)
1089
+ .filter(f => fs.statSync(path.join(videosDir, f)).isDirectory())
1090
+ .sort();
1091
+
1092
+ // Fetch existing Lucille videos once for this tenant so we can dedup
1093
+ let existingLucilleVideos = {};
1094
+ if (tenant.lucilleKeys) {
1095
+ existingLucilleVideos = await lucilleGetVideos(tenant.lucilleKeys.uuid, effectiveLucilleUrl);
1096
+ }
1097
+
1098
+ for (const entry of videoFolders) {
1099
+ const entryPath = path.join(videosDir, entry);
1100
+ const folderTitle = entry.replace(/^\d+-/, '');
1101
+ try {
1102
+ const info = readInfo(entryPath);
1103
+ const title = info.title || folderTitle;
1104
+ const description = info.description || '';
1105
+ const price = info.price || 0;
1106
+ const tags = info.tags || [];
1107
+
1108
+ // Compute Lucille videoId deterministically (sha256(lucilleUuid + title))
1109
+ // so we can embed it in the Sanora product tags before calling Lucille.
1110
+ const lucilleBase = (effectiveLucilleUrl || getLucilleUrl()).replace(/\/$/, '');
1111
+ const lucilleVideoId = tenant.lucilleKeys
1112
+ ? crypto.createHash('sha256').update(tenant.lucilleKeys.uuid + title).digest('hex')
1113
+ : null;
1114
+ const videoTags = buildTags('video', info.keywords) +
1115
+ (lucilleVideoId ? `,lucille-id:${lucilleVideoId},lucille-url:${lucilleBase}` : '');
1116
+
1117
+ // Sanora catalog entry (for discovery / storefront)
1118
+ await sanoraCreateProduct(tenant, title, 'video', description, price, 0, videoTags);
1119
+
1120
+ // Cover / poster image (optional)
1121
+ const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
1122
+ const coverFile = images.find(f => /^(cover|poster|hero|thumbnail)\.(jpg|jpeg|png|webp)$/i.test(f)) || images[0];
1123
+ if (coverFile) {
1124
+ const coverBuf = fs.readFileSync(path.join(entryPath, coverFile));
1125
+ await sanoraUploadImage(tenant, title, coverBuf, coverFile);
1126
+ }
1127
+
1128
+ // Video file → Lucille (with content-hash deduplication)
1129
+ const videoFiles = fs.readdirSync(entryPath).filter(f => VIDEO_EXTS.has(path.extname(f).toLowerCase()));
1130
+ if (videoFiles.length > 0) {
1131
+ const videoFilename = videoFiles[0];
1132
+ const videoBuf = fs.readFileSync(path.join(entryPath, videoFilename));
1133
+ const localHash = crypto.createHash('sha256').update(videoBuf).digest('hex');
1134
+
1135
+ const existing = existingLucilleVideos[title];
1136
+ if (existing && existing.contentHash && existing.contentHash === localHash) {
1137
+ console.log(`[shoppe] ⏩ video unchanged, skipping upload: ${title}`);
1138
+ results.videos.push({ title, price, skipped: true });
1139
+ } else {
1140
+ await lucilleRegisterVideo(tenant, title, description, tags, effectiveLucilleUrl);
1141
+ await lucilleUploadVideo(tenant, title, videoBuf, videoFilename, effectiveLucilleUrl);
1142
+ results.videos.push({ title, price });
1143
+ console.log(`[shoppe] 🎬 video: ${title}`);
1144
+ }
1145
+ } else {
1146
+ results.warnings.push(`video "${title}": no video file found (expected .mp4/.mov/.mkv/.webm/.avi)`);
1147
+ }
1148
+ } catch (err) {
1149
+ console.warn(`[shoppe] ⚠️ video ${entry}: ${err.message}`);
1150
+ results.warnings.push(`video "${entry}": ${err.message}`);
1151
+ }
1152
+ }
1153
+ }
1154
+
803
1155
  // ---- appointments/ ----
804
1156
  // Each subfolder is a bookable appointment type.
805
1157
  // info.json: { title, description, price, duration (mins), timezone, availability[], advanceDays }
@@ -825,7 +1177,7 @@ async function processArchive(zipPath) {
825
1177
  advanceDays: info.advanceDays || 30
826
1178
  };
827
1179
 
828
- await sanoraCreateProduct(tenant, title, 'appointment', description, price, 0, 'appointment');
1180
+ await sanoraCreateProduct(tenant, title, 'appointment', description, price, 0, buildTags('appointment', info.keywords));
829
1181
 
830
1182
  // Upload schedule as a JSON artifact so the booking page can retrieve it
831
1183
  const scheduleBuf = Buffer.from(JSON.stringify(schedule));
@@ -864,33 +1216,55 @@ async function processArchive(zipPath) {
864
1216
  async function getShoppeGoods(tenant) {
865
1217
  const resp = await fetch(`${getSanoraUrl()}/products/${tenant.uuid}`);
866
1218
  const products = await resp.json();
1219
+ const redirects = tenant.redirects || {};
1220
+
1221
+ const goods = { books: [], music: [], posts: [], albums: [], products: [], videos: [], appointments: [], subscriptions: [] };
867
1222
 
868
- const goods = { books: [], music: [], posts: [], albums: [], products: [], appointments: [], subscriptions: [] };
1223
+ const CATEGORY_BUCKET = { book: 'books', music: 'music', post: 'posts', 'post-series': 'posts', album: 'albums', product: 'products', video: 'videos', appointment: 'appointments', subscription: 'subscriptions' };
869
1224
 
870
1225
  for (const [title, product] of Object.entries(products)) {
871
1226
  const isPost = product.category === 'post' || product.category === 'post-series';
1227
+ const bucketName = CATEGORY_BUCKET[product.category];
1228
+
1229
+ // Extract lucille-id and lucille-url from tags for video products
1230
+ let lucillePlayerUrl = null;
1231
+ if (product.category === 'video' && product.tags) {
1232
+ const tagParts = product.tags.split(',');
1233
+ const idTag = tagParts.find(t => t.startsWith('lucille-id:'));
1234
+ const urlTag = tagParts.find(t => t.startsWith('lucille-url:'));
1235
+ if (idTag && urlTag) {
1236
+ const videoId = idTag.slice('lucille-id:'.length);
1237
+ const lucilleBase = urlTag.slice('lucille-url:'.length);
1238
+ lucillePlayerUrl = `${lucilleBase}/watch/${videoId}`;
1239
+ }
1240
+ }
1241
+
1242
+ const defaultUrl = isPost
1243
+ ? `/plugin/shoppe/${tenant.uuid}/post/${encodeURIComponent(title)}`
1244
+ : product.category === 'book'
1245
+ ? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
1246
+ : product.category === 'subscription'
1247
+ ? `/plugin/shoppe/${tenant.uuid}/subscribe/${encodeURIComponent(title)}`
1248
+ : product.category === 'appointment'
1249
+ ? `/plugin/shoppe/${tenant.uuid}/book/${encodeURIComponent(title)}`
1250
+ : product.category === 'product' && product.shipping > 0
1251
+ ? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}/address`
1252
+ : product.category === 'product'
1253
+ ? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
1254
+ : product.category === 'video' && lucillePlayerUrl
1255
+ ? lucillePlayerUrl
1256
+ : `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`;
1257
+
872
1258
  const item = {
873
1259
  title: product.title || title,
874
1260
  description: product.description || '',
875
1261
  price: product.price || 0,
876
1262
  shipping: product.shipping || 0,
877
1263
  image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
878
- url: isPost
879
- ? `/plugin/shoppe/${tenant.uuid}/post/${encodeURIComponent(title)}`
880
- : product.category === 'book'
881
- ? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
882
- : product.category === 'subscription'
883
- ? `/plugin/shoppe/${tenant.uuid}/subscribe/${encodeURIComponent(title)}`
884
- : product.category === 'appointment'
885
- ? `/plugin/shoppe/${tenant.uuid}/book/${encodeURIComponent(title)}`
886
- : product.category === 'product' && product.shipping > 0
887
- ? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}/address`
888
- : product.category === 'product'
889
- ? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
890
- : `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`
1264
+ url: (bucketName && redirects[bucketName]) || defaultUrl,
1265
+ ...(lucillePlayerUrl && { lucillePlayerUrl })
891
1266
  };
892
- const CATEGORY_BUCKET = { book: 'books', music: 'music', post: 'posts', 'post-series': 'posts', album: 'albums', product: 'products', appointment: 'appointments', subscription: 'subscriptions' };
893
- const bucket = goods[CATEGORY_BUCKET[product.category]];
1267
+ const bucket = goods[bucketName];
894
1268
  if (bucket) bucket.push(item);
895
1269
  }
896
1270
 
@@ -1015,21 +1389,167 @@ async function getSubscriptionStatus(tenant, productId, recoveryKey) {
1015
1389
  } catch { return { active: false }; }
1016
1390
  }
1017
1391
 
1018
- const CATEGORY_EMOJI = { book: '📚', music: '🎵', post: '📝', album: '🖼️', product: '📦', appointment: '📅', subscription: '🎁' };
1392
+ const CATEGORY_EMOJI = { book: '📚', music: '🎵', post: '📝', album: '🖼️', product: '📦', appointment: '📅', subscription: '🎁', video: '🎬' };
1393
+
1394
+ // ============================================================
1395
+ // OWNER ORDERS
1396
+ // ============================================================
1397
+
1398
+ // Validate an owner-signed request (used for browser-facing owner routes).
1399
+ // Expects req.query.timestamp and req.query.signature.
1400
+ // Returns an error string if invalid, null if valid.
1401
+ function checkOwnerSignature(req, tenant) {
1402
+ if (!tenant.ownerPubKey) return 'This shoppe was registered before owner signing was added';
1403
+ const { timestamp, signature } = req.query;
1404
+ if (!timestamp || !signature) return 'Missing timestamp or signature — generate a fresh URL with: node shoppe-sign.js orders';
1405
+ const age = Date.now() - parseInt(timestamp, 10);
1406
+ if (isNaN(age) || age < 0 || age > 5 * 60 * 1000) return 'URL has expired — generate a new one with: node shoppe-sign.js orders';
1407
+ const message = timestamp + tenant.uuid;
1408
+ if (!sessionless.verifySignature(signature, message, tenant.ownerPubKey)) return 'Signature invalid';
1409
+ return null;
1410
+ }
1411
+
1412
+ // Fetch all orders for every product belonging to a tenant.
1413
+ // Returns an array of { product, orders } objects.
1414
+ async function getAllOrders(tenant) {
1415
+ const sanoraUrl = getSanoraUrl();
1416
+ const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
1417
+ const products = await productsResp.json();
1418
+
1419
+ sessionless.getKeys = () => tenant.keys;
1420
+
1421
+ const results = [];
1422
+ for (const [title, product] of Object.entries(products)) {
1423
+ const timestamp = Date.now().toString();
1424
+ const signature = await sessionless.sign(timestamp + tenant.uuid);
1425
+ try {
1426
+ const resp = await fetch(
1427
+ `${sanoraUrl}/user/${tenant.uuid}/orders/${encodeURIComponent(product.productId)}` +
1428
+ `?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}`
1429
+ );
1430
+ if (!resp.ok) continue;
1431
+ const data = await resp.json();
1432
+ const orders = data.orders || [];
1433
+ if (orders.length > 0) results.push({ product, orders });
1434
+ } catch { /* skip products with no order data */ }
1435
+ }
1436
+ return results;
1437
+ }
1438
+
1439
+ function fmtDate(ts) {
1440
+ return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
1441
+ }
1442
+
1443
+ function generateOrdersHTML(tenant, orderData) {
1444
+ const totalOrders = orderData.reduce((n, p) => n + p.orders.length, 0);
1445
+ const totalRevenue = orderData.reduce((n, p) =>
1446
+ n + p.orders.reduce((m, o) => m + (o.amount || p.product.price || 0), 0), 0);
1447
+
1448
+ const sections = orderData.map(({ product, orders }) => {
1449
+ const emoji = CATEGORY_EMOJI[product.category] || '🛍️';
1450
+ const rows = orders.map(o => {
1451
+ const date = fmtDate(o.paidAt || o.createdAt || Date.now());
1452
+ const amount = o.amount != null ? `$${(o.amount / 100).toFixed(2)}` : `$${((product.price || 0) / 100).toFixed(2)}`;
1453
+ const detail = o.slot
1454
+ ? `<span class="tag">📅 ${o.slot}</span>`
1455
+ : o.renewalDays
1456
+ ? `<span class="tag">🔄 ${o.renewalDays}d renewal</span>`
1457
+ : '';
1458
+ const keyHint = o.orderKey
1459
+ ? `<span class="hash" title="sha256(recoveryKey+productId)">${o.orderKey.slice(0, 12)}…</span>`
1460
+ : '—';
1461
+ return `<tr><td>${date}</td><td>${amount}</td><td>${keyHint}</td><td>${detail}</td></tr>`;
1462
+ }).join('');
1463
+
1464
+ return `
1465
+ <div class="product-section">
1466
+ <div class="product-header">
1467
+ <span class="product-emoji">${emoji}</span>
1468
+ <span class="product-title">${escHtml(product.title || 'Untitled')}</span>
1469
+ <span class="order-count">${orders.length} order${orders.length !== 1 ? 's' : ''}</span>
1470
+ </div>
1471
+ <table>
1472
+ <thead><tr><th>Date</th><th>Amount</th><th>Key hash</th><th>Details</th></tr></thead>
1473
+ <tbody>${rows}</tbody>
1474
+ </table>
1475
+ </div>`;
1476
+ }).join('');
1477
+
1478
+ const empty = totalOrders === 0
1479
+ ? '<p class="empty">No orders yet. Share your shoppe link to get started!</p>'
1480
+ : '';
1481
+
1482
+ return `<!DOCTYPE html>
1483
+ <html lang="en">
1484
+ <head>
1485
+ <meta charset="UTF-8">
1486
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1487
+ <title>Orders — ${escHtml(tenant.name)}</title>
1488
+ <style>
1489
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
1490
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f12; color: #e0e0e0; min-height: 100vh; }
1491
+ header { background: linear-gradient(135deg, #1a1a2e, #0f3460); padding: 36px 32px 28px; }
1492
+ header h1 { font-size: 26px; font-weight: 700; margin-bottom: 4px; }
1493
+ header p { font-size: 14px; color: #aaa; }
1494
+ .stats { display: flex; gap: 20px; padding: 24px 32px; border-bottom: 1px solid #222; }
1495
+ .stat { background: #18181c; border: 1px solid #333; border-radius: 12px; padding: 16px 24px; }
1496
+ .stat-val { font-size: 28px; font-weight: 800; color: #7ec8e3; }
1497
+ .stat-lbl { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
1498
+ .main { max-width: 900px; margin: 0 auto; padding: 28px 24px 60px; }
1499
+ .product-section { margin-bottom: 32px; }
1500
+ .product-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
1501
+ .product-emoji { font-size: 20px; }
1502
+ .product-title { font-size: 17px; font-weight: 600; flex: 1; }
1503
+ .order-count { font-size: 12px; color: #888; background: #222; border-radius: 10px; padding: 3px 10px; }
1504
+ table { width: 100%; border-collapse: collapse; background: #18181c; border: 1px solid #2a2a2e; border-radius: 12px; overflow: hidden; }
1505
+ thead { background: #222; }
1506
+ th { padding: 10px 14px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #888; text-align: left; }
1507
+ td { padding: 11px 14px; font-size: 13px; border-top: 1px solid #222; }
1508
+ .hash { font-family: monospace; font-size: 12px; color: #7ec8e3; }
1509
+ .tag { background: #2a2a2e; border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #ccc; }
1510
+ .empty { color: #555; font-size: 15px; text-align: center; padding: 60px 0; }
1511
+ .back { display: inline-block; margin-bottom: 20px; color: #7ec8e3; text-decoration: none; font-size: 13px; }
1512
+ .back:hover { text-decoration: underline; }
1513
+ .warning { background: #2a1f0a; border: 1px solid #665; border-radius: 10px; padding: 12px 16px; font-size: 13px; color: #cc9; margin-bottom: 24px; }
1514
+ </style>
1515
+ </head>
1516
+ <body>
1517
+ <header>
1518
+ <h1>${escHtml(tenant.emojicode)} ${escHtml(tenant.name)}</h1>
1519
+ <p>Order history — this URL is valid for 5 minutes</p>
1520
+ </header>
1521
+ <div class="stats">
1522
+ <div class="stat"><div class="stat-val">${totalOrders}</div><div class="stat-lbl">Total orders</div></div>
1523
+ <div class="stat"><div class="stat-val">$${(totalRevenue / 100).toFixed(2)}</div><div class="stat-lbl">Total revenue</div></div>
1524
+ <div class="stat"><div class="stat-val">${orderData.length}</div><div class="stat-lbl">Products sold</div></div>
1525
+ </div>
1526
+ <div class="main">
1527
+ <a class="back" href="/plugin/shoppe/${tenant.uuid}">← Back to shoppe</a>
1528
+ <div class="warning">🔑 Key hashes are shown (not recovery keys — those never reach this server). Revenue totals are approximate when order amounts aren't stored.</div>
1529
+ ${empty}
1530
+ ${sections}
1531
+ </div>
1532
+ </body>
1533
+ </html>`;
1534
+ }
1019
1535
 
1020
1536
  function renderCards(items, category) {
1021
1537
  if (items.length === 0) {
1022
1538
  return '<p class="empty">Nothing here yet.</p>';
1023
1539
  }
1024
1540
  return items.map(item => {
1541
+ const isVideo = !!item.lucillePlayerUrl;
1025
1542
  const imgHtml = item.image
1026
- ? `<div class="card-img"><img src="${item.image}" alt="" loading="lazy"></div>`
1543
+ ? `<div class="card-img${isVideo ? ' card-video-play' : ''}"><img src="${item.image}" alt="" loading="lazy"></div>`
1027
1544
  : `<div class="card-img-placeholder">${CATEGORY_EMOJI[category] || '🎁'}</div>`;
1028
1545
  const priceHtml = (item.price > 0 || category === 'product')
1029
1546
  ? `<div class="price">$${(item.price / 100).toFixed(2)}${item.shipping ? ` <span class="shipping">+ $${(item.shipping / 100).toFixed(2)} shipping</span>` : ''}</div>`
1030
1547
  : '';
1548
+ const clickHandler = isVideo
1549
+ ? `playVideo('${item.lucillePlayerUrl}')`
1550
+ : `window.open('${item.url}','_blank')`;
1031
1551
  return `
1032
- <div class="card" onclick="window.open('${item.url}','_blank')">
1552
+ <div class="card" onclick="${clickHandler}">
1033
1553
  ${imgHtml}
1034
1554
  <div class="card-body">
1035
1555
  <div class="card-title">${item.title}</div>
@@ -1049,6 +1569,7 @@ function generateShoppeHTML(tenant, goods) {
1049
1569
  { id: 'posts', label: '📝 Posts', count: goods.posts.length },
1050
1570
  { id: 'albums', label: '🖼️ Albums', count: goods.albums.length },
1051
1571
  { id: 'products', label: '📦 Products', count: goods.products.length },
1572
+ { id: 'videos', label: '🎬 Videos', count: goods.videos.length },
1052
1573
  { id: 'appointments', label: '📅 Appointments', count: goods.appointments.length },
1053
1574
  { id: 'subscriptions', label: '🎁 Infuse', count: goods.subscriptions.length }
1054
1575
  ]
@@ -1056,7 +1577,7 @@ function generateShoppeHTML(tenant, goods) {
1056
1577
  .map((t, i) => `<div class="tab${i === 0 ? ' active' : ''}" onclick="show('${t.id}',this)">${t.label} <span class="badge">${t.count}</span></div>`)
1057
1578
  .join('');
1058
1579
 
1059
- const allItems = [...goods.books, ...goods.music, ...goods.posts, ...goods.albums, ...goods.products, ...goods.appointments, ...goods.subscriptions];
1580
+ const allItems = [...goods.books, ...goods.music, ...goods.posts, ...goods.albums, ...goods.products, ...goods.videos, ...goods.appointments, ...goods.subscriptions];
1060
1581
 
1061
1582
  return `<!DOCTYPE html>
1062
1583
  <html lang="en">
@@ -1064,6 +1585,7 @@ function generateShoppeHTML(tenant, goods) {
1064
1585
  <meta charset="UTF-8">
1065
1586
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1066
1587
  <title>${tenant.name}</title>
1588
+ ${tenant.keywords ? `<meta name="keywords" content="${escHtml(tenant.keywords)}">` : ''}
1067
1589
  <style>
1068
1590
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
1069
1591
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f7; color: #1d1d1f; }
@@ -1090,6 +1612,16 @@ function generateShoppeHTML(tenant, goods) {
1090
1612
  .price { font-size: 15px; font-weight: 700; color: #0066cc; }
1091
1613
  .shipping { font-size: 12px; font-weight: 400; color: #888; }
1092
1614
  .empty { color: #999; text-align: center; padding: 60px 0; font-size: 15px; }
1615
+ .card-video-play { position: relative; }
1616
+ .card-video-play::after { content: '▶'; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 36px; color: rgba(255,255,255,0.9); background: rgba(0,0,0,0.35); opacity: 0; transition: opacity 0.2s; pointer-events: none; }
1617
+ .card:hover .card-video-play::after { opacity: 1; }
1618
+ .video-modal { display: none; position: fixed; inset: 0; z-index: 1000; align-items: center; justify-content: center; }
1619
+ .video-modal.open { display: flex; }
1620
+ .video-modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.85); }
1621
+ .video-modal-content { position: relative; z-index: 1; width: 90vw; max-width: 960px; aspect-ratio: 16/9; background: #000; border-radius: 10px; overflow: hidden; box-shadow: 0 24px 80px rgba(0,0,0,0.6); }
1622
+ .video-modal-content iframe { width: 100%; height: 100%; border: none; display: block; }
1623
+ .video-modal-close { position: absolute; top: 10px; right: 12px; z-index: 2; background: rgba(0,0,0,0.5); border: none; color: #fff; font-size: 20px; line-height: 1; padding: 4px 10px; border-radius: 6px; cursor: pointer; }
1624
+ .video-modal-close:hover { background: rgba(0,0,0,0.8); }
1093
1625
  </style>
1094
1626
  </head>
1095
1627
  <body>
@@ -1106,12 +1638,20 @@ function generateShoppeHTML(tenant, goods) {
1106
1638
  <div id="posts" class="section"><div class="grid">${renderCards(goods.posts, 'post')}</div></div>
1107
1639
  <div id="albums" class="section"><div class="grid">${renderCards(goods.albums, 'album')}</div></div>
1108
1640
  <div id="products" class="section"><div class="grid">${renderCards(goods.products, 'product')}</div></div>
1641
+ <div id="videos" class="section"><div class="grid">${renderCards(goods.videos, 'video')}</div></div>
1109
1642
  <div id="appointments" class="section"><div class="grid">${renderCards(goods.appointments, 'appointment')}</div></div>
1110
1643
  <div id="subscriptions" class="section"><div class="grid">${renderCards(goods.subscriptions, 'subscription')}</div></div>
1111
1644
  <div style="text-align:center;padding:24px 0 8px;font-size:14px;color:#888;">
1112
1645
  Already infusing? <a href="/plugin/shoppe/${tenant.uuid}/membership" style="color:#0066cc;">Access your membership →</a>
1113
1646
  </div>
1114
1647
  </main>
1648
+ <div id="video-modal" class="video-modal">
1649
+ <div class="video-modal-backdrop" onclick="closeVideo()"></div>
1650
+ <div class="video-modal-content">
1651
+ <button class="video-modal-close" onclick="closeVideo()">✕</button>
1652
+ <iframe id="video-iframe" src="" allowfullscreen allow="autoplay"></iframe>
1653
+ </div>
1654
+ </div>
1115
1655
  <script>
1116
1656
  function show(id, tab) {
1117
1657
  document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
@@ -1119,6 +1659,15 @@ function generateShoppeHTML(tenant, goods) {
1119
1659
  document.getElementById(id).classList.add('active');
1120
1660
  tab.classList.add('active');
1121
1661
  }
1662
+ function playVideo(url) {
1663
+ document.getElementById('video-iframe').src = url;
1664
+ document.getElementById('video-modal').classList.add('open');
1665
+ }
1666
+ function closeVideo() {
1667
+ document.getElementById('video-modal').classList.remove('open');
1668
+ document.getElementById('video-iframe').src = '';
1669
+ }
1670
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeVideo(); });
1122
1671
  </script>
1123
1672
  </body>
1124
1673
  </html>`;
@@ -1193,14 +1742,54 @@ async function startServer(params) {
1193
1742
  // Register a new tenant (owner only)
1194
1743
  app.post('/plugin/shoppe/register', owner, async (req, res) => {
1195
1744
  try {
1196
- const tenant = await registerTenant(req.body.name);
1197
- res.json({ success: true, tenant });
1745
+ const { uuid, emojicode, name, ownerPrivateKey, ownerPubKey } = await registerTenant(req.body.name);
1746
+
1747
+ // Generate a single-use, short-lived token for the starter bundle download.
1748
+ const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
1749
+ const token = crypto.randomBytes(24).toString('hex');
1750
+ bundleTokens.set(token, { uuid, ownerPrivateKey, ownerPubKey, wikiOrigin, expiresAt: Date.now() + 15 * 60 * 1000 });
1751
+
1752
+ // Expire tokens automatically after 15 minutes.
1753
+ setTimeout(() => bundleTokens.delete(token), 15 * 60 * 1000);
1754
+
1755
+ res.json({ success: true, tenant: { uuid, emojicode, name }, bundleToken: token });
1198
1756
  } catch (err) {
1199
1757
  console.error('[shoppe] register error:', err);
1200
1758
  res.status(500).json({ success: false, error: err.message });
1201
1759
  }
1202
1760
  });
1203
1761
 
1762
+ // Starter bundle download — single-use token acts as the credential.
1763
+ // The zip contains manifest.json, shoppe-key.json (private key), shoppe-sign.js, and empty content folders.
1764
+ app.get('/plugin/shoppe/bundle/:token', (req, res) => {
1765
+ const entry = bundleTokens.get(req.params.token);
1766
+ if (!entry) {
1767
+ return res.status(404).send('<h1>Bundle link expired or invalid</h1><p>Re-register to get a new link.</p>');
1768
+ }
1769
+ if (Date.now() > entry.expiresAt) {
1770
+ bundleTokens.delete(req.params.token);
1771
+ return res.status(410).send('<h1>Bundle link expired</h1><p>Re-register to get a new link.</p>');
1772
+ }
1773
+
1774
+ // Invalidate immediately — single use
1775
+ bundleTokens.delete(req.params.token);
1776
+
1777
+ const tenant = getTenantByIdentifier(entry.uuid);
1778
+ if (!tenant) return res.status(404).send('<h1>Tenant not found</h1>');
1779
+
1780
+ try {
1781
+ const buf = generateBundleBuffer(tenant, entry.ownerPrivateKey, entry.ownerPubKey, entry.wikiOrigin);
1782
+ const filename = `${tenant.name.replace(/[^a-z0-9-]/gi, '-').toLowerCase()}-shoppe-starter.zip`;
1783
+ res.set('Content-Type', 'application/zip');
1784
+ res.set('Content-Disposition', `attachment; filename="${filename}"`);
1785
+ res.send(buf);
1786
+ console.log(`[shoppe] Starter bundle downloaded for "${tenant.name}" (${tenant.uuid})`);
1787
+ } catch (err) {
1788
+ console.error('[shoppe] bundle error:', err);
1789
+ res.status(500).send('<h1>Error generating bundle</h1><p>' + err.message + '</p>');
1790
+ }
1791
+ });
1792
+
1204
1793
  // List all tenants (owner only — includes uuid for management)
1205
1794
  app.get('/plugin/shoppe/tenants', owner, (req, res) => {
1206
1795
  const tenants = loadTenants();
@@ -1243,16 +1832,17 @@ async function startServer(params) {
1243
1832
  // Get config (owner only)
1244
1833
  app.get('/plugin/shoppe/config', owner, (req, res) => {
1245
1834
  const config = loadConfig();
1246
- res.json({ success: true, sanoraUrl: config.sanoraUrl || '' });
1835
+ res.json({ success: true, sanoraUrl: config.sanoraUrl || '', lucilleUrl: config.lucilleUrl || '' });
1247
1836
  });
1248
1837
 
1249
1838
  // Save config (owner only)
1250
1839
  app.post('/plugin/shoppe/config', owner, (req, res) => {
1251
- const { sanoraUrl, addieUrl } = req.body;
1840
+ const { sanoraUrl, addieUrl, lucilleUrl } = req.body;
1252
1841
  if (!sanoraUrl) return res.status(400).json({ success: false, error: 'sanoraUrl required' });
1253
1842
  const config = loadConfig();
1254
1843
  config.sanoraUrl = sanoraUrl;
1255
1844
  if (addieUrl) config.addieUrl = addieUrl;
1845
+ if (lucilleUrl) config.lucilleUrl = lucilleUrl;
1256
1846
  saveConfig(config);
1257
1847
  console.log('[shoppe] Sanora URL set to:', sanoraUrl);
1258
1848
  res.json({ success: true });
@@ -1294,7 +1884,8 @@ async function startServer(params) {
1294
1884
  ebookUrl,
1295
1885
  shoppeUrl,
1296
1886
  payees,
1297
- tenantUuid: tenant.uuid
1887
+ tenantUuid: tenant.uuid,
1888
+ keywords: extractKeywords(product)
1298
1889
  });
1299
1890
 
1300
1891
  res.set('Content-Type', 'text/html');
@@ -1343,7 +1934,8 @@ async function startServer(params) {
1343
1934
  duration: String(schedule ? schedule.duration : 60),
1344
1935
  proceedLabel: price === 0 ? 'Confirm Booking →' : 'Continue to Payment →',
1345
1936
  shoppeUrl,
1346
- tenantUuid: tenant.uuid
1937
+ tenantUuid: tenant.uuid,
1938
+ keywords: extractKeywords(product)
1347
1939
  });
1348
1940
 
1349
1941
  res.set('Content-Type', 'text/html');
@@ -1411,7 +2003,8 @@ async function startServer(params) {
1411
2003
  benefits,
1412
2004
  renewalDays: String(tierInfo ? (tierInfo.renewalDays || 30) : 30),
1413
2005
  shoppeUrl,
1414
- tenantUuid: tenant.uuid
2006
+ tenantUuid: tenant.uuid,
2007
+ keywords: extractKeywords(product)
1415
2008
  });
1416
2009
 
1417
2010
  res.set('Content-Type', 'text/html');
@@ -1422,6 +2015,114 @@ async function startServer(params) {
1422
2015
  }
1423
2016
  });
1424
2017
 
2018
+ // Owner orders page — authenticated via signed URL from shoppe-sign.js
2019
+ app.get('/plugin/shoppe/:uuid/orders', async (req, res) => {
2020
+ try {
2021
+ const tenant = getTenantByIdentifier(req.params.uuid);
2022
+ if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
2023
+
2024
+ const err = checkOwnerSignature(req, tenant);
2025
+ if (err) {
2026
+ return res.status(403).send(
2027
+ `<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px;background:#0f0f12;color:#e0e0e0">` +
2028
+ `<h2>Access denied</h2><p style="color:#f66;margin-top:12px">${escHtml(err)}</p></body></html>`
2029
+ );
2030
+ }
2031
+
2032
+ const orderData = await getAllOrders(tenant);
2033
+ res.set('Content-Type', 'text/html');
2034
+ res.send(generateOrdersHTML(tenant, orderData));
2035
+ } catch (err) {
2036
+ console.error('[shoppe] orders page error:', err);
2037
+ res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
2038
+ }
2039
+ });
2040
+
2041
+ // Owner payouts setup — validates owner sig, redirects to Stripe Connect Express onboarding
2042
+ app.get('/plugin/shoppe/:uuid/payouts', async (req, res) => {
2043
+ try {
2044
+ const tenant = getTenantByIdentifier(req.params.uuid);
2045
+ if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
2046
+
2047
+ const err = checkOwnerSignature(req, tenant);
2048
+ if (err) {
2049
+ return res.status(403).send(
2050
+ `<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px;background:#0f0f12;color:#e0e0e0">` +
2051
+ `<h2>Access denied</h2><p style="color:#f66;margin-top:12px">${escHtml(err)}</p></body></html>`
2052
+ );
2053
+ }
2054
+
2055
+ if (!tenant.addieKeys) {
2056
+ return res.status(500).send(
2057
+ `<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px;background:#0f0f12;color:#e0e0e0">` +
2058
+ `<h2>Payment account not configured</h2><p>This shoppe has no Addie user. Re-register to get one.</p></body></html>`
2059
+ );
2060
+ }
2061
+
2062
+ const addieKeys = { pubKey: tenant.addieKeys.pubKey, privateKey: tenant.addieKeys.privateKey };
2063
+ sessionless.getKeys = () => addieKeys;
2064
+ const timestamp = Date.now().toString();
2065
+ const message = timestamp + tenant.addieKeys.uuid;
2066
+ const signature = await sessionless.sign(message);
2067
+
2068
+ const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
2069
+ const returnUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}/payouts/return`;
2070
+
2071
+ const resp = await fetch(`${getAddieUrl()}/user/${tenant.addieKeys.uuid}/processor/stripe/express`, {
2072
+ method: 'PUT',
2073
+ headers: { 'Content-Type': 'application/json' },
2074
+ body: JSON.stringify({ timestamp, pubKey: tenant.addieKeys.pubKey, signature, returnUrl })
2075
+ });
2076
+ const json = await resp.json();
2077
+
2078
+ if (json.error) {
2079
+ return res.status(500).send(
2080
+ `<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px;background:#0f0f12;color:#e0e0e0">` +
2081
+ `<h2>Error setting up payouts</h2><p style="color:#f66;margin-top:12px">${escHtml(json.error)}</p></body></html>`
2082
+ );
2083
+ }
2084
+
2085
+ res.redirect(json.onboardingUrl);
2086
+ } catch (err) {
2087
+ console.error('[shoppe] payouts error:', err);
2088
+ res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
2089
+ }
2090
+ });
2091
+
2092
+ // Stripe Connect Express return page — no auth, Stripe redirects here after onboarding
2093
+ app.get('/plugin/shoppe/:uuid/payouts/return', (req, res) => {
2094
+ const tenant = getTenantByIdentifier(req.params.uuid);
2095
+ const name = tenant ? escHtml(tenant.name) : 'your shoppe';
2096
+ const shoppeUrl = tenant ? `/plugin/shoppe/${tenant.uuid}` : '/';
2097
+ res.set('Content-Type', 'text/html');
2098
+ res.send(`<!DOCTYPE html>
2099
+ <html lang="en">
2100
+ <head>
2101
+ <meta charset="UTF-8">
2102
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2103
+ <title>Payouts connected — ${name}</title>
2104
+ <style>
2105
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2106
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f12; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
2107
+ .card { background: #18181c; border: 1px solid #333; border-radius: 16px; padding: 48px 40px; max-width: 480px; text-align: center; }
2108
+ h1 { font-size: 28px; font-weight: 700; margin-bottom: 12px; }
2109
+ p { color: #aaa; font-size: 15px; line-height: 1.6; margin-top: 10px; }
2110
+ a { display: inline-block; margin-top: 28px; color: #7ec8e3; text-decoration: none; font-size: 14px; }
2111
+ a:hover { text-decoration: underline; }
2112
+ </style>
2113
+ </head>
2114
+ <body>
2115
+ <div class="card">
2116
+ <div style="font-size:52px;margin-bottom:20px">✅</div>
2117
+ <h1>Payouts connected!</h1>
2118
+ <p>Your Stripe account is now linked to <strong>${name}</strong>.</p>
2119
+ <p>Payments will be transferred to your account automatically after each sale.</p>
2120
+ <a href="${escHtml(shoppeUrl)}">← Back to shoppe</a>
2121
+ </div>
2122
+ </body>
2123
+ </html>`);
2124
+ });
2125
+
1425
2126
  // Membership portal page
1426
2127
  app.get('/plugin/shoppe/:identifier/membership', (req, res) => {
1427
2128
  const tenant = getTenantByIdentifier(req.params.identifier);
@@ -1495,7 +2196,7 @@ async function startServer(params) {
1495
2196
  const tenant = getTenantByIdentifier(req.params.identifier);
1496
2197
  if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
1497
2198
 
1498
- const { recoveryKey, productId, title, slotDatetime } = req.body;
2199
+ const { recoveryKey, productId, title, slotDatetime, payees: clientPayees } = req.body;
1499
2200
  if (!productId) return res.status(400).json({ error: 'productId required' });
1500
2201
  if (!recoveryKey && !title) return res.status(400).json({ error: 'recoveryKey or title required' });
1501
2202
 
@@ -1546,7 +2247,19 @@ async function startServer(params) {
1546
2247
  }
1547
2248
 
1548
2249
  // Sign and create Stripe intent via Addie
1549
- const payees = tenant.addieKeys ? [{ pubKey: tenant.addieKeys.pubKey, amount }] : [];
2250
+ // Client may supply payees parsed from ?payees= URL param (pipe-separated 4-tuples).
2251
+ // Each payee is capped at 5% of the product price; any that exceed this are dropped.
2252
+ const maxPayeeAmount = amount * 0.05;
2253
+ const validatedPayees = Array.isArray(clientPayees)
2254
+ ? clientPayees.filter(p => {
2255
+ if (p.percent != null && p.percent > 5) return false;
2256
+ if (p.amount != null && p.amount > maxPayeeAmount) return false;
2257
+ return true;
2258
+ })
2259
+ : [];
2260
+ const payees = validatedPayees.length > 0
2261
+ ? validatedPayees
2262
+ : tenant.addieKeys ? [{ pubKey: tenant.addieKeys.pubKey, amount }] : [];
1550
2263
  const buyerKeys = { pubKey: buyer.pubKey, privateKey: buyer.privateKey };
1551
2264
  sessionless.getKeys = () => buyerKeys;
1552
2265
  const intentTimestamp = Date.now().toString();
@@ -1579,9 +2292,18 @@ async function startServer(params) {
1579
2292
  const tenant = getTenantByIdentifier(req.params.identifier);
1580
2293
  if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
1581
2294
 
1582
- const { recoveryKey, productId, orderRef, address, title, amount, slotDatetime, contactInfo, type, renewalDays } = req.body;
2295
+ const { recoveryKey, productId, orderRef, address, title, amount, slotDatetime, contactInfo, type, renewalDays, paymentIntentId } = req.body;
1583
2296
  const sanoraUrlInternal = getSanoraUrl();
1584
2297
 
2298
+ // Fire transfer after successful payment — fire-and-forget, does not affect response
2299
+ function triggerTransfer() {
2300
+ if (!paymentIntentId || !tenant.addieKeys) return;
2301
+ fetch(`${getAddieUrl()}/payment/${encodeURIComponent(paymentIntentId)}/process-transfers`, {
2302
+ method: 'POST',
2303
+ headers: { 'Content-Type': 'application/json' }
2304
+ }).catch(err => console.warn('[shoppe] transfer trigger failed:', err.message));
2305
+ }
2306
+
1585
2307
  if (recoveryKey && type === 'subscription') {
1586
2308
  // Subscription payment — record an order with a hashed subscriber key + payment timestamp.
1587
2309
  // The recovery key itself is never stored; orderKey = sha256(recoveryKey + productId).
@@ -1596,6 +2318,7 @@ async function startServer(params) {
1596
2318
  headers: { 'Content-Type': 'application/json' },
1597
2319
  body: JSON.stringify({ timestamp: ts, signature: sig, order })
1598
2320
  });
2321
+ triggerTransfer();
1599
2322
  return res.json({ success: true });
1600
2323
  }
1601
2324
 
@@ -1622,6 +2345,7 @@ async function startServer(params) {
1622
2345
  headers: { 'Content-Type': 'application/json' },
1623
2346
  body: JSON.stringify({ timestamp: bookingTimestamp, signature: bookingSignature, order })
1624
2347
  });
2348
+ triggerTransfer();
1625
2349
  return res.json({ success: true });
1626
2350
  }
1627
2351
 
@@ -1630,6 +2354,7 @@ async function startServer(params) {
1630
2354
  const recoveryHash = recoveryKey + productId;
1631
2355
  const createResp = await fetch(`${sanoraUrlInternal}/user/create-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
1632
2356
  const createJson = await createResp.json();
2357
+ triggerTransfer();
1633
2358
  return res.json({ success: createJson.success });
1634
2359
  }
1635
2360
 
@@ -1661,6 +2386,7 @@ async function startServer(params) {
1661
2386
  headers: { 'Content-Type': 'application/json' },
1662
2387
  body: JSON.stringify({ timestamp: orderTimestamp, signature: orderSignature, order })
1663
2388
  });
2389
+ triggerTransfer();
1664
2390
  return res.json({ success: true });
1665
2391
  }
1666
2392