→ cambiar.world (project site) · Issues & support
API-first change management for small workshops. Node.js + React, local or Active Directory authentication, admin-managed change types and approver groups, email/SMS notifications, recurring changes, inbound email ingestion, iCal + Google Calendar sync, single-container deploy.
The name is from the Spanish verb cambiar — "to change". The repository directory and git URL keep the short form
cambiar; everywhere else (UI, emails, calendar feeds, branding) the product is cambiar.world.
For a higher-level overview of what the project is, who it's for, and what it deliberately is not, see the project site. The rest of this README is the operator-facing manual: installation, configuration, API reference, and contributing notes.
- Features
- Quick start (Docker)
- Quick start (local development)
- Default credentials
- Resetting the admin password
- Repo layout
- Configuration
- Active Directory
- Email and SMS
- Branding (logo + app name)
- Roles, groups, and the approval policy
- Workflow states
- Admin guide
- API reference
- Development
- Testing — the API contract
- End-to-end tests (Playwright)
- Continuous integration
- Release notes
- Theme
- License
- Local + AD/LDAP auth — bcrypt-hashed local accounts, plus Active Directory bind/search. Local takes precedence on username collisions, so the bootstrap admin always works.
- AD allowlist —
auth.ad.allowedGroupsgates login on AD group membership; only users in one of the listed groups can sign in even if their AD password is correct. - AD group sync —
auth.ad.groupSyncreconciles Cambiar group memberships and roles from AD on every login. Synced groups are flaggedad_managedand become read-only in the API; AD is the source of truth. - Approver groups — many-to-many user/group membership; any-one-group approval (membership in any group assigned to a change type lets you approve). Admin override always works. Submitter can never approve their own change.
- Workflow with audit —
draft → submitted → approved → in_progress → implemented → closed, withrejectedandrolled_backbranches. Every transition is captured in an audit log. - Auto-approve change types — flag a type to skip the approval gate (
draft → approvedin one step) for routine, low-risk work. - Recurring changes — mark a change as a parent with a cron schedule; at each fire, a child is spawned with the parent's blueprint and threaded back via
parent_change_id. Composes with auto-approve for fully unattended scheduled work. - Linked changes —
depends_on(directional, gates/startand/implementuntil prereqs are implemented or closed; transitive cycles refused) andrelates_to(symmetric, soft). - Planned + actual duration — track expected and observed implementation time; variance shown on the change detail.
- Admin-managed change types — seeded from
config/change-types.jsonon first run, then editable through the admin UI: rename, edit fields (string/text/number/select/boolean), per-type approval-SLA override, soft-delete when in use. - Notes + attachments — chronological markdown notes per change; file uploads up to 10 MB (images, PDFs, text/CSV/JSON). Attachments can be threaded under a specific note (deleted with the note) or change-wide.
- Templates — save a change as a template, copy a change as a new draft, or instantiate a fresh draft from a template.
- Email + SMS — email via SMTP (nodemailer); SMS via Twilio. Per-event channel filtering in
config/notifications.json. - Inbound email engine — IMAP poller turns each message into a configurable action:
create_change,transition(submit/approve/start/etc.), oradd_note. Idempotent byMessage-ID. - Scheduled email digests — admin-defined cron schedules render upcoming-changes digests to a recipient list (free-form addresses or user-id resolved emails).
- Operational alerts — scheduled checker raises and resolves alerts when an approval has been pending past the SLA threshold (global default + per-change-type override) or a recurring parent's last fire is older than the most recent expected fire. Notifications go to admin emails; resolves automatically when the underlying state clears.
- iCal feed — per-user tokenized URL for
/upcomingso workshop members subscribe from Google/Apple Calendar without logging in.
- Configurable branding — admin-uploadable logo (PNG / SVG / JPEG / WebP, max 1 MB) and app name. Renders top-left for everyone, including the login screen.
- Light + dark theme — toggleable, persisted per browser.
- Mobile responsive — topbar wraps on phones, calendar week view scrolls horizontally inside its panel, tables degrade to horizontal scroll instead of busting the layout.
- Calendar — month / week / day / list views for upcoming changes; status filter; week and day use a time-grid with blocks sized by planned duration.
- API-first — every endpoint has tests; the README endpoint list and
GET /apiare kept in sync. - Single-container deploy — multi-stage Dockerfile (build web, install server, slim runtime as non-root).
docker compose up -d --buildand you're running. - Apache-2.0 licensed.
~310 vitest server tests + Playwright end-to-end specs run in CI on every push. See Testing.
git clone https://github.com/djsincla/cambiar && cd cambiar
cp .env.example .env
# Required: set JWT_SECRET to something long and random.
# JWT_SECRET=$(openssl rand -hex 64)
docker compose up -d --build
# open http://localhost:3000 → log in admin / admin (forced password change on first login)The container persists its SQLite database in ./data/ and reads JSON config from ./config/ (mounted read-only). After editing config/auth.json or config/notifications.json, run docker compose restart cambiar to apply. (config/change-types.json is only used on the first migration to seed the catalog — once seeded, change types are managed in the admin UI.)
npm install
cp server/.env.example server/.env # set JWT_SECRET
npm run migrate # creates data/cambiar.sqlite, bootstraps admin/admin, seeds change types
npm run dev # API on :3000, web on :5173 with hot reloadFor a production-style local run:
npm run build
npm start # serves API + built web on :3000The Vite dev server proxies /api/* to the server, so a single http://localhost:5173 URL works for development.
First login: admin / admin. The bootstrap admin is forced to change their password on first login (must_change_password=1). After that, manage all users through the admin UI.
If the admin password is lost or all admins get locked out, run the reset-admin CLI from the host. By design there is no API equivalent — recovery requires direct access to data/cambiar.sqlite, the same trust boundary as the database file itself.
# Local install
npm run reset-admin # generates a strong random password and prints it
npm run reset-admin -- --password 'MyNewPwd1234' # set a specific password
npm run reset-admin -- --username admin2 # reset (or create) admin2
# Docker (running container)
docker compose exec cambiar npm run reset-admin
docker compose exec cambiar npm run reset-admin -- --password 'MyNewPwd1234'
# Docker (one-shot, container not running)
docker compose run --rm cambiar npm run reset-adminWhat it does:
- User exists → updates the password, sets
must_change_password=1, setsactive=1. Role is not changed. - User doesn't exist → creates them with
role=admin,must_change_password=1,active=1. - AD-sourced user → refused (use AD password reset instead).
- The user must change the password on first login.
The script applies any pending migrations before doing the reset, so it's safe on a fresh install too.
SQLite + WAL mode is not safe to copy with cp while the server is running. The .sqlite file may be missing committed transactions that live in the .sqlite-wal sidecar, and a non-transactional copy is at risk of bad-row corruption. Use the bundled CLI instead — it uses SQLite's online backup API to produce a fully consistent snapshot even with cambiar live.
# Default location: data/backups/cambiar-<timestamp>.sqlite
npm run backup
# Specific path
npm run backup -- --out /mnt/backups/cambiar-2026-05-07.sqlite
# Also bundle data/uploads/ as a tar.gz alongside (for full restore)
npm run backup -- --uploadsInside Docker:
docker compose exec cambiar npm run backup -- --uploads
docker compose cp cambiar:/app/data/backups ./local-backupsRestore: stop cambiar, replace data/cambiar.sqlite with the snapshot file, restore data/uploads/ from the tarball if you took one, start cambiar back up. Migrations apply automatically.
A reasonable cadence for a small workshop: cron a daily snapshot to a separate disk, weekly with --uploads for the full picture.
cambiar/
├── server/ Express API + SQLite + auth + notifiers + tests
│ ├── src/
│ │ ├── app.js Express app factory (used by index.js and tests)
│ │ ├── index.js Production entry — runs migrations, bootstraps admin, listens
│ │ ├── auth/ jwt, password hashing, AD/LDAP client
│ │ ├── db/ schema migrations (.sql), runner, sqlite singleton
│ │ ├── middleware/ requireAuth, requireRole, blockIfPasswordChangeRequired
│ │ ├── notifications/ pluggable channels (email, sms)
│ │ ├── routes/ auth, users, groups, changeTypes, changes, settings
│ │ └── services/ changeTypes, groups, audit, settings
│ └── test/ vitest tests (one file per route surface)
├── web/ Vite + React SPA (served by Express in production)
│ └── src/
│ ├── App.jsx Router with <Protected> guard
│ ├── auth.jsx AuthProvider (login/logout/refresh)
│ ├── branding.jsx BrandingProvider (logo/appName fetched at boot)
│ ├── api.js fetch wrapper
│ └── pages/ Login, ChangeList/Detail/New, ChangePassword,
│ Users, Groups, ChangeTypesAdmin, Settings
├── config/ auth.json, notifications.json, change-types.json (seed)
├── data/ SQLite db + uploads/ (volume-mounted, gitignored)
├── e2e/ Playwright specs
├── .github/workflows/ci.yml CI pipeline
├── Dockerfile multi-stage (web-build → server-install → runtime)
└── docker-compose.yml
| Where | What | Lifetime |
|---|---|---|
.env (Docker) / server/.env (local) |
Secrets: JWT_SECRET, AD_BIND_PASSWORD, SMTP_PASSWORD, SMS_AUTH_TOKEN |
Read on every server start |
config/auth.json |
Toggle local/AD; AD server settings; AD group→role mapping | Read on every server start |
config/notifications.json |
Toggle email/SMS; SMTP host/port/from; SMS adapter; per-event channel filters | Read on every server start |
config/change-types.json |
Seed only — imported into the change_types DB table on the first migration. Edits to this file after that have no effect. |
First-run seed |
data/cambiar.sqlite |
Users, groups, change records, approvals, audit log, settings, change types | Authoritative |
data/uploads/ |
Admin-uploaded files (e.g. logo) | Authoritative |
Secrets always come from env vars, never from JSON.
Set auth.ad.enabled = true in config/auth.json and fill in:
{
"ad": {
"enabled": true,
"url": "ldaps://ad.example.com:636",
"bindDN": "cn=cambiar-svc,ou=ServiceAccounts,dc=example,dc=com",
"searchBase": "ou=Users,dc=example,dc=com",
"searchFilter": "(sAMAccountName={username})",
"tlsRejectUnauthorized": true,
"attributes": {
"username": "sAMAccountName",
"email": "mail",
"displayName": "displayName"
},
"defaultRole": "submitter",
"groupRoleMap": {
"Cambiar-Admins": "admin",
"Cambiar-Approvers": "approver"
}
}
}The bind password is AD_BIND_PASSWORD in env. groupRoleMap keys are matched as case-insensitive substrings against the user's memberOf DNs — the first match wins. If none match, defaultRole is assigned. Admin role is preserved across re-logins (won't be downgraded by group mapping).
If a username matches both a local account and an AD account, local takes precedence — useful for the bootstrap admin and for emergency access if AD is unreachable.
In config/notifications.json:
{
"email": {
"enabled": true,
"from": "Cambiar <cambiar@example.com>",
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "cambiar@example.com" },
"events": ["submitted", "approved", "rejected", "implemented"]
},
"sms": {
"enabled": false,
"adapter": "twilio",
"twilio": { "accountSid": "ACxxx", "fromNumber": "+15555555555" },
"events": ["approved", "rejected"]
}
}SMTP_PASSWORD and SMS_AUTH_TOKEN come from env. Per-user phone numbers are stored on the user record (admin can set them via Users → Edit). The events array picks which workflow transitions trigger that channel.
Recipient rules:
| Event | Email/SMS goes to |
|---|---|
submitted |
Approvers (admins + members of any approver group on this change type) — never the submitter |
approved / rejected / implemented / closed |
The submitter |
Cambiar can push changes directly into a shared Google Calendar so the workshop sees them in their normal calendar app without each user having to subscribe to the iCal feed individually. Same scope as the iCal feed: scheduled non-recurring-parent changes in submitted / approved / in_progress / implemented.
One-time setup:
- Google Cloud project — create one (or use an existing one). In the console, enable the Google Calendar API.
- Service account — IAM & Admin → Service Accounts → create one. Generate a JSON key and save it to
config/gcal-service-account.json(this path is gitignored — keep the file out of source control). - Share the target calendar — open Google Calendar settings for the calendar you want events to land in. Under "Share with specific people or groups", add the service account's email (it ends in
iam.gserviceaccount.com) with Make changes to events permission. - Calendar ID — same Settings page, scroll to "Integrate calendar" and copy the Calendar ID (looks like
abc...@group.calendar.google.com, or justprimaryfor the service account's own calendar). - Edit
config/notifications.json:"googleCalendar": { "enabled": true, "calendarId": "abc123@group.calendar.google.com", "credentialsFile": "config/gcal-service-account.json", "syncIntervalMinutes": 5 }
- Restart Cambiar. The
Google Calendaradmin page (underAdmin ▾) shows status, counts, and a Sync now button for verification.
A background reconciler runs every syncIntervalMinutes (default 5) and inserts / updates / deletes events as changes move through the lifecycle. The reconciler is idempotent — safe to run repeatedly. If an event is deleted manually in Google Calendar, the next sync notices the 404 on update/delete and clears the local gcal_event_id so it can be re-created if the change still belongs there.
Admin → Settings → upload PNG / SVG / JPEG / WebP (max 1 MB). The logo renders top-left for every user, including on the login screen (the branding endpoint is intentionally public). Files persist in data/uploads/. Replacing or removing the logo deletes the previous file.
The app name (default cambiar) shown in the topbar when no logo is set is also editable on this page.
Roles
admin— manage users, groups, change types, branding; can approve any change (override).approver— legacy fallback role; only matters for change types with no approver groups assigned. Once you start using groups, treat this role as deprecated.submitter— create/edit own drafts, submit for approval, mark implemented, close.
Groups
- Many-to-many: a user can belong to any number of groups. A group can have any number of users.
- Created and managed by admins on the Groups page.
- Assigned as approver groups per change type on the Change Types page.
Approval policy: any-one-group
For a change type with N approver groups assigned, any one member of any one group can approve. One approval moves the change to approved. One rejection moves it to rejected (single veto).
admin → can approve anything (override)
member of any one group → can approve types where that group is assigned
approver role → legacy; only counts when the change type has zero groups
submitter (own change) → cannot approve their own change, ever
GET /api/changes/:id includes a requiredApprovalGroups field so the UI can show "any one member of: <groupA>, <groupB>".
Standard changes (auto-approve)
A change type can be marked auto-approve ("standard change" in ITIL terms). Submissions of that type skip the approval gate entirely — draft → submitted → approved happens in a single transaction with the system as the actor for the auto-approve step. Field validation still runs at submit.
Use auto-approve for routine, low-risk, well-understood work — planned reboots in a maintenance window, recurring patch jobs, scheduled backups. Anything that would otherwise create approver fatigue.
- Mutually exclusive with approver groups (the API rejects setting both — they're conceptually contradictory).
- Audit log shows two rows: the human
submitand theauto_approvesystem action withdetails: { reason: 'change type configured for auto-approval' }. - Notifications: no "submitted" email is sent (no one needs to act); the submitter still gets the "approved" email so they know it cleared.
- Flipping a type to auto-approve does not retroactively approve existing pending changes — only new submissions.
Approver inbox
The topbar shows an Approvals link with a count badge of changes currently waiting on the signed-in user. Clicking it opens a focused inbox view (/changes?awaiting=true) sorted oldest-first.
The inbox eligibility predicate is the same one used for the "submitted" notification recipients, so what shows up in your inbox is exactly what gets you emailed:
| You are… | Inbox shows |
|---|---|
admin |
All submitted changes (except your own) |
| in approver group(s) | Submitted changes whose type lists any of your groups, except your own |
approver role + no groups |
Submitted changes whose type has no approver groups assigned (legacy) |
plain submitter |
Empty |
The badge polls every 60 seconds and refreshes immediately on navigation between routes.
draft ── submit ──▶ submitted ── approve ──▶ approved ── implement ──▶ implemented ── close ──▶ closed
│ │
└── reject ──▶ rejected └── rollback ──▶ rolled_back
Every transition writes to audit_log with the user, from-status, to-status, and any decision comment. The audit log is exposed via GET /api/changes/:id.
The topbar exposes admin-only links once you log in as admin:
- Users (
/admin/users) — list / create local / edit role / set group memberships / reset password / activate-deactivate. Last admin cannot be demoted or disabled. - Groups (
/admin/groups) — list / create / edit name + description / pick members. Refuses delete if the group is assigned as an approver group on any change type (re-assign first). - Change Types (
/admin/change-types) — list (active + inactive) / create / edit (rename, change description, edit field schema, pick approver groups) / delete (soft-deletes if records reference the type, hard-deletes if not). - Settings (
/admin/settings) — branding (logo upload, app name).
GET /api returns a live endpoint index. Highlights:
POST /login—{ username, password }→ setscambiar_sessioncookie + returns userPOST /logoutGET /mePOST /change-password—{ currentPassword, newPassword }
GET /— list (includesgroups[])POST /— create local user (accepts optionalgroupIds)GET /:id/PATCH /:id— read/update (strict mode: unknown fields rejected). Accepts optionalgroupIdsfor atomic membership replacement.POST /:id/reset-password— admin reset; user must change on next login. Refuses for AD users.
GET /— list with member counts (visible to any authed user, so the UI can render group names)GET /:id— group + membersPOST /(admin) — create with optionalmemberIdsPATCH /:id(admin) — edit name/description/membersDELETE /:id(admin) — refuses if assigned as approver group on any change typePOST /:id/members(admin) /DELETE /:id/members/:userId(admin) — fine-grained member management
GET /— list active types (admins can pass?includeInactive=true)GET /:keyOrId— by key or numeric idPOST /(admin) — create withkey,name,description,icon,fields[],approverGroupIds[],autoApprove. Validates field schema (no duplicate keys, select fields require options, lowercase keys). RejectsautoApprove: truetogether with non-emptyapproverGroupIds(mutual exclusion).PATCH /:id(admin) — partial update; can deactivate viaactive: false; can toggleautoApprove(clears groups in the same patch if needed).DELETE /:id(admin) — soft-deletes if records reference the type (active=0), hard-deletes otherwise
GET /— list with optional?status=&mine=true&type=GET /?awaitingMyApproval=true— inbox: only changes the current user can approve right now (admin override + group eligibility + legacy approver fallback). Sorted oldest-first.POST /— create draft; lenient field validation on draft, strict on submitGET /:id— change detail withapprovals[],audit[],requiredApprovalGroups[],changeType(incl.autoApprove)PATCH /:id— edit draft (only by submitter or admin, only ifstatus=draft)DELETE /:id— delete draft (only by submitter or admin, only ifstatus=draft)POST /:id/submit— strict field validation. If the change type is auto-approve, transitions straight toapprovedin a single transaction.POST /:id/approve/POST /:id/reject—{ comment? }. Requires admin or membership of an assigned approver group.POST /:id/implement/POST /:id/close— submitter or adminPOST /:id/rollback—{ comment? }. Fromimplementedorclosed.
GET /branding— public (no auth) —{ appName, logoUrl }. Used by the login screen.PUT /branding(admin) —{ appName? }POST /branding/logo(admin, multipartlogofield) — PNG/SVG/JPEG/WebP, max 1 MBDELETE /branding/logo(admin) — clears the logo
GET /api/health—{ ok: true, version }GET /api— endpoint index
Uploaded files are served at /uploads/<filename> (no auth — these are public branding assets).
npm run dev # server (3000) + web (5173) with hot reload
npm test # server tests (vitest, ~24s)
npm run test:e2e # Playwright E2E (~10s, uses port 3500)
npm run build # build the SPA into web/dist/ for npm startCambiar is API-first: the test suite is the contract. Any change to an API endpoint must come with a test change, and npm test must stay green before merging.
npm test # full suite
npm test -- --watch # iterate
npm test -- changes # only changes.test.jsTests run against an in-memory SQLite with a per-test reset (resetDb() in server/test/helpers.js), so they're hermetic and fast — the full 131-test suite runs in ~24s.
| File | What it locks down |
|---|---|
test/meta.test.js |
/api/health, /api endpoint index |
test/auth.test.js |
login (good/bad/missing/disabled), me, logout, password change (success / wrong-current / weakness rules), must-change-password gate |
test/users.test.js |
admin RBAC on user CRUD, last-admin protection, weak-password rejection, AD-user reset blocked, strict-mode patch |
test/userGroups.test.js |
groups[] in user payload, groupIds on create/patch, atomic replacement, unknown groupId rejection |
test/groups.test.js |
groups CRUD, member add/remove, member counts, name validation, deletion guard when assigned as approver |
test/changeTypes.test.js |
public type catalog shape, structural invariants, 404 on unknown |
test/changeTypesAdmin.test.js |
admin CRUD, duplicate-key, duplicate-field-key, select-without-options, unknown approverGroupId, soft- vs hard-delete |
test/changes.test.js |
full state machine (draft→submitted→approved→implemented→closed, plus rejected/rolled_back), submitter-cannot-approve-own, role gates, field validation at submit time, audit log captures every transition |
test/groupApproval.test.js |
any-one-group rule, multi-group OR semantics, admin override, submitter-still-blocked, reject also requires group membership, legacy fallback to approver role when no groups assigned |
test/branding.test.js |
public GET, admin-only writes, file-type allowlist, 1 MB cap, replace-deletes-old-file, clear flow |
test/notifications.test.js |
recipients per event (approvers + admins on submit; submitter on approve/reject), per-channel event filtering |
test/ad.test.js |
AD path with mocked ldapts: bind→search→re-bind, group→role mapping, attribute refresh on re-login, local-takes-precedence on collision, admin not downgraded by AD group mapping |
test/resetAdmin.test.js |
reset existing → forces change; reactivates disabled; creates as admin if missing; preserves role on existing; refuses AD; generated password complexity; full rescue scenario when all admins are demoted |
test/awaitingApproval.test.js |
the inbox-eligibility matrix: plain submitter empty, admin sees all-but-own, group-member sees own-group only, multi-group OR, legacy approver-role fallback only when no groups assigned, submitted-only state, oldest-first sort, deactivated types still listed, auto-approved types never reach inbox |
test/autoApprove.test.js |
mark type auto-approve, mutual exclusion with approverGroupIds (create + patch), submit lands on approved with two audit rows (human submit + system auto_approve), implement/close still work, field validation still runs at submit, no retroactive approval when flag is flipped on existing submitted records |
When adding or modifying an endpoint:
- Add or update the test in
server/test/*.test.js. - Update the route handler.
npm test→ green.- Update this README's API section and
GET /apiif the surface changed.
npm run test:e2eThe Playwright config (playwright.config.js) starts a fresh isolated server on port 3500 with a wiped data-e2e/ directory each run, then runs the specs in e2e/ against it. First-run installs the chromium browser on demand:
npx playwright install chromium| Spec | What it covers |
|---|---|
e2e/auth.spec.js |
bootstrap admin/admin → forced password change → topbar branding → admin nav links → sign out |
e2e/changes.spec.js |
admin creates a server-reboot change as a draft via the form, sees it in the list |
e2e/admin.spec.js |
reaches each admin page (Users, Groups, Change Types, Settings) and exercises a basic interaction |
e2e/approval.spec.js |
end-to-end approver flow (admin creates submitter, submitter signs in & submits, admin sees Approvals badge with count, opens inbox, approves, badge clears) and end-to-end auto-approve flow (admin marks type auto-approve, submission goes straight to approved with the auto-approve note in the policy panel) |
e2e/theme-and-notes.spec.js |
theme toggle switches data-theme, persists across reload; release-notes page reachable from topbar and renders the changelog |
.github/workflows/ci.yml runs on push and PR to main:
test—npm ci,npm test(vitest),npm run build(Vite)e2e— installs Chromium, runsnpm run test:e2e, uploads the Playwright report on failuredocker— builds the production container
Concurrency cancels in-progress runs on the same ref.
The full changelog lives in CHANGELOG.md and is also rendered inside the app at /release-notes (linked from the topbar) for any signed-in user.
A light/dark toggle in the topbar. Default is dark; the choice persists per browser via localStorage. The whole UI is theme-driven via CSS custom properties — adding a new theme is a matter of adding a [data-theme="..."] block in web/src/styles.css.