Know where your team is. Own where you've been.
CheckMark is a full-stack Next.js application with two PWA surfaces:
- User side (
/me/*) - mobile-first, individuals record their own presence, submit leave, manage their documents - Org side (
/ws/:slug/*) - desktop-first, an HR/admin surface for presence, employee records, assets, leave, documents and approvals
The org surface is permission-driven: every page and every API route is gated by a
resource × action grid, not by an isAdmin boolean. See
Permissions before touching anything under /ws.
| Layer | Choice |
|---|---|
| Framework | Next.js 16.1 (App Router, React 19, TypeScript) |
| Database | better-sqlite3 in local dev → Turso / libSQL (@libsql/client) in prod |
| Auth | Custom - email + password + OTP, jose (JWT) + bcryptjs |
| Styling | Tailwind CSS v4 + component classes in globals.css, no component library |
| UI kit | src/components/ui/ - 25 in-house primitives + 3 hand-rolled SVG charts |
| Resend (OTP, consent, ownership transfer) | |
| Spreadsheets | exceljs (XLSX attendance export, XLSX/CSV imports) |
| Icons | lucide-react |
| Deployment | Vercel; cron via GitHub Actions |
There is no charting library, no component library, no ORM and no payment integration. That is deliberate in every case.
The production Turso database is hosted in aws-ap-south-1 (Mumbai). That is not
incidental: employee_sensitive stores PAN, Aadhaar and bank account numbers - Indian
statutory identifiers - so data residency matters. They are additionally encrypted at the
field level with AES-256-GCM (src/lib/encryption.ts).
- Node.js 20+
- npm 10+
npm installCopy .env.example to .env.local and fill it in. The minimum for a working local dev
server:
# Leave TURSO_* UNSET → SQLite at ./checkmark.db (this is what you want locally)
# Generate: openssl rand -base64 32
JWT_SECRET=your-random-32-char-secret
# 64-char hex. Employee sensitive fields THROW without it.
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
FIELD_ENCRYPTION_KEY=<64 hex chars>
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Optional - without it, OTPs are printed to the server console
RESEND_API_KEY=re_xxxxxxxxxxxx
⚠️ Never put Turso credentials in.env.local.src/lib/db/index.tsselects the Turso backend the momentTURSO_DATABASE_URLis set, and Next.js auto-loads.env.local. Putting production credentials there silently pointsnpm run devat production. They belong in.env.sync.local, which onlyscripts/sync-local-db.jsreads. Both files are gitignored (*.env*).
npm run migrate # or: node scripts/migrate.jsscripts/migrate.js is the single schema source. On a fresh DB it creates every table;
on an existing DB it runs additive ALTER TABLE statements (duplicate-column errors are
swallowed). Re-running is always safe.
npm run db:syncReads TURSO_DATABASE_URL / TURSO_AUTH_TOKEN from .env.sync.local (then .env.local,
then the shell - shell wins), copies every table's schema and rows into a fresh SQLite
file, verifies integrity_check and foreign_key_check, then swaps it in and keeps a
timestamped backup of the old file.
This includes
employee_document_blobs, so a sync pulls real document bytes onto your laptop. Override the destination withLOCAL_DATABASE_PATH=./tmp/prod.db npm run db:syncif you would rather not overwrite./checkmark.db.
npm run dev # http://localhost:3000| Variable | Required | Description |
|---|---|---|
JWT_SECRET |
Yes | 32+ char random string. Signs session JWTs, OTP cookies and domain-verify tokens |
FIELD_ENCRYPTION_KEY |
Yes | 64-char hex (32 bytes). AES-256-GCM key for PAN / Aadhaar / bank account. Any read or write of employee_sensitive throws without it |
NEXT_PUBLIC_APP_URL |
Yes | Canonical origin. http://localhost:3000 in dev, https://checkmark.kabirinnovations.com in prod. Drives canonical links, OG URLs, robots and sitemap |
TURSO_DATABASE_URL |
No (dev) | Turso/libSQL URL. Unset → SQLite at ./checkmark.db. Setting it in .env.local points dev at prod |
TURSO_AUTH_TOKEN |
No (dev) | Turso auth token |
LOCAL_DATABASE_PATH |
No | Destination SQLite path for npm run db:sync. Defaults to ./checkmark.db |
RESEND_API_KEY |
Recommended | Resend API key. Missing → OTPs are logged to the server console |
RESEND_FROM_EMAIL |
No | From-address override for outbound mail |
VAPID_PUBLIC_KEY |
Push | Web Push public key - npx web-push generate-vapid-keys |
VAPID_PRIVATE_KEY |
Push | Web Push private key (never exposed to the client) |
VAPID_EMAIL |
Push | mailto: contact for VAPID registration |
CRON_SECRET |
Push | Bearer secret for POST /api/push/cron. Must match the GitHub Actions secret. If unset, the cron route returns 401 to everyone - reminders and auto-checkout silently stop |
src/
├── app/
│ ├── layout.tsx # Root metadata, fonts, PWA meta
│ ├── page.tsx # / - marketing landing
│ ├── manifest.ts robots.ts sitemap.ts error.tsx
│ ├── (public)/ # No session required
│ │ ├── login/ # /login - 9-state auth machine
│ │ ├── join/[slug]/ # Invite / domain-match landing
│ │ ├── consent/[token]/ # Accept or decline from the email link
│ │ ├── for-teams/ for-you/ pricing/ open-source/ privacy/ terms/
│ ├── me/ # User PWA - session required
│ │ ├── layout.tsx # MeTopbar + BottomNav (Timeline · Home · Leave)
│ │ ├── page.tsx # /me - check-in / checkout, today, stats
│ │ ├── timeline/ # /me/timeline - event history
│ │ ├── leave/ # /me/leave - balances, apply, my requests
│ │ ├── documents/ # /me/documents - own document folder
│ │ ├── workspace/ # /me/workspace - today's roster for a workspace
│ │ ├── ws/[slug]/ # /me/ws/:slug - workspace-scoped member home
│ │ ├── orgs/ profile/ settings/ notifications/
│ └── ws/ # Org PWA - session + org-surface permission
│ ├── page.tsx # /ws - workspace picker
│ ├── new/ # /ws/new - create a workspace
│ └── [slug]/
│ ├── layout.tsx # Resolves role → readableResources → sidebar
│ ├── page.tsx # Overview
│ ├── employees/ # HR directory + wizard + document folders
│ ├── assets/ # Asset register
│ ├── attendance/ # Day-level attendance table
│ ├── leaves/ # Applied · Maternity · Paternity (types+balances in Settings)
│ ├── holidays/ # Holiday calendar + CSV/XLSX import
│ ├── approvals/ # Leave / regularization / document queue
│ ├── people/ # Membership: invite, roles, consent
│ │ └── [userId]/details/
│ ├── members/[memberId]/
│ ├── insights/ monthly/ reports/
│ ├── roles/ # Permission grid editor
│ └── settings/ # Org · Leave · Balances · Signals · Domains · Ownership
├── app/api/ # ~92 route handlers - see API Reference
├── components/
│ ├── ui/ # The design system (barrel: index.ts)
│ │ ├── Avatar BottomSheet Button Card Chip DataTable Divider DropdownMenu
│ │ ├── Dropzone EmptyState Field IconButton Input Modal Progress Select
│ │ ├── Skeleton SlideOver SplitBar StageDots StatCard TabBar Textarea
│ │ ├── Toggle WizardSteps
│ │ └── charts/ # AreaChart BarChart DeptBars (hand-rolled SVG)
│ ├── ws/ # WsSidebar, WsLayoutClient, WsAccountMenu, …
│ ├── user/ # BottomNav, CheckinButtons, EventCard, MeTopbar, …
│ ├── notifications/ marketing/ shared/
│ └── Hero.tsx Features.tsx FAQ.tsx … # marketing sections
├── lib/
│ ├── db/
│ │ ├── index.ts # THE db abstraction (SQLite ↔ libSQL)
│ │ └── queries/ # One file per domain - no raw SQL outside here
│ │ ├── users.ts events.ts workspaces.ts signals.ts stats.ts tokens.ts
│ │ ├── push.ts holidays.ts leaves.ts roles.ts notifications.ts
│ │ ├── employees.ts employees-list.ts documents.ts assets.ts
│ │ ├── maternity.ts regularizations.ts reminders.ts
│ ├── permissions/
│ │ ├── catalogue.ts # Resource / Action / Scope / SystemRole enums
│ │ ├── screens.ts # Screen registry - the org sidebar
│ │ ├── can.ts # can(), readableResources(), normalisePermissions()
│ │ ├── guards.ts # System-role, catalogue and escalation guards
│ │ ├── ranks.ts # canManage / canGrant / getRedirectAfterLogin
│ │ ├── system-roles.json # Seeded grids - read by the app AND migrate.js
│ │ └── system-roles.ts
│ ├── ws-access.ts # requireWsAccess() · getWsRole() · forbidden()
│ ├── ws-admin.ts # requireWsMember() only (requireWsAdmin is GONE)
│ ├── storage.ts # DocumentStore seam + magic-byte MIME sniffing
│ ├── reminders.ts # Wall-clock check-in/checkout reminder pass
│ ├── approvals.ts # Unified pending-approvals feed
│ ├── attendance-summary.ts # Day-level WFO / WFH / leave classification
│ ├── signals.ts # queryWorkspaceEvents() - signal matching
│ ├── encryption.ts # AES-256-GCM field encryption
│ ├── api/ # documents-upload.ts, notifications.ts
│ ├── client/ # device-info, format-time, presence helpers
│ ├── auth.ts email.ts geo.ts geo-label.ts plans.ts slug.ts password.ts
│ ├── stats.ts timezone.ts timezone-server.ts domain-verify.ts trust.ts
│ ├── midnight.ts parse.ts push.ts push-client.ts session.ts constants.ts
├── locales/
│ ├── en.ts # Barrel: composes the modules below + legacy inline copy
│ └── en/ # me · me-screens · me-settings · marketing
│ # documents · ws-overview · ws-people
│ # ws-settings · ws-reminders
└── proxy.ts # Edge route protection (Next 16 name for middleware)
public/sw.js # Service worker - push + notification actions
scripts/
├── migrate.js # The ONLY schema definition
└── sync-local-db.js # Turso → local SQLite snapshot
.github/workflows/push-reminders.yml # cron '0,30 * * * *' → POST /api/push/cron
docs/architecture/ # HLD + per-flow docs
There is no src/lib/db/schema.ts. It was deleted: it exported a SCHEMA_SQL constant
nothing imported, and having a second copy of the schema next to migrate.js is exactly the
kind of drift that ships broken databases.
Verify with sqlite3 checkmark.db ".tables".
Identity & auth
| Table | Purpose |
|---|---|
users |
Accounts - email, password hash, name, timezone, deleted_at, deactivated_at |
otp_codes |
6-digit OTPs - purpose, expiry, attempts |
revoked_tokens |
Invalidated JWT jtis, checked on every server-side request |
user_api_tokens |
Personal API tokens; token_prefix gives O(1) lookup |
rate_limit_log |
Sliding-window limiter (IP-keyed for login, user-keyed for checkin etc.) |
push_subscriptions |
Web Push endpoint + keys, per user per device |
notifications |
In-app notification feed, per user, optionally workspace-scoped |
Presence
| Table | Purpose |
|---|---|
presence_events |
The core table. Check-in and checkout GPS / IP / device / trust flags, location_label, scheduled_checkout_at, checkout_reason, push_reminders_sent. Never hard-deleted |
admin_overrides |
Additive audit log; may carry effective_checkout_at. Never mutates the event |
regularization_requests |
Member-raised "I was actually in the office / remote" corrections |
user_stats |
Pre-computed streak, totals, monthly counts - upserted after check-in |
reminder_log |
One row per (workspace, user, kind, local date) - the reminder dedupe |
Workspace
| Table | Purpose |
|---|---|
workspaces |
slug, name, plan, display_timezone, working_days, allow_remote, leaves_enabled, leave_cutover_date, notification_categories_off, archived_at (plus vestigial checkin_reminder_at / checkout_reminder_at) |
member_reminder_prefs |
per-member, per-workspace checkin_at / checkout_at - the reminder schedule; NULL = that kind off |
member_presence_prefs |
per-member session ladder: half_day_after_h, full_day_after_h, repeat_every_h, auto_checkout_after_h |
workspace_members |
User ↔ workspace membership, role (FK by key to workspace_roles), consent status + token |
workspace_roles |
Per-workspace roles. permissions is the JSON grid; scope; soft-deleted |
workspace_domains |
Email domains for auto-enrolment |
workspace_signal_config |
GPS / IP location configs used for presence matching |
workspace_holidays |
Holiday calendar. Soft-deleted; partial unique index on (workspace_id, name, date) for active rows |
workspace_assets |
Asset register - laptops, phones, access cards. Status assigned / available / repair / retired |
HR
| Table | Purpose |
|---|---|
employees |
Employee record - personal, contact, emergency contact, employment status |
employee_sensitive |
PAN, Aadhaar, bank account encrypted (AES-256-GCM); UAN, passport, IFSC in clear |
employment_details |
Designation, department, work mode, reporting manager, joining / exit dates |
employee_documents |
Document metadata only. One row per (employee, doc_key) slot. Soft-deleted |
employee_document_blobs |
Document bytes as base64 TEXT, one row per document. Hard-deleted |
announcement_attachments |
Policy-document metadata hung off an announcement. Soft-deleted |
announcement_attachment_blobs |
Those bytes as base64 TEXT. Hard-deleted with the metadata |
maternity_cases |
Parental leave cases, maternity and paternity (case_type) - requested → approved → onleave → returned |
Leave
| Table | Purpose |
|---|---|
workspace_leave_types |
Per-workspace leave types with accrual frequency + credits. Soft-deleted |
leave_requests |
Immutable once inserted |
leave_opening_balances |
Migrated-in starting balance per (workspace, user, leave type) |
parental_leave_extensions |
Member requests for unpaid extra days on an open parental case. Append-only apart from approve/reject |
scripts/migrate.js is the single migration file and must always produce the latest schema
on both a fresh and an existing database.
- Fresh DB -
CREATE TABLE IF NOT EXISTScreates everything. - Existing DB - additive
ALTER TABLE … ADD COLUMNstatements fill in what's missing; duplicate-column errors are caught and skipped.
Adding a column or table means editing migrate.js in the same change. It also reads
src/lib/permissions/system-roles.json so seeded role grids can never drift from the app's.
npm run migrate
sqlite3 checkmark.db ".tables"
sqlite3 checkmark.db ".schema employees"Route handlers call query functions. They never call db.query() / db.execute() directly,
and they never import better-sqlite3 or @libsql/client - only @/lib/db.
requireWsAdmin()no longer exists.src/lib/ws-admin.tsexports onlyrequireWsMember(), which authenticates an ordinary member for the/mesurface and carries no permission meaning. Re-adding a binary admin gate bypasses the whole model.
import { requireWsAccess, forbidden } from '@/lib/ws-access'
import { Action, Resource } from '@/lib/permissions/catalogue'
const ctx = await requireWsAccess(req, slug, Resource.Employees, Action.Write)
if (!ctx) return forbidden() // 403 { error: 'Forbidden', code: 'FORBIDDEN' }
// ctx: { workspace, userId, memberId, role, visibleMemberIds }
await updateEmployee(id, ctx.workspace.id, patch)requireWsAccess(request, slug, resource, action) returns AccessContext | null. It reads
x-user-id from the proxy-set header, resolves the slug to a workspace, loads the caller's
membership and its role row, and asks can(role.permissions, resource, action). Any
failure - no header, no workspace, inactive membership, missing permission - returns null.
getWsRole(workspaceId, userId) resolves the caller's role without asserting a permission,
for pages that need to know who is asking in order to decide what to render (the /ws/:slug
layout does this).
Permissions are a resource × action grid. Actions: read, write, delete. Each
resource declares which actions are meaningful for it - anything undeclared does not exist.
| Resource | Label | Actions |
|---|---|---|
dashboard |
Dashboard | read |
analytics |
Analytics & insights | read |
activity |
Activity | read |
export |
Export | read |
members |
Members | read, write, delete |
employees |
Employee records | read, write, delete |
assets |
Assets | read, write, delete |
documents |
Employee documents | read, write, delete |
holidays |
Holidays | read, write, delete |
leaves |
Leave | read, write, delete |
approvals |
Approvals | read, write |
signals |
Signal config | read, write, delete |
domains |
Domains | read, write, delete |
settings |
Workspace settings | read, write |
members.role |
Assign roles | write |
roles |
Roles | read, write, delete |
ownership |
Ownership | write, delete |
members.role is split out from members on purpose: inviting someone and changing
someone's role are different risk levels.
The org sidebar is generated, not hard-coded. Each screen names its path, its group, the
resource whose read gates it, and an optional workspace feature switch.
| Group | Screen | Path | Gated on | Feature switch |
|---|---|---|---|---|
| Workforce | Overview | `` (root) | dashboard |
— |
| Employees | /employees |
employees |
— | |
| Assets | /assets |
assets |
— | |
| Attendance | /attendance |
dashboard |
— | |
| Leave | /leaves |
leaves |
leaves_enabled |
|
| Holidays | /holidays |
holidays |
leaves_enabled |
|
| Approvals | /approvals |
approvals |
— | |
| Manage | People | /people |
members |
— |
| Analytics | /insights |
analytics |
— | |
| Activity | /monthly |
activity |
— | |
| Reports | /reports |
export |
— | |
| Roles | /roles |
roles |
— | |
| Settings | /settings |
settings |
— |
Employees and People are different screens. /employees is the HR directory of
employee records (Resource.Employees); /people is workspace membership - invites,
roles, consent (Resource.Members). Someone can hold one without the other.
Hiding a tab is a courtesy only. The matching API route enforces the same permission independently.
Labels live in en.wsNav.screens, icons live in WsSidebar.tsx. A screen added to the
registry without a label is a build error.
Seeded into every workspace from src/lib/permissions/system-roles.json, the one file
read by both the app (system-roles.ts) and scripts/migrate.js.
| Key | Scope | Grid |
|---|---|---|
owner |
all |
Everything, including ownership: [write, delete] |
admin |
all |
Everything except ownership - cannot transfer ownership or archive |
member |
self |
Empty grid - no org surface at all |
System roles are immutable: guardSystemRole() rejects any edit or delete. If an owner could
untick settings:write on the owner role they would lock every human out permanently.
The workspace creator is the owner, not an admin. createWorkspace() calls
seedSystemRoles() inside the same transaction as the workspace row. A workspace with no
rows in workspace_roles grants nobody anything, creator included - so never insert a
workspace by any other path.
can() answers "may this role touch this kind of thing?". Rank answers "may this person act
on that person?". Both are required.
owner 100 · admin 50 · any custom role 20 · member 10
canManage(actor, target)- equal rank is allowed (admins can manage each other), but the owner is never manageable by anyone. Ownership moves only through the OTP-gated transfer flow.canGrant(actor, granted)-ownercan never be granted here, so no permission tick on any grid can make someone the owner.getRedirectAfterLogin()keys on which workspaces grant org-surface access (hasAnyOrgAccess), not on holding the admin role - a custom role with an empty grid is legal and must land on/me.
validateGridForSave() runs, in order:
guardCatalogue()- reject resources/actions the catalogue doesn't declare, so a hand-crafted body fails loudly rather than being silently dropped.normalisePermissions()- write/delete implies read; unknown keys dropped; catalogue order preserved so stored grids stay diffable.guardEscalation()- you cannot grant a permission you do not hold yourself.
guardEscalation() runs on role create, role edit and role assignment
(PATCH /members/[memberId]/role). Rank alone is not a ceiling: every custom role shares
rank 20, so rank-only gating would let any custom role with members.role:write assign any
other custom role, however powerful.
Scope.Self means "no org surface at all", not "the org surface filtered to your own rows" -
only the seeded member role carries it. Every /ws role is Scope.All. /me/* is
self-only for every role, decided from the session user ID with no role lookup at all. The
roles builder therefore offers no scope choice, and routes set scope server-side rather than
accepting one from the client.
| Cookie | Purpose | Expiry |
|---|---|---|
cm_session |
JWT session (httpOnly) | 30 days |
cm_otp_ok |
Proof an OTP was verified | 15 min |
- Tokens - JWT via
jose,SameSite=Lax,securein production. Each carries a uniquejti(UUID). - Revocation - logout inserts the
jtiintorevoked_tokens.getSessionFromCookies()checks it on every server-component / route-handler request. Edge middleware does signature-only verification (no DB hit). - Passwords -
bcryptjscost 12, 8-char minimum enforced server-side on registration and on change. - OTP - 6 digits, 10-minute expiry, single-use, 5 attempts per code, max 3 sends per email per 15 minutes.
- OTP proof - after verify, a 15-minute signed
cm_otp_okhttpOnly cookie is set server-side.POST /api/auth/registervalidates that cookie; the client never gets to assertotpVerified: true.
Why
SameSite=Laxand notStrict:Strictcaused session loss on PWA cold-opens on iOS and Android, because the PWA→browser navigation is treated as cross-origin.Laxstill blocks cross-site POST mutations; only same-site GET navigations carry the cookie.
A single client state machine with nine states (type Step in
src/app/(public)/login/page.tsx):
| State | Description |
|---|---|
email |
Enter email - existence checked via /api/auth/check-email |
password |
Existing user - enter password |
otp |
New user (or reset flow) - enter the 6-digit code |
accountType |
OTP verified - choose Personal or Organisation |
personal |
Name + password + confirm |
org |
Org name, URL handle (live slug check), optional domain, name + password |
deactivated |
Soft-deleted account detected - enter password to reactivate |
forgotPassword |
Enter email to receive a reset code |
resetPassword |
Enter a new password (gated by cm_otp_ok) |
Without RESEND_API_KEY, OTPs print to the server console:
[DEV] OTP for user@example.com: 481923
Decided by getRedirectAfterLogin() over workspaces that grant org-surface access:
| Condition | Redirects to |
|---|---|
| Org access in 1 workspace | /ws/:slug |
| Org access in 2+ workspaces | /ws |
| No org access anywhere | /me |
Next.js 16 uses proxy.ts natively in place of middleware.ts. Matcher:
/me/:path*, /ws/:path*, /api/:path*.
| Path | Requirement |
|---|---|
/me/* |
Valid JWT → else redirect to /login |
/ws/* |
Valid JWT → else redirect to /login (permission checked per-route) |
/api/* |
Valid JWT → else 401; sets x-user-id / x-user-email |
/api/v1/* |
Skipped - Bearer token handled inside the route handler |
| Public API routes | Skipped entirely (list below) |
Public API routes: /api/auth/login, /api/auth/register, /api/auth/otp/send,
/api/auth/otp/verify, /api/auth/logout, /api/auth/check-email,
/api/auth/reset-password, /api/workspace/check-slug, /api/me/reactivate.
src/lib/signals.ts → queryWorkspaceEvents(workspaceId, plan, options) is the core query
behind every admin presence view.
There are two signal types: gps and ip. WiFi SSID matching was removed. The
wifi_ssid* columns still exist on workspace_signal_config and presence_events as dead
weight, and POST /api/ws/:slug/signals rejects anything that is not gps or ip.
- Apply the plan's history gate to the requested start date.
- Fetch active member IDs (capped by the plan's
maxUsers; a single-member query skips the cap so a member always sees their own rows). - Fetch events in range for those members.
- Load signal configs + the workspace's override event IDs.
- Config-light mode - no signals configured: every event comes back as
verified(oroverride), with an emptymatched_signals. - Matching mode - AND semantics across configured types: GPS matches when the event's
coordinates fall within a configured location's radius (Haversine, default 300 m); IP
matches on IP-geolocation proximity. Matching all configured types →
verified; some →partial; none →none. - Admin overrides bypass matching entirely →
override.
MatchedBy = 'verified' | 'partial' | 'none' | 'override'
| Badge | Colour | Meaning |
|---|---|---|
| ✓ Verified | Teal | verified - every configured signal matched |
| ~ Partial | Amber | partial - some configured signals matched |
| Unverified | Muted grey | none - no configured signal matched |
| Override | Purple | override - an admin override applies |
Presence stats are day-level, not event-level. Everything that shows WFO / WFH / Leave -
/me, attendance, monthly, reports, exports - goes through
src/lib/attendance-summary.ts, whose AttendanceDayStatus is
'office' | 'remote' | 'absent' | 'holiday' | 'future':
- office - at least one event that workspace-local day is
verifiedoroverride - remote - events exist that day, but none are verified/overridden
- absent - no event exists on a workday
- holiday - a
workspace_holidaysrow, or a non-working weekday perworking_days
Multiple events on one day count once, with office taking priority.
/ws/:slug/employees - the HR directory, gated on Resource.Employees. A multi-step wizard
creates a record spread across three tables: employees (personal + contact),
employment_details (designation, department, manager, dates), employee_sensitive
(PAN / Aadhaar / bank account, encrypted).
Records are soft-deleted (archived) and restorable via
POST /api/ws/:slug/employees/:id/restore. Members can self-edit a whitelist of their own
personal fields at PATCH /api/me/ws/:slug/employee; employment fields are admin-only.
Per-employee document folders, gated on Resource.Documents. Storage goes through the
DocumentStore seam in src/lib/storage.ts.
- Bytes live in the database as base64 TEXT (
employee_document_blobs). That keeps the whole product on a single Turso connection with no bucket, no signed URLs and no second failure domain. It is a deliberate trade for the current scale - swapping in an S3 implementation is one new class instorage.tsplus a config change, touching no route and no query file. - Metadata and bytes are separate tables.
employee_documentsis read on every folder view; the blob is read only on an actual download. A join would drag megabytes through every list query. - 2 MB cap, checked twice: against
File.sizebefore reading (so a huge upload is rejected without buffering) and against the real buffer length after. - Magic-byte sniffing, never the client MIME string.
sniffMimeType()accepts only%PDF, the PNG 8-byte signature, and the JPEGFF D8 FFprefix →application/pdf,image/png,image/jpeg. TrustingFile.typewould let an HTML or SVG payload be stored and served back under a benign Content-Type. Anything else is a415. - Both upload surfaces (
/api/ws/.../documentsand/api/me/ws/:slug/documents) share one parser,src/lib/api/documents-upload.ts, so the two can't drift. - One row per
(workspace, employee, doc_key)slot, enforced by a partial unique index. Re-uploading replaces the file on the existing row. - Metadata is soft-deleted; the blob is hard-deleted in the same transaction. A soft-deleted blob would be unreachable dead weight.
- Bytes are only ever emitted by the
/fileroutes, as a response body, never inside JSON. - Statuses:
missing·pending·verified·rejected·issued. Admin-uploaded files default toissued(nothing to verify); employee-uploaded files arependingand land in the approvals queue.
/ws/:slug/assets - a register of company property (workspace_assets), gated on
Resource.Assets. Categories, serial numbers, condition, purchase value; status is
assigned / available / repair / retired. Assignment is a separate endpoint
(POST|DELETE /api/ws/:slug/assets/:id/assign) targeting an employee_id. Soft-deleted.
GET /api/ws/:slug/assets/export emits CSV, with every field quoted and leading
= + - @ neutralised against CSV injection.
Admins configure per-workspace leave types with an accrual frequency (monthly /
quarterly) and credits. Balances are never stored - always computed:
periods_elapsed = complete calendar months (or quarter-groups) since the member joined
total_accrued = periods_elapsed × accrual_credits (+ any leave_opening_balances row)
used_days = Σ (end_date − start_date + 1) over approved requests of that type
available_days = max(0, total_accrued − used_days)
leave_requests rows are immutable once inserted - same principle as presence_events.
Opening balances (for workspaces migrating in mid-year) can be bulk-imported from CSV/XLSX
with columns email, leave_type, opening_balance.
The whole Leave and Holidays area is hidden when workspaces.leaves_enabled is off,
regardless of permission.
maternity_cases, surfaced as two tabs under /ws/:slug/leaves and discriminated by
case_type. One table, one stage machine, one reminder gate — only the entitlement differs
(weeks defaults to 26 for maternity, 2 for paternity). Filed under Resource.Leaves
rather than getting its own catalogue resource. A case carries a due date, start/end dates
and a weeks count, and moves through requested → approved → onleave → returned.
Soft-deleted, and editable and deletable from the tab. An active case of either type
suppresses check-in reminders for that person.
parental_leave_extensions lets a member request unpaid extra days on an open case.
The day count is computed server-side with countWorkdays() against the workspace's working
days and holiday calendar — never sent by the client — and recomputed at approval. Approving
moves the case's end_date; it writes no leave_requests row and consumes no balance.
/ws/:slug/approvals, gated on Resource.Approvals. src/lib/approvals.ts is the single
source for the pending feed, reused by the Overview widget and the Approvals page so both
always agree. Four kinds:
leave- pending leave requestsregularization- member-raised attendance correctionsdoc- employee-uploaded documents awaiting verificationextension- a request for unpaid extra days on an open parental-leave case
This is the only place a pending item is actioned. The same rows used to be approvable from the Attendance screen, a Requests tab on the Leaves screen and an ungated section on the People page; those are gone. Read-only listings elsewhere (the Applied-leaves tab) stay.
PATCH /api/ws/:slug/approvals/:kind/:id actions leave, regularization and extension
(anything else is a 404). Rejection requires a rejection_reason. Document verification is a
PATCH on the document itself.
/ws/:slug/settings → Ownership tab, visible only to roles holding ownership:write
(the resource has no read action, so write is what gates the tab). It holds archiving and
restoring the workspace, because they are gated on the same resource.
There is no payment integration in this codebase. CheckMark is free and open source, and nothing in the app prices a plan or asks anyone to upgrade.
Two independent passes run from POST /api/push/cron.
Both are push-only: neither writes a notifications row, so neither appears in the
in-app feed. Only the organisation's two notification categories (approvals,
announcements) do that. Both passes are also member-configured - the workspace sets
neither schedule.
Pass 1 - event-anchored (in the route). Iterates open presence_events and fires the
member's own session ladder, resolved by resolveLadder() in src/lib/presence-ladder.ts
from member_presence_prefs: a half-day rung, a full-day rung, and an overtime rung
repeating every N hours until the session closes. Every hour is the member's; a member with
no row gets no rungs at all, because the ladder is opt-in. Progress is recorded in
presence_events.push_reminders_sent under the keys half, full, ot-<n> and
autocheckedout.
Two guards keep it honest. A rung more than LADDER_WINDOW_H (1.5h) past its hour is
claimed without being sent - GitHub Actions cron is best-effort, and without this an
event left stale by an outage fired every rung plus the auto-checkout notice seconds apart.
And because the batch is read once and then looped, the route re-checks isEventOpen()
immediately before each push, so a member who checks out mid-loop is not buzzed.
Auto-checkout is a mechanic, not a nudge: it runs first, unconditionally, and cannot be
switched off - a session that never closes corrupts the day's attendance. Check-in schedules
it at the member's auto_checkout_after_h (default 12h, the previous hardcoded value, so
an unconfigured member is unaffected); POST /api/checkin/extend pushes it out +4h at a
time up to MAX_AUTO_CHECKOUT_H (24h) from check-in. autoCheckoutEvent() reports whether it
actually closed anything, so the "we closed your session" notice is only sent when a session
really closed.
Pass 2 - wall-clock (src/lib/reminders.ts). The first pass starts from open events, so
it is structurally incapable of noticing someone who never checked in. This pass anchors on
workspaces instead - one holidays, leave and parental query per workspace rather than per
member - but the times are per member: member_reminder_prefs.checkin_at /
checkout_at hold an HH:MM wall-clock time in the workspace's own timezone, and NULL
means that kind is off. There is deliberately no separate on/off column: the time is the
switch. workspaces.checkin_reminder_at / checkout_reminder_at still exist but are
vestigial - never read as the schedule again. Gates, in order:
- archived workspace → excluded by the query
- not a working day per
workspaces.working_days→ skip the workspace - a
workspace_holidaysdate → skip the workspace - no member's time falls within
REMINDER_GRACE_MIN(30 min) after it → return before any member query - member on approved leave or in an active parental-leave case → skip the member
- already reminded today → skip the member
Gate 6 is a reminder_log insert protected by a partial unique index, so the insert is the
check - two overlapping cron runs cannot both get past it. Each member gets at most one
reminder per kind per local day.
The workflow runs 0,30 * * * *, not hourly. India (UTC+5:30), Iran (+3:30) and parts of
Australia (+9:30 / +10:30) sit on half-hour offsets, so an hourly UTC schedule lands at :30
past their local hour and a 10:00 IST reminder could never fire on time. With a 30-minute
window against a 30-minute tick, every minute of the day is reachable exactly once and the
worst-case lateness is 29 minutes. The accepted cost is stated plainly: a skipped or badly
delayed run drops that person's reminder for the day rather than delivering it stale,
because a reminder that arrives hours late is a nag, and a nag is what makes somebody revoke
push permission entirely - which would also cost them the approval notifications that matter.
Every plan is unlimited: getPlanLimits() in src/lib/plans.ts returns one UNLIMITED
object (no member cap, no history window, no location cap, export on) for every
workspaces.plan value.
The enforcement points are kept as no-ops - the user cap and history window inside
queryWorkspaceEvents() before signal matching, and the export flag and history window
in GET /api/ws/:slug/export (402 PLAN_GATE / PLAN_HISTORY_GATE) - so an operator of a
hosted instance can reintroduce caps by giving a plan its own limits object, with no
migration.
Session required. One account can hold multiple active workspace memberships, each independent.
presence_events rows do not carry a workspace_id - verification is always computed
for a chosen workspace. That chosen workspace comes from one place on this whole surface:
the pill in MeTopbar.
One workspace selector. The top-bar pill is the single source of truth for the active
workspace. It is backed by the cm_ws cookie (en.constants.cookieWorkspace), written from
the browser by src/app/me/workspace-scope.tsx and read back on the server by
resolveActiveWorkspaceSlug() in src/app/me/active-workspace.ts. Resolution order is
?ws= → cookie → first active membership, and the server checks the value against the
memberships it just loaded before seeding the provider, so a stale or hand-edited cookie
falls back to a real one. The cookie is deliberately not httpOnly - it is a UI
preference, not a credential, and src/app/me/layout.tsx is a Server Component that must
read it to paint the pill on first render. No /me screen may add a second picker.
Bottom nav is three tabs: Timeline · Home · Leave. /me/orgs, /me/profile,
/me/settings and /me/notifications are reachable by URL and from the profile sheet in
MeTopbar, not as tabs.
| Route | What it is |
|---|---|
/me |
Check-in / checkout, today's events, monthly stat chips, live roster link |
/me/timeline |
Event history with date filters and inline note editing |
/me/leave |
Balances, apply for leave, own request history |
/me/documents |
Own document folder - upload, view status, download |
/me/workspace |
Today's roster for a selected workspace |
/me/ws/[slug] |
Workspace-scoped member home |
/me/orgs |
Pending consent invites (Accept / Decline) + active memberships (Leave) |
/me/profile |
Employee record self-service |
/me/settings |
Name, email change, password, API tokens, sign out, deactivate |
/me/notifications |
Notification feed. ?ws=<slug> = one workspace (the bell); bare = unified |
Check-in flow. navigator.geolocation.getCurrentPosition() fires on tap. If denied,
check-in still proceeds with null GPS and a toast explains why. The server writes the
presence_events row, kicks off a Nominatim reverse-geocode in the background (stored as
location_label - it may stay NULL, which is acceptable, not a bug), schedules auto-checkout
at the member's auto_checkout_after_h (default 12h), and updates user_stats. Rate limit: 10 check-ins per hour per user.
Checkout collects signals too - GPS, IP and device info are stored for both ends of an
event, in the checkout_* columns.
Timeline data source. /me/timeline is always scoped to the active workspace and always
calls GET /api/me/ws/[slug]/events, which runs queryWorkspaceEvents() for that workspace
and the current user only, so the transparency a member sees uses the same AND semantics as
the admin dashboards. It used to carry its own workspace dropdown with an All workspaces
option reading the unscoped GET /api/events; that selector is gone. The endpoint survives -
it is still the only global-history route - but nothing in the UI calls it.
Notes are the only editable field on an event. DELETE /api/events/:id returns
405 NOT_SUPPORTED - presence data is never deleted.
The layout resolves the caller's role, redirects to /me if hasAnyOrgAccess() is false
(an empty custom grid is legal), and serialises readableResources(role.permissions) to the
client so the sidebar can render without shipping the permission logic to the browser.
A non-existent slug is a 404.
/ws itself is always reachable by any authenticated user: active workspaces as cards,
archived ones greyed out but still openable, plus a create button. One active workspace per
creator - POST /api/workspace returns 403 WORKSPACE_LIMIT_REACHED beyond that, and
restoring an archived workspace is blocked while another is active.
| Tab | Gated on | Contents |
|---|---|---|
| Org | always (write to edit) | Name, timezone, working days, remote policy, reminder times |
| Leave | leaves:read |
Leave types and policies |
| Balances | leaves:read |
Opening balances + CSV/XLSX import |
| Signals | signals:read |
GPS locations (radius, timezone auto-detected) and IP configs |
| Domains | domains:read |
Add a domain, view its TXT record, run DNS verification |
| Ownership | ownership:write |
Archive / restore |
Admins add e.g. acme.com. CheckMark generates:
Name: _checkmark-verify.acme.com
Value: checkmark-verify=<token>
The token is deterministic - HMAC-SHA256("domain-verify:{workspaceId}:{domain}", JWT_SECRET),
first 32 hex chars - so no extra column is needed; it is recomputed on each verify request.
POST /api/ws/:slug/domain/:domainId/verify resolves the TXT record and marks the domain
verified. Domains already verified by another workspace are rejected with
409 DOMAIN_CLAIMED.
Auto-enrolment: a user whose email matches a verified domain is added as an active
member on /join/:slug without an explicit invite.
Email link:
- Admin invites
colleague@company.comfrom the People page. - Consent email goes out with Accept / Decline links to
/consent/:token. - Decline resolves the token and marks the member declined - no login needed.
- Accept while logged in calls
acceptConsentand redirects to/me. - Accept while logged out resolves the workspace slug and redirects to
/login?invite={slug}.
In-app - visiting /join/:slug: active → /me; pending_consent → Accept/Decline
buttons (POST /api/me/consent); verified domain match → auto-enrolled; otherwise "Invite
required".
Consent tokens are validated on three axes: status must be pending_consent, the token
must not be expired, and the logged-in email must match the invited email.
POST /api/ws/:slug/transfer-ownership, gated on ownership:write. The most destructive
action in a workspace - it hands over full control and demotes the caller to a plain
member, with no way back except through the new owner. Two factors, in order:
{ action: 'request', targetMemberId, password }- re-authenticate with the account password (rate-limited, keyed on the actor), then an OTP is emailed. The password gates issuance of the code, so a hijacked session with inbox access is not enough on its own.{ action: 'confirm', targetMemberId, code }- validate the OTP, then swap roles (owner → member, target → owner).
targetMemberId is the workspace_members.id record ID, not a user_id. Admins are valid
targets; the current owner and yourself are not.
All routes return JSON. Errors are always:
{ "error": "Human-readable message", "code": "MACHINE_READABLE_CODE" }Workspace-admin routes return 403 { "error": "Forbidden", "code": "FORBIDDEN" } from
forbidden() when requireWsAccess() returns null.
| Method | Route | Description |
|---|---|---|
| POST | /api/auth/check-email |
{ email } → { exists } |
| POST | /api/auth/login |
Email check, or password verify. IP rate-limited: 10 attempts / 15 min |
| POST | /api/auth/otp/send |
Send a 6-digit OTP. Max 3 per email / 15 min |
| POST | /api/auth/otp/verify |
Verify OTP → sets cm_otp_ok |
| POST | /api/auth/register |
Create account (personal or org). Requires cm_otp_ok |
| POST | /api/auth/reset-password |
Reset password. Requires cm_otp_ok |
| POST | /api/auth/logout |
Revoke jti, clear cookie |
// POST /api/auth/register (org)
{
"email": "jane@acme.com", "fullName": "Jane Doe", "password": "…",
"accountType": "org", "orgName": "Acme Corp", "orgSlug": "acme-corp",
"orgDomain": "acme.com"
}| Method | Route | Description |
|---|---|---|
| POST | /api/checkin |
Create a presence event. 10 / hour / user. Schedules auto-checkout at the member's auto_checkout_after_h (default 12h) |
| POST | /api/checkin/checkout |
Close the open event; stores checkout GPS / IP / label |
| POST | /api/checkin/extend |
Push scheduled_checkout_at out 4h, capped at 24h from check-in |
| GET | /api/checkin/status |
{ state: 'checked_in' | 'checked_out', activeEvent } |
| GET | /api/events |
Own events, paginated + date-filtered. Never accepts a userId |
| PATCH | /api/events/[id] |
Edit the note - the only mutable field |
| DELETE | /api/events/[id] |
Always 405 NOT_SUPPORTED |
| Method | Route | Description |
|---|---|---|
| GET/PATCH/DELETE | /api/me |
Profile + active workspaces · rename · deactivate |
| POST | /api/me/password |
Change password (verifies the current one) |
| POST | /api/me/email |
2-step change: {newEmail} → OTP → {newEmail, code} |
| PATCH | /api/me/timezone |
Report/confirm the browser IANA timezone |
| POST | /api/me/reactivate |
Reactivate a deactivated account (public route) |
| POST | /api/me/consent |
Accept / decline a workspace invite |
| DELETE | /api/me/workspaces/[workspaceId] |
Leave a workspace |
| GET/PATCH | /api/me/presence-prefs |
The member's session ladder (account-level, no workspace) |
| GET | /api/me/notifications |
Notification feed |
| PATCH | /api/me/notifications/read |
Mark read |
| GET | /api/me/notifications/unread-count |
Badge count |
All guarded by requireWsMember() and scoped to the session user. No permission check -
/me is self-only for every role.
| Method | Route | Description |
|---|---|---|
| GET/PATCH | /api/me/ws/[slug]/reminder-times |
The member's check-in / check-out reminder times for this workspace |
| GET | /api/me/ws/[slug]/today |
Today's roster + { id, name, slug } + viewerRole |
| GET | /api/me/ws/[slug]/counts |
{ present, visited, notIn, total } |
| GET | /api/me/ws/[slug]/events |
Own events in range with workspace matched_by |
| GET | /api/me/ws/[slug]/holidays |
Holiday list (?year=, defaults to current year) |
| GET | /api/me/ws/[slug]/leave-types |
Leave types with the caller's available_days |
| POST | /api/me/ws/[slug]/leave |
Submit leave. 400 INSUFFICIENT_BALANCE if over |
| GET | /api/me/ws/[slug]/leave-requests |
Own leave history |
| GET | /api/me/ws/[slug]/leave-requests/today |
Who is on leave today |
| GET/POST | /api/me/ws/[slug]/regularizations |
Own corrections. POST rate-limited 10 / hour |
| GET/PATCH | /api/me/ws/[slug]/employee |
Own employee record; self-editable fields only |
| GET/POST | /api/me/ws/[slug]/documents |
Own folder · multipart upload (2 MB, sniffed MIME) |
| GET | /api/me/ws/[slug]/documents/[docId]/file |
Download own document bytes |
| GET | /api/me/ws/[slug]/notifications |
Feed + unread_count for this workspace only |
| PATCH | /api/me/ws/[slug]/notifications/read |
Mark read in this workspace. Body { ids? }, all if omitted |
| GET | /api/me/ws/[slug]/notifications/unread-count |
{ count } - the /me bell's 30s poll target |
| Method | Route | Auth | Description |
|---|---|---|---|
| GET | /api/workspace |
Session | Workspaces where the caller has org access |
| POST | /api/workspace |
Session | Create one. 403 WORKSPACE_LIMIT_REACHED past 1 |
| POST | /api/workspace/check-slug |
None | { slug } → { available } |
Every row is gated by requireWsAccess(req, slug, Resource, Action).
| Method | Route | Resource : Action |
|---|---|---|
| GET | /api/ws/[slug] |
settings:read |
| PATCH | /api/ws/[slug] |
settings:write — name, displayTimezone, allowRemote, leavesEnabled, workingDays (0–6), leaveCutoverDate, checkinReminderAt / checkoutReminderAt (HH:MM; null or "" turns it off; anything else is a 400) |
| GET | /api/ws/[slug]/dashboard |
dashboard:read — today's roster, grouped and filterable |
| GET | /api/ws/[slug]/realtime |
dashboard:read — live presence strip |
| GET | /api/ws/[slug]/overview |
dashboard:read — Overview page payload |
| GET | /api/ws/[slug]/analytics |
analytics:read — per-member date-range analytics |
| GET | /api/ws/[slug]/insights |
analytics:read — time-bucketed check-in data |
| GET | /api/ws/[slug]/monthly |
activity:read — ?year=&month=, per-day status per member |
| GET | /api/ws/[slug]/export |
export:read — XLSX attendance workbook; 403 when the plan has no export |
| GET | /api/ws/[slug]/approvals |
approvals:read — unified pending feed |
| PATCH | /api/ws/[slug]/approvals/[kind]/[id] |
approvals:write — kind ∈ leave | regularization; body { action: 'approve'|'reject', rejection_reason? } |
| GET | /api/ws/[slug]/members |
members:read — all statuses; limit/offset, nextOffset |
| POST | /api/ws/[slug]/members |
members:write — invite; 409 ALREADY_MEMBER |
| DELETE | /api/ws/[slug]/members/[memberId] |
members:delete — plus canManage() rank check |
| GET | /api/ws/[slug]/members/[memberId]/timeline |
members:read — page size 20, pagination.nextOffset |
| PATCH | /api/ws/[slug]/members/[memberId]/role |
members.role:write — plus canManage, canGrant and guardEscalation |
| GET/POST/PATCH | /api/ws/[slug]/members/[memberId]/employee |
employees:read / employees:write |
| GET/PUT | /api/ws/[slug]/members/[memberId]/leave-balances |
leaves:read / leaves:write |
| GET | /api/ws/[slug]/employees |
employees:read |
| POST | /api/ws/[slug]/employees |
employees:write |
| GET | /api/ws/[slug]/employees/[id] |
employees:read, or requireWsMember for one's own record |
| PATCH | /api/ws/[slug]/employees/[id] |
employees:write |
| DELETE | /api/ws/[slug]/employees/[id] |
employees:delete — archive |
| POST | /api/ws/[slug]/employees/[id]/restore |
employees:write |
| GET | /api/ws/[slug]/employees/[id]/documents |
documents:read — metadata only |
| POST | /api/ws/[slug]/employees/[id]/documents |
documents:write — multipart file, doc_key, name?, owner? |
| PATCH | /api/ws/[slug]/employees/[id]/documents/[docId] |
documents:write — { status: 'verified'|'rejected', reject_reason? } and/or { name } |
| DELETE | /api/ws/[slug]/employees/[id]/documents/[docId] |
documents:delete — soft-delete row, hard-delete blob |
| GET | /api/ws/[slug]/employees/[id]/documents/[docId]/file |
documents:read — the only route that emits bytes |
| GET | /api/ws/[slug]/assets |
assets:read — ?category=, ?status= |
| POST | /api/ws/[slug]/assets |
assets:write |
| PATCH | /api/ws/[slug]/assets/[id] |
assets:write |
| DELETE | /api/ws/[slug]/assets/[id] |
assets:delete |
| POST | /api/ws/[slug]/assets/[id]/assign |
assets:write — { employee_id } |
| DELETE | /api/ws/[slug]/assets/[id]/assign |
assets:write — return to the pool |
| GET | /api/ws/[slug]/assets/export |
assets:read — CSV |
| GET | /api/ws/[slug]/holidays |
holidays:read — ?year= |
| POST | /api/ws/[slug]/holidays |
holidays:write — JSON creates one; multipart file bulk-imports CSV/XLSX (≤ 2 MB) |
| PATCH | /api/ws/[slug]/holidays/[id] |
holidays:write |
| DELETE | /api/ws/[slug]/holidays/[id] |
holidays:delete — soft |
| GET | /api/ws/[slug]/leaves |
leaves:read |
| PATCH | /api/ws/[slug]/leaves/[id] |
leaves:write |
| GET | /api/ws/[slug]/leave-types |
leaves:read |
| POST | /api/ws/[slug]/leave-types |
leaves:write — { name, accrual_frequency, accrual_credits } |
| DELETE | /api/ws/[slug]/leave-types/[id] |
leaves:delete — soft; existing requests unaffected |
| GET | /api/ws/[slug]/leave-balances |
leaves:read |
| POST | /api/ws/[slug]/leave-balances/import |
leaves:write — multipart CSV/XLSX: email, leave_type, opening_balance |
| GET | /api/ws/[slug]/maternity |
leaves:read |
| POST | /api/ws/[slug]/maternity |
leaves:write |
| PATCH | /api/ws/[slug]/maternity/[id] |
leaves:write — edit dates/notes and/or advance one stage |
| DELETE | /api/ws/[slug]/maternity/[id] |
leaves:delete |
| GET | /api/ws/[slug]/roles |
roles:read — every role with its grid, plus the catalogue |
| POST | /api/ws/[slug]/roles |
roles:write |
| PUT | /api/ws/[slug]/roles/[id] |
roles:write — replaces the whole grid atomically |
| DELETE | /api/ws/[slug]/roles/[id] |
roles:delete |
| GET | /api/ws/[slug]/signals |
signals:read |
| POST | /api/ws/[slug]/signals |
signals:write — signal_type must be gps or ip |
| DELETE | /api/ws/[slug]/signals/[signalId] |
signals:delete |
| GET | /api/ws/[slug]/domain |
domains:read — includes the TXT token while unverified |
| POST | /api/ws/[slug]/domain |
domains:write |
| DELETE | /api/ws/[slug]/domain/[domainId] |
domains:delete |
| POST | /api/ws/[slug]/domain/[domainId]/verify |
domains:write — resolve DNS TXT |
| POST | /api/ws/[slug]/archive |
ownership:write — soft-archive |
| POST | /api/ws/[slug]/restore |
ownership:write — blocked if another workspace is active |
| POST | /api/ws/[slug]/transfer-ownership |
ownership:write — password + OTP, two steps |
| GET | /api/ws/[slug]/notifications |
requireWsMember |
| PATCH | /api/ws/[slug]/notifications/read |
requireWsMember |
| GET | /api/ws/[slug]/notifications/unread-count |
requireWsMember |
| Method | Route | Auth | Description |
|---|---|---|---|
| GET | /api/tokens |
Session | List tokens (never the secret) |
| POST | /api/tokens |
Session | Create; the plain token is returned exactly once |
| DELETE | /api/tokens/[id] |
Session | Revoke |
| POST | /api/v1/checkin |
Bearer | Programmatic check-in. Looks the token up by its 8-char token_prefix (O(1)), then bcrypt-compares; the user must still be active |
| POST | /api/push/subscribe |
Session | Register a Web Push subscription |
| DELETE | /api/push/subscribe |
Session | Remove one |
| GET | /api/push/vapid-public-key |
Session | VAPID public key for the client |
| POST | /api/push/cron |
Authorization: Bearer $CRON_SECRET |
Both reminder passes + auto-checkout. 401 if CRON_SECRET is unset |
CSS variables in src/app/globals.css:
| Variable | Value | Use |
|---|---|---|
--brand |
#1B4DFF |
Primary buttons, links, focus |
--brand-hover |
#1640E0 |
Hover / pressed |
--navy |
#0D1B2A |
Headings, dark text |
--teal |
#00D4AA |
Verified / present |
--amber |
#F59E0B |
Partial match, warnings |
--danger |
#EF4444 |
Errors, destructive |
--info |
#8B5CF6 |
Informational |
--surface-0 |
#FFFFFF |
Card backgrounds |
--surface-1 |
#F8FAFC |
Page backgrounds |
--surface-2 |
#F1F5F9 |
Input backgrounds |
--border |
#E2E8F0 |
All borders |
--header-bg |
#0D1B2A |
App header / PWA theme colour |
--radius-sm/md/lg/xl |
6 / 10 / 16 / 22 px |
Corner radii |
--shadow-sm/md |
subtle | Overlays only - never inline cards |
--ease-out/inout/spring/drawer |
cubic-beziers | Motion |
Fonts (Google Fonts, imported at the top of globals.css):
- Headings - Syne (400–800)
- Body - DM Sans (300–800)
- Code / timestamps - JetBrains Mono (400–700)
Rules:
- Borders, not drop shadows, on inline surfaces. Elevation is reserved for overlays.
- No gradients - flat fills.
- Minimum touch target 44px.
- Skeleton loaders for async states, never spinners.
- Reach for
@/components/uibefore writing an inlinestyleobject or re-implementing a modal, chip or table.globals.cssowns how those primitives look; the components own behaviour and accessibility. - Marketing pages under
src/app/(public)keep their own Tailwind-utility styling for layout, but still use the UI layer for controls -/loginis built onField/Input/Button/Card.
All user-visible English lives in src/locales/en.ts, which composes per-area modules from
src/locales/en/: me, me-screens, me-settings, marketing, documents (also exports
assets and maternity), ws-overview, ws-people, ws-settings, ws-reminders.
New copy belongs in a module. Both en.me.x and a direct
import { me } from '@/locales/en/me' resolve to the same object. en.constants holds
technical identifiers (cookie names, DNS prefixes, the DB filename).
Installable on mobile and desktop, with two manifests so the user app and the admin app can be installed side by side:
src/app/manifest.ts→/manifest.webmanifestpublic/manifest-me.json(user),public/manifest-ws.json(org, linked from the/ws/[slug]layout withtheme-color: #0D1B2A)
public/sw.js handles push display and notification click actions
(Extend 4h · Checkout Now). <meta name="apple-mobile-web-app-capable"> is set through
appleWebApp metadata so iOS runs it full-screen from the home screen.
One-time VAPID setup:
npx web-push generate-vapid-keysStatic Server Components sharing MarketingNav and MarketingFooter.
| Page | Route | Contents |
|---|---|---|
| Landing | / |
Hero, how it works, features, industries, comparison, for-who, FAQ |
| For Teams | /for-teams |
Team types, multi-signal verification, setup walkthrough, CTA |
| For You | /for-you |
Feature cards, "works with any employer", plain-language privacy |
| Pricing | /pricing |
Free and open source: self-host or hosted, FAQ, contact |
| Open Source | /open-source |
What's open, what's hosted, self-host guide |
| Privacy | /privacy |
Data table, visibility, retention, consent model, rights, security |
| Terms | /terms |
Acceptable use, admin responsibilities, signal-accuracy disclaimer, liability |
- Metadata:
src/app/layout.tsx; crawler rules:src/app/robots.ts; sitemap:src/app/sitemap.ts. - Indexable:
/,/for-teams,/for-you,/pricing,/open-source,/privacy,/terms. - Not indexable:
/login,/join/*,/consent/*,/me/*,/ws/*,/api/*. - Set
NEXT_PUBLIC_APP_URL=https://checkmark.kabirinnovations.comin production or every canonical link, OG URL, robots rule and sitemap entry points at localhost.
| Concern | Mechanism |
|---|---|
| Session auth | JWT HS256, 30-day expiry, unique jti, httpOnly; SameSite=Lax, secure in production |
| Logout invalidation | jti → revoked_tokens; checked by getSessionFromCookies() on every server-component and route-handler request |
| CSRF | SameSite=Lax blocks cross-site POSTs. Strict is not used - it broke PWA cold-open sessions on iOS/Android |
| User ID never from client | Always x-user-id, set by proxy.ts from the verified JWT. Never read from a body or query param |
| Workspace scoping | requireWsAccess() resolves slug → verified workspace.id; every query carries AND workspace_id = ?. markDomainVerified, deleteSignalConfig, document and asset queries all scope in the WHERE clause |
| Privilege escalation | guardEscalation() on role create, role edit and role assignment - you cannot grant what you do not hold. guardSystemRole() makes the three seeded roles immutable |
| Rank abuse | canManage() / canGrant() on top of permissions. The owner is unmanageable; owner can never be granted through the roles UI |
| Ownership transfer | Password re-auth (rate-limited) gates OTP issuance; the OTP gates the swap. Two factors, not one |
| Password storage | bcrypt cost 12, 8-char minimum enforced server-side on both registration and change |
| Sensitive HR fields | PAN, Aadhaar, bank account AES-256-GCM encrypted at the field level (iv:authTag:ciphertext). Audit logs never carry decrypted values |
| Document uploads | 2 MB cap checked before and after buffering; MIME decided by magic bytes, never File.type; doc_key matched against ^[a-z0-9_]{1,64}$; bytes served only as a response body |
| CSV injection | Every asset-export field is quoted and a leading = + - @ is neutralised |
| OTP brute force | 5 attempts per code; max 3 sends per email per 15 minutes |
| Rate limiting | rate_limit_log: login 10 / 15 min per IP; check-in 10 / hour per user; regularization 10 / hour per user; ownership password attempts per actor |
| API token lookup | token_prefix (first 8 chars) gives an O(1) indexed lookup before the bcrypt compare - never skipped |
| Consent token hijacking | Three checks: status pending_consent, token not expired, logged-in email matches the invited email |
| Domain uniqueness | A domain verified by another workspace is rejected with 409 DOMAIN_CLAIMED |
| Event ownership | getEventByIdForUser(eventId, userId) enforces user_id = ? in SQL, so no caller can skip it |
| Reserved slugs | Blocked at validateSlug(), shared by check-slug, register and workspace creation |
| Immutability | presence_events and leave_requests are never deleted or rewritten. Admin corrections live in admin_overrides; notes are the only editable event field |
| Soft delete | users.deleted_at, workspaces.archived_at, employees.deleted_at, roles, holidays, leave types, assets, document metadata. Active queries filter deleted_at IS NULL |
| Cron endpoint | POST /api/push/cron requires Authorization: Bearer $CRON_SECRET; an unset secret returns 401 to everyone |
- Never call
db.query()/db.execute()outsidelib/db/queries/ - Never import
better-sqlite3or@libsql/clientoutsidelib/db/index.ts - Never re-introduce
requireWsAdmin()or any binary admin gate on/wsroutes - Never accept
userId,workspaceIdor a permission scope from a request body - Never trust
otpVerified: trueor a client-suppliedFile.type - Never delete
presence_eventsorleave_requestsrows - Never store a raw WiFi SSID (the feature is gone; the columns are dead)
- Never let base64 document bytes escape
storage.tsanddb/queries/documents.ts - Never write a second copy of the seeded role grids -
system-roles.jsonis the only one - Never add a drop shadow to an inline card, or a gradient anywhere
- Never use a spinner where a skeleton will do
- Never put Turso credentials in
.env.local
CLAUDE.md/AGENTS.md- working agreements and invariants for agentsdocs/architecture/- HLD plus per-flow docs (auth, check-in, signal matching, workspace, notifications)docs/design/- tokens, typography, primitives, motion, shells, status chips, accessibilityCONTRIBUTING.md- contribution guide