diff --git a/.dockerignore b/.dockerignore index 49ad795..4b7fa98 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ node_modules/ dist/ -web/dist/ .git/ .gitattributes .gitignore diff --git a/.env.template b/.env.template index 9112de5..44a4557 100644 --- a/.env.template +++ b/.env.template @@ -12,7 +12,14 @@ BRAND_LOGO= INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band DEFAULT_RELAYS=relay.damus.io,nos.lol SEARCH_RELAYS=relay.nostr.band -POSTMARK_API_KEY= -POSTMARK_SENDER_ADDRESS= +SMTP_HOST= +SMTP_PORT= +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= +# CORS_ORIGIN must be set to the exact origin your browser client runs on +# (e.g. https://app.example.com). There is no wildcard fallback — the server +# will refuse to start without it. +CORS_ORIGIN= PORT=4738 DATA_DIR=./data diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..35fd51b --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,31 @@ +# Forgejo Actions CI — runs the repo's check gate on every push/PR +# Single source of truth: ./script/checks defines what "passing CI" means. +--- +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Install dependencies + run: | + corepack enable + pnpm i --frozen-lockfile + + - name: Run check gate + run: ./script/checks \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index dc3287a..f5feb6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,12 +25,7 @@ COPY src/ ./src/ COPY src/pages/ ./src/pages/ COPY src/emails/ ./src/emails/ -# Build web UI -COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml web/tsconfig.json web/vite.config.js web/index.html ./web/ -COPY web/src/ ./web/src/ -RUN cd web && pnpm install --frozen-lockfile && pnpm run build - -# Build TypeScript (runs tsc && build:html && build:web) +# Build TypeScript (runs tsc && build:html) RUN pnpm run build # Production image @@ -51,7 +46,6 @@ RUN pnpm install --frozen-lockfile --prod # Copy build artifacts COPY --from=build /app/dist/ ./dist/ -COPY --from=build /app/web/dist/ ./web/dist/ COPY --from=build /app/src/pages/ ./dist/pages/ COPY --from=build /app/src/emails/ ./dist/emails/ diff --git a/README.md b/README.md index 1ffaaa0..5c5c7d1 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Flotilla ──HTTP──▶ Mailship (PUT /subscription/email) UUID in path ├── fetch event from relay is the auth ├── store in SQLite (dedup) │ - cron fires ──▶ render digest ──▶ Postmark ──▶ email + cron fires ──▶ render digest ──▶ SMTP ──▶ email ``` ## Configuration @@ -32,42 +32,49 @@ Flotilla ──HTTP──▶ Mailship (PUT /subscription/email) | `MAILSHIP_NAME` | ✓ | Name of this Mailship instance | | `MAILSHIP_URL` | ✓ | Public URL of this instance | | `BASE_URL` | ✓ | Base URL for callback URLs (same as MAILSHIP_URL typically) | +| `SMTP_HOST` | ✓ | SMTP server hostname | +| `SMTP_PORT` | ✓ | SMTP server port | +| `SMTP_USER` | ✓ | SMTP username | +| `SMTP_PASSWORD` | ✓ | SMTP password | +| `SMTP_FROM` | ✓ | From email address for outgoing mail | | `EVENT_VIEWER_URL` | | Base URL of the app event links open in (defaults to Flotilla at `https://app.flotilla.social`, or anything handling the same `/spaces//` and `/` URL shapes) | | `BRAND_NAME` | | Name used in email branding (default: `Flotilla`) | | `BRAND_ACCENT` | | Accent color string used in email branding (default: `#7161FF`) | | `BRAND_LOGO` | | URL of the logo image shown in the email header. Defaults to `/logo.png`; if empty/unset, a colored brand name is shown instead | -| `POSTMARK_API_KEY` | ✓ | Postmark API key for sending emails | -| `POSTMARK_SENDER_ADDRESS` | ✓ | Verified sender email in Postmark | | `DEFAULT_RELAYS` | ✓ | Comma-separated list of default relays | | `INDEXER_RELAYS` | ✓ | Comma-separated list of indexer relays | | `SEARCH_RELAYS` | ✓ | Comma-separated list of search relays | -| `PORT` | | Port to run on (default: 3000) | +| `PORT` | | Port to run on (default: 4738) | ## API ### PUT /subscription/email Idempotently register or update an email subscription. Re-sends the confirmation email only when the subscription is new or the email address changed; a frequency -change keeps the existing confirmation. +change keeps the existing confirmation. The pubkey is extracted from the NIP-98 +Authorization header — the body does not include a `pubkey` field. ``` -Body: { email, frequency, pubkey } -Auth: NIP-98 (planned) +Body: { email, frequency } +Auth: NIP-98 (Nostr Authorization header) Response: { key, callback } ``` -### GET /subscription/email?pubkey=... -Look up an existing subscription. Returns 404 if none exists. +### GET /subscription/email +Look up an existing subscription for the authenticated pubkey, so clients can +avoid re-registering (and re-confirming) when settings haven't changed. +Returns 404 if none exists. ``` +Auth: NIP-98 (Nostr Authorization header) Response: { key, callback, email, frequency, confirmed } ``` ### DELETE /subscription/:key -Unsubscribe. +Unsubscribe. Verifies the NIP-98 auth pubkey matches the subscription owner. ``` -Auth: NIP-98 (planned) +Auth: NIP-98 (Nostr Authorization header) Response: { ok: true } ``` @@ -75,11 +82,28 @@ Response: { ok: true } NIP-9a relay push callback. Called by relays or NPB when matching events are found. ``` -Body: { id, relay } +Body: { id, relay, event? } Response: { ok: true, stored: boolean } Returns 404 if subscription not found or inactive. ``` +The optional `event` field supports NIP-98 `include_event` — relays can embed +the full event inline to bypass fetching. When `event` is provided: + +- `event.id` must match the `id` string, **and** the event signature must be + cryptographically valid (`verifyEvent` from nostr-tools). +- If either check fails, the endpoint returns **400** `{ error: 'Invalid event' }`. + +When `event` is omitted, the server fetches the event from the relay using +`id` and `relay`. If the relay has no matching event (deleted, expired, or +never published), the endpoint returns `{ ok: true, stored: false }` — the +event is silently skipped rather than erroring. + +After obtaining the event (from body or relay), it is stored in the local +database. If the event is already known (deduplication), `stored` is `false`; +otherwise `stored` is `true`. The `stored` field is always present in a 200 +response. + ### GET /confirm?token=... Confirm email address via link from confirmation email. @@ -128,6 +152,7 @@ docker run -d \ ## Tests ```sh -pnpm test # Run integration tests -pnpm test:server # Start server for manual testing -``` \ No newline at end of file +pnpm test:unit # Run unit tests (vitest) +pnpm test # Run integration tests (bash E2E) +pnpm test:server # Start server for manual testing +``` diff --git a/build-in-production.sh b/build-in-production.sh old mode 100755 new mode 100644 index 37bf10d..639fba3 --- a/build-in-production.sh +++ b/build-in-production.sh @@ -2,11 +2,9 @@ # Remove link overrides node remove-pnpm-overrides.js package.json -node remove-pnpm-overrides.js web/package.json # When CI=true as it is on render.com, removing link overrides breaks the lockfile pnpm i --no-frozen-lockfile -(cd web && pnpm i --no-frozen-lockfile) # Build everything pnpm run build diff --git a/docker-compose.yml b/docker-compose.yml index 1d172dd..10eac1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,11 @@ services: - MAILSHIP_NAME=${MAILSHIP_NAME:-Mailship} - MAILSHIP_URL=${MAILSHIP_URL:?required} - BASE_URL=${BASE_URL:?required} - - POSTMARK_API_KEY=${POSTMARK_API_KEY:?required} - - POSTMARK_SENDER_ADDRESS=${POSTMARK_SENDER_ADDRESS:?required} + - SMTP_HOST=${SMTP_HOST:?required} + - SMTP_PORT=${SMTP_PORT:?required} + - SMTP_USER=${SMTP_USER:?required} + - SMTP_PASSWORD=${SMTP_PASSWORD:?required} + - SMTP_FROM=${SMTP_FROM:?required} - DEFAULT_RELAYS=${DEFAULT_RELAYS:-wss://relay.damus.io,wss://relay.primal.net} - INDEXER_RELAYS=${INDEXER_RELAYS:-wss://purplepag.es,wss://relay.damus.io} - SEARCH_RELAYS=${SEARCH_RELAYS:-wss://relay.nostr.band} diff --git a/package.json b/package.json index b2b588f..306a4b7 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,7 @@ "type": "module", "version": "1.0.0", "scripts": { - "build": "tsc && pnpm run build:html && pnpm run build:web", - "build:web": "cd web && pnpm run build", + "build": "tsc && pnpm run build:html", "build:html": "cp -r src/pages dist/ && cp -r src/emails dist/", "check": "tsc --noEmit && eslint src", "format": "prettier --write \"src/**/*.{ts,js,json,html}\"", @@ -12,6 +11,7 @@ "preview:digest": "node script/render-preview.mjs", "run-alert": "node dist/run.js", "test": "bash test/integration.sh", + "test:unit": "vitest run", "test:server": "bash test/integration.sh --server-only" }, "devDependencies": { @@ -30,7 +30,8 @@ "globals": "^15.15.0", "onchange": "^7.1.0", "prettier": "^3.6.2", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "vitest": "^5.0.0" }, "dependencies": { "@types/node": "^22.18.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15e0f81..f444b05 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/script/checks b/script/checks index 310e1d1..287929d 100755 --- a/script/checks +++ b/script/checks @@ -1,6 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# mailship CI checks — type-check + lint + build +# mailship CI checks — type-check + lint + build + unit tests pnpm run check -pnpm run build \ No newline at end of file +pnpm run build +pnpm test:unit \ No newline at end of file diff --git a/script/nip98-auth-header.mjs b/script/nip98-auth-header.mjs new file mode 100644 index 0000000..75c0603 --- /dev/null +++ b/script/nip98-auth-header.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// Generates a NIP-98 Authorization header value ("Nostr ") for +// testing purposes. +// +// Usage: +// node script/nip98-auth-header.mjs [body] +// +// Example: +// export AUTH=$(node script/nip98-auth-header.mjs \ +// "$SECRET" "$BASE_URL/subscription/email" PUT '{"email":"a@b.com","frequency":"daily"}') +// curl -H "Authorization: $AUTH" ... + +import { makeHttpAuth, makeHttpAuthHeader } from '@welshman/util' +import { Nip01Signer } from '@welshman/signer' + +const [, , secret, url, method, body] = process.argv + +if (!secret || !url) { + console.error('Usage: node script/nip98-auth-header.mjs [body]') + process.exit(1) +} + +const signer = Nip01Signer.fromSecret(secret) + +// Create the unsigned auth event template +const event = await makeHttpAuth(url, method || 'GET', body || undefined) + +// Stamp (created_at, pubkey, id) and sign +const signed = await signer.sign(event) + +// Encode as "Nostr " +const header = makeHttpAuthHeader(signed) + +console.log(header) \ No newline at end of file diff --git a/script/render-preview.mjs b/script/render-preview.mjs index c74d6ea..e2fec6d 100644 --- a/script/render-preview.mjs +++ b/script/render-preview.mjs @@ -16,8 +16,8 @@ const sample = { Duration: '24 hours', Total: 12, TopProfiles: 'bob, carol', - HasPopular: true, - Popular: [ + HasLatest: true, + Latest: [ { Link: 'https://app.flotilla.social/nevent1qqs...', Timestamp: 'Aug 25, 2026 at 9:00 AM', diff --git a/src/actions.ts b/src/actions.ts index ceec6fe..975accb 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,6 +1,4 @@ import { instrument } from 'succinct-async' -import type { Subscription } from './alert.js' -import { getCronExpression } from './alert.js' import * as mailer from './mailer.js' import * as worker from './worker/index.js' import * as db from './database.js' @@ -23,11 +21,13 @@ export const registerSubscription = instrument( const sub = await db.insertSubscription(pubkey, email, frequency) const callback = `${process.env.BASE_URL}/notify/${sub.id}` - // Only send a confirmation when the subscription is new, unconfirmed, or - // its email address changed. An already-confirmed, unchanged subscription - // (or one where only the frequency changed) skips it. if (!sub.confirmed_at) { + // New or email-changed subscription — send a confirmation email. await mailer.sendConfirm(sub) + } else { + // Already confirmed (e.g. frequency-only change) — reschedule the + // cron job so it uses the new cadence immediately. + worker.registerSubscription(sub) } return { key: sub.key, callback } diff --git a/src/alert.ts b/src/alert.ts index 01df785..c61586f 100644 --- a/src/alert.ts +++ b/src/alert.ts @@ -1,6 +1,3 @@ -import { CronExpressionParser } from 'cron-parser' -import { tryCatch, int, HOUR } from '@welshman/lib' - export type Subscription = { id: string key: string diff --git a/src/database.ts b/src/database.ts index 336e293..718e225 100644 --- a/src/database.ts +++ b/src/database.ts @@ -319,6 +319,18 @@ export const deleteEventsForSubscription = instrument( } ) +export const deleteEventsByIds = instrument( + 'database.deleteEventsByIds', + async (subscriptionId: string, eventIds: string[]) => { + if (eventIds.length === 0) return + const placeholders = eventIds.map(() => '?').join(',') + await run( + `DELETE FROM events WHERE subscription_id = ? AND id IN (${placeholders})`, + [subscriptionId, ...eventIds] + ) + } +) + export const purgeEventsOlderThan = instrument( 'database.purgeEventsOlderThan', async (timestamp: number) => { diff --git a/src/digest.ts b/src/digest.ts index 334a2c8..ef9850a 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -1,13 +1,9 @@ import { neventEncode, decode } from 'nostr-tools/nip19' import { spec, - now, sortBy, groupBy, displayList, - nth, - nthEq, - dateToSeconds, secondsToDate, } from '@welshman/lib' import { parse, truncate, renderAsHtml } from '@welshman/content' @@ -15,8 +11,6 @@ import { TrustedEvent, normalizeRelayUrl, getParentId, - getIdFilters, - getReplyFilters, NOTE, COMMENT, REACTION, @@ -24,15 +18,12 @@ import { displayPubkey, getTagValue, } from '@welshman/util' -import { Loader, AdapterContext, makeLoader, SocketAdapter } from '@welshman/net' -import { call } from '@welshman/lib' import { displayDuration, createElement } from './util.js' import type { Subscription } from './alert.js' import { sendDigest } from './mailer.js' import { EVENT_VIEWER_URL } from './env.js' import { profilesByPubkey, - loadRelaySelections, loadProfile, } from './repository.js' @@ -86,8 +77,8 @@ export class Digest { return { Total: events.length, Duration: displayDuration(Math.floor(Date.now() / 1000) - this.since), - Popular: sorted.map((e) => getEventVariables(e)), - HasPopular: sorted.length > 0, + Latest: sorted.map((e) => getEventVariables(e)), + HasLatest: sorted.length > 0, UserName: displayProfile(userProfile, this.sub.email.split('@')[0]), TopProfiles: displayList(topProfiles.map(([pk]) => displayProfileByPubkey(pk))), } diff --git a/src/emails/digest.mjml b/src/emails/digest.mjml index 5d6525d..58f5e2d 100644 --- a/src/emails/digest.mjml +++ b/src/emails/digest.mjml @@ -5,7 +5,7 @@ .header { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 24px; font-weight: 700; } .subheader { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 15px; color: #64748b; line-height: 1.5; } - .event-item { margin-bottom: 20px; border-left: 3px solid #7161FF; padding-left: 12px; } + .event-item { margin-bottom: 20px; border-left: 3px solid {{brandAccent}}; padding-left: 12px; } .event-meta { margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; } .event-meta-left { display: flex; align-items: center; } .event-author { font-family: Inter, Helvetica, Arial, sans-serif; font-weight: 600; margin-right: 4px; color: #1e293b; } @@ -19,7 +19,7 @@ .event-stats { margin-top: 8px; color: #64748b; font-size: 13px; } .stat-item { display: inline-flex; align-items: center; margin-right: 12px; } .footer { font-family: Inter, Helvetica, Arial, sans-serif; color: #94a3b8; font-size: 12px; line-height: 1.5; } - .footer a { color: #7161FF; text-decoration: underline; } + .footer a { color: {{brandAccent}}; text-decoration: underline; } .logo { max-width: 48px; max-height: 48px; } a { text-decoration: none; } @@ -44,11 +44,11 @@ - {{#HasPopular}} + {{#HasLatest}} Latest Activity - {{#Popular}} + {{#Latest}}
@@ -72,10 +72,10 @@
- {{/Popular}} + {{/Latest}}
- {{/HasPopular}} + {{/HasLatest}} diff --git a/src/env.ts b/src/env.ts index 3588110..da16c85 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,7 +1,6 @@ import 'dotenv/config' import { always } from '@welshman/lib' import { normalizeRelayUrl } from '@welshman/util' -import { netContext } from '@welshman/net' import { Nip01Signer } from '@welshman/signer' import { routerContext } from '@welshman/router' @@ -16,22 +15,24 @@ if (!process.env.SMTP_FROM) throw new Error('SMTP_FROM is not defined.') if (!process.env.DEFAULT_RELAYS) throw new Error('DEFAULT_RELAYS is not defined.') if (!process.env.INDEXER_RELAYS) throw new Error('INDEXER_RELAYS is not defined.') if (!process.env.SEARCH_RELAYS) throw new Error('SEARCH_RELAYS is not defined.') -if (!process.env.PORT) throw new Error('PORT is not defined.') +if (!process.env.PORT) console.log('PORT not set, defaulting to 4738') +if (!process.env.CORS_ORIGIN) throw new Error('CORS_ORIGIN is not defined.') if (!process.env.BASE_URL) throw new Error('BASE_URL is not defined.') export const MAILSHIP_URL = process.env.MAILSHIP_URL export const MAILSHIP_NAME = process.env.MAILSHIP_NAME export const BASE_URL = process.env.BASE_URL -export const EVENT_VIEWER_URL = process.env.EVENT_VIEWER_URL || 'https://app.flotilla.social' +export const EVENT_VIEWER_URL = (process.env.EVENT_VIEWER_URL || 'https://app.flotilla.social').replace(/\/$/, '') export const BRAND_ACCENT = process.env.BRAND_ACCENT || '#7161FF' export const BRAND_NAME = process.env.BRAND_NAME || 'Flotilla' export const BRAND_LOGO = - process.env.BRAND_LOGO || `${EVENT_VIEWER_URL.replace(/\/$/, '')}/logo.png` + process.env.BRAND_LOGO || `${EVENT_VIEWER_URL}/logo.png` export const appSigner = Nip01Signer.fromSecret(process.env.MAILSHIP_SECRET) export const DEFAULT_RELAYS = process.env.DEFAULT_RELAYS.split(',').map(normalizeRelayUrl) export const INDEXER_RELAYS = process.env.INDEXER_RELAYS.split(',').map(normalizeRelayUrl) export const SEARCH_RELAYS = process.env.SEARCH_RELAYS.split(',').map(normalizeRelayUrl) -export const PORT = process.env.PORT +export const CORS_ORIGIN = process.env.CORS_ORIGIN +export const PORT = process.env.PORT || '4738' export const SMTP_HOST = process.env.SMTP_HOST export const SMTP_PORT = process.env.SMTP_PORT export const SMTP_USER = process.env.SMTP_USER diff --git a/src/mailer.ts b/src/mailer.ts index a67b24d..043f2e9 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -29,7 +29,7 @@ const transporter = nodemailer.createTransport({ export const sendConfirm = (sub: Subscription) => { const href = `${BASE_URL}/confirm?token=${sub.key}` - const settingsUrl = `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts` + const settingsUrl = `${EVENT_VIEWER_URL}/settings/alerts` return transporter .sendMail({ @@ -86,7 +86,7 @@ export const sendDigest = async (sub: Subscription, variables: Record { diff --git a/src/server.ts b/src/server.ts index eb3fe92..9e7aea1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,40 +1,116 @@ import { instrument } from 'succinct-async' import express, { Request, Response, NextFunction } from 'express' import rateLimit from 'express-rate-limit' -import { appSigner, BRAND_NAME, BRAND_ACCENT, BRAND_LOGO, EVENT_VIEWER_URL } from './env.js' +import { appSigner, BASE_URL, BRAND_NAME, BRAND_ACCENT, BRAND_LOGO, EVENT_VIEWER_URL, CORS_ORIGIN } from './env.js' import { render } from './templates.js' import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js' import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js' import { load } from '@welshman/net' import { getIdFilters } from '@welshman/util' +import crypto from 'crypto' import { verifyEvent } from 'nostr-tools/pure' -// Endpoints +// ── NIP-98 HTTP Auth ──────────────────────────────────────────────────── + +// Verify a NIP-98 Authorization header and return the authenticated pubkey, +// or null if the header is missing, malformed, or invalid. +// +// The client constructs the auth event via @welshman/util: +// makeHttpAuth(url, method, body) → event +// makeHttpAuthHeader(event) → "Nostr " +// +// We decode, verify kind=27235, verifyEvent, then check u / method / payload +// tags against the actual request URL / method / body. +const verifyNip98Auth = async (req: Request): Promise => { + const authHeader = req.headers.authorization + if (!authHeader) return null + + // Format: "Nostr " + const match = authHeader.match(/^Nostr\s+(.+)$/) + if (!match) return null + + // Decode base64 + let eventJson: string + try { + eventJson = Buffer.from(match[1], 'base64').toString('utf-8') + } catch { + return null + } + + // Parse event + let event: any + try { + event = JSON.parse(eventJson) + } catch { + return null + } + + // Must be kind 27235 (HTTP Auth) + if (event.kind !== 27235) return null + + // Verify event signature and id hash + if (!verifyEvent(event)) return null + + const tags = event.tags || [] + + // Find required tags + const uTag = tags.find((t: string[]) => t[0] === 'u') + const methodTag = tags.find((t: string[]) => t[0] === 'method') + const payloadTag = tags.find((t: string[]) => t[0] === 'payload') + + // Build the full URL the server received + const expectedUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}` + + // u tag must match the request URL exactly + if (!uTag || uTag[1] !== expectedUrl) return null + + // method tag must match the HTTP method (upper case) + if (!methodTag || methodTag[1] !== req.method.toUpperCase()) return null + + // For requests with a body, check payload tag is the SHA256 of the body + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method.toUpperCase())) { + if (req.body && Object.keys(req.body).length > 0) { + const bodyStr = JSON.stringify(req.body) + const expectedPayload = crypto.createHash('sha256').update(bodyStr).digest('hex') + if (!payloadTag || payloadTag[1] !== expectedPayload) return null + } + } + + return event.pubkey as string +} + +// ── Endpoints ────────────────────────────────────────────────────────── export const server: express.Application = express() +// CORS middleware for browser-facing routes only. // The browser hits /subscription with an Authorization header and Content-Type: // application/json, which triggers a CORS preflight. Answer it and allow the // configured origin so the client can register. -const corsOrigin = process.env.CORS_ORIGIN ?? '*' +// Server-to-server routes (/notify) intentionally do NOT get CORS headers. +const corsMiddleware = (req: Request, res: Response, next: NextFunction) => { + // Skip server-to-server routes + if (req.path.startsWith('/notify')) { + return next() + } -server.use((req: Request, res: Response, next: NextFunction) => { - res.setHeader('Access-Control-Allow-Origin', corsOrigin) + res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN) res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization') res.setHeader('Access-Control-Max-Age', '86400') + res.setHeader('Vary', 'Origin') if (req.method === 'OPTIONS') { return res.sendStatus(204) } next() -}) +} + +server.use('/', corsMiddleware) server.use(express.json()) -server.use(express.static('web/dist')) - // Rate limit for registration endpoints server.use( '/subscription', @@ -71,16 +147,6 @@ const addRoute = (method: 'get' | 'post' | 'put' | 'delete', path: string, handl } addRoute('get', '/', async (req: Request, res: Response) => { - try { - const {existsSync} = await import('fs') - const webIndex = new URL('../web/dist/index.html', import.meta.url) - if (existsSync(webIndex)) { - return res.send(await render('../web/dist/index.html')) - } - } catch { - // Fall through to JSON - } - res.json({ name: 'Mailship', description: 'Email notification server for Nostr', @@ -89,13 +155,15 @@ addRoute('get', '/', async (req: Request, res: Response) => { }) }) -// Look up an existing email subscription for a pubkey, so clients can avoid -// re-registering (and re-confirming) when settings haven't changed. +// Look up an existing email subscription for the authenticated pubkey, so +// clients can avoid re-registering (and re-confirming) when settings +// haven't changed. Requires NIP-98 HTTP auth proving the caller controls +// the pubkey. addRoute('get', '/subscription/email', async (req: Request, res: Response) => { - const { pubkey } = req.query + const pubkey = await verifyNip98Auth(req) - if (!pubkey || typeof pubkey !== 'string') { - return res.status(400).json({ error: 'pubkey is required' }) + if (!pubkey) { + return res.status(401).json({ error: 'NIP-98 authorization required' }) } const sub = await getSubscriptionByPubkey(pubkey) @@ -104,7 +172,7 @@ addRoute('get', '/subscription/email', async (req: Request, res: Response) => { return res.status(404).json({ error: 'Subscription not found' }) } - const callback = `${process.env.BASE_URL}/notify/${sub.id}` + const callback = `${BASE_URL}/notify/${sub.id}` res.json({ key: sub.key, @@ -115,9 +183,17 @@ addRoute('get', '/subscription/email', async (req: Request, res: Response) => { }) }) -// Subscribe to email digests (idempotent PUT upsert) +// Subscribe to email digests (idempotent PUT upsert). Requires NIP-98 HTTP +// auth proving the caller controls the pubkey — the pubkey is extracted from +// the auth event, not from the request body. addRoute('put', '/subscription/email', async (req: Request, res: Response) => { - const { email, frequency, pubkey } = req.body + const { email, frequency } = req.body + + const pubkey = await verifyNip98Auth(req) + + if (!pubkey) { + return res.status(401).json({ error: 'NIP-98 authorization required' }) + } if (!email || !email.includes('@')) { return res.status(400).json({ error: 'A valid email address is required' }) @@ -127,15 +203,6 @@ addRoute('put', '/subscription/email', async (req: Request, res: Response) => { return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' }) } - if (!pubkey) { - return res.status(400).json({ error: 'pubkey is required' }) - } - - // TODO: Verify NIP-98 auth header - // const auth = req.headers.authorization - // if (!auth) return res.status(401).json({ error: 'NIP-98 authorization required' }) - // Verify using @welshman/util makeHttpAuth - try { const result = await registerSubscription({ pubkey, email, frequency }) res.json(result) @@ -147,7 +214,7 @@ addRoute('put', '/subscription/email', async (req: Request, res: Response) => { // Look up the actual subscription key from the DB const sub = await getSubscriptionByPubkey(pubkey) if (sub) { - const callback = `${process.env.BASE_URL}/notify/${sub.id}` + const callback = `${BASE_URL}/notify/${sub.id}` res.json({ key: sub.key, callback }) } else { console.error('Failed to register subscription:', error) @@ -156,17 +223,26 @@ addRoute('put', '/subscription/email', async (req: Request, res: Response) => { } }) -// Delete subscription +// Delete subscription. Requires NIP-98 HTTP auth proving the caller controls +// the pubkey that owns this subscription. addRoute('delete', '/subscription/:key', async (req: Request, res: Response) => { const { key } = req.params + const pubkey = await verifyNip98Auth(req) + + if (!pubkey) { + return res.status(401).json({ error: 'NIP-98 authorization required' }) + } + const sub = await getSubscriptionByKey(key) if (!sub) { return res.status(404).json({ error: 'Subscription not found' }) } - // TODO: Verify NIP-98 auth header matches sub.pubkey + if (sub.pubkey !== pubkey) { + return res.status(403).json({ error: 'Forbidden: you do not own this subscription' }) + } await unsubscribeAction({ token: key }) res.json({ ok: true }) @@ -209,8 +285,8 @@ addRoute('post', '/notify/:id', async (req: Request, res: Response) => { storedEvent = fetched if (!storedEvent) { - // Event not found at relay — don't 404, just skip - return res.json({ ok: true, skipped: true }) + // Event not found at relay — don't 404, reflect that nothing was stored + return res.json({ ok: true, stored: false }) } } @@ -223,16 +299,22 @@ addRoute('post', '/notify/:id', async (req: Request, res: Response) => { } }) +// ── Branding helper ────────────────────────────────────────────────────── + +const brandingVars = () => ({ + brandName: BRAND_NAME, + brandAccent: BRAND_ACCENT, + brandLogo: BRAND_LOGO, + settingsUrl: `${EVENT_VIEWER_URL}/settings/alerts`, +}) + // Confirmation addRoute('get', '/confirm', async (req: Request, res: Response) => { if (typeof req.query.token !== 'string') { return res.send( await render('pages/confirm-error.html', { message: 'No confirmation token was provided. Please check the link in your email and try again.', - brandName: BRAND_NAME, - brandAccent: BRAND_ACCENT, - brandLogo: BRAND_LOGO, - settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`, + ...brandingVars(), }) ) } @@ -241,10 +323,7 @@ addRoute('get', '/confirm', async (req: Request, res: Response) => { await confirmSubscriptionAction({ token: req.query.token }) res.send(await render('pages/confirm-success.html', { - brandName: BRAND_NAME, - brandAccent: BRAND_ACCENT, - brandLogo: BRAND_LOGO, - settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`, + ...brandingVars(), })) } catch (error) { const isActionError = error instanceof ActionError @@ -252,10 +331,7 @@ addRoute('get', '/confirm', async (req: Request, res: Response) => { res.send(await render('pages/confirm-error.html', { message, - brandName: BRAND_NAME, - brandAccent: BRAND_ACCENT, - brandLogo: BRAND_LOGO, - settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`, + ...brandingVars(), })) if (!isActionError) { diff --git a/src/worker/email.ts b/src/worker/email.ts index d60e4a3..06a546e 100644 --- a/src/worker/email.ts +++ b/src/worker/email.ts @@ -6,6 +6,12 @@ import * as db from '../database.js' const jobsById = new Map() +// Test-only accessor to inspect stored jobs +export const getJobCronSource = (id: string): string | undefined => { + const source = jobsById.get(id)?.cronTime.source + return typeof source === 'string' ? source : undefined +} + export const runJob = async (sub: Subscription) => { try { if (!sub.confirmed_at || sub.unsubscribed_at) { @@ -27,8 +33,11 @@ export const runJob = async (sub: Subscription) => { const digest = new Digest(sub) await digest.sendFromStoredEvents(events) - // Clean up processed events - await db.deleteEventsForSubscription(sub.id, since) + // Collect the exact IDs that were fetched + sent, then delete ONLY those. + // Deleting by timestamp (received_at > since) would also remove any event + // that arrived between the fetch and the delete — the race condition. + const sentIds = events.map(e => e.id) + await db.deleteEventsByIds(sub.id, sentIds) await db.updateLastDigestAt(sub.id, Math.floor(Date.now() / 1000)) console.log('worker: job completed', sub.id, 'in', Date.now() - start, 'ms') @@ -43,7 +52,12 @@ const createJob = (sub: Subscription) => { const cron = getCronExpression(sub.frequency) const run = async () => { - await runJob(sub) + // Re-fetch the subscription to pick up the latest last_digest_at. + // The closure-captured `sub` is stale — its last_digest_at never + // advances, so every tick would re-fetch and re-send old events. + const fresh = await db.getSubscriptionById(sub.id) + if (!fresh) return + await runJob(fresh) } return CronJob.from({ @@ -65,7 +79,7 @@ export const removeJob = (sub: Subscription) => { } // Daily purge of events older than 7 days -const purgeJob = CronJob.from({ +CronJob.from({ cronTime: '0 0 3 * * *', // 3am UTC daily onTick: async () => { const weekAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 3600 diff --git a/test/cors.test.sh b/test/cors.test.sh new file mode 100644 index 0000000..3684e55 --- /dev/null +++ b/test/cors.test.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Passing test: CORS is scoped to browser routes only, no wildcard default. +# +# The fix: src/env.ts now requires CORS_ORIGIN (fail closed, no wildcard). +# src/server.ts applies CORS middleware only to browser-facing routes +# (/, /subscription/*, /confirm, /unsubscribe) and adds Vary: Origin. +# Server-to-server routes (/notify) get no CORS headers. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PORT="${PORT:-4742}" +BASE_URL="http://localhost:$PORT" +PASS=0 +FAIL=0 + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +cleanup() { + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + rm -rf "$PROJECT_DIR/test-data-cors" +} +trap cleanup EXIT + +# Build if needed +cd "$PROJECT_DIR" +if [ ! -d "dist" ]; then + pnpm exec tsc +fi + +SECRET="$(openssl rand -hex 32)" + +# Set CORS_ORIGIN to a specific allowed origin (fail closed — must be set) +export CORS_ORIGIN="https://app.example.com" +export MAILSHIP_SECRET="$SECRET" +export MAILSHIP_NAME="Mailship Test" +export MAILSHIP_URL="$BASE_URL" +export BASE_URL="$BASE_URL" +export POSTMARK_API_KEY="test" +export POSTMARK_SENDER_ADDRESS="test@test.com" +export DEFAULT_RELAYS="wss://relay.damus.io" +export INDEXER_RELAYS="wss://purplepag.es" +export SEARCH_RELAYS="wss://relay.nostr.band" +export PORT="$PORT" +export DATA_DIR="$PROJECT_DIR/test-data-cors" +# SMTP env vars (required by src/env.ts) +export SMTP_HOST="localhost" +export SMTP_PORT="1025" +export SMTP_USER="test" +export SMTP_PASSWORD="test" +export SMTP_FROM="test@test.com" + +mkdir -p "$DATA_DIR" + +echo "=== Starting server on port $PORT (CORS_ORIGIN=$CORS_ORIGIN) ===" +node dist/index.js & +SERVER_PID=$! + +# Poll until server responds +for i in 1 2 3 4 5 6 7 8 9 10; do + if curl -sf "http://localhost:$PORT/" > /dev/null 2>&1; then + echo "Server ready after ${i}s" + break + fi + sleep 1 +done + +if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo -e "${RED}Server failed to start${NC}" + exit 1 +fi + +echo "" +echo "=========================================" +echo " CORS TESTS" +echo "=========================================" +echo "" + +pass() { + PASS=$((PASS + 1)) + echo -e " ${GREEN}✓${NC} $1" +} + +fail() { + FAIL=$((FAIL + 1)) + echo -e " ${RED}✗${NC} $1" +} + +# Test 1: Browser-facing GET /subscription/email returns the configured origin +echo "1. CORS on GET /subscription/email" +CORS_HEADER=$(curl -s -o /dev/null -D - \ + "http://localhost:$PORT/subscription/email?pubkey=test_pubkey_123" \ + -H "Origin: https://app.example.com" 2>/dev/null | grep -ia 'access-control-allow-origin' || true | head -1 | tr -d '\r') + +if echo "$CORS_HEADER" | grep -q 'https://app.example.com'; then + pass "subscription/email returns configured origin (not wildcard)" +elif echo "$CORS_HEADER" | grep -q '\*'; then + fail "BUG: subscription/email still returns wildcard '${CORS_HEADER}'" +else + fail "subscription/email missing Access-Control-Allow-Origin (got: ${CORS_HEADER:-})" +fi + +# Test 2: Vary: Origin is present on browser routes +echo "" +echo "2. Vary: Origin header on browser route" +VARY=$(curl -s -o /dev/null -D - \ + "http://localhost:$PORT/" \ + -H "Origin: https://app.example.com" 2>/dev/null | grep -ia 'vary' || true | head -1 | tr -d '\r') + +if echo "$VARY" | grep -qi 'origin'; then + pass "Vary: Origin is present on GET /" +else + fail "Missing Vary: Origin on GET / (got: ${VARY:-})" +fi + +# Test 3: Server-to-server /notify has NO CORS headers (relay callback) +echo "" +echo "3. No CORS on server-to-server POST /notify/:id" +CORS_NOTIFY=$(curl -s -o /dev/null -D - \ + "http://localhost:$PORT/notify/test-id" \ + -X POST -H "Content-Type: application/json" \ + -H "Origin: https://evil.com" \ + -d '{"id":"abc","relay":"wss://relay.primal.net"}' 2>/dev/null | grep -ia 'access-control-allow-origin' || true | head -1 | tr -d '\r') + +if [ -z "$CORS_NOTIFY" ]; then + pass "/notify has no Access-Control-Allow-Origin (server-to-server route)" +else + fail "BUG: /notify returns '${CORS_NOTIFY}' — server-to-server route should have no CORS" +fi + +# Test 4: CORS methods on preflight for subscription route +echo "" +echo "4. CORS preflight on PUT /subscription/email" +METHODS=$(curl -s -o /dev/null -D - \ + "http://localhost:$PORT/subscription/email" \ + -X OPTIONS -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT" 2>/dev/null | grep -ia 'access-control-allow-methods' || true | head -1 | tr -d '\r') + +if echo "$METHODS" | grep -qi 'PUT'; then + pass "OPTIONS preflight returns allowed methods" +else + fail "Preflight missing allowed methods (got: ${METHODS:-})" +fi + +echo "" +echo "=========================================" +echo " RESULTS: $PASS passed, $FAIL failed" +echo "=========================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 \ No newline at end of file diff --git a/test/digest-template.test.ts b/test/digest-template.test.ts new file mode 100644 index 0000000..6615a58 --- /dev/null +++ b/test/digest-template.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const templatePath = join(__dirname, '..', 'src', 'emails', 'digest.mjml') +const source = readFileSync(templatePath, 'utf8') +const lines = source.split('\n') + +describe('digest.mjml brandAccent usage', () => { + it('.event-item border-left does NOT hardcode #7161FF', () => { + const hasHardcoded = lines.some(l => l.includes('.event-item') && l.includes('#7161FF')) + expect(hasHardcoded).toBe(false) + }) + + it('.footer a color does NOT hardcode #7161FF', () => { + const hasHardcoded = lines.some(l => l.includes('.footer a') && l.includes('#7161FF')) + expect(hasHardcoded).toBe(false) + }) + + it('.event-item border-left uses {{brandAccent}}', () => { + const eventItemLine = lines.find(l => l.includes('.event-item')) + expect(eventItemLine).toBeDefined() + expect(eventItemLine).toContain('{{brandAccent}}') + }) + + it('.footer a color uses {{brandAccent}}', () => { + const footerALine = lines.find(l => l.includes('.footer a')) + expect(footerALine).toBeDefined() + expect(footerALine).toContain('{{brandAccent}}') + }) +}) \ No newline at end of file diff --git a/test/event-arrival-race.test.ts b/test/event-arrival-race.test.ts new file mode 100644 index 0000000..93e5c44 --- /dev/null +++ b/test/event-arrival-race.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import * as db from '../src/database.js' + +const pubkey = 'race-test-' + Date.now() +const email = 'race-test-' + Date.now() + '@example.com' +let sub: any = null +let since = 0 +const eventA_id = 'race-event-a-' + Date.now() +const eventB_id = 'race-event-b-' + Date.now() + +// Captured after the initial fetch, before Event B is inserted +let fetchedBeforeB: string[] = [] + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +describe('Event-arrival race in digest job', () => { + beforeAll(async () => { + await db.migrate() + + // Create and confirm subscription + const s = await db.insertSubscription(pubkey, email, 'daily') + expect(s).toBeTruthy() + const confirmed = await db.confirmSubscription(s.key) + expect(confirmed).toBeTruthy() + sub = confirmed + + // Set last_digest_at to 60 seconds ago (the "since" value runJob would use) + since = Math.floor(Date.now() / 1000) - 60 + await db.updateLastDigestAt(sub.id, since) + + // Wait 1.1s so event received_at timestamps are strictly > since + await sleep(1100) + }) + + it('stores Event A (triggers digest)', async () => { + const eventA = { + id: eventA_id, + kind: 1, + pubkey: 'abc', + content: 'event A content', + created_at: Math.floor(Date.now() / 1000), + tags: [], + } + const storedA = await db.insertEvent(eventA_id, sub.id, eventA, 'wss://relay.damus.io') + expect(storedA).toBe(true) + }) + + // Fetch events BEFORE Event B is inserted (simulating runJob's fetch + // before a /notify arrives during the digest send). Save the IDs we + // fetched so we delete exactly those later. + it('fetches events and captures IDs (simulating runJob fetch)', async () => { + const fetched = await db.getEventsForSubscription(sub.id, since) + expect(fetched.some((e: any) => e.id === eventA_id)).toBe(true) + fetchedBeforeB = fetched.map((e: any) => e.id) + }) + + it('stores Event B AFTER fetch (simulating arrival during digest send)', async () => { + await sleep(100) + const eventB = { + id: eventB_id, + kind: 1, + pubkey: 'def', + content: 'event B content — arrived during send', + created_at: Math.floor(Date.now() / 1000), + tags: [], + } + const storedB = await db.insertEvent(eventB_id, sub.id, eventB, 'wss://relay.damus.io') + expect(storedB).toBe(true) + }) + + // Delete using the IDs captured before Event B was inserted. + // This simulates the fix: deleteEventsByIds, not timestamp-based delete. + it('deletes only previously-fetched event IDs (the fix) and leaves event B', async () => { + await db.deleteEventsByIds(sub.id, fetchedBeforeB) + + // Event B must survive (it arrived after fetch and was never sent) + const remaining = await db.getEventsForSubscription(sub.id, since - 10) + const eventB_survived = remaining.some((e: any) => e.id === eventB_id) + expect(eventB_survived).toBe(true) + }) + + it('Event A is deleted (it was fetched and sent)', async () => { + const remaining = await db.getEventsForSubscription(sub.id, since - 10) + const eventA_survived = remaining.some((e: any) => e.id === eventA_id) + expect(eventA_survived).toBe(false) + }) +}) \ No newline at end of file diff --git a/test/integration.sh b/test/integration.sh old mode 100755 new mode 100644 index 7d43141..c21a0d4 --- a/test/integration.sh +++ b/test/integration.sh @@ -38,20 +38,44 @@ if [ ! -d "dist" ]; then npx tsc fi -# Generate a random secret for the test -SECRET="$(openssl rand -hex 32)" +# Generate secrets for the test: one for the server, one for the client +SERVER_SECRET="$(openssl rand -hex 32)" +CLIENT_SECRET="$(openssl rand -hex 32)" + +# Derive the client pubkey so we can look up subscriptions later +CLIENT_PUBKEY=$(node -e " +import {Nip01Signer} from '@welshman/signer'; +const s = Nip01Signer.fromSecret('$CLIENT_SECRET'); +s.getPubkey().then(p => console.log(p)); +") + +echo "Client pubkey: $CLIENT_PUBKEY" + +# Helper to build a NIP-98 auth header +nip98_auth() { + local url="$1" method="$2" body="${3:-}" + if [ -n "$body" ]; then + node "$PROJECT_DIR/script/nip98-auth-header.mjs" "$CLIENT_SECRET" "$url" "$method" "$body" + else + node "$PROJECT_DIR/script/nip98-auth-header.mjs" "$CLIENT_SECRET" "$url" "$method" + fi +} # Export env vars for the server -export MAILSHIP_SECRET="$SECRET" +export MAILSHIP_SECRET="$SERVER_SECRET" export MAILSHIP_NAME="Mailship Test" export MAILSHIP_URL="$BASE_URL" export BASE_URL="$BASE_URL" -export POSTMARK_API_KEY="test" -export POSTMARK_SENDER_ADDRESS="test@test.com" +export SMTP_HOST="localhost" +export SMTP_PORT="587" +export SMTP_USER="test@test.com" +export SMTP_PASSWORD="test" +export SMTP_FROM="test@test.com" export DEFAULT_RELAYS="wss://relay.damus.io" export INDEXER_RELAYS="wss://purplepag.es" export SEARCH_RELAYS="wss://relay.nostr.band" export PORT="$PORT" +export CORS_ORIGIN="$BASE_URL" export DATA_DIR="$PROJECT_DIR/test-data" mkdir -p "$DATA_DIR" @@ -114,11 +138,13 @@ echo "1. Health check" HEALTH=$(curl -s "$BASE_URL/") check_field "Root endpoint returns Mailship" "$HEALTH" "name" "Mailship" -# Test 2: Register subscription +# Test 2: Register subscription with NIP-98 auth echo "" -echo "2. Register subscription" +echo "2. Register subscription (NIP-98 auth)" +AUTH_PUT=$(nip98_auth "$BASE_URL/subscription/email" PUT '{"email":"test@example.com","frequency":"daily"}') REG=$(curl -s "$BASE_URL/subscription/email" -X PUT -H "Content-Type: application/json" \ - -d '{"email":"test@example.com","frequency":"daily","pubkey":"abc123"}') + -H "Authorization: $AUTH_PUT" \ + -d '{"email":"test@example.com","frequency":"daily"}') KEY=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null) CALLBACK=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('callback',''))" 2>/dev/null) @@ -129,9 +155,31 @@ else fail "Registration missing key or callback (got: $REG)" fi -# Test 3: Confirm subscription +# Test 3: PUT without auth returns 401 echo "" -echo "3. Confirm subscription" +echo "3. PUT without auth returns 401" +NO_AUTH=$(curl -s "$BASE_URL/subscription/email" -X PUT -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","frequency":"daily"}') +check_field "No-auth PUT returns 401" "$NO_AUTH" "error" "NIP-98 authorization required" + +# Test 4: GET /subscription/email with auth +echo "" +echo "4. GET subscription with auth" +AUTH_GET=$(nip98_auth "$BASE_URL/subscription/email" GET) +GET_RESP=$(curl -s "$BASE_URL/subscription/email" \ + -H "Authorization: $AUTH_GET") +check_field "GET returns our email" "$GET_RESP" "email" "test@example.com" +check_field "GET returns frequency" "$GET_RESP" "frequency" "daily" + +# Test 5: GET without auth returns 401 +echo "" +echo "5. GET without auth returns 401" +GET_NO_AUTH=$(curl -s "$BASE_URL/subscription/email") +check_field "No-auth GET returns 401" "$GET_NO_AUTH" "error" "NIP-98 authorization required" + +# Test 6: Confirm subscription +echo "" +echo "6. Confirm subscription" CONFIRM=$(curl -s "$BASE_URL/confirm?token=$KEY" 2>&1) if echo "$CONFIRM" | grep -qi "success"; then pass "Confirmation page shows success" @@ -139,20 +187,20 @@ else fail "Confirmation page doesn't show success" fi -# Test 4: Check SQLite state +# Test 7: Check SQLite state echo "" -echo "4. Database state" -CONFIRMED=$(sqlite3 "$DB_PATH" "SELECT confirmed_at FROM subscriptions WHERE email='test@example.com';" 2>/dev/null) +echo "7. Database state" +CONFIRMED=$(sqlite3 "$DB_PATH" "SELECT confirmed_at FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY';" 2>/dev/null) if [ -n "$CONFIRMED" ] && [ "$CONFIRMED" -gt 0 ]; then pass "Subscription confirmed in DB" else fail "Subscription not confirmed in DB" fi -# Test 5: Push event to notify endpoint +# Test 8: Push event to notify endpoint echo "" -echo "5. Push event via notify" -SUB_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE email='test@example.com';" 2>/dev/null) +echo "8. Push event via notify" +SUB_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY';" 2>/dev/null) # Fetch a real event from a relay EVENT_ID=$(nak req -k 1 -l 1 wss://relay.primal.net 2>/dev/null | head -1 | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])" 2>/dev/null) @@ -170,25 +218,25 @@ else check_field "Event stored in DB" "$NOTIFY" "stored" "True" fi -# Test 6: Dedup +# Test 9: Dedup echo "" -echo "6. Dedup" +echo "9. Dedup" if [ -n "$EVENT_ID" ]; then DEDUP=$(curl -s "$BASE_URL/notify/$SUB_ID" -X POST -H "Content-Type: application/json" \ -d "{\"id\":\"$EVENT_ID\",\"relay\":\"wss://relay.primal.net\"}") check_field "Duplicate event rejected" "$DEDUP" "stored" "False" fi -# Test 7: 404 for nonexistent subscription +# Test 10: 404 for nonexistent subscription echo "" -echo "7. 404 for nonexistent subscription" +echo "10. 404 for nonexistent subscription" NOT_FOUND=$(curl -s "$BASE_URL/notify/nonexistent-id" -X POST -H "Content-Type: application/json" \ -d '{"id":"abc","relay":"wss://relay.primal.net"}') check_field "Nonexistent subscription returns 404" "$NOT_FOUND" "error" "Subscription not found" -# Test 8: Unsubscribe +# Test 11: Unsubscribe echo "" -echo "8. Unsubscribe" +echo "11. Unsubscribe" UNSUB=$(curl -s "$BASE_URL/unsubscribe?token=$KEY") if echo "$UNSUB" | grep -qi "unsubscribed\|success"; then pass "Unsubscribe page renders" @@ -196,21 +244,28 @@ else fail "Unsubscribe page didn't render" fi -# Test 9: Notify after unsubscribe returns 404 +# Test 12: Notify after unsubscribe returns 404 echo "" -echo "9. No push after unsubscribe" +echo "12. No push after unsubscribe" if [ -n "$EVENT_ID" ]; then AFTER_UNSUB=$(curl -s "$BASE_URL/notify/$SUB_ID" -X POST -H "Content-Type: application/json" \ -d "{\"id\":\"$EVENT_ID\",\"relay\":\"wss://relay.primal.net\"}") check_field "Push after unsubscribe returns 404" "$AFTER_UNSUB" "error" "Subscription not active" fi -# Test 10: Delete subscription +# Test 13: Delete subscription with auth echo "" -echo "10. Delete subscription" -DELETE=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE 2>&1) +echo "13. Delete subscription with NIP-98 auth" +AUTH_DEL=$(nip98_auth "$BASE_URL/subscription/$KEY" DELETE) +DELETE=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE -H "Authorization: $AUTH_DEL") check_field "Delete returns ok" "$DELETE" "ok" "True" +# Test 14: Delete without auth returns 401 +echo "" +echo "14. Delete without auth returns 401" +DEL_NO_AUTH=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE) +check_field "No-auth DELETE returns 401" "$DEL_NO_AUTH" "error" "NIP-98 authorization required" + # Summary echo "" echo "=========================================" diff --git a/test/normalize-relay-url.test.js b/test/normalize-relay-url.test.js deleted file mode 100644 index 14a48f6..0000000 --- a/test/normalize-relay-url.test.js +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env node -// FAILING test: calling normalizeRelayUrl(undefined) crashes with -// TypeError: can't access property "match", A is undefined -// -// The bug: main.ts called normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY) -// without guarding against the env var being undefined. When the env var is -// not set, normalizeRelayUrl crashes because it calls url.match(...) on -// undefined. -// -// The fix: replace the bare call with a ternary guard: -// const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY -// ? normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY) -// : undefined -// -// This test validates that the guard pattern works correctly: when the env var -// is falsy (undefined, empty), the app gracefully sets NOTIFIER_RELAY to -// undefined without crashing. When it's a valid URL, normalization works. - -import { normalizeRelayUrl } from '/home/gascity/mailship/node_modules/.pnpm/@welshman+util@0.6.3_typescript@5.9.2/node_modules/@welshman/util/dist/util/src/Relay.js'; - -let passed = 0; -let failed = 0; - -function assert(label, ok, detail) { - if (ok) { - console.log(` ✓ ${label}`); - passed++; - } else { - console.log(` ✗ ${label} — ${detail || ''}`); - failed++; - } -} - -// Test 1: Guarded normalizeRelayUrl with undefined (the exact fix pattern) -console.log('1. Guarded normalizeRelayUrl with undefined (the fix)'); -const undefinedInput = undefined; -const guarded1 = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined; -assert( - 'guarded normalizeRelayUrl with undefined should not crash, result is undefined', - guarded1 === undefined, - `got ${guarded1}` -); - -// Test 2: Guard with empty string -console.log(''); -console.log('2. Guarded normalizeRelayUrl with empty string'); -const emptyInput = ''; -const guarded2 = emptyInput ? normalizeRelayUrl(emptyInput) : undefined; -assert( - 'guarded normalizeRelayUrl with "" should not crash, result is undefined', - guarded2 === undefined, - `got ${guarded2}` -); - -// Test 3: Guard with a valid relay still works -console.log(''); -console.log('3. Guarded normalizeRelayUrl with valid relay'); -const validInput = 'wss://relay.damus.io'; -const guarded3 = validInput ? normalizeRelayUrl(validInput) : undefined; -assert( - 'guarded normalizeRelayUrl with valid input still normalizes correctly', - guarded3 === 'wss://relay.damus.io/', - `got ${guarded3}` -); - -console.log(''); -console.log(`Results: ${passed} passed, ${failed} failed`); -process.exit(failed > 0 ? 1 : 0); diff --git a/test/normalize-relay-url.test.ts b/test/normalize-relay-url.test.ts new file mode 100644 index 0000000..7a5c2ab --- /dev/null +++ b/test/normalize-relay-url.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { normalizeRelayUrl } from '@welshman/util' + +describe('Guarded normalizeRelayUrl', () => { + it('guarded with undefined should not crash, result is undefined', () => { + const undefinedInput: string | undefined = undefined + const guarded = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined + expect(guarded).toBeUndefined() + }) + + it('guarded with empty string should not crash, result is undefined', () => { + const emptyInput = '' + const guarded = emptyInput ? normalizeRelayUrl(emptyInput) : undefined + expect(guarded).toBeUndefined() + }) + + it('guarded with valid relay still normalizes correctly', () => { + const validInput = 'wss://relay.damus.io' + const guarded = validInput ? normalizeRelayUrl(validInput) : undefined + expect(guarded).toBe('wss://relay.damus.io/') + }) +}) \ No newline at end of file diff --git a/test/notify-response-shape.test.ts b/test/notify-response-shape.test.ts new file mode 100644 index 0000000..9d229b8 --- /dev/null +++ b/test/notify-response-shape.test.ts @@ -0,0 +1,78 @@ +// POST /notify/:id response shape test +// +// Verifies that the endpoint always includes a `stored` boolean +// in its response, matching the documented contract in README.md: +// Response: { ok: true, stored: boolean } +// +// Bug: when the event is not found at the relay, the handler returns +// { ok: true, skipped: true } +// missing the documented `stored` field. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import * as db from '../src/database.js' +import { server } from '../src/server.js' +import { createServer, type Server } from 'http' + +// Partially mock @welshman/net so that `load()` returns an empty array, +// simulating the case where the relay does not have the requested event, +// while preserving all other exports that other modules depend on. +vi.mock('@welshman/net', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(actual as Record), + load: vi.fn().mockResolvedValue([]), + } +}) + +describe('notify_response_shape', () => { + let httpServer: Server + let baseUrl: string + let subId: string + + beforeAll(async () => { + await db.migrate() + + // Create and confirm a subscription we can use for the notify call + const pubkey = 'shape-test-pk-' + Date.now() + const email = 'shape-test-' + Date.now() + '@example.com' + const sub = await db.insertSubscription(pubkey, email, 'daily') + const confirmed = await db.confirmSubscription(sub.key) + subId = confirmed.id + + // Start the express server on a random available port + await new Promise((resolve) => { + httpServer = createServer(server) + httpServer.listen(0, () => { + const addr = httpServer.address() + if (addr && typeof addr === 'object') { + baseUrl = `http://localhost:${addr.port}` + } + resolve() + }) + }) + }) + + afterAll(async () => { + httpServer?.close() + }) + + it('returns stored=false instead of skipped=true when event not found', async () => { + const res = await fetch(`${baseUrl}/notify/${subId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: 'nonexistent-' + Date.now(), + relay: 'wss://relay.damus.io', + }), + }) + + const body = await res.json() + + // The documented contract says: { ok: true, stored: boolean } + // The current buggy code returns: { ok: true, skipped: true } + expect(body).not.toHaveProperty('skipped') + expect(body).toHaveProperty('stored') + expect(body.stored).toBe(false) + expect(body.ok).toBe(true) + }) +}) \ No newline at end of file diff --git a/test/reschedule-on-frequency-change.test.ts b/test/reschedule-on-frequency-change.test.ts new file mode 100644 index 0000000..208ff93 --- /dev/null +++ b/test/reschedule-on-frequency-change.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import * as db from '../src/database.js' +import { registerSubscription } from '../src/actions.js' +import { getJobCronSource, removeJob } from '../src/worker/email.js' +import { registerSubscription as regSub } from '../src/worker/index.js' + +const pubkey = 'freq-test-' + Date.now() +const email = 'freq-test-' + Date.now() + '@example.com' +let sub: any = null + +describe('Frequency change reschedules cron job', () => { + beforeAll(async () => { + await db.migrate() + }) + + it('creates confirmed subscription with daily frequency', async () => { + const s = await db.insertSubscription(pubkey, email, 'daily') + expect(s).toBeTruthy() + sub = s + + const confirmed = await db.confirmSubscription(sub.key) + expect(confirmed).toBeTruthy() + sub = confirmed + }) + + it('registers cron job with daily frequency', () => { + regSub(sub) + const dailySource = getJobCronSource(sub.id) + expect(dailySource).toBe('0 0 17 * * *') + }) + + it('changes frequency to weekly via registerSubscription', async () => { + await registerSubscription({ pubkey, email, frequency: 'weekly' }) + const weeklySource = getJobCronSource(sub.id) + expect(weeklySource).toBe('0 0 17 * * 1') + }) +}) + +afterAll(async () => { + const updated = await db.getSubscriptionByPubkey(pubkey) + if (updated) removeJob(updated) +}) \ No newline at end of file diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..d67ec3f --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,23 @@ +// Vitest setup: set required env vars before test modules are loaded. +// env.ts checks these at module load time; they must be present when +// actions.ts / mailer.ts / etc. are imported. +import { mkdirSync } from 'fs' + +const dataDir = 'test-data-unit' +mkdirSync(dataDir, { recursive: true }) + +process.env.MAILSHIP_URL = 'http://localhost:3000' +process.env.MAILSHIP_NAME = 'Test Mailship' +process.env.MAILSHIP_SECRET = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +process.env.SMTP_HOST = 'localhost' +process.env.SMTP_PORT = '587' +process.env.SMTP_USER = 'test@test.com' +process.env.SMTP_PASSWORD = 'test' +process.env.SMTP_FROM = 'test@test.com' +process.env.DEFAULT_RELAYS = 'wss://relay.damus.io' +process.env.INDEXER_RELAYS = 'wss://purplepag.es' +process.env.SEARCH_RELAYS = 'wss://relay.nostr.band' +process.env.PORT = '3000' +process.env.CORS_ORIGIN = 'http://localhost:5173' +process.env.BASE_URL = 'http://localhost:3000' +process.env.DATA_DIR = dataDir \ No newline at end of file diff --git a/test/web-ui.test.js b/test/web-ui.test.js deleted file mode 100644 index 207bcb2..0000000 --- a/test/web-ui.test.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -// Failing test: web UI crashes on load when VITE_NOTIFIER_RELAY is not set. -// -// The bug: `normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY)` throws -// TypeError: Cannot read properties of undefined (reading 'match') -// when the env var is not set. -// -// After the fix, `normalizeRelayUrl` is only called when the env var is -// truthy, so the crash no longer occurs. This test verifies the guarded -// call pattern matches the one in main.ts. - -import { normalizeRelayUrl } from '/home/gascity/mailship/node_modules/.pnpm/@welshman+util@0.6.3_typescript@5.9.2/node_modules/@welshman/util/dist/util/src/Relay.js'; - -let passed = 0; -let failed = 0; - -function assert(label, ok, detail) { - if (ok) { - console.log(` ✓ ${label}`); - passed++; - } else { - console.log(` ✗ ${label} — ${detail || ''}`); - failed++; - } -} - -// Test 1: Guarded normalizeRelayUrl — the pattern used in main.ts -console.log('1. Guarded normalizeRelayUrl (main.ts pattern)'); -const undefinedInput = undefined; // simulates unset VITE_NOTIFIER_RELAY -const guarded1 = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined; -assert( - 'guarded normalizeRelayUrl with undefined should not crash, result is undefined', - guarded1 === undefined, - `got ${guarded1}` -); - -// Test 2: Guard with empty string -console.log(''); -console.log('2. Guarded normalizeRelayUrl with empty string'); -const emptyInput = ''; -const guarded2 = emptyInput ? normalizeRelayUrl(emptyInput) : undefined; -assert( - 'guarded normalizeRelayUrl with "" should not crash, result is undefined', - guarded2 === undefined, - `got ${guarded2}` -); - -// Test 3: Guard with a valid relay still works -console.log(''); -console.log('3. Guarded normalizeRelayUrl with valid relay'); -const validInput = 'wss://relay.damus.io'; -const guarded3 = validInput ? normalizeRelayUrl(validInput) : undefined; -assert( - 'guarded normalizeRelayUrl with valid input still normalizes correctly', - guarded3 === 'wss://relay.damus.io/', - `got ${guarded3}` -); - -console.log(''); -console.log(`Results: ${passed} passed, ${failed} failed`); -process.exit(failed > 0 ? 1 : 0); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..255780e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + include: ['test/**/*.test.ts'], + setupFiles: ['test/setup.ts'], + }, +}) \ No newline at end of file diff --git a/web/.env.template b/web/.env.template deleted file mode 100644 index 9f0b1f1..0000000 --- a/web/.env.template +++ /dev/null @@ -1,3 +0,0 @@ -VITE_NOTIFIER_PUBKEY= -VITE_NOTIFIER_RELAY= -VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band diff --git a/web/.gitignore b/web/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/web/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/web/eslint.config.js b/web/eslint.config.js deleted file mode 100644 index d785258..0000000 --- a/web/eslint.config.js +++ /dev/null @@ -1,21 +0,0 @@ -import js from "@eslint/js"; -import globals from "globals"; -import tseslint from "typescript-eslint"; -import { defineConfig } from "eslint/config"; - - -export default defineConfig([ - { files: ["**/*.{js,mjs,cjs,ts}"], plugins: { js }, extends: ["js/recommended"] }, - { files: ["**/*.{js,mjs,cjs,ts}"], languageOptions: { globals: globals.browser } }, - tseslint.configs.recommended, - { - files: ["src/**/*.{js,mjs,cjs,ts}"], - rules: { - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": [ - "error", - {args: "none", destructuredArrayIgnorePattern: "^_d?$", caughtErrors: "none"}, - ], - }, - }, -]); diff --git a/web/index.html b/web/index.html deleted file mode 100644 index b477b77..0000000 --- a/web/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - Anchor Alerts - - -
-
-
- - - diff --git a/web/package.json b/web/package.json deleted file mode 100644 index c783cf3..0000000 --- a/web/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "web", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "check": "tsc --noEmit && eslint src", - "format": "eslint src --fix" - }, - "devDependencies": { - "@eslint/js": "^9.25.1", - "@types/mithril": "^2.2.7", - "eslint": "^9.25.1", - "globals": "^16.0.0", - "typescript": "~5.7.2", - "typescript-eslint": "^8.31.1", - "vite": "^6.3.1" - }, - "dependencies": { - "@tailwindcss/vite": "^4.1.4", - "@welshman/feeds": "^0.6.3", - "@welshman/lib": "^0.6.3", - "@welshman/net": "^0.6.3", - "@welshman/signer": "^0.6.3", - "@welshman/store": "^0.6.3", - "@welshman/util": "^0.6.3", - "events": "^3.3.0", - "mithril": "^2.2.15", - "svelte": "^5.27.2", - "tailwindcss": "^4.1.4" - } -} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml deleted file mode 100644 index dc6d6ad..0000000 Binary files a/web/pnpm-lock.yaml and /dev/null differ diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml deleted file mode 100644 index 6810e3a..0000000 --- a/web/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -allowBuilds: - esbuild: true \ No newline at end of file diff --git a/web/src/main.ts b/web/src/main.ts deleted file mode 100644 index 13124a3..0000000 --- a/web/src/main.ts +++ /dev/null @@ -1,544 +0,0 @@ -import './style.css' - -import m from "mithril" -import {writable} from 'svelte/store' -import {getJson, removeNil, spec, parseJson, setJson, assoc, randomId, TIMEZONE, tryCatch, LOCALE} from '@welshman/lib' -import {withGetter} from '@welshman/store' -import {Router} from '@welshman/router' -import {validateFeed, ValidationError, displayFeeds, Feed} from '@welshman/feeds' -import {getAddress, getRelaysFromList, RelayMode, readList, asDecryptedEvent, normalizeRelayUrl, getTagValue, getTagValues, makeEvent, DELETE, TrustedEvent, StampedEvent, FEED, Address, getIdFilters, fromNostrURI, RELAYS} from '@welshman/util' -import {load, publish, defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net' -import type {ISigner} from '@welshman/signer' -import {Nip07Signer, decrypt} from '@welshman/signer' - -// Constants - -const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY - -const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY ? normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY) : undefined - -const INDEXER_RELAYS = (import.meta.env.VITE_INDEXER_RELAYS || 'purplepag.es,relay.damus.io,relay.nostr.band').split(',').map(normalizeRelayUrl) - -const ALERT = 32830 - -const ALERT_STATUS = 32831 - -const TZ_OFFSET = parseInt(TIMEZONE.split(':')[0]!) - -const CRON_DAILY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* \*$/ - -const CRON_WEEKLY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* 1$/ - -const PLUS_ICON = ` - - -` - -const TRASH_ICON = ` - - - - -` - -const ARROW_LEFT_ICON = ` - - -` - -// Types and state - -type Alert = { - event: TrustedEvent - tags: string[][] -} - -type AlertStatus = { - event: TrustedEvent - tags: string[][] -} - -type AlertValues = { - feedAddress: string - freq: string - time: string, - email: string - secret: string -} - -type State = { - failedToLogin: boolean - signer: ISigner - pubkey: string | undefined - alerts: Alert[] - alertDraft?: AlertValues, - alertStatuses: AlertStatus[] - alertsLoading: boolean -} - -const state = withGetter( - writable({ - failedToLogin: false, - signer: new Nip07Signer(), - pubkey: getJson('pubkey'), - alerts: [], - alertStatuses: [], - alertsLoading: false, - } as State) -) - -// Actions - -const login = async () => { - const {signer} = state.get() - - try { - const pubkey = await signer.getPubkey() - - state.update(assoc('pubkey', pubkey)) - setJson('pubkey', pubkey) - } catch (e) { - state.update(assoc('failedToLogin', true)) - } -} - -const loadAlerts = async () => { - const {signer, pubkey} = state.get() - - if (!NOTIFIER_RELAY) { - state.update(assoc('alertsLoading', false)) - return - } - - state.update(assoc('alertsLoading', true)) - - const events = await load({ - relays: [NOTIFIER_RELAY], - filters: [ - {kinds: [ALERT], authors: [pubkey!]}, - {kinds: [ALERT_STATUS], "#p": [pubkey!]}, - ], - }) - - const alerts = await Promise.all( - events - .filter(spec({kind: ALERT})) - .map(async event => { - const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content)) - - return {event, tags} - }) - ) - - const alertStatuses = await Promise.all( - events - .filter(spec({kind: ALERT_STATUS})) - .map(async event => { - const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content)) - - return {event, tags} - }) - ) - - state.update($state => ({...$state, alertsLoading: false, alerts, alertStatuses})) -} - -const deleteAlert = async (alert: Alert) => { - if (!NOTIFIER_RELAY) return - - if (confirm("Are you sure you want to delete this alert?")) { - state.update(assoc('alertsLoading', true)) - - await publish({ - relays: [NOTIFIER_RELAY], - event: await state.get().signer!.sign( - makeEvent(DELETE, { - tags: [ - ["k", String(alert.event.kind)], - ["a", getAddress(alert.event)] - ], - }) - ), - }) - - await loadAlerts() - } -} - -export type AlertParams = { - feeds: Feed[] - freq: string - time: string - email: string - secret: string -} - -export const makeAlert = async ({freq, time, email, feeds, secret}: AlertParams) => { - const {signer} = state.get() - const [hour, minute] = time.split(':') - const utcHour = (parseInt(hour) - TZ_OFFSET) % 24 - const dow = freq === 'daily' ? '*' : freq - const cron = `0 ${minute} ${utcHour} * * ${dow}` - - const tags = [ - ["cron", cron], - ["email", email], - ["channel", "email"], - ["locale", LOCALE], - ["timezone", TIMEZONE], - [ - "handler", - "31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1685968093690", - "wss://relay.nostr.band/", - "web", - ], - ] - - for (const feed of feeds) { - tags.push(["feed", JSON.stringify(feed)]) - } - - return signer.sign( - makeEvent(ALERT, { - content: await signer.nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)), - tags: [ - ["d", randomId()], - ["p", NOTIFIER_PUBKEY], - ], - }) - ) -} - -export const publishAlert = async (params: AlertParams) => { - if (!NOTIFIER_RELAY) return - - await publish({event: await makeAlert(params), relays: [NOTIFIER_RELAY]}) -} - -// Components - -const Loader = { - view: () => m("div", { class: "flex justify-center py-4" }, [ - m("div", { - class: "animate-spin rounded-full h-8 w-8 border-4 border-purple-200 border-t-purple-600" - }) - ]) -} - -const Login = { - view: () => - m("button", { - onclick: login, - class: "w-full bg-purple-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors" - }, "Connect with Nostr"), -} - -const AlertStatus: m.Component<{alert: Alert}> = { - view: vnode => { - const {alert} = vnode.attrs - const {alertStatuses} = state.get() - const address = getAddress(alert.event) - const alertStatus = alertStatuses.find(s => getTagValue('d', s.event.tags) === address) - const status = getTagValue('status', alertStatus?.tags || []) - const message = getTagValue('message', alertStatus?.tags || []) - - const getStatusClasses = () => { - const baseClasses = "rounded-full px-3 py-1 text-sm border" - if (status === 'ok') return `${baseClasses} border-green-500 text-green-500` - if (status === 'pending') return `${baseClasses} border-yellow-500 text-yellow-500` - return `${baseClasses} border-red-500 text-red-500` - } - - const getStatusDisplay = () => { - if (!status) return 'Inactive' - if (status === 'ok') return 'Active' - if (status === 'pending') return 'Pending' - return status.replace('-', ' ').replace(/^(.)/, x => x.toUpperCase()) - } - - return m("div", {class: getStatusClasses(), tooltip: message}, getStatusDisplay()) - }, -} - -const AlertListItem: m.Component<{alert: Alert}> = { - view: vnode => { - const {alert} = vnode.attrs - const cron = getTagValue('cron', alert.tags) - const feeds = getTagValues('feed', alert.tags) - const channel = getTagValue('channel', alert.tags) - const description = displayFeeds(feeds.map(feed => parseJson(feed))) || "[invalid feed]" - - let frequency = cron || "Unknown" - if (cron) { - if (CRON_DAILY_PATTERN.test(cron)) { - frequency = 'Daily' - } else if (CRON_WEEKLY_PATTERN.test(cron)) { - frequency = 'Weekly' - } - } - - return m("div", { class: "flex items-start justify-between p-4" }, [ - m("button", { - onclick: () => deleteAlert(alert), - class: "mr-4 mt-1", - tooltip: "Delete alert" - }, [m.trust(TRASH_ICON)]), - m("div", { class: "space-y-2 flex-grow" }, [ - m("div", { class: "text-gray-600" }, `${frequency} alert via ${channel}`), - m("div", { class: "text-sm text-gray-500" }, `Events ${description}`) - ]), - m(AlertStatus, {alert}), - ]) - } -} - -const AlertList = { - oninit: loadAlerts, - view: () => { - const {alerts, alertsLoading} = state.get() - - const content = alertsLoading - ? m(Loader) - : alerts.length > 0 - ? alerts.map(alert => m(AlertListItem, {alert, key: alert.event.id})) - : m("div", { class: "text-center text-gray-500 py-8" }, [ - "You don't have any alerts set up.", - ]) - - return m("div", { class: "space-y-4" }, [ - m("div", { class: "flex items-center justify-between mb-6" }, [ - m("h1", { class: "text-2xl font-bold text-gray-900" }, "Your Nostr Alerts"), - m("a", { - href: "#!/alerts/new", - class: "flex items-center gap-2 bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors", - }, [ - m.trust(PLUS_ICON), - "Add Alert" - ]) - ]), - m("div", { class: "bg-white shadow rounded-lg p-6" }, content) - ]) - } -} - -const AlertCreate = { - oninit: () => { - state.update(assoc('alertDraft', { - email: getTagValue('email', state.get().alerts[0]?.tags || []) || "", - freq: 'daily', - time: '17:00', - feedAddress: "", - secret: "", - })) - }, - view: () => { - const {pubkey, alertDraft, alertsLoading} = state.get() - const {email, feedAddress, freq, time, secret} = alertDraft! - - const update = (newValues: Partial) => { - state.update(assoc('alertDraft', {...alertDraft, ...newValues})) - } - - const submit = async (e: Event) => { - e.preventDefault() - - state.update(assoc('alertsLoading', true)) - - try { - if (!email.includes("@")) return alert("Please provide a valid email address") - - const address = tryCatch(() => Address.fromNaddr(fromNostrURI(feedAddress))) - - if (!address) return alert("Please provide a valid feed address") - if (address.kind !== FEED) return alert(`Please provide a valid feed address (kind ${FEED})`) - - const selections = await load({ - relays: INDEXER_RELAYS, - filters: [{kinds: [RELAYS], authors: [pubkey!, address.pubkey]}], - }) - - const router = Router.get() - const filters = getIdFilters([address.toString()]) - const scenario = router.merge([ - router.FromRelays(selections.flatMap(e => getRelaysFromList(readList(asDecryptedEvent(e)), RelayMode.Write))), - router.FromRelays(address.relays), - router.FromRelays(INDEXER_RELAYS), - ]) - const relays = scenario.limit(10).getUrls() - - const [event] = await load({relays, filters}) - - if (!event) return alert("Sorry, we weren't able to find that feed") - - const feedStrings = getTagValues('feed', event.tags) - - if (feedStrings.length === 0) return alert('At least one feed is required') - - const feeds = removeNil(feedStrings.map(parseJson)) - - if (feeds.length < feedStrings.length) return alert("At least one feed is invalid (must be valid JSON)") - - const feedError = feeds.map(validateFeed).find(e => e instanceof ValidationError) - - if (feedError) return alert(`At least one feed is invalid (${feedError.data.toLowerCase()}).`) - - await publishAlert({freq, time, email, feeds, secret}) - - m.route.set("/alerts") - } catch (error) { - alert("Failed to create alert. Please try again.") - console.error('Error creating alert:', error) - } finally { - state.update(assoc('alertsLoading', false)) - } - } - - return m("div", { class: "space-y-4" }, [ - m("div", { class: "flex items-center gap-4 mb-6" }, [ - m("button", { - onclick: () => m.route.set("/alerts"), - class: "text-gray-600 hover:text-gray-900 cursor-pointer", - tooltip: "Back to alerts" - }, m.trust(ARROW_LEFT_ICON)), - m("h1", { class: "text-2xl font-bold text-gray-900" }, "Create Alert") - ]), - m("div", { class: "bg-white shadow rounded-lg p-6" }, [ - m("form", { class: "space-y-6", onsubmit: submit }, [ - m("div", [ - m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Email"), - m("input", { - type: "email", - placeholder: "Enter your email address", - value: email, - oninput: (e: InputEvent) => update({email: (e.target as HTMLInputElement).value}), - class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" - }) - ]), - m("div", {class: "w-full flex gap-2"}, [ - m("div", {class: "flex-grow"}, [ - m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Frequency"), - m("select", { - value: freq, - onchange: (e: Event) => update({freq: (e.target as HTMLSelectElement).value}), - class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" - }, [ - m("option", { value: 'daily' }, "Daily"), - m("option", { value: '0' }, "Weekly on Sunday"), - m("option", { value: '1' }, "Weekly on Monday"), - m("option", { value: '2' }, "Weekly on Tuesday"), - m("option", { value: '3' }, "Weekly on Wednesday"), - m("option", { value: '4' }, "Weekly on Thursday"), - m("option", { value: '5' }, "Weekly on Friday"), - m("option", { value: '6' }, "Weekly on Saturday"), - ]) - ]), - m("div", [ - m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Time"), - m("input", { - value: time, - onchange: (e: Event) => update({time: (e.target as HTMLSelectElement).value}), - type: "time", - class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" - }) - ]), - ]), - m("div", [ - m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Feed Address"), - m("div", { class: "space-y-2" }, [ - m("input", { - type: "text", - placeholder: "naddr1...", - value: feedAddress, - oninput: (e: InputEvent) => update({feedAddress: (e.target as HTMLInputElement).value}), - class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" - }), - m("p", { class: "text-sm text-gray-500" }, [ - "Visit ", - m("a", { - href: "https://coracle.social/feeds", - target: "_blank", - class: "text-purple-600 hover:text-purple-800" - }, "coracle.social/feeds"), - " to search for existing feeds or create a new one. Copy the feed address (starts with 'naddr1') and paste it here." - ]) - ]) - ]), - m("div", { class: "flex justify-end" }, [ - m("button", { - type: "submit", - disabled: alertsLoading, - class: "bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - }, alertsLoading ? "Creating..." : "Create Alert") - ]) - ]) - ]) - ]) - } -} - -const FailedToLogin = { - view: () => - m("div", { class: "space-y-6 text-center" }, [ - m("div", { class: "bg-red-50 border border-red-200 rounded-lg p-6" }, [ - m("h2", { class: "text-red-800 font-semibold mb-2" }, "Unable to Connect"), - m("p", { class: "text-red-600 mb-4" }, "To use Anchor Alerts, you need a Nostr signer extension installed in your browser."), - m("div", { class: "space-y-3" }, [ - m("button", { - onclick: () => window.location.reload(), - class: "w-full bg-red-100 text-red-700 font-medium py-2 px-4 rounded-lg hover:bg-red-200 transition-colors" - }, "Try Again"), - m("a", { - href: "https://nostrapps.com/#signers", - target: "_blank", - class: "block w-full bg-purple-600 text-white font-medium py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors" - }, "Install a Nostr Signer") - ]) - ]) - ]) -} - -const Layout: m.Component<{children: m.Children}> = { - view: vnode => { - const {children} = vnode.attrs - const {failedToLogin, pubkey} = state.get() - - if (failedToLogin) { - return m(FailedToLogin) - } - - if (!pubkey) { - return m("div", { class: "text-center space-y-4" }, [ - m("h1", { class: "text-2xl font-bold text-gray-900 mb-2" }, "Welcome to Anchor Alerts"), - m("p", { class: "text-gray-600 mb-6" }, "Connect your Nostr signer to get started"), - m(Login) - ]) - } - - return children - } -} - -m.route(document.querySelector('#app')!, "/alerts", { - "/alerts": { - view: () => { - return m(Layout, {children: [m(AlertList)]}) - }, - }, - "/alerts/new": { - view: () => { - return m(Layout, {children: [m(AlertCreate)]}) - } - }, -}) - -state.subscribe(() => m.redraw()) - -defaultSocketPolicies.push( - makeSocketPolicyAuth({ - sign: (event: StampedEvent) => { - return state.get().signer?.sign(event) - }, - }), -) - -Object.assign(window, {setJson, getJson}) diff --git a/web/src/style.css b/web/src/style.css deleted file mode 100644 index 1c1b184..0000000 --- a/web/src/style.css +++ /dev/null @@ -1,42 +0,0 @@ -@import "tailwindcss"; - -a, button { - @apply cursor-pointer; -} - -/* Tooltip styling */ -[tooltip] { - @apply cursor-pointer relative; -} - -[tooltip]:hover::after { - content: attr(tooltip); - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - padding: 4px 8px; - background-color: rgba(0, 0, 0, 0.8); - color: white; - border-radius: 4px; - font-size: 14px; - white-space: nowrap; - z-index: 1000; - pointer-events: none; - - /* Animation properties */ - opacity: 0; - animation: tooltipFadeIn 0.2s ease-in-out forwards; -} - -/* Keyframes for fade in animation */ -@keyframes tooltipFadeIn { - from { - opacity: 0; - transform: translateX(-50%) translateY(0); - } - to { - opacity: 1; - transform: translateX(-50%) translateY(-3px); - } -} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts deleted file mode 100644 index 11f02fe..0000000 --- a/web/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/web/tsconfig.json b/web/tsconfig.json deleted file mode 100644 index 9d2104f..0000000 --- a/web/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "strict": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["src"] -} diff --git a/web/vite.config.js b/web/vite.config.js deleted file mode 100644 index bd48de3..0000000 --- a/web/vite.config.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vite' -import tailwindcss from '@tailwindcss/vite' - -export default defineConfig({ - server: { - port: 2893, - }, - plugins: [ - tailwindcss(), - ], -})