wiki-plugin-shoppe 0.0.35 → 0.0.37

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/CLAUDE.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Multi-tenant digital goods shoppe for Federated Wiki, powered by Sanora.
4
4
 
5
+ **Current version**: 0.0.35
6
+
5
7
  ## Architecture
6
8
 
7
9
  Follows the Service-Bundling Plugin Pattern. Each tenant (seller/creator) gets their own Sanora user account, identified by a UUID and an 8-emoji emojicode (same format as BDO: 3 base + 5 unique from the EMOJI_PALETTE).
@@ -99,25 +101,16 @@ my-shoppe.zip
99
101
  "uuid": "your-uuid-from-registration",
100
102
  "emojicode": "🛍️🎨🎁🌟💎🐉📚🔥",
101
103
  "name": "My Shoppe",
102
- "keywords": ["digital goods", "indie creator", "music", "books"]
104
+ "keywords": ["digital goods", "indie creator", "music", "books"],
105
+ "lightMode": false
103
106
  }
104
107
  ```
105
108
 
106
- `keywords` is optional. When present, it is stored in the tenant record and rendered as a `<meta name="keywords">` tag on the main shoppe page.
109
+ `keywords` is optional. Stored in the tenant record and rendered as a `<meta name="keywords">` tag.
107
110
 
108
- `redirects` is optional. Each key is a content category (`books`, `music`, `posts`, `albums`, `products`, `appointments`, `subscriptions`) and the value is an external URL. When set, clicking any card in that category sends visitors to that URL instead of the plugin's built-in purchase/download pages. Example:
111
+ `redirects` is optional. Each key is a content category and the value is an external URL. Clicking any card in that category sends visitors to that URL instead of the plugin's built-in pages.
109
112
 
110
- ```json
111
- {
112
- "uuid": "...",
113
- "emojicode": "...",
114
- "name": "My Shoppe",
115
- "redirects": {
116
- "books": "https://myauthorsite.com/books",
117
- "music": "https://mybandcamp.com"
118
- }
119
- }
120
- ```
113
+ `lightMode` is optional (default `false`). When `true`, the shoppe page uses light mode styling (white cards, `#f5f5f7` background, `#0066cc` accent). Default is dark mode (`#0f0f12` background, `#7ec8e3` accent). Stored in `tenants.json` and applied on every page load — no re-upload needed once set.
121
114
 
122
115
  ### books/*/info.json
123
116
 
@@ -183,6 +176,8 @@ preview = "ocean.jpg"
183
176
 
184
177
  The hero image is resolved automatically: `hero.jpg` or `hero.png` is used if present, otherwise the first image in the folder. Folder numeric prefix (`01-`, `02-`, …) sets display order.
185
178
 
179
+ **Note:** If the image upload fails (e.g. 413 from nginx), the product metadata is still recorded in Sanora and the upload result still counts as a success with a warning. Re-uploading the archive will push the image without duplicating the product entry.
180
+
186
181
  ### appointments/*/info.json
187
182
 
