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:
parent
a79f4a32c7
commit
dd4c87b548
5 changed files with 181 additions and 0 deletions
11
env.d.ts
vendored
11
env.d.ts
vendored
|
|
@ -1 +1,12 @@
|
|||
/// <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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
70
pnpm-lock.yaml
generated
70
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
30
src/features/nostr/store.ts
Normal file
30
src/features/nostr/store.ts
Normal 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 }
|
||||
})
|
||||
69
src/features/nostr/submitInquiry.ts
Normal file
69
src/features/nostr/submitInquiry.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue