Worked through on 2026-09-27, before the restructured repository was pushed, and re-checked against the code on 2026-10-09. Boxes are ticked only where the thing is actually true today; where a row has changed its answer, the old answer is left in the row rather than deleted, because what I believed and when is the point of a checklist.
25 of the 26 rows are ticked. Three closed since the first pass — helmet,
rate limiting, and secret scanning with push protection — and one row turned out
to be wrong rather than stale: the count of commits carrying a personal email
address, which was understated by seven.
The one row left open is the author email and surname in the git history, and it is left open deliberately rather than reworded until it passed. A rewrite is possible and was costed; what it cannot do is erase objects GitHub has already served, so the honest state of that row is “measured, bounded and declared”. The row carries the measurement, the three options and the reason for the choice. A security checklist whose boxes are all ticked because the hard row was rephrased is worth less than one that names what is still true.
The longer, rubric-shaped version of this document is
SECURITY-CHECKLIST.md in the repository root. This
file is the prose one; that one is the table with the evidence commands in it.
.gitignore includes .env; git check-ignore -v .env confirms it, and
the same rule covers server/.env and client/.envgit ls-files | grep -iE '\.env$|\.pem$|id_rsa' prints nothing.env.example is committed in three places — root, server/, client/ —
with placeholder values onlydocs/assets/Docu/ — the
coursework folder, whose filenames carry a surname — is now git-ignored,
and the two files from it that were tracked have been removed from the
index. LICENSE carries a GitHub handle, not a namegit config user.email is set, for this
repository only, to Damudachi@users.noreply.github.com. The global
config is untouched, so other projects are unaffectedA name is still in the history, and the count was wrong. Commit
db5e93b added a reflection journal PDF whose filename carried my
surname. Removing a file does not remove it from the history, so the name
is reachable by anyone who walks the log — and an author email is too.
This row used to say “the eighteen commits made before 27 September” and
“every commit since carries the noreply address”. Counted again on
2026-10-11, against 58 commits:
git log --pretty=%ae | sort | uniq -c returns 28 carrying the
personal address and 30 carrying
Damudachi@users.noreply.github.com. Eighteen was never the number, and
the cut-off is not clean: ten of the 28 are dated after the switch —
seven on 2026-10-04 and three more on 2026-10-11. The repository-local
setting is not the leak — git config --local user.email still reads the
noreply address today. All ten are README and asset edits made through
GitHub’s web interface, which commits as the account’s primary email and
never sees a local git config. So the rule is: an edit made in the
browser re-attaches the address, every time. Edit locally, or set the
primary address on the GitHub account to the noreply one.
The three from 2026-10-11 (`7b6baf8`, `44c816f`, `882fed8`, the demo-video
link) were committed in the browser *after* this row was written warning
against exactly that. The count in this row is therefore a floor that
rises on its own: re-run the command before quoting it, rather than
trusting the number printed here.
**Everything in the history that carries an identity**, measured rather
than assumed:
| What | Where | Reachable how |
| --- | --- | --- |
| A surname in a filename | a reflection-journal PDF under `Docu/`, added in `db5e93b`, modified in `cfba87c`, deleted in `d6baf70` | `git log --all --name-only` |
| A personal author email | 28 of 58 commits, counted 2026-10-11 | `git log --pretty=%ae` |
| Reflogs of a stray embedded git directory | `git(old)/logs/refs/…` in `7e30ee4`, removed in `34eac90` | same |
The `git(old)/` entry is new to this row and was not previously known:
an embedded `.git` directory's reflogs were committed in the initial
commit, and a reflog line carries an author name and address of its own.
**Why this is not simply "run `filter-repo` and force push".** The rewrite
itself is the easy half. The hard half is that this repository has been
public for weeks, so:
- every one of the 53 commit ids changes, which breaks **19 commit
permalinks in `AI-USAGE.md`** — a graded disclosure document whose whole
value is that a marker can click a link and check the claim;
- a force push does **not** delete the old objects from GitHub. They stay
reachable by full SHA, and a merged pull request (`#10`) keeps showing
its original commits. Purging those needs GitHub Support, not a git
command;
- there are ten remote branches, six of them Dependabot's, which all
need deleting and letting regenerate.
So the options are: rewrite and accept that the exposure is reduced rather
than erased until Support purges it; start a fresh repository, which is
the only way to be certain and costs the URL and the history; or keep
declaring it. Nothing sensitive beyond a surname and a gmail address is in
any of it, and no credential was ever committed.
**This box stays unticked on purpose.** The honest state of this row is
"measured, bounded and declared", and that is worth more in a security
document than a tick that would mean "I reworded the row until it was
true". Decided, not forgotten. Open
$1, $2, with the values in an array - and no
value is concatenated into SQL. This row used to describe ? and
@named binding, which was true of the better-sqlite3 layer this
replaced; the property is the same one, checked against the code that
actually shipsWHERE clause is gone.
server/src/routes/settings.js used to assemble its clause from an array
of fragments. Always with bound values and behind isIsoDate(), so never
injectable — but the wrong shape, and “it happens to be safe” is a worse
property than “it cannot be unsafe”. Replaced with four fixed query
constants chosen by which bounds are present, so no SQL is built at run
time at allserver/src/middleware/requireAuth.js,
registered on /api in src/app.js. Supabase Auth issues the session;
the middleware verifies the token and resolves req.orgId from the
caller’s membership rows, so the gate says which parish is asking and
not merely whether the caller is allowed in. Off when SUPABASE_URL and
SUPABASE_SERVICE_ROLE_KEY are unset, so development and the desktop
build are unaffected; on when the host sets both. It replaced an HTTP
Basic Authentication gate, retired to
src/middleware/retired/basicAuth.js: one shared password cannot name a
parish. It guards /api and only /api — above express.static a global
gate served a signed-out browser JSON instead of the page that draws the
sign-in form, so nobody could sign in. The built client is public because
it holds no data: the same bundle for every parish. The landing page,
/healthz, /readyz and /auth-forward.js stay outside, because a
platform health check cannot sign in and a visitor following an email
confirmation link has no session yet. The middleware
fails closed: a credential check that throws produces a 401, never a
pass-throughisIsoDate(), the settings
route rejects unknown keys outright, and the POTF routes check lengths.
The React form is for a fast, friendly message; the server is for
correctnesscors({ origin: allowedOrigins() }) names its origins from CORS_ORIGINS
rather than cors() with no options, which would allow every site on the
internetNODE_ENV=production on a host, and no stack trace in any response body —
src/app.js returns a plain message for a 5xx in production and logs the
detailhelmet, with a policy written to fit. Installed in src/app.js.
This row used to read “not installed”, on the reasoning that helmet’s
default Content-Security-Policy blocks the inline styles the built client
needs and that adding it with the wrong policy is worse than not adding
it. The policy is now written out instead of switched off: styleSrc
allows 'unsafe-inline' because Tailwind injects a stylesheet at run
time, scriptSrc does not — which is why the landing page’s fragment
forwarder is a file at /auth-forward.js rather than an inline block —
and connectSources() derives connect-src from SUPABASE_URL instead
of hardcoding it or leaving it open. crossOriginEmbedderPolicy is off:
it buys nothing here and breaks the WebView2 desktop shell.
server/test/csp.test.js exists because getting this wrong caused a real
outage — the browser blocked every sign-in request before it left the
page, fetch rejected with “Failed to fetch”, and nothing was logged
anywhere. A policy that is too tight fails silently in the browser with
no server-side trace, so it gets a testexpress-rate-limit on /api, applied after the
gate so a signed-in office is measured separately from anonymous traffic
at the door. 600 requests a minute, which is generous on purpose: opening
a month fires one calendar request plus one check per chosen day, and a
200-day batch polls its own progress. It is a brake on abuse, not a quota.
Disabled under NODE_ENV=test so the suite is not throttled. Sign-in
attempts are rate-limited by Supabase, not here. This row used to read
“knowingly deferred, because this is a local tool for one office” — true
until the API went on a public URL, which is what made it realVITE_ variable, every one
of which is compiled into a file the whole internet can downloadnpm audit run on 2026-09-27: three moderate advisories in qs, reached
through body-parser and express. npm audit fix cleared all three
without a breaking change; npm audit now reports zero. Dependabot is on
for both npm and Actions, grouped into one PR per ecosystem. Two
dependencies have been added since and are in that audit surface:
helmet and express-rate-limitmemberships table now. What it holds is a user_id and a role — not a
name, not an email. The address itself lives in Supabase Auth, never
here. See the last row of this sectionserver/db/seed.sql and the demo seed hold
placeholder prayers written for this tool and three invented Mass datesmemberships holds a user_id
and a role, and the server reads the address from the token when it needs
to show “signed in as”. No name, no phone number, no address, nothing
about a parishioner, and nothing about anybody who has not signed up.
Deleting the Supabase user removes it. The prayers, dates and citations
beside it are not personal data at allNot on the standard checklist, but it is the thing most likely to go wrong in this particular project.
server/data/orillo/, which is git-ignored, and the public
installer is built without them. A fresh clone runs on placeholder prayers
and says soserver/tools/build-demo-seed.mjs
replaces every body of text with a stand-in line, and the check is a
grep for a distinctive phraseThe riskiest thing in this project is not injection or credentials. The secrets
are a connection string and a Supabase service role key, neither of which has
ever been committed, and the only personal information is a staff email address
and a password hash, both held by Supabase rather than by this application. It is that the app’s
entire value comes from text somebody else owns: two published books of
intercessions and the New American Bible. A public repository that redistributed
either would be a real problem with my name permanently attached, and the first
draft of the demo seed generator would have done exactly that. What I did about
it: the books never enter version control, the scripture is fetched at runtime
and stripped out of anything committed, and the public installer ships without
the office’s data. What I knowingly accepted at the time: no rate
limiting and no helmet, because this was a single-office local tool. The API
then went on a public URL, which is exactly the condition I had written down as
the thing that would make both real, and both are now in — the CSP with a test
behind it, because the way it fails is silent. What is still open is the author
email in the history, and the fact that an edit made through GitHub’s web
interface puts it back.