wiki-plugin-shoppe 0.0.20 → 0.0.21

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
@@ -1,6 +1,7 @@
1
1
  (function() {
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
+ const crypto = require('crypto');
4
5
  const fetch = require('node-fetch');
5
6
  const multer = require('multer');
6
7
  const FormData = require('form-data');
@@ -10,9 +11,14 @@ const sessionless = require('sessionless-node');
10
11
  const SHOPPE_BASE_EMOJI = process.env.SHOPPE_BASE_EMOJI || '🛍️🎨🎁';
11
12
 
12
13
  const TEMPLATES_DIR = path.join(__dirname, 'templates');
13
- const RECOVER_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-recover-stripe.html'), 'utf8');
14
- const ADDRESS_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-address-stripe.html'), 'utf8');
15
- const EBOOK_DOWNLOAD_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'ebook-download.html'), 'utf8');
14
+ const RECOVER_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-recover-stripe.html'), 'utf8');
15
+ const ADDRESS_STRIPE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'generic-address-stripe.html'), 'utf8');
16
+ const EBOOK_DOWNLOAD_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'ebook-download.html'), 'utf8');
17
+ const APPOINTMENT_BOOKING_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'appointment-booking.html'), 'utf8');
18
+ const SUBSCRIPTION_SUBSCRIBE_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'subscription-subscribe.html'), 'utf8');
19
+ const SUBSCRIPTION_MEMBERSHIP_TMPL = fs.readFileSync(path.join(TEMPLATES_DIR, 'subscription-membership.html'), 'utf8');
20
+
21
+ const SUBSCRIPTION_PERIOD_MS = 30 * 24 * 60 * 60 * 1000; // default 30-day billing period
16
22
 
17
23
 
