# Church Key Register Tracks which physical key copy and which NFC alarm tag is held by which person, across several cabinets, with a signed record of every handover. Node + SQLite in one container. The whole register is a single file. **Print Part 5 and tape it inside the key cabinet.** --- ## What it does - **Board** — one peg per piece of metal, grouped by cabinet, in hook order. Filled means it's on its hook, hollow means it's out with someone. - **Doors** — one row per door, showing all its copies and everyone holding one. - **People** — who holds what, and everything they've ever returned. - **Log** — every handover, append-only, with signatures. - **Journal** — cabinet-level events that belong to no single key. - **Admin** at `/admin` — keys, doors, people, cabinets, CSV import, backup. Key holders never log in. Only you and your colleague use it. --- ## Part 1 — Install ```bash sudo mkdir -p /opt/keyreg && cd /opt/keyreg # copy the project files here mkdir -p data docker compose up -d --build docker compose logs -f keyreg ``` First build takes a few minutes — `better-sqlite3` is compiled from source. ### Access, via Tailscale ```bash curl -fsSL https://tailscale.com/install.sh | sh sudo tailscale up sudo tailscale serve --bg 3000 tailscale status # gives you https://yournas.tailXXXX.ts.net ``` Install Tailscale on your phone, sign in to the same account, open that address. Nothing is exposed to the internet and no ports are opened. **Real HTTPS is what makes the phone camera and signature pad behave.** Over plain `http://` on a LAN IP, some browsers restrict things. ### Optional passcode Set `ACCESS_CODE` in a `.env` file next to `docker-compose.yml` for a second layer on top of Tailscale. Leave it empty and there is no login at all, which is a reasonable choice when only your own devices can reach the app. When it is set, a login screen appears. The cookie stores a hash rather than the passcode itself, and five wrong attempts from one address trigger a one-minute pause — a shared passcode is short enough to be worth guessing otherwise. Failed attempts appear in Admin → Change log. ### Want to click around first? Set `SEED_DEMO=1`, start it, explore, then wipe from **Admin → Import / backup → Clear everything** and set it back to `0`. --- ## Part 2 — Load your keys The model is three tables, and getting this right matters more than the app: | Table | What it holds | |---|---| | **Doors** | The 48 physical doors. One row each. | | **Items** | The ~80 pieces of metal, plus 30 alarm tags. `12-A`, `12-B`, `12-C`. | | **Handovers** | Who took which copy, when, and when it came back. | **Every physical copy needs its own tag number.** Your 48 numbers identify doors; add a letter for each copy. Without this, the register can tell you a key is out but never *which one* — and then it is no better than paper. ### Import Admin → Importar / cópias, then pick what you are importing. Four separate importers. **Load them in this order** — keys point at doors and cabinets, so those must exist first: 1. Cabinets → 2. Doors → 3. People → 4. Keys & tags #### How the CSV is read - The **first row must be the header**, naming the columns. - Column **order does not matter**, and names are **not case-sensitive** (`Tag`, `tag` and `TAG` are the same). - Columns you don't need can be **left out entirely**, or left blank per row. - Extra columns the importer doesn't recognise are **ignored**, so you can paste a wider spreadsheet without cleaning it up first. - Comma, semicolon or tab all work as separators — Excel in Portuguese usually exports semicolons, and that is fine. - Use quotes around any value containing the separator: `12-A,"Sacristia, porta interior",Igreja` - Wherever a column is described as **yes/no** below, these all count as yes: `1`, `sim`, `s`, `yes`, `y`, `true`, `x`. Anything else, including blank, counts as no. #### 1. Cabinets | Column | Required | What it is | |---|---|---| | `id` | **yes** | Short code, e.g. `SAC`. Forced to uppercase. This is what the keys file refers to. | | `name` | **yes** | Short label for the tab on the board, e.g. `Sacristia`. Keep it under ~12 characters. | | `full_name` | no | Full name used in headings, e.g. `Armário da sacristia`. Defaults to `name`. | | `note` | no | Where it is and what opens it. Shown under the tab. | | `off_site` | no | yes/no. Yes for your home reserve box: keys returned there go back to *reserve* instead of *in cabinet*. | | `sort` | no | Number. Lower comes first on the board. Give the reserve a high number so it sits last. Default `0`. | ```csv id,name,full_name,note,off_site,sort SAC,Sacristia,Armário da sacristia,Sacristia atrás da porta,0,1 CASA,Reserva,Conjunto de reserva,Caixa selada fora das instalações,1,20 ``` #### 2. Doors | Column | Required | What it is | |---|---|---| | `num` | **yes** | The door number, exactly as on your list. Must be unique. | | `building` | **yes** | The grouping the door belongs to, e.g. `Igreja`. Also the list of **areas** offered on the public request form, so avoid anything you would not show an outsider. | | `name` | **yes** | The door itself, e.g. `Sacristia`. | | `lock_group` | no | Doors sharing a key profile get the same code, e.g. `B`. Lets you see what else a lost key might open. Fill in later if you don't know yet. | | `notes` | no | Anything about the door itself. | ```csv num,building,name,lock_group,notes 1,Igreja,Entrada principal poente,A, 2,Igreja,Sacristia,B,Fechadura dura no inverno ``` #### 3. People | Column | Required | What it is | |---|---|---| | `name` | **yes** | Full name. This is the match key — re-importing the same name **updates** that person. | | `dept` | no | Department or group, e.g. `Coro`. | | `role` | no | Function, e.g. `Diretora do coro`. | | `phone` | no | Useful when chasing an overdue key. | | `email` | no | Stored only; the app sends no email. | | `active` | no | yes/no. **Blank means yes.** Set no for people who no longer hold keys — they stay in old handovers but cannot be given new ones. | ```csv name,dept,role,phone Maria Fonseca,Secretaria,Secretária,910000000 João Ribeiro,Manutenção,Sacristão, ``` #### 4. Keys & tags One row per **physical piece of metal**, not per door. | Column | Required | What it is | |---|---|---| | `tag` | **yes** | The number stamped on that copy, e.g. `12-A`. Must be unique — this is what makes the register able to say *which* copy is out. | | `door` | **yes** | The door **number** or its **name** — whichever your list uses. Matched against doors already imported, number first. A number that matches nothing is refused (not turned into a door called `12`); a name that matches nothing creates the door. | | `building` | **yes** | Used together with `door` to find the right door, since two buildings may both have a `Cozinha`. | | `lock_group` | no | **Only used if this row has to create a new door.** For doors that already exist it is ignored — edit the door instead. | | `cabinet` | no | The cabinet **code**, e.g. `SAC`. Case does not matter. A code that does not exist is **refused** and the row is listed as skipped — import cabinets first (step 1). | | `hook` | no | Position in the cabinet, e.g. `SAC·R2·05`. Sets the order keys appear on the board and on the audit sheet. Blank falls back to tag order. | | `master` | no | yes/no. Marks it as a master key on the board. | | `status` | no | `in` (default), `out`, `reserve`, `lost`, `retired`. Use `reserve` for copies in the off-site box. Do **not** import `out` — issue the key in the app instead, so there is a handover record. | | `kind` | no | The type **code** from Admin → Tipos, e.g. `key`, `nfc`, `cadeado`. Defaults to the first type. A code that does not exist is **refused**, and the row is listed as skipped — create the type first. | | `notes` | no | Permanent note about this copy. Searchable. | ```csv tag,door,building,cabinet,hook,master,status,notes 12-A,Sacristia,Igreja,SAC,SAC·R1·03,,in, 12-B,Sacristia,Igreja,SEC,SEC·R2·01,,in,Cópia guardada de propósito na secretaria 12-R,Sacristia,Igreja,CASA,CASA·12,,reserve, ``` #### Re-running an import All four are safe to run again. Cabinets match on `id`, doors on `num`, people on `name` (ignoring capitals) — so a corrected spreadsheet **updates** those records instead of creating duplicates. Keys match on `tag` and are **skipped** if the tag already exists, so you can never end up with two records for one piece of metal. The result box tells you how many were created, updated and skipped. There is a useful distinction between a **missing column** and an **empty cell**: - A column you leave out is **not touched**. Re-importing just `name,role` updates everyone's role and leaves phone and department alone. - A column that is present but blank on that row **clears** the field. That is how you empty something on purpose. So to fix one field across many people, export, delete every column except `name` and the one you are correcting, and re-import. Nothing else changes. To change a key that already exists, edit it in Admin → Chaves e etiquetas. ### Types Out of the box there are two: **Chave** (circle, green) and **Etiqueta de alarme** (rounded, violet). Add your own in Admin → Tipos — padlocks, gate remotes, fobs, meter-cupboard keys, whatever you keep on those hooks. Each type has: - a **code**, used by the `kind` column on import. Lowercase, no spaces. Do not change it once keys exist with it. - a **name**, shown everywhere. Change it whenever you like. - a **shape** — circle, rounded or square — and a **colour**. The board reads shape as *what a thing is* and fill as *where it is*, so give each type a distinct shape rather than relying on colour alone. Types appear automatically as filters on the board and in the legend. A type cannot be deleted while any item uses it, and you cannot delete the last one. ### Cabinets Create them first (Admin → Cabinets), because import references them by code. **Board order** controls where a cabinet appears on the board and in reports. Lower numbers come first. Give the reserve store a high number — 20, say — so it always sits at the end, after the cabinets you use daily. Within a cabinet, keys are shown in **hook order**, using the hook field. Fill it in and the board reads down the cabinet exactly as the hooks do, which is what makes the audit sheet countable. Leave it blank and keys fall back to tag order. Tick **off site** for your home reserve box. Keys returning to an off-site store go back to *reserve* rather than *in cabinet*, so they stay out of everyday circulation. Issuing one writes itself into the journal. ### Two physical rules - **Label each key with the number only.** `12-A`, never "Sacristy". A lost key labelled with its door is an invitation. - **Never store a cabinet's own key inside that cabinet.** Cross-store them, and keep a reserve copy of each off site. --- ## Part 3 — Everyday use - **Giving out a key:** Board or Doors → tap the key → Issue → pick the person → set a return date if it's temporary → they sign on your phone. - **Taking it back:** tap the key → Take it back. - **Who has what:** People tab, or Doors for a whole door at once. - **Overdue:** the Overdue filter on the Board. Check it monthly — nothing emails you. Most church keys are held permanently, so "no return date" is the default. Only set a date for genuinely temporary loans. --- ## Part 4 — Printed reports The printer icon in the header, or `/report`. Everything renders as a plain web page, so your browser's own **Print → Save as PDF** produces the file. Nothing to install, nothing to keep updated. | Report | Use it for | |---|---| | **Keys currently issued** | Grouped by person. The one to print for the council. | | **Overdue keys** | Only what is past its return date. | | **Receipt per person** | What that person holds, with their captured signatures, and a line to sign. Print when someone takes on a permanent set. | | **Full inventory** | Every key by cabinet. A copy in the folder, and proof of the whole register on paper. | | **Audit sheet** | A tick-box list per cabinet, in hook order. Take it to the cabinet and count along. | The audit sheet is the one to use for the monthly check. It marks which keys should be absent, so a missing key is obvious rather than something you have to remember. Reports link deeply: `/report?view=person&id=3` goes straight to one person's receipt. There is a shortcut on each person's card in the app. --- ## Part 5 — Access requests (`/pedido`) A form where someone asks for access. It creates **nothing** — no person, no key, no handover. It files a request and writes a line in the journal. You still decide. Requests appear at the top of the **Journal** tab, with a count on the tab so you notice them. Handle one by writing what you decided; it stays on record. ### Checking whether the form is actually protected The Journal tab shows the live state of the form, read from the running server rather than from what you think is in `.env`: - *Formulário só acessível a quem entra na aplicação* — staff only. - *Formulário público, protegido por código* — public, PIN required. - *FORMULÁRIO PÚBLICO SEM CÓDIGO* — in red. Public with no PIN, which means anyone who finds the address can post. Set `REQUEST_PIN` and restart. The server prints the same thing on startup, so `docker compose logs keyreg` tells you too. The PIN field only appears on the form when a PIN is configured — so if you open `/pedido` and see no PIN box, there is no PIN. ### Handing your phone to someone The **✚** icon in the app header opens the form in hand-over mode (`/pedido?kiosk=1`). Give the person your phone, they fill it in, and the confirmation says *"Pode devolver o telemóvel"* with a button for a fresh blank form if two people are waiting. No PIN is asked for in this case. The PIN exists to stop strangers on the internet, and you are standing next to them — so a request submitted from inside the app skips it. The journal records which way it came, *Formulário (no local)* or *Formulário (online)*, so months later you can tell whether you were there. **One caveat, and it is a real one.** The form page has no link back into the app, but the browser's back button still works. If you hand over an unlocked phone, that person can return to the register. For a visitor you do not know, use Guided Access on iOS or Screen Pinning on Android to lock the phone to that one screen first. For a colleague, it does not matter. ### It is staff-only until you say otherwise `PUBLIC_REQUESTS=0` is the default, which means `/pedido` sits behind the same passcode as the rest of the app. Useful as a tablet form in the office, but nobody outside can submit. Setting `PUBLIC_REQUESTS=1` makes `/pedido` and its two endpoints reachable without the passcode. **That is the only part of this system an outsider can reach**, so understand what you are opening: - Set `REQUEST_PIN` as well. Without it, anything that finds the URL can post. - The PIN is shared, so treat it as semi-public — print it in the church bulletin if you like, and change it when it spreads too far. - Five wrong PINs from one address pause that address for five minutes. A hidden field catches bots that fill in every input; their submissions are silently discarded rather than rejected, so they get no feedback to learn from. ### What the form deliberately does not show It offers **areas** — your building names — never door names. A public page listing "Sacristia, Torre sineira, Casa das caldeiras" is a map of the building for anyone who finds it. The `/api/request/areas` endpoint returns building names only. Check what your building names actually are before opening this up: they come from the `building` field on your doors, so if you have a building called "Armários" it will be offered as a requestable area. Rename anything that reads oddly to an outsider. ### Behind a reverse proxy Set `TRUST_PROXY=1` (or `2` for Cloudflare in front of Nginx Proxy Manager). Without it every request looks like it came from the proxy, so all rate limits share one bucket and five wrong attempts lock out everyone. Leave it **empty** when nothing proxies the app — trusting a forwarded header nobody sets lets a client forge their own address and dodge the limits. ### Reaching it from outside Tailscale keeps everything private, which is the point — so publishing `/pedido` needs a deliberate second path. A Cloudflare Tunnel restricted to that one path is the usual approach. Whatever you choose, confirm that `/`, `/admin` and `/api/state` are **not** reachable by the same route before you share the link. --- ## Part 6 — Backups ```bash chmod +x backup.sh # edit BACKUP_DIR and OFFSITE_DEST at the top sudo ./backup.sh # run once by hand sudo crontab -e # 20 3 * * * /opt/keyreg/backup.sh >> /var/log/keyreg-backup.log 2>&1 ``` It writes a verified SQLite snapshot **and** a CSV. The CSV matters: if the app is ever gone, a spreadsheet still tells you who holds which key. **Do a test restore before you trust it.** The instructions are at the bottom of `backup.sh` and amount to copying one file back. --- ## Part 7 — Handover to the next person Fill this in and keep it current: - Machine: `________________________` - Address: `https://________________.ts.net` - Tailscale account: `________________________` - Passcode, if set, is in: `________________________` - Backups run at 03:20 to: `________________________` - Off-site copy at: `________________________` - Technical help: `________________________` **Monthly, five minutes:** 1. Board → Overdue filter. Chase anything red. 2. Check `/var/log/keyreg-backup.log` for failures. 3. Print the audit sheet for one cabinet and count along the hooks. **Yearly:** full audit of every cabinet, and confirm the reserve box is still sealed. Write it in the Journal. **On handover:** add their device to Tailscale, walk them through one real handover and one restore, then remove your own access. --- ## Design decisions worth keeping - **Nothing is ever deleted.** People are deactivated, keys are retired. Both appear in old handovers, and deleting them would make that history anonymous — which is exactly what the register exists to prevent. - **Handovers are append-only.** Returning fills in a date; it never erases. - **Cabinet and building are separate fields**, because the office cabinet can hold spare masters for the church. - **Admin changes are logged** in Admin → Change log. With two of you editing, "who moved 12-B and when" comes up eventually. --- ## Part 8 — Running it on a VPS Works well: the app is small, and a VPS gives you a real hostname, automatic certificates and uptime independent of the church building. But the security assumptions change, because the login page now faces the internet. ### Non-negotiable once it is public 1. **Set `ACCESS_CODE`.** On a private network, running with no login was a defensible choice. On a VPS it is not. 2. **Set `TRUST_PROXY=1`**, or all rate limiting collapses into one bucket. 3. **Add Basic auth in NPM** (Access Lists) as a second, independent layer, so bots never reach the application at all. Skip this only if you need `/pedido` publicly reachable — in which case rely on the app's own gate, which already allows exactly the request paths and nothing else. 4. **Force SSL, HTTP/2 and HSTS** on the proxy host. 5. **Pull backups off the VPS.** Set `OFFSITE_DEST` in `backup.sh`. If the server dies or the account is suspended, whatever is only on that disk is gone. ### Do not publish the port If NPM runs in Docker on the same host, delete the `ports:` block from `docker-compose.yml` and join NPM's network instead (both are commented in the file). Point the proxy host at `keyreg` port `3000`. Nothing then listens on any public interface. Confirm it afterwards: ```bash ss -lntp | grep 3000 # should print nothing curl -sS http://YOUR_VPS_IP:3000/health # should refuse to connect ``` ### Choosing how to protect it Three workable shapes, in order of how much they expose: **1. Nothing public.** Run Tailscale on the VPS as well and keep the app private, exactly as it would be at home. You still get the VPS's uptime and no dependency on the church building. Choose this unless you actually need outsiders to submit requests. **2. Basic auth over everything.** An NPM **Access List** puts a browser password prompt in front of the whole host, and `ACCESS_CODE` sits behind it — two independent layers, and bots never reach the application at all. Simple: no path rules to maintain. The cost is that `/pedido` is behind the prompt too, so only people with that password can submit a request. Fine when you are always the one holding the phone. **3. Public request form, everything else closed.** Do **not** add an Access List. Set `ACCESS_CODE`, `REQUEST_PIN` and `PUBLIC_REQUESTS=1`, and rely on the app's own gate: it allows exactly `/pedido`, `/api/request`, `/api/request/areas`, `/styles.css` and `/i18n.js`, and returns the login screen or 401 for everything else. That whitelist lives in the code, so it cannot drift out of step with nginx location blocks the day a route is added. A neat hybrid, if you want both: Tailscale for the app *and* NPM publishing only `/pedido`. ### Nginx Proxy Manager settings On the proxy host: **Block Common Exploits**, **Websockets off** (unused), and under SSL: **Force SSL**, **HTTP/2**, **HSTS enabled**. Forward to `keyreg` port `3000` over the shared Docker network — not to `127.0.0.1`, which would not resolve from inside the NPM container. ### Basic auth has no lockout NPM will not rate-limit it. If you use an Access List, run fail2ban on the VPS watching NPM's log for repeated 401s. ### One thing you lose When the VPS or your connection is down, you cannot look up a key while standing at the cabinet. Print the full inventory report and keep it in the cabinet — worth doing regardless, and the reason the report exists. --- ## Part 9 — Git and the Forgejo registry ### Push the code ```bash cd /path/to/keyreg git init git add . git commit -m "Registo de chaves" git branch -M main git remote add origin https://git.plascloud.pt/LuisL/lxc-keyreg.git git push -u origin main ``` **Check `git status` before the first commit.** `.gitignore` keeps `data/`, any `.db` file and `.env` out — a repository gets cloned and mirrored, and a list of every door with its key holders is the last thing that should travel. If `git status` shows `.env` or anything under `data/`, stop and fix it first. Once a secret has been committed, removing it in a later commit is not enough — it stays in the history. Change the value instead. ### Build and publish the image Create a token in Forgejo: **Settings → Applications → Generate token**, with the `write:package` scope. Then: ```bash docker login git.plascloud.pt # username LuisL, password = the token docker buildx build \ --platform linux/amd64 \ -t git.plascloud.pt/luisl/lxc-keyreg:1.0 \ -t git.plascloud.pt/luisl/lxc-keyreg:latest \ --push . ``` The owner part of the image name must be **lowercase**, whatever case your username uses. Pick the platform to match the VPS, not your laptop. A Mac builds `arm64` by default and that image will not start on an x86 VPS. `uname -m` on the VPS tells you: `x86_64` → `linux/amd64`, `aarch64` → `linux/arm64`. ### Run it on the VPS Only two files are needed there — `docker-compose.yml` and `.env`: ```bash docker login git.plascloud.pt echo "IMAGE=git.plascloud.pt/luisl/lxc-keyreg:1.0" >> .env docker compose pull docker compose up -d ``` Delete the `build: .` line from `docker-compose.yml` once you are pulling a published image, so a stray `--build` cannot rebuild from a stale checkout. ### Upgrading, and going back ```bash ./backup.sh # always first # bump IMAGE to the new tag in .env docker compose pull && docker compose up -d ``` Tag every build with a real version. If `1.1` misbehaves, putting `1.0` back in `.env` and running `docker compose up -d` restores the previous image in seconds. `latest` gives you nothing to go back to. ### Simpler, if you would rather skip the registry The VPS can build it itself: ```bash git clone https://git.plascloud.pt/LuisL/lxc-keyreg.git keyreg cd keyreg && cp .env.example .env # edit it docker compose up -d --build ``` No registry, no token, no cross-architecture problem — it builds for whatever the VPS is. `git pull && docker compose up -d --build` to upgrade. The registry is worth it once you are running it in more than one place. ### Automating it `.forgejo/workflows/build.yml` builds and publishes on every `v*` tag, if you have a Forgejo Actions runner. No runner, no problem — the file is inert. --- ## Part 10 — Building once, running anywhere The image is self-contained: source, dependencies and static files are all inside it. You can build it once, push it to a registry, and run it on the NAS without ever copying the source there. ### What is not in the image `.dockerignore` keeps three things out, deliberately: - **`data/` and any `.db` file.** Baking the register into an image would ship a list of every door in the building and who holds each key — to whatever registry you push to. Never remove this rule. - **`.env`.** Your passcode. - **`node_modules/`.** Rebuilt inside the image, because a native module built on your Mac will not run on the NAS. ### Build and push Your Mac is almost certainly ARM64. If the NAS is x86, an image built plainly on the Mac **will not start there**. Build for both: ```bash docker buildx create --use --name keyreg-builder # once docker buildx build \ --platform linux/amd64,linux/arm64 \ -t ghcr.io/YOURNAME/keyreg:1.0 \ -t ghcr.io/YOURNAME/keyreg:latest \ --push . ``` For a private registry, log in first with `docker login ghcr.io`. ### Run from the registry On the NAS, you need only `docker-compose.yml`: ```bash echo "IMAGE=ghcr.io/YOURNAME/keyreg:1.0" >> .env docker compose pull docker compose up -d ``` Delete the `build: .` line there and the source tree is never needed. ### Version your tags Tag every build with a real version, not just `latest`. When an upgrade misbehaves, `IMAGE=ghcr.io/YOURNAME/keyreg:1.0 docker compose up -d` rolls you back in seconds. `latest` gives you nothing to roll back to. ### Upgrading ```bash ./backup.sh # always first docker compose pull docker compose up -d ``` The database lives in a Docker volume, not in the image, so it survives every upgrade untouched.