Skip to content

Commit 91e33b5

Browse files
committed
Make the whole stack run locally in Docker, and document it from a real run
make up, make seed, open localhost:9080. Migrations run as their own container the API waits on, seed data is eight invented members covering the states the screens have to render, and a mail catcher makes password reset followable without a mail server. Every instruction in the README was run from an empty working tree before it was written down, and the screenshots are that run. Four bugs the browser found that no test would have: The design system had no box-sizing reset, so min-height on a control applied to its content box. Every 44px tap target rendered 70px tall and every full width input overflowed its card. Only the admin app had worked around it locally. PUBLIC_ORIGIN was built from the scheme and host without the port, so better-auth refused every sign in with invalid origin on any port but 80. It is now one variable holding the exact URL a browser types. The Caddyfile's global email directive resolved to an empty string when HSL_ACME_EMAIL was unset, which stops the whole server parsing its config. The tools stage was appended after runtime in the API Dockerfile, quietly making it the default build target, so the API and migrate containers both started the import script instead.
1 parent c79455b commit 91e33b5

28 files changed

Lines changed: 888 additions & 92 deletions

.env.example

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,34 @@
1-
# Copy to .env and fill in. .env is gitignored and lives only on the host.
1+
# Copy to .env. It is gitignored and lives only on the machine it configures.
22
#
3-
# Secrets are files under ./secrets/, not values here. Run `make secrets` to
4-
# generate them. Compose refuses to start if any value below is missing, so a
3+
# Secrets are files under ./secrets/, never values here. Run `make secrets` to
4+
# generate them. Compose refuses to start when a value below is missing, so a
55
# misconfigured host fails at `docker compose config` with a readable message
66
# rather than at runtime with a stack trace.
7+
#
8+
# The defaults below run the whole stack on this machine. See README.md.
9+
10+
# The exact URL a browser types, port and all. The session cookie is checked
11+
# against this, so a mismatched port refuses every sign in with "invalid origin".
12+
HSL_PUBLIC_ORIGIN=http://localhost:9080
13+
14+
# The hostname Caddy answers on: the same host as above, without the scheme or
15+
# the port. In production, members.heatsynclabs.org.
16+
HSL_DOMAIN=localhost
717

8-
# The hostname everything is served from. All three apps and the API share this
9-
# origin so the session cookie is first-party.
10-
HSL_DOMAIN=members.heatsynclabs.org
18+
# http locally, https in production. http is what disables automatic TLS.
19+
HSL_SCHEME=http
1120

12-
# https in production, http locally. http disables Caddy's automatic TLS.
13-
HSL_SCHEME=https
21+
# The ports Caddy publishes. 80 and 443 in production. Above 1024 locally, so
22+
# Docker needs no privileged port and nothing collides with another stack.
23+
HSL_HTTP_PORT=9080
24+
HSL_HTTPS_PORT=9443
1425

15-
# Where Let's Encrypt sends expiry warnings.
16-
HSL_ACME_EMAIL=
26+
# Where the development mail catcher publishes its inbox.
27+
HSL_MAIL_PORT=8026
1728

1829
# Who password reset mail comes from.
1930
HSL_MAIL_FROM=HeatSync Labs <noreply@heatsynclabs.org>
2031

21-
# Development uses the override file. A production host leaves this unset.
32+
# Development runs the apps from Vite on the host instead of behind Caddy.
33+
# Leave this commented out on a server.
2234
# COMPOSE_FILE=compose.yaml:compose.dev.yaml

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ coverage/
1515
*.sql.gz
1616
backups/
1717
secrets/
18+
legacy/

