Skip to content

Latest commit

 

History

History
406 lines (310 loc) · 18.7 KB

File metadata and controls

406 lines (310 loc) · 18.7 KB

Project ORO

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.

The members portal, your record

The members portal, your cards

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.


Run it locally

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:1025

Three 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.

Make the first three admins

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.

With or without the old database

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 fixture

Read 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.


Local against production

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
Mail 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.


Environment

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.


Roadmap

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.


Everything you can run

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 .githooks

Where things are

CLAUDE.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.


Contributing

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.


Licence

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.


Built on other people's work

Prior HeatSync work, without which this would be guesswork:

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.


Thanks

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.