188
183
  ```json
@@ -226,13 +221,15 @@ The hero image is resolved automatically: `hero.jpg` or `hero.png` is used if pr
226
221
  |--------|------|------|-------------|
227
222
  | `POST` | `/plugin/shoppe/register` | Owner | Register new tenant |
228
223
  | `GET` | `/plugin/shoppe/tenants` | Owner | List all tenants |
229
- | `POST` | `/plugin/shoppe/upload` | UUID+emojicode in archive | Upload goods archive |
224
+ | `POST` | `/plugin/shoppe/upload` | UUID+emojicode in archive | Upload goods archive (returns `{ jobId }` immediately) |
225
+ | `GET` | `/plugin/shoppe/upload/progress/:jobId` | Public | SSE stream of upload progress events |
230
226
  | `GET` | `/plugin/shoppe/:id` | Public | Shoppe HTML page |
231
227
  | `GET` | `/plugin/shoppe/:id/goods` | Public | Goods JSON |
232
228
  | `GET` | `/plugin/shoppe/:id/goods?category=books` | Public | Filtered goods JSON |
233
- | `GET` | `/plugin/shoppe/:id/book/:title` | Public | Appointment booking page |
229
+ | `GET` | `/plugin/shoppe/:id/music/feed` | Public | Music feed `{ albums, tracks }` built from Sanora products |
230
+ | `GET` | `/plugin/shoppe/:id/book/:title` | Public | Appointment booking page (standalone) |
234
231
  | `GET` | `/plugin/shoppe/:id/book/:title/slots` | Public | Available slots JSON |
235
- | `GET` | `/plugin/shoppe/:id/subscribe/:title` | Public | Subscription sign-up page |
232
+ | `GET` | `/plugin/shoppe/:id/subscribe/:title` | Public | Subscription sign-up page (standalone) |
236
233
  | `GET` | `/plugin/shoppe/:id/membership` | Public | Membership portal |
237
234
  | `POST` | `/plugin/shoppe/:id/membership/check` | Public | Check subscription status |
238
235
  | `POST` | `/plugin/shoppe/:id/purchase/intent` | Public | Create Stripe payment intent |
@@ -245,12 +242,88 @@ The hero image is resolved automatically: `hero.jpg` or `hero.png` is used if pr
245
242
 
246
243
  `:id` accepts either UUID or emojicode.
247
244
 
245
+ ## Upload Flow (SSE Progress)
246
+
247
+ The upload endpoint is non-blocking. The client POSTs the archive and immediately gets `{ jobId }`. It then opens an `EventSource` to `/plugin/shoppe/upload/progress/:jobId` and receives a stream of events:
248
+
249
+ | Event | Data |
250
+ |-------|------|
251
+ | `start` | `{ total, name }` — total item count and shoppe name |
252
+ | `progress` | `{ current, total, label }` — item number and human-readable label |
253
+ | `warning` | `{ message }` — non-fatal issue (e.g. image upload failed) |
254
+ | `complete` | `{ success, books, music, posts, … }` — final result counts |
255
+ | `error` | `{ message }` — fatal upload failure |
256
+
257
+ The progress stream is buffered for late-connecting clients. Jobs are cleaned up after 15 minutes.
258
+
259
+ ## Shoppe Page UI
260
+
261
+ The shoppe page is a single-page app generated server-side by `generateShoppeHTML`. All tabs are lazy-initialized on first open.
262
+
263
+ ### Tabs and their UI patterns
264
+
265
+ | Tab | Pattern |
266
+ |-----|---------|
267
+ | All | Card grid of everything |
268
+ | Books | Card grid → buy page |
269
+ | Music | Album grid → track list → fixed player bar |
270
+ | Posts | Series cards → numbered parts list; standalones below |
271
+ | Albums | Card grid |
272
+ | Products | Card grid → buy page |
273
+ | Videos | Card grid with inline player modal |
274
+ | Appointments | Inline date strip → slot picker → booking form → Stripe |
275
+ | Infuse | Inline tier cards with benefits → recovery key → Stripe |
276
+
277
+ ### Music Player
278
+
279
+ The music tab fetches `/music/feed` on first open (lazy). The feed is built from Sanora products with `category: 'music'`. Products with multiple audio artifacts are treated as albums; single-artifact products are standalone tracks.
280
+
281
+ The player bar is fixed at the bottom of the page and is always dark regardless of `lightMode`. Track titles default to "Track 1", "Track 2", etc. because Sanora stores artifacts by UUID (original filenames are not preserved).
282
+
283
+ ### Posts Hierarchy
284
+
285
+ Post series are detected server-side by `category: 'post-series'` products. Parts are linked by `series:SeriesTitle` and `part:N` tags. The hierarchy is pre-computed in `generateShoppeHTML` and embedded as `_postsRaw` JSON so the client does no extra fetching.
286
+
287
+ ### Inline Subscriptions and Appointments
288
+
289
+ Subscriptions and appointments are fully handled inline on the shoppe page — no navigation to separate pages. The full payment flow (recovery key → Stripe Elements → confirmation) runs inside expanding panels under each tier/appointment card. Data (`productId`, `renewalDays`, `benefits`, `timezone`, `duration`) is fetched from Sanora artifact JSON during `getShoppeGoods` and embedded in the page.
290
+
291
+ ## Theming
292
+
293
+ The shoppe page is **dark mode by default**. All colors use CSS custom properties defined in `:root`:
294
+
295
+ | Variable | Dark | Light |
296
+ |----------|------|-------|
297
+ | `--bg` | `#0f0f12` | `#f5f5f7` |
298
+ | `--card-bg` | `#18181c` | `white` |
299
+ | `--accent` | `#7ec8e3` | `#0066cc` |
300
+ | `--text` | `#e8e8ea` | `#1d1d1f` |
301
+ | `--border` | `#333` | `#ddd` |
302
+
303
+ Set `"lightMode": true` in `manifest.json` and re-upload to switch a shoppe to light mode. The flag is stored in `tenants.json` so it persists across re-uploads. The music player bar is always dark in both modes.
304
+
305
+ ## UUID Alias / Redis Reset Recovery
306
+
307
+ If Sanora's Redis is cleared, the tenant's UUID changes on the next `sanoraEnsureUser` call. The server handles this automatically:
308
+
309
+ 1. The old UUID is kept in `tenants.json` as a forwarding alias: `{ "old-uuid": "new-uuid-string", "new-uuid": { ...fullRecord } }`
310
+ 2. `getTenantByIdentifier` follows string values as aliases
311
+ 3. All subsequent uploads and page loads use the new UUID transparently
312
+
313
+ If you encounter `Unknown UUID` errors after a Redis reset, manually add `"old-uuid": "new-uuid"` to `~/.shoppe/tenants.json`.
314
+
315
+ ## Resilience Features
316
+
317
+ - **`sanoraCreateProductResilient`** — wraps `sanoraCreateProduct`. On 404/not-found mid-upload (Redis cleared), calls `sanoraEnsureUser`, updates `tenant.uuid`, and retries once.
318
+ - **`fetchWithRetry`** — wraps `fetch`. On 429 Too Many Requests, backs off exponentially (1s → 2s → 4s) up to 3 retries.
319
+ - **Image upload isolation** — product image upload failures are caught independently; the product entry is always recorded even if the image fails. A warning is emitted so the user can re-upload to fix just the image.
320
+
248
321
  ## Payment / Transfer Flow
