EVENT_VIEWER_URL.replace(/\/$/, '') was duplicated across:
- env.ts (BRAND_LOGO, 1x)
- server.ts (settingsUrl in confirm routes, 3x)
- mailer.ts (settingsUrl in sendConfirm/sendDigest, 2x)
Now trailing-slash normalization happens at the export source in env.ts,
so all consumers get a clean URL without ad-hoc stripping.
server.ts was building callback URLs from raw process.env.BASE_URL at
lines 175 and 217, while env.ts already validates and exports BASE_URL
as a typed constant. This adds BASE_URL to the import from ./env.js and
replaces both direct process.env references so the callback URL cannot
drift from the validated value.
Bug: when the event was not found at the relay, the handler returned
{ ok: true, skipped: true }, which did not match the documented contract
{ ok: true, stored: boolean } in README.md.
Fix: change the 'skipped' response to { ok: true, stored: false }, so the
response shape is consistent across all code paths.
- src/server.ts: changed line 287 from { ok: true, skipped: true } to
{ ok: true, stored: false }, with updated comment
- README.md: added a note documenting the skip case (stored: false)
- test/notify-response-shape.test.ts: new test that asserts stored=false
and that skipped is never present
Three browser-facing endpoints now require a kind-27235 HTTP auth event
(NIP-98) proving the caller controls the pubkey:
- GET /subscription/email — pubkey extracted from auth header instead
of query param; returns subscription for the authed pubkey.
- PUT /subscription/email — pubkey extracted from auth header instead
of trusting a client-supplied body field.
- DELETE /subscription/:key — verifies auth pubkey matches subscription
owner (returns 403 if mismatch).
Server-side: decode base64 'Nostr <b64>' Authorization header, JSON.parse,
check kind === 27235, verifyEvent (nostr-tools/pure), then check u /
method / payload tags against the request URL / method / body.
README updated to reflect 'implemented' auth (not 'planned').
Integration test updated to generate NIP-98 auth headers via a new helper
script (script/nip98-auth-header.mjs).
The server was setting Access-Control-Allow-Origin: * on every route,
including the unauthenticated GET /subscription/email which returns the
subscriber's email address. Any website could query known pubkeys and
harvest emails.
Changes:
- src/env.ts: require CORS_ORIGIN env var (fail closed, no wildcard)
- src/server.ts: scope CORS middleware to browser-facing routes only,
skip /notify (server-to-server), add Vary: Origin header, import
CORS_ORIGIN from env instead of defaulting to '*'
- .env.template: document new CORS_ORIGIN variable
- test/cors.test.sh: verify CORS on browser routes, no CORS on
server-to-server routes, Vary: Origin presence
Remove the browser admin SPA under web/ that let users log in via
a Nostr signer and manage subscription filters. The server is now
a headless API only.
Changes:
- Delete web/ directory entirely (SPA source, config, deps)
- Remove express.static('web/dist') serving from server.ts
- Simplify GET / handler to always return JSON (no fallback)
- Remove build:web from package.json build pipeline
- Remove web build steps from Dockerfile
- Remove web references from build-in-production.sh
Kept: core subscription/unsubscribe/confirm/notify backend and
transactional src/pages/*.html (part of email flow, not the UI).
Switch the subscribe endpoint from POST to an idempotent PUT so clients can
always upsert without a stateful lookup first.
- Add GET /subscription/email?pubkey= lookup so clients can avoid re-POSTing.
- insertSubscription no longer clears confirmed_at unless the email address
changes; a frequency change keeps the existing confirmation.
- registerSubscription only sends a confirmation email when the subscription
is new, unconfirmed, or the email address changed.
- Update integration test and README for the PUT endpoint.
- test/integration.sh: runs full E2E flow against fresh server
- pnpm test and pnpm test:server scripts added
- Root endpoint handles missing web UI gracefully (returns JSON)
- database.ts reads DATA_DIR env var for test isolation
- .gitignore updated for test artifacts