HANDOFF.md

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Handoff
2+
3+
Where this stands, what is proven, and what the next person has to decide. One
4+
page, kept current. If it disagrees with anything else, fix one of them.
5+
6+
Last updated 2026-09-02.
7+
8+
## 1. State
9+
10+
Nothing is deployed. Nothing in production has been touched. The whole system
11+
runs on a laptop under Docker Compose, and `README.md` is the instructions.
12+
13+
| Part | State | Proven by |
14+
|---|---|---|
15+
| `packages/schema` | built | 26 tests, 3 migrations applied to a real Postgres |
16+
| `packages/ui` | built | 16 tests, rendered in a browser |
17+
| `packages/api-client` | built | 14 tests |
18+
| `services/api` | built | 161 tests against a real Postgres |
19+
| `services/door` | built, never spoken to hardware | 103 tests against a fake controller |
20+
| `apps/members` | built | 38 tests, walked through in a browser |
21+
| `apps/signup` | built | 33 tests, walked through in a browser |
22+
| `apps/admin` | built | 73 tests, walked through in a browser |
23+
| `tools/import` | built, run against the real dump | 23 tests, plus the run below |
24+
| Compose stack | runs | brought up from nothing, every URL answers |
25+
| Backup and restore | works | `tools/restore-drill.sh` passes, in CI |
26+
| Deployment | not started | no host exists yet, see section 4 |
27+
28+
464 tests. Lint, typecheck and the voice check are clean across the repository.
29+
30+
12,368 lines of TypeScript and Vue, and 6,055 lines of tests. The previous
31+
attempt was 50,941 lines and deployed nothing; the difference is almost entirely
32+
enforcement machinery that is not here on purpose.
33+
34+
## 2. What has been proven against real data
35+
36+
The production dump was restored, the import was run against it, and the result
37+
was checked. This is the part that matters, because two previous rewrites never
38+
got here.
39+
40+
- The backup restores. `pg_restore` exit 0, no errors.
41+
- Every row reconciles: 1,061 members, 1,030 credentials, 64 cards, 63 members
42+
with card access, 10 certifications.
43+
- All 1,030 bcrypt hashes are byte identical between the two databases.
44+
- All 64 card slots come across unchanged, 14 through 200.
45+
- All 64 card numbers canonicalise to eight uppercase hex characters.
46+
- Every account row carries `providerId: credential`, `issuer:
47+
local:credential`, and `accountId` equal to the member id, which is what
48+
better-auth 1.7.2 filters on.
49+
- A real imported member signs in through the real API and reads their own
50+
record: 110 payments, 4 certifications, card slot 14, level 50.
51+
- A member cannot read another member, cannot reach the directory without being
52+
oriented, cannot change another member, cannot make themself an admin, cannot
53+
open a door without card access, and cannot read the audit log. All six
54+
refused, on real data.
55+
56+
## 3. Facts that overrule the older documents
57+
58+
`docs/legacy-system.md` has the full list with sources. The ones that changed
59+
the build:
60+
61+
- **One card is in slot 200.** The firmware's `addUser` accepts it, `checkUser`
62+
never reads it, and on an ATmega328 its bytes land on the alarm state. That
63+
card does not open the door today even though the members database says it
64+
does. The import reports it and preserves it; the door service refuses to
65+
write it.
66+
- **Card matching is an exact 32 bit comparison.** The `% 32767` in the old
67+
field manual is a log encoding. Applying it would match the wrong card.
68+
- **Production is Postgres 8.4.20 on CentOS 6.8**, not 9.x.
69+
- **There are no duplicate emails.** The merge step in the old plan is not
70+
needed.
71+
- **Dues are 25, 50 and 100.** The 20, 35 and 80 figures do not match the data.
72+
- **`member_level` maps to labels** exactly as `app/models/user.rb` does, and
73+
`paymentStatus` is the 60 day rule from the same file.
74+
75+
## 4. Decisions somebody has to make
76+
77+
These block deployment, not development. None of them is technical.
78+
79+
1. **Which machine runs this.** `hsl-web` is 32 bit CentOS 6.8 on kernel 2.6.32
80+
and cannot run Docker at all. Until a host is named and owned, the Compose
81+
stack has nowhere to go. This is the single biggest risk to the project.
82+
2. **Whether the board sanctions this rewrite.** A recorded position from
83+
2026-05-17 in the Slack export says the lab "already decided to not go with
84+
yet another bespoke one-off platform". Nobody has confirmed a board decision
85+
either way.
86+
3. **Sign-off on dropping two-admin approval**, recorded in
87+
`docs/decisions/0008-single-admin-plus-audit-log.md`. The mockups promised it
88+
in member-facing copy; the apps now say what the system actually does.
89+
4. **An SMTP account.** Password reset is the only way in for the 31 members
90+
with no password hash. The API refuses to start on https without it.
91+
5. **Waiver retention and the under-18 path.** No legal input yet. The signup
92+
app records acceptance and points at the paper release rather than replacing
93+
it, which is the conservative reading.
94+
95+
## 5. Unknowns that need somebody at the lab
96+
97+
Nobody has been in front of the controller. Each of these is a five minute job
98+
with LAN access and each one changes code.
99+
100+
1. **Dump the card table with `?a`.** Confirms whether slot 200 is really on the
101+
device, and whether tags are stored upper or lower case. The reconcile loop
102+
assumes uppercase and says so.
103+
2. **Which physical door is controller door 1.** Getting it wrong opens the
104+
wrong door. `o1` and `u=1` are assumed to be the front.
105+
3. **Whether the deployed firmware is the DEBUG build.** If it is not,
106+
`dumpUser` prints asterisks instead of tags and readback verification is
107+
impossible.
108+
4. **The live `PRIVPASSWORD`, controller IP and MAC.** The committed `0x1234` is
109+
the public example value.
110+
5. **Whether `user_certifications` holds a duplicate pair.** No unique
111+
constraint was added, because section 13 forbids one that rejects existing
112+
data, and nobody has checked.
113+
114+
## 6. Known gaps in what is built
115+
116+
Honest list. None of these is hidden in the code.
117+
118+
- The door service has never spoken to real hardware. Every test runs against a
119+
fake that speaks the same wire protocol through the same codec.
120+
- The API image is 487 MB because `pnpm deploy --prod` keeps a workspace
121+
dependency's own devDependencies. Roughly 110 MB of build tooling ships and
122+
never runs. Fixing it properly means bundling the service to one file.
123+
- There is no password reset completion screen. The link in the mail reaches a
124+
route the members app does not render yet.
125+
- The members app posts to the three better-auth endpoints directly rather than
126+
using `better-auth/vue`, because the package is not a dependency of that app.
127+
The call sites name the file and line each path was read from.
128+
- The Recent list on the door screen only shows what that browser did since the
129+
screen opened. There is no member-facing door event route.
130+
- `packages/ui` has no multi-line input, so the two free text profile fields use
131+
single line ones.
132+
- No deploy workflow. Deployment is four lines by hand in
133+
`docs/operations.md`, which is the right amount until a second person needs to
134+
do it.
135+
136+
## 7. Open licence questions
137+
138+
Read from the source files, not assumed. In `ATTRIBUTIONS.md` with detail.
139+
140+
- **`Open_Access_Control_Ethernet` has no licence at all.** No LICENSE file, no
141+
statement in any source file. The door service reimplements its wire protocol,
142+
which is ordinarily fine for interoperability, but the lab should put a
143+
licence on its own firmware.
144+
- **GANTRY has no declared licence.** `new-hsl` has no licence file and no
145+
`license` field. The tokens and all 29 marks in `packages/ui` came from it.
146+
- **The Rails app is CC BY 3.0**, which is a content licence rather than a
147+
software one. This system reimplements its schema, its member level mapping
148+
and its payment status rule, and credits it.
149+
- This repository declares Apache 2.0 in `package.json` and has no LICENSE file.
150+
151+
## 8. If you are picking this up
152+
153+
Read in this order: `README.md`, then `CONTRIBUTING.md`, then
154+
`docs/architecture.md`, then `docs/legacy-system.md`. The decisions in
155+
`docs/decisions/` explain why things are the way they are, each with the one
156+
condition that would flip it.
157+
158+
Then run it. `make up && make seed` takes a few minutes and the thing you end up
159+
looking at is the actual system.
160+
161+
The previous three attempts died on the members side, not the door. The members
162+
side is built and works. What is left is a machine to run it on and a decision
163+
to run it.

