Merge branch 'main' of ssh://forgejo.lorentz.is:4201/matt/mailship
All checks were successful
CI / checks (push) Successful in 38s
All checks were successful
CI / checks (push) Successful in 38s
This commit is contained in:
commit
c21ed9f71d
44 changed files with 842 additions and 999 deletions
|
|
@ -1,6 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
web/dist/
|
||||
.git/
|
||||
.gitattributes
|
||||
.gitignore
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
31
.forgejo/workflows/ci.yml
Normal file
31
.forgejo/workflows/ci.yml
Normal file
|
|
@ -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
|
||||
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
55
README.md
55
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/<relay>/<hash>` and `/<nevent>` 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 `<EVENT_VIEWER_URL>/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 <base64> 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 <base64> 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 <base64> 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
|
||||
```
|
||||
pnpm test:unit # Run unit tests (vitest)
|
||||
pnpm test # Run integration tests (bash E2E)
|
||||
pnpm test:server # Start server for manual testing
|
||||
```
|
||||
|
|
|
|||
2
build-in-production.sh
Executable file → Normal file
2
build-in-production.sh
Executable file → Normal file
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -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
|
||||
pnpm run build
|
||||
pnpm test:unit
|
||||
34
script/nip98-auth-header.mjs
Normal file
34
script/nip98-auth-header.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#!/usr/bin/env node
|
||||
// Generates a NIP-98 Authorization header value ("Nostr <base64>") for
|
||||
// testing purposes.
|
||||
//
|
||||
// Usage:
|
||||
// node script/nip98-auth-header.mjs <secret-hex> <url> <method> [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 <secret-hex> <url> <method> [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 <base64>"
|
||||
const header = makeHttpAuthHeader(signed)
|
||||
|
||||
console.log(header)
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
import { CronExpressionParser } from 'cron-parser'
|
||||
import { tryCatch, int, HOUR } from '@welshman/lib'
|
||||
|
||||
export type Subscription = {
|
||||
id: string
|
||||
key: string
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<mj-style inline="inline">
|
||||
.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; }
|
||||
</mj-style>
|
||||
|
|
@ -44,11 +44,11 @@
|
|||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
{{#HasPopular}}
|
||||
{{#HasLatest}}
|
||||
<mj-section background-color="#ffffff" padding="0 32px 24px 32px">
|
||||
<mj-column>
|
||||
<mj-text css-class="section-header">Latest Activity</mj-text>
|
||||
{{#Popular}}
|
||||
{{#Latest}}
|
||||
<mj-text>
|
||||
<div class="event-item">
|
||||
<div class="event-meta">
|
||||
|
|
@ -72,10 +72,10 @@
|
|||
</div>
|
||||
</div>
|
||||
</mj-text>
|
||||
{{/Popular}}
|
||||
{{/Latest}}
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
{{/HasPopular}}
|
||||
{{/HasLatest}}
|
||||
|
||||
<mj-section background-color="#ffffff" padding="0 32px 24px 32px">
|
||||
<mj-column>
|
||||
|
|
|
|||
11
src/env.ts
11
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
|
||||
|
|
|
|||
|
|
@ -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<string, an
|
|||
brandName: BRAND_NAME,
|
||||
brandAccent: BRAND_ACCENT,
|
||||
brandLogo: BRAND_LOGO,
|
||||
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`,
|
||||
settingsUrl: `${EVENT_VIEWER_URL}/settings/alerts`,
|
||||
}),
|
||||
})
|
||||
.catch(error => {
|
||||
|
|
|
|||
180
src/server.ts
180
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 <base64>"
|
||||
//
|
||||
// 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<string | null> => {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader) return null
|
||||
|
||||
// Format: "Nostr <base64>"
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ import * as db from '../database.js'
|
|||
|
||||
const jobsById = new Map<string, CronJob>()
|
||||
|
||||
// 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
|
||||
|
|
|
|||
156
test/cors.test.sh
Normal file
156
test/cors.test.sh
Normal file
|
|
@ -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:-<none>})"
|
||||
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:-<none>})"
|
||||
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:-<none>})"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo " RESULTS: $PASS passed, $FAIL failed"
|
||||
echo "========================================="
|
||||
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
33
test/digest-template.test.ts
Normal file
33
test/digest-template.test.ts
Normal file
|
|
@ -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}}')
|
||||
})
|
||||
})
|
||||
89
test/event-arrival-race.test.ts
Normal file
89
test/event-arrival-race.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
109
test/integration.sh
Executable file → Normal file
109
test/integration.sh
Executable file → Normal file
|
|
@ -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 "========================================="
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
22
test/normalize-relay-url.test.ts
Normal file
22
test/normalize-relay-url.test.ts
Normal file
|
|
@ -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/')
|
||||
})
|
||||
})
|
||||
78
test/notify-response-shape.test.ts
Normal file
78
test/notify-response-shape.test.ts
Normal file
|
|
@ -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<string, unknown>),
|
||||
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<void>((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)
|
||||
})
|
||||
})
|
||||
42
test/reschedule-on-frequency-change.test.ts
Normal file
42
test/reschedule-on-frequency-change.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
23
test/setup.ts
Normal file
23
test/setup.ts
Normal file
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['test/**/*.test.ts'],
|
||||
setupFiles: ['test/setup.ts'],
|
||||
},
|
||||
})
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
VITE_NOTIFIER_PUBKEY=
|
||||
VITE_NOTIFIER_RELAY=
|
||||
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band
|
||||
24
web/.gitignore
vendored
24
web/.gitignore
vendored
|
|
@ -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?
|
||||
|
|
@ -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"},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Anchor Alerts</title>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div id="app" class="max-w-2xl mx-auto"></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -1,2 +0,0 @@
|
|||
allowBuilds:
|
||||
esbuild: true
|
||||
544
web/src/main.ts
544
web/src/main.ts
|
|
@ -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 = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`
|
||||
|
||||
const TRASH_ICON = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.5 6H3.49991" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M18.8333 8.5L18.3734 15.3991C18.1964 18.054 18.1079 19.3815 17.2429 20.1907C16.3779 21 15.0475 21 12.3867 21H11.6133C8.95252 21 7.62212 21 6.75711 20.1907C5.8921 19.3815 5.80361 18.054 5.62661 15.3991L5.16667 8.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M6.5 6C6.55588 6 6.58382 6 6.60915 5.99936C7.43259 5.97849 8.15902 5.45491 8.43922 4.68032C8.44784 4.65649 8.45667 4.62999 8.47434 4.57697L8.57143 4.28571C8.65431 4.03708 8.69575 3.91276 8.75071 3.8072C8.97001 3.38607 9.37574 3.09364 9.84461 3.01877C9.96213 3 10.0932 3 10.3553 3H13.6447C13.9068 3 14.0379 3 14.1554 3.01877C14.6243 3.09364 15.03 3.38607 15.2493 3.8072C15.3043 3.91276 15.3457 4.03708 15.4286 4.28571L15.5257 4.57697C15.5433 4.62992 15.5522 4.65651 15.5608 4.68032C15.841 5.45491 16.5674 5.97849 17.3909 5.99936C17.4162 6 17.4441 6 17.5 6" stroke="#1C274C" stroke-width="1.5"/>
|
||||
</svg>`
|
||||
|
||||
const ARROW_LEFT_ICON = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`
|
||||
|
||||
// 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<AlertValues>) => {
|
||||
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})
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
1
web/src/vite-env.d.ts
vendored
1
web/src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 2893,
|
||||
},
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
||||
Loading…
Reference in a new issue