From dd4c87b548b84bf820f7812be16569357aab8517 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 27 May 2026 11:22:47 +0200 Subject: [PATCH] feat: nostr-tools + ephemeral inquiry submission helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contact form's submissions land in the client's Nostr inbox without ever touching a server we control. submitInquiry generates a throwaway secp256k1 keypair per submission, wraps the payload as a NIP-17 / NIP-59 gift-wrapped kind:1059 event (NIP-44 v2 encryption inside), publishes through SimplePool to the configured relays, and discards the ephemeral key. The visitor leaves no persistent identity. VITE_OWNER_NPUB (the recipient) and VITE_NOSTR_RELAYS (optional override of the default damus/nos.lol/relay.nostr.band set) are read at build time. env.d.ts grows typed declarations so the rest of the app catches typos at compile time. The store is the boilerplate feature README's prescribed shape — SimplePool + relays ref + Promise.allSettled-wrapped publish — extended only with env-driven relay parsing. submitInquiry returns {ok, acceptedBy, attempted} so the form can surface partial-relay failures honestly. Co-Authored-By: Claude Opus 4.7 (1M context) --- env.d.ts | 11 +++++ package.json | 1 + pnpm-lock.yaml | 70 +++++++++++++++++++++++++++++ src/features/nostr/store.ts | 30 +++++++++++++ src/features/nostr/submitInquiry.ts | 69 ++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+) create mode 100644 src/features/nostr/store.ts create mode 100644 src/features/nostr/submitInquiry.ts diff --git a/env.d.ts b/env.d.ts index 11f02fe..ad3afc1 100644 --- a/env.d.ts +++ b/env.d.ts @@ -1 +1,12 @@ /// + +interface ImportMetaEnv { + /** Hex- or bech32-encoded npub of the inquiry recipient. */ + readonly VITE_OWNER_NPUB?: string + /** Comma-separated wss:// relay list; falls back to a default set if unset. */ + readonly VITE_NOSTR_RELAYS?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/package.json b/package.json index 8c678b7..1486224 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@vueuse/core": "^14.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "nostr-tools": "^2.23.5", "pinia": "^3.0.4", "reka-ui": "^2.9.8", "tailwind-merge": "^3.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48b159c..f90a373 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + nostr-tools: + specifier: ^2.23.5 + version: 2.23.5(typescript@6.0.3) pinia: specifier: ^3.0.4 version: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)) @@ -257,6 +260,18 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -374,6 +389,15 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@scure/base@2.0.0': + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + + '@scure/bip32@2.0.1': + resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} + + '@scure/bip39@2.0.1': + resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -1126,6 +1150,17 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + nostr-tools@2.23.5: + resolution: {integrity: sha512-Fa7ZlUdjfUW1P4E7H3yBexhOHYi18XNyvd2n7eNHkYR085xADX6Y8V8Vm7nT/XQajaFOBrptXmVIGkJ2E4vfVw==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + nostr-wasm@0.1.0: + resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -1667,6 +1702,14 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@noble/ciphers@2.1.1': {} + + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1734,6 +1777,19 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@scure/base@2.0.0': {} + + '@scure/bip32@2.0.1': + dependencies: + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + + '@scure/bip39@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -2477,6 +2533,20 @@ snapshots: natural-compare@1.4.0: {} + nostr-tools@2.23.5(typescript@6.0.3): + 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 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 6.0.3 + + nostr-wasm@0.1.0: {} + nth-check@2.1.1: dependencies: boolbase: 1.0.0 diff --git a/src/features/nostr/store.ts b/src/features/nostr/store.ts new file mode 100644 index 0000000..6d8c763 --- /dev/null +++ b/src/features/nostr/store.ts @@ -0,0 +1,30 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { SimplePool, type Event } from 'nostr-tools' + +const DEFAULT_RELAYS = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.nostr.band', +] + +function parseRelays(): string[] { + const env = import.meta.env.VITE_NOSTR_RELAYS + if (!env) return DEFAULT_RELAYS + const list = env + .split(',') + .map((r: string) => r.trim()) + .filter((r: string) => r.startsWith('wss://') || r.startsWith('ws://')) + return list.length ? list : DEFAULT_RELAYS +} + +export const useNostrStore = defineStore('nostr', () => { + const pool = new SimplePool() + const relays = ref(parseRelays()) + + async function publish(event: Event): Promise[]> { + return Promise.allSettled(pool.publish(relays.value, event)) + } + + return { relays, publish } +}) diff --git a/src/features/nostr/submitInquiry.ts b/src/features/nostr/submitInquiry.ts new file mode 100644 index 0000000..24ee305 --- /dev/null +++ b/src/features/nostr/submitInquiry.ts @@ -0,0 +1,69 @@ +import { nip17, nip19 } from 'nostr-tools' +import { generateSecretKey } from 'nostr-tools/pure' +import { useNostrStore } from './store' + +export type ContactMethod = 'email' | 'whatsapp' | 'signal' | 'telegram' | 'nostr' + +export interface InquiryPayload { + name?: string + contactMethod: ContactMethod + contactValue: string + message: string +} + +export interface SubmitResult { + ok: boolean + acceptedBy: number + attempted: number +} + +const METHOD_LABEL: Record = { + email: 'Email', + whatsapp: 'WhatsApp', + signal: 'Signal', + telegram: 'Telegram', + nostr: 'Nostr', +} + +function ownerHex(): string { + const npub = import.meta.env.VITE_OWNER_NPUB + if (!npub) { + throw new Error( + 'VITE_OWNER_NPUB is not set — the inquiry form has no destination.', + ) + } + const decoded = nip19.decode(npub.trim() as `npub1${string}`) + if (decoded.type !== 'npub') { + throw new Error( + `VITE_OWNER_NPUB must be an npub (got ${(decoded as { type: string }).type}).`, + ) + } + return decoded.data +} + +function formatMessage(p: InquiryPayload): string { + const lines: string[] = [] + lines.push('New inquiry — Earth Walker Design') + lines.push('') + if (p.name) lines.push(`From: ${p.name}`) + lines.push(`Preferred contact: ${METHOD_LABEL[p.contactMethod]} — ${p.contactValue}`) + lines.push('') + lines.push(p.message) + return lines.join('\n') +} + +export async function submitInquiry(payload: InquiryPayload): Promise { + const recipient = ownerHex() + const senderSk = generateSecretKey() + const wrapped = nip17.wrapEvent(senderSk, { publicKey: recipient }, formatMessage(payload)) + + const nostr = useNostrStore() + const results = await nostr.publish(wrapped) + + const accepted = results.filter((r) => r.status === 'fulfilled').length + return { + ok: accepted > 0, + acceptedBy: accepted, + attempted: results.length, + } +}