Architecture - digitalunconciousness/shiftledger GitHub Wiki
An overview of ShiftLedger's internals for developers and contributors.
- Runtime: Node.js 20+
- Framework: Express 4
-
Database: SQLite via
better-sqlite3(synchronous, fast, single-file) - Validation: Zod — runtime schema validation for all API inputs
-
Logging: Pino — structured JSON logging (with
pino-prettyfor development) - PDF Generation: PDFKit — server-side PDF rendering
-
Auth: Node.js
cryptomodule — scrypt hashing, HMAC-SHA256 cookie signing - Frontend: Vanilla JavaScript single-page app (no build step)
- Charts: Chart.js 4 (loaded from CDN)
- Fonts: IBM Plex Mono + Syne (Google Fonts CDN)
- Mobile: React Native with Expo 50, Zustand for state, Axios for HTTP
shiftledger/
├── server.js # Express server — all backend logic (~2,600 lines)
├── package.json # Backend dependencies and metadata
├── install.sh # Automated installer script (Debian/Proxmox LXC)
├── backup.sh # SQLite backup script
├── public/
│ ├── index.html # Entire frontend SPA (~1,850 lines)
│ ├── manifest.json # PWA manifest
│ ├── sw.js # Service worker
│ ├── icon-192.svg # App icon (192×192)
│ └── icon-512.svg # App icon (512×512)
├── mobile/ # React Native companion app
│ ├── App.js # Root navigation (auth stack / app stack)
│ ├── package.json # Mobile dependencies
│ ├── eas.json # Expo Application Services config
│ └── app/
│ ├── api/
│ │ └── client.js # Axios client with Bearer token interceptor
│ ├── screens/
│ │ ├── LoginScreen.js
│ │ ├── SignupScreen.js
│ │ ├── HomeScreen.js
│ │ ├── AddShiftScreen.js
│ │ └── EditShiftScreen.js
│ └── store/
│ ├── authStore.js # Zustand: auth state, token persistence
│ └── shiftStore.js # Zustand: shift CRUD operations
├── shifts.db # SQLite database (created at runtime)
├── README.md # Project documentation
└── .gitignore
There is no build step, transpilation, or bundling. The backend is a single server.js; the frontend is a single index.html.
ShiftLedger uses SQLite with WAL mode and foreign keys enabled. The schema is managed through a versioned migration system (currently at v17).
meta — Tracks schema version and runtime settings
-
keyTEXT PRIMARY KEY -
valueTEXT
users — User accounts
-
idINTEGER PRIMARY KEY -
usernameTEXT UNIQUE -
display_nameTEXT -
password_hashTEXT — format:salt:derivedKey(scrypt) -
is_adminINTEGER (0 or 1) -
colorTEXT — hex color for UI identification -
created_atTEXT
sessions — Active login sessions
-
idINTEGER PRIMARY KEY -
user_idINTEGER →users(id)ON DELETE CASCADE -
token_hashTEXT UNIQUE — SHA-256 hash of the session token -
expires_atTEXT -
created_atTEXT
shifts — Shift earnings records
-
idINTEGER PRIMARY KEY -
dateTEXT —YYYY-MM-DD -
hourly_rateREAL -
hours_workedREAL -
tip_modeTEXT —'total'or'per_hour' -
tip_inputREAL — raw user input -
total_tipsREAL — computed:tip_input × hours(if per_hour) ortip_input -
wage_totalREAL — computed:hourly_rate × hours_worked -
grand_totalREAL — computed:wage_total + total_tips -
notesTEXT -
job_idINTEGER →jobs(id)(nullable) -
user_idINTEGER →users(id)(nullable) -
deleted_atTEXT — null = active, timestamp = soft-deleted -
created_atTEXT
jobs — Employers / work locations
-
idINTEGER PRIMARY KEY -
nameTEXT -
default_rateREAL -
colorTEXT -
archivedINTEGER (0 or 1) -
overtime_thresholdREAL (default 40) -
overtime_multiplierREAL (default 1.5) -
tip_paymentTEXT —'cash'or'paycheck' -
employer_idINTEGER →employers(id)(nullable) -
tip_calc_roundINTEGER (0 or 1) — per-job tip calculator rounding preference -
user_idINTEGER →users(id)— owner (nullable for legacy rows) -
created_atTEXT
templates — Reusable shift configurations
-
idINTEGER PRIMARY KEY -
nameTEXT -
job_idINTEGER →jobs(id)(nullable) -
hourly_rateREAL -
hours_workedREAL -
tip_modeTEXT -
tip_inputREAL -
notesTEXT -
user_idINTEGER →users(id)— owner -
created_atTEXT
goals — Income targets
-
idINTEGER PRIMARY KEY -
periodTEXT —'weekly'or'monthly' -
target_amountREAL -
activeINTEGER (0 or 1) -
user_idINTEGER →users(id)— owner -
created_atTEXT
tax_config — Tax and deduction items per user
-
idINTEGER PRIMARY KEY -
keyTEXT — unique slug per user (e.g.federal,state,tip_tax) -
labelTEXT -
rateREAL — 0–1 decimal fraction -
flat_amountREAL — optional fixed per-period deduction -
enabledINTEGER (0 or 1) -
sort_orderINTEGER -
user_idINTEGER →users(id) - UNIQUE constraint on
(key, user_id)
paychecks — Actual paycheck records
-
idINTEGER PRIMARY KEY -
user_idINTEGER →users(id) -
period_startTEXT —YYYY-MM-DD -
period_endTEXT —YYYY-MM-DD -
gross_amountREAL -
net_amountREAL -
notesTEXT -
created_atTEXT
employers — Employer records used for tax handling and job/fixed-income grouping
-
idINTEGER PRIMARY KEY -
user_idINTEGER →users(id)ON DELETE CASCADE -
nameTEXT -
no_taxINTEGER (0 or 1) -
archivedINTEGER (0 or 1) -
created_atTEXT
fixed_incomes — Recurring non-shift income streams
-
idINTEGER PRIMARY KEY -
user_idINTEGER →users(id)ON DELETE CASCADE -
employer_idINTEGER →employers(id)(nullable) -
amountREAL -
recurrenceTEXT —'weekly','biweekly','semimonthly','monthly', or'custom' -
anchor_dateTEXT -
semimonthly_day1INTEGER (nullable) -
semimonthly_day2INTEGER (nullable) -
custom_interval_daysINTEGER (nullable) -
custom_datesTEXT -
notesTEXT -
archivedINTEGER (0 or 1) -
created_atTEXT -
updated_atTEXT
households — Named groups of users for shared data access
-
idINTEGER PRIMARY KEY -
nameTEXT -
invite_codeTEXT UNIQUE — 8-character alphanumeric code for joining -
created_byINTEGER →users(id) -
created_atTEXT
household_members — Membership mapping (many-to-many)
-
idINTEGER PRIMARY KEY -
household_idINTEGER →households(id)ON DELETE CASCADE -
user_idINTEGER →users(id) -
roleTEXT —'admin'or'member' -
joined_atTEXT - UNIQUE constraint on
(household_id, user_id)
household_invitations — Pending invitations to join a household
-
idINTEGER PRIMARY KEY -
household_idINTEGER →households(id)ON DELETE CASCADE -
inviter_idINTEGER →users(id) -
invitee_idINTEGER →users(id) -
statusTEXT —'pending','accepted', or'declined' -
created_atTEXT
audit_log — Log of sensitive operations
-
idINTEGER PRIMARY KEY -
user_idINTEGER →users(id)— actor -
actionTEXT — e.g.login,register,change_password,delete_user -
target_idINTEGER — the affected resource ID (nullable) -
metaTEXT — JSON string with additional context -
created_atTEXT
password_history — Previous password hashes to prevent reuse
-
idINTEGER PRIMARY KEY -
user_idINTEGER →users(id)ON DELETE CASCADE -
password_hashTEXT -
created_atTEXT
Migrations are defined as an array of functions in server.js. Each function runs the SQL needed to upgrade from the previous version.
| Version | Changes |
|---|---|
| v1 |
shifts, users, sessions tables; user_id, deleted_at, job_id columns on shifts |
| v2 |
jobs table |
| v3 |
templates table |
| v4 |
goals table |
| v5 |
tax_config table; pay period default meta values |
| v6 |
tip_payment column on jobs
|
| v7 |
paychecks table |
| v8 |
user_id column on jobs, templates, goals, and tax_config (user-scoped data) |
| v9 |
households and household_members tables; invite_code on households |
| v10 | Fix tax_config UNIQUE constraint to (key, user_id) for per-user rows |
| v11 | Repair missing user_id columns on older databases that skipped v8 |
| v12 |
audit_log and password_history tables |
| v13 | Assign legacy NULL user_id rows to the first admin user |
| v14 |
household_invitations table; role column on household_members
|
| v15 |
tip_calc_round column on jobs
|
| v16 |
employers table; employer_id column on jobs
|
| v17 |
fixed_incomes table |
- On startup, read
db_versionfrom themetatable (default: 0) - Run all migration functions from the current version to the latest
- Each migration increments
db_version - All migrations run inside a single transaction — if any fails, everything rolls back
-
CREATE TABLE IF NOT EXISTSprevents conflicts on re-runs - Column additions check
PRAGMA table_info()before runningALTER TABLE - Migrations are idempotent by design
- Generate 16-byte random salt
- Derive 64-byte key using
crypto.scrypt(password, salt, 64) - Store as
salt:derivedKey(both hex-encoded)
Password history is checked on change to prevent reuse of recent passwords.
- On login, generate 32-byte random token
- Hash token with SHA-256 → store hash in
sessionstable - Sign the raw token with HMAC-SHA256 using
SESSION_SECRET - Set signed token as
sl_sessioncookie (HttpOnly, SameSite=Lax, 30-day Max-Age)
-
POST /api/auth/loginor/api/auth/signup— returns{ token, user }in the JSON body - Client stores the token and sends it as
Authorization: Bearer <token>on subsequent requests -
POST /api/auth/refreshrotates the token (issues a new one, invalidates the old one) -
authMiddlewareaccepts either a validsl_sessioncookie or a validAuthorization: Bearertoken
- Parse
sl_sessioncookie orAuthorization: Bearerheader - Verify HMAC signature against
SESSION_SECRET - SHA-256 hash the token
- Look up hash in
sessionstable, joined withusers - Check
expires_at > now() - Attach user object to
req.user
- Raw tokens never stored server-side (only SHA-256 hashes)
- Timing-safe comparison for both password verification and cookie/token verification
- Session tokens are cryptographically random (256-bit)
- Cookies are HttpOnly (no JavaScript access) and SameSite=Lax (CSRF protection)
When a user belongs to one or more households, data queries are expanded to include the combined user IDs of all household members. This allows users to see each other's shifts on the shared dashboard.
The resolveUserFilter() helper returns the set of visible user IDs for a given request. Queries use appendUserFilter() to inject the correct WHERE user_id IN (...) clause based on household membership.
Admins can always filter to any user. Non-admins can view combined household data or filter to just their own.
| Limiter | Window | Max Requests |
|---|---|---|
| Auth (login/logout/setup) | 15 min | 20 per IP |
| Profile endpoints | 15 min | 60 per IP |
| Household mutations | 15 min | 30 per IP |
| Password change | 15 min | 10 per IP |
| General API | 1 min | 120 per IP |
Client Request
│
├── express.json() middleware (parse body, 100 KB limit)
├── express.static() (serve public/ files)
├── Security headers middleware (CSP, X-Frame-Options, etc.)
├── Request logger (Pino — logs method, URL, status, duration)
│
├── Route handler
│ ├── Rate limiter (per-endpoint)
│ ├── authMiddleware (verify session cookie or Bearer token, attach req.user)
│ ├── adminOnly (check req.user.is_admin) [if needed]
│ ├── validate(schema) (Zod validation, attach req.validated) [if needed]
│ └── Handler logic (DB queries, response)
│
└── Response
The frontend is a single HTML file containing inline CSS and JavaScript. No build tools, no framework.
Five views managed by a showView(n) function that toggles visibility:
- Log — shift entry form
- Dashboard — analytics and charts
- History — shift table with CRUD
- Reports — export/import UI
- Settings — profile, user management, tax config, jobs, pay period
- No formal state library — DOM is the source of truth
- API calls use
fetch()with credentials - On view switch, data is re-fetched from the API
- Theme preference stored in
localStorage
- Chart.js loaded from CDN
- Four chart instances created on dashboard load
- Chart type and data updated when the user selects a different trend view
- Charts destroyed and recreated on each dashboard refresh to prevent memory leaks
-
Install: caches the app shell (
/,/manifest.json,/icon-192.svg) -
Fetch strategy:
- API calls (
/api/*): network-first, falls back to{"error":"Offline"}JSON - Static assets: cache-first, falls back to network (and caches the response)
- API calls (
- Activate: cleans up old cache versions
The React Native (Expo 50) companion app uses the same REST API as the web frontend.
-
Auth: Bearer token stored in
AsyncStorage; injected via Axios request interceptor -
State: Zustand stores for auth (
authStore) and shift data (shiftStore) - Navigation: React Navigation native stack (auth stack when logged out, app stack when logged in)
-
Token rotation: calls
POST /api/auth/refreshto rotate the session token -
Self-registration: users can create accounts via
POST /api/auth/signupwithout an admin invite -
API URL: configured via
EXPO_PUBLIC_API_URLenv variable (defaults tohttp://localhost:3000)
The server handles SIGTERM and SIGINT:
- Stop accepting new connections (
server.close()) - Close the SQLite database connection (
db.close()) - Exit cleanly
- Forced exit after 5-second timeout if connections don't drain
This ensures clean shutdown when systemd stops the service and prevents database corruption.