249
322
 
250
323
  1. Buyer calls `POST /purchase/intent` → shoppe creates a buyer Addie user and calls `PUT /user/:buyerUuid/processor/stripe/intent` on Addie → returns `{ clientSecret, publishableKey }`
251
324
  2. Stripe.js confirms payment client-side (no redirect)
252
325
  3. Client extracts `paymentIntentId` from `clientSecret` (`clientSecret.split('_secret_')[0]`) and posts to `POST /purchase/complete` with `paymentIntentId`
253
- 4. Server records the order in Sanora, then fires a **fire-and-forget** `POST ${addieUrl}/payment/${paymentIntentId}/process-transfers` — Addie splits the payment and routes it to the tenant's Stripe account (no auth required on this Addie endpoint)
326
+ 4. Server records the order in Sanora, then fires a **fire-and-forget** `POST ${addieUrl}/payment/${paymentIntentId}/process-transfers` — Addie splits the payment and routes it to the tenant's Stripe account
254
327
 
255
328
  **Important:** Transfers only flow to the owner after `node shoppe-sign.js payouts` has been run and Stripe Connect onboarding is complete.
256
329
 
@@ -264,6 +337,16 @@ export SHOPPE_BASE_EMOJI="🏪🎪🎁"
264
337
  export SANORA_PORT=7243
