Repository navigation
v0.16.8 — live exchange rates
Patch — zakat calculations used exchange rates frozen in 2023
CurrencyService supplied the exchange rates used by ZakatEngine. Its rates came from a
hardcoded table commented "for demo purposes" — and shipped to production.
Measured against a live provider:
| pair | in the code | actual | error |
|---|---|---|---|
| USD → EGP | 30.90 | 52.04 | 68% low |
| USD → TRY | 27.50 | 48.79 | 77% low |
| USD → INR | 83.20 | 96.04 | 15% low |
| USD → IDR | 15,750 | 17,790 | 13% low |
| USD → EUR | 0.85 | 0.8713 | 3% low |
| USD → GBP | 0.73 | 0.7474 | 2% low |
SAR and AED were correct — both are USD-pegged. That is precisely why the table looked
plausible and survived: the pegged entries never drifted.
Why this is more than a display defect. Nisab is compared in the base currency, and this
service sits inside the calculation engine. An understated conversion understates the user's
wealth, so a user can be shown as below the nisab threshold while actually being above it —
and told they owe nothing when they owe zakat. The error is systematic and one-directional:
every drifting currency was too low, always in the direction that hides an obligation.
A second defect in the same file. When no rate was available the fallback returned 1.0
with only a log line, silently asserting 1 TRY = 1 USD — understating wealth by roughly 49×
for TRY and 15,800× for IDR.
Fixed by
- fetching live rates from a keyless provider (verified reachable from the production host,
not only from a dev machine).RATES_API_URLoverrides the provider;
RATES_API_TIMEOUT_MS(default 5s) bounds the wait so a slow provider degrades rather than
hanging a request. - caching one full rate table per hour and deriving cross-rates through the provider base,
instead of a separate lookup per pair. - removing the parity fallback. A degraded path now cross-rates through the built-in USD
table so a non-USD pair keeps a sane order of magnitude during an outage, and raises an
error only for a pair it genuinely cannot rate. - exposing
getRateSource()so a surface can disclose whether a figure used a live, cached,
or fallback rate.
Not affected: calculations already stored in the database. Their amounts were written at
their own rates and are unchanged. What changes is any new calculation and any recomputation.
Note: a 1-hour rate cache means a deploy can serve the built-in fallback until the first
successful fetch. getRateSource() reports which case applies.
[0.16.7] - 2026-09-20
Patch — payment amounts below 1,000,000 were read back as NaN
Payment amounts are stored encrypted. To decide whether a stored value was ciphertext or a
plain numeric string, the read path called EncryptionService.isEncrypted(). That predicate
requires every base64 group in the value to be at least 12 characters — but an AES-256-GCM
ciphertext is ivB64:bodyB64:tagB64 where bodyB64 = 4*ceil(len/3). For any plaintext
shorter than 7 characters the body is under 12 characters, so isEncrypted() returned
false for perfectly valid ciphertext.
The code then took the not-encrypted branch and ran parseFloat() on the ciphertext:
| amount stored | returned |
|---|---|
| 0 … 1000 | NaN |
| 1.5, 12.25, 99.99 | NaN |
| 1234.56 | 1234.56 (plaintext is exactly 7 chars) |
Every amount below 1,000,000 read back as NaN, so zakat-paid totals, payment history and
per-category statistics were all NaN. In unlucky cases it returned a wrong number
instead of NaN — "99.99" produced 8, because parseFloat stopped at the first digit
it found in the ciphertext.
Reachable via POST /api/tracking/snapshots/:id/payments, which is authenticated and
returns the amount directly to the client.
Fixed by deciding from content rather than from a length-sensitive heuristic: try the
value as a plain number first, only attempt decryption when that fails, and let AES-GCM
authentication decide which ciphertext form is real. An amount that is neither a plain
number nor decryptable now throws with the row id instead of silently returning NaN.
Not retroactive data loss. Verified read-only against existing databases: stored rows
use the 2-part legacy CBC format, which isEncrypted() does recognise, and all of them
decrypt correctly. The exposure was new writes, not existing data.
Tests
30 tests for PaymentRecordService (previously 1.4% covered, 144 untested statements),
covering exact money round trips, encrypted-at-rest assertions, totals per snapshot and per
user, per-category statistics including the division-by-zero case, pagination totals, and
cross-user isolation on both read and list.
[0.16.6] - 2026-09-20
Patch — registration no longer reports success when email fails
Registration returned 201 even when the verification email could not be sent. The token
write and the send shared one try/catch that only logged, so with
requireEmailVerification enabled the user was told to check an inbox that would never
receive anything — and the account could not log in. The failure was visible only in
server logs.
Changed
- Registration returns
503 VERIFICATION_EMAIL_FAILEDwhen verification is required and
the send did not happen, instead of201. - New
POST /api/auth/resend-verification. Recovery previously meant re-registering
or an admin editing the database. The response is identical whether or not the address
exists, so it cannot be used to enumerate registered emails. - Login now carries the API error code through to the UI, which offers a resend when it
seesEMAIL_NOT_VERIFIED. The code was previously dropped at the API boundary, leaving
the UI to match on message text. - Limit defaults unified. Three services each hardcoded a fallback that disagreed with
config/limits.ts— assets50vs30, payments100vs50, nisab records10vs5.
Enforcement and the limits reported to clients now come from one source. - Admin system status reports email delivery health and verification counts, so a broken
provider is visible without reading logs.
Operators: if
requireEmailVerificationis on and sending fails, new accounts cannot
sign in. Check Admin → System Health for the email status.
[0.16.5] - 2026-09-20
Patch — security: the allowRegistration gate now actually runs
v0.16.4 did not close registration (#407)
v0.16.4 added the gate to server/src/routes/auth/register.ts. Nothing imports that
module — it is dead code. The handler served at POST /api/auth/register lives in
server/src/routes/auth.ts, so the gate was never executed and registration remained
open with allowRegistration = false.
The gate is now in the mounted handler, before any validation or user creation,
failing closed (503) if the setting cannot be read.
The earlier test passed because it called the patched module directly. The replacement
drives the real express app, so it fails if the gate exists only in an unused module.
Also
test.ymlnow runs onrelease/**. A pull request against a release branch was
receiving only the secret scan, so a patch release could merge with no tests run.
Operators: verify on your instance that registration is actually refused. Two
register handlers existed, and patching the unreferenced one produced a fix that
looked complete but changed nothing.
[0.16.4] - 2026-09-20
Patch — security: enforce the allowRegistration setting
Public registration could not be disabled (#407)
The allowRegistration system setting was stored, exposed through the admin API, and presented as a working toggle in the admin UI — but nothing read it on the registration path. Setting it to false had no effect: /api/auth/register validated the payload and created the account regardless.
For any deployment intending closed or invite-only signups, this silently defeated that intent while the UI reported otherwise.
The registration handler now consults the setting before any validation or user creation, so it cannot be bypassed with a malformed body. It fails closed: if the setting cannot be read, registration returns 503 rather than silently falling through to open signups.
Audited the sibling setting in the same struct: requireEmailVerification is genuinely enforced (auth/login.ts, auth.ts). allowRegistration was the only decorative one.
Operators on a release before 0.16.4: with the earlier code, flipping
allowRegistrationtofalsedoes not close registration. Until you upgrade, restrict it at the edge.
[0.16.3] - 2026-09-17
Patch — upgrade safety, currency correctness, session hygiene
Ships the production defects found while triaging #267/#310 and the release-plan audit.
Docker: the startup guard now actually runs, and is safe to upgrade into (#395, #267)
- The published image never copied
docker/entrypoint.shand used the base NodeENTRYPOINT, so the secret-validation guard was dead code in every production container. It is now installed and wired up (docker/Dockerfile.production). - P3005 upgrade lockout fixed. Instances whose schema predates migration history would have hard-failed on
prisma migrate deployand never started. The entrypoint and the newdocker/migrate-only.shnow auto-baseline such databases — but only afterprisma migrate diffproves the live schema already matches, recording migrations as applied without re-running their SQL. On genuine schema drift both fail closed rather than guess. - Severity now matches the application:
ENCRYPTION_KEY/JWT_SECRETare fatal (the app throws on them anyway); missingJWT_REFRESH_SECRETwarns only, since the app boots with a random fallback. DB_PATHis derived fromDATABASE_URL(it was hardcoded todev.dbwhile production usesprod.db, so the pending-migration probe checked the wrong file).- Raw
JWT_SECRETwas being printed to the container log (server/src/utils/jwt.ts,JWTService.ts) — removed. - Added
.dockerignore(build context ~1.64 GB → 27 MB, and prevents a localdev.dbfrom being baked into images).
New: operator upgrade path
scripts/ops/upgrade.sh— backup-gated upgrade with a preflight that classifies the instance as fresh / migrated / unmigrated before changing anything.docs/UPGRADING.md— what P3005 means, the automatic path, manual baseline, rollback, and troubleshooting. Includes theJWT_REFRESH_SECRETlogout loop (#267).
Currency: dashboard no longer hardcodes USD (#310)
ActiveRecordWidgetresolved the user's currency but still rendered six hardcoded$amounts, so an IDR user saw$42,000,000.00aboveRp 42.000.000on one screen.DashboardActionCardsandZakatDashboardhad the same flaw.- All three now format through the canonical
useDisplayCurrencyhook. Note: USD renders$6,500rather than$6,500.00— matching the app-wide formatter.
Push notifications (#383)
- Logging out now detaches the device:
logout()clears the session but previously left the PushManager subscription registered, so logged-out devices kept receiving push. - The unsubscribe runs before the token is cleared (it is an authenticated request). If the server call fails, the browser subscription is still torn down and logout proceeds.
Release process
docs/release-cycle.mdcorrected: every row was one Hijri month behind reality (2026-09-12 is 1 Rabi' al-Thani, not Rabiʿ al-Awwal). v0.17.0 retargeted to 1 Jumada al-Ula 1448 (2026-10-12).- Version parity repaired:
cliandsharedwere stranded at 0.15.2 while the rest were at 0.16.1.
Full Changelog: v0.16.2...v0.16.3
[0.16.2] - 2026-09-14
Patch — push notification delivery
Retroactively recorded: this release shipped without a CHANGELOG entry.
- Push notification subscription + delivery work landed on top of 0.16.1 (PR #392, #386, #387).
- Added the
push_subscriptionsmigration and VAPID key handling.
Full Changelog: v0.16.1...v0.16.2
[0.16.1] - 2026-09-12
Patch — PushSubscription migration fix (#313)
Production-deploy verification caught that PR #382 added the PushSubscription Prisma model without a migration — prisma migrate deploy never created the push_subscriptions table in production (unit tests passed because the test setup falls back to db push).
- Fixed: additive-only migration
20260912160000_add_push_subscriptions(CREATE TABLE + unique endpoint index + userId index + cascade FK to users). No drops, renames, or data changes. Verified on a scratch DB and applied cleanly in production; all user data intact.
No other changes. Version bump only so the release tag matches the deployed images exactly.
Full Changelog: v0.16.0...v0.16.1
[0.16.0] - 2026-09-12 — Moon Phase Release
User-facing summary: Push notifications for Zakat due reminders. App now speaks Arabic (with full right-to-left support) and lays the groundwork for 7 more languages. Broken links show a friendly 404 instead of a blank page. Local installs without sync no longer throw errors. Security dependencies fully clean.
Projects completed (all five from the v0.16.0 plan)
- #339 React Router v7 (PR #378):
react-router-dom6.30.6 → 7.18.3 across the client. SPA surface needed zero import changes; thefuture={{...}}router flags became defaults. Clears both remaining production advisories —npm audit --omit=devis now 0 vulnerabilities. - #313 Push notifications — server core (PR #382): additive
PushSubscriptionPrisma model (endpoint/p256dh/auth, cascade to user), idempotent subscribe/unsubscribe API (/api/push/*, zod-validated),sendPushToUserwith automatic pruning of expired subscriptions, and a Zakat-reminder job that scans non-finalized Hawl windows ending within 30 days and fires on day 30/7/1 markers (deduped viaReminderEvent). Client subscription UI tracked in #383. - #338 i18n foundation (PR #384):
react-i18nextwired with browser-language detection and sticky persistence (zakapp_lang); 8 locales declared (en, ar, ms, ur, fr, tr, id, bn) with clean English fallback; full RTL support (document direction flips for Arabic/Urdu); complete English + Arabic bundles for the onboarding wizard (all 8 steps) and dashboard education/assets/privacy panels; language switcher in Settings. - #321 Vitest 4 migration (PR #374, closed with the suite green).
- #340 code hygiene (PR #372).
Fixed
- Liabilities page crash (#310, PRs #373/#375): legacy string-typed amounts no longer crash reduction functions, and the fix's own early-return no longer violates React hook order.
- Dashboard currency format (#310, PR #376): dashboard now uses the shared locale-aware
formatCurrency— IDR displaysRp 42.000.000consistently across dashboard and analytics. - Blank page on unknown URLs (#377, PR #379): a catch-all
NotFoundPagerenders a friendly 404 with links to Dashboard / Nisab Records / Calculator instead of a silent empty page. - Dark-mode date inputs (#370, PR #380): native date/datetime/time/month pickers declare
color-scheme: darkso UA chrome matches the dark surface. - ZK account pill overflow (#370, PR #380): the long zero-knowledge identifier in the header truncates with ellipsis (capped at 12rem) instead of overflowing narrow viewports.
- Local dev sync errors (#371, PR #381):
POST /api/sync/tokenreturns a typed503 SYNC_DISABLED(honest disabled-state) instead of a raw 500 when CouchDB isn't configured; the client warns once and continues in local vault-only mode without error-chip spam;.env.exampledocuments the optional CouchDB section.
Tests
- Server suite: 482 passing (up from 474: +8 push-notification tests — subscription CRUD, expired-sub pruning, reminder day-marker firing, dedupe, no-subscriber no-op)
- Client suite: 546 passing / 1 skipped (up from 540: +6 i18n foundation tests — namespace loading, Arabic translation output, skeleton-locale fallback, persistence, RTL direction mapping, language list)
- Both
tsc --noEmitclean; production builds green; every fix verified live on the staged production build per the standing QA doctrine
Dependencies
react-router-dom^7.18.3 — clears GHSA open-redirect + SSR advisoriesweb-pushadded as a direct dependency (push notification delivery), with graceful no-config fallbacknpm audit --omit=dev: 0 vulnerabilities
Known follow-ups (filed)
- #383: client push subscription UI (service worker + settings toggle)
- i18n: remaining string extraction (settings tabs, admin, learn hub) + community translation bundles
- #360/#361 dark-mode/semantic-token sweeps → v0.17
Full Changelog: v0.15.2...v0.16.0
[0.15.2] - 2026-09-10
💱 Currency Consistency — Final Round of Issue #310
User-facing summary: Currency setting now sticks everywhere. Totals no longer mix currencies. App recovers instead of showing "You're Offline." Reloading on /assets/ paths works again.
Fixed
- Currency setting now persists (#350): changing currency in Settings used to silently revert on page reload. ProfileForm now writes both the profile blob and
settings.currencyviaPUT /api/user/settings, reading current settings first so nothing in the encrypted blob is clobbered. - Mixed-currency sums eliminated (#350): Dashboard, AssetList, and NisabYearRecordsPage previously summed USD + IDR amounts directly (e.g. $500 + Rp 50M displayed as $50,000,500). A new
currencyNormalization.tsutil converts every amount to the display currency before summing, with aconvertedflag so a missing-FX state never silently displays apples+oranges. New server endpointGET /api/zakat/fx-rates(USD base, optional auth) +useFxRateshook power the conversion. - Nisab endpoint fallback for pre-fix users (#350):
/api/zakat/nisabnow falls back to the profile store's currency for users whosesettings.currencywas never populated by the earlier fix. - Client nisab cache keyed by currency (#350):
getNisab(currency?)now always sends?currency=; the query cache is keyed by currency. ZakatCalculator passes the user's currency, re-fetches on change, and its hardcodedcurrency="USD"payment modal is gone. - "You're Offline" dead-end fixed (#350): Workbox
navigateFallbackpointed to/offline.htmlwhich wasn't precached on all routes, leaving users stranded. Now points to/index.htmlso the SPA boots and routes correctly.offline.htmlremains precached for genuine offline use. - nginx trailing-slash 403 fixed (#350):
try_fileswas probing$uri/for/assets/reloads, hitting autoindex-off 403 instead of falling through to the SPA. No longer probes the directory.
API changes
- New endpoint:
GET /api/zakat/fx-rates— returns USD-base exchange rates for all supported currencies. Optional auth (authenticated callers get fresh rates; unauthenticated get cached). GET /api/zakat/nisabnow has a profile-store currency fallback.
Honest caveat
Mixed-currency totals convert at FX fetch time, so for the first second after a cold load the Dashboard may show "Updating exchange rates…" instead of a number — deliberate (wrong number replaced by honest placeholder).
Tests
- Server suite: 474 passing (up from 472: +2 contract tests pinning the profile fallback + fx-rates endpoint)
- Client suite: 504 passing / 15 skipped (up from 480: +24 regression tests in
currencyRound4.test.ts+currencyNormalization.test.ts) - Client
tsc --noEmitclean; client build green; regeneratedsw.jsinspected and confirmed
Full Changelog: v0.15.1...v0.15.2
[0.15.1] - 2026-09-10
💱 Currency Consistency — Server-Side Completion of Issue #310
Fixed
POST /api/zakat/calculatenow returns results in the user's currency (#348):
the engine still computes in USD internally, but every displayed money field is
converted before the response is sent —totalAssets,totalLiabilities,
netWorth,nisabThreshold,zakatDue. An IDR user now sees IDR totals and an
IDR nisab instead of USD-scale numbers.- Breakdown converted too:
assetsByCategorytotals, per-asset
value/zakatableValue/zakatAmount, liability amounts, and the
methodologyRules.nisabCalculation.effectiveNisabare all scaled by the same
fxRateFromUSD, so the Detailed Breakdown tab matches the summary
(independent-review fix B2). - Currency resolution chain (#348): explicit
?currency=/bodycurrencyparam →
authenticated user's saved currency preference (decrypted settings blob) → USD
fallback. Unknown/unsupported currency codes are whitelisted back to USD. GET /api/zakat/nisabnow resolves currency from the query param → user
settings → USD, and returns the resolvedcurrencyin the response (#348).- FX failure path is safe: if the exchange-rate lookup fails, the response
falls back to USD and is labeled USD — never silently mislabeled (#348). - Client call-sites:
ActiveRecordWidget,Dashboard,ZakatSetupStep,
MetalsStep,AssetList,ZakatResultsall pass the user's currency; no
remaining live call-site hardcodes'USD'(IdentityStepstays USD by design) (#348). /api/zakat/nisabuses optional auth (review fix B3): pre-auth onboarding
(IdentityStep) and the/calculatorpage keep working; only authenticated
callers get a preference lookup (#348).- Summary arithmetic consistency (review fix B1):
summary.totalLiabilitiesis
converted with the rest of the summary, so
netWorth = totalAssets − totalLiabilitiesholds in every currency (#348).
API changes
summary.zirconYearAmountremoved (renamedzakatDue);zirconYearRate→
zakatRate.summary.currencyandsummary.fxRateFromUSDadded.
Verified: zero remaining consumers of the removed fields repo-wide.
Tests
- Server suite: 472 passing (up from 461: +11 in
currencyConsistency310.test.tspinning the fix contract — resolution chain,
FX direction, failure fallback, client call-sites) - Client suite: unchanged (471 pass / 15 skip) — display-side fixes were already
in place from the #318/#323 rounds - Client
tsc --noEmitclean
Full Changelog: v0.15.0...v0.15.1