Members and door access for HeatSync Labs, a community workshop in Mesa, Arizona.
It replaces the Rails 3.2.8 app at members.heatsynclabs.org and the software
around the Arduino that unlocks the building. The Arduino itself stays.
Nothing is deployed. Nothing in production has been touched. You can run the whole thing on a laptop today, with invented data.
Those are real screenshots of make development on a laptop, taken on
2026-08-29, when the portal read a mock of the API contract and said so in an
amber band. It reads the members API against a real database now, and the band
says what it actually found on every load. The pages have moved on since and
these have not been retaken.
You need Docker (with the Compose plugin) and python3. Nothing else
installs on your machine. The two Python locks in this repository, under
services/api/ and tools/import-boundaries/, are installed inside images the
test suites build.
git clone https://github.com/heatsynclabs/project-oro.git
cd project-oro
cp .env.example .env # then fill in every empty value, see Environment below
make development.env.example ships ORO_HTTP_PORT=80, which is the deployment value. Set it
to 8080 before make development, or open the port you chose instead of the one
below.
Open http://localhost:8080. That is the members portal, signed out.
Nothing can sign in until the apps are registered against the identity service, which is one step and is safe to run again:
ORO_IDENTITY_TOKEN="$(docker compose -f compose.yaml -f compose.development.yaml \
cp identity:/bootstrap/pat - | tar -xO)" \
ORO_IDENTITY_URL=http://localhost:8180 python3 tools/identity/configure.py \
--members-origin http://localhost:8080 \
--admin-origin http://localhost:8081 \
--door-origin http://localhost:8082 \
--mail-host mail:1025Three things about that command, each of which cost somebody an hour.
The token is written once, into a volume, by the identity service's own first
setup. docker cp is the only way to read it: that image is distroless and has
no shell to run cat in.
ORO_IDENTITY_URL has to be passed. The default is built from ORO_HOSTNAME as
https://id.<host>, which is what a deployment serves and which resolves
nowhere on a laptop.
--mail-host is the catcher make development runs. Without it the Register
button on the sign in screens leads to a code nothing sent, and so does a
forgotten password. Read the mail at http://localhost:8025.
Stop it with make down. The database volume is kept.
The database ships with no members. It also enforces that granting admin needs a second admin to approve it, which cannot bind until admins exist. So three admin grants are allowed to carry no approval, once. This is the command that spends them:
ORO_IDENTITY_URL=http://localhost:8180 make bootstrap-admins \
ADMIN1="Ada Byron <ada@example.org>" \
ADMIN2="Grace Hopper <grace@example.org>" \
ADMIN3="Katherine Johnson <katherine@example.org>"ORO_IDENTITY_URL for the same reason as above. A deployment leaves it off and
the target builds the right name from .env.
Run it from a terminal. Each person gets an account, a member record and the admin role. Their first sign in password is printed on that terminal and written to no file, so redirecting the output to keep the report does not capture a password. They are made to choose their own password the first time they sign in.
Run it twice and it reports what is already there and changes nothing. Ask for a fourth admin and the database refuses, which is the point. After the third, the two approver rule arms itself and stays armed. Revoking admins does not open it again.
tools/bootstrap/README.md has the detail.
Both work, and neither needs the other.
Without. Everything above is the whole of it. The stack starts empty and you
add people with make bootstrap-admins. Nothing reads or contacts the existing
system.
With. If you have a dump of the legacy members database, the import carries
members, cards, the admin and accountant flags as roles, and the waiver date
as a pointer to where the document is kept. It refuses to start while anything in
the data still needs a person to decide, and it names the rows.
make migration-test # proves the import against an invented fixtureRead tools/migration/README.md before running it against anything real. Every
card keeps the slot it had, because a slot is an EEPROM address on the door
controller and renumbering one points a member at somebody else's door
permission.
One override file is the whole difference.
Your laptop (make development) |
A deployment (make up) |
|
|---|---|---|
| Command | docker compose -f compose.yaml -f compose.development.yaml up |
docker compose up |
| Scheme | plain HTTP, nothing redirects | HTTPS, and plain HTTP redirects to it |
| Certificate | none, so nothing to click through | from ORO_TLS, either Caddy's local authority or Let's Encrypt |
| The root serves | the members portal | a 404 saying no application is deployed here yet |
/v1/* |
the members API | the members API |
| Identity service | localhost:8180, loopback only |
id.YOURHOST, through Caddy |
a catcher on localhost:8025, holding what would have been sent |
the lab's own server, configured once by hand | |
| The mock | running on ORO_MOCK_PORT, with no route of its own |
absent. It never reaches a deployment |
A laptop serves plain HTTP on purpose. Under a local certificate authority Chrome
shows an interstitial that no automation can click through, and a volunteer gets
past it only by installing a root certificate as an administrator.
docs/decisions/0003-plain-http-for-development.md records what that trades
away: a Secure cookie is never sent on a plain HTTP origin, and mixed content
cannot happen where nothing is HTTPS. Check that kind of change against make up
before it ships.
A deployment also needs DNS. YOURHOST and id.YOURHOST both have to resolve to
the machine, and for a public certificate they have to resolve from the internet
before Caddy can get one.
Copy .env.example to .env. It documents every line. Nothing a deployment
needs has a default, and the stack refuses to start rather than run on a value
nobody chose.
| Variable | What it is | Generate it with |
|---|---|---|
ORO_HOSTNAME |
the name Caddy serves. localhost on a laptop |
|
ORO_TLS |
internal for a local authority, or an email address for a public certificate |
|
ORO_HTTP_PORT, ORO_HTTPS_PORT |
ports Caddy binds. .env.example ships 80 and 443, which are the deployment values. Set the first to 8080 on a laptop |
|
ORO_DB_PASSWORD |
the Postgres superuser password | openssl rand -base64 24 |
ORO_IDENTITY_DB_PASSWORD |
the identity service's own database login | openssl rand -base64 24 |
ORO_IDENTITY_MASTERKEY |
exactly 32 bytes. Encrypts every secret the identity service stores | openssl rand -hex 16 |
ORO_IDENTITY_ADMIN_USERNAME, ORO_IDENTITY_ADMIN_PASSWORD |
the first administrator of the identity service | |
ORO_IDENTITY_PORT |
the port a laptop publishes the identity service on. Defaults to 8180 |
Back up ORO_IDENTITY_MASTERKEY somewhere other than beside the database dump.
Lose it and the identity database cannot be read.
Seven phases. None of them has met its exit criterion yet, and a phase does
not start while the roles it needs have no names against them.
docs/plan/order-of-operations.md has the exit criterion for each.
Phase 0, foundations. Blocked on getting a shell on the current server.
- Repository, working rules, prose gate, commit hook, CI running all of it
- Compose stack, Makefile, documented environment
- A backup command, and a restore drill that proves the mechanism
- That backup running on a timer, with an offsite copy and the drill posting a result somewhere a named person reads
- DNS for
id,api,admin,door - A verified restore of production onto a staging copy
Phase 1, the contract and the door port. Blocked on a review.
- The members API written as OpenAPI, with a mock that serves it
- The database: schema, constraints, comments, row level security
- A test per policy per role, including anonymous, and a refusal test per rule
- The door controller port, a fake that speaks the real wire protocol, and a conformance suite both must pass
- The GANTRY token layer, with a contrast checker over every ink on every ground
- The contract reviewed by somebody who did not write it
Phase 2, identity. Blocked on volunteers.
- The identity service in the stack, with its own database
- Four clients registered, ten minute tokens, rotating refresh, lab branding
- Proof that it holds the passwords members already have, using hashes the old application wrote
- Ten real members signing in to staging with the password they already use
Phase 3, member management.
- The members portal: sign in through the identity service, your record and the profile fields you own, your cards, waiver, certifications and the directory, against the members API
- The legacy import: members, cards, roles and waivers, with every card at the slot it had
-
services/api, ten of the contract's twenty four operations: your own record and how you change it, your cards, waiver, certifications and card eligibility, the directory, and the door events you are allowed to see. Wired into both shapes under/v1, with the database policies deciding every answer - Certifications, payments and door events carried across
Phase 4, admin. Blocked on a vote at Hack Your Hackerspace.
- The two approver rule, enforced in the database and tested there
- The admin portal
- Card issue and revoke, with a reason required on revoke
Phase 5, the door.
- The adapter port and its fake, with the conformance suite, built early on purpose
- The door service and its reconcile loop, plus the real adapter
- A week running read only beside the live system, then writes
Phase 6, cutover.
- Point the members hostname at the new portal
- Run the old app read only for two weeks, then decommission it
Through all of it: physical cards keep opening the door, even when everything in this repository is down. The old app keeps driving the door until phase 5 says otherwise.
make check # every suite below, in one command
make help # every target, with a line each| Command | What it proves | Touches your stack? |
|---|---|---|
make test |
the schema from nothing, and every policy and rule | no, throwaway container |
make mock-test |
the mock serves the API contract | no, own project |
make development-test |
both stack shapes, laptop and deployment | no, own project |
make portal-test |
the members portal through Caddy | no, own project |
make identity-test |
the identity service holds the lab's existing passwords | no, own project |
make migration-test |
the legacy import, and every refusal it makes | no, own project |
make backup-test |
the restore drill: back up, destroy the database, restore, check every row came back | no, own project |
./tools/bootstrap/tests/run.sh |
the first three admins seated, and the fourth refused | no, own project |
make ceilings |
file and function size limits | no |
make api-identity-test |
the members API accepts a token the real identity service issued, and refuses four kinds it should not | no, own project |
make attributions-check |
the dependency tables in ATTRIBUTIONS.md still match the two lockfiles | no, builds two images |
make import-boundaries |
the layers only import downward, over the Python in services/ |
no |
make api-test |
ten operations of the members API, including the two writes, against a real Postgres and the real policies | no, own project |
make names |
every name a Python module uses exists | no |
./tools/ci/voice-gate.sh |
the writing rules, over every tracked file | no |
python3 tools/voice-check/test_voice_check.py |
every ban in rules 1 and 11, on copy that must fail and copy that must pass | no, python only |
python3 tools/voice-check/test_regressions.py |
that the defects the prose gate has already had stay fixed | no, python only |
python3 tools/voice-check/test_behaviour.py |
the rhythm warnings, the accessibility checks, the pragmas, and which files the walk reaches | no, python only |
./services/door/tests/run.sh |
the door port, its fake, and the conformance suite | no, python only |
./packages/gantry-tokens/tests/run.sh |
the theme, every ink on every ground | no, python only |
make up / make down / make ps / make psql / make logs |
operating the stack | yes |
make development |
starts the laptop stack and leaves it running | yes |
make bootstrap-admins |
seats the first three admins | yes |
make backup |
writes a backup outside this repository | yes, reads it |
make restore FILE=... |
restores one. Refuses over a database that holds members unless you name how many you are destroying | yes |
Every suite builds and removes its own containers and leaves nothing in the
working tree. make import-boundaries keeps the image it builds, on purpose, so
every run after the first reads the graph rather than building again. Each one
prints its own counts, so run it rather than trusting a number written down
somewhere.
Enable the commit hook once per clone:
git config core.hooksPath .githooksCLAUDE.md the working rules. Most have a gate that enforces them
HANDOFF.md current state, how to run things, and the traps
docs/plan/ architecture, API design, data model, build order, people
docs/decisions/ one short record per decision, and what would reverse it
docs/glossary.md domain words. The code uses these exactly
db/migrations/ the schema. This is the authority
db/tests/ the policy and rule suite
services/door/ the controller port, the fake, the conformance suite
packages/gantry-tokens/ the theme, and the contrast validator
apps/members/ the members portal
tools/bootstrap/ seat the first three admins
tools/migration/ the legacy import, and a fixture the old app itself wrote
tools/identity/ the password proof
tools/voice-check/ the writing gate
compose.yaml a deployment
compose.development.yaml what a laptop adds on top
If you read two files, read CLAUDE.md and
docs/plan/people-and-custody.md. The second one holds the real blocker, which
is that every role in this project is still unnamed.
Open to members and to anyone else who wants to help.
Read CLAUDE.md first. It is short, and most of it is enforced rather than
advisory. Two rules catch people out:
- No LLM is named as an author, co-author or reviewer, anywhere. The commit hook rejects it and so does CI.
- No em dashes and no emoji. Swapping in a double hyphen is caught too. The prose gate rejects all of it.
Tests come with the change. Database rules are tested at the database level,
because that is where they are enforced. Decisions go in docs/decisions/ as
short records; reversing one is fine, reversing one without writing down what
changed is not.
Not decided, and this needs fixing. A public repository with no licence is
all rights reserved by default, which is not the intent. MIT matches the rest of
the organisation. It needs a board decision, and it is tracked in
ATTRIBUTIONS.md alongside the same gap in two other HeatSync repositories.
Prior HeatSync work, without which this would be guesswork:
- Open-Source-Access-Control-Web-Interface,
the members app that has run the lab since around 2010. Its schema, its
space_api.jsoncontract and its authorization matrix are the starting point. - Open_Access_Control_Ethernet, the door firmware, live since 2013. The wire protocol and the EEPROM slot model come from reading it. Forked from zyphlar/Open_Access_Control_Ethernet.
- members_api and members_ui (Apache 2.0), the 2018 rewrite, which is the direct ancestor of this schema.
- hsl-members-site, the 2025 rewrite, which produced the best annotated schema of the three.
- hackerspace-management, the only one to model certification expiry and revocation. Adopted.
- new-hsl, the public site, and the GANTRY design tokens this theme extends.
- hsl_door_api_poller,
which reads
space_api.jsonwith a 1 KB buffer, which is why that payload has a hard size ceiling.
Ideas taken from outside the lab: SpaceAPI for
space_api.json; PostgREST, rejected as the front door
but its central idea that authorization belongs in the database is the best part
of this design; Supabase for the overall shape;
hexagonal architecture for the door port and adapter;
pg_regress for how the
database tests are written; architecture decision records for docs/decisions/;
and RFC 9457 for the error shape.
Full licence details are in ATTRIBUTIONS.md.
To everyone who built and kept running the systems this replaces. The door has worked for over a decade and the members app has outlived two attempts to retire it, which is a better record than either usually gets credit for.
To the three previous rewrites. They all stalled, and they still did the most useful thing available: three people, working separately, in three languages, independently arrived at nearly the same data model. That agreement is the strongest evidence this project had.
To the members who wrote down what they actually needed, in meetings and on the
mailing list, sometimes years ago. Most of the requirements in docs/plan/ are
not new. Somebody who had felt the problem had already stated them.

