From 75f5908039773422606e56848879e74c42451248 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 10:08:14 -0400 Subject: [PATCH 01/23] Strip out the web UI (login + subscription filter management) Remove the browser admin SPA under web/ that let users log in via a Nostr signer and manage subscription filters. The server is now a headless API only. Changes: - Delete web/ directory entirely (SPA source, config, deps) - Remove express.static('web/dist') serving from server.ts - Simplify GET / handler to always return JSON (no fallback) - Remove build:web from package.json build pipeline - Remove web build steps from Dockerfile - Remove web references from build-in-production.sh Kept: core subscription/unsubscribe/confirm/notify backend and transactional src/pages/*.html (part of email flow, not the UI). --- Dockerfile | 8 +- build-in-production.sh | 2 - package.json | 3 +- src/server.ts | 12 - web/.env.template | 3 - web/.gitignore | 24 -- web/eslint.config.js | 21 -- web/index.html | 15 -- web/package.json | 34 --- web/pnpm-lock.yaml | Bin 78215 -> 0 bytes web/pnpm-workspace.yaml | 2 - web/src/main.ts | 544 ---------------------------------------- web/src/style.css | 42 ---- web/src/vite-env.d.ts | 1 - web/tsconfig.json | 22 -- web/vite.config.js | 11 - 16 files changed, 2 insertions(+), 742 deletions(-) mode change 100755 => 100644 build-in-production.sh delete mode 100644 web/.env.template delete mode 100644 web/.gitignore delete mode 100644 web/eslint.config.js delete mode 100644 web/index.html delete mode 100644 web/package.json delete mode 100644 web/pnpm-lock.yaml delete mode 100644 web/pnpm-workspace.yaml delete mode 100644 web/src/main.ts delete mode 100644 web/src/style.css delete mode 100644 web/src/vite-env.d.ts delete mode 100644 web/tsconfig.json delete mode 100644 web/vite.config.js diff --git a/Dockerfile b/Dockerfile index dc3287a..f5feb6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,12 +25,7 @@ COPY src/ ./src/ COPY src/pages/ ./src/pages/ COPY src/emails/ ./src/emails/ -# Build web UI -COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml web/tsconfig.json web/vite.config.js web/index.html ./web/ -COPY web/src/ ./web/src/ -RUN cd web && pnpm install --frozen-lockfile && pnpm run build - -# Build TypeScript (runs tsc && build:html && build:web) +# Build TypeScript (runs tsc && build:html) RUN pnpm run build # Production image @@ -51,7 +46,6 @@ RUN pnpm install --frozen-lockfile --prod # Copy build artifacts COPY --from=build /app/dist/ ./dist/ -COPY --from=build /app/web/dist/ ./web/dist/ COPY --from=build /app/src/pages/ ./dist/pages/ COPY --from=build /app/src/emails/ ./dist/emails/ diff --git a/build-in-production.sh b/build-in-production.sh old mode 100755 new mode 100644 index 37bf10d..639fba3 --- a/build-in-production.sh +++ b/build-in-production.sh @@ -2,11 +2,9 @@ # Remove link overrides node remove-pnpm-overrides.js package.json -node remove-pnpm-overrides.js web/package.json # When CI=true as it is on render.com, removing link overrides breaks the lockfile pnpm i --no-frozen-lockfile -(cd web && pnpm i --no-frozen-lockfile) # Build everything pnpm run build diff --git a/package.json b/package.json index b2b588f..9ac4de2 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,7 @@ "type": "module", "version": "1.0.0", "scripts": { - "build": "tsc && pnpm run build:html && pnpm run build:web", - "build:web": "cd web && pnpm run build", + "build": "tsc && pnpm run build:html", "build:html": "cp -r src/pages dist/ && cp -r src/emails dist/", "check": "tsc --noEmit && eslint src", "format": "prettier --write \"src/**/*.{ts,js,json,html}\"", diff --git a/src/server.ts b/src/server.ts index eb3fe92..4e6e8fa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -33,8 +33,6 @@ server.use((req: Request, res: Response, next: NextFunction) => { server.use(express.json()) -server.use(express.static('web/dist')) - // Rate limit for registration endpoints server.use( '/subscription', @@ -71,16 +69,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', diff --git a/web/.env.template b/web/.env.template deleted file mode 100644 index 9f0b1f1..0000000 --- a/web/.env.template +++ /dev/null @@ -1,3 +0,0 @@ -VITE_NOTIFIER_PUBKEY= -VITE_NOTIFIER_RELAY= -VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band diff --git a/web/.gitignore b/web/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/web/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/web/eslint.config.js b/web/eslint.config.js deleted file mode 100644 index d785258..0000000 --- a/web/eslint.config.js +++ /dev/null @@ -1,21 +0,0 @@ -import js from "@eslint/js"; -import globals from "globals"; -import tseslint from "typescript-eslint"; -import { defineConfig } from "eslint/config"; - - -export default defineConfig([ - { files: ["**/*.{js,mjs,cjs,ts}"], plugins: { js }, extends: ["js/recommended"] }, - { files: ["**/*.{js,mjs,cjs,ts}"], languageOptions: { globals: globals.browser } }, - tseslint.configs.recommended, - { - files: ["src/**/*.{js,mjs,cjs,ts}"], - rules: { - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": [ - "error", - {args: "none", destructuredArrayIgnorePattern: "^_d?$", caughtErrors: "none"}, - ], - }, - }, -]); diff --git a/web/index.html b/web/index.html deleted file mode 100644 index b477b77..0000000 --- a/web/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - Anchor Alerts - - -
-
-
- - - diff --git a/web/package.json b/web/package.json deleted file mode 100644 index c783cf3..0000000 --- a/web/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "web", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "check": "tsc --noEmit && eslint src", - "format": "eslint src --fix" - }, - "devDependencies": { - "@eslint/js": "^9.25.1", - "@types/mithril": "^2.2.7", - "eslint": "^9.25.1", - "globals": "^16.0.0", - "typescript": "~5.7.2", - "typescript-eslint": "^8.31.1", - "vite": "^6.3.1" - }, - "dependencies": { - "@tailwindcss/vite": "^4.1.4", - "@welshman/feeds": "^0.6.3", - "@welshman/lib": "^0.6.3", - "@welshman/net": "^0.6.3", - "@welshman/signer": "^0.6.3", - "@welshman/store": "^0.6.3", - "@welshman/util": "^0.6.3", - "events": "^3.3.0", - "mithril": "^2.2.15", - "svelte": "^5.27.2", - "tailwindcss": "^4.1.4" - } -} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml deleted file mode 100644 index dc6d6add4e6f81f33c0ef9c19c53403b022976f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 78215 zcmceO+Zg!@_i`11L8^2*D?BoD4Z+a*=`&kuiRKOukr+uypN?~^j>{`t2LADp4D zq_XRsJl6y8xqp7>+aUly2xc!I{6J31tP|U+kUzidpC3Xe?}ESmEh*}%?H^zH8~FIA z$ICu^@Pj%i{h;)c;N|s?XMg{#canTcO5f``cue}>@2?+z{IIJ7FA0;N{pW{&5ueac z#LH_QKN-JB{qp!9kMI3PeZoJX|M;z6>Y#(m$`5|SuumxXz`y+?P5R_F>=W?``?r7O zNfh@bP!asX-_TDO3m*R4=NJF|w<*ZGxNu513pdFZ~U#-74=lp&1HuOpU zR|et_zvc5(24hh6Uw72^+Xe@l0{P=o{q}K@^l_WyKi_uPC$NXeKfd6*=iM0Oeem-a zke@L66ZXd!eE0kt8}ReDytVsZ-t7nD_NV40=<=lO;q+H~8Ek*@6Z%K_zIzU=)QeU5 zJXF7!yvLZ(e|*!ok6)$vxsKoc#&1mA>+^4I*yrC7<&_J}?a$waQoz#uWahtn{`-ph z=}Z3C!wCI!?tpFjEX41x`1qD`z=EMa8|K&N|9JiCZ+}$vCpPi(FMinkpGy6&mxJJs z^YC9dAO7|?V8$~idYoI}#ee_JDeAhd(!lHCHYl9B2ImPlV8CAcyf)gPt8%c>A1CYo z01GgP+N59p`Jsy)62(5^NOJEvJz62zlTD~5)Tn9-PmWJ9Cqcd&crLC;Ir1V zj!NS}n?*KP+%E(^6W6fuN_oDq$2!6dQsxQUp8oyc|MNxdpae$^koCj=cpQV^X#bCY ze_0KG|Ib(He|GvjC-=OjbKOEdwoYcvJ1UYvJT)E9_w^=nE~{OIOJ8U-mc(fbtHQRD z$8MJsH#tmiy2oqSsx63%StQnn#w`_i@sjp0a=#2~(78jB`>>PDAM4tq2ykA4?TI}n z+{8<6rODW0-IoIMqJk=NjwY~|$k< z62cO4V%n*PNw@A}7dX)ygNia>L?7;mR}cUE@ZVk}{_igjDlqu}4QTP-UkCiFUZvkw ziT}}Qi+Acq-Z_h$wZa7MOR#YQRnSY_353CkNnG-=-Xc7Vzv}qGF*I`zr9@bUdp>JX zJ+%~{W@4r5Av4dyqj!V1x!E8I@z#R*LAMu)zt!zklV9uiu9;I$@t#h_uD{yk%;i(C%s7=6BAV7 zwYHSXmt%>}_5QxNh1T%5X@jh%u}ghn-)HYwl|bG<)y;QW;DY~!WvSE(C10+l#jV(FK|P4#o8`IrKWkCaMBDRlmT4%iO*#A0Ac`jt&cutE314EHD7Nw^)R?pa5Z#N zijwpDChj}+UKROaResj)y%R?UhTfVMJ&DV33mCBxM16;O7X)&L{&c#ko=J4qq;68# zKBZ~QI1Dsi%rM#<6^7B=4MRF{SDKSZ>u0H_^c{4{-l5y~6Zcci!Zrxp?yuOBLY@j@ zw2sLMyO?K-fTUtcbU)dr$vu`Ffjx~p-Azb;r}mYqq&C-_>fJIVVdlQ*_ui?anq?^|Gj*23dD_(lb6`)ly*qhEt_LVzv%$@f$DbKQVjI?R7z z%S8>`rE;e&iixrkQ@I=IJjRus+$GN0i453a)_@MoqL0i*AouyruG}K;!Xy=?I~z;a zot?S97z+)LsG~w;s)oG7p8R+cyy)@6ynWT}U8lfhzcHeNSEPrLtb&us2(@Y4I~dK% zhRQ2>HoMfr%UNRsi_^EVce{~fn77K+W9dfJ-Qup}Xz!rh{dzf`)}y()@6_xYOZjWP z5(mfLrI@?1`H`*M=!SV49m4LnFtyDK6q4R-RJ+}YJO^9X_C=n%*Y>Rq(=@cF@lxxI zk{?|iDW^LbE!qV}oXdJA_cwhQ-?1DY0zai$4vy65o#)$mukMm#Kb9BF-{cCpWq8XF zeIKHNKDx8M6*@W}9QqZ)X7DYOTu0@ zTVeH(Quc^_U*83Z+&GX{6dg&B)J)zSbx3yjK+iGBX;hcg<$-Mp&$yj7Sbs_NMan$t z9rol$uld*eu1z{`eCO)S2)VDLE3F<*T9LyjGUykiBhH+)E2}9Xo6R|OY7k#|_65!DL5e+v~l!90fibKsue1HkC~( z%((X`A&{`!jrCzZOEgwF9S}*oSxnr@@w{`}3LZDsVRTMb5Q5`FM^>afT}}T42kNpD zujuZ4Z0GPDOY+A9{8f=3PVmp#y?5Hi>Ta@ksIRrT9mAuzgj-wph^-P0mxsQIN!5u zji*#gYrG{3w)rIaY__4aOux*U&|IK4Ywc@uaF1B*L^n-oJ9F15Ed#YT1XCcN)M`>y z^GGSuHSioGq|J9I_v6HU*5rqo`=Z}_r>=+1>^3=Zwnxk^sv7h#(5Eok#ax^1vR*J!*SU&PNpSuU0<1V_AN`<9@DNH-KWm z$M;@8^M2tC3 zCY$eBKcD4>R8E^sGz}>+kAFFkd(q*CIRn8rj{SvLfR?KzjiD{9&TyxhD{!JhcZ2T^ z!`|0UORKeu=-)?uFNXwXW^6KUE+iND37sq!G*hixVoFRgCebwLdD|Q|CyjiE{r_=A zzUc8&-QMjp-;6`lt?W=DWph6eN*Fut>}*-FXO2nLT=0XEl$tqKu4_?>cD^Lhd6QR> z&YU**FxQ7|C!VQAg>Z4Z@f(56BlbOMlOKHMUu*W>)tD*x{^+9ARRU>BDY&)@xgklJnR4o^qgTrSDz`w01-T-W#e z?0dbyIoE&ms-hT5kgbMav+^Gw^{VbS0u(*b=Mu3;vpXmSh&tU?lmlGe#)!^iqE&lm z`_RLRoaR{!v$9YYM_WGIg$X0VjXm5rH4UnuNQQe`R8gNG{f*>fs8KKW_LrvQUnr=s zV4si=|MP!-_}8P}OTYg8KmPr-*72V&8QB^Xuzyw|`dqa50ICL{Uh?t|@cl1uU|xzS zpL5ecj7|$&kh!kGzyJZB92uU!^V`GV4;xmyWMXDxQdHOKT}iNfbBeZDvrbkfBq00g zMFCbPsSP_;!czP}X@;#*;^o25^()Ge$MzstQz)zJFE{LCWWFc`2Ii|)U&iGdy+9qh z43h}hpw?L)J}CI`bCVs;`5DbJDV;(3Yr)Rxz;9Th!Q=j{wnJ!}*5FRbMhu+O-rZAD zoe+2=9vW=r6_6C$$5OG!t)fNLtUGQ;FTUmV=?#@$%HgjQ1%w2kg~y!z$G>Bsyz~kA z&oBA*$6S5UDGu@)-~}F9Jv&c^gwlPt#lp7V4lwQ)5`8k-E#U^fdejw!Srfx{c#?~= zUAs!$A;=3hJ6uq6Esu~>&(sz28e?Vpuoi+HUP((jzp;Yfr4#@RAfM2uYfVd-McWBI z8Wh!LCdn~gLL}|FT{@K(!4Z!4{h`VYkg{w}NygopiDOdodS9Fl8q;N?iqe5r!o@wX zb*@OVgk4(x|BNoLo3HhLd(@+!+(J<=q721bv6Q1VQ6JRZePV2nG5~O+3Up%>5Q{}C zedpriov9VP(a!9RRd>46v+|lan=5U#hudz+)svO&yi&oX|1;|??LJp9pW8XUAWa}4 z&#v!4)p&t{7~^J%HD^(GC4IHLyHI(zXvuTe7fMjWr#wJ9#= zt+>$=)Ew?~^Gkp7UjRh{{(ElcRPLj;`LXk2P~8NDP4pA>oKD6@iAd3q5ebPp5>?=6 zICCip2Pknp5g?yXp48hkY)y*NQ9>ecsW0?Ux*M^^hL@|PYuw4Y5VX_h%XEjmwE~H~ zU;0|_(&-g}^D#^M7fZlAr_i0NI09)^MW40sNA&P3UQ#rBssynM! zo;{v`vjXid&TctgeyI#7JAYntUkcA(1b;zi0l}$fmz6z8*nNMJY34-k-FTa9IMgEK z!uBox$SWr)oRE5=>tuhJ7DEsN_i!28lS+6>*Db=CnxPTsIHSB&JEzOh_D^^1mp1Gd zf=_YffqHre7nMKc!N)HkH-I7ox;(d>+<6SH1n|>I+a*d*G)=tK&(VFO)qIwQM`R(Y z>-Bn=q+xsA7|Paa^~Ew|mSGaoso8>c%S8~errva zYP_D7L1Npai~v*T4U(70VlVES!aq#b+rk4(-*Y2B2O3F>wKNblY9#offd#7}_}nNM z%D;-(3Paf_mP>QB4QDj(g(EtbjeFy@VK8H)f+Ko+?lLO#u(3T8*PN_$?U(1qmnHN? z__R&@C;;ff9FBtW;}^)tWAL9eL`lK9n_cEYfqPo!W3-MWNZgi%q0QA+v-UBA1mo;i zma4TQS6*j&O^Ve+wTg^cBM+~{s9TPJB?<|$eeS67e13at@V|qMd?Wu0v}FuF4s8O? zZGfPBZv0(CI!C;e>KFp|?(MAK6d@sr(^WU_!ki6THb2PFMVc?ie!3nwX1bwf>!PXB zp@wKxD8Dh)J50tk4~mn;@#a_c@vSZVEBOJ!^|PA`WPf(w8cZY`<~>+y1Ye3(hbcM9 zVT|c_!A@A?8EvqPM)2eAW^fXE^+#Q@{2h&44<1%?%7m*NbvVjo9E$_m9V|pCOZcyh zepd~x_kbLMIL!|{V0*}CJ@shB?}j6tFL;>hT%>kwvN4+7?IcspAkGM3EBXyyw~VF8 z-37;rnCIQl)u`iLl+)3!`8%^NTOn$?D1`J$cNBW}vIAZMiEoxj-#T9K-6F@x=T^z6 zW|SjLi@WQc=~gVTzE0(CtEB)ta650YsTtGVj*B#I;&->@Y+IpS%{_uhhs+cqMs`Vh zJ83S?IY`CwTpiSj{kx21<>mqGC3XB75Rjkn=j7TT5G6_bq!BuNqbnv7dq%Mzg%msO zHFu@-Y+Fvt5dF_oClym3ltu+kQTA8w1<0xr-j$tJzUY@2_`t z8sqsyC^x4wRAJ3sVb7%eP2{Iogf%xMxtC@6GqFH4`J7ml?d7c+8IHo9yUk^DbzMfG zw&s<-?+x=fL_-@?worOD=~3ifD~xqKC#HZeByT&qec<5THEUx_s6>E(vmI7fUaKxY z6N~*sKPOh?9(SH`0pJ=AhgY6go!iz+OhuFspU=i>z)hLC7YnI+bawzwD3bPCaIZ{9 z0S2luD%LaNRP*l#D~M)WAVn2^E2;x$6Z#2%jZabPb8PxL)v?pXFH`;W;VZb!ly#?R zW#_H23%6&Jp{7{hL<&U;h_;7oK5A~rZgX8V40BZhQU}QmOqWIhx+6TN7u+#2f)*~L zCAp+)c0qZ3%L08ssV}G3A7p;g<||j85x7%LE2H2QIC8uRs+dLwq+~@1vak$m9n4IW z@~Mh1WbAF+61``~hCW`+1ltz@t}8ncOOT7UB89`pQ!+pQ9E#9jut-@ES zJYTD;-&=x2{BoQh39Fwj5iT$3ab;=jLD_^9_RGK$ zxH=!cI%yBj{)^zf(*vnNzr%9_Z)CBq*Vx)NTkgccSgOY5+Iu>egQBnH$5Kr@G(i#^ zS&b6C8L~O*X~m)kf8qvwjnJ)EgW_(x;@2yB4xDDR3Fwyt|0}5{C(j;jAZVHOZ6nk8hG}dt)cT+gK$&#wg@eb|G1*vtEA>CjX(Q=-|uzKE~ zub9PkXkM;m<(;1!>$b}Cp@v`o_y|_X7i{Urz;P3hmn5D};z6RDcd(z_5n~&)vA|>t zPsJ&>*R0Wj#-=l}FbHIHNI#V}R2qxy4E1c@KZDNX60g%s)#LHo) zlupTu9b#TtRzVcz6vxh)&pN221ou>lqkM{NZD-B7vO5JZ_0`Gzs>gSj!?!f~3_X0Q zle|lj-DGQ?=4AHX56d(?PM{LLi%Qa=yo*IchJ)<`HHCM+HEP)PplKGL*miD+7lI#L zQK4vnSu0jqD>(irgky~#(Cn+_{JA3EK@7j2kLA~`%V$y^JSfkw4THod&eP@f5nF}Q-W?}FEf_)_9Xz%1(-CLDqUfl*dK|=p^ zOTTLIeLdq19ln4G-#-~Gz3h@Ru~*?NaYy027Ol2{_4%m8x|y$#=s0yh^$s}J)=S(ur(wh)vC1Jl#l zn&ujxRrBnmGRf>HOh4>4_iGOCrn;aHM;5Zjd_yB>>z&Lsz1-15bg6W+tbWhTeVvtW zY}Sj!KebrD>rxC|{uh+lReYx>Zh!R;qCUbKO&T{x4YrEuE>Dhn^Or4S9&>AGYGe#5 ztlJ9^2amOKA^~8!H7+YWI8{o-{?0dV4f0NEeaSX@fMClP=`-p21FZpi0|QdCIvZQLmw)hdMSY zBsY$K)Z;q?{G29_iFubQoOvI4W5;j%5w(e^-bz+?hNe&q#6g~}^K#joWoJqw>xlJ+ zK!s&@5;B4Cy3@(N<(s?{*WOt9ax>npq2~lJ+RT4qg8xjF7qFw#dhuVFnrX)dnAJ`R5w?5wy~SADao{n+-5A+TuJ+yIp(sr$*t=aD>CyeoCHexJ)V2D0B^w) zf2Pl8z~8%d84j6k4pGzV_stoTCg`6bl!0{!ac{Ugc(SE zxK0oVoB;HO@aLhX7w%BwDKgAw1-=G~j8Jr?Jb60DO^#^+4HFz7b6 zjn)`_M&tO5*JO9A?@1nm0$8>O09ih|b)r}y{;s_20uIvdsgdK*)o!5u!fV1Xc|UHR z_p9@QSA6%>oa@rgx$vlK)wMe{g()78p_`GFA>4Hbs*6s^@d$UvTmM|)T?v}jV>cGf z`~VP$%Ct5dNY$mlGlSr>f}`jIjdK3s*nA5~`Ll@u$LzcHFud(f=^K$1M3qS}!&b2y zL=n8dZ-)cf>P?&56XZn-K4@3{+?615?`W@1t$=j{c-hv>g{QA_#aBn3IpD_<6Myt6 zzd!!}%$z;6c=yuU-^a90F2z}$$yJP|{=@+kq_cBY6EkjO6KoSCzCv8jGeg-lAZhQ2 zsXm9DMm2hWG>ix%&q>GexAK6~c2Y4t$Tfax?s(lP&#LhPpLqpteJr}sd)EPT>F=kL zL}swd__U8FEXdw*a1VhxX@qp zy)%;6x`29esDs+hdQFeYj3RZkDUO1CD#e4fkH&t__ru|)jf^rkpn|WceYB4@Vn_=P z2GhcDog6l-{M#k8xZJwS_aA2D`=#~md~_a2i^8tc1urS!Co78gS&O^#GTW1UNby>} z^FgR4{oJJpi}x-zHY`%qSU*>GuxVT`-KLEgFA5V}(gq&Hf?5wyaTywb;{p45INPXV zzKs1#uJt>iuxIPF5nL9e7+o9~R`?W+t_ZuD2ardljvH>@$SRA__Vv_+^vV+~wDmM% zgLV~hw-8zgQN)~DsvH_y*t0t!ibRVe#Nx%*_*ST-28}o%!}@Fyr|QNru={28g{#}+ zS~yLX*b+Jhu*fnwpeMW`G<5?o%kZVv2CL9=H<~;3mE$3_hs` zT)kDf-SC16-EZAx#Y}+QO%GAz83Nm)>&5B$8xZAw7t^z`&=H1Taa_USI(1BPv`(+S7N zK=zoRhPLWL^EZ{mWXLpk?1CO~mY&!$TSrKlpyGa$&Pfx4R`@SR+)wBMKA;H5_5a?= z{*oj4Ci4A0$Mr`|f5>w^t>|HAq{!J8hwZ@alKduus44b>IT*26JBDgjSrBqE0W#1A zfWb&)uQohhm4+=CNptN|E*-?mBWUUQ1QS)P)nw%D?CGB<`d$9$mwEyl4o>=~we0+D zAgkb*_Uz!+2?t`2ZpTzxon2CO#K{GPZTTu+JH$F6SC(|c(8Qt6tNYd7=}KDA^GHCA z67$kq-a}78WBd~GseV6O8(W&viVo}BGX5-)p zt!6$VO#tPIPRDs+Thj4{plh6^Sb{TOb~EaUeFaPCE!K;K+;3+K1zXZ(j`k** zvb_Xy|GXJiFz4;o%HjaDMh?FQ)6ebDZ}#W6j`kb6ehnI))-whnvO2oOg{M|Bg2B>- z?lT?I6G~3;`6?AvChLVxg&}%s%&P#I^F?9x-Jo{qe$2*vf9ZFozb&TEx!=P#2zZ)^HFuy|Tk3+Fm)j{8VAZMYx<|I+(@ac5e-9mdVr&$g^gtk&KAJ%Y+u zbjA{|+Z)sDUl3oBdNsTj?Iu`|Jh42{{=6 zn<0nl*R%YIDgE9WJ+pKlTE2HWN2r*u(H_f+p@l6{@!ORxKzC<#OF>xRQ`7V=SvsCcdo*#=&!cO9ZEkBz-jkKqQ8%THK}y!_JMHqz&Sg|Dl4}tZVU7(d!fQ`jBiWcEy>~=&2XFVwe#`DS3LuHOIgS>NkU$j) zUKiS$3C-~_cp6@P>4y{gZX@!hn(x_BH92XuwP=T#E{-v&STR{0Gbn4kl`$cg!&jKR z-fqEZ65C?fNd0bF85`sG8QdKpUv=3HQrcozB5ewLN5I~bI)8##d3fGnW&V)~^F4AC zms6m$n(B^iXB{o8hKITE1(iHV2iC~;AlhC#ic@ADKJqA})&SeRaa75xXj1^~Sbf~g zceiKi3!KzjC_bhI^BUSd=HpiZ+6xEUhkpTvuFri1)Mrgz*zy8Egc&??8OXB$tqEhK z>U`l3b>eSfb2ldUX;KberV8i7-4X{{-P}q0Kor8IR9oj#vUo|!ny5VH6y&bOu{3_N9T_S+40`To~;aJ~l9>H-JGZt1hc51yp`}71llateN zH2Z;caT>kMmTwNtD#ZxbdPY|pI+7sQWPoI=>!lOAvQ&=(hN{_Qs~kapkI-Ma1=Fr7 zzcgGtI?&K3VKXEY>I@(X5mg@I7SLWcw;Qa^?trXlZBDn#TAzbq00cb+b5%eJK((E5 z(^R529~t+w5~t2BiRvNDZeJGwoQs7vZ%{LljEd^&dn4Oib%GyNC`*j? zxW8q1!t_?6!D*#WmaN#L$eN~z1WmM;hSyGlZ*48I8KxTXusV#qvIVQX87RYf{ z@kC_Si55_Ep*ei1MJ+%i?{H-_SOHm#9m{ez4770_+Z##UdV*MXER>{-=rk2Ti4Pm= zhoO35mHkZ7_@%M)GbimU2jg?YS6%Ww5u@D386`qQtTJcdQ0o;L2Br5>!Z@_FeJF%T z(vpx30LB_pbFBE}epD-J+c)g((@z3o3pZ52>Wt={fc7CUL z?X+Fc{{1DV^rbWMrN{H5lh?8Hl`!*bS19v|{M=6ZFOY-!`t#qjXfN!F#3$wx@!|g` zA9*Ukq5NXZAGYQvzZ=l`KA13{9c@_a?d#&lRyhoJuuKQm6-B)`7aQ0O zVe6(cWSn1JSV3)9^LcTU&eBOI&i;O5sy{$xx4y3t0Gh#6E0YQ^6=|(&FtVr zJ0aw*+S`^pPYgOk%h=K;*Mf$?f#;~}_2@P?`+9G$q;iE(uJJwAckR0pV>aTC3H&(+}i}B()y=PHm4u=eZ z9AG1T97zM0sfe3cg7dbqnaN8nzX( z6^X(fKx{|InLKW&Z9Ey!?XL6bn%#5U+e5^Rc>ykTGKq+ToBs7l|9`zp#-w}UozH^h zK_UPAnIl1P8#UT>Q&IpaNUK}3;Pl%K@ZJnfd_`#@5ABgFK)0@Q3z8s|{b6QdTf*T| z9wMP+WhV5_31SuXF@alh0DOz}f6;V)&&m&476F&@b0_GD+zxdG@h~qH^hUWu5WR^{ zei|(U-d_mkh)pPsc{383Vt6*2<1DuB%_4&7a%&nUm{AzDd?*gCaUcMe7((Q)ugR{L zUHnxrkWYAzSQ>?6%i#p0(xq@&ao`~hu-9{e6$@ z?*P(V_nKUd&D+!-WEk?plZyx zH~5jT6g%F6@`NhdDMzjKx*rrKxe`{)lrrfueMtpWNCB2IOdyB5659T)>qn2LwDgh(@w+mfLNg7nIk;Wpe9bNZnLEb- z)Y@an?-aSeip<}jV>;YBou0UKunZScB*MYY4E zfUsG?PZQ3I2p>@M-*t(N(S3E&H*kj;p0guFTaUA)1Q5>^L=St2+(`l8(0DNw-wXCI z{_l}1OEAz1w=L#ILR!}^vj&;Yv@TkvjrezrShUCs7Bg8V5%zn}I2Is3vWyCZ*;C|3 z&PAg(eRE8+-ow*2rYvXHPG91w*D`wA&ESd-{1f&{LH{Ro?`^|&&_RtuMB?ML--6bH z{6I$pTHQ19oLha3$opyGNH0jIk{*x65l~y`Z9)MYraof&g(5qy3nCZbHW^Rs4D-u}zya|f&Us1~Z9KtPec2w1DC8punmf}%r`;Ty z4k(cCub%1kH{x>G5OLxjW^8ZGvqoNuYqX?_1Kqb>;)LXMlP$Y8Zc4EMMTGs!;QlNE z1f~lt)ECS3_%Udk+a!Qe|K%QZ%k=T7);e&y9_caGx%%2t86UzTiBDG~8?VU!@% z-1SQi`uYg4G?1r6THOuX@(0dDTQ^OvTNjI)qqtXX8MHTLjCwKVJ1*U)EiO`OKfBib z#FyncLFH_Qq-_=ZdM?%&hD}#amg3-kIGu&JhVyf)meWQr!SG9m)^jt@KYeI#TyF(2IyTT^I zx8u+r@t0Nnn8`MAJ~l%D7zv)=SG;Cub?H=2jS85EAjp}*T8g8PVSg`+e5{H%Tucnm z-FvN#YV8I<7B7HmzA4Gc`$V_-49rfZ$~Ge^p+ z%#WhYa}(Z3DY9dyyQ=`aOez-V;5=TXR(L!NEMQyOx zxllIFL%44YO}d!bLa#`dn&d(hy?LVREmuE9AL!j+&iyPq+ot&RFNog96V za_KA_V*5hsUkttb(ku?Bv>dR3suG9={(WvmCOn|p-HK7Wx9B}K3KC4t^@`BzdA~sr zvj%wTxUlvN~PIi?hBh7iBN_f!fN%MeRMl|w&+r5vrE4CBR|UZ@SvU( z%PKi(JTZX+Wjy6v!&Qta&oOmgSGP=;M}Dyk@L2K=zU0?P)0bk$FtD+ zm9icK{b+eT9*tY3l0EI@OY3f^TT)CRVH+^u75VI;n>7UO<-a4#f3n~{TNscy$w3SP ztl@(t;$GKQ1grbq#EeKWJl zFyZgKYFN89IP6F;{hs$&Td-iskw^<@ZF zCfO*(A~;0prUZb(Eko?V)52yp4d1T1H-SFTD@Rd`J}^e6(GW<(QD@oEf@Na)`h-wu zLKMGD!94u;&%V2t1IOdR$MoDv4D13q-8TKD-Q4cC9pd8*rR_N2D&JX_)D!JmVH?^_ zZ)bNO_`948ko(PPC^FM_(dDcLogi(q4J(~p6QDnitRM6Dx8(dXo}dl*V;lG{Himd= znbOVS)=0g~G4NHtg65N4v@VbeI33|;2?0LdIe3F&!dTD-ICgJFk<>szz*oSvDB(yA&od?FtCXU19}*;nJF}BEDXW!mLzJ3wJ!k8{0ZO!WWVuLM6hP`<{1%{P@;- zeBmB^Jz-vDE}h~P4*c9G`q~2F+;GDkY*O7jC{8H%+VrAyH^>)w*vRnUdUui4EO%Qm zVMu7qa;?cthy>vobj~aO6&l*Gc+>V6b zP0I-&YpZxyuWUuxAZ%Unb9>n~n>`5@zkwbX_ycG7M-R^HZb!5pP#93ydKV4Cw) zB=-?$qT4%wp)oOc!m>4!!(o|`VIk3z+jNE}k1<DJ#Dib?qSEow)9IE=U?b6KOFF=9}JK$LBk_50N(6p8VkTS z^O2@t7bLX3YWtv4fQ*0*l*gRuVKTlBBXApANN#tz%l^)07^Ok z-z!Gs$ImN4@4V^=ZrA(%J75vM{!`VD+rFIq_u#}2g8F0f;GG1pil>IClJqs}3Ns>L zrA-pY-f2*kSzD7{`x2gTq3%Z*iq4X+_(w3S$UyLbX-+)o3q+ic%D^|f37h7UgDwWJ zI6dRQ?iUZ+5BdkEAIMaBFHS%Hlt}KhAj%G0wBhau^xDkxQfr-m_IHg8G9(flR^Aku zHrTGNe*v^?vmCL!xb6_9TQR3uvvNv+bC%_ZdlJ+%ViI|?{Br$)lkT(k5P)R+i?8|; zT8{yU3La!~Psw~g-|)gAcbRwENcD6#&w!MPQ9+G`Y&Y9Th|uky|*tB*$Km?m?($;D!m} zV!6lROoqqoaPyb38%0{F!2={^ywHxFv4RN|I2~@)2azD zCZ84gE`5&!8~faDooWxjO@d?rO$@W13)yA8P~Etk(Xf>?T#4os4gG}%s2Mw2qrsUm z6&D?N3p){dYh0~veF++DYEmwsvhQG^QuOO9@cx{6`48%L6Dy%l3qNJL?08@ZWlm>E3*CQfVhE2mQ~uAv^c3euc<7}^|&Ob;aGJXuo5kv&Qw|H*cYH*1Jpk9J`M<(u`xYG}kE#E|dlC*;>x@{DBf2ahX zJ=?~tx{lZmn<*45ILiQbG9<|q1I0{$f%OV-tE8)=W?_9rHU(Y%0#17{Fg|D7kdw_b zE=KHcHnRd=x2-8tMzRmLE_W*_ym1LxNzpsuA~eMjLm4nAv;s=Nh|cMvgb7B!m`x?u z{n-K$W0jQ`hR=VQse5iIz9ydlJMWV^cn(`npgpk=!C5nkGNAA{UXCns2TfaUveQQ6 zpt@27r7`ad@3Qw+sylVR)Zrz0`@4m1LEHd5 zp(mYm5e8lPAQ{G<_cks8>a#7e<*d}~>87W5puG}@86)P^TfxH=4UoVj>DLM9Em>%x z&Yetj!u*ODu+BrFe+JFX#-4l4?Y$VKHUQn0pwsgCOzp7UEEPJQ8s9pi~trxI~MxrpKXmavxEfWt7U_C?kw6m0(5wt}&H zO?$sc3kVe-SDrt-pl6q>qWGLKT!qt`sAh23GTTT2hGQ~NbYq<0$3~;M+1gWg(0|!DpbfK?ce)-%DV4K>ai?=*SLI`dEZ9 z;!c=a-MrR37RBpLR4-%r3L$u8@t!+Nvuo!yRz`s9k|^eUNBk@jj#n^iOp@XVa@^d^ zLxW>F?1Qc@^E@31^tPC@ldEW86dZYHFBe$0*exyI&c&5IbN2k^#aO_J1b)_MC-MtO z_1WnzP7{QM*~#VP^+bbvgKb1={1ULi24=4lEt0G3N^_hu@mACX4()D}1OwUcR@$_p z5ASD0!8EX;&nSxWj+k9M_sv_%5P*4`Uxf{wuK)NF?F7x>T7O+dBlUy7(Jb!_styUgY&fbx6H)-@nK7yv^y+`hCb!EMs1gcMYz2&87>m)ts zpTjxRV2CdP&x=@(OR|AL;NPdt99pS+nYkcd8N&&)Y>-=5imO zEn$0T_Vbo5I)m9F179?GHl_TPna64WC5`qnqWR;#`7Z8*ql4+a5i^IBVAJ_A^(gI+^;99fZ z<;l460h7SDay8&=c`%_rHO;$8HkUc$trS4!8YwnAMZ& z0KP8K&U6B;e1F^B4R(j9o1<$TL17s1abKj)wqTVSUvFdtrY3yQHBwLe?7CtVH*7({ zJ%b^LR6RCwENS3*qaLaiKRPSpUBC?%cvn=z2}tD^ zyX`I>V0$x^--}qka_s5^-1G7%0|6}T3q|R_2q5|&9l>u?AHRR+<)e>}|1s+U+Ft_T z==JUL(R0B40U#QCHhQ!y^D^-u^l`|_@zkB6v*%Fl?TyMQ31sNgMl9U?E{K54ubcMv z=}f`*Fr&|)36I#4DbDXP6tz{utI(~W01~M?|8^MsQX2of`;t8W*g9ntfK1}E`E|S7 z)d7Cn%SYyJCq#eSM%ZD&1J_uw)~crMT;@LEW+zaN@;n&5PXM=F@<_t2{q_KS>#;wL zj>H}vc{#Qo6mM?G@}{}?B5dh@9BSYWJUg~D11hj4I^}@REi%__M|Ww%#_NiVplN3< zA$nTCRV74doo2r9izeK4f_|Edgg>7o+)2eNi@Miy2d=|y4@PpM%H5ltNniZFFb65) z*OlIHnDT_2jnTmS*>6*69n^?L*og)GeMrF?JTTbQ7U(_!ZIDs@sF^ zxBHG$g+b&Mxh)Md5f)bQc4?9ECwl@%1P5C*L+#)&RbWUgFWfs*12*wL*%tI2W-Ew z{ng#+1R2QF!!2Jo;O-YWSOOpIcs4FwPxDw%h)@=VH1rcl?yZD#2G?2qG7|vY?+>4A zS>KBLuouq-uhu+C0G=0LPtmQWlC2EkF48hoNca?IQS13alKXH&EIuy`mTY;X2-pEM zaJhZC3;Qzxy0QisMcbY@9&}TQ+^`sbO(P7%{VoUjklD!rmH6|iyUK}o;{X^>bA5u7 zQL;Db_1xaL@Fs79Y#@8Uje~`nE+^1!ABNJ+9by1eNaN)cn}8BMBn39z3VTA-3e%44 z$vpyUrZ@U!zufHbRn`|{{M7NCNaYPBEUD)$!9-C0BsPPuKT0~Ot>m-`os5$ST~yD2 zlT+!bu!vmLI`U~M5wndRsVA3!EL^^_y?n6ODTa1A{#I=BRn`YqAsq6L;RE9X>afq5 zGs8WQa0-HMRpq_ym8{snEI0B2-goQmis&@%dXN|r9S6h@m34-Z$vNA{rH#(?hVz%5mK-d3B0o_Z!QZ|$W$TD*33#D<;GEP9P)!Hx&X zrH{alK59eHo=(D&9ZdLy9#iuMNHK;2%QR?=wo{GlSm$eXkoJLaNs~y)yV4%)FS?Ob8p(Lx~(*f{+0bLImO+_ z44z2Uy%pwpHsd#g4L0D}#y0Pte_N6bh6P?`<(%ia*-0fa8d_4T)pMI+W7W@ZDDzkk zA@o8U9H+Hz@nGfB9{gXCzY!@m^BdAyI0}B9vSFSuE%o-cuo2e%ag{yJkNbcfG|S7~ z(r)+5j>ib2ZI9lZ)`zOJgba60V`fY`Qo|FxDPJ@-H)t0jqOw(1pq#hAw+m*!YNc&# zL^M^1x`)336_gL`UtMiA*nqRlrLOhWc0)Ut!_(Cov1dr#Z0z%`77fLvTVi7`D}tM6aS)D`x^+^7IV5Q4&x>QC@k8&>^V@~hQ}BM32~VXuzvaM1 z)Ch}P+4gs~?QJW=(~!-V7Ak#gY%X-UFE6?S9U}64XM5fFhTSm2hU&S_5UTgBrR}K1 zUiV6?%T5|~bF}F+Pjy$fnC>*tF6R;mfNA@iR1yHh7+iGEvWd5We4dG6yA3LyLxbBa zm9xs3%MuM~KZj?2bi6RVg?8%JX{9RFORmBT`&0R{7M=ber>&NwjnX_FHv2&7&ve_L z+E7bJ z99_tn`i8FzFW>}752k&(H0eQ7=^khaE<=+YZkC&Q^}KDZ)&>Lbz$oKgOc2>OgiREO%$4^wV}Q=$qxU-fOqoQ~gk$ zG?i&>uw7Sn3sYyyjy7xUtkS;32FJrXOfop5y!I^tfi!=ke&>EUc6&+9U~ zmZgPMXkR=}HkpdkIa|FPs)22;g|ORVWF}u~7K4r_6wkXkYuD}>HQ$N6KJcN)!ViEW zV+Cv?JmHzJ&_0x}-C?dp>qULvJu`!436f49HYaQ7a`ee;so-e&fLygMf2>qa=WkW2*FT?%#YS2$i9J zsL8#cGoPr9`LG1GlF{i(gM<6}BF&}kuwRC!Yd3&gTD|MmsB;2kl}2l*Z8hHX=M7e> z2D6swDU^HWbr%)F9TR{;QA632v-t4>7kDie8&;F#T48 zQz*S8Uv@LfnVh?;n%ObgP~%6-E+a449Lv=m3xa4wQ2;;4YQ-(u*S#T93?GPm@&V!Cy!xAH7{?^`N*qIB_7KvxoG2AaIv6(!E`ek_X~;worRIHE!{9O60^E zP<*-9ADnyO(jyPsnzZGtxn4aD$9g|!ESUPSJ2&ztYw2|7qPcMf^bx$2I_}o0)-UJX z?Ha$}$9|#57cT)ZY$O-&b1uS!CgIg0A)5{0ZssK(8EYMgS~&6PQ+>5!6kV_N2K$oM zKesO}n%)=bNvIafEJLj!2TZX%SX)PX0gk48Rh7bRyJ|wby++r$`jlB%wO)NS%crUp zUM%r3Rn)r$CKL00utL9n;s!-`!0{cGmrv#OQD5_eu)H2)9xq0*yQZh%)&>uQo^Ivm zd1a#qOhs=wUbB5{=|iDF`$aa@y!~pafUhqz^SxeZl)vqRMfQ3%b9B}e>3Eq#N_eg6 zi9HBr%o}pOQ?2%!>YQonR-hE9`iAa~H!3Y(8f{MK8MA70G#f2ST~2LiT2=HZ9!%!* z%d~e+6FJ^2@*&UEJ&W7hW#R;GAFOl2((<6NgVM zTDaWu+F#Ok4^mAH!u9YJ=9)nB%|7Ig1j}}BG8ffBm*=j-QN3f^>E!dp&3q<4sp|wJ z$I19USY-afLaL+M7+m4Z{>rVksp^iPMDz)$D> z9W$es>c%h^gHdyNR6A}};KbR!zJhpQ@xl&h+E#Q10wsZ0t>*lJFa@iH6`bUC!=02Q zsmzw|MVbDSYb5#5@R^e&=pUS<-si5sK4}tYy@zlftM>PmfDlbnM!3(x+9vscb)eNS@L4o)_)mT9S1l zbj3;QKUif$>dZm-DPHAKYC%yAG1^JAsBUF-(Kao?X!^8D;V%?+u#a2rCurgJBHR_H+L6@@#oe8=x^ z#)qDmZBZa9oFT=GEB}TNBv3A+uy>?s$R^y+mW7h<0 zy6n={1flL4TsRn8i`fQJd#oAZxK*WGWeXK;^@`wzhqbHLJKfe~I@ODt(JlThg3AyedsCH>Czd5e>S{fmJaK^IV$dt{%L%$6ZCEATY02^X2*h zs{NGYvv75d-ni6rbx0>H=*3~XI;$_{Y1bgOGoB3zl#TxeJvdl!!O1Kmi<|G6M{6}* zT>ToG%LO4%^2C0fhnOj*(=sQg{-Kz+95Y*!7+tpas%nww2`KFYcEB0J!_JUA`* z%(Ol^ipECHm(~?IjSrwr2bA8^p0ff#BW%AD|S@9OPXdDLJCEexS z-F`6+`9r=u+)ezAwDp?}OOXNZPDbZ?)Txrts{8J!wsu#o07O*Y9be5Ilz*)sF1c}W z%!K*;1}f;KzM7G}KvN;&h(ymKrE50b3`9QftIFETE7jWA=j(dDkz3Z+TTa}Uq*;Hl z?5U-8>%!9=sYwfA)2QV`CumCfmJ#@zKD*EYltFHECxNqe8fvrFgP^@M@&Z0ZM1iTP zx=Nr$A(+{8U0u!uWz1Bq9R0$u-thgxl2c1ut{Qgi5!i}WlS$>eDJ@33ettZJ(st%v zTTsJ1)MBYz{YiN~3mdx?>y`i`dW>jQC!@~If#?np%BIF-x(h8<$Zu9O9~_Kpz%e{` zGUQcx?SR8&$hNc#MUDBg({SbTqUPpEVnReNn)kQqc@5Ar)&=~#N{pcK9>@orI#i?zIRh4zNo zEw$@qzgh|!%~zHicPnq|+tHzPfjF;lH9qPcvrwOMOV9RwYGBZX(RAn)>cjEbY8Ufo z?$lZJM(kzE7&k)tP7(jd-#3s+vS;u?!zLl5nkP$h4FlX40KvJ8WUC70n1e2@gtp(1 z0;dJB;(oC~<*3cB=QXdBojqIE+g)>AS>yvz249!?yf~5i?4mtBZ`)2)8F>479g1`* zMOoYQO(;rzfnB~Pi$um$A%5W+++STOnPt0NuR2$U-uTTruM9miL}kW%aK`s+)X z9S!IF(kV>}T_}lJadULZTuzkEnP-EIwv^?=x;BLL{bw$m5tK25RZL)DIHUb%ATz!A;atg{|&x)4X$ zlAC!=SXixIv$HxD>XY5T5Q@cFX$sFwxTb9Ity+r9O<4?=nkbcTDE3PFOK3q&2zP|s zwZKwmwOk16k*E(!e#;-WoXxPfTL|lM#rC1_bfB+5Tc|CYg>A#A?C9KXP8nyewNN++ zcZZ^6h32p=dF@@Pw;6!@uoujk)sFSL(=_c$%)bJtSP=NYjMf*2M|ZlMSJ%1RwaVxX zYUuAo2p>Gp#;4pYw`mWn1>q`f2Vjp5St-=bO3UR|lzXNoYpW!5{gVPn`mGs~cP0s*vsnsSlVW2zH75(6GIAB4Ut8{evw^Ze)8%G;0qrw@&uFx2Xh;Qr~cy)6SWml~nJEhz~+C3>f!S z2R!MrJEe%H@+4#?+;P1vE`7)t#Wc;!q1c17r#2lhf%iwVsy5Za!`Ny0r^Uj}?>A!J zvfaTor?GAqyi)d?3E$MUDm`e?{bk5eX`)QTWMXK+?t!s3+r}D4O&;bgDHrCC&b8qh zYnr$7W7%RPf6zE}&b5)pPMAGiIPXu6zw&zS+ExyRVD|N}vukTbdJY-DbBtiYhU+uG zuQb>+tc^jHQ4vN19D_f&Vwave;ipgu@(fuT!0=+od!-2Oh8RqEAEyVK>Z7=?r064Vb5}jV>MxOf|#;nV@pAlSu>OmL+=c=YMpa!H7+;nM@W6< zEewxZqqwNGnpbblH&)kLeF0fI zt3{FDJDrO!&fKWYOGeX{&wa-o!aj4RI4tn>10j-dULaH zBEub8bWyU23nSMX}XOpGv;rzk@{ z?R9Xj&TN-&dby5y1hwRB%`3yab`~paYt6g)F*81PMjKh1_pel=Sk*PEDDCDWSr<8G zBn+W~5660&>To`{-g^!{a-K+>z+Sx&d3g39K?-uvMAAei$S}Uuku>?%A00QbZPB8vgS~5H5_{D!>F{FhW5HW zyRMkb@2IDT;KhI(PqL!$W$kkFdSPB(9@s*Oca*-{A6=x1m*eYBjZ)7|x3OqLid8?9 zb~AI>fM6tW`IEdoJ2*|7qS9xYYYuGfb58FqHcfYL@y#@NBU%q3ksc(CMuNI4Iw!ot z*>D4rdJJbGyFd2?rYp|YlWW&F<>~HK=ocW3ve;5CjJ=1HjpKt=$qkA%#oT~arN%3~ zN^iiibjGiksdcJX+ZE2H75P}|m18Opr7-{pK12K94KlaXV68y`fyya#rpi{&Kvu@-03}TJ`#f!I%(j!4Ta3w<>}FqS%Zq&8J5cL=S>FuUeP5p2 zfIn=|#Ai@@#)Yo$J9yn6gK!&sUm_;w;~3_6Or4TiG6+{-RP3 z+LBVGcKO!aY3IUHdA_fyCFu(0i}S=ALuno;-e%O>6QX(o!Nrj^2ucNKK8TmeM8cG_ z#k&=#jiOO(!KAB;>27@Ov~0HDQwP35F9xjCQd!fbt=;sb4X*+u6=mgG7f@?Ww&~*ahu6V z(S%7yZ#N{U8ep#^2v@n*F0DMb0U3EM^+D@94_7WTphD#e4g|YxA#KlNcn#0qq2&E0 zcoco`%*|KJK~L$Ad`Mc)K{=SYI~lKn{d%7B9B{XQOlCcb_gW3UHW^%OWn}u7YrX0p z^Aly(=LVAWYrz_P}$Wh&zmUE+vKTRWfF~GPa zEf8mvWhxV-vfMbdcE!4@g0mJ=8A`6iU&g*$t{lXHqZfKA)rDcpR9_g>7!3BE zb+fALy%iM3&I!BK_T=`RO|CuY?~lrH)*da(wftyN7PRqL2o};da2VH9XS_O*ceS7| zv`y)-^bf=)_l7{ku_=l2Ua+-bOv$vfg-vH}x#Nz|++L`9FxBcd1Ib{qc{n+{AO_;YJOhl`HNX_uKseWYU6xS(*-9eEnS_lrjoTT z`^A;yHHY)VW#ofD?8G_xH4{pAWk~?(s(Ofg>9--MwgA{{f{%6D)2^-cW*v1PI8=iz z?SgjBIM#|DqL<-3Nntjq>w-aeZQs6FQ`HxIbQX zRC4{a3v}~Pf#=sXNPea$Xca55GaWh0j5+nnu9&c47k2*caDh$Uc6@GrDjh~4BXuNq zL)&$zFl;Hd)E(#gD&z-X-LRL3{1CZfs}|~VZO~O)mR+gWX2TQjq+O<)QzG(&6+d_k ziW0B_-NwR&lyx;jRg6)t4H=w=IZu^1b?f%@4Q(F#O?qvG^^xE5#(7U@^@X{98J;R; zxo9b+wRV_ZXde>F=|?84jw@-9VmmkDDI=B)D~kw630dM$`NBAhL{_%AHa`fPenGW; z-Yb^3S9?^a^fu2Nt#HTDb#~?`&4UNQ?G?^&v?92xN$WPpiHB3bK`dQ$7}PDUcHHM6 zbbm$hkg|N4yO)%Iq5ec!(vWzJs6|G#H7;L-{o-+9M7XY5Gp!ljrdOuhlWnfIki6M^ z(&v2GJ4@edeP>jm^ojz`6^(Y@uQiNbp3=thYR>LTey_gZOM}zMYmcXG$cWSH&CBIZ zRlLq1^M2%Ri`3-c79sCWa3|p;G9mX0!orMQ+4EZ~>$vn+;#}BNv@7UiHw@44lS=V& z2?}$Y2|7C!EL`9o-PfD-!_+*_OL@;>UD=Y(&Z)Wga-vN4xbn!5)2`&Ngu3~z*pDi0 zp;Tm9*@q)rcN&IUr}phq8FGxRgvr!jua{?+tLO4aQiYea z>XvImw$%3acMTf@MyApLVjb@fYpHvj72wPSqV$7DZLQz&oaxl^m#kePbQ+lS&I>N~Yq4 zY14Xsz|y;tcA7HfU6=JIW{#`TP+Ru^T1YTeN#eRc9MQB4iACHhu)148g|gshw>XgU zLb+Y;Vu}VA-m_K2Y=h97kz?iT-aHITU?2*2W!{_=>J_c;LV!cQ+~kDLetBLN){?<5 z&jnR2%;dHz%Syi8-jC&eaX(n~*lXV5u8jVd+ir{(ax%zNO!^CA<7hqiEyPTSaH_O(Qs(^cMK2-Zg>m zJ_PP5Soe{Q-L3XJCE;8UKm&R?bNO>Av`TB1R&%0n`YX$6RoBbY$r`viWSqAL^;~%5 zY-O20d5wAii9pw2S#B)BK|nNn!$AZ7IjY+<*&OFhMo0flej%t<`t6g1a>b0k4%K0? zSY>2!FzA%$1NUgRB<8@#we{GnjqCF;-|VioYey8A>pD0#N4wLEDa|JJ1{lpx3u9@v zSBq^4e06q>AuaeK!&I5nV3wq2A+89TO%pwCbXv7Fn|{HH12HHPny z6%HgLYy|+eG3&c*FHX+I(T6pu3*M8h9UC0RemLZ1MXm)->ehasK#Q)J)Shj*vQ8w%^#d{zu(D z?*tz1**iT%_hnvI)a#>};s~@4w<7NL(OkDsuurz))+zh$kuIuODt_AMY#93CbLceH zbRe_|RW!?pfds@4(Ux>V?irWQVT5uU`QTFkKRRBhJh*i}zEn}Kk7k05;Q9}yio1O@ zm*aug-tUjPkXW1#ZxreD8PbwI8DRtLfvjy{1Mzg#4da60MEQk+D++%S9YB-^`_Fqq zg@64kg)qm80|-C{F(cV4dJ$fvKmY3&&VL@$@h8Ygih7iz9(1EnR{Pig!S-K7zEMl? zFV*+z0f@IPV%08Ua|lE8`rV4UMa8`wP(NuRj0$7_n~h+^g)jd4g~aSwOuG5CBVa*D z!!6-KgNjy=<4+#TvJGkBWL<((BB~5eE8%K>KUI@Rt}%vOf!vQEnxV`1dVRQNBQ8IX zwl1uxps;D{wkYItTZmrRXx-#rHge~Jg{15hRNnjR7b(5>ep^VA?Z18jdkOqP)Go^R z1z)jtHTp_2G5z%mr(%k~X{b5}$$5k}79fg#ffN};4n>BJc;J_Z0A}j1Mm=&$WUzmD zN}z8c$ov)c9&iX)CcvPtjfl(k?-Mwwq7&v*wmgB;HqtY|;oWx?MP)QC&>=5iIDsg zH$n#njh-72ztP_$r4@J(z!820?r}JP5#!_SdcT+w+JH$hvB=jvC>|<(pA#J&*(JmS z#>LKBw?!u#VPiM4b)y~F;iez~y=t1qM z-Sh!Eyir9a6)#?c41;5!Uu}T%2n{sF@DM!F4KNo{I~5YzVUh>bAOUFft7l(8=?ZG~ zZ%8?54ypr1cJMSebbJ~JbbhIed@)e*0p1v*^1r#y1_C8yav%aLEbS53lk?*rli2X| zBeJ#KA3=1zq2Ld-cm559T|j4TV>*<6rpZpO=)!PFDau5Lne;>=SK_vW`&`fsCW20{ zVL_?kVIkj5OpIPNgTf@@~__s+3mM1ZdIfkzjFYuq`tTP zfruQFpEhji&mf89zkfc!&o=|hl0OFkQ1PF2{t4s> zp^T(R_z;cm^4UM=j#iHIGkiO_1GP+K3MGvLstSJ+RQKU0sFVHmC)EViNA%=iy84;} zOum^g2!4KV?|bqQ!at&?mdH4N{Cx)@>KBpf=sV0`hWW8{^X>dkulcuAVE3(~Ly@ZO z3A;1mIhj9`)z8!9)~J*^Six@mb;8{W8~)&ag3ndT^Z#7Y4#_&mStHsfZ2p3m7FgKm z%Y!KkHXP-&ihfE=js#Jdn7n?8@01ZhS-a#5h~JVsjIUn5ul=7Mu^)CP@5!f~Fx(k&oMB@+l0rQEp`DpNtUEqMr?Mr-VQ6|3d&!6l$pmC4C&kLVY^@3u^kaArchv z!vS7G{YoVR7Hd&G1H=*p7QMPdK*Pv|i)qi-_Q^Ywy*Vr{L*vAq$k6(Rz4trtNiTx) zAND{fmbpuW(;v0ZI6(quGYtQXqnW$JIC<*;B>EAFupI=(?hT~3sQT0*fy$3qj^IY>K6uuD26*&5A$KWICgU2& z=miFG`KHk!`w`W55CC(vTNXnO_2}FC}(sXrvIzoH{fjIx7R12^&$j1}xr0tezX813{!JBR@SFP+G(FhvLdC*BR*mAKgb z>t7$WPT8DT4Vf?xL`ryqO@PZ0mlAEkiv>W-XpKbS{K|~*bqOiM;=-?BWrlhuvV9vp zr32a@;>>$%{m}-c42`WM|M5|i6=83KM^KPW{Oi|Sv&yYT1Y+FpW{X!!_os{}j~$Xu z(8MT`e|t=K3`CqRczI%Af2wZ3j}~8h$!AVz1`JU zcPb4vUKp#s#g;o*|DBV2*wBcUeRw7q#M^GAZ1L;8fex=W5{(*p`2T=B{bUI-ZT$J+ z|8r{dBNRzQL;P z!ID@%2?a%E7jWGV#G(P06d`}Y`iXO^MW0EK?q9!fly3Cx|4&q$xD@=SZyOTo|H_dPO51h*y>~$H zupLZFgna77g0&zQ$ArRfF#`1=M4 znAzZvF(fzax%~)!OYx7XQ+@4eLrn(E(ULW$!TGf_Nf39N`M^AKen4|W< z3SLCSU&azc7Tz#*4bi~Af8D3Nee#_r)PVydh?7d}pOn|2LBFjQPeKua6&8y{*62s_ zkx=btlm%Rf5cUq`e+B6>9l|4|w{Q8FqtOq172g6v%0VE{V5Rq{-4>9pWng^ zTlt^b5OQau4N1jzXoGAR|8bJ`#nJwBxz9rO^bo@S{-$B&Q3Uua5OK;s;P-XN zNaUi4!J`llVIQ!b2zw(HZi}{65rqiZA=%N}_aO4wB)=h@eLnsT>gkG52T%efBUDWX zg*>=x-hRWOD{^2Whz%Wngg;Tzm{-s~j)hnt7%bW+SOe4pKmjv^R^erApC-WTdw=q; z$A<~a{x zyfA&VSmz;i4Vo=t@posycHj#kkU0|Qk!K|&ypZtDN4ZU2&Gisd^v>BZey|8-Aj$w> z;m|q6Pu=6~Ast9VFoT{?C{#Ad@qv$alnIku56tyFU}c1Yc;&AECygO*2v}MW0%4Q> z{UfX!zTI~t#uCg0+;_<~APCqoMPCQm1|F36ryQplCGf^N`#8^3lo^X$rTDmTz$p2I z9U#F(3U^t4NDKICX>r8?(zO&1M@|Q!I)tsqf&lCf)`S2~fqLUT_!{&;2vS1GiHVbn z1i{1}NwN!3YmfnWyTf~5iZ}1s69b3@#`U3h{PD8Ez0LNQwbaqOdUl z1x5FeF=JPY`imHVM4(w5n}#JP)EnapQdPc)Ma$F(o0JJ8Z^t5b=QoX>OxfwJQb&pn z*Vr%dJ@9d})Uj@6?f2u{nxt(3X@tUPz8fft_4=+^9NYC>I~>dPU8Bf%@4IH$zwWz6 zsoM!G;VnSw{XkKx)RE$#sc#NBZk9UMEtcv%NC~6;ca72ynGEy*4!+6|cPdBqTS&4c z6leqz#Ep{RAfw+{Cy3NvX#`7lY#IFY93bz>ClI|Uq&_H;w9(UuXGRL^-%0%!JiE~C zY2t?lSU>jI;DufFJ(YW5*!`*6H0pN`AbV>eCN1M&Cmg0ljuLh`L9);Vlqr6FFUwc$!=P z$5550;1?JRaUJ(nJhr~a(%k(5Bdv7gM-dGWvn}zD33eM=M3G)(r@n@6VI~ftC2(;O z*pNOqW+X`WSnu`1Od&&T!Sg*qquq}%!Uud$uwC?axX~|3L?%yceEKhA$Ycj|=xFcm zxk_XQNr;#&xSK`NWfv8Et0Bvy9YPTe2=zmt-GSbN6WDJL*l?@>i}nRYuc1t=&$Bd1j&0n%~fR~DR?Lez6z-bZvv7QI5-2Cgd63<+}(;@9KPsn-37 zO#xj5f))xq;IYz}muIKdEVAt*&rhhG2B&ZV4i~tnI{||<+2GHN0>4t&z*lTK{st^1 zq^@}(Fh?7df_Ts(LNEXc&iFmad_%lRViGux_dPR-DHxFG)%g1x3HS{t@#6wKF51UP zn0gRo;AMeLP6PF&n1q4DH|~kMzC?Lb6Z1XYLYNU4_ZS%bBf3}c8UCaJf(R_|^EjUA zYiQCRe!x5M>k{`NQ1>p8s|$aM3uxJmeb_Q{sE6FG9+AgsJb99k2%psTj9| zkW5*36jxStyhChq!4{wviduTV+9(A#f(k~)v!nPe7O??;KBejZ7a1n)s@{5#@ z1pNp42P_O?Zt!-!lH>Mg&`d;>5|MW-@Z7ZpAaMe$@4G){tR!?b8Uj;Vz|qv{6BP*w z?Tz1vM12pkIvB=fER28=wxG)6Xf!B2jyIpQiEuQ!M|+3ZPvCrS zm>VC$U%y=2lTp_M-oX+9?cPK3WZHk~jNhA2JBz5yzysHc;K5(fcsH7m5OQI^0G)42 zg`4*n;4BOjqcJ>dzhUPNcz17K$vcS2&5f3mNM91jjW;mmEXdm$)s}gD?c$(VltVvp z&WTs{Q2_v(DF-g5Ea-OL))Q?qhC7Aa+`AJ^14dQ&sC$rO4dp#gY#w~J<-H7S!A^!1 z{*a!kjvOFm%EQ2h1_jq4Q`c{>NI#q6-s6G~d=Tg~Kqf&Lcu&s|AKp#94bXyiJMkF> z*2C5yYKCvZD?%VfYE`1p!5f3Q7n08jZ@_K06P)Nx@eAh!Gu$VIgxk)O_V}6c0o+4( zIJ$V~asrTWH{S!9fC$~}h+LsS`DpsL%exooKnREwJA`va?;y!YN;KD)%q0D^p{}M4 zvSgGi2)cqOK>++p6<+lN2^7|uxmQA2@=3RwZsZ;n@T2ZgNX!QS-)2UkZ796*LvKIXUjEP0;~=-!{UbYz@B~@)<|cMt#JITT9sdH$|W$YL{Sq zfd5P7k>WCPvDAvk{!$q^8uxvD;#YW=Rb+{^{rDk+`oi&K$tu6`x0m&FUrkOdSA>qo zE{_S87S|<0FWpV^?@KO6^;qfX9gJeu>vG8HNJYV&mn; zHCvPE?KatA9e;$dNAzN$3IM z82Db?EXPP(!|6V7`iw|ci&^^k$Ls;Wu^R9mR*bazIItNOg8sUNBBPMwXukMqCkuVH z)u+)^EEHpRO?1~#e8X+O&@h-4y&-=z&{OHmS2z`!vL5wC&+;U}v{)sV%>6@Fnw^0m z?Qi*1F-?N}8nBcXwKHGAo5;6GV8%>QOoj-@`W{1~NzlEHgxm-IAKt=L!R=<0ei-1n z*kg2eVuL593^z{lZn(wYQ<&aZwMvvH7=P~O;s=e&BbiFd2JtUwf1fKA#$dvC;$uLH zt$q27WVxGY^`Zpct5YJB2F(r|9pn5Rf{6M>_vD>j_KsDEN4YcO=3{lZ2wjLcjWdkJ2@G zK-JKTl}t^ADO3tEf1*z0-NU0il(mcK&y!b7q@uw${1qLFn`5gdA^G@KeE1H}O+wG} zKyx3->x%@2k5Aa6MFkX7ph0^7haY3$E%X*y*8V&}Xz@6SDa>9M* zf0bwohKg6|p9z1GLf9ksNUZI;YwyLIhhJm^im0*p;Nge10I?lplGwux;R5KV;;)g9 z6Io^abbk2UlL?-MycjdcuSsDHaZ)AVd}6Eaok>A`B2MW=ZLQMIZF`I1>eKrM%BK*c|Osl@c^D#dbB{Ime< z|1?3&dt{7UNYTrZa4r1_$sklQ5w+Rh&G9 z%>2yxk+09LD9^Q%h`|8SyDw#LUY!j##i4^ik{s&vRB - -` - -const TRASH_ICON = ` - - - - -` - -const ARROW_LEFT_ICON = ` - - -` - -// Types and state - -type Alert = { - event: TrustedEvent - tags: string[][] -} - -type AlertStatus = { - event: TrustedEvent - tags: string[][] -} - -type AlertValues = { - feedAddress: string - freq: string - time: string, - email: string - secret: string -} - -type State = { - failedToLogin: boolean - signer: ISigner - pubkey: string | undefined - alerts: Alert[] - alertDraft?: AlertValues, - alertStatuses: AlertStatus[] - alertsLoading: boolean -} - -const state = withGetter( - writable({ - failedToLogin: false, - signer: new Nip07Signer(), - pubkey: getJson('pubkey'), - alerts: [], - alertStatuses: [], - alertsLoading: false, - } as State) -) - -// Actions - -const login = async () => { - const {signer} = state.get() - - try { - const pubkey = await signer.getPubkey() - - state.update(assoc('pubkey', pubkey)) - setJson('pubkey', pubkey) - } catch (e) { - state.update(assoc('failedToLogin', true)) - } -} - -const loadAlerts = async () => { - const {signer, pubkey} = state.get() - - if (!NOTIFIER_RELAY) { - state.update(assoc('alertsLoading', false)) - return - } - - state.update(assoc('alertsLoading', true)) - - const events = await load({ - relays: [NOTIFIER_RELAY], - filters: [ - {kinds: [ALERT], authors: [pubkey!]}, - {kinds: [ALERT_STATUS], "#p": [pubkey!]}, - ], - }) - - const alerts = await Promise.all( - events - .filter(spec({kind: ALERT})) - .map(async event => { - const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content)) - - return {event, tags} - }) - ) - - const alertStatuses = await Promise.all( - events - .filter(spec({kind: ALERT_STATUS})) - .map(async event => { - const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content)) - - return {event, tags} - }) - ) - - state.update($state => ({...$state, alertsLoading: false, alerts, alertStatuses})) -} - -const deleteAlert = async (alert: Alert) => { - if (!NOTIFIER_RELAY) return - - if (confirm("Are you sure you want to delete this alert?")) { - state.update(assoc('alertsLoading', true)) - - await publish({ - relays: [NOTIFIER_RELAY], - event: await state.get().signer!.sign( - makeEvent(DELETE, { - tags: [ - ["k", String(alert.event.kind)], - ["a", getAddress(alert.event)] - ], - }) - ), - }) - - await loadAlerts() - } -} - -export type AlertParams = { - feeds: Feed[] - freq: string - time: string - email: string - secret: string -} - -export const makeAlert = async ({freq, time, email, feeds, secret}: AlertParams) => { - const {signer} = state.get() - const [hour, minute] = time.split(':') - const utcHour = (parseInt(hour) - TZ_OFFSET) % 24 - const dow = freq === 'daily' ? '*' : freq - const cron = `0 ${minute} ${utcHour} * * ${dow}` - - const tags = [ - ["cron", cron], - ["email", email], - ["channel", "email"], - ["locale", LOCALE], - ["timezone", TIMEZONE], - [ - "handler", - "31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1685968093690", - "wss://relay.nostr.band/", - "web", - ], - ] - - for (const feed of feeds) { - tags.push(["feed", JSON.stringify(feed)]) - } - - return signer.sign( - makeEvent(ALERT, { - content: await signer.nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)), - tags: [ - ["d", randomId()], - ["p", NOTIFIER_PUBKEY], - ], - }) - ) -} - -export const publishAlert = async (params: AlertParams) => { - if (!NOTIFIER_RELAY) return - - await publish({event: await makeAlert(params), relays: [NOTIFIER_RELAY]}) -} - -// Components - -const Loader = { - view: () => m("div", { class: "flex justify-center py-4" }, [ - m("div", { - class: "animate-spin rounded-full h-8 w-8 border-4 border-purple-200 border-t-purple-600" - }) - ]) -} - -const Login = { - view: () => - m("button", { - onclick: login, - class: "w-full bg-purple-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors" - }, "Connect with Nostr"), -} - -const AlertStatus: m.Component<{alert: Alert}> = { - view: vnode => { - const {alert} = vnode.attrs - const {alertStatuses} = state.get() - const address = getAddress(alert.event) - const alertStatus = alertStatuses.find(s => getTagValue('d', s.event.tags) === address) - const status = getTagValue('status', alertStatus?.tags || []) - const message = getTagValue('message', alertStatus?.tags || []) - - const getStatusClasses = () => { - const baseClasses = "rounded-full px-3 py-1 text-sm border" - if (status === 'ok') return `${baseClasses} border-green-500 text-green-500` - if (status === 'pending') return `${baseClasses} border-yellow-500 text-yellow-500` - return `${baseClasses} border-red-500 text-red-500` - } - - const getStatusDisplay = () => { - if (!status) return 'Inactive' - if (status === 'ok') return 'Active' - if (status === 'pending') return 'Pending' - return status.replace('-', ' ').replace(/^(.)/, x => x.toUpperCase()) - } - - return m("div", {class: getStatusClasses(), tooltip: message}, getStatusDisplay()) - }, -} - -const AlertListItem: m.Component<{alert: Alert}> = { - view: vnode => { - const {alert} = vnode.attrs - const cron = getTagValue('cron', alert.tags) - const feeds = getTagValues('feed', alert.tags) - const channel = getTagValue('channel', alert.tags) - const description = displayFeeds(feeds.map(feed => parseJson(feed))) || "[invalid feed]" - - let frequency = cron || "Unknown" - if (cron) { - if (CRON_DAILY_PATTERN.test(cron)) { - frequency = 'Daily' - } else if (CRON_WEEKLY_PATTERN.test(cron)) { - frequency = 'Weekly' - } - } - - return m("div", { class: "flex items-start justify-between p-4" }, [ - m("button", { - onclick: () => deleteAlert(alert), - class: "mr-4 mt-1", - tooltip: "Delete alert" - }, [m.trust(TRASH_ICON)]), - m("div", { class: "space-y-2 flex-grow" }, [ - m("div", { class: "text-gray-600" }, `${frequency} alert via ${channel}`), - m("div", { class: "text-sm text-gray-500" }, `Events ${description}`) - ]), - m(AlertStatus, {alert}), - ]) - } -} - -const AlertList = { - oninit: loadAlerts, - view: () => { - const {alerts, alertsLoading} = state.get() - - const content = alertsLoading - ? m(Loader) - : alerts.length > 0 - ? alerts.map(alert => m(AlertListItem, {alert, key: alert.event.id})) - : m("div", { class: "text-center text-gray-500 py-8" }, [ - "You don't have any alerts set up.", - ]) - - return m("div", { class: "space-y-4" }, [ - m("div", { class: "flex items-center justify-between mb-6" }, [ - m("h1", { class: "text-2xl font-bold text-gray-900" }, "Your Nostr Alerts"), - m("a", { - href: "#!/alerts/new", - class: "flex items-center gap-2 bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors", - }, [ - m.trust(PLUS_ICON), - "Add Alert" - ]) - ]), - m("div", { class: "bg-white shadow rounded-lg p-6" }, content) - ]) - } -} - -const AlertCreate = { - oninit: () => { - state.update(assoc('alertDraft', { - email: getTagValue('email', state.get().alerts[0]?.tags || []) || "", - freq: 'daily', - time: '17:00', - feedAddress: "", - secret: "", - })) - }, - view: () => { - const {pubkey, alertDraft, alertsLoading} = state.get() - const {email, feedAddress, freq, time, secret} = alertDraft! - - const update = (newValues: Partial) => { - state.update(assoc('alertDraft', {...alertDraft, ...newValues})) - } - - const submit = async (e: Event) => { - e.preventDefault() - - state.update(assoc('alertsLoading', true)) - - try { - if (!email.includes("@")) return alert("Please provide a valid email address") - - const address = tryCatch(() => Address.fromNaddr(fromNostrURI(feedAddress))) - - if (!address) return alert("Please provide a valid feed address") - if (address.kind !== FEED) return alert(`Please provide a valid feed address (kind ${FEED})`) - - const selections = await load({ - relays: INDEXER_RELAYS, - filters: [{kinds: [RELAYS], authors: [pubkey!, address.pubkey]}], - }) - - const router = Router.get() - const filters = getIdFilters([address.toString()]) - const scenario = router.merge([ - router.FromRelays(selections.flatMap(e => getRelaysFromList(readList(asDecryptedEvent(e)), RelayMode.Write))), - router.FromRelays(address.relays), - router.FromRelays(INDEXER_RELAYS), - ]) - const relays = scenario.limit(10).getUrls() - - const [event] = await load({relays, filters}) - - if (!event) return alert("Sorry, we weren't able to find that feed") - - const feedStrings = getTagValues('feed', event.tags) - - if (feedStrings.length === 0) return alert('At least one feed is required') - - const feeds = removeNil(feedStrings.map(parseJson)) - - if (feeds.length < feedStrings.length) return alert("At least one feed is invalid (must be valid JSON)") - - const feedError = feeds.map(validateFeed).find(e => e instanceof ValidationError) - - if (feedError) return alert(`At least one feed is invalid (${feedError.data.toLowerCase()}).`) - - await publishAlert({freq, time, email, feeds, secret}) - - m.route.set("/alerts") - } catch (error) { - alert("Failed to create alert. Please try again.") - console.error('Error creating alert:', error) - } finally { - state.update(assoc('alertsLoading', false)) - } - } - - return m("div", { class: "space-y-4" }, [ - m("div", { class: "flex items-center gap-4 mb-6" }, [ - m("button", { - onclick: () => m.route.set("/alerts"), - class: "text-gray-600 hover:text-gray-900 cursor-pointer", - tooltip: "Back to alerts" - }, m.trust(ARROW_LEFT_ICON)), - m("h1", { class: "text-2xl font-bold text-gray-900" }, "Create Alert") - ]), - m("div", { class: "bg-white shadow rounded-lg p-6" }, [ - m("form", { class: "space-y-6", onsubmit: submit }, [ - m("div", [ - m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Email"), - m("input", { - type: "email", - placeholder: "Enter your email address", - value: email, - oninput: (e: InputEvent) => update({email: (e.target as HTMLInputElement).value}), - class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" - }) - ]), - m("div", {class: "w-full flex gap-2"}, [ - m("div", {class: "flex-grow"}, [ - m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Frequency"), - m("select", { - value: freq, - onchange: (e: Event) => update({freq: (e.target as HTMLSelectElement).value}), - class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" - }, [ - m("option", { value: 'daily' }, "Daily"), - m("option", { value: '0' }, "Weekly on Sunday"), - m("option", { value: '1' }, "Weekly on Monday"), - m("option", { value: '2' }, "Weekly on Tuesday"), - m("option", { value: '3' }, "Weekly on Wednesday"), - m("option", { value: '4' }, "Weekly on Thursday"), - m("option", { value: '5' }, "Weekly on Friday"), - m("option", { value: '6' }, "Weekly on Saturday"), - ]) - ]), - m("div", [ - m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Time"), - m("input", { - value: time, - onchange: (e: Event) => update({time: (e.target as HTMLSelectElement).value}), - type: "time", - class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" - }) - ]), - ]), - m("div", [ - m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Feed Address"), - m("div", { class: "space-y-2" }, [ - m("input", { - type: "text", - placeholder: "naddr1...", - value: feedAddress, - oninput: (e: InputEvent) => update({feedAddress: (e.target as HTMLInputElement).value}), - class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" - }), - m("p", { class: "text-sm text-gray-500" }, [ - "Visit ", - m("a", { - href: "https://coracle.social/feeds", - target: "_blank", - class: "text-purple-600 hover:text-purple-800" - }, "coracle.social/feeds"), - " to search for existing feeds or create a new one. Copy the feed address (starts with 'naddr1') and paste it here." - ]) - ]) - ]), - m("div", { class: "flex justify-end" }, [ - m("button", { - type: "submit", - disabled: alertsLoading, - class: "bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - }, alertsLoading ? "Creating..." : "Create Alert") - ]) - ]) - ]) - ]) - } -} - -const FailedToLogin = { - view: () => - m("div", { class: "space-y-6 text-center" }, [ - m("div", { class: "bg-red-50 border border-red-200 rounded-lg p-6" }, [ - m("h2", { class: "text-red-800 font-semibold mb-2" }, "Unable to Connect"), - m("p", { class: "text-red-600 mb-4" }, "To use Anchor Alerts, you need a Nostr signer extension installed in your browser."), - m("div", { class: "space-y-3" }, [ - m("button", { - onclick: () => window.location.reload(), - class: "w-full bg-red-100 text-red-700 font-medium py-2 px-4 rounded-lg hover:bg-red-200 transition-colors" - }, "Try Again"), - m("a", { - href: "https://nostrapps.com/#signers", - target: "_blank", - class: "block w-full bg-purple-600 text-white font-medium py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors" - }, "Install a Nostr Signer") - ]) - ]) - ]) -} - -const Layout: m.Component<{children: m.Children}> = { - view: vnode => { - const {children} = vnode.attrs - const {failedToLogin, pubkey} = state.get() - - if (failedToLogin) { - return m(FailedToLogin) - } - - if (!pubkey) { - return m("div", { class: "text-center space-y-4" }, [ - m("h1", { class: "text-2xl font-bold text-gray-900 mb-2" }, "Welcome to Anchor Alerts"), - m("p", { class: "text-gray-600 mb-6" }, "Connect your Nostr signer to get started"), - m(Login) - ]) - } - - return children - } -} - -m.route(document.querySelector('#app')!, "/alerts", { - "/alerts": { - view: () => { - return m(Layout, {children: [m(AlertList)]}) - }, - }, - "/alerts/new": { - view: () => { - return m(Layout, {children: [m(AlertCreate)]}) - } - }, -}) - -state.subscribe(() => m.redraw()) - -defaultSocketPolicies.push( - makeSocketPolicyAuth({ - sign: (event: StampedEvent) => { - return state.get().signer?.sign(event) - }, - }), -) - -Object.assign(window, {setJson, getJson}) diff --git a/web/src/style.css b/web/src/style.css deleted file mode 100644 index 1c1b184..0000000 --- a/web/src/style.css +++ /dev/null @@ -1,42 +0,0 @@ -@import "tailwindcss"; - -a, button { - @apply cursor-pointer; -} - -/* Tooltip styling */ -[tooltip] { - @apply cursor-pointer relative; -} - -[tooltip]:hover::after { - content: attr(tooltip); - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - padding: 4px 8px; - background-color: rgba(0, 0, 0, 0.8); - color: white; - border-radius: 4px; - font-size: 14px; - white-space: nowrap; - z-index: 1000; - pointer-events: none; - - /* Animation properties */ - opacity: 0; - animation: tooltipFadeIn 0.2s ease-in-out forwards; -} - -/* Keyframes for fade in animation */ -@keyframes tooltipFadeIn { - from { - opacity: 0; - transform: translateX(-50%) translateY(0); - } - to { - opacity: 1; - transform: translateX(-50%) translateY(-3px); - } -} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts deleted file mode 100644 index 11f02fe..0000000 --- a/web/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/web/tsconfig.json b/web/tsconfig.json deleted file mode 100644 index 9d2104f..0000000 --- a/web/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "strict": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["src"] -} diff --git a/web/vite.config.js b/web/vite.config.js deleted file mode 100644 index bd48de3..0000000 --- a/web/vite.config.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vite' -import tailwindcss from '@tailwindcss/vite' - -export default defineConfig({ - server: { - port: 2893, - }, - plugins: [ - tailwindcss(), - ], -}) From abc993e595ec8ca1fc4aaa820dad5c1730cd85d2 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 10:09:13 -0400 Subject: [PATCH 02/23] Remove orphaned web/dist/ reference from .dockerignore --- .dockerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 49ad795..4b7fa98 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ node_modules/ dist/ -web/dist/ .git/ .gitattributes .gitignore From 2fb738981e65fad6a2af2d52bd5f9854f23cc9d2 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 10:36:54 -0400 Subject: [PATCH 03/23] Fix pre-existing eslint unused-import errors to pass script/checks gate Remove 20 no-unused-vars violations across 5 files: - src/actions.ts: unused Subscription type import, getCronExpression - src/alert.ts: unused cron-parser and @welshman/lib imports - src/digest.ts: unused now, nth, nthEq, dateToSeconds, getIdFilters, getReplyFilters, Loader, AdapterContext, makeLoader, SocketAdapter, call, loadRelaySelections - src/env.ts: unused netContext import - src/worker/email.ts: assigned-but-unused purgeJob variable These were pre-existing errors unrelated to the web UI removal. --- src/actions.ts | 2 -- src/alert.ts | 3 +-- src/digest.ts | 9 --------- src/env.ts | 1 - src/worker/email.ts | 2 +- 5 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index ceec6fe..661044a 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,6 +1,4 @@ import { instrument } from 'succinct-async' -import type { Subscription } from './alert.js' -import { getCronExpression } from './alert.js' import * as mailer from './mailer.js' import * as worker from './worker/index.js' import * as db from './database.js' diff --git a/src/alert.ts b/src/alert.ts index 01df785..62f5bf8 100644 --- a/src/alert.ts +++ b/src/alert.ts @@ -1,5 +1,4 @@ -import { CronExpressionParser } from 'cron-parser' -import { tryCatch, int, HOUR } from '@welshman/lib' + export type Subscription = { id: string diff --git a/src/digest.ts b/src/digest.ts index 334a2c8..334c00b 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -1,13 +1,9 @@ import { neventEncode, decode } from 'nostr-tools/nip19' import { spec, - now, sortBy, groupBy, displayList, - nth, - nthEq, - dateToSeconds, secondsToDate, } from '@welshman/lib' import { parse, truncate, renderAsHtml } from '@welshman/content' @@ -15,8 +11,6 @@ import { TrustedEvent, normalizeRelayUrl, getParentId, - getIdFilters, - getReplyFilters, NOTE, COMMENT, REACTION, @@ -24,15 +18,12 @@ import { displayPubkey, getTagValue, } from '@welshman/util' -import { Loader, AdapterContext, makeLoader, SocketAdapter } from '@welshman/net' -import { call } from '@welshman/lib' import { displayDuration, createElement } from './util.js' import type { Subscription } from './alert.js' import { sendDigest } from './mailer.js' import { EVENT_VIEWER_URL } from './env.js' import { profilesByPubkey, - loadRelaySelections, loadProfile, } from './repository.js' diff --git a/src/env.ts b/src/env.ts index 3588110..7795c40 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,7 +1,6 @@ import 'dotenv/config' import { always } from '@welshman/lib' import { normalizeRelayUrl } from '@welshman/util' -import { netContext } from '@welshman/net' import { Nip01Signer } from '@welshman/signer' import { routerContext } from '@welshman/router' diff --git a/src/worker/email.ts b/src/worker/email.ts index d60e4a3..df776a5 100644 --- a/src/worker/email.ts +++ b/src/worker/email.ts @@ -65,7 +65,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 From 97eaf8ab360569390bc7a8ffe8e6cf8045d13a5c Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:23:31 -0400 Subject: [PATCH 04/23] fix(digest): replace hardcoded #7161FF with {{brandAccent}} Lines 8 and 22 of digest.mjml hardcoded #7161FF for the event-item border-left and footer link color, while mailer.ts already passes brandAccent into the template (used at lines 15 and 34). When BRAND_ACCENT is customized, the border and footer links stayed the default purple. Drive both from {{brandAccent}} so they respect the customization. Fixes bead mailship-hu5 --- src/emails/digest.mjml | 4 +- test/digest-template.test.js | 74 ++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 test/digest-template.test.js diff --git a/src/emails/digest.mjml b/src/emails/digest.mjml index 5d6525d..0e4ef68 100644 --- a/src/emails/digest.mjml +++ b/src/emails/digest.mjml @@ -5,7 +5,7 @@ .header { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 24px; font-weight: 700; } .subheader { font-family: Inter, Helvetica, Arial, sans-serif; font-size: 15px; color: #64748b; line-height: 1.5; } - .event-item { margin-bottom: 20px; border-left: 3px solid #7161FF; padding-left: 12px; } + .event-item { margin-bottom: 20px; border-left: 3px solid {{brandAccent}}; padding-left: 12px; } .event-meta { margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; } .event-meta-left { display: flex; align-items: center; } .event-author { font-family: Inter, Helvetica, Arial, sans-serif; font-weight: 600; margin-right: 4px; color: #1e293b; } @@ -19,7 +19,7 @@ .event-stats { margin-top: 8px; color: #64748b; font-size: 13px; } .stat-item { display: inline-flex; align-items: center; margin-right: 12px; } .footer { font-family: Inter, Helvetica, Arial, sans-serif; color: #94a3b8; font-size: 12px; line-height: 1.5; } - .footer a { color: #7161FF; text-decoration: underline; } + .footer a { color: {{brandAccent}}; text-decoration: underline; } .logo { max-width: 48px; max-height: 48px; } a { text-decoration: none; } diff --git a/test/digest-template.test.js b/test/digest-template.test.js new file mode 100644 index 0000000..47de77f --- /dev/null +++ b/test/digest-template.test.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// FAILING test: digest.mjml hardcodes #7161FF instead of using {{brandAccent}} +// +// The bug: in src/emails/digest.mjml line 8 and line 22, the CSS for +// .event-item border-left and .footer a color hardcode #7161FF even though +// {{brandAccent}} is passed into the template by mailer.ts and used +// elsewhere (lines 15, 34). When BRAND_ACCENT is customized, the event-item +// border and footer links stay the default purple. +// +// The fix: replace both hardcoded #7161FF values with {{brandAccent}}. + +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'); + +let passed = 0; +let failed = 0; + +function assert(label, ok, detail) { + if (ok) { + console.log(` ✓ ${label}`); + passed++; + } else { + console.log(` ✗ ${label} — ${detail || ''}`); + failed++; + } +} + +// Read the MJML template +const source = readFileSync(templatePath, 'utf8'); +const lines = source.split('\n'); + +console.log('1. No hardcoded #7161FF in .event-item or .footer a CSS'); + +// Check .event-item border-left doesn't have #7161FF +const eventItemLineIdx = lines.findIndex(l => l.includes('.event-item')); +const hasEventItemHardcoded = lines.some(l => l.includes('.event-item') && l.includes('#7161FF')); +assert( + '.event-item border-left does NOT hardcode #7161FF', + !hasEventItemHardcoded, + hasEventItemHardcoded ? `Line ${eventItemLineIdx + 1} still has #7161FF: "${lines[eventItemLineIdx].trim()}"` : '' +); + +// Check .footer a color doesn't have #7161FF +const footerAIdx = lines.findIndex(l => l.includes('.footer a')); +const hasFooterHardcoded = lines.some(l => l.includes('.footer a') && l.includes('#7161FF')); +assert( + '.footer a color does NOT hardcode #7161FF', + !hasFooterHardcoded, + hasFooterHardcoded ? `Line ${footerAIdx + 1} still has #7161FF: "${lines[footerAIdx].trim()}"` : '' +); + +// Check .event-item border-left uses {{brandAccent}} +const eventItemLine = lines[eventItemLineIdx]; +assert( + '.event-item border-left uses {{brandAccent}}', + eventItemLine && eventItemLine.includes('{{brandAccent}}'), + eventItemLine ? `Line ${eventItemLineIdx + 1}: "${eventItemLine.trim()}"` : '.event-item line not found' +); + +// Check .footer a color uses {{brandAccent}} +const footerALine = lines[footerAIdx]; +assert( + '.footer a color uses {{brandAccent}}', + footerALine && footerALine.includes('{{brandAccent}}'), + footerALine ? `Line ${footerAIdx + 1}: "${footerALine.trim()}"` : '.footer a line not found' +); + +console.log(''); +console.log(`Results: ${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0); \ No newline at end of file From fdc4579aa76ab501aa52b118ed2305e5e835f088 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:29:18 -0400 Subject: [PATCH 05/23] fix: scope CORS to browser routes only, require CORS_ORIGIN (fail closed) The server was setting Access-Control-Allow-Origin: * on every route, including the unauthenticated GET /subscription/email which returns the subscriber's email address. Any website could query known pubkeys and harvest emails. Changes: - src/env.ts: require CORS_ORIGIN env var (fail closed, no wildcard) - src/server.ts: scope CORS middleware to browser-facing routes only, skip /notify (server-to-server), add Vary: Origin header, import CORS_ORIGIN from env instead of defaulting to '*' - .env.template: document new CORS_ORIGIN variable - test/cors.test.sh: verify CORS on browser routes, no CORS on server-to-server routes, Vary: Origin presence --- .env.template | 4 ++ src/env.ts | 2 + src/server.ts | 18 ++++-- test/cors.test.sh | 156 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 test/cors.test.sh diff --git a/.env.template b/.env.template index 9112de5..483338c 100644 --- a/.env.template +++ b/.env.template @@ -12,6 +12,10 @@ BRAND_LOGO= INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band DEFAULT_RELAYS=relay.damus.io,nos.lol SEARCH_RELAYS=relay.nostr.band +# 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= POSTMARK_API_KEY= POSTMARK_SENDER_ADDRESS= PORT=4738 diff --git a/src/env.ts b/src/env.ts index 3588110..3892bcd 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,6 +17,7 @@ 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.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 @@ -31,6 +32,7 @@ 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 CORS_ORIGIN = process.env.CORS_ORIGIN export const PORT = process.env.PORT export const SMTP_HOST = process.env.SMTP_HOST export const SMTP_PORT = process.env.SMTP_PORT diff --git a/src/server.ts b/src/server.ts index eb3fe92..6dbbaf9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,7 @@ 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, 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' @@ -13,23 +13,31 @@ import { verifyEvent } from 'nostr-tools/pure' 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()) diff --git a/test/cors.test.sh b/test/cors.test.sh new file mode 100644 index 0000000..3684e55 --- /dev/null +++ b/test/cors.test.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Passing test: CORS is scoped to browser routes only, no wildcard default. +# +# The fix: src/env.ts now requires CORS_ORIGIN (fail closed, no wildcard). +# src/server.ts applies CORS middleware only to browser-facing routes +# (/, /subscription/*, /confirm, /unsubscribe) and adds Vary: Origin. +# Server-to-server routes (/notify) get no CORS headers. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PORT="${PORT:-4742}" +BASE_URL="http://localhost:$PORT" +PASS=0 +FAIL=0 + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +cleanup() { + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + rm -rf "$PROJECT_DIR/test-data-cors" +} +trap cleanup EXIT + +# Build if needed +cd "$PROJECT_DIR" +if [ ! -d "dist" ]; then + pnpm exec tsc +fi + +SECRET="$(openssl rand -hex 32)" + +# Set CORS_ORIGIN to a specific allowed origin (fail closed — must be set) +export CORS_ORIGIN="https://app.example.com" +export MAILSHIP_SECRET="$SECRET" +export MAILSHIP_NAME="Mailship Test" +export MAILSHIP_URL="$BASE_URL" +export BASE_URL="$BASE_URL" +export POSTMARK_API_KEY="test" +export POSTMARK_SENDER_ADDRESS="test@test.com" +export DEFAULT_RELAYS="wss://relay.damus.io" +export INDEXER_RELAYS="wss://purplepag.es" +export SEARCH_RELAYS="wss://relay.nostr.band" +export PORT="$PORT" +export DATA_DIR="$PROJECT_DIR/test-data-cors" +# SMTP env vars (required by src/env.ts) +export SMTP_HOST="localhost" +export SMTP_PORT="1025" +export SMTP_USER="test" +export SMTP_PASSWORD="test" +export SMTP_FROM="test@test.com" + +mkdir -p "$DATA_DIR" + +echo "=== Starting server on port $PORT (CORS_ORIGIN=$CORS_ORIGIN) ===" +node dist/index.js & +SERVER_PID=$! + +# Poll until server responds +for i in 1 2 3 4 5 6 7 8 9 10; do + if curl -sf "http://localhost:$PORT/" > /dev/null 2>&1; then + echo "Server ready after ${i}s" + break + fi + sleep 1 +done + +if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo -e "${RED}Server failed to start${NC}" + exit 1 +fi + +echo "" +echo "=========================================" +echo " CORS TESTS" +echo "=========================================" +echo "" + +pass() { + PASS=$((PASS + 1)) + echo -e " ${GREEN}✓${NC} $1" +} + +fail() { + FAIL=$((FAIL + 1)) + echo -e " ${RED}✗${NC} $1" +} + +# Test 1: Browser-facing GET /subscription/email returns the configured origin +echo "1. CORS on GET /subscription/email" +CORS_HEADER=$(curl -s -o /dev/null -D - \ + "http://localhost:$PORT/subscription/email?pubkey=test_pubkey_123" \ + -H "Origin: https://app.example.com" 2>/dev/null | grep -ia 'access-control-allow-origin' || true | head -1 | tr -d '\r') + +if echo "$CORS_HEADER" | grep -q 'https://app.example.com'; then + pass "subscription/email returns configured origin (not wildcard)" +elif echo "$CORS_HEADER" | grep -q '\*'; then + fail "BUG: subscription/email still returns wildcard '${CORS_HEADER}'" +else + fail "subscription/email missing Access-Control-Allow-Origin (got: ${CORS_HEADER:-})" +fi + +# Test 2: Vary: Origin is present on browser routes +echo "" +echo "2. Vary: Origin header on browser route" +VARY=$(curl -s -o /dev/null -D - \ + "http://localhost:$PORT/" \ + -H "Origin: https://app.example.com" 2>/dev/null | grep -ia 'vary' || true | head -1 | tr -d '\r') + +if echo "$VARY" | grep -qi 'origin'; then + pass "Vary: Origin is present on GET /" +else + fail "Missing Vary: Origin on GET / (got: ${VARY:-})" +fi + +# Test 3: Server-to-server /notify has NO CORS headers (relay callback) +echo "" +echo "3. No CORS on server-to-server POST /notify/:id" +CORS_NOTIFY=$(curl -s -o /dev/null -D - \ + "http://localhost:$PORT/notify/test-id" \ + -X POST -H "Content-Type: application/json" \ + -H "Origin: https://evil.com" \ + -d '{"id":"abc","relay":"wss://relay.primal.net"}' 2>/dev/null | grep -ia 'access-control-allow-origin' || true | head -1 | tr -d '\r') + +if [ -z "$CORS_NOTIFY" ]; then + pass "/notify has no Access-Control-Allow-Origin (server-to-server route)" +else + fail "BUG: /notify returns '${CORS_NOTIFY}' — server-to-server route should have no CORS" +fi + +# Test 4: CORS methods on preflight for subscription route +echo "" +echo "4. CORS preflight on PUT /subscription/email" +METHODS=$(curl -s -o /dev/null -D - \ + "http://localhost:$PORT/subscription/email" \ + -X OPTIONS -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT" 2>/dev/null | grep -ia 'access-control-allow-methods' || true | head -1 | tr -d '\r') + +if echo "$METHODS" | grep -qi 'PUT'; then + pass "OPTIONS preflight returns allowed methods" +else + fail "Preflight missing allowed methods (got: ${METHODS:-})" +fi + +echo "" +echo "=========================================" +echo " RESULTS: $PASS passed, $FAIL failed" +echo "=========================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 \ No newline at end of file From 49b7b0d83b3890d3a9bb41bb3c2df022aec0ee2a Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:30:50 -0400 Subject: [PATCH 06/23] Align README, docker-compose, .env.template with SMTP mailer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all Postmark references (POSTMARK_API_KEY, POSTMARK_SENDER_ADDRESS) with SMTP configuration (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) across documentation, deployment config, template, and test. - README.md: architecture diagram (Postmark → SMTP) and configuration table - docker-compose.yml: POSTMARK_* env vars replaced with SMTP_* required vars - .env.template: POSTMARK_* entries replaced with SMTP_* entries - test/integration.sh: POSTMARK_* test exports replaced with SMTP_* test values --- .env.template | 7 +++++-- README.md | 9 ++++++--- docker-compose.yml | 7 +++++-- test/integration.sh | 7 +++++-- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.env.template b/.env.template index 885b96f..4f469f2 100644 --- a/.env.template +++ b/.env.template @@ -5,7 +5,10 @@ BASE_URL=http://localhost:4738 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= PORT=4738 DATA_DIR=./data diff --git a/README.md b/README.md index 563d760..a7d388a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Flotilla ──HTTP──▶ Mailship (POST /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 @@ -30,8 +30,11 @@ Flotilla ──HTTP──▶ Mailship (POST /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) | -| `POSTMARK_API_KEY` | ✓ | Postmark API key for sending emails | -| `POSTMARK_SENDER_ADDRESS` | ✓ | Verified sender email in Postmark | +| `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 | | `DEFAULT_RELAYS` | ✓ | Comma-separated list of default relays | | `INDEXER_RELAYS` | ✓ | Comma-separated list of indexer relays | | `SEARCH_RELAYS` | ✓ | Comma-separated list of search relays | diff --git a/docker-compose.yml b/docker-compose.yml index 1d172dd..10eac1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,11 @@ services: - MAILSHIP_NAME=${MAILSHIP_NAME:-Mailship} - MAILSHIP_URL=${MAILSHIP_URL:?required} - BASE_URL=${BASE_URL:?required} - - POSTMARK_API_KEY=${POSTMARK_API_KEY:?required} - - POSTMARK_SENDER_ADDRESS=${POSTMARK_SENDER_ADDRESS:?required} + - SMTP_HOST=${SMTP_HOST:?required} + - SMTP_PORT=${SMTP_PORT:?required} + - SMTP_USER=${SMTP_USER:?required} + - SMTP_PASSWORD=${SMTP_PASSWORD:?required} + - SMTP_FROM=${SMTP_FROM:?required} - DEFAULT_RELAYS=${DEFAULT_RELAYS:-wss://relay.damus.io,wss://relay.primal.net} - INDEXER_RELAYS=${INDEXER_RELAYS:-wss://purplepag.es,wss://relay.damus.io} - SEARCH_RELAYS=${SEARCH_RELAYS:-wss://relay.nostr.band} diff --git a/test/integration.sh b/test/integration.sh index 88dcc3a..e6f629a 100755 --- a/test/integration.sh +++ b/test/integration.sh @@ -46,8 +46,11 @@ 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 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" From 3e667fe3c6a234f7c4e5c2b980ad14a99ac57d77 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:28:53 -0400 Subject: [PATCH 07/23] fix: reschedule cron job when confirmed subscriber changes frequency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a confirmed subscriber calls PUT /subscription/email with a changed frequency, db.updateSubscription updates the row but the running CronJob captured the original frequency in createJob and was never rescheduled. A subscriber switching daily→weekly kept the daily cadence until restart. Fix: in actions.ts:registerSubscription, call worker.registerSubscription(sub) when the subscription is already confirmed, so addJob stops the old job and creates a new one with the updated frequency. Changes: - src/actions.ts: add else branch calling worker.registerSubscription when sub.confirmed_at is set - test/reschedule-on-frequency-change.test.js: new failing-before/passing-after test verifying the cron expression is updated after frequency change Closes mailship-e04 --- src/actions.ts | 8 +- src/worker/email.ts | 6 ++ test/reschedule-on-frequency-change.test.js | 83 +++++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 test/reschedule-on-frequency-change.test.js diff --git a/src/actions.ts b/src/actions.ts index ceec6fe..c28caee 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -23,11 +23,13 @@ export const registerSubscription = instrument( const sub = await db.insertSubscription(pubkey, email, frequency) const callback = `${process.env.BASE_URL}/notify/${sub.id}` - // Only send a confirmation when the subscription is new, unconfirmed, or - // its email address changed. An already-confirmed, unchanged subscription - // (or one where only the frequency changed) skips it. if (!sub.confirmed_at) { + // New or email-changed subscription — send a confirmation email. await mailer.sendConfirm(sub) + } else { + // Already confirmed (e.g. frequency-only change) — reschedule the + // cron job so it uses the new cadence immediately. + worker.registerSubscription(sub) } return { key: sub.key, callback } diff --git a/src/worker/email.ts b/src/worker/email.ts index d60e4a3..7a9b0c6 100644 --- a/src/worker/email.ts +++ b/src/worker/email.ts @@ -6,6 +6,12 @@ import * as db from '../database.js' const jobsById = new Map() +// Test-only accessor to inspect stored jobs +export const getJobCronSource = (id: string): string | undefined => { + const source = jobsById.get(id)?.cronTime.source + return typeof source === 'string' ? source : undefined +} + export const runJob = async (sub: Subscription) => { try { if (!sub.confirmed_at || sub.unsubscribed_at) { diff --git a/test/reschedule-on-frequency-change.test.js b/test/reschedule-on-frequency-change.test.js new file mode 100644 index 0000000..f67229f --- /dev/null +++ b/test/reschedule-on-frequency-change.test.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// FAILING test: frequency change does not reschedule the running cron job. +// +// The fix: actions.ts:registerSubscription calls worker.registerSubscription() +// after a DB update on an already-confirmed subscription, so addJob creates +// a new CronJob with the updated frequency on the fly. +// +// Step 1-2: Create subscription via raw DB (bypass mailer for simplicity) +// Step 3: Register cron job with daily (simulating confirmSubscriptionAction) +// Step 4: Call registerSubscription with new frequency — the bug path +// BEFORE FIX: cron stays daily; AFTER FIX: cron becomes weekly + +import * as db from '../dist/database.js' +import { registerSubscription } from '../dist/actions.js' +import { getJobCronSource, removeJob } from '../dist/worker/email.js' +import { registerSubscription as regSub } from '../dist/worker/index.js' + +let passed = 0 +let failed = 0 + +function assert(label, ok, detail) { + if (ok) { + console.log(` ? ${label}`) + passed++ + } else { + console.log(` ? ${label} -- ${detail || ''}`) + failed++ + } +} + +async function main() { + await db.migrate() + + const pubkey = 'freq-test-' + Date.now() + const email = 'freq-test-' + Date.now() + '@example.com' + + // Step 1: Insert subscription directly (bypass mailer) and confirm + console.log('1. Create confirmed subscription with daily frequency') + const sub = await db.insertSubscription(pubkey, email, 'daily') + assert('subscription created', !!sub, 'insert returned null') + if (!sub) { process.exit(1) } + + const confirmed = await db.confirmSubscription(sub.key) + assert('subscription confirmed', !!confirmed, 'confirm returned null') + if (!confirmed) { process.exit(1) } + + // Step 2: Register cron job with daily (simulating confirmSubscriptionAction) + console.log('\n2. Register cron job with daily frequency') + regSub(confirmed) + const dailySource = getJobCronSource(confirmed.id) + assert( + 'cron source is daily', + dailySource === '0 0 17 * * *', + `expected 0 0 17 * * *, got ${dailySource}` + ) + + // Step 3: Register subscription again with weekly — the bug path. + // BEFORE FIX: registerSubscription skips worker call because + // sub.confirmed_at is set → cron stays daily + // AFTER FIX: registerSubscription calls worker.registerSubscription + // → addJob reschedules → cron becomes weekly + console.log('\n3. Change frequency to weekly via registerSubscription') + await registerSubscription({ pubkey, email, frequency: 'weekly' }) + const weeklySource = getJobCronSource(confirmed.id) + assert( + 'cron source is weekly after frequency change', + weeklySource === '0 0 17 * * 1', + `expected 0 0 17 * * 1, got ${weeklySource}` + ) + + // Cleanup + const updated = await db.getSubscriptionByPubkey(pubkey) + if (updated) removeJob(updated) + + console.log('') + console.log(`Results: ${passed} passed, ${failed} failed`) + process.exit(failed > 0 ? 1 : 0) +} + +main().catch(err => { + console.error('Unhandled error in test:', err) + process.exit(1) +}) \ No newline at end of file From 97e92232c7c65c2cc1f4d16901c98b5f94c535dd Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:31:44 -0400 Subject: [PATCH 08/23] chore: remove unused import of netContext from env.ts eslint flagged netContext as imported but never used (pre-existing). Removing the unused import so the repo's eslint gate passes on the modified area. --- src/env.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/env.ts b/src/env.ts index 3892bcd..a2a5e6b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,7 +1,6 @@ import 'dotenv/config' import { always } from '@welshman/lib' import { normalizeRelayUrl } from '@welshman/util' -import { netContext } from '@welshman/net' import { Nip01Signer } from '@welshman/signer' import { routerContext } from '@welshman/router' From 4497a7dee95815fc8c6afb6bb349808058c1b07c Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:58:05 -0400 Subject: [PATCH 09/23] fix: remove pre-eslint unused-variable errors across src/ Remove 21 unused imports/variables that caused eslint failures: - actions.ts (2): Subscription type import, getCronExpression import - alert.ts (4): CronExpressionParser, tryCatch, int, HOUR - digest.ts (12): now, nth, nthEq, dateToSeconds, getIdFilters, getReplyFilters, Loader, AdapterContext, makeLoader, SocketAdapter, call, loadRelaySelections - env.ts (1): netContext - worker/email.ts (1): purgeJob variable (kept CronJob side-effect) All unused symbols were pre-existing, not related to changed files. tsc --noEmit passes; script/checks now returns 0 on the src check. --- src/actions.ts | 2 -- src/alert.ts | 3 +-- src/digest.ts | 10 +--------- src/env.ts | 1 - src/worker/email.ts | 2 +- 5 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index ceec6fe..661044a 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,6 +1,4 @@ import { instrument } from 'succinct-async' -import type { Subscription } from './alert.js' -import { getCronExpression } from './alert.js' import * as mailer from './mailer.js' import * as worker from './worker/index.js' import * as db from './database.js' diff --git a/src/alert.ts b/src/alert.ts index 01df785..62f5bf8 100644 --- a/src/alert.ts +++ b/src/alert.ts @@ -1,5 +1,4 @@ -import { CronExpressionParser } from 'cron-parser' -import { tryCatch, int, HOUR } from '@welshman/lib' + export type Subscription = { id: string diff --git a/src/digest.ts b/src/digest.ts index 334a2c8..daccaf2 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -1,13 +1,9 @@ import { neventEncode, decode } from 'nostr-tools/nip19' import { spec, - now, sortBy, groupBy, displayList, - nth, - nthEq, - dateToSeconds, secondsToDate, } from '@welshman/lib' import { parse, truncate, renderAsHtml } from '@welshman/content' @@ -15,8 +11,6 @@ import { TrustedEvent, normalizeRelayUrl, getParentId, - getIdFilters, - getReplyFilters, NOTE, COMMENT, REACTION, @@ -24,15 +18,13 @@ 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' diff --git a/src/env.ts b/src/env.ts index 3588110..7795c40 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,7 +1,6 @@ import 'dotenv/config' import { always } from '@welshman/lib' import { normalizeRelayUrl } from '@welshman/util' -import { netContext } from '@welshman/net' import { Nip01Signer } from '@welshman/signer' import { routerContext } from '@welshman/router' diff --git a/src/worker/email.ts b/src/worker/email.ts index d60e4a3..df776a5 100644 --- a/src/worker/email.ts +++ b/src/worker/email.ts @@ -65,7 +65,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 From 0eca03189020b0e031f4a4d8fa401fdff30e204e Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 16:59:15 -0400 Subject: [PATCH 10/23] fix: update stale 'Postmark' comment to 'SMTP' in server.ts The subscription error fallback comment still referenced Postmark after the mailer migration. Update it to reflect the SMTP mailer. --- src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 8671380..bdc913c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -113,7 +113,7 @@ addRoute('post', '/subscription/email', async (req: Request, res: Response) => { const result = await registerSubscription({ pubkey, email, frequency }) res.json(result) } catch (error: any) { - // If the error is just Postmark failing, the subscription was still created + // If the error is just SMTP failing, the subscription was still created // Look up the actual subscription key from the DB const sub = await getSubscriptionByPubkey(pubkey) if (sub) { From 2e2676012604480c502042fbee660a89cd21f1d6 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 17:03:35 -0400 Subject: [PATCH 11/23] fix: remove unused imports and variables flagged by eslint Resolve 21 pre-existing @typescript-eslint/no-unused-vars errors across 5 files (actions.ts, alert.ts, digest.ts, env.ts, worker/email.ts) that were blocking the script/checks gate. All removals are unused imports and unused variable assignments with no runtime impact. --- src/actions.ts | 2 -- src/alert.ts | 3 --- src/digest.ts | 11 +---------- src/env.ts | 1 - src/worker/email.ts | 2 +- 5 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index 459cd01..dd0c082 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,6 +1,4 @@ import { instrument } from 'succinct-async' -import type { Subscription } from './alert.js' -import { getCronExpression } from './alert.js' import * as mailer from './mailer.js' import * as worker from './worker/index.js' import * as db from './database.js' diff --git a/src/alert.ts b/src/alert.ts index 01df785..c61586f 100644 --- a/src/alert.ts +++ b/src/alert.ts @@ -1,6 +1,3 @@ -import { CronExpressionParser } from 'cron-parser' -import { tryCatch, int, HOUR } from '@welshman/lib' - export type Subscription = { id: string key: string diff --git a/src/digest.ts b/src/digest.ts index a693aaa..538c7d5 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -1,36 +1,27 @@ 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' import { TrustedEvent, getParentId, - getIdFilters, - getReplyFilters, NOTE, COMMENT, REACTION, displayProfile, displayPubkey, } from '@welshman/util' -import { Loader, AdapterContext, makeLoader, SocketAdapter } from '@welshman/net' -import { Router, addMinimalFallbacks } from '@welshman/router' -import { call } from '@welshman/lib' +import { Router } from '@welshman/router' import { displayDuration, createElement } from './util.js' import type { Subscription } from './alert.js' import { sendDigest } from './mailer.js' import { profilesByPubkey, - loadRelaySelections, loadProfile, } from './repository.js' diff --git a/src/env.ts b/src/env.ts index 218e4c2..fc7d3ae 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,7 +1,6 @@ import 'dotenv/config' import { always } from '@welshman/lib' import { normalizeRelayUrl } from '@welshman/util' -import { netContext } from '@welshman/net' import { Nip01Signer } from '@welshman/signer' import { routerContext } from '@welshman/router' diff --git a/src/worker/email.ts b/src/worker/email.ts index d60e4a3..df776a5 100644 --- a/src/worker/email.ts +++ b/src/worker/email.ts @@ -65,7 +65,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 From e83cd1f7d2cf703a86842e338932d490a882ff4c Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 14 Sep 2026 13:00:07 -0400 Subject: [PATCH 12/23] =?UTF-8?q?Rename=20Popular/HasPopular=20=E2=86=92?= =?UTF-8?q?=20Latest/HasLatest=20in=20digest=20ts/mjml/render-preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buildParameters function returns events sorted newest-first (sortBy created_at, slice(0,100)), and the mjml section is already labeled 'Latest Activity'. The object keys Popular/HasPopular no longer reflect the semantics, so rename them to Latest/HasLatest. --- script/render-preview.mjs | 4 ++-- src/digest.ts | 4 ++-- src/emails/digest.mjml | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/script/render-preview.mjs b/script/render-preview.mjs index c74d6ea..e2fec6d 100644 --- a/script/render-preview.mjs +++ b/script/render-preview.mjs @@ -16,8 +16,8 @@ const sample = { Duration: '24 hours', Total: 12, TopProfiles: 'bob, carol', - HasPopular: true, - Popular: [ + HasLatest: true, + Latest: [ { Link: 'https://app.flotilla.social/nevent1qqs...', Timestamp: 'Aug 25, 2026 at 9:00 AM', diff --git a/src/digest.ts b/src/digest.ts index 334c00b..ef9850a 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -77,8 +77,8 @@ export class Digest { return { Total: events.length, Duration: displayDuration(Math.floor(Date.now() / 1000) - this.since), - Popular: sorted.map((e) => getEventVariables(e)), - HasPopular: sorted.length > 0, + Latest: sorted.map((e) => getEventVariables(e)), + HasLatest: sorted.length > 0, UserName: displayProfile(userProfile, this.sub.email.split('@')[0]), TopProfiles: displayList(topProfiles.map(([pk]) => displayProfileByPubkey(pk))), } diff --git a/src/emails/digest.mjml b/src/emails/digest.mjml index 0e4ef68..58f5e2d 100644 --- a/src/emails/digest.mjml +++ b/src/emails/digest.mjml @@ -44,11 +44,11 @@ - {{#HasPopular}} + {{#HasLatest}} Latest Activity - {{#Popular}} + {{#Latest}}
@@ -72,10 +72,10 @@
- {{/Popular}} + {{/Latest}}
- {{/HasPopular}} + {{/HasLatest}} From 40ac0476291a6376c4fc695b8da35ed9cc803c59 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 14 Sep 2026 13:04:26 -0400 Subject: [PATCH 13/23] Implement NIP-98 HTTP auth for GET/PUT/DELETE /subscription/email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three browser-facing endpoints now require a kind-27235 HTTP auth event (NIP-98) proving the caller controls the pubkey: - GET /subscription/email — pubkey extracted from auth header instead of query param; returns subscription for the authed pubkey. - PUT /subscription/email — pubkey extracted from auth header instead of trusting a client-supplied body field. - DELETE /subscription/:key — verifies auth pubkey matches subscription owner (returns 403 if mismatch). Server-side: decode base64 'Nostr ' Authorization header, JSON.parse, check kind === 27235, verifyEvent (nostr-tools/pure), then check u / method / payload tags against the request URL / method / body. README updated to reflect 'implemented' auth (not 'planned'). Integration test updated to generate NIP-98 auth headers via a new helper script (script/nip98-auth-header.mjs). --- README.md | 19 +++--- script/nip98-auth-header.mjs | 34 ++++++++++ src/server.ts | 118 +++++++++++++++++++++++++++++------ test/integration.sh | 101 ++++++++++++++++++++++-------- 4 files changed, 220 insertions(+), 52 deletions(-) create mode 100644 script/nip98-auth-header.mjs mode change 100755 => 100644 test/integration.sh diff --git a/README.md b/README.md index 51c9fa7..986ec88 100644 --- a/README.md +++ b/README.md @@ -49,27 +49,30 @@ Flotilla ──HTTP──▶ Mailship (PUT /subscription/email) ### PUT /subscription/email Idempotently register or update an email subscription. Re-sends the confirmation email only when the subscription is new or the email address changed; a frequency -change keeps the existing confirmation. +change keeps the existing confirmation. The pubkey is extracted from the NIP-98 +Authorization header — the body does not include a `pubkey` field. ``` -Body: { email, frequency, pubkey } -Auth: NIP-98 (planned) +Body: { email, frequency } +Auth: NIP-98 (Nostr Authorization header) Response: { key, callback } ``` -### GET /subscription/email?pubkey=... -Look up an existing subscription, so clients can avoid re-registering (and -re-confirming) when settings haven't changed. Returns 404 if none exists. +### GET /subscription/email +Look up an existing subscription for the authenticated pubkey, so clients can +avoid re-registering (and re-confirming) when settings haven't changed. +Returns 404 if none exists. ``` +Auth: NIP-98 (Nostr Authorization header) Response: { key, callback, email, frequency, confirmed } ``` ### DELETE /subscription/:key -Unsubscribe. +Unsubscribe. Verifies the NIP-98 auth pubkey matches the subscription owner. ``` -Auth: NIP-98 (planned) +Auth: NIP-98 (Nostr Authorization header) Response: { ok: true } ``` diff --git a/script/nip98-auth-header.mjs b/script/nip98-auth-header.mjs new file mode 100644 index 0000000..75c0603 --- /dev/null +++ b/script/nip98-auth-header.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +// Generates a NIP-98 Authorization header value ("Nostr ") for +// testing purposes. +// +// Usage: +// node script/nip98-auth-header.mjs [body] +// +// Example: +// export AUTH=$(node script/nip98-auth-header.mjs \ +// "$SECRET" "$BASE_URL/subscription/email" PUT '{"email":"a@b.com","frequency":"daily"}') +// curl -H "Authorization: $AUTH" ... + +import { makeHttpAuth, makeHttpAuthHeader } from '@welshman/util' +import { Nip01Signer } from '@welshman/signer' + +const [, , secret, url, method, body] = process.argv + +if (!secret || !url) { + console.error('Usage: node script/nip98-auth-header.mjs [body]') + process.exit(1) +} + +const signer = Nip01Signer.fromSecret(secret) + +// Create the unsigned auth event template +const event = await makeHttpAuth(url, method || 'GET', body || undefined) + +// Stamp (created_at, pubkey, id) and sign +const signed = await signer.sign(event) + +// Encode as "Nostr " +const header = makeHttpAuthHeader(signed) + +console.log(header) \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 5ee771e..5029290 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,9 +7,79 @@ import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, Act import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js' import { load } from '@welshman/net' import { getIdFilters } from '@welshman/util' +import crypto from 'crypto' import { verifyEvent } from 'nostr-tools/pure' -// Endpoints +// ── NIP-98 HTTP Auth ──────────────────────────────────────────────────── + +// Verify a NIP-98 Authorization header and return the authenticated pubkey, +// or null if the header is missing, malformed, or invalid. +// +// The client constructs the auth event via @welshman/util: +// makeHttpAuth(url, method, body) → event +// makeHttpAuthHeader(event) → "Nostr " +// +// We decode, verify kind=27235, verifyEvent, then check u / method / payload +// tags against the actual request URL / method / body. +const verifyNip98Auth = async (req: Request): Promise => { + const authHeader = req.headers.authorization + if (!authHeader) return null + + // Format: "Nostr " + const match = authHeader.match(/^Nostr\s+(.+)$/) + if (!match) return null + + // Decode base64 + let eventJson: string + try { + eventJson = Buffer.from(match[1], 'base64').toString('utf-8') + } catch { + return null + } + + // Parse event + let event: any + try { + event = JSON.parse(eventJson) + } catch { + return null + } + + // Must be kind 27235 (HTTP Auth) + if (event.kind !== 27235) return null + + // Verify event signature and id hash + if (!verifyEvent(event)) return null + + const tags = event.tags || [] + + // Find required tags + const uTag = tags.find((t: string[]) => t[0] === 'u') + const methodTag = tags.find((t: string[]) => t[0] === 'method') + const payloadTag = tags.find((t: string[]) => t[0] === 'payload') + + // Build the full URL the server received + const expectedUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}` + + // u tag must match the request URL exactly + if (!uTag || uTag[1] !== expectedUrl) return null + + // method tag must match the HTTP method (upper case) + if (!methodTag || methodTag[1] !== req.method.toUpperCase()) return null + + // For requests with a body, check payload tag is the SHA256 of the body + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method.toUpperCase())) { + if (req.body && Object.keys(req.body).length > 0) { + const bodyStr = JSON.stringify(req.body) + const expectedPayload = crypto.createHash('sha256').update(bodyStr).digest('hex') + if (!payloadTag || payloadTag[1] !== expectedPayload) return null + } + } + + return event.pubkey as string +} + +// ── Endpoints ────────────────────────────────────────────────────────── export const server: express.Application = express() @@ -85,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) @@ -111,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' }) @@ -123,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) @@ -152,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 }) diff --git a/test/integration.sh b/test/integration.sh old mode 100755 new mode 100644 index d48122a..17ed3c5 --- a/test/integration.sh +++ b/test/integration.sh @@ -38,11 +38,31 @@ 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" @@ -117,11 +137,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) @@ -132,9 +154,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" @@ -142,20 +186,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) @@ -173,25 +217,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" @@ -199,21 +243,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 "=========================================" From b94018c11efec79e0eefd25b7e0af0bab56700c8 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 14 Sep 2026 13:07:16 -0400 Subject: [PATCH 14/23] Fix integration test: add missing CORS_ORIGIN env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test was failing because src/env.ts requires CORS_ORIGIN to be set, but the integration test never exported it. This is a pre-existing setup gap exposed by running the test — not a NIP-98 regression. --- test/integration.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/test/integration.sh b/test/integration.sh index 17ed3c5..c21a0d4 100644 --- a/test/integration.sh +++ b/test/integration.sh @@ -75,6 +75,7 @@ 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" From 737f949f9ba594fdc480a5c547cc787593cd70ab Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 14 Sep 2026 13:07:34 -0400 Subject: [PATCH 15/23] fix digest job race: delete sent events by ID, not timestamp Bug: runJob computed since = sub.last_digest_at, fetched events with received_at > since, sent the digest (slow), then deleted ALL events with received_at > since. Any /notify event that arrived between the fetch and the delete was also received_at > since, so it was deleted without ever being sent in a digest. Two changes: 1. Delete by exact event IDs (src/database.ts, src/worker/email.ts): Added deleteEventsByIds(subscriptionId, eventIds) which deletes only the events that were actually fetched + sent. The old timestamp-based delete is retained but no longer called from runJob. 2. Re-fetch subscription on each cron tick (src/worker/email.ts): createJob's closure captured the original sub, so sub.last_digest_at stayed stale in memory. Every subsequent tick recomputed since from the old value, re-fetching and re-sending duplicate events. Now each tick re-fetches the subscription from the DB via getSubscriptionById before calling runJob. Fixes bead mailship-091 --- src/database.ts | 12 +++ src/worker/email.ts | 14 +++- test/event-arrival-race.test.js | 139 ++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 test/event-arrival-race.test.js diff --git a/src/database.ts b/src/database.ts index 336e293..718e225 100644 --- a/src/database.ts +++ b/src/database.ts @@ -319,6 +319,18 @@ export const deleteEventsForSubscription = instrument( } ) +export const deleteEventsByIds = instrument( + 'database.deleteEventsByIds', + async (subscriptionId: string, eventIds: string[]) => { + if (eventIds.length === 0) return + const placeholders = eventIds.map(() => '?').join(',') + await run( + `DELETE FROM events WHERE subscription_id = ? AND id IN (${placeholders})`, + [subscriptionId, ...eventIds] + ) + } +) + export const purgeEventsOlderThan = instrument( 'database.purgeEventsOlderThan', async (timestamp: number) => { diff --git a/src/worker/email.ts b/src/worker/email.ts index 7ef4076..06a546e 100644 --- a/src/worker/email.ts +++ b/src/worker/email.ts @@ -33,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') @@ -49,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({ diff --git a/test/event-arrival-race.test.js b/test/event-arrival-race.test.js new file mode 100644 index 0000000..8a938c7 --- /dev/null +++ b/test/event-arrival-race.test.js @@ -0,0 +1,139 @@ +#!/usr/bin/env node +// Test: event-arrival race in digest job — fix verification +// +// The original bug: runJob fetched events via getEventsForSubscription(sub.id, since), +// sent the digest (slow), then deleted events via deleteEventsForSubscription(sub.id, since) +// which deletes EVERY row with received_at > since. Any event that arrived between +// the fetch and the delete was also received_at > since, so it was deleted without +// ever being emailed. +// +// The fix: deleteEventsByIds(sub.id, sentEventIds) deletes only the exact event IDs +// that were fetched and sent. A late-arriving event (inserted after the fetch) has +// a different ID and is not touched. +// +// This test simulates the sequence with the fixed approach: +// 1. Create a confirmed subscription with a known last_digest_at +// 2. Insert event A +// 3. Fetch events for the subscription (simulating runJob's fetch) +// 4. Insert event B after the fetch (simulating a /notify arriving during digest send) +// 5. Delete ONLY the event A IDs (the fix — instead of timestamp-based delete) +// 6. Verify event B survives (it arrived after fetch and was never sent) + +import * as db from '../dist/database.js' + +let passed = 0 +let failed = 0 + +function assert(label, ok, detail) { + if (ok) { + console.log(` ? ${label}`) + passed++ + } else { + console.log(` ? ${label} -- ${detail || ''}`) + failed++ + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +async function main() { + await db.migrate() + + const pubkey = 'race-test-' + Date.now() + const email = 'race-test-' + Date.now() + '@example.com' + + // Step 1: Create and confirm subscription + console.log('1. Create confirmed subscription') + const sub = await db.insertSubscription(pubkey, email, 'daily') + assert('subscription created', !!sub, 'insert returned null') + if (!sub) { process.exit(1) } + + const confirmed = await db.confirmSubscription(sub.key) + assert('subscription confirmed', !!confirmed, 'confirm returned null') + if (!confirmed) { process.exit(1) } + + // Set last_digest_at to a known time well in the past. + // This is the "since" value runJob would use. + const since = Math.floor(Date.now() / 1000) - 60 // 60 seconds ago + await db.updateLastDigestAt(confirmed.id, since) + + // Wait 1s so event received_at timestamps (whole seconds) are strictly > since. + await sleep(1100) + + // Step 2: Insert Event A — simulates events that trigger a digest run + console.log('\n2. Insert Event A (triggers digest)') + const eventA_id = 'race-event-a-' + Date.now() + 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, confirmed.id, eventA, 'wss://relay.damus.io') + assert('Event A stored', storedA === true, `got ${storedA}`) + + // Step 3: Fetch events — simulating what runJob does with since=last_digest_at + console.log('\n3. Fetch events for subscription (simulating runJob fetch)') + const fetched = await db.getEventsForSubscription(confirmed.id, since) + assert('Event A was fetched', fetched.some(e => e.id === eventA_id), + `fetched ids: [${fetched.map(e => e.id).join(', ')}]`) + + // Step 4: Insert Event B after the fetch — simulating a /notify arriving + // during the slow digest send (profile loads, MJML render, SMTP). + console.log('\n4. Insert Event B AFTER fetch (simulating event arriving during digest send)') + await sleep(100) // ensure distinct received_at + const eventB_id = 'race-event-b-' + Date.now() + 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, confirmed.id, eventB, 'wss://relay.damus.io') + assert('Event B stored', storedB === true, `got ${storedB}`) + + // Step 5: Delete ONLY the exact event IDs that were fetched + sent — THE FIX. + // Unlike the old timestamp-based delete, this does NOT touch Event B + // because Event B has a different ID. + console.log('\n5. Delete events by exact IDs (the fix — deleteEventsByIds)') + const sentIds = fetched.map(e => e.id) + await db.deleteEventsByIds(confirmed.id, sentIds) + console.log(' deleted ids:', JSON.stringify(sentIds)) + + // Step 6: Check which events remain. + // CORRECT BEHAVIOR: Only Event A (which was sent) is deleted. + // Event B (which arrived after fetch) must survive. + console.log('\n6. Check remaining events after delete') + const remaining = await db.getEventsForSubscription(confirmed.id, since - 10) + + const eventB_survived = remaining.some(e => e.id === eventB_id) + assert( + 'Event B survives the delete (it arrived after fetch and was never sent)', + eventB_survived, + `Event B was deleted despite never being sent. ` + + `remaining events: [${remaining.map(e => e.id).join(', ')}]` + ) + + // Verify Event A is gone (it was sent, so deletion is correct for A) + const eventA_survived = remaining.some(e => e.id === eventA_id) + assert( + 'Event A is deleted (it was fetched and sent)', + !eventA_survived, + `Event A should have been deleted but is still present` + ) + + console.log('') + console.log(`Results: ${passed} passed, ${failed} failed`) + process.exit(failed > 0 ? 1 : 0) +} + +main().catch(err => { + console.error('Unhandled error in test:', err) + process.exit(1) +}) \ No newline at end of file From 02dd5944c8ffe14eece6cc3b4baa1be4480eb865 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 14 Sep 2026 13:14:05 -0400 Subject: [PATCH 16/23] align PORT default: make optional with 4738, update README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/env.ts: remove required-PORT throw, default to '4738' when unset - README.md: change documented default from 3000 to 4738 All other files (.env.template, Dockerfile, docker-compose.yml) already use 4738 — no further changes needed. --- README.md | 2 +- src/env.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 51c9fa7..0dbb9f0 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Flotilla ──HTTP──▶ Mailship (PUT /subscription/email) | `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 diff --git a/src/env.ts b/src/env.ts index a2a5e6b..648795a 100644 --- a/src/env.ts +++ b/src/env.ts @@ -15,7 +15,7 @@ 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.') @@ -32,7 +32,7 @@ export const DEFAULT_RELAYS = process.env.DEFAULT_RELAYS.split(',').map(normaliz export const INDEXER_RELAYS = process.env.INDEXER_RELAYS.split(',').map(normalizeRelayUrl) export const SEARCH_RELAYS = process.env.SEARCH_RELAYS.split(',').map(normalizeRelayUrl) export const CORS_ORIGIN = process.env.CORS_ORIGIN -export const PORT = process.env.PORT +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 From 13b1e9d00f262c13f1f2c2115129c22af836fb26 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 14 Sep 2026 13:29:54 -0400 Subject: [PATCH 17/23] Standardize tests on vitest - Add vitest as dev dep (pnpm add -D vitest), create vitest.config.ts with globals:true and setupFiles - Migrate digest-template, normalize-relay-url, reschedule-on-frequency-change, and event-arrival-race from raw JS + hand-rolled asserts to vitest describe/it/expect, as .test.ts (port all assertions without weakening) - Create test/setup.ts with env vars needed by env.ts/mailer.ts - DELETE test/web-ui.test.js: dead test for removed web UI (PR #1) - package.json: add test:unit script (vitest run), keep test = bash E2E - script/checks: add pnpm test:unit after build - README: document pnpm test:unit / pnpm test --- README.md | 5 +- package.json | 4 +- pnpm-lock.yaml | Bin 150827 -> 172089 bytes script/checks | 5 +- test/digest-template.test.js | 74 ----------- test/digest-template.test.ts | 33 +++++ test/event-arrival-race.test.js | 139 -------------------- test/event-arrival-race.test.ts | 89 +++++++++++++ test/normalize-relay-url.test.js | 68 ---------- test/normalize-relay-url.test.ts | 22 ++++ test/reschedule-on-frequency-change.test.js | 83 ------------ test/reschedule-on-frequency-change.test.ts | 42 ++++++ test/setup.ts | 23 ++++ test/web-ui.test.js | 61 --------- vitest.config.ts | 9 ++ 15 files changed, 227 insertions(+), 430 deletions(-) delete mode 100644 test/digest-template.test.js create mode 100644 test/digest-template.test.ts delete mode 100644 test/event-arrival-race.test.js create mode 100644 test/event-arrival-race.test.ts delete mode 100644 test/normalize-relay-url.test.js create mode 100644 test/normalize-relay-url.test.ts delete mode 100644 test/reschedule-on-frequency-change.test.js create mode 100644 test/reschedule-on-frequency-change.test.ts create mode 100644 test/setup.ts delete mode 100644 test/web-ui.test.js create mode 100644 vitest.config.ts diff --git a/README.md b/README.md index 51c9fa7..3af5090 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,9 @@ 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 ``` ## Forked from Anchor diff --git a/package.json b/package.json index 9ac4de2..306a4b7 100644 --- a/package.json +++ b/package.json @@ -11,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": { @@ -29,7 +30,8 @@ "globals": "^15.15.0", "onchange": "^7.1.0", "prettier": "^3.6.2", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "vitest": "^5.0.0" }, "dependencies": { "@types/node": "^22.18.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15e0f817fa1b101a5c14be4aed0f233305dfd049..f444b0528ec9a5b99489fa6cd9b34333329db50d 100644 GIT binary patch delta 15312 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 diff --git a/script/checks b/script/checks index 310e1d1..287929d 100755 --- a/script/checks +++ b/script/checks @@ -1,6 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# mailship CI checks — type-check + lint + build +# mailship CI checks — type-check + lint + build + unit tests pnpm run check -pnpm run build \ No newline at end of file +pnpm run build +pnpm test:unit \ No newline at end of file diff --git a/test/digest-template.test.js b/test/digest-template.test.js deleted file mode 100644 index 47de77f..0000000 --- a/test/digest-template.test.js +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env node -// FAILING test: digest.mjml hardcodes #7161FF instead of using {{brandAccent}} -// -// The bug: in src/emails/digest.mjml line 8 and line 22, the CSS for -// .event-item border-left and .footer a color hardcode #7161FF even though -// {{brandAccent}} is passed into the template by mailer.ts and used -// elsewhere (lines 15, 34). When BRAND_ACCENT is customized, the event-item -// border and footer links stay the default purple. -// -// The fix: replace both hardcoded #7161FF values with {{brandAccent}}. - -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'); - -let passed = 0; -let failed = 0; - -function assert(label, ok, detail) { - if (ok) { - console.log(` ✓ ${label}`); - passed++; - } else { - console.log(` ✗ ${label} — ${detail || ''}`); - failed++; - } -} - -// Read the MJML template -const source = readFileSync(templatePath, 'utf8'); -const lines = source.split('\n'); - -console.log('1. No hardcoded #7161FF in .event-item or .footer a CSS'); - -// Check .event-item border-left doesn't have #7161FF -const eventItemLineIdx = lines.findIndex(l => l.includes('.event-item')); -const hasEventItemHardcoded = lines.some(l => l.includes('.event-item') && l.includes('#7161FF')); -assert( - '.event-item border-left does NOT hardcode #7161FF', - !hasEventItemHardcoded, - hasEventItemHardcoded ? `Line ${eventItemLineIdx + 1} still has #7161FF: "${lines[eventItemLineIdx].trim()}"` : '' -); - -// Check .footer a color doesn't have #7161FF -const footerAIdx = lines.findIndex(l => l.includes('.footer a')); -const hasFooterHardcoded = lines.some(l => l.includes('.footer a') && l.includes('#7161FF')); -assert( - '.footer a color does NOT hardcode #7161FF', - !hasFooterHardcoded, - hasFooterHardcoded ? `Line ${footerAIdx + 1} still has #7161FF: "${lines[footerAIdx].trim()}"` : '' -); - -// Check .event-item border-left uses {{brandAccent}} -const eventItemLine = lines[eventItemLineIdx]; -assert( - '.event-item border-left uses {{brandAccent}}', - eventItemLine && eventItemLine.includes('{{brandAccent}}'), - eventItemLine ? `Line ${eventItemLineIdx + 1}: "${eventItemLine.trim()}"` : '.event-item line not found' -); - -// Check .footer a color uses {{brandAccent}} -const footerALine = lines[footerAIdx]; -assert( - '.footer a color uses {{brandAccent}}', - footerALine && footerALine.includes('{{brandAccent}}'), - footerALine ? `Line ${footerAIdx + 1}: "${footerALine.trim()}"` : '.footer a line not found' -); - -console.log(''); -console.log(`Results: ${passed} passed, ${failed} failed`); -process.exit(failed > 0 ? 1 : 0); \ No newline at end of file diff --git a/test/digest-template.test.ts b/test/digest-template.test.ts new file mode 100644 index 0000000..6615a58 --- /dev/null +++ b/test/digest-template.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const templatePath = join(__dirname, '..', 'src', 'emails', 'digest.mjml') +const source = readFileSync(templatePath, 'utf8') +const lines = source.split('\n') + +describe('digest.mjml brandAccent usage', () => { + it('.event-item border-left does NOT hardcode #7161FF', () => { + const hasHardcoded = lines.some(l => l.includes('.event-item') && l.includes('#7161FF')) + expect(hasHardcoded).toBe(false) + }) + + it('.footer a color does NOT hardcode #7161FF', () => { + const hasHardcoded = lines.some(l => l.includes('.footer a') && l.includes('#7161FF')) + expect(hasHardcoded).toBe(false) + }) + + it('.event-item border-left uses {{brandAccent}}', () => { + const eventItemLine = lines.find(l => l.includes('.event-item')) + expect(eventItemLine).toBeDefined() + expect(eventItemLine).toContain('{{brandAccent}}') + }) + + it('.footer a color uses {{brandAccent}}', () => { + const footerALine = lines.find(l => l.includes('.footer a')) + expect(footerALine).toBeDefined() + expect(footerALine).toContain('{{brandAccent}}') + }) +}) \ No newline at end of file diff --git a/test/event-arrival-race.test.js b/test/event-arrival-race.test.js deleted file mode 100644 index 8a938c7..0000000 --- a/test/event-arrival-race.test.js +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env node -// Test: event-arrival race in digest job — fix verification -// -// The original bug: runJob fetched events via getEventsForSubscription(sub.id, since), -// sent the digest (slow), then deleted events via deleteEventsForSubscription(sub.id, since) -// which deletes EVERY row with received_at > since. Any event that arrived between -// the fetch and the delete was also received_at > since, so it was deleted without -// ever being emailed. -// -// The fix: deleteEventsByIds(sub.id, sentEventIds) deletes only the exact event IDs -// that were fetched and sent. A late-arriving event (inserted after the fetch) has -// a different ID and is not touched. -// -// This test simulates the sequence with the fixed approach: -// 1. Create a confirmed subscription with a known last_digest_at -// 2. Insert event A -// 3. Fetch events for the subscription (simulating runJob's fetch) -// 4. Insert event B after the fetch (simulating a /notify arriving during digest send) -// 5. Delete ONLY the event A IDs (the fix — instead of timestamp-based delete) -// 6. Verify event B survives (it arrived after fetch and was never sent) - -import * as db from '../dist/database.js' - -let passed = 0 -let failed = 0 - -function assert(label, ok, detail) { - if (ok) { - console.log(` ? ${label}`) - passed++ - } else { - console.log(` ? ${label} -- ${detail || ''}`) - failed++ - } -} - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)) -} - -async function main() { - await db.migrate() - - const pubkey = 'race-test-' + Date.now() - const email = 'race-test-' + Date.now() + '@example.com' - - // Step 1: Create and confirm subscription - console.log('1. Create confirmed subscription') - const sub = await db.insertSubscription(pubkey, email, 'daily') - assert('subscription created', !!sub, 'insert returned null') - if (!sub) { process.exit(1) } - - const confirmed = await db.confirmSubscription(sub.key) - assert('subscription confirmed', !!confirmed, 'confirm returned null') - if (!confirmed) { process.exit(1) } - - // Set last_digest_at to a known time well in the past. - // This is the "since" value runJob would use. - const since = Math.floor(Date.now() / 1000) - 60 // 60 seconds ago - await db.updateLastDigestAt(confirmed.id, since) - - // Wait 1s so event received_at timestamps (whole seconds) are strictly > since. - await sleep(1100) - - // Step 2: Insert Event A — simulates events that trigger a digest run - console.log('\n2. Insert Event A (triggers digest)') - const eventA_id = 'race-event-a-' + Date.now() - 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, confirmed.id, eventA, 'wss://relay.damus.io') - assert('Event A stored', storedA === true, `got ${storedA}`) - - // Step 3: Fetch events — simulating what runJob does with since=last_digest_at - console.log('\n3. Fetch events for subscription (simulating runJob fetch)') - const fetched = await db.getEventsForSubscription(confirmed.id, since) - assert('Event A was fetched', fetched.some(e => e.id === eventA_id), - `fetched ids: [${fetched.map(e => e.id).join(', ')}]`) - - // Step 4: Insert Event B after the fetch — simulating a /notify arriving - // during the slow digest send (profile loads, MJML render, SMTP). - console.log('\n4. Insert Event B AFTER fetch (simulating event arriving during digest send)') - await sleep(100) // ensure distinct received_at - const eventB_id = 'race-event-b-' + Date.now() - 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, confirmed.id, eventB, 'wss://relay.damus.io') - assert('Event B stored', storedB === true, `got ${storedB}`) - - // Step 5: Delete ONLY the exact event IDs that were fetched + sent — THE FIX. - // Unlike the old timestamp-based delete, this does NOT touch Event B - // because Event B has a different ID. - console.log('\n5. Delete events by exact IDs (the fix — deleteEventsByIds)') - const sentIds = fetched.map(e => e.id) - await db.deleteEventsByIds(confirmed.id, sentIds) - console.log(' deleted ids:', JSON.stringify(sentIds)) - - // Step 6: Check which events remain. - // CORRECT BEHAVIOR: Only Event A (which was sent) is deleted. - // Event B (which arrived after fetch) must survive. - console.log('\n6. Check remaining events after delete') - const remaining = await db.getEventsForSubscription(confirmed.id, since - 10) - - const eventB_survived = remaining.some(e => e.id === eventB_id) - assert( - 'Event B survives the delete (it arrived after fetch and was never sent)', - eventB_survived, - `Event B was deleted despite never being sent. ` + - `remaining events: [${remaining.map(e => e.id).join(', ')}]` - ) - - // Verify Event A is gone (it was sent, so deletion is correct for A) - const eventA_survived = remaining.some(e => e.id === eventA_id) - assert( - 'Event A is deleted (it was fetched and sent)', - !eventA_survived, - `Event A should have been deleted but is still present` - ) - - console.log('') - console.log(`Results: ${passed} passed, ${failed} failed`) - process.exit(failed > 0 ? 1 : 0) -} - -main().catch(err => { - console.error('Unhandled error in test:', err) - process.exit(1) -}) \ No newline at end of file diff --git a/test/event-arrival-race.test.ts b/test/event-arrival-race.test.ts new file mode 100644 index 0000000..93e5c44 --- /dev/null +++ b/test/event-arrival-race.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import * as db from '../src/database.js' + +const pubkey = 'race-test-' + Date.now() +const email = 'race-test-' + Date.now() + '@example.com' +let sub: any = null +let since = 0 +const eventA_id = 'race-event-a-' + Date.now() +const eventB_id = 'race-event-b-' + Date.now() + +// Captured after the initial fetch, before Event B is inserted +let fetchedBeforeB: string[] = [] + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +describe('Event-arrival race in digest job', () => { + beforeAll(async () => { + await db.migrate() + + // Create and confirm subscription + const s = await db.insertSubscription(pubkey, email, 'daily') + expect(s).toBeTruthy() + const confirmed = await db.confirmSubscription(s.key) + expect(confirmed).toBeTruthy() + sub = confirmed + + // Set last_digest_at to 60 seconds ago (the "since" value runJob would use) + since = Math.floor(Date.now() / 1000) - 60 + await db.updateLastDigestAt(sub.id, since) + + // Wait 1.1s so event received_at timestamps are strictly > since + await sleep(1100) + }) + + it('stores Event A (triggers digest)', async () => { + const eventA = { + id: eventA_id, + kind: 1, + pubkey: 'abc', + content: 'event A content', + created_at: Math.floor(Date.now() / 1000), + tags: [], + } + const storedA = await db.insertEvent(eventA_id, sub.id, eventA, 'wss://relay.damus.io') + expect(storedA).toBe(true) + }) + + // Fetch events BEFORE Event B is inserted (simulating runJob's fetch + // before a /notify arrives during the digest send). Save the IDs we + // fetched so we delete exactly those later. + it('fetches events and captures IDs (simulating runJob fetch)', async () => { + const fetched = await db.getEventsForSubscription(sub.id, since) + expect(fetched.some((e: any) => e.id === eventA_id)).toBe(true) + fetchedBeforeB = fetched.map((e: any) => e.id) + }) + + it('stores Event B AFTER fetch (simulating arrival during digest send)', async () => { + await sleep(100) + const eventB = { + id: eventB_id, + kind: 1, + pubkey: 'def', + content: 'event B content — arrived during send', + created_at: Math.floor(Date.now() / 1000), + tags: [], + } + const storedB = await db.insertEvent(eventB_id, sub.id, eventB, 'wss://relay.damus.io') + expect(storedB).toBe(true) + }) + + // Delete using the IDs captured before Event B was inserted. + // This simulates the fix: deleteEventsByIds, not timestamp-based delete. + it('deletes only previously-fetched event IDs (the fix) and leaves event B', async () => { + await db.deleteEventsByIds(sub.id, fetchedBeforeB) + + // Event B must survive (it arrived after fetch and was never sent) + const remaining = await db.getEventsForSubscription(sub.id, since - 10) + const eventB_survived = remaining.some((e: any) => e.id === eventB_id) + expect(eventB_survived).toBe(true) + }) + + it('Event A is deleted (it was fetched and sent)', async () => { + const remaining = await db.getEventsForSubscription(sub.id, since - 10) + const eventA_survived = remaining.some((e: any) => e.id === eventA_id) + expect(eventA_survived).toBe(false) + }) +}) \ No newline at end of file diff --git a/test/normalize-relay-url.test.js b/test/normalize-relay-url.test.js deleted file mode 100644 index 14a48f6..0000000 --- a/test/normalize-relay-url.test.js +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env node -// FAILING test: calling normalizeRelayUrl(undefined) crashes with -// TypeError: can't access property "match", A is undefined -// -// The bug: main.ts called normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY) -// without guarding against the env var being undefined. When the env var is -// not set, normalizeRelayUrl crashes because it calls url.match(...) on -// undefined. -// -// The fix: replace the bare call with a ternary guard: -// const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY -// ? normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY) -// : undefined -// -// This test validates that the guard pattern works correctly: when the env var -// is falsy (undefined, empty), the app gracefully sets NOTIFIER_RELAY to -// undefined without crashing. When it's a valid URL, normalization works. - -import { normalizeRelayUrl } from '/home/gascity/mailship/node_modules/.pnpm/@welshman+util@0.6.3_typescript@5.9.2/node_modules/@welshman/util/dist/util/src/Relay.js'; - -let passed = 0; -let failed = 0; - -function assert(label, ok, detail) { - if (ok) { - console.log(` ✓ ${label}`); - passed++; - } else { - console.log(` ✗ ${label} — ${detail || ''}`); - failed++; - } -} - -// Test 1: Guarded normalizeRelayUrl with undefined (the exact fix pattern) -console.log('1. Guarded normalizeRelayUrl with undefined (the fix)'); -const undefinedInput = undefined; -const guarded1 = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined; -assert( - 'guarded normalizeRelayUrl with undefined should not crash, result is undefined', - guarded1 === undefined, - `got ${guarded1}` -); - -// Test 2: Guard with empty string -console.log(''); -console.log('2. Guarded normalizeRelayUrl with empty string'); -const emptyInput = ''; -const guarded2 = emptyInput ? normalizeRelayUrl(emptyInput) : undefined; -assert( - 'guarded normalizeRelayUrl with "" should not crash, result is undefined', - guarded2 === undefined, - `got ${guarded2}` -); - -// Test 3: Guard with a valid relay still works -console.log(''); -console.log('3. Guarded normalizeRelayUrl with valid relay'); -const validInput = 'wss://relay.damus.io'; -const guarded3 = validInput ? normalizeRelayUrl(validInput) : undefined; -assert( - 'guarded normalizeRelayUrl with valid input still normalizes correctly', - guarded3 === 'wss://relay.damus.io/', - `got ${guarded3}` -); - -console.log(''); -console.log(`Results: ${passed} passed, ${failed} failed`); -process.exit(failed > 0 ? 1 : 0); diff --git a/test/normalize-relay-url.test.ts b/test/normalize-relay-url.test.ts new file mode 100644 index 0000000..7a5c2ab --- /dev/null +++ b/test/normalize-relay-url.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { normalizeRelayUrl } from '@welshman/util' + +describe('Guarded normalizeRelayUrl', () => { + it('guarded with undefined should not crash, result is undefined', () => { + const undefinedInput: string | undefined = undefined + const guarded = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined + expect(guarded).toBeUndefined() + }) + + it('guarded with empty string should not crash, result is undefined', () => { + const emptyInput = '' + const guarded = emptyInput ? normalizeRelayUrl(emptyInput) : undefined + expect(guarded).toBeUndefined() + }) + + it('guarded with valid relay still normalizes correctly', () => { + const validInput = 'wss://relay.damus.io' + const guarded = validInput ? normalizeRelayUrl(validInput) : undefined + expect(guarded).toBe('wss://relay.damus.io/') + }) +}) \ No newline at end of file diff --git a/test/reschedule-on-frequency-change.test.js b/test/reschedule-on-frequency-change.test.js deleted file mode 100644 index f67229f..0000000 --- a/test/reschedule-on-frequency-change.test.js +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node -// FAILING test: frequency change does not reschedule the running cron job. -// -// The fix: actions.ts:registerSubscription calls worker.registerSubscription() -// after a DB update on an already-confirmed subscription, so addJob creates -// a new CronJob with the updated frequency on the fly. -// -// Step 1-2: Create subscription via raw DB (bypass mailer for simplicity) -// Step 3: Register cron job with daily (simulating confirmSubscriptionAction) -// Step 4: Call registerSubscription with new frequency — the bug path -// BEFORE FIX: cron stays daily; AFTER FIX: cron becomes weekly - -import * as db from '../dist/database.js' -import { registerSubscription } from '../dist/actions.js' -import { getJobCronSource, removeJob } from '../dist/worker/email.js' -import { registerSubscription as regSub } from '../dist/worker/index.js' - -let passed = 0 -let failed = 0 - -function assert(label, ok, detail) { - if (ok) { - console.log(` ? ${label}`) - passed++ - } else { - console.log(` ? ${label} -- ${detail || ''}`) - failed++ - } -} - -async function main() { - await db.migrate() - - const pubkey = 'freq-test-' + Date.now() - const email = 'freq-test-' + Date.now() + '@example.com' - - // Step 1: Insert subscription directly (bypass mailer) and confirm - console.log('1. Create confirmed subscription with daily frequency') - const sub = await db.insertSubscription(pubkey, email, 'daily') - assert('subscription created', !!sub, 'insert returned null') - if (!sub) { process.exit(1) } - - const confirmed = await db.confirmSubscription(sub.key) - assert('subscription confirmed', !!confirmed, 'confirm returned null') - if (!confirmed) { process.exit(1) } - - // Step 2: Register cron job with daily (simulating confirmSubscriptionAction) - console.log('\n2. Register cron job with daily frequency') - regSub(confirmed) - const dailySource = getJobCronSource(confirmed.id) - assert( - 'cron source is daily', - dailySource === '0 0 17 * * *', - `expected 0 0 17 * * *, got ${dailySource}` - ) - - // Step 3: Register subscription again with weekly — the bug path. - // BEFORE FIX: registerSubscription skips worker call because - // sub.confirmed_at is set → cron stays daily - // AFTER FIX: registerSubscription calls worker.registerSubscription - // → addJob reschedules → cron becomes weekly - console.log('\n3. Change frequency to weekly via registerSubscription') - await registerSubscription({ pubkey, email, frequency: 'weekly' }) - const weeklySource = getJobCronSource(confirmed.id) - assert( - 'cron source is weekly after frequency change', - weeklySource === '0 0 17 * * 1', - `expected 0 0 17 * * 1, got ${weeklySource}` - ) - - // Cleanup - const updated = await db.getSubscriptionByPubkey(pubkey) - if (updated) removeJob(updated) - - console.log('') - console.log(`Results: ${passed} passed, ${failed} failed`) - process.exit(failed > 0 ? 1 : 0) -} - -main().catch(err => { - console.error('Unhandled error in test:', err) - process.exit(1) -}) \ No newline at end of file diff --git a/test/reschedule-on-frequency-change.test.ts b/test/reschedule-on-frequency-change.test.ts new file mode 100644 index 0000000..208ff93 --- /dev/null +++ b/test/reschedule-on-frequency-change.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import * as db from '../src/database.js' +import { registerSubscription } from '../src/actions.js' +import { getJobCronSource, removeJob } from '../src/worker/email.js' +import { registerSubscription as regSub } from '../src/worker/index.js' + +const pubkey = 'freq-test-' + Date.now() +const email = 'freq-test-' + Date.now() + '@example.com' +let sub: any = null + +describe('Frequency change reschedules cron job', () => { + beforeAll(async () => { + await db.migrate() + }) + + it('creates confirmed subscription with daily frequency', async () => { + const s = await db.insertSubscription(pubkey, email, 'daily') + expect(s).toBeTruthy() + sub = s + + const confirmed = await db.confirmSubscription(sub.key) + expect(confirmed).toBeTruthy() + sub = confirmed + }) + + it('registers cron job with daily frequency', () => { + regSub(sub) + const dailySource = getJobCronSource(sub.id) + expect(dailySource).toBe('0 0 17 * * *') + }) + + it('changes frequency to weekly via registerSubscription', async () => { + await registerSubscription({ pubkey, email, frequency: 'weekly' }) + const weeklySource = getJobCronSource(sub.id) + expect(weeklySource).toBe('0 0 17 * * 1') + }) +}) + +afterAll(async () => { + const updated = await db.getSubscriptionByPubkey(pubkey) + if (updated) removeJob(updated) +}) \ No newline at end of file diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..d67ec3f --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,23 @@ +// Vitest setup: set required env vars before test modules are loaded. +// env.ts checks these at module load time; they must be present when +// actions.ts / mailer.ts / etc. are imported. +import { mkdirSync } from 'fs' + +const dataDir = 'test-data-unit' +mkdirSync(dataDir, { recursive: true }) + +process.env.MAILSHIP_URL = 'http://localhost:3000' +process.env.MAILSHIP_NAME = 'Test Mailship' +process.env.MAILSHIP_SECRET = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +process.env.SMTP_HOST = 'localhost' +process.env.SMTP_PORT = '587' +process.env.SMTP_USER = 'test@test.com' +process.env.SMTP_PASSWORD = 'test' +process.env.SMTP_FROM = 'test@test.com' +process.env.DEFAULT_RELAYS = 'wss://relay.damus.io' +process.env.INDEXER_RELAYS = 'wss://purplepag.es' +process.env.SEARCH_RELAYS = 'wss://relay.nostr.band' +process.env.PORT = '3000' +process.env.CORS_ORIGIN = 'http://localhost:5173' +process.env.BASE_URL = 'http://localhost:3000' +process.env.DATA_DIR = dataDir \ No newline at end of file diff --git a/test/web-ui.test.js b/test/web-ui.test.js deleted file mode 100644 index 207bcb2..0000000 --- a/test/web-ui.test.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -// Failing test: web UI crashes on load when VITE_NOTIFIER_RELAY is not set. -// -// The bug: `normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY)` throws -// TypeError: Cannot read properties of undefined (reading 'match') -// when the env var is not set. -// -// After the fix, `normalizeRelayUrl` is only called when the env var is -// truthy, so the crash no longer occurs. This test verifies the guarded -// call pattern matches the one in main.ts. - -import { normalizeRelayUrl } from '/home/gascity/mailship/node_modules/.pnpm/@welshman+util@0.6.3_typescript@5.9.2/node_modules/@welshman/util/dist/util/src/Relay.js'; - -let passed = 0; -let failed = 0; - -function assert(label, ok, detail) { - if (ok) { - console.log(` ✓ ${label}`); - passed++; - } else { - console.log(` ✗ ${label} — ${detail || ''}`); - failed++; - } -} - -// Test 1: Guarded normalizeRelayUrl — the pattern used in main.ts -console.log('1. Guarded normalizeRelayUrl (main.ts pattern)'); -const undefinedInput = undefined; // simulates unset VITE_NOTIFIER_RELAY -const guarded1 = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined; -assert( - 'guarded normalizeRelayUrl with undefined should not crash, result is undefined', - guarded1 === undefined, - `got ${guarded1}` -); - -// Test 2: Guard with empty string -console.log(''); -console.log('2. Guarded normalizeRelayUrl with empty string'); -const emptyInput = ''; -const guarded2 = emptyInput ? normalizeRelayUrl(emptyInput) : undefined; -assert( - 'guarded normalizeRelayUrl with "" should not crash, result is undefined', - guarded2 === undefined, - `got ${guarded2}` -); - -// Test 3: Guard with a valid relay still works -console.log(''); -console.log('3. Guarded normalizeRelayUrl with valid relay'); -const validInput = 'wss://relay.damus.io'; -const guarded3 = validInput ? normalizeRelayUrl(validInput) : undefined; -assert( - 'guarded normalizeRelayUrl with valid input still normalizes correctly', - guarded3 === 'wss://relay.damus.io/', - `got ${guarded3}` -); - -console.log(''); -console.log(`Results: ${passed} passed, ${failed} failed`); -process.exit(failed > 0 ? 1 : 0); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..255780e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + include: ['test/**/*.test.ts'], + setupFiles: ['test/setup.ts'], + }, +}) \ No newline at end of file From 53b0182c7be9f293dc0dd24291c766db13ffa7f5 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 14 Sep 2026 13:40:08 -0400 Subject: [PATCH 18/23] feat(ci): add Forgejo Actions workflow for CI checks Creates .forgejo/workflows/ci.yml that runs the repo's single-source-of-truth check gate (./script/checks) on every push to main and every pull request. Triggers: push to main, pull_request Concurrency: group by workflow+ref, cancel-in-progress true Steps: actions/checkout@v4, actions/setup-node@v4 (node-version-file: .nvmrc), corepack + pnpm i --frozen-lockfile, then ./script/checks. Part of bead mailship-e77. --- .forgejo/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .forgejo/workflows/ci.yml diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..35fd51b --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,31 @@ +# Forgejo Actions CI — runs the repo's check gate on every push/PR +# Single source of truth: ./script/checks defines what "passing CI" means. +--- +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Install dependencies + run: | + corepack enable + pnpm i --frozen-lockfile + + - name: Run check gate + run: ./script/checks \ No newline at end of file From b54fb4f8916d82e939d322f6498943632063c844 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 14 Sep 2026 13:42:11 -0400 Subject: [PATCH 19/23] =?UTF-8?q?POST=20/notify/:id:=20standardize=20respo?= =?UTF-8?q?nse=20shape=20=E2=80=94=20always=20include=20stored=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: when the event was not found at the relay, the handler returned { ok: true, skipped: true }, which did not match the documented contract { ok: true, stored: boolean } in README.md. Fix: change the 'skipped' response to { ok: true, stored: false }, so the response shape is consistent across all code paths. - src/server.ts: changed line 287 from { ok: true, skipped: true } to { ok: true, stored: false }, with updated comment - README.md: added a note documenting the skip case (stored: false) - test/notify-response-shape.test.ts: new test that asserts stored=false and that skipped is never present --- README.md | 4 ++ src/server.ts | 4 +- test/notify-response-shape.test.ts | 78 ++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 test/notify-response-shape.test.ts diff --git a/README.md b/README.md index be2965d..6738437 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,10 @@ Response: { ok: true, stored: boolean } Returns 404 if subscription not found or inactive. ``` +When the event is not found at the relay (e.g. it was deleted or never arrived), +the endpoint returns `{ ok: true, stored: false }` — the event is silently skipped +rather than erroring. The `stored` field is always present in a 200 response. + ### GET /confirm?token=... Confirm email address via link from confirmation email. diff --git a/src/server.ts b/src/server.ts index 5029290..be88a56 100644 --- a/src/server.ts +++ b/src/server.ts @@ -285,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 }) } } diff --git a/test/notify-response-shape.test.ts b/test/notify-response-shape.test.ts new file mode 100644 index 0000000..9d229b8 --- /dev/null +++ b/test/notify-response-shape.test.ts @@ -0,0 +1,78 @@ +// POST /notify/:id response shape test +// +// Verifies that the endpoint always includes a `stored` boolean +// in its response, matching the documented contract in README.md: +// Response: { ok: true, stored: boolean } +// +// Bug: when the event is not found at the relay, the handler returns +// { ok: true, skipped: true } +// missing the documented `stored` field. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import * as db from '../src/database.js' +import { server } from '../src/server.js' +import { createServer, type Server } from 'http' + +// Partially mock @welshman/net so that `load()` returns an empty array, +// simulating the case where the relay does not have the requested event, +// while preserving all other exports that other modules depend on. +vi.mock('@welshman/net', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(actual as Record), + load: vi.fn().mockResolvedValue([]), + } +}) + +describe('notify_response_shape', () => { + let httpServer: Server + let baseUrl: string + let subId: string + + beforeAll(async () => { + await db.migrate() + + // Create and confirm a subscription we can use for the notify call + const pubkey = 'shape-test-pk-' + Date.now() + const email = 'shape-test-' + Date.now() + '@example.com' + const sub = await db.insertSubscription(pubkey, email, 'daily') + const confirmed = await db.confirmSubscription(sub.key) + subId = confirmed.id + + // Start the express server on a random available port + await new Promise((resolve) => { + httpServer = createServer(server) + httpServer.listen(0, () => { + const addr = httpServer.address() + if (addr && typeof addr === 'object') { + baseUrl = `http://localhost:${addr.port}` + } + resolve() + }) + }) + }) + + afterAll(async () => { + httpServer?.close() + }) + + it('returns stored=false instead of skipped=true when event not found', async () => { + const res = await fetch(`${baseUrl}/notify/${subId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: 'nonexistent-' + Date.now(), + relay: 'wss://relay.damus.io', + }), + }) + + const body = await res.json() + + // The documented contract says: { ok: true, stored: boolean } + // The current buggy code returns: { ok: true, skipped: true } + expect(body).not.toHaveProperty('skipped') + expect(body).toHaveProperty('stored') + expect(body.stored).toBe(false) + expect(body.ok).toBe(true) + }) +}) \ No newline at end of file From a0a284b0a33fac06c663627df0003350f7b3d7c1 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 14 Sep 2026 16:08:40 -0400 Subject: [PATCH 20/23] Route BASE_URL through env module instead of reading process.env directly server.ts was building callback URLs from raw process.env.BASE_URL at lines 175 and 217, while env.ts already validates and exports BASE_URL as a typed constant. This adds BASE_URL to the import from ./env.js and replaces both direct process.env references so the callback URL cannot drift from the validated value. --- src/server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server.ts b/src/server.ts index be88a56..0e9220e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,7 @@ 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, CORS_ORIGIN } 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' @@ -172,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, @@ -214,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) From 560c7ef9bc4411aaa0452e31594f27d610b1a91f Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 14 Sep 2026 16:10:10 -0400 Subject: [PATCH 21/23] document include_event body on POST /notify/:id The endpoint accepts an optional field for NIP-98 include_event support. When provided, the event is verified inline (id match + signature check). When omitted, the event is fetched from the relay. Documents the { ok: true, stored: boolean } response and dedup behavior. --- README.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6738437..ba1ae0e 100644 --- a/README.md +++ b/README.md @@ -80,14 +80,27 @@ 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. ``` -When the event is not found at the relay (e.g. it was deleted or never arrived), -the endpoint returns `{ ok: true, stored: false }` — the event is silently skipped -rather than erroring. The `stored` field is always present in a 200 response. +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. From dbde1ec02d29f36ecbca3f99bd0985c415058c11 Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 16 Sep 2026 09:28:03 -0400 Subject: [PATCH 22/23] Normalize EVENT_VIEWER_URL trailing slash once in env.ts, remove 6 ad-hoc strips EVENT_VIEWER_URL.replace(/\/$/, '') was duplicated across: - env.ts (BRAND_LOGO, 1x) - server.ts (settingsUrl in confirm routes, 3x) - mailer.ts (settingsUrl in sendConfirm/sendDigest, 2x) Now trailing-slash normalization happens at the export source in env.ts, so all consumers get a clean URL without ad-hoc stripping. --- src/env.ts | 4 ++-- src/mailer.ts | 4 ++-- src/server.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/env.ts b/src/env.ts index 648795a..da16c85 100644 --- a/src/env.ts +++ b/src/env.ts @@ -22,11 +22,11 @@ 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) diff --git a/src/mailer.ts b/src/mailer.ts index a67b24d..043f2e9 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -29,7 +29,7 @@ const transporter = nodemailer.createTransport({ export const sendConfirm = (sub: Subscription) => { const href = `${BASE_URL}/confirm?token=${sub.key}` - const settingsUrl = `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts` + const settingsUrl = `${EVENT_VIEWER_URL}/settings/alerts` return transporter .sendMail({ @@ -86,7 +86,7 @@ export const sendDigest = async (sub: Subscription, variables: Record { diff --git a/src/server.ts b/src/server.ts index 0e9220e..5cdd626 100644 --- a/src/server.ts +++ b/src/server.ts @@ -308,7 +308,7 @@ addRoute('get', '/confirm', async (req: Request, res: Response) => { brandName: BRAND_NAME, brandAccent: BRAND_ACCENT, brandLogo: BRAND_LOGO, - settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`, + settingsUrl: `${EVENT_VIEWER_URL}/settings/alerts`, }) ) } @@ -320,7 +320,7 @@ addRoute('get', '/confirm', async (req: Request, res: Response) => { brandName: BRAND_NAME, brandAccent: BRAND_ACCENT, brandLogo: BRAND_LOGO, - settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`, + settingsUrl: `${EVENT_VIEWER_URL}/settings/alerts`, })) } catch (error) { const isActionError = error instanceof ActionError @@ -331,7 +331,7 @@ addRoute('get', '/confirm', async (req: Request, res: Response) => { brandName: BRAND_NAME, brandAccent: BRAND_ACCENT, brandLogo: BRAND_LOGO, - settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`, + settingsUrl: `${EVENT_VIEWER_URL}/settings/alerts`, })) if (!isActionError) { From 4b436644117886de5a04eac7468bc425da1d7eb3 Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 16 Sep 2026 09:56:13 -0400 Subject: [PATCH 23/23] extract shared brandingVars() helper to eliminate duplication The { brandName, brandAccent, brandLogo, settingsUrl } object was built identically 3 times in the /confirm handler. Extract a brandingVars() helper so it is defined once and reused via spread. Closes mailship-90c --- src/server.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/server.ts b/src/server.ts index 5cdd626..9e7aea1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -299,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}/settings/alerts`, + ...brandingVars(), }) ) } @@ -317,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}/settings/alerts`, + ...brandingVars(), })) } catch (error) { const isActionError = error instanceof ActionError @@ -328,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}/settings/alerts`, + ...brandingVars(), })) if (!isActionError) {