Makefile

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
.PHONY: up down logs secrets backup restore check
1+
.PHONY: up down logs secrets seed reset backup restore check import legacy-restore
22

3+
# The whole stack in Docker: database, API, the three apps behind Caddy, and a
4+
# mail catcher. Reads .env. See README.md.
35
up:
46
docker compose up -d --build
57

@@ -24,12 +26,38 @@ secrets:
2426
@if [ -f secrets/smtp_url ]; then \
2527
echo "secrets/smtp_url exists, left alone"; \
2628
else \
27-
echo "smtp://localhost:1025" > secrets/smtp_url; \
29+
echo "smtp://mail:1025" > secrets/smtp_url; \
2830
chmod 600 secrets/smtp_url; \
29-
echo "secrets/smtp_url written with a placeholder. Put the real SMTP URL in it"; \
30-
echo " before deploying, or password reset mail will not arrive."; \
31+
echo "secrets/smtp_url written, pointing at the local mail catcher."; \
32+
echo " Replace it with a real SMTP URL before deploying."; \
3133
fi
3234

35+
# Invented members, so there is something to look at without a copy of the
36+
# lab's data. Refuses to run against a database that already holds members.
37+
seed:
38+
docker compose run --rm --entrypoint node api dist/seed.js
39+
40+
# Throws the local database away and rebuilds it from the migrations. Local
41+
# only: it deletes the volume.
42+
reset:
43+
docker compose down
44+
docker volume rm hsl_db_data 2>/dev/null || true
45+
docker compose up -d --build
46+
47+
# Copies the old Rails database into this one. Needs compose.legacy.yaml and a
48+
# dump at ./legacy/members.dump. See README.md.
49+
import:
50+
docker compose -f compose.yaml -f compose.legacy.yaml --profile tools run --rm import $(ARGS)
51+
52+
# Restores ./legacy/members.dump into the legacy database container.
53+
legacy-restore:
54+
docker compose -f compose.yaml -f compose.legacy.yaml up -d legacy-db
55+
@echo "waiting for the legacy database"
56+
@until docker compose -f compose.yaml -f compose.legacy.yaml exec -T legacy-db pg_isready -h 127.0.0.1 -U legacy >/dev/null 2>&1; do sleep 1; done
57+
docker compose -f compose.yaml -f compose.legacy.yaml exec -T legacy-db \
58+
pg_restore --no-owner --no-acl -h 127.0.0.1 -U legacy -d members /dumps/members.dump
59+
@echo "restored. Now run: make import ARGS=--dry-run"
60+
3361
backup:
3462
./tools/backup.sh
3563

0 commit comments

Comments
 (0)