Open-source attendance for offices and communities. An organization keeps a directory of people and groups them into teams or cohorts. An event expects a group, or anyone who registered on its public page: the room shows a rotating QR screen, the door has a scanner, and whoever does not check in is marked absent unless their leave was approved. Everyone signs in through one door with an email address and a 6 digit code.
One command writes the compose file and the secrets. One more starts the stack next to its own Postgres.
npx absqir@latest init my-absqir
cd my-absqir
npx absqir upinit asks for the address, a Resend key for the sign-in codes, and the
sign-in providers you want. up starts the stack, follows the log until the
app answers, and tells you what to do next. The first email address to sign
in becomes the operator. After that, people join through invitations. You
need Docker and Node 22 or newer. The full guide — HTTPS, configuration,
upgrades, accounts — lives in apps/docs and on the docs site at
https://absqir.rimzzlabs.com.
absqir is a beta at 0.3. Start from this version: 0.1 and 0.2 fixed the
install path. Things change between versions, and CHANGELOG.md says what.
What works today:
- One door for everyone: an email code, and GitHub or Google if you turn them on.
- Organizations with roles, a people directory with CSV import, groups, and invitations.
- Events with a start, an end, a late threshold, and the groups expected. A rotating QR screen for the room, and a scanner for the door.
- A public registration page per event, and leave requests that organizers approve or decline.
- Reports with CSV export, a month and week calendar, and reminders worded in each reader's own time zone.
- An operator CLI:
init,up,doctorwith a repair for the database password,db reset, andadmin create. - English and Bahasa Indonesia. Each account picks one on the first run, and reads every screen, notification and email in it.
What it is not, yet: there is no hosted version, you self-host. It does not
do payroll, and it does not track location. If something breaks, open an
issue with the output of npx absqir doctor.
A Turborepo monorepo that builds for two targets from one codebase: a Docker
image on Node.js for self-hosting, and a Cloudflare Worker kept for a hosted
version later. Astro serves the site, Hono serves the API at /api, and both run
on the same origin. Everything belongs to an organization: accounts join
organizations with a role (owner, admin, organizer, member), the
directory and the groups live inside them.
The room screen's token, in packages/api/src/lib/qr-token.ts, is an HMAC
over the event id and the current 20 second time window, keyed by a
per-event secret that never leaves the server. The screen fetches a fresh
token when the window ends, so a photo of the code stops working almost at
once. The server accepts the current window and the one before it, so a scan
near a rotation still checks in. Only a signed-in member on the list checks
in with it, so a borrowed identifier gets nobody in.
The member's pass, in packages/api/src/lib/member-pass.ts, goes the other
way: an HMAC over the event id and the person id, shown as a QR code on
the member's phone and read by the organizer's scanner.
Experimental. The thresholds, the signals, and their weights can change in a later release. Treat a flagged check-in as a prompt to look, never as a verdict.
An organizer can attach a place to an event, or to a schedule, and ask absqir to refuse a check-in made somewhere else. Places live under Settings > Places: a point, a radius, and a name. An event copies the circle when it is created, so moving or deleting a place never changes what a past check-in was judged against.
Read packages/core/src/location-risk.ts before you change any of this. The
short version:
A browser cannot tell a real satellite fix from a fake one. An Android
fake-GPS app writes the system mock-location provider, and the web
Geolocation API has no isMock field, so the page receives an ordinary
looking fix. An override made through the developer-tools protocol sits
below the page, and getCurrentPosition still reads as native code. Only a
browser extension that replaces navigator.geolocation leaves a mark, and
that is the rarest of the three attacks.
So the fence is not the anchor. The rotating code on the room screen is: to hold a valid token you had to see the live screen. The fence closes the one gap that code leaves, which is the member who photographs the screen and sends it to a friend across town.
The server scores every accepted reading against a set of signals: a burst of readings that never moved, an accuracy no receiver reports, no altitude, a device clock belonging to another part of the world, a network address far from the claimed spot, a jump no aircraft could make since the last check-in, coordinates identical to another person's. Each one is weak alone and cheap to defeat alone. Together, and repeated across every event a person attends, they leave a pattern that is tiring to fake.
Only the geometry refuses. Every other signal flags the record for the organizer and lets the check-in through. A soft signal that locks a door falls on the member indoors with an old phone far more often than on the one person gaming it, and the cheat can try again while the honest member cannot. Flagged records show in the event's Where column with the reasons, and the organizer clears the flag or marks the person absent.
A refused member can push back. I am here, tell the organizer opens a short form, and the report lands in Check-in problems with the evidence already attached, including whether they held a live code from the room screen. Approving writes the attendance record timed at the refused scan, not at the decision, so nobody is marked late for how long a report waited.
Every attempt, accepted or refused, is written to check_in_attempt. The
refused rows are the point: one is a member in the wrong place, and thirty
across a term, each a little nearer the fence, is somebody finding the line.
packages/api/src/lib/risk-provider.ts is the seam for a paid
device-intelligence service. None ships with absqir, because a self-host
must work with no account anywhere.
| Layer | Tool |
|---|---|
| Site | Astro 7, React 19 islands, React Compiler |
| UI | shadcn CLI over Base UI, Tailwind v4, Motion, Phosphor icons |
| API | Hono with @hono/zod-openapi and Scalar |
| Data | ts-belt, ts-pattern, date-fns, Dinero.js on bigint |
| Database | Postgres through Drizzle ORM (Hyperdrive on Workers) |
| Auth | Better Auth with the organization plugin, email and password |
| Resend with React Email, optional | |
| Language | i18next in packages/i18n, English and Bahasa Indonesia |
| Runtime | Node.js in Docker for self-host, Cloudflare Workers for cloud |
| Docs | Vocs in apps/docs |
| CLI | The absqir package in packages/cli |
| Tests | Vitest |
| Lint | Biome for code, Prettier for Markdown and YAML |
| Commits | commitlint, cz-git, Lefthook |
| Release | release-please, GitHub Actions, GHCR, npm |
apps/
web/
src/
components/ Islands. One folder per feature: auth, onboarding,
app-shell, people, groups, events, schedules,
check-in, my, settings, home, shared
layouts/ Astro shells: auth, dashboard
lib/ Clients, schemas, query client, runtime glue
mutations/ One hook per action
queries/ One hook per read
pages/ sign-in, onboarding, invite, the dashboard modules, api
middleware.ts Event, memberships, and onboarding into locals, plus
the route guards
docker-entry.mjs Container entrypoint: migrate, then serve
docs/ Vocs docs site and landing page
packages/
api/
src/
lib/ QR token, member pass, event clock, schedules,
org access, slugs, CSV
middleware/ Security, request context, event
routes/ One file per resource: auth-flow, onboarding, me,
organizations, people, groups, events, schedules, my
context.ts Shared by Hono and the Astro middleware
tests/
auth/ Better Auth instance, email codes, organizations,
roles, the sign-up door
cli/ The absqir operator CLI, published to npm
config/ Shared tsconfig and vitest presets
core/ Money on bigint, dates, query keys
db/ Drizzle schema, client, migrations, operator ops
i18n/ The message catalogs, one folder per language
transactional/ Resend mailer, the code and invitation templates
ui/ Base UI primitives, Tailwind theme, motion
Tests live in a `tests/` folder beside `src/`, never mixed into it.
The site imports the API and mounts it in src/pages/api/[...path].ts. One
build, one deploy, one origin. Same-origin removes CORS and keeps event
cookies on SameSite=Lax.
On macOS or Linux, one script does every step below:
./setup.shThe script checks each tool first and installs only what is missing, so you
can run it again at any time. It asks before every install. It sets up Node,
pnpm and a local Postgres, writes the environment files with a fresh secret,
installs the dependencies, and pushes the schema. ./setup.sh --check reports
what is missing and changes nothing. ./setup.sh --yes installs without
questions.
The same steps by hand:
- Install the dependencies.
pnpm install- Create the database.
createdb absqir- Copy the environment files.
cp .env.example .env
cp apps/web/.dev.vars.example apps/web/.dev.vars- Write a secret into
apps/web/.dev.vars.
openssl rand -base64 32-
Set
localConnectionStringinapps/web/wrangler.jsoncto your local Postgres. Miniflare needs a password in the URL, even withtrustauth. -
Apply the schema.
pnpm db:push- Start the stack.
pnpm dev- Site and API: http://localhost:4321
- API reference: http://localhost:4321/api/reference
In dev the site reads each packages/* workspace straight from its
TypeScript source, so an edit there hot-reloads with no build step. Run
pnpm dev:all instead if you want the tsdown watchers as well: they keep
the .d.ts files fresh, which the editor needs to see a new export. Run
pnpm dev:email for the email preview on http://localhost:3001.
Without RESEND_API_KEY the server prints every sign-in code and invitation
link to its log, so you can sign up on a laptop with no mail account.
Read CONTRIBUTING.md. In short: small pull requests,
conventional commits, and pnpm check && pnpm typecheck && pnpm test green
before you push. The repo squash-merges pull requests.
| Command | Action |
|---|---|
pnpm dev |
The site and the API, with hot reload |
pnpm dev:all |
The same, plus the package type watchers |
pnpm dev:email |
The email preview on port 3001 |
pnpm docs:dev |
The docs site with hot reload |
pnpm docs:build |
Build the static docs site |
pnpm docs:preview |
Serve the built docs site |
pnpm build |
Build every workspace for Cloudflare |
pnpm build:node |
Build every workspace for the Node target |
pnpm preview |
Serve the built Worker |
pnpm deploy |
Build, then wrangler deploy |
pnpm typecheck |
tsc and astro check |
pnpm check |
Biome lint and format check |
pnpm fix |
Biome, with fixes applied |
pnpm format |
Prettier over Markdown and YAML |
pnpm cf:types |
Write worker-configuration.d.ts from the bindings |
pnpm cf:hyperdrive |
Create a Hyperdrive config |
pnpm cf:secret |
Put a production secret |
pnpm db:generate |
Write a migration from the schema |
pnpm db:migrate |
Apply the migrations |
pnpm db:push |
Push the schema, no migration file |
pnpm db:studio |
Open Drizzle Studio |
Two sources, because two runtimes read them.
apps/web/wrangler.jsoncholds the bindings and the plain variables. The Worker reads them.apps/web/.dev.varsholds the local secrets. Never commit this file..envat the root holdsDATABASE_URLfor drizzle-kit only. The Worker does not read it.
The map that picks a place uses OpenStreetMap raster tiles, which need no
account and no key. Set PUBLIC_MAP_TILE_URL and
PUBLIC_MAP_TILE_ATTRIBUTION to your own tile server before you put real
traffic through it.
@t3-oss/env-core validates the Worker variables in packages/api/src/env.ts.
A missing or short secret stops the request with a clear message.
Put a production secret with:
pnpm cf:secret BETTER_AUTH_SECRETSelf-hosting runs the published Docker image — see the docs site. This section covers deploying your own Cloudflare Worker from source.
- Create a Hyperdrive config against your production Postgres.
pnpm cf:hyperdrive -- --connection-string="postgresql://user:password@host:5432/absqir"-
Copy the returned id into
hyperdrive[0].idinapps/web/wrangler.jsonc. -
Put the secrets.
pnpm cf:secret BETTER_AUTH_SECRET
pnpm cf:secret RESEND_API_KEY- Apply the migrations against production, then deploy.
DATABASE_URL="postgresql://..." pnpm db:migrate
pnpm deployThe API applies these on every request:
- Headers: a
default-src 'none'content security policy,nosniff,frame-ancestors 'none',no-referrer, and HSTS in production. The Scalar page gets a wider policy because it loads a CDN bundle. - CSRF:
hono/csrfrejects a cross-origin form post. JSON requests are covered by the same-origin rule. - Body limit: 64 KB.
- Rate limit: the Cloudflare rate limit binding, keyed on
CF-Connecting-IP. The edge sets that header, so a client cannot forge it. - Auth: Better Auth adds its own limits, 5 sign-ins per minute, 3 codes
per minute, 5 code checks per minute. Passwords are 12 characters or more.
These limits are off while
ENVIRONMENTisdevelopment. - Cookies:
HttpOnlyalways.SecurewhenENVIRONMENTisproduction. - Errors: the handler returns a request id, never the internal message.
Two controls change with the environment, because a strict value blocks local work:
| Control | Development | Production |
|---|---|---|
| HSTS | off | on |
| Secure cookies | off | on |
| API docs | on | off, unless ENABLE_DOCS=true |
Sign-in codes and invitations travel by email. RESEND_API_KEY is required
when ENVIRONMENT is production; the request fails with a clear message
without it. Outside production the mailer is optional and the server logs
what it would have sent. The templates live in
packages/transactional/src/emails/.
- Absolute imports. Every package maps
@/*to its ownsrc/*. verbatimModuleSyntaxis on in every workspace.- Biome formats code, JSON, and CSS. Prettier formats Markdown and YAML, because Biome does not read those.
- Lefthook runs Biome and Prettier before a commit, and
typecheckbefore a push.
Four libraries cover data work. Each has one job.
| Need | Use |
|---|---|
| Transform, group, or fold data | @mobily/ts-belt (v4 release candidate) |
| Branch on a shape or a union | ts-pattern |
| Read or format a date | date-fns, through @absqir/core/date |
| Hold or compute an amount | @absqir/core/money |
Three rules go with them.
- Data is read-only in the browser. An island renders what it is given. It never rewrites a collection in place.
- Aggregation happens on the server. Sums, group-bys, and joins run in the API where the data comes from, not in a component.
- Money is never a float. An amount is a bigint of minor units in Postgres
and a
Moneyvalue in code.moneyColumn()in the schema andtoMinorUnits/fromMinorUnitsin@absqir/core/moneyare the only bridge.
const price = money({ amount: 1234n, currency: USD }); // $12.34
const shares = split(price, [1n, 1n, 1n]); // no cent is lostmatch from ts-pattern replaces a chain of if. Use .exhaustive() so a new
case in a union becomes a type error instead of a silent fall-through.
packages/ui holds the design system. The shadcn CLI writes into it, over Base
UI rather than Radix.
pnpm dlx shadcn@latest add <component> -c packages/uiThe app imports one component per path, so nothing unused is bundled:
import { Button } from "@absqir/ui/button";
import { Reveal } from "@absqir/ui/reveal";- Icons come from
@phosphor-icons/react. Import the*Iconname, such asXIcon. The bare name is deprecated. - Motion comes from
motion. Wrap an animated island inMotionProviderand build entrances withReveal. Every popup inpackages/ui(dialog, sheet, popover, tooltip, menu, select, combobox) animates throughpopup-motion.tsx, not CSS keyframes. The root hands Base UI anactionsRefandMotionPopupunmounts the popup after the exit completes. Drawer, toast, accordion, and collapsible keep Base UI's own transitions, because Base UI measures or swipes those itself. - shadcn refresh:
shadcn add --overwriterewrites a component from the registry and drops therenderprops that hook Motion in. After an overwrite, diff the file against git and restore theMotionPopuprender. - Reduced motion is honoured twice:
MotionConfig reducedMotion="user"for React animation, and aprefers-reduced-motionblock inglobals.cssfor every CSS transition and keyframe. You do not opt in per component. - Fonts are Inter for sans, JetBrains Mono for mono, and Merriweather for serif. Astro downloads them at build time and serves them from our own origin, so no request leaves for a font CDN.
Vitest runs per package, so Turbo caches each result on its own.
pnpm test
pnpm --filter @absqir/core test:watchpackages/core and packages/api run on Node. packages/ui runs on jsdom with
Testing Library. vitest.shared.ts at the root holds the settings they share.
Commits follow Conventional Commits. Lefthook checks the message, and CI checks every commit in a pull request.
pnpm commitThe prompt limits the scope to one of: web, api, auth, db, ui, core,
email, ci, deps, repo.
release-please reads the commits on main and keeps a release pull request open
with the next version and the changelog entries. Merge that pull request to cut
a release.
- A pull request into
mainruns lint, format, types, tests, both target builds, a Docker build, and the commit message check. - Merging the release pull request tags the version and writes
CHANGELOG.md. - The tag publishes the Docker image to
ghcr.io(amd64 and arm64) and theabsqirCLI to npm. The tagged commit is the squash merge of a pull request CI already checked, so nothing runs twice. - A failed publish runs again from the Actions page: start the Release workflow by hand with the tag as input.
The CLI publishes through npm trusted publishing, so no npm token lives in the
repository. The Docker image publishes with the built-in GITHUB_TOKEN. The
docs site needs two repository secrets:
| Secret | Used for |
|---|---|
CLOUDFLARE_API_TOKEN |
the docs site deploy on Cloudflare |
CLOUDFLARE_ACCOUNT_ID |
the docs site deploy on Cloudflare |
Dependabot opens one grouped pull request a week for the GitHub Actions and
one for npm minor and patch updates. Major npm updates arrive on their own.
| DATABASE_URL | drizzle-kit migrate on Cloudflare deploy |
Every page renders per request because each one depends on the reader.
| Path | Who can open it | Behaviour |
|---|---|---|
/ |
Signed in | Dashboard: create and list events |
/events/[id] |
Signed in | Live check-ins, toggle, CSV export, delete |
/events/[id]/display |
Signed in | Full-screen rotating QR for the projector |
/a/[id] |
Anyone | Check-in form, opened from a scanned QR |
/sign-in |
Signed out | Signed-in readers go to / |
/sign-up |
Signed out | Signed-in readers go to / |
The middleware sends a signed-out reader of a private page to
/sign-in?next=…. The ownership check runs in the API, so an event page for
another user's event renders an error, not the data.
src/middleware.ts reads the event once per request, puts the user on
Astro.locals, and applies the guard. The page passes that user into the island
as a prop, so the panel never flashes a signed-out state before hydrating.
Every page answer carries Cache-Control: private, no-store.
There is no refresh token here, and none is needed. Better Auth uses a rolling
database session: an opaque token in an HttpOnly cookie, with a row in the
session table that holds expiresAt.
Reading the session is what renews it.
- A request arrives with the session cookie.
- If the session is older than
updateAge(1 day), Better Auth pushesexpiresAtout to now +expiresIn(30 days) and re-sets the cookie. - If it is not that old yet, nothing is written.
So an active reader is never signed out, and an idle one has 30 days. A JWT access and refresh token pair solves a different problem: it lets a stateless service verify a token without asking the database. This app has one origin and one database, so the cookie is simpler and can be revoked instantly.
Three things keep it working:
src/middleware.tsreads the session on every page request, which renews it as a side effect.useSession()refetches on window focus, on reconnect, and every four minutes, which is just under the five minute cookie cache. An open tab therefore keeps its own event alive.- "Keep me signed in" maps to Better Auth's
rememberMe. Unchecked, the cookie dies when the browser closes.
Tune the window in packages/auth/src/index.ts.
Forms use React Hook Form with a Zod resolver. Fields bind through control and
the design system's FormField, never by spreading register() onto an input.
<FormField
control={form.control}
name="email"
label="Email"
render={(field) => <Input {...field} type="email" />}
/>Data fetching uses TanStack Query. Each query and mutation is a hook in its own directory, so a component reads as a list of capabilities:
const health = useHealth();
const session = useSession();
const signOut = useSignOut();Every key comes from the factory in @absqir/core/query-keys. Do not write an
inline ["events"] array anywhere: keys drift, and a cache read then stops
matching the cache write. queryFn is the one place the frontend throws,
because TanStack Query turns a throw into error state for the UI.