265
338
  ```
266
339
 
340
+ ## nginx Requirements
341
+
342
+ The allyabase server's nginx must allow large uploads for books, audio, and images:
343
+
344
+ ```nginx
345
+ client_max_body_size 50M;
346
+ ```
347
+
348
+ Without this, epub/audio artifact uploads and large cover images will fail with 413. Apply to the relevant `server {}` block and reload: `sudo nginx -t && sudo systemctl reload nginx`.
349
+
267
350
  ## Supported File Types
268
351
 
269
352
  | Category | Extensions |
@@ -276,7 +359,9 @@ export SANORA_PORT=7243
276
359
 
277
360
  ## Storage
278
361
 
279
- Tenant registry: `.shoppe-tenants.json` (gitignored contains private keys)
362
+ - `~/.shoppe/tenants.json` — tenant registry (private keys + UUID aliases — gitignored)
363
+ - `~/.shoppe/buyers.json` — buyer Addie keys, keyed by `recoveryKey + productId`
364
+ - `~/.shoppe/config.json` — plugin config (sanoraUrl)
280
365
 
281
366
  Each tenant's goods are stored in Sanora under their own UUID.
282
367
 
@@ -288,6 +373,6 @@ Each tenant's goods are stored in Sanora under their own UUID.
288
373
  "form-data": "^4.0.0",
289
374
  "multer": "^1.4.5-lts.1",
290
375
  "node-fetch": "^2.6.1",
291
- "sessionless-node": "^0.9.12"
376
+ "sessionless-node": "latest"
292
377
  }
293
378
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.35",
3
+ "version": "0.0.37",
4
4
  "description": "Multi-tenant digital goods shoppe for federated wiki, powered by Sanora",
5
5
  "keywords": [
6
6
  "wiki",
package/server/server.js CHANGED
@@ -107,6 +107,67 @@ async function getOrCreateBuyerAddieUser(recoveryKey, productId) {
107
107
  return buyer;
108
108
  }
109
109
 
110
+ // ── Shoppere app: pubKey-based buyer auth ────────────────────────────────────
111
+
112
+ // Verify a buyer-signed request from the Shoppere app.
113
+ // Message convention: timestamp + pubKey (mirrors Sessionless ecosystem standard)
114
+ // Returns an error string on failure, null on success.
115
+ function verifyBuyerSignature(pubKey, timestamp, signature, maxAgeMs = 5 * 60 * 1000) {
116
+ if (!pubKey || !timestamp || !signature) return 'pubKey, timestamp, and signature required';
117
+ const age = Date.now() - parseInt(timestamp, 10);
118
+ if (isNaN(age) || age < 0 || age > maxAgeMs) return 'Request expired';
119
+ if (!sessionless.verifySignature(signature, timestamp + pubKey, pubKey)) return 'Invalid signature';
120
+ return null;
121
+ }
122
+
123
+ // Like getOrCreateBuyerAddieUser but keyed by pubKey rather than a recoveryKey.
124
+ // Prefixed 'pk:' in buyers.json to avoid collisions with legacy recovery-key entries.
125
+ async function getOrCreateBuyerAddieUserByPubKey(pubKey, productId) {
126
+ const buyerKey = 'pk:' + pubKey + productId;
127
+ const buyers = loadBuyers();
128
+ if (buyers[buyerKey]) return buyers[buyerKey];
129
+
130
+ const addieKeys = await sessionless.generateKeys(() => {}, () => null);
131
+ sessionless.getKeys = () => addieKeys;
132
+ const timestamp = Date.now().toString();
133
+ const message = timestamp + addieKeys.pubKey;
134
+ const signature = await sessionless.sign(message);
135
+
136
+ const resp = await fetch(`${getAddieUrl()}/user/create`, {
137
+ method: 'PUT',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({ timestamp, pubKey: addieKeys.pubKey, signature })
140
+ });
141
+
142
+ const addieUser = await resp.json();
143
+ if (addieUser.error) throw new Error(`Addie: ${addieUser.error}`);
144
+
145
+ const buyer = { uuid: addieUser.uuid, pubKey: addieKeys.pubKey, privateKey: addieKeys.privateKey };
146
+ buyers[buyerKey] = buyer;
147
+ saveBuyers(buyers);
148
+ return buyer;
149
+ }
150
+
151
+ // Check whether a pubKey has a completed purchase for a productId by looking
152
+ // for an order whose orderKey === sha256(pubKey + productId) in Sanora.
153
+ async function hasPurchasedByPubKey(tenant, pubKey, productId) {
154
+ const orderKey = crypto.createHash('sha256').update(pubKey + productId).digest('hex');
155
+ const sanoraUrl = getSanoraUrl();
156
+ sessionless.getKeys = () => tenant.keys;
157
+ const timestamp = Date.now().toString();
158
+ const signature = await sessionless.sign(timestamp + tenant.uuid);
159
+ try {
160
+ const resp = await fetch(
161
+ `${sanoraUrl}/user/${tenant.uuid}/orders/${encodeURIComponent(productId)}` +
162
+ `?timestamp=${timestamp}&signature=${encodeURIComponent(signature)}`
163
+ );
164
+ const json = await resp.json();
165
+ return (json.orders || []).some(o => o.orderKey === orderKey);
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
110
171
  // Same diverse palette as BDO emojicoding
111
172
  const EMOJI_PALETTE = [
112
173
  '🌟', '🌙', '🌍', '🌊', '🔥', '💎', '🎨', '🎭', '🎪', '🎯',
@@ -1434,13 +1495,27 @@ async function getShoppeGoods(tenant) {
1434
1495
  ? lucillePlayerUrl
1435
1496
  : `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`;
1436
1497
 
1498
+ const resolvedUrl = (bucketName && redirects[bucketName]) || defaultUrl;
1499
+
1500
+ // Build clip URL for App Clip invocation — books and no-shipping products only.
1501
+ // Appends product identity params so the App Clip can parse them from the URL.
1502
+ const isClipBuyPage = !redirects[bucketName] && product.productId &&
1503
+ (product.category === 'book' ||
1504
+ (product.category === 'product' && !(product.shipping > 0)));
1505
+ const clipUrl = isClipBuyPage
1506
+ ? `${resolvedUrl}?productId=${encodeURIComponent(product.productId)}` +
1507
+ `&price=${product.price || 0}` +
1508
+ `&shopName=${encodeURIComponent(tenant.name || '')}`
1509
+ : null;
1510
+
1437
1511
  const item = {
1438
1512
  title: product.title || title,
1439
1513
  description: product.description || '',
1440
1514
  price: product.price || 0,
1441
1515
  shipping: product.shipping || 0,
1442
1516
  image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
1443
- url: (bucketName && redirects[bucketName]) || defaultUrl,
1517
+ url: resolvedUrl,
1518
+ ...(clipUrl && { clipUrl }),
1444
1519
  ...(isPost && { category: product.category, tags: product.tags || '' }),
1445
1520
  ...(lucillePlayerUrl && { lucillePlayerUrl }),
1446
1521
  ...(product.category === 'video' && { shoppeId: tenant.uuid })
@@ -1778,9 +1853,12 @@ function renderCards(items, category) {
1778
1853
  </div>
1779
1854
  </div>`;
1780
1855
  }
1856
+ // clipUrl is present for books and no-shipping products — it carries
1857
+ // productId/price/shopName so the iOS App Clip can parse them on invocation.
1858
+ const targetUrl = item.clipUrl || item.url;
1781
1859
  const clickHandler = isVideo
1782
1860
  ? `playVideo('${item.lucillePlayerUrl}')`