18
24
  function fillTemplate(tmpl, vars) {
@@ -24,6 +30,9 @@ const DATA_DIR = path.join(process.env.HOME || '/root', '.shoppe');
24
30
  const TENANTS_FILE = path.join(DATA_DIR, 'tenants.json');
25
31
  const BUYERS_FILE = path.join(DATA_DIR, 'buyers.json');
26
32
  const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
33
+ // Shipping addresses are stored locally only — never forwarded to Sanora or any third party.
34
+ // This file contains PII (name, address). Purge individual records once orders ship.
35
+ const ORDERS_FILE = path.join(DATA_DIR, 'orders.json');
27
36
  const TMP_DIR = '/tmp/shoppe-uploads';
28
37
 
29
38
  // ============================================================
@@ -418,7 +427,7 @@ async function processArchive(zipPath) {
418
427
  throw new Error('emojicode does not match registered tenant');
419
428
  }
420
429
 
421
- const results = { books: [], music: [], posts: [], albums: [], products: [], warnings: [] };
430
+ const results = { books: [], music: [], posts: [], albums: [], products: [], appointments: [], subscriptions: [], warnings: [] };
422
431
 
423
432
  function readInfo(entryPath) {
424
433
  const infoPath = path.join(entryPath, 'info.json');
@@ -731,6 +740,107 @@ async function processArchive(zipPath) {
731
740
  }
732
741
  }
733
742
 
743
+ // ---- subscriptions/ ----
744
+ // Each subfolder defines one support tier (Patreon-style).
745
+ // info.json: { title, description, price (cents/month), benefits: [], renewalDays: 30 }
746
+ // cover.jpg / hero.jpg → product image. All other files → exclusive member artifacts.
747
+ const subscriptionsDir = path.join(root, 'subscriptions');
748
+ if (fs.existsSync(subscriptionsDir)) {
749
+ const subFolders = fs.readdirSync(subscriptionsDir)
750
+ .filter(f => fs.statSync(path.join(subscriptionsDir, f)).isDirectory())
751
+ .sort();
752
+
753
+ for (const entry of subFolders) {
754
+ const entryPath = path.join(subscriptionsDir, entry);
755
+ const folderTitle = entry.replace(/^\d+-/, '');
756
+ try {
757
+ const info = readInfo(entryPath);
758
+ const title = info.title || folderTitle;
759
+ const description = info.description || '';
760
+ const price = info.price || 0;
761
+ const tierMeta = {
762
+ benefits: info.benefits || [],
763
+ renewalDays: info.renewalDays || 30
764
+ };
765
+
766
+ await sanoraCreateProduct(tenant, title, 'subscription', description, price, 0, 'subscription');
767
+
768
+ // Upload tier metadata (benefits list, renewal period) as a JSON artifact
769
+ const tierBuf = Buffer.from(JSON.stringify(tierMeta));
770
+ await sanoraUploadArtifact(tenant, title, tierBuf, 'tier-info.json', 'application/json');
771
+
772
+ // Cover image (optional)
773
+ const allFiles = fs.readdirSync(entryPath);
774
+ const coverFile = allFiles.find(f => /^(cover|hero)\.(jpg|jpeg|png|webp)$/i.test(f));
775
+ if (coverFile) {
776
+ const buf = fs.readFileSync(path.join(entryPath, coverFile));
777
+ await sanoraUploadImage(tenant, title, buf, coverFile);
778
+ }
779
+
780
+ // Every other non-JSON, non-cover file is an exclusive member artifact
781
+ const exclusiveFiles = allFiles.filter(f =>
782
+ f !== 'info.json' && f !== coverFile && !f.endsWith('.json')
783
+ );
784
+ for (const ef of exclusiveFiles) {
785
+ const buf = fs.readFileSync(path.join(entryPath, ef));
786
+ await sanoraUploadArtifact(tenant, title, buf, ef, getMimeType(ef));
787
+ }
788
+
789
+ results.subscriptions.push({ title, price, renewalDays: tierMeta.renewalDays });
790
+ console.log(`[shoppe] 🎁 subscription tier: ${title} ($${price}/mo, ${exclusiveFiles.length} exclusive files)`);
791
+ } catch (err) {
792
+ console.warn(`[shoppe] ⚠️ subscription ${entry}: ${err.message}`);
793
+ }
794
+ }
795
+ }
796
+
797
+ // ---- appointments/ ----
798
+ // Each subfolder is a bookable appointment type.
799
+ // info.json: { title, description, price, duration (mins), timezone, availability[], advanceDays }
800
+ // availability: [{ day: "monday", start: "09:00", end: "17:00" }, ...]
801
+ const appointmentsDir = path.join(root, 'appointments');
802
+ if (fs.existsSync(appointmentsDir)) {
803
+ const apptFolders = fs.readdirSync(appointmentsDir)
804
+ .filter(f => fs.statSync(path.join(appointmentsDir, f)).isDirectory())
805
+ .sort();
806
+
807
+ for (const entry of apptFolders) {
808
+ const entryPath = path.join(appointmentsDir, entry);
809
+ const folderTitle = entry.replace(/^\d+-/, '');
810
+ try {
811
+ const info = readInfo(entryPath);
812
+ const title = info.title || folderTitle;
813
+ const description = info.description || '';
814
+ const price = info.price || 0;
815
+ const schedule = {
816
+ duration: info.duration || 60,
817
+ timezone: info.timezone || 'America/New_York',
818
+ availability: info.availability || [],
819
+ advanceDays: info.advanceDays || 30
820
+ };
821
+
822
+ await sanoraCreateProduct(tenant, title, 'appointment', description, price, 0, 'appointment');
823
+
824
+ // Upload schedule as a JSON artifact so the booking page can retrieve it
825
+ const scheduleBuf = Buffer.from(JSON.stringify(schedule));
826
+ await sanoraUploadArtifact(tenant, title, scheduleBuf, 'schedule.json', 'application/json');
827
+
828
+ // Cover image (optional)
829
+ const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
830
+ const coverFile = images.find(f => /^(cover|hero)\.(jpg|jpeg|png|webp)$/i.test(f)) || images[0];
831
+ if (coverFile) {
832
+ const coverBuf = fs.readFileSync(path.join(entryPath, coverFile));
833
+ await sanoraUploadImage(tenant, title, coverBuf, coverFile);
834
+ }
835
+
836
+ results.appointments.push({ title, price, duration: schedule.duration });
837
+ console.log(`[shoppe] 📅 appointment: ${title} ($${price}/session, ${schedule.duration}min)`);
838
+ } catch (err) {
839
+ console.warn(`[shoppe] ⚠️ appointment ${entry}: ${err.message}`);
840
+ }
841
+ }
842
+ }
843
+
734
844
  return {
735
845
  tenant: { uuid: tenant.uuid, emojicode: tenant.emojicode, name: tenant.name },
736
846
  results
@@ -749,7 +859,7 @@ async function getShoppeGoods(tenant) {
749
859
  const resp = await fetch(`${getSanoraUrl()}/products/${tenant.uuid}`);
750
860
  const products = await resp.json();
751
861
 
752
- const goods = { books: [], music: [], posts: [], albums: [], products: [] };
862
+ const goods = { books: [], music: [], posts: [], albums: [], products: [], appointments: [], subscriptions: [] };
753
863
 
754
864
  for (const [title, product] of Object.entries(products)) {
755
865
  const isPost = product.category === 'post' || product.category === 'post-series';
@@ -763,13 +873,17 @@ async function getShoppeGoods(tenant) {
763
873
  ? `/plugin/shoppe/${tenant.uuid}/post/${encodeURIComponent(title)}`
764
874
  : product.category === 'book'
765
875
  ? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
766
- : product.category === 'product' && product.shipping > 0
767
- ? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}/address`
768
- : product.category === 'product'
769
- ? `/plugin/shoppe/${tenant.uuid}/buy/${encodeURIComponent(title)}`
770
- : `${getSanoraUrl()}/products/${tenant.uuid}/${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)}`
771
885
  };
772
- const CATEGORY_BUCKET = { book: 'books', music: 'music', post: 'posts', 'post-series': 'posts', album: 'albums', product: 'products' };
886
+ const CATEGORY_BUCKET = { book: 'books', music: 'music', post: 'posts', 'post-series': 'posts', album: 'albums', product: 'products', appointment: 'appointments', subscription: 'subscriptions' };
773
887
  const bucket = goods[CATEGORY_BUCKET[product.category]];
774
888
  if (bucket) bucket.push(item);
775
889
  }
@@ -777,7 +891,130 @@ async function getShoppeGoods(tenant) {
777
891
  return goods;
778
892
  }
779
893
 
780
- const CATEGORY_EMOJI = { book: '📚', music: '🎵', post: '📝', album: '🖼️', product: '📦' };
894
+ // ============================================================
895
+ // APPOINTMENT UTILITIES
896
+ // ============================================================
897
+
898
+ // Fetch and parse the schedule JSON artifact for an appointment product.
899
+ async function getAppointmentSchedule(tenant, product) {
900
+ const sanoraUrl = getSanoraUrl();
901
+ const scheduleArtifact = (product.artifacts || []).find(a => a.includes('schedule'));
902
+ if (!scheduleArtifact) return null;
903
+ const resp = await fetch(`${sanoraUrl}/artifacts/${scheduleArtifact}`);
904
+ if (!resp.ok) return null;
905
+ try { return await resp.json(); } catch { return null; }
906
+ }
907
+
908
+ // Fetch booked slot strings for an appointment product from Sanora orders.
909
+ async function getBookedSlots(tenant, productId) {
910
+ const sanoraUrl = getSanoraUrl();
911
+ const tenantKeys = tenant.keys;
912
+ sessionless.getKeys = () => tenantKeys;
913
+ const timestamp = Date.now().toString();
914
+ const signature = await sessionless.sign(timestamp + tenant.uuid);
915
+ const resp = await fetch(
916
+ `${sanoraUrl}/user/${tenant.uuid}/orders/${encodeURIComponent(productId)}?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}`
917
+ );
918
+ if (!resp.ok) return [];
919
+ try {
920
+ const data = await resp.json();
921
+ return (data.orders || []).map(o => o.slot).filter(Boolean);
922
+ } catch { return []; }
923
+ }
924
+
925
+ // Generate available slot strings grouped by date.
926
+ // Slot strings are "YYYY-MM-DDTHH:MM" in the appointment's local timezone.
927
+ // Returns: [{ date: "YYYY-MM-DD", dayLabel: "Monday", slots: ["YYYY-MM-DDTHH:MM", ...] }]
928
+ function generateAvailableSlots(schedule, bookedSlots) {
929
+ const DAY_NAMES = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
930
+ const timezone = schedule.timezone || 'UTC';
931
+ const advanceDays = schedule.advanceDays || 30;
932
+ const duration = schedule.duration || 60;
933
+ const bookedSet = new Set(bookedSlots);
934
+
935
+ const dateFmt = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' });
936
+ const timeFmt = new Intl.DateTimeFormat('en-GB', { timeZone: timezone, hour: '2-digit', minute: '2-digit', hour12: false });
937
+ const weekdayFmt = new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'long' });
938
+ const dayLabelFmt= new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'long', month: 'short', day: 'numeric' });
939
+
940
+ const nowStr = timeFmt.format(new Date());
941
+ const nowMins = parseInt(nowStr.split(':')[0]) * 60 + parseInt(nowStr.split(':')[1]);
942
+
943
+ const available = [];
944
+ const now = new Date();
945
+
946
+ for (let d = 0; d < advanceDays; d++) {
947
+ const date = new Date(now.getTime() + d * 86400000);
948
+ const dateStr = dateFmt.format(date);
949
+ const dayName = weekdayFmt.format(date).toLowerCase();
950
+ 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;
957
+
958
+ const slots = [];
959
+ for (let m = startMins; m + duration <= endMins; m += duration) {
960
+ // 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}`;
965
+ if (!bookedSet.has(slotStr)) slots.push(slotStr);
966
+ }
967
+
968
+ if (slots.length > 0) {
969
+ available.push({ date: dateStr, dayLabel: dayLabelFmt.format(date), slots });
970
+ }
971
+ }
972
+ return available;
973
+ }
974
+
975
+ // ============================================================
976
+ // SUBSCRIPTION UTILITIES
977
+ // ============================================================
978
+
979
+ // Fetch tier metadata (benefits list, renewalDays) from the tier-info artifact.
980
+ async function getTierInfo(tenant, product) {
981
+ const sanoraUrl = getSanoraUrl();
982
+ const tierArtifact = (product.artifacts || []).find(a => a.includes('tier-info'));
983
+ if (!tierArtifact) return null;
984
+ const resp = await fetch(`${sanoraUrl}/artifacts/${tierArtifact}`);
985
+ if (!resp.ok) return null;
986
+ try { return await resp.json(); } catch { return null; }
987
+ }
988
+
989
+ // Check whether a subscriber (identified by recoveryKey) has an active subscription
990
+ // for a given subscription product. Uses Sanora orders only — no session-based hash.
991
+ // The recovery key itself is never stored; the order records sha256(recoveryKey+productId).
992
+ async function getSubscriptionStatus(tenant, productId, recoveryKey) {
993
+ const orderKey = crypto.createHash('sha256').update(recoveryKey + productId).digest('hex');
994
+ const sanoraUrl = getSanoraUrl();
995
+ const tenantKeys = tenant.keys;
996
+ sessionless.getKeys = () => tenantKeys;
997
+ const timestamp = Date.now().toString();
998
+ const signature = await sessionless.sign(timestamp + tenant.uuid);
999
+ try {
1000
+ const resp = await fetch(
1001
+ `${sanoraUrl}/user/${tenant.uuid}/orders/${encodeURIComponent(productId)}?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}`
1002
+ );
1003
+ if (!resp.ok) return { active: false };
1004
+ const data = await resp.json();
1005
+ const myOrders = (data.orders || []).filter(o => o.orderKey === orderKey);
1006
+ if (!myOrders.length) return { active: false };
1007
+ const latest = myOrders.reduce((a, b) => (b.paidAt > a.paidAt ? b : a));
1008
+ const period = (latest.renewalDays || 30) * 24 * 60 * 60 * 1000;
1009
+ const renewsAt = latest.paidAt + period;
1010
+ const now = Date.now();
1011
+ const active = renewsAt > now;
1012
+ const daysLeft = Math.max(0, Math.floor((renewsAt - now) / (24 * 60 * 60 * 1000)));
1013
+ return { active, paidAt: latest.paidAt, renewsAt, daysLeft };
1014
+ } catch { return { active: false }; }
1015
+ }
1016
+
1017
+ const CATEGORY_EMOJI = { book: '📚', music: '🎵', post: '📝', album: '🖼️', product: '📦', appointment: '📅', subscription: '🎁' };
781
1018
 
