Torim (תורים — Hebrew for "queues"/"appointments") is open-source appointment booking software for small service businesses: salons, barbers, clinics, studios, anyone who takes bookings by the slot. It's multi-tenant (one deployment can host many businesses), mobile-first, and bilingual — English and Hebrew with right-to-left layout — from the ground up. MIT licensed.
torim.holzman-ai.com/b/demo — a live instance with a fictional salon in it. Book something; it is the real application, not a video.
Two things worth knowing before you type anything into it. The demo resets every night, so whatever you book will be gone. And it has no messaging transport configured, which is the default for a fresh clone — so nothing you enter is sent anywhere, to you or to anyone else.
The owner-facing side is not reachable from the demo, because signing in would mean handing us a Google account. To see it, run it locally: the quickstart below seeds the same fictional business.
Early development, pre-1.0. The schema, tenancy model and core booking logic exist and are tested against real Postgres, but the product is not feature-complete and APIs may still change. Don't run it for a real business yet.
- Services with durations, prices, and configurable buffers before/after each booking.
- Working hours per weekday, breaks (a second working-hours row for the same day), and one-off or recurring closed dates.
- A slot picker that respects buffers, a minimum-notice window, and a booking horizon (how far ahead customers can book).
- Customer booking with phone-first identity — no account, no password.
- An admin day/week calendar with manual entry for phone or walk-in bookings.
- Owner-initiated messaging: a
wa.melink the owner taps, which needs no account and no provider. - Optional automated notifications through a transport the deployment configures itself. Nothing is configured out of the box, and nothing is sent by default.
- Bilingual English/Hebrew, including RTL layout.
Explicitly not in v1: payments and deposits, multi-staff scheduling, waitlists, or calendar sync (Google/Outlook). Torim also ships with no messaging provider and no account with anybody — a fresh clone sends nothing, and no phone number, endpoint or key for any provider exists in this repository. Automated sending is opt-in per deployment; see Notifications.
-
Node.js 22.22+ (24 recommended — CI runs 24)
Node 20 reached end of life in April 2026. The app itself would run on it, but the test suite needs a supported runtime, and shipping a booking system that handles personal data on an unmaintained Node is not a trade worth making.
-
PostgreSQL 14+
git clone https://github.com/<your-fork>/torim.git
cd torim
npm ci
cp .env.example .env.localCreate a database. Name it with a _dev, _test, or _demo suffix — npm run db:seed is destructive and refuses to run against anything else, so that the demo
seed can never be pointed at a real instance by accident:
createdb torim_devThen fill in .env.local. At minimum:
| Variable | What it is |
|---|---|
DATABASE_URL |
The app's connection — the restricted role, see the warning below. |
MIGRATE_DATABASE_URL |
The schema owner. Used only by migrate and db:grant. |
APP_DB_ROLE, APP_DB_PASSWORD |
The restricted role db:grant creates. |
SESSION_PASSWORD |
Seals the admin session cookie. openssl rand -base64 32, 32 chars minimum, no default. |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
Google sign-in. There is no other way into /admin — see Google sign-in. |
OAUTH_REDIRECT_URI |
Must match what you register with Google byte for byte. |
The defaults in .env.example already assume torim_dev. See the comments there for
what each variable does and which are required.
# Apply the schema, as the database owner (MIGRATE_DATABASE_URL).
npm run migrate
# Create the restricted application role and grant it the schema, as the owner.
# ⚠ Re-run this after EVERY migrate, not just the first one — see the note below.
npm run db:grant
# Prove DATABASE_URL is that restricted role, not the owner. See the warning below.
npm run db:check-role
# Load a demo business with services, hours, and bookings. You will run this
# again later, with SEED_OWNER_EMAIL, to make yourself its owner.
npm run db:seed
npm run devNow open http://localhost:3000/b/demo — that is the seeded demo business's public
booking page, and it works immediately with no sign-in, because customers never sign in.
(http://localhost:3000 is a static description of the project with nothing to click.)
The admin side needs Google credentials and a couple more steps, in an order that is not the obvious one — see First run: getting into /admin.
Warning
DATABASE_URL must point at a non-superuser role with NOSUPERUSER and
NOBYPASSRLS. Torim's multi-tenancy is enforced entirely by Postgres row-level
security. Postgres silently skips RLS for superusers and for roles with
BYPASSRLS — there is no error, no warning, nothing in the logs. Point the app at
such a role by mistake and every business will quietly read and write every other
business's bookings. npm run db:grant creates the correct role; npm run db:check-role asserts DATABASE_URL is safe before you trust it.
scripts/check-role.ts reads DATABASE_URL and nothing else — it takes no
argument and never looks at TEST_DATABASE_URL. CI runs it ahead of the suite, and it
does cover the suite there, but only because CI happens to set both variables to the
same connection string. If yours differ, point DATABASE_URL at the test database and
run it again.
Important
npm run db:grant must be re-run after every npm run migrate. It issues
GRANT … ON ALL TABLES IN SCHEMA torim, and ALL TABLES means the tables that exist
at that moment — it is not a standing rule, and there are no ALTER DEFAULT PRIVILEGES here. Nor does it ever cover functions, which are granted one by one in
scripts/grant-app-role.ts.
So a git pull that brings a new migration, followed by a bare npm run migrate,
leaves the app role with no rights on whatever that migration created. The symptom is
a bare permission denied for table … from a schema that migrated cleanly one second
earlier. 003_date_overrides.sql and 004_public_booking.sql each say so in a
trailing SQL comment; nobody reads those after the migration has already succeeded.
Make it muscle memory: migrate, then db:grant, every time.
npm run db:seed deletes and re-inserts every service, working-hours row, closure,
customer and booking belonging to the demo business. Its only guard is the name of
the database in DATABASE_URL: it refuses to run unless that name ends in _dev,
_test, or _demo, which is why the quickstart names the database torim_dev (and
why a publicly hosted demo can safely use torim_demo instead of ever setting
ALLOW_DESTRUCTIVE_SEED).
ALLOW_DESTRUCTIVE_SEED=1 turns that guard off. It is the one thing standing between a
mistyped or stale DATABASE_URL and destroying a real business's data with no way back.
Leave it unset. Never put it in a shell profile or a platform environment — if you ever
genuinely need it, prefix the single command with it and let it die there.
npm run db:reset is more destructive still: it TRUNCATEs every table in schema torim
except schema_migrations, for every business, as the schema owner
(MIGRATE_DATABASE_URL) — sharing the same database-name guard and
ALLOW_DESTRUCTIVE_SEED escape hatch.
The seed creates a business. It does not create a user, and it deliberately cannot: Torim keys users on their Google subject id, never on their email address, so a user row invented by a script would never match a real sign-in. The membership would look correct in the database and do nothing.
So the first run goes sign-in first, seed second:
-
Configure Google sign-in (below) and start the app.
-
Sign in once at http://localhost:3000/login. You will land on
/onboarding— signed in, with no business yet. That is the normal first-run state, not an error. -
Attach yourself to the demo business by re-running the seed with your address:
SEED_OWNER_EMAIL=you@example.com npm run db:seed
The seed prints whether it found your account. If it says there is no account yet for that address, step 2 did not complete — sign in, then re-run.
-
Open http://localhost:3000/admin. Your existing session still has no active business, so you will be bounced through Google once more; that round trip is what picks up the new membership. You land on the demo business's day calendar.
If you would rather not use the demo data at all, skip the seed and just fill in the form
on /onboarding — that creates a business of your own with you as its owner, through
torim.create_business_with_owner.
Torim's admin side has exactly one way in: Google. There is no password login, no magic
link and no bootstrap admin account. Without GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET and OAUTH_REDIRECT_URI set, /admin is unreachable — every
attempt bounces to /login?error=misconfigured, with the real reason logged
server-side only.
In the Google Cloud console:
-
Create an OAuth client of type "Web application." That is the type that issues a client secret and accepts Authorized redirect URIs, and this flow needs both: the secret is posted server-to-server during the code exchange and never reaches the browser (
exchangeCodeForTokeninsrc/lib/oauth.ts). -
Register the redirect URI exactly. For local development that is:
http://localhost:3000/api/auth/google/callbackThen put the same string in
OAUTH_REDIRECT_URI.[!IMPORTANT] It must match byte for byte — scheme, host, port, path, and no trailing slash.
127.0.0.1is notlocalhostas far as Google is concerned. Torim reads this value from configuration and never derives it from the incoming request, precisely so it cannot drift; it is sent both on the way out to Google and again during the code exchange, and Google rejects the whole sign-in if either copy differs from what is registered. -
Add yourself as a test user. A new OAuth client's consent screen starts in Testing publishing status, and while it is there only Google accounts on its Test users list may complete the flow — in the current console that list lives under Google Auth Platform → Audience. Your own account is not on it automatically. Add the account you intend to sign in with, or Google blocks you at its own consent screen — an error page on Google's domain, before Torim ever sees the request, so there is nothing in your own logs to explain it.
Three scopes, and only these three (GOOGLE_SCOPES in src/lib/oauth.ts):
| Scope | Why |
|---|---|
openid |
The stable subject id — the join key. Email addresses move between people; sub does not. |
email |
Identifies which owner or staff member signed in. |
profile |
A display name for the admin header. |
No calendar, no contacts, no Gmail, no offline access. The authorization request sets
access_type=online, so Google never issues a refresh token — Torim never acts on
anyone's behalf while they are away, so storing one would be keeping a credential it has
no use for. It also sets prompt=select_account, because a shop's owner and staff often
share one machine and one browser profile.
Sign-in also depends on SESSION_PASSWORD: the CSRF state nonce is parked in the
sealed session cookie and compared on the way back, so a deployment without a session
secret cannot complete the round trip at all.
Nothing is sent by default. Torim ships with no messaging provider and no account with anybody, so a fresh clone messages nobody.
- Owner-initiated WhatsApp needs no setup. Torim composes the message and hands the
owner a
wa.melink; tapping it opens WhatsApp on her own device, signed in as her, with the text prefilled — she presses send herself. Awa.melink carries no sender identity, so no number of anyone's is stored in the app or in this repository. - Automated messages are opt-in, per deployment. Set
TORIM_TRANSPORT(defaultnone;smtpis the other built-in) and supply that transport's own credentials in your own environment. An unrecognised value refuses to start, at boot, insrc/instrumentation.ts— rather than quietly sending nothing. - A fork writes an adapter for whatever it already uses. The
MessageTransportcontract is three members, and there is a complete worked example for a generic HTTP messaging API. - Or drain the queue from outside, over the bearer-token ops endpoints, with n8n or any cron job.
See docs/NOTIFICATIONS.md for the message flow, the contract
field by field, the worked example, and what v1 deliberately does not do (no retries,
backoff, quiet hours, debouncing or rate caps).
- Next.js App Router for both the admin app and the customer-facing booking pages.
- PostgreSQL with row-level security as the multi-tenancy mechanism — every
tenant-scoped table carries a
business_idand an RLS policy enforced viaFORCE ROW LEVEL SECURITY, keyed off a per-transaction Postgres setting (GUC). There is no separate authorization layer re-implementing what RLS already does. - No ORM. Queries are plain SQL against
pg, run through a small tenant-scoping wrapper (src/lib/db.ts,src/lib/tenant.ts) that sets the tenant GUC and refuses to run a tenant-scoped query with no tenant in scope. - Migrations are plain numbered
.sqlfiles (scripts/sql/), applied in order and checksummed — seeCONTRIBUTING.md. - Vitest runs against a real, migrated Postgres database — there is no mocked database layer, because a mock can't prove a row-level security policy actually does what it claims.
Tests need a migrated database of their own — the suite is real SQL against real Postgres, not mocks.
Set TEST_DATABASE_URL in .env.local first, pointing at that separate database as
the restricted app role — the same non-superuser, no-BYPASSRLS warning applies. Do
this before running anything below, not after. src/test-setup.ts overrides
DATABASE_URL with TEST_DATABASE_URL only if that variable is set; leave it unset
and the entire suite runs against whatever DATABASE_URL names, which is your
development database. Tests do roll their writes back (src/lib/test-db.ts pins one
connection inside a transaction per file), so this is usually survivable rather than
destructive — but they are real writes against real rows while they run, and an
interrupted run can leave a transaction open on the database you are developing against.
createdb torim_test # or your platform's equivalent
MIGRATE_DATABASE_URL=postgres://<owner>@localhost:5432/torim_test npm run migrate
MIGRATE_DATABASE_URL=postgres://<owner>@localhost:5432/torim_test npm run db:grant
npm run testRe-run that db:grant line after any later migration too, for the same reason as above.
Note that npm run db:check-role will not vet this database for you — it reads
DATABASE_URL only. To check the test database, run it once with DATABASE_URL
temporarily set to TEST_DATABASE_URL's value.
The quickstart above is a development setup. npm run dev is not a production server.
npm ci
npm run migrate # from a shell with MIGRATE_DATABASE_URL exported
npm run db:grant # same shell — and again after every future migrate
npm run build
npm run startWhat has to change from the development values:
OAUTH_REDIRECT_URI→ your real origin, e.g.https://booking.example.com/api/auth/google/callback— and register that exact string as an Authorized redirect URI on the same Google OAuth client. Google matches it byte for byte; a localhost value left in place means nobody can sign in.APP_BASE_URL→ the same public origin. Nothing crashes without it, which is the problem: management links in notification emails fall back to a relative/manage/<token>that is a dead link the moment it leaves the server, and the admin settings page shows that same relative path as the business's shareable booking link.- Serve it over HTTPS. The session cookie is set
securewhenNODE_ENVisproduction(getSessionOptionsinsrc/lib/auth.ts), so a plain-HTTP production origin means the browser drops the cookie carrying the OAuthstatenonce and every sign-in fails the CSRF check. SESSION_PASSWORDmust be a real random secret, not the one from your laptop. Rotating it signs everybody out at once; there is no per-session revocation.TRUSTED_PROXY_HOPSmust match how many proxies actually sit in front of you (default1). Get this wrong upwards and every IP-based rate limit on the public booking pages silently stops working. Read the rate-limiting section ofSECURITY.mdbefore you deploy behind a CDN.- Leave
MIGRATE_DATABASE_URLunset in the running app. It is the schema owner's connection and the app has no business holding it. Export it in the shell you runmigrateanddb:grantfrom, and nowhere else. - Leave
ALLOW_DESTRUCTIVE_SEEDunset, and don't runnpm run db:seedagainst anything real. - Publish the Google consent screen once you are past testing, or only the accounts on its test-user list can sign in.
Torim is still pre-1.0 (see Status) — this is how to run it properly, not a claim that you should yet.
MIT — see LICENSE. See CONTRIBUTING.md for how
to set up a dev environment, the migration rule, and what to expect from
contributions right now.