1783
- : `window.open('${item.url}','_blank')`;
1861
+ : `window.open('${escHtml(targetUrl)}','_blank')`;
1784
1862
  return `
1785
1863
  <div class="card" onclick="${clickHandler}">
1786
1864
  ${imgHtml}
@@ -1819,6 +1897,9 @@ function generateShoppeHTML(tenant, goods, uploadAuth = null) {
1819
1897
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1820
1898
  <title>${tenant.name}</title>
1821
1899
  ${tenant.keywords ? `<meta name="keywords" content="${escHtml(tenant.keywords)}">` : ''}
1900
+ <!-- Shoppere App Clip — shown as a Smart App Banner on iOS Safari -->
1901
+ <meta name="apple-itunes-app"
1902
+ content="app-id=6760954663, app-clip-bundle-id=app.foures.shoppere.shoppere, app-clip-display=card">
1822
1903
  <script src="https://js.stripe.com/v3/"></script>
1823
1904
  <style>
1824
1905
  /* ── Theme variables (dark default) ── */
@@ -2964,6 +3045,29 @@ async function startServer(params) {
2964
3045
  }
2965
3046
  });
2966
3047
 
3048
+ // Apple App Clip associated domain verification.
3049
+ // Apple fetches this file from /.well-known/apple-app-site-association on the wiki domain
3050
+ // to confirm the App Clip is authorized to handle URLs on this host.
3051
+ // Replace TEAM_ID with the Apple Developer Team ID (RLJ2FY35FD).
3052
+ app.get('/.well-known/apple-app-site-association', (req, res) => {
3053
+ res.set('Content-Type', 'application/json');
3054
+ res.json({
3055
+ applinks: {
3056
+ details: [
3057
+ {
3058
+ appIDs: ['RLJ2FY35FD.app.foures.shoppere'],
3059
+ components: [
3060
+ { '/': '/plugin/shoppe/*', comment: 'All shoppe pages' }
3061
+ ]
3062
+ }
3063
+ ]
3064
+ },
3065
+ appclips: {
3066
+ apps: ['RLJ2FY35FD.app.foures.shoppere.shoppere']
3067
+ }
3068
+ });
3069
+ });
3070
+
2967
3071
  // Public directory — name, emojicode, and shoppe URL only
2968
3072
  app.get('/plugin/shoppe/directory', (req, res) => {
2969
3073
  const tenants = loadTenants();
@@ -3065,6 +3169,19 @@ async function startServer(params) {
3065
3169
  ? JSON.stringify([{ pubKey: tenant.addieKeys.pubKey, amount: product.price || 0 }])
3066
3170
  : '[]';
3067
3171
 
3172
+ // Forward Shoppere App Clip credentials if present in the query string
3173
+ const buyerPubKey = req.query.pubKey || '';
3174
+ const buyerTimestamp = req.query.timestamp || '';
3175
+ const buyerSignature = req.query.signature || '';
3176
+
3177
+ // When a pubKey credential is present, the download page can verify access via
3178
+ // a signed request — embed the credentials so the template can redirect there.
3179
+ const ebookUrlWithCreds = buyerPubKey
3180
+ ? `${ebookUrl}?pubKey=${encodeURIComponent(buyerPubKey)}&timestamp=${encodeURIComponent(buyerTimestamp)}&signature=${encodeURIComponent(buyerSignature)}`
3181
+ : ebookUrl;
3182
+
3183
+ const clipUrl = `${wikiOrigin}${req.path}?${new URLSearchParams(req.query).toString()}`;
3184
+
3068
3185
  const html = fillTemplate(templateHtml, {
3069
3186
  title: product.title || title,
3070
3187
  description: product.description || '',
@@ -3072,15 +3189,17 @@ async function startServer(params) {
3072
3189
  amount: String(product.price || 0),
3073
3190
  formattedAmount: ((product.price || 0) / 100).toFixed(2),
3074
3191
  productId: product.productId || '',
3075
- pubKey: '',
3076
- signature: '',
3192
+ buyerPubKey,
3193
+ buyerTimestamp,
3194
+ buyerSignature,
3077
3195
  sanoraUrl,
3078
3196
  allyabaseOrigin: wikiOrigin,
3079
- ebookUrl,
3197
+ ebookUrl: ebookUrlWithCreds,
3080
3198
  shoppeUrl,
3081
3199
  payees,
3082
3200
  tenantUuid: tenant.uuid,
3083
- keywords: extractKeywords(product)
3201
+ keywords: extractKeywords(product),
3202
+ clipUrl,
3084
3203
  });
3085
3204
 
3086
3205
  res.set('Content-Type', 'text/html');
@@ -3391,9 +3510,15 @@ async function startServer(params) {
3391
3510
  const tenant = getTenantByIdentifier(req.params.identifier);
3392
3511
  if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
3393
3512
 
3394
- const { recoveryKey, productId, title, slotDatetime, payees: clientPayees } = req.body;
3513
+ const { recoveryKey, pubKey, timestamp: buyerTimestamp, signature: buyerSignature, productId, title, slotDatetime, payees: clientPayees } = req.body;
3395
3514
  if (!productId) return res.status(400).json({ error: 'productId required' });
3396
- if (!recoveryKey && !title) return res.status(400).json({ error: 'recoveryKey or title required' });
3515
+ if (!pubKey && !recoveryKey && !title) return res.status(400).json({ error: 'pubKey (with timestamp+signature) or recoveryKey required' });
3516
+
3517
+ // Shoppere app path: verify the buyer's Sessionless signature before proceeding
3518
+ if (pubKey) {
3519
+ const sigErr = verifyBuyerSignature(pubKey, buyerTimestamp, buyerSignature);
3520
+ if (sigErr) return res.status(401).json({ error: sigErr });
3521
+ }
3397
3522
 
3398
3523
  const sanoraUrlInternal = getSanoraUrl();
3399
3524
 
@@ -3406,15 +3531,37 @@ async function startServer(params) {
3406
3531
  let buyer;
3407
3532
  let orderRef;
3408
3533
 
3409
- if (recoveryKey && product?.category === 'subscription') {
3410
- // Subscription flow — check if already actively subscribed
3534
+ if (pubKey && product?.category === 'subscription') {
3535
+ // Shoppere: subscription — check active status by pubKey orderKey
3536
+ const alreadyPurchased = await hasPurchasedByPubKey(tenant, pubKey, productId);
3537
+ if (alreadyPurchased) {
3538
+ return res.json({ alreadySubscribed: true });
3539
+ }
3540
+ buyer = await getOrCreateBuyerAddieUserByPubKey(pubKey, productId);
3541
+ } else if (pubKey && slotDatetime) {
3542
+ // Shoppere: appointment — verify slot availability
3543
+ const schedule = await getAppointmentSchedule(tenant, product);
3544
+ if (schedule) {
3545
+ const bookedSlots = await getBookedSlots(tenant, productId);
3546
+ if (bookedSlots.includes(slotDatetime)) {
3547
+ return res.status(409).json({ error: 'That time slot is no longer available.' });
3548
+ }
3549
+ }
3550
+ buyer = await getOrCreateBuyerAddieUserByPubKey(pubKey, productId);
3551
+ } else if (pubKey) {
3552
+ // Shoppere: digital product — check if already purchased by pubKey
3553
+ const alreadyPurchased = await hasPurchasedByPubKey(tenant, pubKey, productId);
3554
+ if (alreadyPurchased) return res.json({ purchased: true });
3555
+ buyer = await getOrCreateBuyerAddieUserByPubKey(pubKey, productId);
3556
+ } else if (recoveryKey && product?.category === 'subscription') {
3557
+ // Legacy recovery key: subscription flow — check if already actively subscribed
3411
3558
  const status = await getSubscriptionStatus(tenant, productId, recoveryKey);
3412
3559
  if (status.active) {
3413
3560
  return res.json({ alreadySubscribed: true, renewsAt: status.renewsAt, daysLeft: status.daysLeft });
3414
3561
  }
3415
3562
  buyer = await getOrCreateBuyerAddieUser(recoveryKey, productId);
3416
3563
  } else if (recoveryKey && slotDatetime) {
3417
- // Appointment flow — verify slot is still open before charging
3564
+ // Legacy recovery key: appointment flow — verify slot is still open before charging
3418
3565
  const schedule = await getAppointmentSchedule(tenant, product);
3419
3566
  if (schedule) {
3420
3567
  const bookedSlots = await getBookedSlots(tenant, productId);
@@ -3424,7 +3571,7 @@ async function startServer(params) {
3424
3571
  }
3425
3572
  buyer = await getOrCreateBuyerAddieUser(recoveryKey, productId);
3426
3573
  } else if (recoveryKey) {
3427
- // Digital product flow — check if already purchased
3574
+ // Legacy recovery key: digital product flow — check if already purchased
3428
3575
  const recoveryHash = recoveryKey + productId;
3429
3576
  const checkResp = await fetch(`${sanoraUrlInternal}/user/check-hash/${encodeURIComponent(recoveryHash)}/product/${encodeURIComponent(productId)}`);
3430
3577
  const checkJson = await checkResp.json();
@@ -3487,9 +3634,15 @@ async function startServer(params) {
3487
3634
  const tenant = getTenantByIdentifier(req.params.identifier);
3488
3635
  if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
3489
3636
 
3490
- const { recoveryKey, productId, orderRef, address, title, amount, slotDatetime, contactInfo, type, renewalDays, paymentIntentId } = req.body;
3637
+ const { recoveryKey, pubKey, timestamp: buyerTimestamp, signature: buyerSignature, productId, orderRef, address, title, amount, slotDatetime, contactInfo, type, renewalDays, paymentIntentId } = req.body;
3491
3638
  const sanoraUrlInternal = getSanoraUrl();
3492
3639
 
3640
+ // Verify Shoppere signature if pubKey path
3641
+ if (pubKey) {
3642
+ const sigErr = verifyBuyerSignature(pubKey, buyerTimestamp, buyerSignature);
3643
+ if (sigErr) return res.status(401).json({ error: sigErr });
3644
+ }
3645
+
3493
3646
  // Fire transfer after successful payment — fire-and-forget, does not affect response
3494
3647
  function triggerTransfer() {
3495
3648
  if (!paymentIntentId || !tenant.addieKeys) return;
@@ -3499,6 +3652,69 @@ async function startServer(params) {
3499
3652
  }).catch(err => console.warn('[shoppe] transfer trigger failed:', err.message));
3500
3653
  }
3501
3654
 
3655
+ // ── Shoppere app (pubKey) paths ───────────────────────────────────────
3656
+
3657
+ if (pubKey && type === 'subscription') {
3658
+ // Shoppere subscription: orderKey = sha256(pubKey + productId)
3659
+ const orderKey = crypto.createHash('sha256').update(pubKey + productId).digest('hex');
3660
+ const tenantKeys = tenant.keys;
3661
+ sessionless.getKeys = () => tenantKeys;
3662
+ const ts = Date.now().toString();
3663
+ const sig = await sessionless.sign(ts + tenant.uuid);
3664
+ const order = { orderKey, pubKey, paidAt: Date.now(), title, productId, renewalDays: renewalDays || 30, status: 'active' };
3665
+ await fetch(`${sanoraUrlInternal}/user/${tenant.uuid}/orders`, {
3666
+ method: 'PUT',
3667
+ headers: { 'Content-Type': 'application/json' },
3668
+ body: JSON.stringify({ timestamp: ts, signature: sig, order })
3669
+ });
3670
+ triggerTransfer();
3671
+ return res.json({ success: true });
3672
+ }
3673
+
3674
+ if (pubKey && slotDatetime) {
3675
+ // Shoppere appointment: record booking with pubKey credential
3676
+ const orderKey = crypto.createHash('sha256').update(pubKey + productId).digest('hex');
3677
+ const tenantKeys = tenant.keys;
3678
+ sessionless.getKeys = () => tenantKeys;
3679
+ const bookingTimestamp = Date.now().toString();
3680
+ const bookingSignature = await sessionless.sign(bookingTimestamp + tenant.uuid);
3681
+ const order = {
3682
+ orderKey,
3683
+ pubKey,
3684
+ productId,
3685
+ title,
3686
+ slot: slotDatetime,
3687
+ contactInfo: contactInfo || {},
3688
+ status: 'booked'
3689
+ };
3690
+ await fetch(`${sanoraUrlInternal}/user/${tenant.uuid}/orders`, {
3691
+ method: 'PUT',
3692
+ headers: { 'Content-Type': 'application/json' },
3693
+ body: JSON.stringify({ timestamp: bookingTimestamp, signature: bookingSignature, order })
3694
+ });
3695
+ triggerTransfer();
3696
+ return res.json({ success: true });
3697
+ }
3698
+
3699
+ if (pubKey) {
3700
+ // Shoppere digital product: record purchase with pubKey as credential
3701
+ const orderKey = crypto.createHash('sha256').update(pubKey + productId).digest('hex');
3702
+ const tenantKeys = tenant.keys;
3703
+ sessionless.getKeys = () => tenantKeys;
3704
+ const ts = Date.now().toString();
3705
+ const sig = await sessionless.sign(ts + tenant.uuid);
3706
+ const order = { orderKey, pubKey, paidAt: Date.now(), title, productId, status: 'purchased' };
3707
+ await fetch(`${sanoraUrlInternal}/user/${tenant.uuid}/orders`, {
3708
+ method: 'PUT',
3709
+ headers: { 'Content-Type': 'application/json' },
3710
+ body: JSON.stringify({ timestamp: ts, signature: sig, order })
3711
+ });
3712
+ triggerTransfer();
3713
+ return res.json({ success: true });
3714
+ }
3715
+
3716
+ // ── Legacy recovery key paths ─────────────────────────────────────────
3717
+
3502
3718
  if (recoveryKey && type === 'subscription') {
3503
3719
  // Subscription payment — record an order with a hashed subscriber key + payment timestamp.
3504
3720
  // The recovery key itself is never stored; orderKey = sha256(recoveryKey + productId).
@@ -3599,12 +3815,23 @@ async function startServer(params) {
3599
3815
  if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
3600
3816
 
3601
3817
  const title = decodeURIComponent(req.params.title);
3818
+ const { pubKey, timestamp: buyerTimestamp, signature: buyerSignature } = req.query;
3602
3819
  const sanoraUrl = getSanoraUrl();
3603
3820
  const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
3604
3821
  const products = await productsResp.json();
3605
3822
  const product = products[title] || Object.values(products).find(p => p.title === title);
3606
3823
  if (!product) return res.status(404).send('<h1>Book not found</h1>');
3607
3824
 
3825
+ // Verify access credential — either a valid Shoppere pubKey signature
3826
+ // with a matching purchase record, or the legacy recovery hash (checked client-side
3827
+ // in the download template itself via the existing hash endpoints).
3828
+ if (pubKey) {
3829
+ const sigErr = verifyBuyerSignature(pubKey, buyerTimestamp, buyerSignature);
3830
+ if (sigErr) return res.status(401).send(`<h1>Access denied</h1><p>${sigErr}</p>`);
3831
+ const purchased = await hasPurchasedByPubKey(tenant, pubKey, product.productId);
3832
+ if (!purchased) return res.status(403).send('<h1>No purchase found for this key</h1>');
3833
+ }
3834
+
3608
3835
  const imageUrl = product.image ? `${sanoraUrl}/images/${product.image}` : '';
3609
3836
 
3610
3837
  // Map artifact UUIDs to download paths by extension
@@ -3620,8 +3847,8 @@ async function startServer(params) {
3620
3847
  description: product.description || '',
3621
3848
  image: imageUrl,
3622
3849
  productId: product.productId || '',
3623
- pubKey: '',
3624
- signature: '',
3850
+ pubKey: pubKey || '',
3851
+ signature: buyerSignature || '',
3625
3852
  epubPath,
3626
3853
  pdfPath,
3627
3854
  mobiPath
@@ -3697,6 +3924,64 @@ async function startServer(params) {
3697
3924
  }
3698
3925
  });
3699
3926
 
3927
+ // Shoppere app: purchases for a pubKey across all products on this shoppe.
3928
+ // Auth: pubKey + timestamp + signature (Sessionless, 5-min TTL)
3929
+ // Returns the subset of Sanora orders whose orderKey === sha256(pubKey + productId).
3930
+ app.get('/plugin/shoppe/:identifier/purchases', async (req, res) => {
3931
+ try {
3932
+ const tenant = getTenantByIdentifier(req.params.identifier);
3933
+ if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
3934
+
3935
+ const { pubKey, timestamp, signature } = req.query;
3936
+ const sigErr = verifyBuyerSignature(pubKey, timestamp, signature);
3937
+ if (sigErr) return res.status(401).json({ error: sigErr });
3938
+
3939
+ const sanoraUrl = getSanoraUrl();
3940
+ const productsResp = await fetch(`${sanoraUrl}/products/${tenant.uuid}`);
3941
+ if (!productsResp.ok) return res.status(502).json({ error: 'Could not reach Sanora' });
3942
+ const products = await productsResp.json();
3943
+
3944
+ sessionless.getKeys = () => tenant.keys;
3945
+ const purchases = [];
3946
+
3947
+ for (const [, product] of Object.entries(products)) {
3948
+ if (!product.productId) continue;
3949
+ const orderKey = crypto.createHash('sha256').update(pubKey + product.productId).digest('hex');
3950
+ const ts = Date.now().toString();
3951
+ const sig = await sessionless.sign(ts + tenant.uuid);
3952
+ try {
3953
+ const ordersResp = await fetch(
3954
+ `${sanoraUrl}/user/${tenant.uuid}/orders/${encodeURIComponent(product.productId)}` +
3955
+ `?timestamp=${ts}&signature=${encodeURIComponent(sig)}`
3956
+ );
3957
+ const json = await ordersResp.json();
3958
+ const match = (json.orders || []).find(o => o.orderKey === orderKey);
3959
+ if (match) {
3960
+ purchases.push({
3961
+ productId: product.productId,
3962
+ title: product.title,
3963
+ category: product.category,
3964
+ image: product.image ? `${sanoraUrl}/images/${product.image}` : null,
3965
+ price: product.price,
3966
+ paidAt: match.paidAt,
3967
+ status: match.status,
3968
+ slot: match.slot || null,
3969
+ renewalDays: match.renewalDays || null,
3970
+ downloadUrl: ['book', 'post', 'video'].includes(product.category)
3971
+ ? `${reqProto(req)}://${req.get('host')}/plugin/shoppe/${tenant.uuid}/download/${encodeURIComponent(product.title)}`
3972
+ : null,
3973
+ });
3974
+ }
3975
+ } catch { /* skip products whose orders can't be fetched */ }
3976
+ }
3977
+
3978
+ res.json({ success: true, shopName: tenant.name, purchases });
3979
+ } catch (err) {
3980
+ console.error('[shoppe] purchases error:', err);
3981
+ res.status(500).json({ error: err.message });
3982
+ }
3983
+ });
3984
+
3700
3985
  // Goods JSON (public)
3701
3986
  app.get('/plugin/shoppe/:identifier/goods', async (req, res) => {
3702
3987
  try {