Changelog
All notable changes to Generous.nz are recorded here, grouped by version. Every deployed build has a version number, a git tag, and an entry below.
Versioning scheme
Versions follow MAJOR.MINOR.PATCH:
- 0.x โ pre-launch. Minor bumps mark a completed build phase or major feature set; patches are bug fixes on the current build.
- 1.0.0 โ first public launch.
- 1.x+ โ post-launch: minor = new features, patch = fixes, major = breaking changes or significant re-architecture.
Each version is tagged in git (v0.2.0) and the current version is kept in
package.json. See docs/version-control.md for how to cut a release.
[0.9.387] โ 2026-10-20
Added
- Anything created from outside New Zealand is held for a person. This is a
New Zealand site โ campaigns raise for NZ causes and pay out to NZ bank
accounts โ so a campaign or business application created from a foreign IP
now raises a HIGH moderation flag and cannot go live until someone clears
it. It is a hold, never a rejection or a block: New Zealanders travel and
live overseas, and a false positive costs those people a few hours.
- Applies to campaign creation and wholesale applications, before the AI review is scheduled so the review sees the flag and can't approve over it.
- The flag names the country and the IP, and emails the moderation inbox (rare enough to be worth an email rather than sitting in the queue).
- Lookups use
ipwho.isthenip-api.com, cached for 12 hours. Fail-open: if both providers are down, the content saves normally โ a third-party outage must not hold a legitimate campaign. Loopback, private, CGNAT and link-local addresses are ignored. - Why it matters: the October 2026 pharmacy spam came from an OVH datacenter
host in Virginia (
15.204.167.1), which crawled the site, registered and posted a campaign inside three minutes. Under this rule it would have been held before a person had to notice it โ and it is now blocked outright.
[0.9.386] โ 2026-10-20
Added
A purge keeps the source IP, so the source can be blocked. An IP is personal data, so it is the one thing that survives: the tombstone records it (score, campaign type, keyword reason and IP โ no email, title or user id), and the admin email lists it with the command to block it. Without that, deleting the account destroys the only evidence worth acting on.
scripts/block-ip.shโ a small root helper that blocks an IP or CIDR from generous.nz at nginx, scoped to the site's server block so the other sites on the box are unaffected. It refuses to reload a config thatnginx -trejects (restoring the backup instead), keeps a.bakbefore every change, and appends what was blocked and why to/var/log/generosity-ip-blocks.log. It exists because CloudPanel can regenerate the vhost: if the deny disappears, one command puts it back.
Security
- Blocked 15.204.167.1 โ OVH SAS, Reston, Virginia, flagged as a proxy and hosting address. It crawled every public page, registered, signed in and posted the pharmacy-spam campaign inside three minutes (112 requests, all between 11:37:56 and 11:41:01 UTC on 17 October), then fetched its own campaign page three times. It has not been seen before or since, so this is a site-scoped deny rather than a server-wide ban.
[0.9.385] โ 2026-10-20
Added
Unambiguous spam is deleted, not queued (
lib/spam-purge.ts). The October 2026 "Buy Valium Online Trusted Pharmacy Shipping Platform" campaign scored 0.97 and was held โ correct under the promise that a person decides a campaign's fate โ but the right outcome for a pharmacy spam page is that it never existed, not that it waits in a queue beside real fundraisers.The rule is deliberately tight, and any doubt falls back to the existing hold:
- the AI screening scored 0.95 or higher;
- the content also tripped the prohibited-items keyword screen โ an illegal or restricted item, independent of the model's opinion;
- the account is under 7 days old;
- it has no history at all โ no donations, orders, other campaigns, products, P2P pages or reviews.
When all four hold, the campaign, the account, its flags, its audit rows and its email-log rows are deleted outright. One non-identifying audit entry records that a purge happened (no email, title or user id) and the admins get an email saying what was removed โ a system that deletes things silently is its own problem.
Prescription medicines are on the prohibited list. It covered illicit drugs but no prescription medicines, so "Valium", "Adipex", "no prescription" and similar sailed past the keyword screen and relied entirely on the AI score. Now CRITICAL: valium, diazepam, xanax, alprazolam, adipex, phentermine, tramadol, oxycodone, hydrocodone, fentanyl, ritalin, adderall, ambien, and the phrases "without prescription" / "without a prescription" / "no prescription". Plain "prescription" and "pharmacy" are deliberately not terms โ real medical fundraisers and real pharmacies use them.
CRITICAL moderation flags now email the moderation inbox (
createModerationFlag). Before, only the high-risk campaign path emailed anyone, so a prohibited item caught by the keyword screen (weapons, drugs, stolen goods, ID documents) could sit unseen. The alert carries the item's name, the reason, the AI score and a link straight to the queue. HIGH and MEDIUM stay in the queue โ the Stripe webhook warnings are HIGH, and emailing those would teach everyone to ignore the alerts.MODERATION_ALERT_EMAILSoverridesADMIN_EMAILSfor these, so they can go to an inbox that is actually read. The high-risk campaign path keeps its richer alert and setsnotifyAdmins: false, so the two don't stack.
Changed
- The AI was taught this pattern explicitly. Both fraud prompts now say that commercial spam โ an online pharmacy, prescription medicines without a prescription, a "buy X online" page with discount codes and overnight delivery, a title naming one product while the story sells another, or a page that is mostly links and calls to action โ is a 0.95+ score, not merely "high risk". The prose-based screen catches the variants; the keyword list catches the exact terms.
Docs
- Runbook Step 4R covers the auto-purge rule, the real sample text, and the false-positive check against every genuine campaign.
[0.9.384] โ 2026-10-16
Changed
- The account guard no longer sends its vague "your listings are paused" email
for a business that has simply never finished Stripe onboarding โ the payout
setup follow-ups in
lib/stripe-onboarding-nudges.tssay what is actually missing, and one useful email beats two. A level that was forced (a deauthorised account we couldn't read) still emails, because we can't see enough to be sure.
[0.9.383] โ 2026-10-16
Fixed
A business that never finished its Stripe setup is no longer labelled "suspended". Stripe reports the same
requirements.past_duesignal for a business that started onboarding and walked away as it does for one it has actively shut down, and the account guard read both as "disabled" โ so a half-finished application was suspended, put in a queue for a human, and shown in Admin โ Users as Business ยท suspended next to businesses Stripe had genuinely shut down. Alzheimers Whanganui was suspended 84 seconds after applying, for no reason anyone could see.The rule now separates them: a Stripe requirement on an account that has never submitted its details is "unavailable" โ not finished, not disabled. Their listings stay hidden so nobody hits a dead checkout, the business stays approved, and it clears itself the moment they finish. A requirement on an account that has submitted its details (a live account something lapsed on) is still a block, as is anything Stripe calls a rejection.
A suspension the guard itself applied is also lifted when that turns out to be the situation โ only the guard's own, never an admin's.
Added
- Follow-ups for people who started connecting a payout account and stopped
(
lib/stripe-onboarding-nudges.ts, sent by the daily job). Four emails over a fortnight โ day 1, 3, 7, 14 โ and the thing that makes them worth sending is that they say what Stripe is actually waiting for:- Stripe's requirement codes are translated into plain English
(
lib/stripe-requirements.ts).external_accountbecomes "Your bank account โ the account Stripe should pay you into";tos_acceptance.datebecomes "Accept Stripe's terms โ the last screen has a box to tick";individual.verification.documentbecomes "A photo of your ID". Duplicates are merged (Stripe lists the terms date and IP separately) and anything unrecognised still produces a humanised line rather than a bare token, because Stripe adds requirements over time. "Finish your Stripe setup" is not an instruction; a list of four things is. - The emails escalate sensibly. Day 1: what's missing and that it picks up where they left off. Day 3: the three steps people actually get stuck on (bank account, mobile number, photo ID) and an offer to walk them through it. Day 7: what it's holding up. Day 14: the last one, finish it or reply and say it's not happening.
- The button goes straight back into Stripe via a freshly minted onboarding link, not to a page that then sends them to Stripe.
- Rules: it stops the moment the account can take payments, never sends on a Stripe API error (a transient failure must not become an email telling someone their setup is broken), never invents a requirement when Stripe lists none, respects the unsubscribe switch, skips paused accounts and our own test accounts, and gives up after four. The account guard also stops sending its own vaguer "your listings are paused" email for this situation โ one email that lists what's missing beats two that don't โ and its reminder for this level now waits 21 days rather than 7.
?payments=dryon the daily cron shows exactly who would be emailed, what Stripe reports for each of them, and the subject line โ without sending.
- Stripe's requirement codes are translated into plain English
(
Changed
- Admin โ Users and Admin โ Wholesalers now say "Business ยท setup incomplete" (with the reason on hover) for an approved business whose payout account isn't finished, instead of leaving it looking identical to a business that can trade.
[0.9.382] โ 2026-09-29
Fixed
The off-site backups had been stopped for seventeen days, silently. The nightly dump to the backup host (DediRock) last succeeded on 11 September โ and so had the daily audit archive, since 31 August. Nobody knew, because the only symptom was a line in a log file.
What happened. The local 02:30 job copies the application's
.envinto/var/backups/generosity/so the secrets are backed up too. On 11 September a stray paste left one malformed line in it. The application never noticed โ its env parser skips a line it can't read, and mail kept working โ but both off-site jobs began withset -a; . "$ENV_FILE"; set +a, and sourcing that line made bash try to run a command that doesn't exist. Withset -euo pipefailthe script died before the dump. Every night, for seventeen nights.What changed:
- The config is read, never executed. Both jobs now pull
DATABASE_URLandUPLOADS_DIRout of the file withsedโ falling back to the live app env โ and validate them. A malformed line anywhere else in that file can no longer stop a backup. - Failures shout. A failed run writes
/var/lib/generosity-backup-status.jsonand POSTs to the new/api/cron/backup-alert, which emails the admin inbox through the application's own SMTP. The silent log line was the bug. - The destination watches the arrivals (
scripts/backup-host-watchdog.sh, installed on the backup host asbackup-freshness-check.sh). Twice a day it checks that a new file has arrived from every source โ Generous.nz database, uploads and audit archive, plus FreeWills' database and secrets โ and that the disk isn't filling. It emails on a change of state and sends a daily summary whether or not anything is wrong, so a monitor that dies is noticed too. Deliberately on the receiving end: a monitor on the sending machine only proves that machine was alive a moment ago. - The backup is now verified rather than assumed: the dump is checked as a decompressible gzip of plausible size, and the remote copy is listed after upload before anything is pruned.
- The config is read, never executed. Both jobs now pull
Security
- The failure message quoted the malformed line into the cron log, which put an
SMTP password in a world-readable file for seventeen days on a server
that hosts other people's sites. The leaked logs are now root-only, the logs
and status files are created root-only, and the off-site script runs with
umask 077. That SMTP password should be rotated โ it should be assumed read.
Docs
docs/backup-offsite.mdrewritten: the two jobs and where they push, the security model (including the two honest limitations โ a push model can't stop a compromised app server deleting its own backups, and the DediRock copies are not encrypted), the September outage and the three changes that stop it recurring, how to operate it, and how to restore.
[0.9.381] โ 2026-09-26
Added
- Follow-ups for campaigns that were saved as a draft and never submitted
(
lib/draft-nudges.ts, sent by the daily maintenance job). A draft is invisible to everyone โ it can't be shared, it raises nothing, and it usually stalled on something small rather than on the idea. So: four emails over a fortnight, one action each, and then they stop.- Email 1, day 1 โ "Saved isn't live โ here's what's left". Names what is actually missing from that page, split into in the way (a payout account โ the only hard requirement) and worth doing before you share the link (a banner photo, a target, something to sell, a fuller story). The distinction matters: a photo doesn't stop approval, so claiming it does would read as a lie the moment the page went live without one.
- Email 2, day 3 โ the single biggest blocker, framed as the consequence rather than the instruction: "Nobody can give to it yet" when payouts aren't connected, or the photo / target / empty-shop nudge that applies.
- Email 3, day 7 โ "What's in the way?" A short, human email that offers to write the bit they're stuck on with them, and mentions the closing date if the page has one ("your page says it closes on 12 October โ 16 days away. It can't raise anything until it's open").
- Email 4, day 14 โ the last one. Finish it or delete it, with no hard feelings stated out loud. After that, never again.
- It stops the moment they act. Submitting the campaign ends the sequence (and the first-week tip sequence takes over instead). It never emails a paused, closed, unsubscribed, hidden or flagged account, or our own admin and test accounts, never touches a draft older than 60 days, and never sends two nudges within three days โ so an older draft catches up at a sane cadence instead of getting three emails in three days.
- Replies reach a person. An email that asks "what's in the way?" is only
honest if somebody reads the answer, so
sendEmailnow supports a reply-to (EMAIL_REPLY_TO, default hello@generous.nz). ?nudges=dryon the daily cron prints exactly which emails would go out, with the detected gaps, without sending anything.
- The unsubscribe page now says what it actually switches off ("campaign update
emails or reminders about unfinished pages"), and the first-week tip sequence
honours that switch too โ
User.emailUpdateswas previously only checked by campaign updates.
Docs
- Test runbook Step 4O covers the follow-up sequence: the dry run, the day windows, the three-day cadence, the four stop rules, and the cleanup.
[0.9.380] โ 2026-09-17
Fixed
- The sample club shop's dates are New Zealand dates. The window was
computed from the server's own calendar date, and the production box runs on
UTC โ so for the first eight hours of every NZ day the page quoted a closing
date a day earlier than the rest of the platform (
lib/nz-date.ts). The window is now anchored to the NZ calendar date, so "orders close" and "delivered by" agree with every other date a club sees, whatever the server's clock says.