feat: nostr-tools + ephemeral inquiry submission helper

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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-27 11:22:47 +02:00
commit dd4c87b548
5 changed files with 181 additions and 0 deletions

11
env.d.ts vendored
View file

@ -1 +1,12 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
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
}

View file

@ -17,6 +17,7 @@
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"nostr-tools": "^2.23.5",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"reka-ui": "^2.9.8", "reka-ui": "^2.9.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",

70
pnpm-lock.yaml generated
View file

@ -23,6 +23,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
nostr-tools:
specifier: ^2.23.5
version: 2.23.5(typescript@6.0.3)
pinia: pinia:
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)) 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/core': ^1.7.1
'@emnapi/runtime': ^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': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -374,6 +389,15 @@ packages:
'@rolldown/pluginutils@1.0.1': '@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} 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': '@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
@ -1126,6 +1150,17 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} 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: nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@ -1667,6 +1702,14 @@ snapshots:
'@tybys/wasm-util': 0.10.2 '@tybys/wasm-util': 0.10.2
optional: true 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': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@ -1734,6 +1777,19 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {} '@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': '@swc/helpers@0.5.23':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -2477,6 +2533,20 @@ snapshots:
natural-compare@1.4.0: {} 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: nth-check@2.1.1:
dependencies: dependencies:
boolbase: 1.0.0 boolbase: 1.0.0

View file

@ -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<string[]>(parseRelays())
async function publish(event: Event): Promise<PromiseSettledResult<string>[]> {
return Promise.allSettled(pool.publish(relays.value, event))
}
return { relays, publish }
})

View file

@ -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<ContactMethod, string> = {
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<SubmitResult> {
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,
}
}