wiki-plugin-shoppe 0.0.1

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 ADDED
@@ -0,0 +1,125 @@
1
+ # wiki-plugin-shoppe — Developer Documentation
2
+
3
+ Multi-tenant digital goods shoppe for Federated Wiki, powered by Sanora.
4
+
5
+ ## Architecture
6
+
7
+ 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).
8
+
9
+ ### Tenant Identity
10
+
11
+ Each tenant has:
12
+ - **UUID** — their Sanora user UUID
13
+ - **Emojicode** — 8-emoji human-readable identifier (e.g. `🛍️🎨🎁🌟💎🐉📚🔥`)
14
+
15
+ The first 3 emoji are fixed per wiki instance (`SHOPPE_BASE_EMOJI`, default `🛍️🎨🎁`). The last 5 are unique per tenant.
16
+
17
+ ### Tenant Lifecycle
18
+
19
+ 1. Wiki owner registers a tenant: `POST /plugin/shoppe/register { name }`
20
+ - Plugin creates a Sanora user for the tenant
21
+ - Generates emojicode, stores `{ uuid, emojicode, name, keys }` in `.shoppe-tenants.json`
22
+ - Returns `{ uuid, emojicode }` — tenant puts these in their `manifest.json`
23
+ 2. Tenant builds their archive (see format below)
24
+ 3. Tenant drags archive onto the wiki plugin widget
25
+ 4. Plugin verifies `uuid + emojicode`, processes all goods into Sanora
26
+ 5. Shoppe accessible at `/plugin/shoppe/:uuid` or `/plugin/shoppe/:emojicode`
27
+
28
+ ## Archive Format
29
+
30
+ ```
31
+ my-shoppe.zip
32
+ manifest.json ← required: { uuid, emojicode, name }
33
+ books/
34
+ My Novel.epub
35
+ Technical Guide.pdf
36
+ music/
37
+ My Album/ ← album = subfolder
38
+ cover.jpg
39
+ 01-track.mp3
40
+ 02-track.mp3
41
+ Standalone Track.mp3 ← standalone track = file directly in music/
42
+ posts/
43
+ 2025-01-hello-world.md
44
+ 2025-02-another-post.md
45
+ albums/
46
+ Vacation 2025/ ← photo album = subfolder
47
+ photo1.jpg
48
+ photo2.jpg
49
+ products/
50
+ T-Shirt/ ← product = subfolder with cover + info.json
51
+ cover.jpg
52
+ info.json
53
+ ```
54
+
55
+ ### manifest.json
56
+
57
+ ```json
58
+ {
59
+ "uuid": "your-uuid-from-registration",
60
+ "emojicode": "🛍️🎨🎁🌟💎🐉📚🔥",
61
+ "name": "My Shoppe"
62
+ }
63
+ ```
64
+
65
+ ### products/*/info.json
66
+
67
+ ```json
68
+ {
69
+ "title": "Planet Nine T-Shirt",
70
+ "description": "Comfortable cotton tee with logo",
71
+ "price": 25,
72
+ "shipping": 5
73
+ }
74
+ ```
75
+
76
+ ## Routes
77
+
78
+ | Method | Path | Auth | Description |
79
+ |--------|------|------|-------------|
80
+ | `POST` | `/plugin/shoppe/register` | Owner | Register new tenant |
81
+ | `GET` | `/plugin/shoppe/tenants` | Owner | List all tenants |
82
+ | `POST` | `/plugin/shoppe/upload` | UUID+emojicode in archive | Upload goods archive |
83
+ | `GET` | `/plugin/shoppe/:id` | Public | Shoppe HTML page |
84
+ | `GET` | `/plugin/shoppe/:id/goods` | Public | Goods JSON |
85
+ | `GET` | `/plugin/shoppe/:id/goods?category=books` | Public | Filtered goods JSON |
86
+
87
+ `:id` accepts either UUID or emojicode.
88
+
89
+ ## Configuration
90
+
91
+ ```bash
92
+ # Base emoji for all tenant emojicodes on this wiki (default: 🛍️🎨🎁)
93
+ export SHOPPE_BASE_EMOJI="🏪🎪🎁"
94
+
95
+ # Sanora port (default: 7243)
96
+ export SANORA_PORT=7243
97
+ ```
98
+
99
+ ## Supported File Types
100
+
101
+ | Category | Extensions |
102
+ |----------|-----------|
103
+ | Books | .epub, .pdf, .mobi, .azw, .azw3 |
104
+ | Music | .mp3, .flac, .m4a, .ogg, .wav |
105
+ | Posts | .md |
106
+ | Albums | .jpg, .jpeg, .png, .gif, .webp, .svg |
107
+ | Products | .jpg/.png cover + info.json |
108
+
109
+ ## Storage
110
+
111
+ Tenant registry: `.shoppe-tenants.json` (gitignored — contains private keys)
112
+
113
+ Each tenant's goods are stored in Sanora under their own UUID.
114
+
115
+ ## Dependencies
116
+
117
+ ```json
118
+ {
119
+ "adm-zip": "^0.5.10",
120
+ "form-data": "^4.0.0",
121
+ "multer": "^1.4.5-lts.1",
122
+ "node-fetch": "^2.6.1",
123
+ "sessionless-node": "^0.9.12"
124
+ }
125
+ ```
@@ -0,0 +1,207 @@
1
+ (function() {
2
+
3
+ window.plugins.shoppe = {
4
+ emit: function($item, item) {
5
+ const div = $item[0];
6
+ div.innerHTML = `
7
+ <div class="shoppe-widget">
8
+ <style>
9
+ .shoppe-widget { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 700px; margin: 0 auto; }
10
+ .shoppe-drop { border: 2px dashed #ccc; border-radius: 14px; padding: 40px 24px; text-align: center; cursor: pointer; transition: border-color 0.2s, background 0.2s; background: #fafafa; }
11
+ .shoppe-drop.dragover { border-color: #0066cc; background: #e8f0fe; }
12
+ .shoppe-drop-icon { font-size: 48px; margin-bottom: 12px; }
13
+ .shoppe-drop h3 { font-size: 18px; font-weight: 600; margin-bottom: 6px; color: #1d1d1f; }
14
+ .shoppe-drop p { font-size: 13px; color: #888; margin-bottom: 16px; }
15
+ .shoppe-btn { display: inline-block; padding: 10px 22px; border-radius: 20px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: background 0.15s; }
16
+ .shoppe-btn-blue { background: #0066cc; color: white; }
17
+ .shoppe-btn-blue:hover { background: #0055aa; }
18
+ .shoppe-btn-green { background: #10b981; color: white; }
19
+ .shoppe-btn-green:hover { background: #059669; }
20
+ .shoppe-section { margin-top: 20px; }
21
+ .shoppe-section h4 { font-size: 13px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
22
+ .shoppe-tenant { display: flex; align-items: center; justify-content: space-between; background: white; border: 1px solid #eee; border-radius: 10px; padding: 12px 16px; margin-bottom: 8px; }
23
+ .shoppe-tenant-info { display: flex; flex-direction: column; gap: 2px; }
24
+ .shoppe-tenant-name { font-weight: 600; font-size: 14px; }
25
+ .shoppe-tenant-code { font-size: 16px; letter-spacing: 3px; }
26
+ .shoppe-tenant-link { font-size: 13px; color: #0066cc; text-decoration: none; }
27
+ .shoppe-tenant-link:hover { text-decoration: underline; }
28
+ .shoppe-status { margin-top: 14px; border-radius: 10px; padding: 14px 18px; font-size: 14px; }
29
+ .shoppe-status.info { background: #e8f0fe; color: #1a56db; }
30
+ .shoppe-status.success { background: #d1fae5; color: #065f46; }
31
+ .shoppe-status.error { background: #fee2e2; color: #991b1b; }
32
+ .shoppe-register { display: flex; gap: 8px; margin-top: 12px; }
33
+ .shoppe-register input { flex: 1; padding: 9px 14px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; }
34
+ .shoppe-result-counts { margin-top: 8px; font-size: 13px; opacity: 0.8; }
35
+ </style>
36
+
37
+ <!-- Drop zone -->
38
+ <div class="shoppe-drop" id="shoppe-drop">
39
+ <div class="shoppe-drop-icon">🛍️</div>
40
+ <h3>Drop your shoppe archive here</h3>
41
+ <p>Drag and drop a .zip archive, or click to browse.<br>Archive must contain manifest.json with your uuid and emojicode.</p>
42
+ <button class="shoppe-btn shoppe-btn-blue" id="shoppe-browse-btn">Choose Archive</button>
43
+ <input type="file" id="shoppe-file-input" accept=".zip" style="display:none">
44
+ </div>
45
+
46
+ <!-- Register section -->
47
+ <div class="shoppe-section">
48
+ <h4>Register New Shoppe</h4>
49
+ <div class="shoppe-register">
50
+ <input type="text" id="shoppe-name-input" placeholder="Shoppe name (e.g. Zach's Art Store)">
51
+ <button class="shoppe-btn shoppe-btn-green" id="shoppe-register-btn">Register</button>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- Tenant list -->
56
+ <div class="shoppe-section" id="shoppe-tenants-section">
57
+ <h4>Active Shoppes</h4>
58
+ <div id="shoppe-tenants-list"><em style="font-size:13px;color:#999">Loading...</em></div>
59
+ </div>
60
+
61
+ <!-- Status -->
62
+ <div id="shoppe-status" style="display:none"></div>
63
+ </div>
64
+ `;
65
+
66
+ setupShoppeListeners(div);
67
+ loadTenants(div);
68
+ },
69
+
70
+ bind: function($item, item) {}
71
+ };
72
+
73
+ function setupShoppeListeners(container) {
74
+ const drop = container.querySelector('#shoppe-drop');
75
+ const fileInput = container.querySelector('#shoppe-file-input');
76
+ const browseBtn = container.querySelector('#shoppe-browse-btn');
77
+ const registerBtn = container.querySelector('#shoppe-register-btn');
78
+ const nameInput = container.querySelector('#shoppe-name-input');
79
+
80
+ // Browse button
81
+ browseBtn.addEventListener('click', () => fileInput.click());
82
+
83
+ // File selected via browser
84
+ fileInput.addEventListener('change', e => {
85
+ const file = e.target.files[0];
86
+ if (file) uploadArchive(file, container);
87
+ });
88
+
89
+ // Drag and drop
90
+ drop.addEventListener('dragover', e => {
91
+ e.preventDefault();
92
+ drop.classList.add('dragover');
93
+ });
94
+ drop.addEventListener('dragleave', () => drop.classList.remove('dragover'));
95
+ drop.addEventListener('drop', e => {
96
+ e.preventDefault();
97
+ drop.classList.remove('dragover');
98
+ const file = e.dataTransfer.files[0];
99
+ if (file && file.name.endsWith('.zip')) {
100
+ uploadArchive(file, container);
101
+ } else {
102
+ showStatus(container, 'Please drop a .zip archive', 'error');
103
+ }
104
+ });
105
+
106
+ // Register
107
+ registerBtn.addEventListener('click', async () => {
108
+ const name = nameInput.value.trim();
109
+ if (!name) { showStatus(container, 'Enter a shoppe name first', 'error'); return; }
110
+ registerBtn.disabled = true;
111
+ registerBtn.textContent = 'Registering...';
112
+ try {
113
+ const resp = await fetch('/plugin/shoppe/register', {
114
+ method: 'POST',
115
+ headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify({ name })
117
+ });
118
+ const result = await resp.json();
119
+ if (result.success) {
120
+ nameInput.value = '';
121
+ showStatus(container,
122
+ `✅ Registered! UUID: <code>${result.tenant.uuid}</code><br>Emojicode: <strong>${result.tenant.emojicode}</strong><br>Put these in your manifest.json`,
123
+ 'success');
124
+ loadTenants(container);
125
+ } else {
126
+ throw new Error(result.error || 'Registration failed');
127
+ }
128
+ } catch (err) {
129
+ showStatus(container, `❌ ${err.message}`, 'error');
130
+ } finally {
131
+ registerBtn.disabled = false;
132
+ registerBtn.textContent = 'Register';
133
+ }
134
+ });
135
+ }
136
+
137
+ async function uploadArchive(file, container) {
138
+ showStatus(container, `⏳ Uploading and processing <strong>${file.name}</strong>...`, 'info');
139
+
140
+ const form = new FormData();
141
+ form.append('archive', file);
142
+
143
+ try {
144
+ const resp = await fetch('/plugin/shoppe/upload', { method: 'POST', body: form });
145
+ const result = await resp.json();
146
+
147
+ if (result.success) {
148
+ const r = result.results;
149
+ const counts = [
150
+ r.books.length && `📚 ${r.books.length} book${r.books.length !== 1 ? 's' : ''}`,
151
+ r.music.length && `🎵 ${r.music.length} music item${r.music.length !== 1 ? 's' : ''}`,
152
+ r.posts.length && `📝 ${r.posts.length} post${r.posts.length !== 1 ? 's' : ''}`,
153
+ r.albums.length && `🖼️ ${r.albums.length} album${r.albums.length !== 1 ? 's' : ''}`,
154
+ r.products.length && `📦 ${r.products.length} product${r.products.length !== 1 ? 's' : ''}`
155
+ ].filter(Boolean).join(', ') || 'nothing uploaded';
156
+
157
+ showStatus(container,
158
+ `✅ Uploaded for <strong>${result.tenant.name}</strong> ${result.tenant.emojicode}<br>
159
+ <div class="shoppe-result-counts">${counts}</div>
160
+ <a href="/plugin/shoppe/${result.tenant.uuid}" target="_blank" style="display:inline-block;margin-top:10px;color:#0066cc;">View shoppe →</a>`,
161
+ 'success');
162
+ loadTenants(container);
163
+ } else {
164
+ throw new Error(result.error || 'Upload failed');
165
+ }
166
+ } catch (err) {
167
+ showStatus(container, `❌ ${err.message}`, 'error');
168
+ }
169
+ }
170
+
171
+ async function loadTenants(container) {
172
+ const list = container.querySelector('#shoppe-tenants-list');
173
+ if (!list) return;
174
+ try {
175
+ const resp = await fetch('/plugin/shoppe/tenants');
176
+ if (!resp.ok) {
177
+ list.innerHTML = '<em style="font-size:13px;color:#999">Not available (owner only)</em>';
178
+ return;
179
+ }
180
+ const result = await resp.json();
181
+ if (!result.success || result.tenants.length === 0) {
182
+ list.innerHTML = '<em style="font-size:13px;color:#999">No shoppes registered yet.</em>';
183
+ return;
184
+ }
185
+ list.innerHTML = result.tenants.map(t => `
186
+ <div class="shoppe-tenant">
187
+ <div class="shoppe-tenant-info">
188
+ <span class="shoppe-tenant-name">${t.name}</span>
189
+ <span class="shoppe-tenant-code">${t.emojicode}</span>
190
+ <code style="font-size:11px;color:#888">${t.uuid}</code>
191
+ </div>
192
+ <a class="shoppe-tenant-link" href="${t.url}" target="_blank">View shoppe →</a>
193
+ </div>
194
+ `).join('');
195
+ } catch (err) {
196
+ list.innerHTML = '<em style="font-size:13px;color:#999">Could not load tenants.</em>';
197
+ }
198
+ }
199
+
200
+ function showStatus(container, html, type) {
201
+ const el = container.querySelector('#shoppe-status');
202
+ el.className = `shoppe-status ${type}`;
203
+ el.innerHTML = html;
204
+ el.style.display = 'block';
205
+ }
206
+
207
+ })();
package/factory.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "Shoppe",
3
+ "title": "Multi-Tenant Digital Goods Shoppe",
4
+ "category": "content"
5
+ }
package/index.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require('./server/server.js');
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "wiki-plugin-shoppe",
3
+ "version": "0.0.1",
4
+ "description": "Multi-tenant digital goods shoppe for federated wiki, powered by Sanora",
5
+ "keywords": [
6
+ "wiki",
7
+ "federated",
8
+ "shoppe",
9
+ "storefront",
10
+ "planet-nine",
11
+ "sanora"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "planetnineisaspaceship",
15
+ "type": "commonjs",
16
+ "main": "index.js",
17
+ "scripts": {
18
+ "test": "echo \"Error: no test specified\" && exit 1"
19
+ },
20
+ "dependencies": {
21
+ "adm-zip": "^0.5.10",
22
+ "form-data": "^4.0.0",
23
+ "multer": "^1.4.5-lts.1",
24
+ "node-fetch": "^2.6.1",
25
+ "sessionless-node": "^0.9.12"
26
+ }
27
+ }
@@ -0,0 +1,625 @@
1
+ (function() {
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const fetch = require('node-fetch');
5
+ const multer = require('multer');
6
+ const FormData = require('form-data');
7
+ const AdmZip = require('adm-zip');
8
+ const sessionless = require('sessionless-node');
9
+
10
+ const SANORA_PORT = process.env.SANORA_PORT || 7243;
11
+ const SHOPPE_BASE_EMOJI = process.env.SHOPPE_BASE_EMOJI || '🛍️🎨🎁';
12
+
13
+ const TENANTS_FILE = path.join(__dirname, '../.shoppe-tenants.json');
14
+ const TMP_DIR = '/tmp/shoppe-uploads';
15
+
16
+ // Same diverse palette as BDO emojicoding
17
+ const EMOJI_PALETTE = [
18
+ '🌟', '🌙', '🌍', '🌊', '🔥', '💎', '🎨', '🎭', '🎪', '🎯',
19
+ '🎲', '🎸', '🎹', '🎺', '🎻', '🏆', '🏹', '🏺', '🏰', '🏔',
20
+ '🐉', '🐙', '🐚', '🐝', '🐞', '🐢', '🐳', '🐺', '🐻', '🐼',
21
+ '👑', '👒', '👓', '👔', '👕', '💀', '💡', '💣', '💫', '💰',
22
+ '💼', '📌', '📍', '📎', '📐', '📑', '📕', '📗', '📘', '📙',
23
+ '📚', '📝', '📡', '📢', '📣', '📦', '📧', '📨', '📬', '📮',
24
+ '🔑', '🔒', '🔓', '🔔', '🔨', '🔩', '🔪', '🔫', '🔮', '🔱',
25
+ '🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙',
26
+ '🗝', '🗡', '🗿', '😀', '😁', '😂', '😃', '😄', '😅', '😆',
27
+ '🙂', '🙃', '🙄', '🚀', '🚁', '🚂', '🚃', '🚄', '🚅', '🚆'
28
+ ];
29
+
30
+ const BOOK_EXTS = new Set(['.epub', '.pdf', '.mobi', '.azw', '.azw3']);
31
+ const MUSIC_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav']);
32
+ const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']);
33
+
34
+ // ============================================================
35
+ // TENANT MANAGEMENT
36
+ // ============================================================
37
+
38
+ function loadTenants() {
39
+ if (!fs.existsSync(TENANTS_FILE)) return {};
40
+ try {
41
+ return JSON.parse(fs.readFileSync(TENANTS_FILE, 'utf8'));
42
+ } catch (err) {
43
+ console.warn('[shoppe] Failed to load tenants:', err.message);
44
+ return {};
45
+ }
46
+ }
47
+
48
+ function saveTenants(tenants) {
49
+ fs.writeFileSync(TENANTS_FILE, JSON.stringify(tenants, null, 2));
50
+ }
51
+
52
+ function generateEmojicode(tenants) {
53
+ const base = [...SHOPPE_BASE_EMOJI].slice(0, 3).join('');
54
+ const existing = new Set(Object.values(tenants).map(t => t.emojicode));
55
+ for (let i = 0; i < 100; i++) {
56
+ const shuffled = [...EMOJI_PALETTE].sort(() => Math.random() - 0.5);
57
+ const code = base + shuffled.slice(0, 5).join('');
58
+ if (!existing.has(code)) return code;
59
+ }
60
+ throw new Error('Failed to generate unique emojicode after 100 attempts');
61
+ }
62
+
63
+ async function registerTenant(name) {
64
+ const tenants = loadTenants();
65
+
66
+ // Create a dedicated Sanora user for this tenant
67
+ const keys = await sessionless.generateKeys(() => {}, () => null);
68
+ const timestamp = Date.now().toString();
69
+ const message = timestamp + keys.pubKey;
70
+ const signature = await sessionless.sign(message, keys.privateKey);
71
+
72
+ const resp = await fetch(`http://localhost:${SANORA_PORT}/user/create`, {
73
+ method: 'PUT',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify({ timestamp, pubKey: keys.pubKey, signature })
76
+ });
77
+
78
+ const sanoraUser = await resp.json();
79
+ if (sanoraUser.error) throw new Error(`Sanora: ${sanoraUser.error}`);
80
+
81
+ const emojicode = generateEmojicode(tenants);
82
+
83
+ const tenant = {
84
+ uuid: sanoraUser.uuid,
85
+ emojicode,
86
+ name: name || 'Unnamed Shoppe',
87
+ keys,
88
+ sanoraUser,
89
+ createdAt: Date.now()
90
+ };
91
+
92
+ tenants[sanoraUser.uuid] = tenant;
93
+ saveTenants(tenants);
94
+
95
+ console.log(`[shoppe] Registered tenant: "${name}" ${emojicode} (${sanoraUser.uuid})`);
96
+ return { uuid: sanoraUser.uuid, emojicode, name: tenant.name };
97
+ }
98
+
99
+ function getTenantByIdentifier(identifier) {
100
+ const tenants = loadTenants();
101
+ if (tenants[identifier]) return tenants[identifier];
102
+ return Object.values(tenants).find(t => t.emojicode === identifier) || null;
103
+ }
104
+
105
+ // ============================================================
106
+ // SANORA API HELPERS
107
+ // ============================================================
108
+
109
+ function getMimeType(filename) {
110
+ const ext = path.extname(filename).toLowerCase();
111
+ return ({
112
+ '.epub': 'application/epub+zip',
113
+ '.pdf': 'application/pdf',
114
+ '.mobi': 'application/x-mobipocket-ebook',
115
+ '.mp3': 'audio/mpeg',
116
+ '.flac': 'audio/flac',
117
+ '.m4a': 'audio/mp4',
118
+ '.ogg': 'audio/ogg',
119
+ '.wav': 'audio/wav',
120
+ '.md': 'text/markdown',
121
+ '.jpg': 'image/jpeg',
122
+ '.jpeg': 'image/jpeg',
123
+ '.png': 'image/png',
124
+ '.gif': 'image/gif',
125
+ '.webp': 'image/webp',
126
+ '.svg': 'image/svg+xml'
127
+ })[ext] || 'application/octet-stream';
128
+ }
129
+
130
+ async function sanoraCreateProduct(tenant, title, category, description, price, shipping, tags) {
131
+ const { uuid, keys } = tenant;
132
+ const timestamp = Date.now().toString();
133
+ const safePrice = price || 0;
134
+ const message = timestamp + uuid + title + (description || '') + safePrice;
135
+
136
+ sessionless.getKeys = () => keys;
137
+ const signature = await sessionless.sign(message);
138
+
139
+ const resp = await fetch(
140
+ `http://localhost:${SANORA_PORT}/user/${uuid}/product/${encodeURIComponent(title)}`,
141
+ {
142
+ method: 'PUT',
143
+ headers: { 'Content-Type': 'application/json' },
144
+ body: JSON.stringify({
145
+ timestamp,
146
+ pubKey: keys.pubKey,
147
+ signature,
148
+ description: description || '',
149
+ price: safePrice,
150
+ shipping: shipping || 0,
151
+ category,
152
+ tags: tags || category
153
+ })
154
+ }
155
+ );
156
+
157
+ const product = await resp.json();
158
+ if (product.error) throw new Error(`Create product failed: ${product.error}`);
159
+ return product;
160
+ }
161
+
162
+ async function sanoraUploadArtifact(tenant, title, fileBuffer, filename, artifactType) {
163
+ const { uuid, keys } = tenant;
164
+ const timestamp = Date.now().toString();
165
+ sessionless.getKeys = () => keys;
166
+ const message = timestamp + uuid + title;
167
+ const signature = await sessionless.sign(message);
168
+
169
+ const form = new FormData();
170
+ form.append('artifact', fileBuffer, { filename, contentType: getMimeType(filename) });
171
+
172
+ const resp = await fetch(
173
+ `http://localhost:${SANORA_PORT}/user/${uuid}/product/${encodeURIComponent(title)}/artifact`,
174
+ {
175
+ method: 'PUT',
176
+ headers: {
177
+ 'x-pn-artifact-type': artifactType,
178
+ 'x-pn-timestamp': timestamp,
179
+ 'x-pn-signature': signature,
180
+ ...form.getHeaders()
181
+ },
182
+ body: form
183
+ }
184
+ );
185
+
186
+ const result = await resp.json();
187
+ if (result.error) throw new Error(`Artifact upload failed: ${result.error}`);
188
+ return result;
189
+ }
190
+
191
+ async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
192
+ const { uuid, keys } = tenant;
193
+ const timestamp = Date.now().toString();
194
+ sessionless.getKeys = () => keys;
195
+ const message = timestamp + uuid + title;
196
+ const signature = await sessionless.sign(message);
197
+
198
+ const form = new FormData();
199
+ form.append('image', imageBuffer, { filename, contentType: getMimeType(filename) });
200
+
201
+ const resp = await fetch(
202
+ `http://localhost:${SANORA_PORT}/user/${uuid}/product/${encodeURIComponent(title)}/image`,
203
+ {
204
+ method: 'PUT',
205
+ headers: {
206
+ 'x-pn-timestamp': timestamp,
207
+ 'x-pn-signature': signature,
208
+ ...form.getHeaders()
209
+ },
210
+ body: form
211
+ }
212
+ );
213
+
214
+ const result = await resp.json();
215
+ if (result.error) throw new Error(`Image upload failed: ${result.error}`);
216
+ return result;
217
+ }
218
+
219
+ // ============================================================
220
+ // ARCHIVE PROCESSING
221
+ // ============================================================
222
+
223
+ async function processArchive(zipPath) {
224
+ const zip = new AdmZip(zipPath);
225
+ const tmpDir = path.join(TMP_DIR, `extract-${Date.now()}`);
226
+ zip.extractAllTo(tmpDir, true);
227
+
228
+ try {
229
+ // Read and validate manifest
230
+ const manifestPath = path.join(tmpDir, 'manifest.json');
231
+ if (!fs.existsSync(manifestPath)) {
232
+ throw new Error('Archive is missing manifest.json');
233
+ }
234
+
235
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
236
+ if (!manifest.uuid || !manifest.emojicode) {
237
+ throw new Error('manifest.json must contain uuid and emojicode');
238
+ }
239
+
240
+ const tenant = getTenantByIdentifier(manifest.uuid);
241
+ if (!tenant) throw new Error(`Unknown UUID: ${manifest.uuid}`);
242
+ if (tenant.emojicode !== manifest.emojicode) {
243
+ throw new Error('emojicode does not match registered tenant');
244
+ }
245
+
246
+ const results = { books: [], music: [], posts: [], albums: [], products: [] };
247
+
248
+ // ---- books/ ----
249
+ const booksDir = path.join(tmpDir, 'books');
250
+ if (fs.existsSync(booksDir)) {
251
+ for (const file of fs.readdirSync(booksDir)) {
252
+ if (!BOOK_EXTS.has(path.extname(file).toLowerCase())) continue;
253
+ const title = path.basename(file, path.extname(file));
254
+ try {
255
+ const buf = fs.readFileSync(path.join(booksDir, file));
256
+ await sanoraCreateProduct(tenant, title, 'book', `Book: ${title}`, 0, 0, 'book');
257
+ await sanoraUploadArtifact(tenant, title, buf, file, 'ebook');
258
+ results.books.push({ title });
259
+ console.log(`[shoppe] 📚 book: ${title}`);
260
+ } catch (err) {
261
+ console.warn(`[shoppe] ⚠️ book ${file}: ${err.message}`);
262
+ }
263
+ }
264
+ }
265
+
266
+ // ---- music/ ----
267
+ // Albums are subfolders; standalone files are individual tracks
268
+ const musicDir = path.join(tmpDir, 'music');
269
+ if (fs.existsSync(musicDir)) {
270
+ for (const entry of fs.readdirSync(musicDir)) {
271
+ const entryPath = path.join(musicDir, entry);
272
+ const stat = fs.statSync(entryPath);
273
+
274
+ if (stat.isDirectory()) {
275
+ // Album
276
+ const albumName = entry;
277
+ const tracks = fs.readdirSync(entryPath).filter(f => MUSIC_EXTS.has(path.extname(f).toLowerCase()));
278
+ const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
279
+ try {
280
+ await sanoraCreateProduct(tenant, albumName, 'music', `Album: ${albumName}`, 0, 0, 'music,album');
281
+ if (covers.length > 0) {
282
+ const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
283
+ await sanoraUploadImage(tenant, albumName, coverBuf, covers[0]);
284
+ }
285
+ for (const track of tracks) {
286
+ const buf = fs.readFileSync(path.join(entryPath, track));
287
+ await sanoraUploadArtifact(tenant, albumName, buf, track, 'audio');
288
+ }
289
+ results.music.push({ title: albumName, type: 'album', tracks: tracks.length });
290
+ console.log(`[shoppe] 🎵 album: ${albumName} (${tracks.length} tracks)`);
291
+ } catch (err) {
292
+ console.warn(`[shoppe] ⚠️ album ${albumName}: ${err.message}`);
293
+ }
294
+ } else if (MUSIC_EXTS.has(path.extname(entry).toLowerCase())) {
295
+ // Standalone track
296
+ const title = path.basename(entry, path.extname(entry));
297
+ try {
298
+ const buf = fs.readFileSync(entryPath);
299
+ await sanoraCreateProduct(tenant, title, 'music', `Track: ${title}`, 0, 0, 'music,track');
300
+ await sanoraUploadArtifact(tenant, title, buf, entry, 'audio');
301
+ results.music.push({ title, type: 'track' });
302
+ console.log(`[shoppe] 🎵 track: ${title}`);
303
+ } catch (err) {
304
+ console.warn(`[shoppe] ⚠️ track ${entry}: ${err.message}`);
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ // ---- posts/ ----
311
+ const postsDir = path.join(tmpDir, 'posts');
312
+ if (fs.existsSync(postsDir)) {
313
+ for (const file of fs.readdirSync(postsDir)) {
314
+ if (!file.endsWith('.md')) continue;
315
+ const title = path.basename(file, '.md');
316
+ try {
317
+ const buf = fs.readFileSync(path.join(postsDir, file));
318
+ const firstLine = buf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
319
+ await sanoraCreateProduct(tenant, title, 'post', firstLine || title, 0, 0, 'post,blog');
320
+ await sanoraUploadArtifact(tenant, title, buf, file, 'text');
321
+ results.posts.push({ title });
322
+ console.log(`[shoppe] 📝 post: ${title}`);
323
+ } catch (err) {
324
+ console.warn(`[shoppe] ⚠️ post ${file}: ${err.message}`);
325
+ }
326
+ }
327
+ }
328
+
329
+ // ---- albums/ ----
330
+ // Each subfolder is a photo album
331
+ const albumsDir = path.join(tmpDir, 'albums');
332
+ if (fs.existsSync(albumsDir)) {
333
+ for (const entry of fs.readdirSync(albumsDir)) {
334
+ const entryPath = path.join(albumsDir, entry);
335
+ if (!fs.statSync(entryPath).isDirectory()) continue;
336
+ const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
337
+ try {
338
+ await sanoraCreateProduct(tenant, entry, 'album', `Photo album: ${entry}`, 0, 0, 'album,photos');
339
+ if (images.length > 0) {
340
+ const coverBuf = fs.readFileSync(path.join(entryPath, images[0]));
341
+ await sanoraUploadImage(tenant, entry, coverBuf, images[0]);
342
+ }
343
+ for (const img of images) {
344
+ const buf = fs.readFileSync(path.join(entryPath, img));
345
+ await sanoraUploadArtifact(tenant, entry, buf, img, 'image');
346
+ }
347
+ results.albums.push({ title: entry, images: images.length });
348
+ console.log(`[shoppe] 🖼️ album: ${entry} (${images.length} images)`);
349
+ } catch (err) {
350
+ console.warn(`[shoppe] ⚠️ album ${entry}: ${err.message}`);
351
+ }
352
+ }
353
+ }
354
+
355
+ // ---- products/ ----
356
+ // Each subfolder is a physical product with cover.jpg + info.json
357
+ const productsDir = path.join(tmpDir, 'products');
358
+ if (fs.existsSync(productsDir)) {
359
+ for (const entry of fs.readdirSync(productsDir)) {
360
+ const entryPath = path.join(productsDir, entry);
361
+ if (!fs.statSync(entryPath).isDirectory()) continue;
362
+ try {
363
+ const infoPath = path.join(entryPath, 'info.json');
364
+ const info = fs.existsSync(infoPath)
365
+ ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
366
+ : {};
367
+ const title = info.title || entry;
368
+ const description = info.description || '';
369
+ const price = info.price || 0;
370
+ const shipping = info.shipping || 0;
371
+
372
+ await sanoraCreateProduct(tenant, title, 'product', description, price, shipping, 'product,physical');
373
+
374
+ const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
375
+ if (images.length > 0) {
376
+ const coverBuf = fs.readFileSync(path.join(entryPath, images[0]));
377
+ await sanoraUploadImage(tenant, title, coverBuf, images[0]);
378
+ }
379
+
380
+ results.products.push({ title, price, shipping });
381
+ console.log(`[shoppe] 📦 product: ${title} ($${price} + $${shipping} shipping)`);
382
+ } catch (err) {
383
+ console.warn(`[shoppe] ⚠️ product ${entry}: ${err.message}`);
384
+ }
385
+ }
386
+ }
387
+
388
+ return {
389
+ tenant: { uuid: tenant.uuid, emojicode: tenant.emojicode, name: tenant.name },
390
+ results
391
+ };
392
+
393
+ } finally {
394
+ try { fs.rmSync(tmpDir, { recursive: true }); } catch (e) {}
395
+ }
396
+ }
397
+
398
+ // ============================================================
399
+ // PORTFOLIO PAGE GENERATION
400
+ // ============================================================
401
+
402
+ async function getShoppeGoods(tenant) {
403
+ const resp = await fetch(`http://localhost:${SANORA_PORT}/products/${tenant.uuid}`);
404
+ const products = await resp.json();
405
+
406
+ const goods = { books: [], music: [], posts: [], albums: [], products: [] };
407
+
408
+ for (const [title, product] of Object.entries(products)) {
409
+ const item = {
410
+ title: product.title || title,
411
+ description: product.description || '',
412
+ price: product.price || 0,
413
+ shipping: product.shipping || 0,
414
+ image: product.image ? `http://localhost:${SANORA_PORT}/images/${product.image}` : null,
415
+ url: `http://localhost:${SANORA_PORT}/products/${tenant.uuid}/${encodeURIComponent(title)}`
416
+ };
417
+ const bucket = goods[product.category];
418
+ if (bucket) bucket.push(item);
419
+ else goods.products.push(item);
420
+ }
421
+
422
+ return goods;
423
+ }
424
+
425
+ const CATEGORY_EMOJI = { book: '📚', music: '🎵', post: '📝', album: '🖼️', product: '📦' };
426
+
427
+ function renderCards(items, category) {
428
+ if (items.length === 0) {
429
+ return '<p class="empty">Nothing here yet.</p>';
430
+ }
431
+ return items.map(item => {
432
+ const imgHtml = item.image
433
+ ? `<div class="card-img"><img src="${item.image}" alt="" loading="lazy"></div>`
434
+ : `<div class="card-img-placeholder">${CATEGORY_EMOJI[category] || '🎁'}</div>`;
435
+ const priceHtml = (item.price > 0 || category === 'product')
436
+ ? `<div class="price">$${item.price}${item.shipping ? ` <span class="shipping">+ $${item.shipping} shipping</span>` : ''}</div>`
437
+ : '';
438
+ return `
439
+ <div class="card" onclick="window.open('${item.url}','_blank')">
440
+ ${imgHtml}
441
+ <div class="card-body">
442
+ <div class="card-title">${item.title}</div>
443
+ ${item.description ? `<div class="card-desc">${item.description}</div>` : ''}
444
+ ${priceHtml}
445
+ </div>
446
+ </div>`;
447
+ }).join('');
448
+ }
449
+
450
+ function generateShoppeHTML(tenant, goods) {
451
+ const total = Object.values(goods).flat().length;
452
+ const tabs = [
453
+ { id: 'all', label: 'All', count: total, always: true },
454
+ { id: 'books', label: '📚 Books', count: goods.books.length },
455
+ { id: 'music', label: '🎵 Music', count: goods.music.length },
456
+ { id: 'posts', label: '📝 Posts', count: goods.posts.length },
457
+ { id: 'albums', label: '🖼️ Albums', count: goods.albums.length },
458
+ { id: 'products', label: '📦 Products', count: goods.products.length }
459
+ ]
460
+ .filter(t => t.always || t.count > 0)
461
+ .map((t, i) => `<div class="tab${i === 0 ? ' active' : ''}" onclick="show('${t.id}',this)">${t.label} <span class="badge">${t.count}</span></div>`)
462
+ .join('');
463
+
464
+ const allItems = [...goods.books, ...goods.music, ...goods.posts, ...goods.albums, ...goods.products];
465
+
466
+ return `<!DOCTYPE html>
467
+ <html lang="en">
468
+ <head>
469
+ <meta charset="UTF-8">
470
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
471
+ <title>${tenant.name}</title>
472
+ <style>
473
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
474
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f7; color: #1d1d1f; }
475
+ header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); color: white; padding: 48px 24px 40px; text-align: center; }
476
+ .emojicode { font-size: 30px; letter-spacing: 6px; margin-bottom: 14px; }
477
+ header h1 { font-size: 38px; font-weight: 700; margin-bottom: 6px; }
478
+ .count { opacity: 0.65; font-size: 15px; }
479
+ nav { display: flex; overflow-x: auto; background: white; border-bottom: 1px solid #ddd; padding: 0 20px; gap: 0; }
480
+ .tab { padding: 14px 18px; cursor: pointer; font-size: 14px; font-weight: 500; white-space: nowrap; border-bottom: 2px solid transparent; color: #555; transition: color 0.15s, border-color 0.15s; }
481
+ .tab:hover { color: #0066cc; }
482
+ .tab.active { color: #0066cc; border-bottom-color: #0066cc; }
483
+ .badge { background: #e8f0fe; color: #0066cc; border-radius: 10px; padding: 1px 7px; font-size: 11px; margin-left: 5px; }
484
+ main { max-width: 1200px; margin: 0 auto; padding: 36px 24px; }
485
+ .section { display: none; }
486
+ .section.active { display: block; }
487
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 20px; }
488
+ .card { background: white; border-radius: 14px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.07); cursor: pointer; transition: transform 0.18s, box-shadow 0.18s; }
489
+ .card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px rgba(0,0,0,0.12); }
490
+ .card-img img { width: 100%; height: 190px; object-fit: cover; display: block; }
491
+ .card-img-placeholder { height: 110px; display: flex; align-items: center; justify-content: center; font-size: 44px; background: #f0f0f7; }
492
+ .card-body { padding: 16px; }
493
+ .card-title { font-size: 15px; font-weight: 600; margin-bottom: 5px; line-height: 1.3; }
494
+ .card-desc { font-size: 13px; color: #666; margin-bottom: 8px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
495
+ .price { font-size: 15px; font-weight: 700; color: #0066cc; }
496
+ .shipping { font-size: 12px; font-weight: 400; color: #888; }
497
+ .empty { color: #999; text-align: center; padding: 60px 0; font-size: 15px; }
498
+ </style>
499
+ </head>
500
+ <body>
501
+ <header>
502
+ <div class="emojicode">${tenant.emojicode}</div>
503
+ <h1>${tenant.name}</h1>
504
+ <div class="count">${total} item${total !== 1 ? 's' : ''}</div>
505
+ </header>
506
+ <nav>${tabs}</nav>
507
+ <main>
508
+ <div id="all" class="section active"><div class="grid">${renderCards(allItems, 'all')}</div></div>
509
+ <div id="books" class="section"><div class="grid">${renderCards(goods.books, 'book')}</div></div>
510
+ <div id="music" class="section"><div class="grid">${renderCards(goods.music, 'music')}</div></div>
511
+ <div id="posts" class="section"><div class="grid">${renderCards(goods.posts, 'post')}</div></div>
512
+ <div id="albums" class="section"><div class="grid">${renderCards(goods.albums, 'album')}</div></div>
513
+ <div id="products" class="section"><div class="grid">${renderCards(goods.products, 'product')}</div></div>
514
+ </main>
515
+ <script>
516
+ function show(id, tab) {
517
+ document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
518
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
519
+ document.getElementById(id).classList.add('active');
520
+ tab.classList.add('active');
521
+ }
522
+ </script>
523
+ </body>
524
+ </html>`;
525
+ }
526
+
527
+ // ============================================================
528
+ // EXPRESS ROUTES
529
+ // ============================================================
530
+
531
+ async function startServer(params) {
532
+ const app = params.app;
533
+
534
+ if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
535
+ console.log('🛍️ wiki-plugin-shoppe starting...');
536
+
537
+ const owner = (req, res, next) => {
538
+ if (!app.securityhandler.isAuthorized(req)) {
539
+ return res.status(401).json({ error: 'must be owner' });
540
+ }
541
+ return next();
542
+ };
543
+
544
+ const upload = multer({
545
+ dest: TMP_DIR,
546
+ limits: { fileSize: 500 * 1024 * 1024 } // 500 MB
547
+ });
548
+
549
+ // Register a new tenant (owner only)
550
+ app.post('/plugin/shoppe/register', owner, async (req, res) => {
551
+ try {
552
+ const tenant = await registerTenant(req.body.name);
553
+ res.json({ success: true, tenant });
554
+ } catch (err) {
555
+ console.error('[shoppe] register error:', err);
556
+ res.status(500).json({ success: false, error: err.message });
557
+ }
558
+ });
559
+
560
+ // List all tenants (owner only)
561
+ app.get('/plugin/shoppe/tenants', owner, (req, res) => {
562
+ const tenants = loadTenants();
563
+ const safe = Object.values(tenants).map(({ uuid, emojicode, name, createdAt }) => ({
564
+ uuid, emojicode, name, createdAt,
565
+ url: `/plugin/shoppe/${uuid}`
566
+ }));
567
+ res.json({ success: true, tenants: safe });
568
+ });
569
+
570
+ // Upload goods archive (auth via manifest uuid+emojicode)
571
+ app.post('/plugin/shoppe/upload', upload.single('archive'), async (req, res) => {
572
+ try {
573
+ if (!req.file) {
574
+ return res.status(400).json({ success: false, error: 'No archive uploaded' });
575
+ }
576
+ console.log('[shoppe] Processing archive:', req.file.originalname);
577
+ const result = await processArchive(req.file.path);
578
+ res.json({ success: true, ...result });
579
+ } catch (err) {
580
+ console.error('[shoppe] upload error:', err);
581
+ res.status(500).json({ success: false, error: err.message });
582
+ } finally {
583
+ if (req.file && fs.existsSync(req.file.path)) {
584
+ try { fs.unlinkSync(req.file.path); } catch (e) {}
585
+ }
586
+ }
587
+ });
588
+
589
+ // Goods JSON (public)
590
+ app.get('/plugin/shoppe/:identifier/goods', async (req, res) => {
591
+ try {
592
+ const tenant = getTenantByIdentifier(req.params.identifier);
593
+ if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
594
+ const goods = await getShoppeGoods(tenant);
595
+ const cat = req.query.category;
596
+ res.json({ success: true, goods: (cat && goods[cat]) ? goods[cat] : goods });
597
+ } catch (err) {
598
+ res.status(500).json({ error: err.message });
599
+ }
600
+ });
601
+
602
+ // Shoppe HTML page (public)
603
+ app.get('/plugin/shoppe/:identifier', async (req, res) => {
604
+ try {
605
+ const tenant = getTenantByIdentifier(req.params.identifier);
606
+ if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
607
+ const goods = await getShoppeGoods(tenant);
608
+ res.set('Content-Type', 'text/html');
609
+ res.send(generateShoppeHTML(tenant, goods));
610
+ } catch (err) {
611
+ console.error('[shoppe] page error:', err);
612
+ res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
613
+ }
614
+ });
615
+
616
+ console.log('✅ wiki-plugin-shoppe ready!');
617
+ console.log(' POST /plugin/shoppe/register — register tenant (owner)');
618
+ console.log(' GET /plugin/shoppe/tenants — list tenants (owner)');
619
+ console.log(' POST /plugin/shoppe/upload — upload goods archive');
620
+ console.log(' GET /plugin/shoppe/:id — shoppe page');
621
+ console.log(' GET /plugin/shoppe/:id/goods — goods JSON');
622
+ }
623
+
624
+ module.exports = { startServer };
625
+ }).call(this);