wiki-plugin-shoppe 0.0.21 → 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
@@ -48,6 +48,12 @@ function saveConfig(config) {
48
48
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
49
49
  }
50
50
 
51
+ // Derive the public-facing protocol from the request, respecting reverse-proxy headers.
52
+ // Behind HTTPS proxies req.protocol is 'http'; X-Forwarded-Proto carries the real value.
53
+ function reqProto(req) {
54
+ return (req.get('x-forwarded-proto') || req.protocol || 'https').split(',')[0].trim();
55
+ }
56
+
51
57
  function getSanoraUrl() {
52
58
  const config = loadConfig();
53
59
  if (config.sanoraUrl) return config.sanoraUrl.replace(/\/$/, '');
@@ -59,6 +65,12 @@ function getAddieUrl() {
59
65
  return `http://localhost:${process.env.ADDIE_PORT || 3005}`;
60
66
  }
61
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
+
62
74
  function loadBuyers() {
63
75
  if (!fs.existsSync(BUYERS_FILE)) return {};
64
76
  try { return JSON.parse(fs.readFileSync(BUYERS_FILE, 'utf8')); } catch { return {}; }
@@ -111,6 +123,7 @@ const EMOJI_PALETTE = [
111
123
  const BOOK_EXTS = new Set(['.epub', '.pdf', '.mobi', '.azw', '.azw3']);
112
124
  const MUSIC_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav']);
113
125
  const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']);
126
+ const VIDEO_EXTS = new Set(['.mp4', '.mov', '.mkv', '.webm', '.avi']);
114
127
 
115
128
  // ============================================================
116
129
  // MARKDOWN / FRONT MATTER UTILITIES
@@ -135,6 +148,147 @@ function escHtml(str) {
135
148
  return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
136
149
  }
137
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
+
138
292
  function renderMarkdown(md) {
139
293
  // Process code blocks first to avoid mangling their contents
140
294
  const codeBlocks = [];
@@ -247,6 +401,16 @@ async function registerTenant(name) {
247
401
  console.warn('[shoppe] Could not create addie user (payouts unavailable):', err.message);
248
402
  }
249
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
+
250
414
  const tenant = {
251
415
  uuid: sanoraUser.uuid,
252
416
  emojicode,
@@ -254,6 +418,8 @@ async function registerTenant(name) {
254
418
  keys,
255
419
  sanoraUser,
256
420
  addieKeys,
421
+ lucilleKeys,
422
+ ownerPubKey: ownerKeys.pubKey,
257
423
  createdAt: Date.now()
258
424
  };
259
425
 
@@ -261,7 +427,15 @@ async function registerTenant(name) {
261
427
  saveTenants(tenants);
262
428
 
263
429
  console.log(`[shoppe] Registered tenant: "${name}" ${emojicode} (${sanoraUser.uuid})`);
264
- 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
+ };
265
439
  }
266
440
 
267
441
  function getTenantByIdentifier(identifier) {
@@ -384,6 +558,91 @@ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
384
558
  return result;
385
559
  }
386
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
+
387
646
  // ============================================================
388
647
  // ARCHIVE PROCESSING
389
648
  // ============================================================
@@ -427,7 +686,25 @@ async function processArchive(zipPath) {
427
686
  throw new Error('emojicode does not match registered tenant');
428
687
  }
429
688
 
430
- 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: [] };
431
708
 
432
709
  function readInfo(entryPath) {
433
710
  const infoPath = path.join(entryPath, 'info.json');
@@ -455,7 +732,7 @@ async function processArchive(zipPath) {
455
732
  const description = info.description || '';
456
733
  const price = info.price || 0;
457
734
 
458
- await sanoraCreateProduct(tenant, title, 'book', description, price, 0, 'book');
735
+ await sanoraCreateProduct(tenant, title, 'book', description, price, 0, buildTags('book', info.keywords));
459
736
 
460
737
  // Cover image — use info.cover to pin a specific file, else first image found
461
738
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
@@ -497,7 +774,7 @@ async function processArchive(zipPath) {
497
774
  try {
498
775
  const description = info.description || `Album: ${albumTitle}`;
499
776
  const price = info.price || 0;
500
- await sanoraCreateProduct(tenant, albumTitle, 'music', description, price, 0, 'music,album');
777
+ await sanoraCreateProduct(tenant, albumTitle, 'music', description, price, 0, buildTags('music,album', info.keywords));
501
778
  const coverFile = info.cover ? (covers.find(f => f === info.cover) || covers[0]) : covers[0];
502
779
  if (coverFile) {
503
780
  const coverBuf = fs.readFileSync(path.join(entryPath, coverFile));
@@ -526,7 +803,7 @@ async function processArchive(zipPath) {
526
803
  const buf = fs.readFileSync(entryPath);
527
804
  const description = trackInfo.description || `Track: ${title}`;
528
805
  const price = trackInfo.price || 0;
529
- await sanoraCreateProduct(tenant, title, 'music', description, price, 0, 'music,track');
806
+ await sanoraCreateProduct(tenant, title, 'music', description, price, 0, buildTags('music,track', trackInfo.keywords));
530
807
  await sanoraUploadArtifact(tenant, title, buf, entry, 'audio');
531
808
  results.music.push({ title, type: 'track' });
532
809
  console.log(`[shoppe] 🎵 track: ${title}`);
@@ -566,7 +843,7 @@ async function processArchive(zipPath) {
566
843
  // Register the series itself as a parent product
567
844
  try {
568
845
  const description = info.description || `A ${subDirs.length}-part series`;
569
- 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));
570
847
 
571
848
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
572
849
  if (covers.length > 0) {
@@ -607,7 +884,7 @@ async function processArchive(zipPath) {
607
884
  const description = partInfo.description || partFm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || resolvedTitle;
608
885
 
609
886
  await sanoraCreateProduct(tenant, productTitle, 'post', description, 0, 0,
610
- `post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`);
887
+ buildTags(`post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`, partInfo.keywords));
611
888
 
612
889
  await sanoraUploadArtifact(tenant, productTitle, mdBuf, partMdFiles[0], 'text');
613
890
 
@@ -648,7 +925,7 @@ async function processArchive(zipPath) {
648
925
  const firstLine = fm.body.split('\n').find(l => l.trim()).replace(/^#+\s*/, '');
649
926
  const description = info.description || fm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || firstLine || title;
650
927
 
651
- 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));
652
929
  await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
653
930
 
654
931
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
@@ -722,7 +999,7 @@ async function processArchive(zipPath) {
722
999
  const price = info.price || 0;
723
1000
  const shipping = info.shipping || 0;
724
1001
 
725
- 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));
726
1003
 
727
1004
  // Hero image: prefer hero.jpg / hero.png, fall back to first image
728
1005
  const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
@@ -763,7 +1040,7 @@ async function processArchive(zipPath) {
763
1040
  renewalDays: info.renewalDays || 30
764
1041
  };
765
1042
 
766
- await sanoraCreateProduct(tenant, title, 'subscription', description, price, 0, 'subscription');
1043
+ await sanoraCreateProduct(tenant, title, 'subscription', description, price, 0, buildTags('subscription', info.keywords));
767
1044
 
768
1045
  // Upload tier metadata (benefits list, renewal period) as a JSON artifact
769
1046
  const tierBuf = Buffer.from(JSON.stringify(tierMeta));
@@ -794,6 +1071,87 @@ async function processArchive(zipPath) {
794
1071
  }
795
1072
  }
796
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
+
797
1155
  // ---- appointments/ ----
798
1156
  // Each subfolder is a bookable appointment type.
799
1157
  // info.json: { title, description, price, duration (mins), timezone, availability[], advanceDays }
@@ -819,7 +1177,7 @@ async function processArchive(zipPath) {
819
1177
  advanceDays: info.advanceDays || 30
820
1178
  };
821
1179
 
822
- await sanoraCreateProduct(tenant, title, 'appointment', description, price, 0, 'appointment');
1180
+ await sanoraCreateProduct(tenant, title, 'appointment', description, price, 0, buildTags('appointment', info.keywords));
823
1181
 
824
1182
  // Upload schedule as a JSON artifact so the booking page can retrieve it
825
1183
  const scheduleBuf = Buffer.from(JSON.stringify(schedule));
@@ -858,33 +1216,55 @@ async function processArchive(zipPath) {
858
1216
  async function getShoppeGoods(tenant) {
859
1217
  const resp = await fetch(`${getSanoraUrl()}/products/${tenant.uuid}`);
860
1218
  const products = await resp.json();
1219
+ const redirects = tenant.redirects || {};
1220
+
1221
+ const goods = { books: [], music: [], posts: [], albums: [], products: [], videos: [], appointments: [], subscriptions: [] };
861
1222
 
862
- 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' };
863
1224
 
864
1225
  for (const [title, product] of Object.entries(products)) {
865
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
+
866
1258
  const item = {
867
1259
  title: product.title || title,
868
1260
  description: product.description || '',
869
1261
  price: product.price || 0,
870
1262
  shipping: product.shipping || 0,
871
1263
  image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
872
- url: isPost
873
- ? `/plugin/shoppe/${tenant.uuid}/post/${encodeURIComponent(title)}`
874
- : product.category === 'book'
875
- ? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
876
- : product.category === 'subscription'
877
- ? `/plugin/shoppe/${tenant.uuid}/subscribe/${encodeURIComponent(title)}`
878
- : product.category === 'appointment'
879
- ? `/plugin/shoppe/${tenant.uuid}/book/${encodeURIComponent(title)}`
880
- : product.category === 'product' && product.shipping > 0
881
- ? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}/address`
882
- : product.category === 'product'
883
- ? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
884
- : `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`
1264
+ url: (bucketName && redirects[bucketName]) || defaultUrl,
1265
+ ...(lucillePlayerUrl && { lucillePlayerUrl })
885
1266
  };
886
- const CATEGORY_BUCKET = { book: 'books', music: 'music', post: 'posts', 'post-series': 'posts', album: 'albums', product: 'products', appointment: 'appointments', subscription: 'subscriptions' };
887
- const bucket = goods[CATEGORY_BUCKET[product.category]];
1267
+ const bucket = goods[bucketName];
888
1268
  if (bucket) bucket.push(item);
889
1269
  }
890
1270
 
@@ -898,7 +1278,7 @@ async function getShoppeGoods(tenant) {
898
1278
  // Fetch and parse the schedule JSON artifact for an appointment product.
899
1279
  async function getAppointmentSchedule(tenant, product) {
900
1280
  const sanoraUrl = getSanoraUrl();
901
- const scheduleArtifact = (product.artifacts || []).find(a => a.includes('schedule'));
1281
+ const scheduleArtifact = (product.artifacts || []).find(a => a.endsWith('.json'));
902
1282
  if (!scheduleArtifact) return null;
903
1283
  const resp = await fetch(`${sanoraUrl}/artifacts/${scheduleArtifact}`);
904
1284
  if (!resp.ok) return null;
@@ -948,20 +1328,15 @@ function generateAvailableSlots(schedule, bookedSlots) {
948
1328
  const dateStr = dateFmt.format(date);
949
1329
  const dayName = weekdayFmt.format(date).toLowerCase();
950
1330
  const rule = (schedule.availability || []).find(a => a.day.toLowerCase() === dayName);
951
- if (!rule) continue;
952
-
953
- const [startH, startM] = rule.start.split(':').map(Number);
954
- const [endH, endM] = rule.end.split(':').map(Number);
955
- const startMins = startH * 60 + startM;
956
- const endMins = endH * 60 + endM;
1331
+ if (!rule || !rule.slots || !rule.slots.length) continue;
957
1332
 
958
1333
  const slots = [];
959
- for (let m = startMins; m + duration <= endMins; m += duration) {
1334
+ for (const slotTime of rule.slots) {
1335
+ const [h, m] = slotTime.split(':').map(Number);
1336
+ const slotMins = h * 60 + m;
960
1337
  // For today, skip slots within the next hour
961
- if (d === 0 && m <= nowMins + 60) continue;
962
- const h = Math.floor(m / 60).toString().padStart(2, '0');
963
- const min = (m % 60).toString().padStart(2, '0');
964
- const slotStr = `${dateStr}T${h}:${min}`;
1338
+ if (d === 0 && slotMins <= nowMins + 60) continue;
1339
+ const slotStr = `${dateStr}T${slotTime}`;
965
1340
  if (!bookedSet.has(slotStr)) slots.push(slotStr);
966
1341
  }
967
1342
 
@@ -979,7 +1354,7 @@ function generateAvailableSlots(schedule, bookedSlots) {
979
1354
  // Fetch tier metadata (benefits list, renewalDays) from the tier-info artifact.
980
1355
  async function getTierInfo(tenant, product) {
981
1356
  const sanoraUrl = getSanoraUrl();
982
- const tierArtifact = (product.artifacts || []).find(a => a.includes('tier-info'));
1357
+ const tierArtifact = (product.artifacts || []).find(a => a.endsWith('.json'));
983
1358
  if (!tierArtifact) return null;
984
1359
  const resp = await fetch(`${sanoraUrl}/artifacts/${tierArtifact}`);
985
1360
  if (!resp.ok) return null;
@@ -1014,21 +1389,167 @@ async function getSubscriptionStatus(tenant, productId, recoveryKey) {
1014
1389
  } catch { return { active: false }; }
1015
1390
  }
1016
1391
 
1017
- 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
+ }
1018
1535
 
1019
1536
  function renderCards(items, category) {
1020
1537
  if (items.length === 0) {
1021
1538
  return '<p class="empty">Nothing here yet.</p>';
1022
1539
  }
1023
1540
  return items.map(item => {
1541
+ const isVideo = !!item.lucillePlayerUrl;
1024
1542
  const imgHtml = item.image
1025
- ? `<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>`
1026
1544
  : `<div class="card-img-placeholder">${CATEGORY_EMOJI[category] || '🎁'}</div>`;
1027
1545
  const priceHtml = (item.price > 0 || category === 'product')
1028
1546
  ? `<div class="price">$${(item.price / 100).toFixed(2)}${item.shipping ? ` <span class="shipping">+ $${(item.shipping / 100).toFixed(2)} shipping</span>` : ''}</div>`
1029
1547
  : '';
1548
+ const clickHandler = isVideo
1549
+ ? `playVideo('${item.lucillePlayerUrl}')`
1550
+ : `window.open('${item.url}','_blank')`;
1030
1551
  return `
1031
- <div class="card" onclick="window.open('${item.url}','_blank')">
1552
+ <div class="card" onclick="${clickHandler}">
1032
1553
  ${imgHtml}
1033
1554
  <div class="card-body">
1034
1555
  <div class="card-title">${item.title}</div>
@@ -1048,14 +1569,15 @@ function generateShoppeHTML(tenant, goods) {
1048
1569
  { id: 'posts', label: '📝 Posts', count: goods.posts.length },
1049
1570
  { id: 'albums', label: '🖼️ Albums', count: goods.albums.length },
1050
1571
  { id: 'products', label: '📦 Products', count: goods.products.length },
1572
+ { id: 'videos', label: '🎬 Videos', count: goods.videos.length },
1051
1573
  { id: 'appointments', label: '📅 Appointments', count: goods.appointments.length },
1052
- { id: 'subscriptions', label: '🎁 Support', count: goods.subscriptions.length }
1574
+ { id: 'subscriptions', label: '🎁 Infuse', count: goods.subscriptions.length }
1053
1575
  ]
1054
1576
  .filter(t => t.always || t.count > 0)
1055
1577
  .map((t, i) => `<div class="tab${i === 0 ? ' active' : ''}" onclick="show('${t.id}',this)">${t.label} <span class="badge">${t.count}</span></div>`)
1056
1578
  .join('');
1057
1579
 
1058
- 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];
1059
1581
 
1060
1582
  return `<!DOCTYPE html>
1061
1583
  <html lang="en">
@@ -1063,6 +1585,7 @@ function generateShoppeHTML(tenant, goods) {
1063
1585
  <meta charset="UTF-8">
1064
1586
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1065
1587
  <title>${tenant.name}</title>
1588
+ ${tenant.keywords ? `<meta name="keywords" content="${escHtml(tenant.keywords)}">` : ''}
1066
1589
  <style>
1067
1590
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
1068
1591
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f7; color: #1d1d1f; }
@@ -1089,6 +1612,16 @@ function generateShoppeHTML(tenant, goods) {
1089
1612
  .price { font-size: 15px; font-weight: 700; color: #0066cc; }
1090
1613
  .shipping { font-size: 12px; font-weight: 400; color: #888; }
1091
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); }
1092
1625
  </style>
1093
1626
  </head>
1094
1627
  <body>
@@ -1105,12 +1638,20 @@ function generateShoppeHTML(tenant, goods) {
1105
1638
  <div id="posts" class="section"><div class="grid">${renderCards(goods.posts, 'post')}</div></div>
1106
1639
  <div id="albums" class="section"><div class="grid">${renderCards(goods.albums, 'album')}</div></div>
1107
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>
1108
1642
  <div id="appointments" class="section"><div class="grid">${renderCards(goods.appointments, 'appointment')}</div></div>
1109
1643
  <div id="subscriptions" class="section"><div class="grid">${renderCards(goods.subscriptions, 'subscription')}</div></div>
1110
1644
  <div style="text-align:center;padding:24px 0 8px;font-size:14px;color:#888;">
1111
- Already a supporter? <a href="/plugin/shoppe/${tenant.uuid}/membership" style="color:#0066cc;">Access your membership →</a>
1645
+ Already infusing? <a href="/plugin/shoppe/${tenant.uuid}/membership" style="color:#0066cc;">Access your membership →</a>
1112
1646
  </div>
1113
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>
1114
1655
  <script>
1115
1656
  function show(id, tab) {
1116
1657
  document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
@@ -1118,6 +1659,15 @@ function generateShoppeHTML(tenant, goods) {
1118
1659
  document.getElementById(id).classList.add('active');
1119
1660
  tab.classList.add('active');
1120
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(); });
1121
1671
  </script>
1122
1672
  </body>
1123
1673
  </html>`;
@@ -1192,14 +1742,54 @@ async function startServer(params) {
1192
1742
  // Register a new tenant (owner only)
1193
1743
  app.post('/plugin/shoppe/register', owner, async (req, res) => {
1194
1744
  try {
1195
- const tenant = await registerTenant(req.body.name);
1196
- 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 });
1197
1756
  } catch (err) {
1198
1757
  console.error('[shoppe] register error:', err);
1199
1758
  res.status(500).json({ success: false, error: err.message });
1200
1759
  }
1201
1760
  });
1202
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
+
1203
1793
  // List all tenants (owner only — includes uuid for management)
1204
1794
  app.get('/plugin/shoppe/tenants', owner, (req, res) => {
1205
1795
  const tenants = loadTenants();
@@ -1242,16 +1832,17 @@ async function startServer(params) {
1242
1832
  // Get config (owner only)
1243
1833
  app.get('/plugin/shoppe/config', owner, (req, res) => {
1244
1834
  const config = loadConfig();
1245
- res.json({ success: true, sanoraUrl: config.sanoraUrl || '' });
1835
+ res.json({ success: true, sanoraUrl: config.sanoraUrl || '', lucilleUrl: config.lucilleUrl || '' });
1246
1836
  });
1247
1837
 
1248
1838
  // Save config (owner only)
1249
1839
  app.post('/plugin/shoppe/config', owner, (req, res) => {
1250
- const { sanoraUrl, addieUrl } = req.body;
1840
+ const { sanoraUrl, addieUrl, lucilleUrl } = req.body;
1251
1841
  if (!sanoraUrl) return res.status(400).json({ success: false, error: 'sanoraUrl required' });
1252
1842
  const config = loadConfig();
1253
1843
  config.sanoraUrl = sanoraUrl;
1254
1844
  if (addieUrl) config.addieUrl = addieUrl;
1845
+ if (lucilleUrl) config.lucilleUrl = lucilleUrl;
1255
1846
  saveConfig(config);
1256
1847
  console.log('[shoppe] Sanora URL set to:', sanoraUrl);
1257
1848
  res.json({ success: true });
@@ -1265,7 +1856,7 @@ async function startServer(params) {
1265
1856
 
1266
1857
  const title = decodeURIComponent(req.params.title);
1267
1858
  const sanoraUrlInternal = getSanoraUrl();
1268
- const wikiOrigin = `${req.protocol}://${req.get('host')}`;
1859
+ const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
1269
1860
  const sanoraUrl = `${wikiOrigin}/plugin/allyabase/sanora`;
1270
1861
  const productsResp = await fetch(`${sanoraUrlInternal}/products/${tenant.uuid}`);
1271
1862
  const products = await productsResp.json();
@@ -1293,7 +1884,8 @@ async function startServer(params) {
1293
1884
  ebookUrl,
1294
1885
  shoppeUrl,
1295
1886
  payees,
1296
- tenantUuid: tenant.uuid
1887
+ tenantUuid: tenant.uuid,
1888
+ keywords: extractKeywords(product)
1297
1889
  });
1298
1890
 
1299
1891
  res.set('Content-Type', 'text/html');
@@ -1326,7 +1918,7 @@ async function startServer(params) {
1326
1918
  if (!product) return res.status(404).send('<h1>Appointment not found</h1>');
1327
1919
 
1328
1920
  const schedule = await getAppointmentSchedule(tenant, product);
1329
- const wikiOrigin = `${req.protocol}://${req.get('host')}`;
1921
+ const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
1330
1922
  const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
1331
1923
  const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
1332
1924
 
@@ -1342,7 +1934,8 @@ async function startServer(params) {
1342
1934
  duration: String(schedule ? schedule.duration : 60),
1343
1935
  proceedLabel: price === 0 ? 'Confirm Booking →' : 'Continue to Payment →',
1344
1936
  shoppeUrl,
1345
- tenantUuid: tenant.uuid
1937
+ tenantUuid: tenant.uuid,
1938
+ keywords: extractKeywords(product)
1346
1939
  });
1347
1940
 
1348
1941
  res.set('Content-Type', 'text/html');
@@ -1393,7 +1986,7 @@ async function startServer(params) {
1393
1986
  if (!product) return res.status(404).send('<h1>Tier not found</h1>');
1394
1987
 
1395
1988
  const tierInfo = await getTierInfo(tenant, product);
1396
- const wikiOrigin = `${req.protocol}://${req.get('host')}`;
1989
+ const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
1397
1990
  const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
1398
1991
  const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
1399
1992
  const benefits = tierInfo && tierInfo.benefits
@@ -1410,7 +2003,8 @@ async function startServer(params) {
1410
2003
  benefits,
1411
2004
  renewalDays: String(tierInfo ? (tierInfo.renewalDays || 30) : 30),
1412
2005
  shoppeUrl,
1413
- tenantUuid: tenant.uuid
2006
+ tenantUuid: tenant.uuid,
2007
+ keywords: extractKeywords(product)
1414
2008
  });
1415
2009
 
1416
2010
  res.set('Content-Type', 'text/html');
@@ -1421,11 +2015,119 @@ async function startServer(params) {
1421
2015
  }
1422
2016
  });
1423
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
+
1424
2126
  // Membership portal page
1425
2127
  app.get('/plugin/shoppe/:identifier/membership', (req, res) => {
1426
2128
  const tenant = getTenantByIdentifier(req.params.identifier);
1427
2129
  if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
1428
- const wikiOrigin = `${req.protocol}://${req.get('host')}`;
2130
+ const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
1429
2131
  const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
1430
2132
  const html = fillTemplate(SUBSCRIPTION_MEMBERSHIP_TMPL, { shoppeUrl, tenantUuid: tenant.uuid });
1431
2133
  res.set('Content-Type', 'text/html');
@@ -1444,7 +2146,7 @@ async function startServer(params) {
1444
2146
  const sanoraUrl = getSanoraUrl();
1445
2147
  const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
1446
2148
  const products = await productsResp.json();
1447
- const wikiOrigin = `${req.protocol}://${req.get('host')}`;
2149
+ const wikiOrigin = `${reqProto(req)}://${req.get('host')}`;
1448
2150
  const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
1449
2151
 
1450
2152
  const subscriptions = [];
@@ -1459,7 +2161,7 @@ async function startServer(params) {
1459
2161
  // Only expose exclusive artifact URLs to active subscribers
1460
2162
  const exclusiveArtifacts = status.active
1461
2163
  ? (product.artifacts || [])
1462
- .filter(a => !a.includes('tier-info'))
2164
+ .filter(a => !a.endsWith('.json'))
1463
2165
  .map(a => ({ name: a.split('-').slice(1).join('-'), url: `${sanoraUrl}/artifacts/${a}` }))
1464
2166
  : [];
1465
2167
 
@@ -1494,7 +2196,7 @@ async function startServer(params) {
1494
2196
  const tenant = getTenantByIdentifier(req.params.identifier);
1495
2197
  if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
1496
2198
 
1497
- const { recoveryKey, productId, title, slotDatetime } = req.body;
2199
+ const { recoveryKey, productId, title, slotDatetime, payees: clientPayees } = req.body;
1498
2200
  if (!productId) return res.status(400).json({ error: 'productId required' });
1499
2201
  if (!recoveryKey && !title) return res.status(400).json({ error: 'recoveryKey or title required' });
1500
2202
 
@@ -1545,7 +2247,19 @@ async function startServer(params) {
1545
2247
  }
1546
2248
 
1547
2249
  // Sign and create Stripe intent via Addie
1548
- 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 }] : [];
1549
2263
  const buyerKeys = { pubKey: buyer.pubKey, privateKey: buyer.privateKey };
1550
2264
  sessionless.getKeys = () => buyerKeys;
1551
2265
  const intentTimestamp = Date.now().toString();
@@ -1578,9 +2292,18 @@ async function startServer(params) {
1578
2292
  const tenant = getTenantByIdentifier(req.params.identifier);
1579
2293
  if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
1580
2294
 
1581
- 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;
1582
2296
  const sanoraUrlInternal = getSanoraUrl();
1583
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
+
1584
2307
  if (recoveryKey && type === 'subscription') {
1585
2308
  // Subscription payment — record an order with a hashed subscriber key + payment timestamp.
1586
2309
  // The recovery key itself is never stored; orderKey = sha256(recoveryKey + productId).
@@ -1595,6 +2318,7 @@ async function startServer(params) {
1595
2318
  headers: { 'Content-Type': 'application/json' },
1596
2319
  body: JSON.stringify({ timestamp: ts, signature: sig, order })
1597
2320
  });
2321
+ triggerTransfer();
1598
2322
  return res.json({ success: true });
1599
2323
  }
1600
2324
 
@@ -1621,6 +2345,7 @@ async function startServer(params) {
1621
2345
  headers: { 'Content-Type': 'application/json' },
1622
2346
  body: JSON.stringify({ timestamp: bookingTimestamp, signature: bookingSignature, order })
1623
2347
  });
2348
+ triggerTransfer();
1624
2349
  return res.json({ success: true });
1625
2350
  }
1626
2351
 
@@ -1629,6 +2354,7 @@ async function startServer(params) {
1629
2354
  const recoveryHash = recoveryKey + productId;
1630
2355
  const createResp = await fetch(`${sanoraUrlInternal}/user/create-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
1631
2356
  const createJson = await createResp.json();
2357
+ triggerTransfer();
1632
2358
  return res.json({ success: createJson.success });
1633
2359
  }
1634
2360
 
@@ -1660,6 +2386,7 @@ async function startServer(params) {
1660
2386
  headers: { 'Content-Type': 'application/json' },
1661
2387
  body: JSON.stringify({ timestamp: orderTimestamp, signature: orderSignature, order })
1662
2388
  });
2389
+ triggerTransfer();
1663
2390
  return res.json({ success: true });
1664
2391
  }
1665
2392