diff --git a/.gitignore b/.gitignore
index d70bb9c..1e5247d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@ node_modules
dist
.DS_Store
*.local
+data
+server/data
diff --git a/index.html b/index.html
index 8c5f79c..5a69397 100644
--- a/index.html
+++ b/index.html
@@ -3,7 +3,10 @@
- L484 Vue Tailwind
+
+
+
+ L484
diff --git a/package-lock.json b/package-lock.json
index 748beda..b34b45a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@vitejs/plugin-vue": "^5.2.1",
- "nostr-tools": "^2.23.3",
+ "applesauce-signers": "^5.1.0",
+ "nostr-tools": "^2.10.4",
"vite": "^6.0.5",
"vue": "^3.5.13"
},
@@ -533,44 +534,62 @@
}
},
"node_modules/@noble/ciphers": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
- "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
+ "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
"license": "MIT",
- "engines": {
- "node": ">= 20.19.0"
- },
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
- "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
+ "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
- "@noble/hashes": "2.0.1"
+ "@noble/hashes": "1.3.2"
},
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/curves/node_modules/@noble/hashes": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
+ "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
+ "license": "MIT",
"engines": {
- "node": ">= 20.19.0"
+ "node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
- "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
+ "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
- "node": ">= 20.19.0"
+ "node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@noble/secp256k1": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.2.tgz",
+ "integrity": "sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -935,36 +954,51 @@
]
},
"node_modules/@scure/base": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
- "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
+ "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/@scure/bip32": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
+ "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"license": "MIT",
+ "dependencies": {
+ "@noble/curves": "~1.1.0",
+ "@noble/hashes": "~1.3.1",
+ "@scure/base": "~1.1.0"
+ },
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
- "node_modules/@scure/bip32": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
- "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
+ "node_modules/@scure/bip32/node_modules/@noble/curves": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
+ "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
- "@noble/curves": "2.0.1",
- "@noble/hashes": "2.0.1",
- "@scure/base": "2.0.0"
+ "@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
- "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
+ "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"license": "MIT",
"dependencies": {
- "@noble/hashes": "2.0.1",
- "@scure/base": "2.0.0"
+ "@noble/hashes": "~1.3.0",
+ "@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@@ -1110,6 +1144,100 @@
"node": ">= 8"
}
},
+ "node_modules/applesauce-core": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-5.2.0.tgz",
+ "integrity": "sha512-aSuM6q6/Gs2FGUqytlHDjKZpSst2xKaT0vMXUQFWUctECNIxvwy6/hTDDInukMuI9mrQdjnO781ZJJgghI7RNw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "fast-deep-equal": "^3.1.3",
+ "hash-sum": "^2.0.0",
+ "nanoid": "^5.0.9",
+ "nostr-tools": "~2.19",
+ "rxjs": "^7.8.1"
+ },
+ "funding": {
+ "type": "lightning",
+ "url": "lightning:nostrudel@geyser.fund"
+ }
+ },
+ "node_modules/applesauce-core/node_modules/nanoid": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
+ "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.js"
+ },
+ "engines": {
+ "node": "^18 || >=20"
+ }
+ },
+ "node_modules/applesauce-core/node_modules/nostr-tools": {
+ "version": "2.19.4",
+ "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz",
+ "integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==",
+ "license": "Unlicense",
+ "dependencies": {
+ "@noble/ciphers": "^0.5.1",
+ "@noble/curves": "1.2.0",
+ "@noble/hashes": "1.3.1",
+ "@scure/base": "1.1.1",
+ "@scure/bip32": "1.3.1",
+ "@scure/bip39": "1.2.1",
+ "nostr-wasm": "0.1.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/applesauce-signers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/applesauce-signers/-/applesauce-signers-5.1.0.tgz",
+ "integrity": "sha512-sdQe6J1txYV1GVX8/zSGZDyyXuuZomePHSfUDozZmNAnXhCXE0wqVfhLK0yegVMnomSgoeDUCsGmJiTE2BHqoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/secp256k1": "^1.7.1",
+ "applesauce-core": "^5.1.0",
+ "debug": "^4.4.0",
+ "nanoid": "^5.0.9",
+ "rxjs": "^7.8.2"
+ },
+ "funding": {
+ "type": "lightning",
+ "url": "lightning:nostrudel@geyser.fund"
+ }
+ },
+ "node_modules/applesauce-signers/node_modules/nanoid": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
+ "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.js"
+ },
+ "engines": {
+ "node": "^18 || >=20"
+ }
+ },
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -1326,6 +1454,23 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -1426,6 +1571,12 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -1530,6 +1681,12 @@
"node": ">=10.13.0"
}
},
+ "node_modules/hash-sum": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz",
+ "integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==",
+ "license": "MIT"
+ },
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
@@ -1669,6 +1826,12 @@
"node": ">=8.6"
}
},
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -1717,17 +1880,19 @@
}
},
"node_modules/nostr-tools": {
- "version": "2.23.3",
- "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.3.tgz",
- "integrity": "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==",
+ "version": "2.10.4",
+ "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz",
+ "integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==",
"license": "Unlicense",
"dependencies": {
- "@noble/ciphers": "2.1.1",
- "@noble/curves": "2.0.1",
- "@noble/hashes": "2.0.1",
- "@scure/base": "2.0.0",
- "@scure/bip32": "2.0.1",
- "@scure/bip39": "2.0.1",
+ "@noble/ciphers": "^0.5.1",
+ "@noble/curves": "1.2.0",
+ "@noble/hashes": "1.3.1",
+ "@scure/base": "1.1.1",
+ "@scure/bip32": "1.3.1",
+ "@scure/bip39": "1.2.1"
+ },
+ "optionalDependencies": {
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
@@ -2119,6 +2284,15 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2291,6 +2465,12 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
diff --git a/package.json b/package.json
index 89c693a..47c36ca 100644
--- a/package.json
+++ b/package.json
@@ -4,13 +4,17 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "vite",
+ "dev": "node server/dev.js",
+ "dev:vite": "vite --host",
+ "dev:server": "node server/server.js",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "start": "node server/server.js"
},
"dependencies": {
"@vitejs/plugin-vue": "^5.2.1",
- "nostr-tools": "^2.23.3",
+ "applesauce-signers": "^5.1.0",
+ "nostr-tools": "^2.10.4",
"vite": "^6.0.5",
"vue": "^3.5.13"
},
diff --git a/public/images/header-logo.svg b/public/images/header-logo.svg
index 5d394a2..93c1880 100644
--- a/public/images/header-logo.svg
+++ b/public/images/header-logo.svg
@@ -1,8 +1,8 @@
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..57fda87
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,18 @@
+{
+ "name": "L484 Membership",
+ "short_name": "L484",
+ "description": "Private L484 membership card and agreement portal.",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#000000",
+ "theme_color": "#000000",
+ "icons": [
+ {
+ "src": "/images/small-logo.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..6f0192e
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,39 @@
+const CACHE_NAME = 'l484-pwa-v1'
+const APP_SHELL = [
+ '/',
+ '/manifest.webmanifest',
+ '/images/small-logo.svg',
+ '/images/header-logo.svg',
+ '/images/pattern.jpg',
+]
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)))
+ self.skipWaiting()
+})
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then((keys) =>
+ Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
+ ),
+ )
+ self.clients.claim()
+})
+
+self.addEventListener('fetch', (event) => {
+ const url = new URL(event.request.url)
+ if (url.pathname.startsWith('/api/')) return
+
+ event.respondWith(
+ caches.match(event.request).then((cached) =>
+ cached || fetch(event.request).then((response) => {
+ if (event.request.method === 'GET' && response.ok) {
+ const clone = response.clone()
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
+ }
+ return response
+ }),
+ ),
+ )
+})
diff --git a/server/dev.js b/server/dev.js
new file mode 100644
index 0000000..1861eff
--- /dev/null
+++ b/server/dev.js
@@ -0,0 +1,32 @@
+import { spawn } from 'node:child_process'
+
+const processes = [
+ spawn(process.execPath, ['server/server.js'], {
+ stdio: 'inherit',
+ env: { ...process.env, PORT: process.env.PORT || '3001' },
+ }),
+ spawn(process.execPath, ['node_modules/vite/bin/vite.js', '--host'], {
+ stdio: 'inherit',
+ env: process.env,
+ }),
+]
+
+let isShuttingDown = false
+
+const shutdown = (code = 0) => {
+ if (isShuttingDown) return
+ isShuttingDown = true
+ for (const child of processes) {
+ if (!child.killed) child.kill('SIGTERM')
+ }
+ process.exit(code)
+}
+
+for (const child of processes) {
+ child.on('exit', (code) => {
+ if (!isShuttingDown && code !== 0) shutdown(code || 1)
+ })
+}
+
+process.on('SIGINT', () => shutdown(0))
+process.on('SIGTERM', () => shutdown(0))
diff --git a/server/encryption.js b/server/encryption.js
new file mode 100644
index 0000000..a66f853
--- /dev/null
+++ b/server/encryption.js
@@ -0,0 +1,55 @@
+import crypto from 'node:crypto'
+
+const configuredKey = process.env.MEMBERSHIP_ENCRYPTION_KEY
+const fallbackKey = '0000000000000000000000000000000000000000000000000000000000000000'
+
+if (!configuredKey) {
+ console.warn('MEMBERSHIP_ENCRYPTION_KEY is not set. Using a development fallback key.')
+}
+
+const getKey = () => {
+ const key = Buffer.from(configuredKey || fallbackKey, 'hex')
+ if (key.length !== 32) {
+ throw new Error('MEMBERSHIP_ENCRYPTION_KEY must be 64 hex characters.')
+ }
+ return key
+}
+
+export const encryptField = (value = '') => {
+ if (!value) return ''
+ const iv = crypto.randomBytes(16)
+ const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv)
+ const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()])
+ const tag = cipher.getAuthTag()
+ return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`
+}
+
+export const decryptField = (value = '') => {
+ if (!value || !String(value).includes(':')) return value || ''
+ const parts = String(value).split(':')
+ if (parts.length !== 3 || parts[0].length !== 32 || parts[1].length !== 32) return value
+
+ const [ivHex, tagHex, encryptedHex] = parts
+ const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), Buffer.from(ivHex, 'hex'))
+ decipher.setAuthTag(Buffer.from(tagHex, 'hex'))
+ return Buffer.concat([
+ decipher.update(Buffer.from(encryptedHex, 'hex')),
+ decipher.final(),
+ ]).toString('utf8')
+}
+
+export const encryptMembership = (member) => ({
+ ...member,
+ fullName: encryptField(member.fullName),
+ email: encryptField(member.email || ''),
+ phone: encryptField(member.phone || ''),
+ signature: encryptField(member.signature || ''),
+})
+
+export const decryptMembership = (member) => ({
+ ...member,
+ fullName: decryptField(member.fullName),
+ email: decryptField(member.email || ''),
+ phone: decryptField(member.phone || ''),
+ signature: decryptField(member.signature || ''),
+})
diff --git a/server/server.js b/server/server.js
new file mode 100644
index 0000000..2c0e37c
--- /dev/null
+++ b/server/server.js
@@ -0,0 +1,217 @@
+import fs from 'node:fs/promises'
+import { existsSync, createReadStream } from 'node:fs'
+import http from 'node:http'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { decryptMembership, encryptMembership } from './encryption.js'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const rootDir = path.resolve(__dirname, '..')
+const dataDir = path.join(rootDir, 'server', 'data')
+const membershipsFile = path.join(dataDir, 'memberships.json')
+const distDir = path.join(rootDir, 'dist')
+const port = Number(process.env.PORT || 3001)
+const adminPubkeys = [
+ '7b849efa5604b58d50c419637b9873847dbf957081d526136c3a49b7357cd617',
+ '2be35e9237eaf98fe0a7d1ce3ceb666dccde9c8cc800ff52f138a62c91d90783',
+]
+
+let memberships = []
+
+const json = (res, status, body) => {
+ res.writeHead(status, {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ })
+ res.end(JSON.stringify(body))
+}
+
+const ensureStore = async () => {
+ await fs.mkdir(dataDir, { recursive: true, mode: 0o700 })
+ if (!existsSync(membershipsFile)) {
+ await fs.writeFile(membershipsFile, '[]', { mode: 0o600 })
+ }
+}
+
+const loadMemberships = async () => {
+ await ensureStore()
+ memberships = JSON.parse(await fs.readFile(membershipsFile, 'utf8'))
+}
+
+const saveMemberships = async () => {
+ await fs.writeFile(membershipsFile, JSON.stringify(memberships, null, 2), { mode: 0o600 })
+}
+
+const readBody = async (req) => {
+ const chunks = []
+ let size = 0
+ for await (const chunk of req) {
+ size += chunk.length
+ if (size > 8 * 1024 * 1024) throw new Error('Request body too large')
+ chunks.push(chunk)
+ }
+ return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}
+}
+
+const cleanText = (value, max = 160) =>
+ String(value || '')
+ .normalize('NFKC')
+ .replace(/[\u0000-\u001f\u007f<>`{}[\]\\]/g, '')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, max)
+
+const validateMember = (data) => {
+ const member = {
+ membershipId: cleanText(data.membershipId, 32).toUpperCase(),
+ userId: cleanText(data.userId, 80),
+ fullName: cleanText(data.fullName, 80),
+ email: cleanText(data.email, 160).toLowerCase(),
+ phone: cleanText(data.phone, 32),
+ signature: String(data.signature || ''),
+ signedDate: String(data.signedDate || ''),
+ createdAt: String(data.createdAt || new Date().toISOString()),
+ expiresAt: String(data.expiresAt || ''),
+ status: data.status === 'inactive' ? 'inactive' : 'active',
+ npub: cleanText(data.npub, 80),
+ nsecHash: cleanText(data.nsecHash, 64).toLowerCase(),
+ }
+ const errors = []
+ if (!/^L484-\d{4}-[A-Z0-9]{6}$/.test(member.membershipId)) errors.push('Invalid membership ID.')
+ if (member.fullName.length < 2) errors.push('Full name is required.')
+ if (member.email && !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(member.email)) errors.push('Invalid email.')
+ if (member.phone && !/^[0-9()+.\-\s]{7,32}$/.test(member.phone)) errors.push('Invalid phone.')
+ if (!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(member.signature)) errors.push('Signature is required.')
+ if (Number.isNaN(new Date(member.signedDate).getTime())) errors.push('Invalid signed date.')
+ if (Number.isNaN(new Date(member.createdAt).getTime())) errors.push('Invalid created date.')
+ if (member.expiresAt && Number.isNaN(new Date(member.expiresAt).getTime())) errors.push('Invalid expiry date.')
+ if (!/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(member.npub)) errors.push('Invalid npub.')
+ if (!/^[0-9a-f]{64}$/.test(member.nsecHash)) errors.push('Invalid nsec hash.')
+
+ if (!member.expiresAt) {
+ const expiresAt = new Date(member.createdAt)
+ expiresAt.setFullYear(expiresAt.getFullYear() + 1)
+ member.expiresAt = expiresAt.toISOString()
+ }
+
+ return { member, errors }
+}
+
+const requireAdmin = (req, res) => {
+ const auth = req.headers.authorization || ''
+ const pubkey = auth.startsWith('Bearer ') ? auth.slice(7).trim().toLowerCase() : ''
+ if (!adminPubkeys.includes(pubkey)) {
+ json(res, 403, { error: 'Admin access required.' })
+ return false
+ }
+ return true
+}
+
+const serveStatic = (req, res) => {
+ const requested = decodeURIComponent(new URL(req.url, `http://${req.headers.host}`).pathname)
+ const filePath = requested === '/'
+ ? path.join(distDir, 'index.html')
+ : path.join(distDir, requested)
+ const safePath = filePath.startsWith(distDir) && existsSync(filePath) ? filePath : path.join(distDir, 'index.html')
+ const ext = path.extname(safePath)
+ const types = {
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.css': 'text/css',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.avif': 'image/avif',
+ '.json': 'application/json',
+ }
+ res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' })
+ createReadStream(safePath).pipe(res)
+}
+
+const handleApi = async (req, res) => {
+ const url = new URL(req.url, `http://${req.headers.host}`)
+
+ if (req.method === 'GET' && url.pathname === '/api/health') {
+ return json(res, 200, { ok: true })
+ }
+
+ if (req.method === 'POST' && url.pathname === '/api/membership/create') {
+ const { member, errors } = validateMember(await readBody(req))
+ if (errors.length) return json(res, 400, { error: 'Validation failed.', errors })
+
+ const existingIndex = memberships.findIndex((item) =>
+ item.membershipId === member.membershipId ||
+ item.userId === member.userId ||
+ item.npub === member.npub ||
+ item.nsecHash === member.nsecHash
+ )
+ const encrypted = encryptMembership(member)
+ if (existingIndex >= 0) {
+ memberships[existingIndex] = encrypted
+ } else {
+ memberships.unshift(encrypted)
+ }
+ await saveMemberships()
+ return json(res, existingIndex >= 0 ? 200 : 201, { success: true, membership: member })
+ }
+
+ if (req.method === 'GET' && url.pathname === '/api/membership/check') {
+ const membershipId = cleanText(url.searchParams.get('membershipId'), 32).toUpperCase()
+ const userId = cleanText(url.searchParams.get('userId'), 80)
+ const npub = cleanText(url.searchParams.get('npub'), 80)
+ const found = memberships.find((item) =>
+ (membershipId && item.membershipId === membershipId) ||
+ (userId && item.userId === userId) ||
+ (npub && item.npub === npub)
+ )
+ return json(res, 200, found
+ ? { hasMembership: true, membership: decryptMembership(found) }
+ : { hasMembership: false })
+ }
+
+ if (req.method === 'POST' && url.pathname === '/api/membership/recover') {
+ const body = await readBody(req)
+ const nsecHash = cleanText(body.nsecHash, 64).toLowerCase()
+ const found = memberships.find((item) => item.nsecHash === nsecHash)
+ return json(res, found ? 200 : 404, found
+ ? { success: true, membership: decryptMembership(found) }
+ : { error: 'No membership found for that nsec.' })
+ }
+
+ if (req.method === 'GET' && url.pathname === '/api/memberships') {
+ if (!requireAdmin(req, res)) return
+ return json(res, 200, {
+ success: true,
+ memberships: memberships.map(decryptMembership),
+ })
+ }
+
+ if (req.method === 'DELETE' && url.pathname === '/api/membership') {
+ if (!requireAdmin(req, res)) return
+ const { membershipId } = await readBody(req)
+ const id = cleanText(membershipId, 32).toUpperCase()
+ const before = memberships.length
+ memberships = memberships.filter((item) => item.membershipId !== id)
+ await saveMemberships()
+ return json(res, 200, { success: true, deleted: before - memberships.length })
+ }
+
+ json(res, 404, { error: 'Not found.' })
+}
+
+await loadMemberships()
+
+http.createServer(async (req, res) => {
+ try {
+ if (req.url.startsWith('/api/')) {
+ await handleApi(req, res)
+ } else {
+ serveStatic(req, res)
+ }
+ } catch (error) {
+ console.error(error)
+ json(res, 500, { error: error.message || 'Server error.' })
+ }
+}).listen(port, '127.0.0.1', () => {
+ console.log(`L484 server listening on http://127.0.0.1:${port}`)
+})
diff --git a/src/App.vue b/src/App.vue
index 67fbb12..d7587bb 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,6 +1,14 @@