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 +105 -20
- package/package.json +1 -1
- package/server/server.js +300 -15
- package/server/templates/generic-recover-stripe.html +461 -547
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.
|
|
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
|
|
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
|
-
|
|
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/
|
|
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
|
|
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
|
-
|
|
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": "
|
|
376
|
+
"sessionless-node": "latest"
|
|
292
377
|
}
|
|
293
378
|
```
|
package/package.json
CHANGED
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:
|
|
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('${
|
|
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)}×tamp=${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
|
-
|
|
3076
|
-
|
|
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: '
|
|
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 (
|
|
3410
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 {
|