782
1019
  function renderCards(items, category) {
783
1020
  if (items.length === 0) {
@@ -805,18 +1042,20 @@ function renderCards(items, category) {
805
1042
  function generateShoppeHTML(tenant, goods) {
806
1043
  const total = Object.values(goods).flat().length;
807
1044
  const tabs = [
808
- { id: 'all', label: 'All', count: total, always: true },
809
- { id: 'books', label: '📚 Books', count: goods.books.length },
810
- { id: 'music', label: '🎵 Music', count: goods.music.length },
811
- { id: 'posts', label: '📝 Posts', count: goods.posts.length },
812
- { id: 'albums', label: '🖼️ Albums', count: goods.albums.length },
813
- { id: 'products', label: '📦 Products', count: goods.products.length }
1045
+ { id: 'all', label: 'All', count: total, always: true },
1046
+ { id: 'books', label: '📚 Books', count: goods.books.length },
1047
+ { id: 'music', label: '🎵 Music', count: goods.music.length },
1048
+ { id: 'posts', label: '📝 Posts', count: goods.posts.length },
1049
+ { id: 'albums', label: '🖼️ Albums', count: goods.albums.length },
1050
+ { id: 'products', label: '📦 Products', count: goods.products.length },
1051
+ { id: 'appointments', label: '📅 Appointments', count: goods.appointments.length },
1052
+ { id: 'subscriptions', label: '🎁 Support', count: goods.subscriptions.length }
814
1053
  ]
815
1054
  .filter(t => t.always || t.count > 0)
816
1055
  .map((t, i) => `<div class="tab${i === 0 ? ' active' : ''}" onclick="show('${t.id}',this)">${t.label} <span class="badge">${t.count}</span></div>`)
817
1056
  .join('');
818
1057
 
819
- const allItems = [...goods.books, ...goods.music, ...goods.posts, ...goods.albums, ...goods.products];
1058
+ const allItems = [...goods.books, ...goods.music, ...goods.posts, ...goods.albums, ...goods.products, ...goods.appointments, ...goods.subscriptions];
820
1059
 
821
1060
  return `<!DOCTYPE html>
822
1061
  <html lang="en">
@@ -866,6 +1105,11 @@ function generateShoppeHTML(tenant, goods) {
866
1105
  <div id="posts" class="section"><div class="grid">${renderCards(goods.posts, 'post')}</div></div>
867
1106
  <div id="albums" class="section"><div class="grid">${renderCards(goods.albums, 'album')}</div></div>
868
1107
  <div id="products" class="section"><div class="grid">${renderCards(goods.products, 'product')}</div></div>
1108
+ <div id="appointments" class="section"><div class="grid">${renderCards(goods.appointments, 'appointment')}</div></div>
1109
+ <div id="subscriptions" class="section"><div class="grid">${renderCards(goods.subscriptions, 'subscription')}</div></div>
1110
+ <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>
1112
+ </div>
869
1113
  </main>
870
1114
  <script>
871
1115
  function show(id, tab) {
@@ -1068,22 +1312,193 @@ async function startServer(params) {
1068
1312
  app.get('/plugin/shoppe/:identifier/buy/:title/address', (req, res) =>
1069
1313
  renderPurchasePage(req, res, ADDRESS_STRIPE_TMPL));
1070
1314
 
1071
- // Purchase intent — creates buyer Addie user, checks recovery hash, returns Stripe client secret
1315
+ // Appointment booking page
1316
+ app.get('/plugin/shoppe/:identifier/book/:title', async (req, res) => {
1317
+ try {
1318
+ const tenant = getTenantByIdentifier(req.params.identifier);
1319
+ if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
1320
+
1321
+ const title = decodeURIComponent(req.params.title);
1322
+ const sanoraUrl = getSanoraUrl();
1323
+ const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
1324
+ const products = await productsResp.json();
1325
+ const product = products[title] || Object.values(products).find(p => p.title === title);
1326
+ if (!product) return res.status(404).send('<h1>Appointment not found</h1>');
1327
+
1328
+ const schedule = await getAppointmentSchedule(tenant, product);
1329
+ const wikiOrigin = `${req.protocol}://${req.get('host')}`;
1330
+ const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
1331
+ const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
1332
+
1333
+ const price = product.price || 0;
1334
+ const html = fillTemplate(APPOINTMENT_BOOKING_TMPL, {
1335
+ title: product.title || title,
1336
+ description: product.description || '',
1337
+ image: `"${imageUrl}"`,
1338
+ amount: String(price),
1339
+ formattedAmount: (price / 100).toFixed(2),
1340
+ productId: product.productId || '',
1341
+ timezone: schedule ? schedule.timezone : 'UTC',
1342
+ duration: String(schedule ? schedule.duration : 60),
1343
+ proceedLabel: price === 0 ? 'Confirm Booking →' : 'Continue to Payment →',
1344
+ shoppeUrl,
1345
+ tenantUuid: tenant.uuid
1346
+ });
1347
+
1348
+ res.set('Content-Type', 'text/html');
1349
+ res.send(html);
1350
+ } catch (err) {
1351
+ console.error('[shoppe] appointment booking page error:', err);
1352
+ res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
1353
+ }
1354
+ });
1355
+
1356
+ // Available slots JSON for an appointment
1357
+ app.get('/plugin/shoppe/:identifier/book/:title/slots', async (req, res) => {
1358
+ try {
1359
+ const tenant = getTenantByIdentifier(req.params.identifier);
1360
+ if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
1361
+
1362
+ const title = decodeURIComponent(req.params.title);
1363
+ const sanoraUrl = getSanoraUrl();
1364
+ const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
1365
+ const products = await productsResp.json();
1366
+ const product = products[title] || Object.values(products).find(p => p.title === title);
1367
+ if (!product) return res.status(404).json({ error: 'Appointment not found' });
1368
+
1369
+ const schedule = await getAppointmentSchedule(tenant, product);
1370
+ if (!schedule) return res.status(404).json({ error: 'Schedule not found' });
1371
+
1372
+ const bookedSlots = await getBookedSlots(tenant, product.productId);
1373
+ const available = generateAvailableSlots(schedule, bookedSlots);
1374
+
1375
+ res.json({ available, timezone: schedule.timezone, duration: schedule.duration });
1376
+ } catch (err) {
1377
+ console.error('[shoppe] slots error:', err);
1378
+ res.status(500).json({ error: err.message });
1379
+ }
1380
+ });
1381
+
1382
+ // Subscription sign-up / renew page
1383
+ app.get('/plugin/shoppe/:identifier/subscribe/:title', async (req, res) => {
1384
+ try {
1385
+ const tenant = getTenantByIdentifier(req.params.identifier);
1386
+ if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
1387
+
1388
+ const title = decodeURIComponent(req.params.title);
1389
+ const sanoraUrl = getSanoraUrl();
1390
+ const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
1391
+ const products = await productsResp.json();
1392
+ const product = products[title] || Object.values(products).find(p => p.title === title);
1393
+ if (!product) return res.status(404).send('<h1>Tier not found</h1>');
1394
+
1395
+ const tierInfo = await getTierInfo(tenant, product);
1396
+ const wikiOrigin = `${req.protocol}://${req.get('host')}`;
1397
+ const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
1398
+ const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
1399
+ const benefits = tierInfo && tierInfo.benefits
1400
+ ? tierInfo.benefits.map(b => `<li>${escHtml(b)}</li>`).join('')
1401
+ : '';
1402
+
1403
+ const html = fillTemplate(SUBSCRIPTION_SUBSCRIBE_TMPL, {
1404
+ title: product.title || title,
1405
+ description: product.description || '',
1406
+ image: `"${imageUrl}"`,
1407
+ amount: String(product.price || 0),
1408
+ formattedAmount: ((product.price || 0) / 100).toFixed(2),
1409
+ productId: product.productId || '',
1410
+ benefits,
1411
+ renewalDays: String(tierInfo ? (tierInfo.renewalDays || 30) : 30),
1412
+ shoppeUrl,
1413
+ tenantUuid: tenant.uuid
1414
+ });
1415
+
1416
+ res.set('Content-Type', 'text/html');
1417
+ res.send(html);
1418
+ } catch (err) {
1419
+ console.error('[shoppe] subscribe page error:', err);
1420
+ res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
1421
+ }
1422
+ });
1423
+
1424
+ // Membership portal page
1425
+ app.get('/plugin/shoppe/:identifier/membership', (req, res) => {
1426
+ const tenant = getTenantByIdentifier(req.params.identifier);
1427
+ if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
1428
+ const wikiOrigin = `${req.protocol}://${req.get('host')}`;
1429
+ const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
1430
+ const html = fillTemplate(SUBSCRIPTION_MEMBERSHIP_TMPL, { shoppeUrl, tenantUuid: tenant.uuid });
1431
+ res.set('Content-Type', 'text/html');
1432
+ res.send(html);
1433
+ });
1434
+
1435
+ // Check subscription status for all tiers — used by the membership portal
1436
+ app.post('/plugin/shoppe/:identifier/membership/check', async (req, res) => {
1437
+ try {
1438
+ const tenant = getTenantByIdentifier(req.params.identifier);
1439
+ if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
1440
+
1441
+ const { recoveryKey } = req.body;
1442
+ if (!recoveryKey) return res.status(400).json({ error: 'recoveryKey required' });
1443
+
1444
+ const sanoraUrl = getSanoraUrl();
1445
+ const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
1446
+ const products = await productsResp.json();
1447
+ const wikiOrigin = `${req.protocol}://${req.get('host')}`;
1448
+ const shoppeUrl = `${wikiOrigin}/plugin/shoppe/${tenant.uuid}`;
1449
+
1450
+ const subscriptions = [];
1451
+ for (const [title, product] of Object.entries(products)) {
1452
+ if (product.category !== 'subscription') continue;
1453
+
1454
+ const [status, tierInfo] = await Promise.all([
1455
+ getSubscriptionStatus(tenant, product.productId, recoveryKey),
1456
+ getTierInfo(tenant, product)
1457
+ ]);
1458
+
1459
+ // Only expose exclusive artifact URLs to active subscribers
1460
+ const exclusiveArtifacts = status.active
1461
+ ? (product.artifacts || [])
1462
+ .filter(a => !a.includes('tier-info'))
1463
+ .map(a => ({ name: a.split('-').slice(1).join('-'), url: `${sanoraUrl}/artifacts/${a}` }))
1464
+ : [];
1465
+
1466
+ subscriptions.push({
1467
+ title: product.title || title,
1468
+ productId: product.productId,
1469
+ description: product.description || '',
1470
+ price: product.price || 0,
1471
+ image: product.image ? `${sanoraUrl}/images/${product.image}` : null,
1472
+ benefits: tierInfo ? (tierInfo.benefits || []) : [],
1473
+ renewalDays: tierInfo ? (tierInfo.renewalDays || 30) : 30,
1474
+ active: status.active,
1475
+ daysLeft: status.daysLeft || 0,
1476
+ renewsAt: status.renewsAt || null,
1477
+ exclusiveArtifacts,
1478
+ subscribeUrl: `${shoppeUrl}/subscribe/${encodeURIComponent(product.title || title)}`
1479
+ });
1480
+ }
1481
+
1482
+ res.json({ subscriptions });
1483
+ } catch (err) {
1484
+ console.error('[shoppe] membership check error:', err);
1485
+ res.status(500).json({ error: err.message });
1486
+ }
1487
+ });
1488
+
1489
+ // Purchase intent — creates buyer Addie user, returns Stripe client secret.
1490
+ // Digital products (recoveryKey): checks if already purchased first.
1491
+ // Physical products (no recoveryKey): generates an orderRef the client carries to purchase/complete.
1072
1492
  app.post('/plugin/shoppe/:identifier/purchase/intent', async (req, res) => {
1073
1493
  try {
1074
1494
  const tenant = getTenantByIdentifier(req.params.identifier);
1075
1495
  if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
1076
1496
 
1077
- const { recoveryKey, productId, title } = req.body;
1078
- if (!recoveryKey || !productId) return res.status(400).json({ error: 'recoveryKey and productId required' });
1497
+ const { recoveryKey, productId, title, slotDatetime } = req.body;
1498
+ if (!productId) return res.status(400).json({ error: 'productId required' });
1499
+ if (!recoveryKey && !title) return res.status(400).json({ error: 'recoveryKey or title required' });
1079
1500
 
1080
1501
  const sanoraUrlInternal = getSanoraUrl();
1081
- const recoveryHash = recoveryKey + productId;
1082
-
1083
- // Check if already purchased
1084
- const checkResp = await fetch(`${sanoraUrlInternal}/user/check-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
1085
- const checkJson = await checkResp.json();
1086
- if (checkJson.success) return res.json({ purchased: true });
1087
1502
 
1088
1503
  // Get product price
1089
1504
  const productsResp = await fetch(`${sanoraUrlInternal}/products/${tenant.uuid}`);
@@ -1091,16 +1506,50 @@ async function startServer(params) {
1091
1506
  const product = (title && products[title]) || Object.values(products).find(p => p.productId === productId);
1092
1507
  const amount = product?.price || 0;
1093
1508
 
1094
- // Create/retrieve buyer Addie user
1095
- const buyer = await getOrCreateBuyerAddieUser(recoveryKey, productId);
1509
+ let buyer;
1510
+ let orderRef;
1511
+
1512
+ if (recoveryKey && product?.category === 'subscription') {
1513
+ // Subscription flow — check if already actively subscribed
1514
+ const status = await getSubscriptionStatus(tenant, productId, recoveryKey);
1515
+ if (status.active) {
1516
+ return res.json({ alreadySubscribed: true, renewsAt: status.renewsAt, daysLeft: status.daysLeft });
1517
+ }
1518
+ buyer = await getOrCreateBuyerAddieUser(recoveryKey, productId);
1519
+ } else if (recoveryKey && slotDatetime) {
1520
+ // Appointment flow — verify slot is still open before charging
1521
+ const schedule = await getAppointmentSchedule(tenant, product);
1522
+ if (schedule) {
1523
+ const bookedSlots = await getBookedSlots(tenant, productId);
1524
+ if (bookedSlots.includes(slotDatetime)) {
1525
+ return res.status(409).json({ error: 'That time slot is no longer available.' });
1526
+ }
1527
+ }
1528
+ buyer = await getOrCreateBuyerAddieUser(recoveryKey, productId);
1529
+ } else if (recoveryKey) {
1530
+ // Digital product flow — check if already purchased
1531
+ const recoveryHash = recoveryKey + productId;
1532
+ const checkResp = await fetch(`${sanoraUrlInternal}/user/check-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
1533
+ const checkJson = await checkResp.json();
1534
+ if (checkJson.success) return res.json({ purchased: true });
1535
+ buyer = await getOrCreateBuyerAddieUser(recoveryKey, productId);
1536
+ } else {
1537
+ // Physical product flow — generate an orderRef to link intent → complete
1538
+ orderRef = crypto.randomBytes(16).toString('hex');
1539
+ buyer = await getOrCreateBuyerAddieUser(orderRef, productId);
1540
+ }
1541
+
1542
+ // Free items (price = 0) skip Stripe entirely
1543
+ if (amount === 0) {
1544
+ return res.json({ free: true });
1545
+ }
1096
1546
 
1097
- // Create Stripe intent via Addie
1547
+ // Sign and create Stripe intent via Addie
1098
1548
  const payees = tenant.addieKeys ? [{ pubKey: tenant.addieKeys.pubKey, amount }] : [];
1099
1549
  const buyerKeys = { pubKey: buyer.pubKey, privateKey: buyer.privateKey };
1100
1550
  sessionless.getKeys = () => buyerKeys;
1101
1551
  const intentTimestamp = Date.now().toString();
1102
- const intentMessage = intentTimestamp + buyer.uuid + amount + 'USD';
1103
- const intentSignature = await sessionless.sign(intentMessage);
1552
+ const intentSignature = await sessionless.sign(intentTimestamp + buyer.uuid + amount + 'USD');
1104
1553
  const intentResp = await fetch(`${getAddieUrl()}/user/${buyer.uuid}/processor/stripe/intent`, {
1105
1554
  method: 'POST',
1106
1555
  headers: { 'Content-Type': 'application/json' },
@@ -1110,36 +1559,111 @@ async function startServer(params) {
1110
1559
  const intentJson = await intentResp.json();
1111
1560
  if (intentJson.error) return res.status(500).json({ error: intentJson.error });
1112
1561
 
1113
- res.json({ purchased: false, clientSecret: intentJson.paymentIntent, publishableKey: intentJson.publishableKey });
1562
+ const response = { purchased: false, clientSecret: intentJson.paymentIntent, publishableKey: intentJson.publishableKey };
1563
+ if (orderRef) response.orderRef = orderRef;
1564
+ res.json(response);
1114
1565
  } catch (err) {
1115
1566
  console.error('[shoppe] purchase intent error:', err);
1116
1567
  res.status(500).json({ error: err.message });
1117
1568
  }
1118
1569
  });
1119
1570
 
1120
- // Purchase complete — creates recovery hash in Sanora after successful payment
1571
+ // Purchase complete — called after Stripe payment confirms.
1572
+ // Digital: creates a recovery hash in Sanora.
1573
+ // Physical: records the order (including shipping address) in Sanora, signed by the tenant.
1574
+ // Address is routed through the shoppe server so it never goes directly
1575
+ // from the browser to Sanora. It is only stored after payment succeeds.
1121
1576
  app.post('/plugin/shoppe/:identifier/purchase/complete', async (req, res) => {
1122
1577
  try {
1123
1578
  const tenant = getTenantByIdentifier(req.params.identifier);
1124
1579
  if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
1125
1580
 
1126
- const { recoveryKey, productId, order } = req.body;
1127
- if (!recoveryKey || !productId) return res.status(400).json({ error: 'recoveryKey and productId required' });
1128
-
1581
+ const { recoveryKey, productId, orderRef, address, title, amount, slotDatetime, contactInfo, type, renewalDays } = req.body;
1129
1582
  const sanoraUrlInternal = getSanoraUrl();
1130
- const recoveryHash = recoveryKey + productId;
1131
1583
 
1132
- if (order) {
1133
- await fetch(`${sanoraUrlInternal}/user/orders`, {
1584
+ if (recoveryKey && type === 'subscription') {
1585
+ // Subscription payment — record an order with a hashed subscriber key + payment timestamp.
1586
+ // The recovery key itself is never stored; orderKey = sha256(recoveryKey + productId).
1587
+ const orderKey = crypto.createHash('sha256').update(recoveryKey + productId).digest('hex');
1588
+ const tenantKeys = tenant.keys;
1589
+ sessionless.getKeys = () => tenantKeys;
1590
+ const ts = Date.now().toString();
1591
+ const sig = await sessionless.sign(ts + tenant.uuid);
1592
+ const order = { orderKey, paidAt: Date.now(), title, productId, renewalDays: renewalDays || 30, status: 'active' };
1593
+ await fetch(`${sanoraUrlInternal}/user/${tenant.uuid}/orders`, {
1594
+ method: 'PUT',
1595
+ headers: { 'Content-Type': 'application/json' },
1596
+ body: JSON.stringify({ timestamp: ts, signature: sig, order })
1597
+ });
1598
+ return res.json({ success: true });
1599
+ }
1600
+
1601
+ if (recoveryKey && slotDatetime) {
1602
+ // Appointment — create recovery hash + record booking in Sanora
1603
+ const recoveryHash = recoveryKey + productId;
1604
+ const createResp = await fetch(`${sanoraUrlInternal}/user/create-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
1605
+ await createResp.json();
1606
+
1607
+ // Record the booking in Sanora (contact info flows through the server, never direct from browser)
1608
+ const tenantKeys = tenant.keys;
1609
+ sessionless.getKeys = () => tenantKeys;
1610
+ const bookingTimestamp = Date.now().toString();
1611
+ const bookingSignature = await sessionless.sign(bookingTimestamp + tenant.uuid);
1612
+ const order = {
1613
+ productId,
1614
+ title,
1615
+ slot: slotDatetime,
1616
+ contactInfo: contactInfo || {},
1617
+ status: 'booked'
1618
+ };
1619
+ await fetch(`${sanoraUrlInternal}/user/${tenant.uuid}/orders`, {
1620
+ method: 'PUT',
1621
+ headers: { 'Content-Type': 'application/json' },
1622
+ body: JSON.stringify({ timestamp: bookingTimestamp, signature: bookingSignature, order })
1623
+ });
1624
+ return res.json({ success: true });
1625
+ }
1626
+
1627
+ if (recoveryKey) {
1628
+ // Digital product — create recovery hash so buyer can re-download
1629
+ const recoveryHash = recoveryKey + productId;
1630
+ const createResp = await fetch(`${sanoraUrlInternal}/user/create-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
1631
+ const createJson = await createResp.json();
1632
+ return res.json({ success: createJson.success });
1633
+ }
1634
+
1635
+ if (orderRef && address) {
1636
+ // Physical product — record order in Sanora signed by the tenant.
1637
+ // The shippingAddress is collected here (post-payment) and sent once, server-side.
1638
+ const tenantKeys = tenant.keys;
1639
+ sessionless.getKeys = () => tenantKeys;
1640
+ const orderTimestamp = Date.now().toString();
1641
+ const orderSignature = await sessionless.sign(orderTimestamp + tenant.uuid);
1642
+ const order = {
1643
+ productId,
1644
+ title,
1645
+ amount,
1646
+ orderRef,
1647
+ shippingAddress: {
1648
+ recipientName: address.name,
1649
+ street: address.line1,
1650
+ street2: address.line2 || '',
1651
+ city: address.city,
1652
+ state: address.state,
1653
+ zip: address.zip,
1654
+ country: 'US'
1655
+ },
1656
+ status: 'pending'
1657
+ };
1658
+ await fetch(`${sanoraUrlInternal}/user/${tenant.uuid}/orders`, {
1134
1659
  method: 'PUT',
1135
1660
  headers: { 'Content-Type': 'application/json' },
1136
- body: JSON.stringify({ timestamp: Date.now().toString(), order })
1661
+ body: JSON.stringify({ timestamp: orderTimestamp, signature: orderSignature, order })
1137
1662
  });
1663
+ return res.json({ success: true });
1138
1664
  }
1139
1665
 
1140
- const createResp = await fetch(`${sanoraUrlInternal}/user/create-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
1141
- const createJson = await createResp.json();
1142
- res.json({ success: createJson.success });
1666
+ res.status(400).json({ error: 'recoveryKey or (orderRef + address) required' });
1143
1667
  } catch (err) {
1144
1668
  console.error('[shoppe] purchase complete error:', err);
1145
1669
  res.status(500).json({ error: err.message });