You need:
- .NET 10
- Node.js and pnpm
- Docker, for the database
- DevTunnel
- Telegram Bot Token
Create a tunnel:
devtunnel create fbs -a -d "Facility Booking System";
devtunnel port create fbs -p 5204 --protocol http;
devtunnel port create fbs -p 7172 --protocol https;
devtunnel host fbs;Visit the URL provided and grant access.
In future runs, you only need to start the tunnel:
devtunnel host fbs;Take note of the URL provided and set it as environment variables as show below.
cd ./Fbs.WebApi;
dotnet user-secrets set "Telegram:Token" "<YOUR_TOKEN_HERE>";
dotnet user-secrets set "Telegram:WebhookUrl" "<YOUR_DEVTUNNEL_HERE>/Bot";
dotnet user-secrets set "Telegram:WebhookSecret" "<A_RANDOM_STRING>";Telegram:WebhookSecret is registered with Telegram and checked on every update to /Bot, so that only
Telegram can talk to the bot. Use 16 to 256 characters of A-Z, a-z, 0-9, _ or -, for example the
output of openssl rand -hex 32. The API won't start without it.
Bookings are copied to an organisation's Google Calendar, once it connects one, as a service account. Set its JSON key, in
base64, with dotnet user-secrets set "Google:ServiceAccountJsonCredential" "<THE_KEY_IN_BASE64>"; (see
Google Calendar).
Start the AppHost project, which runs the API and the web app (on http://localhost:3000) and prints a link to the Aspire dashboard:
dotnet run --project ./Fbs.AppHost/Fbs.AppHost.csproj;Or, with the Aspire CLI:
aspire run;The web app is built to sign people in with a phone number and a code on Telegram (what is deployed until the switch to accounts), or with Clerk, which
gives it the pages for accounts and organizations, /t/{slug}, for bookings, the timeline, and the admins of an organization. Which is decided when it
is built: see Fbs.WebApp/README.md, which also says how its pages are checked in a browser.
Everything is kept in the database, on TiDB through SqlSugar (see docs/adr/). Its schema is
applied with the migrator, which the API never does itself:
$env:ConnectionStrings__db = "Server=<host>;Port=4000;User ID=<user>;Password=<password>;Database=fbs;SslMode=VerifyFull";
dotnet run --project ./Fbs.DbMigrator -- diff; # what would change; exits 2 if anything would
dotnet run --project ./Fbs.DbMigrator -- apply; # make the tables match the entities
dotnet run --project ./Fbs.DbMigrator -- apply --create-database; # the same, on a fresh server
dotnet run --project ./Fbs.DbMigrator -- apply --allow-destructive; # also drop columns that are no longer usedapply stops before dropping a column unless --allow-destructive is given, so read the diff first. Applying
also puts back a declared index that has gone missing, which SqlSugar's own comparison doesn't notice.
The API needs ConnectionStrings:db, and refuses to start without it. At startup it checks that the database has every
table, column and index it needs, and refuses to start if not, so a version deployed before its schema was applied never
takes traffic. Extra columns and indexes are fine, so the previous version keeps working after the next one has been
applied. Turn the check off with Startup:ValidateDatabaseSchema=false.
What is booked with the phone number sign-in, and its users, facilities, roster and login codes, belong to the tenant
named by Storage:TenantSlug (3sib by default), which has to exist.
A facility is never booked twice, including by requests at the same moment: booking, or moving a booking onto a facility, first locks the facility's row, then looks for a clash with a locking read. Cancelling keeps the booking, marked as cancelled. Whoever made a booking never changes; changing one records who did, and the point of contact is stored with the booking.
With Clerk:Issuer (Clerk's Frontend API, such as https://example.clerk.accounts.dev) set, the
API also accepts Clerk session tokens as Authorization: Bearer tokens, alongside the Telegram code and cookie the app uses
today. Clerk:AuthorizedParties lists the origins of the apps that may use one, checked against the token's azp (it is
required), so a token issued to another app on the same Clerk instance is refused. The keys come from
{Issuer}/.well-known/jwks.json and are kept for an hour (Clerk:JwksUrl if they are elsewhere). Only RS256 is accepted, and a
token has to be signed, unexpired, from that issuer, and for someone.
Whoever signs in gets an account (UserAccount), made the first time they are seen from their token's sub, email and name
(add email and name as custom claims in Clerk's session token settings), kept up to date from it, and belonging to no
organisation until they are a member of one. GET /Me says who is signed in and which organisations they belong to. Enums are
written in words in JSON.
Anyone signed in with Clerk can make an organisation with POST /Tenants (name, slug, and optionally timeZone, an IANA
name that is UTC if left out, and defaultCountryCode), and becomes its admin. The slug is its address, /t/{slug}: lower case
letters, digits and hyphens, 3 to 63 of them, starting and ending with a letter or digit. Some words, such as api and admin,
are kept back. Nobody can make more than Limits:MaxTenantsPerUser (3) themselves, and a slug that is taken is a 409.
Everything that belongs to an organisation is under /t/{slug}, and the organisation is found from the address, never from what
is sent. Only an active member gets in. Someone who isn't a member, or has left, is told there is no such organisation, the same as
for an address that isn't one, so addresses can't be tried to find which exist; someone waiting to be let in gets a 403 saying so,
and so does anyone in an organisation that has been suspended. GET /t/{slug} says what the organisation is and who the caller is
in it, and PUT /t/{slug}/Me changes what they are called in it (displayName). GET and PUT /t/{slug}/Settings are for
admins: name, time zone, calling code, the length of a slot (15, 30 or 60 minutes) and whether people who join with an invite wait
to be approved (they do unless it's turned off).
What somebody is called in an organisation, which is what everyone in it sees, is the organisation's own. It starts as the name
on their account (its email if it has none) when they make or join it, unless they give another when they join, and they or an
admin can change it. While it is still the name on their account it follows that name: one changed with Clerk is changed in
every organisation where they are called by the old one, apart from those they have left, and each of those writes down that
they did (member.renamed). A name made different, such as a rank in front, stays as it is.
Admins manage what the organisation is made of, all under /t/{slug}:
- Units:
GET /Units(every member, as it is what people pick from), andPOST /Units,PUT /Units/{id},DELETE /Units/{id}for admins. A unit can't be deleted while people are in it or bookings were made for it; one that goes takes the access it gave to facilities with it. - Facilities:
GET /Facilities,POST /Facilities,PUT /Facilities/{id}(which replaces who can book it),DELETE /Facilities/{id}. A facility is available to everyone, or to the units listed, which have to be the organisation's own. One that anything was booked on (cancelled or not) can't be deleted. - Members:
GET /Members(?includeRemoved=truefor those who have left),POST /MembersandPUT /Members/{id}. Adding someone is by phone number, and they are unclaimed until they sign in. A phone number with a plus (or 00) is taken as it is, and one without is in the organisation's country.PUTsets their name, phone, unit, role (MemberorAdmin), whose bookings they hear about (None,Unit,All), andmembership:Inlets them in (or lets someone waiting in),Removedtakes them out. The organisation always keeps an admin: the change that would take the last one is a 409.
Members book under /t/{slug} too:
GET /Facilities/Bookableis what the caller can book: what is available to everyone, and what their unit has been given (admins can book anything).POST /Bookingsbooks one or moreslots(facilityId,startDateTime,endDateTime; up to 50) with the sameconduct,descriptionand point of contact (pocName,pocPhone, written on the booking as they are). All of them, or none if any clashes (a 409 that says which slot, and with what). Times have to be in the future and on the organisation's slot length (15, 30 or 60 minutes) in its time zone, whichever offset they are sent in.GET /Bookingslists bookings, earliest first, cancelled ones left out. With nofromand noto, every one of them. With a window (fromandto; if only one is given the other is 31 days away; at most 93 days), those that share any time with it. AlsofacilityId,bookedBy, andmine=true.GET /Bookings/{id}reads one.PUT /Bookings/{id}changes what it says and, if both are given, its time;DELETE /Bookings/{id}cancels it, and the booking is kept. Their booker, anyone in the booker's unit, and admins can. A booking that is over can't be moved, and one that has started can only have its end changed. Whoever made a booking never changes.
A facility is never booked twice at once, whichever way it is booked: booking, and moving a booking, lock the facility's row and then look for a clash with a locking read (see Database). When a lot of requests are after the same facility, TiDB can turn one away ("pessimistic lock retry limit reached", or a deadlock), so a booking that was turned away is run again, up to five times.
People join with a link an admin shares in a chat, as there is no email:
POST /t/{slug}/Invitesmakes one (role,unitId,expiresInDaysfrom 1 to 30,maxUsesfrom 1 to 100), and returns itstokenonce: only a SHA-256 of it is kept, so it can't be shown again.GET /t/{slug}/Inviteslists the latest 100 and whether each is stillActive,Expired,RevokedorUsedUp, andDELETE /t/{slug}/Invites/{id}stops one. An organisation can haveLimits:MaxActiveInvites(20) going at once.- Whoever has the token signs in and
GET /Invites/{token}says where it leads. A link that doesn't work, for any reason, is a 404, so the reasons can't be told apart.POST /Invites/{token}/Acceptjoins (displayNameif they want another than their name with Clerk). WithrequireApproval(the default) they arePendingand get a 403 from/t/{slug}until an admin lets them in (PUT /t/{slug}/Members/{id}withmembership: In); otherwise they areActiveat once. A use is taken in the statement that checks there is one left, so a link lets in no more thanmaxUseshowever many join together. Joining again changes nothing, and somebody an admin removed can't come back with a link, only be let back in by an admin.
Telegram is where a person is told about bookings, connected to their account from the app:
POST /Me/Telegram/Linkmakes a link to open in Telegram (https://t.me/<bot>?start=<token>), which works once, for ten minutes; a new one replaces it, and the chat that is connected stays until the new one is opened. Opening it starts the bot, which is told whose account it is by the token, and the chat is then theirs.GET /Me/Telegramsays whether one is connected, andDELETE /Me/Telegramdisconnects it. The bot only takes a token in a private chat. A chat belongs to one account: connecting it to another takes it from the first.Telegram:BotUsernamenames the bot in the link, and is asked of Telegram if it isn't set.- A member is told in the chat their account connected, and otherwise in the one their phone number was linked to before, so people carried over from before are told as they were. Somebody waiting to be let in, or who has left, is told nothing. Somebody who is in more than one organisation is told which one a booking is in.
Claiming moves people from the old sign-in (phone number and a Telegram code) to Clerk accounts. The import from the
previous version made them Unclaimed members with no account, and kept the Telegram chat the old version sent their codes
to (LegacyChatId). A signed-in user claims their member row like this:
GET /Claims/{slug}checks they can (404 if claiming is off, or they are a member already).POST /Claims/{slug}/Startreturnshttps://t.me/<bot>?start=claim_<token>, valid once for ten minutes.- They open it in Telegram. The bot gives their account the unclaimed member whose
LegacyChatIdis the chat it came from, and connects that chat for notifications.
The member keeps its phone, unit and notification scope, but its role is always member, even for old admins: the old bot
let anyone link their chat to another person's number, so the chat is not trusted with admin rights. Make the admins with
dotnet Fbs.DbMigrator.dll promote-admin --tenant 3sib --phone +6591234567 once they have claimed.
Tenant.LegacyClaimEnabled is on for organisations the importer made, and can be turned off but not back on. See
Cutover 2.
When somebody deletes their Clerk account, Clerk tells POST /webhooks/clerk (subscribe it to user.deleted, and set the
endpoint's signing secret as Clerk:WebhookSecret, the whsec_... value). Webhooks are signed with Svix: one without a valid signature
over its ID, time and body, or older than five minutes, is a 401, and none are accepted without the secret (503). The account is
erased: its name and email are cleared, its places are made former members (no name, phone or chat, not an admin, not told
anything), and its Telegram link is removed. Bookings stay, with who made them shown as "Former member". The point of contact
typed on a booking is part of the booking and stays. Being told twice, or about someone who never signed in, changes nothing. If it
was the only admin of an organisation, the API logs a warning, and promote-admin makes another.
GET /t/{slug}/Audit (admins only) says what has been done to an organisation, the latest first: its settings changed, units and facilities added, changed
and deleted, invite links made and stopped, people added by phone number, joining with a link, being let in or turned away, changed (name, phone number, unit,
role, whose bookings they are told about), removed and let back in, or taking over their places from before accounts, and it being made, and suspended or made
available again by whoever runs the system. Each says who did it, what was done in words, and what it was done to. limit (50, at most 200) and before (the at of the last one seen) page back through it. Nothing is written for
a change that was refused or that changed nothing.
An entry has no name of a person in it: who did it, and who it was done to, are kept as who they are in the organisation, and turned into names when it is read, so that when somebody's account is erased they are a former member here too, and what they did stays.
An admin of an organisation can delete it, in two steps so that a mistake can be undone. POST /Tenants/{slug}/Deletion with { "confirm": "<the slug>" }
(typing its address, to say they mean it) makes it pending deletion: nobody in it can use it (a 403 with the code pending-deletion, and its invite
links look as if they were never there), nothing is deleted, and GET /Me tells the people in it, in tenantStatus and deleteAfter, that it is to be deleted and
when. Any admin of it can take it back with DELETE /Tenants/{slug}/Deletion until it has been deleted for good, even after the time is up, and it is as it was.
Limits__DeletionGraceDays (30) is how long from asking until it can be deleted for good. Only an organisation that is active can be asked to be deleted (not one that
is suspended, or that is to be already), and one that is to be deleted can't be suspended: suspend says so and exits with 1. Both are written in its history.
Nothing is deleted for good until purge-tenants is run, which deletes the organisations that are due, and everything of them (accounts stay), by
their ID, in a transaction for each; run it on a schedule. --dry-run says what it would delete, --tenant <slug> is for one, and --early with it deletes one
that an admin has asked to be deleted before the time is up, for when somebody has to be erased sooner. See the runbook.
Anybody signed in can download what is kept about them, GET /Me/Export, as a JSON file: their account (name, email), the Telegram chat they connected, every
organisation they are, or were, or asked to be in (with the name, phone number and unit they have there), and every booking they made, cancelled ones and
the point of contact written on each included. What they changed or cancelled of somebody else's booking is only said to have been, and when: what it was for
is not theirs. Nothing about anybody else is in it.
An admin can download everything of their organisation, GET /t/{slug}/Export: its settings, units, facilities, everybody in it (removed ones and phone numbers
included), every booking (cancelled ones, and who to contact, included), the invite links that were made (never the links, only a hash of each is kept),
and its history. As it has everybody's phone number, taking a copy is written in its history. Both are limited (export, above), and neither deletes anything:
deleting is offboarding.
Anybody can make an organisation, so whoever runs the system can stop one being used. From the migrator, which is in the image:
dotnet Fbs.DbMigrator.dll list-tenants # slug, name, status, how many people, how many bookings in the last day
dotnet Fbs.DbMigrator.dll suspend --tenant some-org
dotnet Fbs.DbMigrator.dll unsuspend --tenant some-orgNobody in a suspended organisation can use it, its admins included: everything under /t/{slug} is a 403 with the code unavailable (the web app says it
has been paused, and to ask its admins), and it looks as if its invite links were never there, so nobody can join. Nothing is deleted, and unsuspend
puts it back as it was. Doing either twice changes nothing. Both exit with 1 if there is no such organisation.
Nothing is sent for it either, and the same goes for an organisation that is to be deleted. The outbox doesn't take messages of an organisation that isn't
active: Telegram notifications about it are skipped (marked Skipped, and not sent later if it is made active again, as they are about what happened then), and
calendar messages wait, untried, and go when it is active again, so its calendar catches up. Nothing is added to its calendar by the daily check either. A message
that the dispatcher had taken at the moment it was suspended is still stopped before Telegram is asked. (Replies to somebody who asks the bot something, such as
connecting Telegram, are not about an organisation, and are still answered.)
Work that follows a change, such as telling people about it, is written to the OutboxMessage table in the same
transaction as the change, so it is neither lost when the change is kept nor left behind when it isn't. A background
dispatcher (OutboxDispatcher) takes the messages that are due, has the handler for their type do them, and puts back the
ones that fail, 5 seconds later the first time and twice as long each time up to 15 minutes, until they have been tried
Outbox:MaxAttempts times (8), when they are kept as Dead, with the error, rather than tried again. Handlers have to be
safe to repeat.
TiDB has no SKIP LOCKED, so messages are taken with a lease, in a single statement: any number of API instances can run
at once, as they do while a new version starts, and a message that an instance died holding is taken over when its lease
(Outbox:LeaseDuration, 2 minutes) runs out. Only whoever holds the lease can say the message is done or failed. Messages
that are done are removed after Outbox:Retention (7 days). Outbox:PollInterval (10 seconds) is how long an idle
dispatcher waits before looking, for messages written by another instance, and Outbox:TenantId limits it to one tenant.
Telling people about bookings on Telegram goes through the outbox: whoever made the booking,
plus those who asked to hear about everyone's bookings or their unit's, once each. Bookings made together are one message
that lists them, not one for each. People with no Telegram are left out, and so are those who have blocked the bot; a
Telegram outage is tried again later without telling twice the people who were told already. Times are in the tenant's
time zone. Sending is kept to Telegram:MessagesPerSecond (25) across everything, as Telegram allows a bot about 30.
A tenant can have a Google Calendar its bookings are copied to, one way: what is in the calendar is never read back. The
CalendarConnection table says which (CalendarId, which the service account in Google:ServiceAccountJsonCredential
must have been given access to). While its Status is Active, every booking made, changed or cancelled writes an
outbox message, and CalendarBookingSync sends the booking as it is when the message is handled, so retries and messages
handled out of order can't send old news. The event's ID is the booking's ID without dashes, as it was when bookings
were kept in the calendar, so events already there are updated rather than added again, and cancelling removes them.
Google refusing the calendar (401, 404, or 403 for anything but the rate limit) marks the connection Failed, with the
error in LastError, and stops sending to it until someone puts it right and sets it Active again. Anything else, such
as a rate limit or a problem at Google, is tried again like any outbox message. BookingCalendarEvent records which
version of each booking was sent. CalendarReconciler finds bookings that aren't in the calendar as they are now, and
sends them, every CalendarSync:Interval (24 hours), which is also how a calendar connected after bookings were made gets
them. Bookings that ended over a week ago are left alone.
The API sends metrics with OpenTelemetry to wherever OTEL_EXPORTER_OTLP_ENDPOINT points (with OTEL_EXPORTER_OTLP_HEADERS and
OTEL_SERVICE_NAME as usual, for a hosted collector); locally, the Aspire dashboard has them. There are those for requests (rate, duration, how many failed), the
HTTP clients and the runtime (garbage collection, the thread pool), and the ones of this system, in the meter Fbs.WebApi, which are the ones below. In Prometheus
a name has its dots as underscores, a unit on the end and _total on counters: fbs.outbox.messages is fbs_outbox_messages_total, and fbs.db.query.duration
is fbs_db_query_duration_seconds.
None is for one organisation, so the number of lines doesn't grow as they do, and the tags are ours (a type of message, an outcome), never what somebody typed. To find out
which organisation it is, look in the audit log, at the OutboxMessage table, or at list-tenants.
| Metric | Tags | What it says |
|---|---|---|
fbs.outbox.pending (gauge) |
type |
Messages due and not handled yet |
fbs.outbox.oldest_due.age (gauge, seconds) |
type |
How long ago the oldest of them should have been handled. Growing means what is sent isn't getting there |
fbs.outbox.dead (gauge) |
type |
Messages given up on and kept to see what failed (LastError). Anything but 0 needs looking at |
fbs.outbox.held (gauge) |
Messages waiting because their organisation is suspended, or is to be deleted | |
fbs.outbox.messages |
type, outcome |
What became of each message: done, retry, dead (type is unknown for one given up on because its dispatcher kept stopping) or skipped |
fbs.outbox.handle.duration (seconds) |
type |
How long a message took to handle, whether or not it worked |
fbs.telegram.messages |
result |
One for each person: sent, blocked (they stopped the bot, which is normal) or failed (tried again later) |
fbs.telegram.send.duration (seconds) |
How long Telegram took to take a message, not counting waiting to keep to its limit | |
fbs.calendar.connections.failed |
Calendars Google refused, which stopped being sent to | |
fbs.calendar.connections (gauge) |
status |
Calendars by status: Active, Failed, Disabled |
fbs.db.query.duration (seconds) |
operation |
How long a statement took: select, insert, update, delete, begin, commit, rollback or other |
fbs.db.query.errors |
operation |
Statements that failed |
fbs.db.transaction.retries |
reason |
A transaction run again because the database turned it away: lock_retry_limit, deadlock, lock_wait_timeout or write_conflict |
fbs.db.transaction.given_up |
reason |
The same, still turned away after the last go, which is somebody who got an error |
fbs.bookings.made, .changed, .cancelled |
Bookings, one for each slot | |
fbs.bookings.clashes |
Requests to book or move refused because somebody else has the time | |
fbs.quota.refusals |
limit |
An organisation at a limit: unit-limit, facility-limit, member-limit or booking-limit |
fbs.auth.failures |
reason |
Session tokens that were sent and refused: expired, not_yet_valid, signature, unknown_key, issuer, azp, subject or invalid. Having none isn't a failure |
fbs.webhooks.received |
type, result |
Clerk's webhooks: accepted, invalid_signature or not_configured. A type that isn't one of Clerk's is other, and one that wasn't signed is unknown |
fbs.rate_limit.rejections |
policy |
Requests refused for being too many, by which limit |
fbs.tenants (gauge) |
status |
Organisations: Active, Suspended, PendingDeletion |
fbs.members (gauge) |
status |
People in organisations, by status |
fbs.tenants.created, fbs.accounts.created, fbs.accounts.erased |
Organisations made, people who signed in for the first time, and accounts erased after Clerk said they were deleted | |
fbs.invites.used |
outcome |
Joining with a link: joined, waiting (for an admin), already_in, full, unusable (ended, revoked or used up) or not_found |
fbs.claims.completed, fbs.telegram.linked |
People who took over their place from before accounts, and Telegram chats connected to an account |
What is worth being told about:
fbs.outbox.deadabove 0, orfbs.outbox.oldest_due.ageover five minutes, which is Telegram or Google being down, or the API not running its dispatcher. Sending is the part nobody sees fail.fbs.db.transaction.given_upgoing up at all is somebody who got an error, andfbs.db.transaction.retriesgoing up a lot is the database being fought over. Along withfbs.db.query.errorsand the 99th percentile offbs.db.query.duration(over a second), this is TiDB not coping.fbs.calendar.connections{status="Failed"}going up: an organisation's calendar was refused, and stays that way until somebody puts it right.fbs.telegram.messages{result="failed"}as a share of what is sent.blockedis people who left, and isn't a problem.fbs.auth.failures{reason="unknown_key"}or{reason="azp"}: Clerk's keys aren't being fetched, or the web app is at an addressClerk:AuthorizedPartiesdoesn't have.expiredis normal, as people leave tabs open.fbs.webhooks.received{result="invalid_signature"}is the wrongClerk:WebhookSecret, or someone trying.fbs.rate_limit.rejectionsandfbs.quota.refusalssuddenly high: somebody is trying to fill the system, or a limit is too low for what people do.- For whether it is being used: the rate of
fbs.bookings.made,fbs.tenants.createdandfbs.accounts.created, andfbs.tenants{status="Suspended"}.
The gauges are read from the database every Metrics__DatabaseGauges__Interval (30 seconds, and 00:00:00 turns it off), not when they are scraped, so a scrape isn't a
query, and they are the same for every instance, so with more than one, take the largest of them and not the sum. The counters are for one instance each, so add them up.
A gauge says nothing until the first look, and keeps the last one when the database can't be reached, which is what the health check is for.
The API tests use TUnit and run the API in memory, with Google Calendar and Telegram faked, so they need no secrets. The database they use is TiDB, as production's is, because it reads inside transactions differently to MySQL. The AppHost starts it in a container, so all they need is Docker, and every run creates, and drops, a database of its own on it.
dotnet test;The API answers GET /health with Healthy in every environment. Point the host's health check at it (port
8080). The image includes curl because hosts like Coolify run the health check inside the container, and Coolify
only replaces the old container once it passes.
/health also checks that the database answers, so a version that can't reach it is not put in place of the one that is
running, and says nothing more than Healthy or Unhealthy. The image carries the migrator too, so a hook that
runs in the container can apply the schema before the version starts: dotnet Fbs.DbMigrator.dll apply.
Nothing is sent anywhere until OTEL_EXPORTER_OTLP_ENDPOINT is set to a collector, such as Grafana Cloud's or one of your own, with OTEL_EXPORTER_OTLP_HEADERS for
whatever it needs to let it in. Then the metrics in Metrics are sent, and are worth putting alerts on before the first organisation other than 3SIB is let in.
Behind a proxy such as Coolify's, the client's address and scheme come in X-Forwarded-For and X-Forwarded-Proto, which are only
believed from a proxy in a private range (10/8, 172.16/12, 192.168/16, loopback and fc00::/7), and only the last hop of them
(ForwardedHeaders__ForwardLimit, 1). Somebody who reaches the API directly can't say they are somebody else with a header. Change the
ranges with ForwardedHeaders__TrustedNetworks__0, __1, and so on, for example when a proxy is somewhere else, and raise the limit to 2 if there are
two proxies in front, such as Cloudflare and then Coolify's.
Some things are limited, so that being reachable by anyone doesn't make them a way to fill the database or to guess: making an
organisation (10 an hour, for each person), looking at and using links to join or to claim a place (30 in 10 minutes, for each person), making links for
connecting Telegram (20 in 10 minutes, for each person), downloading a copy of data (5 in 10 minutes, for each person) and Clerk's webhooks (120 a minute, for each address). More than that is a 429 with Retry-After.
Change one with RateLimits__Limits__join__PermitLimit and RateLimits__Limits__join__WindowSeconds (the names are
create-organization, join, link-telegram, export and webhook), or turn them all off with RateLimits__Enabled=false. People at one address, such as a whole unit
on one network, are not one person: what is for a person is counted for their account.
What an organisation can have, and make in a day, is limited too, so that one can't use up what all of them share: Limits__MaxUnits (50),
Limits__MaxFacilities (100), Limits__MaxMembers (500, counting those waiting to be let in and those added by phone number, and not those removed) and
Limits__MaxBookingsPerDay (1000 made in the last 24 hours, counting those cancelled, and counting each slot of a batch). Going over is a 403 whose error code says
which (unit-limit, facility-limit, member-limit or booking-limit), and a batch is made whole or not at all. Somebody who is refused joining with a link,
or letting somebody back in, doesn't use up the link. They are looked at just before something is made, so a number made at the same moment can go a little over.
Maintenance__ReadOnly=true makes the API read only: everything can be read and anything that changes something answers 503,
for while the data is being moved.
Set Telegram__WebhookSecret (see above) in the environment before deploying. The API registers it with
Telegram on startup and rejects bot updates that don't carry it.
Login cookies are encrypted with ASP.NET Core Data Protection keys. The Docker image keeps them in /app/keys, so
mount a persistent volume at that path or every redeploy generates new keys and logs everyone out.