access control
The lock keeps the hours

1club opens doors through three providers. Impro runs on the club's own network, behind the Go bridge I wrote about in A door on someone else's network. WebLock is a cloud service with dated entries. Salto KS is the third, and the one I enjoy working with most.
All three read the same thing. A booking, a check-in or a plan becomes a row in
access_grants: this person may enter this Area, at these times. Each provider
turns those rows into whatever its doors understand. For Impro that is an "open now"
list, for WebLock it is entries with a start and an end. For Salto it is
membership of an access group.
Why Salto
Salto KS is a cloud platform for battery-powered locks. There is no server to run in the club, no bridge to install, and no wiring to the door: the lock is an escutcheon that replaces the handle, and it talks to Salto's cloud through a gateway on the club's network.
Three things make it pleasant to integrate with:
- The lock enforces the schedule. A time schedule on an access group is pushed to the lock, and the lock refuses outside it. If 1club is down, or the lock is offline, the hours still hold.
- The phone is the key. Salto's SDK mints a key on the member's phone after they sign in, and the phone opens the door over Bluetooth. No card to print or lose.
- It has an API for everything. Sites, users, groups, locks, schedules and subscriptions are all reachable through the Connect API, with an OpenAPI spec.
The mapping is short:
- a club is a Salto site;
- an Area is an access group holding that Area's locks;
- a member is a site user carrying their 1club login email;
- an open access window means the site user is subscribed and in the group;
- the Digital Key is a key the phone mints after signing in to Salto.
The window is ours, the hours are the lock's
Salto's time schedules belong to a group, not to a person in it. A booking from 18:00 to 19:00 cannot be written as "this member, this hour" on a shared group. So for bookings and check-ins 1club keeps the window itself: the member joins the Area's group when their grant opens, with a buffer of 15 minutes by default, and leaves when it closes. The club's opening hours go on every group as a backstop. If we ever fail to take someone out, the lock still refuses them at night.
The obvious alternative was a group per booking, each with its own schedule. We rejected it: a group whose schedule call fails part-way admits around the clock, and that failure is invisible until someone walks in at 03:00.
Plans work the other way round. A member on an unlimited plan valid from 09:00 to
15:00 sits in a group for as long as the plan lasts, and the lock applies the hours.
There is one group per access profile - the Areas one plan opens and its hours -
named after both when the name fits, Gym and Sauna plan Mon-Fri 0900-1900. A
member with two plans is in two groups, and the door opens while either is open:
09:00 to 15:00 and 10:00 to 19:00 open the door from 09:00 to 19:00, and nothing in
1club had to merge them. A plan group is created schedule first, then locks, and is
recorded only once both are on. Nobody is added to a group that is not recorded, so a
failure part-way leaves an empty group rather than an open one.
The sync
The part that does the work is a sync, and it runs on two clocks.
booking, check-in, membership or plan write
→ trigger marks the person same transaction
→ dispatcher, every second re-projects their grants, diffs against Salto
→ add to group, remove, subscribe, suspend
reconcile, every five minutes everyone, checked against Salto's own lists
Postgres triggers on bookings, check-ins, memberships, plans, area types and clubs mark the affected person inside the transaction that changed them. Nothing is queued after the commit, so nothing is lost when a process dies between the write and the enqueue. Every second a dispatcher picks up the marked people and anyone whose next window boundary has arrived, re-projects their grants, and applies the difference to Salto. A booking made at the front desk reaches the door before the member gets there.
Every five minutes the reconcile goes through everyone. It reads the groups as Salto has them, not as 1club last wrote them, and corrects anything that drifted. It also picks up what no trigger can see: a new day for a weekly plan, a new Area, a family member added to a shared plan. That gives a bound I can say out loud - a door is at most five minutes behind for anything a trigger misses.
All of it runs inside a distributed cron lock, because every API replica starts every job, and leavers are processed before joiners in each pass. That ordering matters because of seats.
Seats
Salto bills per subscribed site user, and the site's voucher caps how many can be subscribed at once - from 15 to 2,000, depending on its size. A club with ten thousand members cannot buy a voucher for all of them. So when a member's window closes, the sync suspends them as well as taking them out of the group, and the voucher only has to cover the people inside right now. Processing leavers first means a seat frees up before the next member needs it.
There are two exceptions. A member who has just activated their key keeps their seat for fifteen minutes, so a suspend cannot break the sign-in and device registration that follow. A member on a plan stays subscribed for the whole plan, because their access never closes in that sense. That costs seats and saves an API call per member per day, and it means the lock enforces the hours with 1club out of the picture.
The admin page shows the voucher's headroom, and a refused subscribe is reported as a full voucher rather than a vendor error.
What went wrong
Every person has two ids. Salto gives a site user one id and the account behind it another. The subscription and device keys take the site user id. Group membership takes the account id. Our first version used the site user id for both. Removing a member from a group with the wrong id answers 404, and a 404 on a removal reads as "already gone". The sync reported success while members stayed in their groups. Group membership is now keyed by the account id, and the difference is written at the top of our integration docs, because the endpoints do not make it obvious.
Lists come in pages. The first version read only the first page. A site with more locks than fit on one page offered only some of them to link to an Area. Every list is now read to the end.
Group names have rules nobody wrote down. Our first plan groups used a middle dot between the Area and the hours. Salto refuses a middle dot, refuses an en dash, and refuses any name longer than 50 characters, each with a 400. Names are plain ASCII now, and when the hours do not fit, they give way to a short code made from them. The group's schedule carries the hours anyway.
The login page has no button for us. The Digital Key needs a Salto sign-in. Salto
offered to federate their identity server to ours, which is how I ended up building
an identity provider in an afternoon.
Once it worked, the member still landed on Salto's own login form, with Apple and
Google and no 1club. The app now sends acr_values=idp:<scheme> with the scheme
Salto registered for us, and Salto's server goes straight to our login. The first
attempts all answered 401 invalid_client, until Salto had our client secret on
their side. The callback URL to register was not documented either; it turned out to
be the federation path of their identity server, /federation/<scheme>/signin.
Linking is by email, and there is no auto-provisioning. Salto matches the account our identity provider asserts to a site user by email, and a member who signs in without a site user gets an error. So 1club creates the site user first, with the member's login email rather than the email the club typed into their profile. The app also refuses a Salto sign-in made with a different 1club account than the one asking for the key, and forces a fresh sign-in, because a phone's browser can still hold someone else's session.
A block in Salto is final. If the club blocks a member in Salto KS directly, the sync never subscribes them or adds them to a group again, and the member sees that they are blocked. A revocation made at the vendor stands until the club lifts it there.
Sharing a site
Salto has no per-integrator scope. Every API client is a site administrator and sees every user and every group. A club that also uses a coworking system on the same site has both systems writing to the same lists.
So 1club changes only what it created. Our site users carry the alias
1club:contact:<id>. Our groups are found by the ids we stored, never by name. We
never delete a site user, and never call anything that replaces a whole roster. The
one overlap is a person both systems serve, a gym member who also rents a desk. Salto
keeps one site user per person per site, so when the other system created them first,
1club finds that user by email and links them as shared: we add them to our groups
and take them out again, and leave everything else - name, email, alias,
subscription - to the system that owns them.
Accounts
The configuration has three owners.
The deployment decides the Salto environment. Production talks to Salto production, and staging and local development talk to Salto's acceptance environment. The OAuth clients Salto issued to 1club as an integrator are the same for every customer, so they are environment variables, never something a customer types in.
The organization holds its Salto accounts: system users the backend signs in as, with their passwords encrypted at rest. An organization can hold several.
The club picks one account and one of its sites. The site picker lists the sites of every account, grouped by account.
Moving a club to another account or site, or deleting an account clubs still use, takes care. Access provisioned at a site can only be withdrawn at that site, with the account that put it there. So before the move, 1club runs one pass as if the club were disabled: every member out of every group, suspended where suspending applies, and the club's groups deleted by their stored ids. Only when nobody is left does the club move. If anyone is, nothing changes and the admin is told why.
A failed connection says which part to fix. Salto's identity server answers 400 for a
wrong client and a wrong system user alike, and only the OAuth error in the body
tells them apart, invalid_client against invalid_grant. The admin page reads it
and names the part at fault - the system user, the site, or 1club's own client, which
means contacting our support - instead of showing Salto's status line. The most common surprise is the first: system users are invited
per environment, so the one that worked on acceptance does not exist in production.
The management
For all of that, the settings page is small. An accounts card, and per club: the site, the locks of each Area, the buffer, the opening-hours backstop, and an enable switch. Sync areas creates a group per Area, attaches its locks and writes the schedules. Sync now runs a full pass on demand. A status panel shows the seats. Bookings, check-ins and plans need no button at all; they reach the door through the triggers.
The door
Most of this was built against API responses. That only goes so far with a product whose whole point is a physical lock, so we installed one: a Salto escutcheon on the office door, on Salto's acceptance site, set up with the help of Salto's support team.
The lock was the easy part. The office ethernet ports had never been used, so they had to be installed and enabled before the gateway had a wired connection, and the firewall had to be opened before it could reach Salto's cloud. Until both were done, the lock existed in Salto KS and the cloud never heard from it.
The door hangs on the same acceptance site that our staging environment and my laptop talk to. Every change to the sync now reaches a real lock, and the checks that a mocked API cannot answer - a key minted on a phone, a schedule refusing after hours, a lock that has been offline - run against the door.
The next step is the other direction. A pass with ten visits needs each entry counted, and today only a check-in counts one. That needs entries read back from the doors and turned into check-ins, and the escutcheon in the office is where that work will be tested first.


