All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
PORT=0is honored instead of being silently coerced to 3000 (#124).server.jsresolved its listen port viaparseInt(process.env.PORT, 10) || 3000, which short-circuited on the legitimate "kernel pick a free port" case because0 || 3000evaluates to3000.tests/api/server-boots.test.jsrelied on this exact behavior (port 0) to avoid colliding with whatever's already on:3000on a dev box, so the smoke-test was failing onmasterfor any contributor with a busy 3000. Replaced the falsy-fallback with an explicit `Number.isFinite(parsed) && parsed= 0
check so onlyNaN/ negativePORT` values fall back to the default.
- Production hard-fails on empty
DB_PASSWORD(#119). Previously a missingDB_PASSWORDinNODE_ENV=productionwould warn and start anyway;/healthzreported degraded, but a load balancer keyed purely off HTTP 200 from/healthzcould still flip traffic to a pod that couldn't reach its database. Now the process logs to stderr andprocess.exit(1)immediately so systemd / k8s catch the misconfiguration before traffic ever lands. Development and test paths still warn-and-continue so the suite runs without a real DB. DB_PASSWORDis documented as REQUIRED in production (#120). README +.env.examplecallouts updated to match the new hard-fail behavior above; operators get the warning at config-edit time, not just at first deploy.pgbumped 8.20.0 → 8.21.0 (#122). Patch-level dep refresh in theminor-and-patchDependabot group.
- OpenAPI:
Idempotency-Replayresponse-header declaration on every single-create POST 201 (#245 sweep — landed across 16 PRs from #246 through #288). Every/v1/*POST that flows through theIdempotency-Keymiddleware now advertises theIdempotency-Replayheader on its 201 response in the spec — SDK generators (openapi-typescript, etc.) carry the replay flag into client-facing types instead of having callers infer it from prose in the request header's description. The bulk endpoints picked this up in #168; this sweep extends it to single-create symmetry. - Real-PG integration coverage for the cascade auth helpers
(#121, follow-up to #117). Six new test cases against
postgres:16-alpinefor the fourgetCompanyIdBy*helpers inapp/middleware/auth.js(Customer, Job, PurchaseOrderVendor, PurchaseOrderHeader cascades). Includes a regression-pin that archiving an intermediate Customer drops a downstream Job's auth scope to-1— the correct security outcome since the parent's scope no longer applies. Auto-skips locally without a DB; runs on every CI build. npm run dev(#116). Uses Node's built-in--watchflag (stable since Node 22; project pins>=20) to restart on changes toapp/andserver.js. No new dev dependency. CONTRIBUTING.md quick-start updated.
- Fixed an IPv6 rate-limit bypass. The custom
keyByAuthKeyOrIprate-limit key generator was readingreq.ipdirectly and concatenating it into the key. express-rate-limit v8+ surfaced this asERR_ERL_KEY_GEN_IPV6: IPv6 clients could rotate through their /64 allocation, each appearing as a distinct IP, to bypass the per-IP rate-limit budget on the anonymous (brute-force) path. Fix routes the IP through the package'sipKeyGenerator()helper, which normalizes IPv6 to a /64 prefix. Authenticated keying (sha256 of the authKey header) is unchanged. Operators on previous releases should upgrade.
- Unused production dependencies:
express-asyncifyandexpress-promise-router. Both were listed in package.json since the initial port but never imported anywhere. Surfaces as a smallernpm cifootprint; no behavior change.
- CI gains a live Postgres service. Both GitHub Actions and
Woodpecker spin up
postgres:16-alpinealongside the Node matrix, runsetup/TimeTracker.sqlfor thedboschema bootstrap, thennpm run migrateto apply every migration. The integration suite attests/integration/db-roundtrip.test.jsno longer self-skips in CI — it gates every PR against schema / migration drift. The unit + api suites still pass with or without a DB, so localnpm testworks unchanged.
- OpenAPI completeness pass. All 12 previously-undocumented
bulk-create endpoints now appear in the spec via a shared
bulkPath(bodyKey, schemaName)helper (kept the entries from drifting into 13 hand-maintained near-duplicates). TheIdempotency-Keyheader is documented as an optional parameter on every bulk POST./metricsgets its own path entry with the Prometheus text-format response and theMETRICS_BEARER_TOKEN401-gate documented. Three new OpenAPI tests pin the additions. - Bulk-create endpoints for 7 indirect-scoped entities (P3-H2).
New
POST /v1/<entity>/bulkon Job, Invoice, CustomerPayment, InvoiceJob, ProductEntry, PurchaseOrderHeader, PurchaseOrderLine. Same 500-entry cap and transactional all-or-nothing semantics as the direct-compId family from P3-H, but per-entry auth scope is resolved through the parent FK (Customer / Job / Vendor / Header) via the existing helpers inapp/middleware/auth.js. A newmakeBulkCreateIndirectfactory inapp/controllers/_bulk-helpers.jsparameterizes over the parent FK column + the auth-helper that resolves it; the 7 controllers gain ~10 LOC each instead of ~120. The bulk surface now covers all 13 soft-deletable entities.
app/middleware/auth.jsis now testable end-to-end (P5-M). Two changes:- Every DB hit now goes through the Sequelize model layer
(
ApiMaster.findOne,Customer.findByPk, etc.) instead of rawsequelize.querycalls — fewer hand-rolled SQL strings, and the archive-filter relies on the P2-E defaultScope rather than a repeated<arch>: falseWHERE clause. - A test-only
_setDbForTesting(stub)seam lets unit tests substitute model fixtures directly. vitest'svi.mockdoes not intercept this codebase's CJSrequire()reliably; the explicit setter is the smallest practical injection point. Production code MUST NOT call it. Auth helpers gain 19 new unit-level cases including the previously un-mockable "row found → returns the value" success paths.
- Every DB hit now goes through the Sequelize model layer
(
- ESLint flat config + CI gate (P5-L). New
eslint.config.jswith rules tuned for high-signal bug catching rather than style preferences:no-unused-vars(with^_opt-out),eqeqeq,no-console(allowing onlyerror/warnoutside tests),prefer-const,no-var. Tests get a relaxed variant (no-unused-varsas a warning, console allowed). Migrations ignore unused args to honor sequelize-cli's(queryInterface, Sequelize)contract. Wired into GitHub Actions and Woodpecker so every PR now runsnpm run lintahead of the vitest suite.npm run lint:fixavailable for autofixable rules. createdAt/updatedAton every domain entity (P4-K). New migration adds twoTIMESTAMPTZ NOT NULL DEFAULT now()columns to 18 tables (everything exceptIdempotencyKey, which already tracks its own time fields). All 18 models fliptimestamps: false→trueso Sequelize auto-populates the columns on every.create()/.update(). Existing rows are backfilled tonow()at apply time; operators with the original SQL Server timestamps from the Atbash legacy can patch real values post-migration via a one-off UPDATE.- Prometheus
/metricsendpoint (P4-J). Exposes prom-client's default Node.js metrics (event-loop lag, heap, GC, etc.) plus per-requesthttp_requests_total{method,route,status}andhttp_request_duration_seconds{method,route,status}series. Route labels use the Express pattern (/v1/customer/:id) not the rendered path, so cardinality stays bounded. Authentication is OPTIONAL: unsetMETRICS_BEARER_TOKENleaves the endpoint open (the usual private-network deployment); setting it requiresAuthorization: Bearer <token>on the scrape. Token comparison is constant-time. migrationfield onGET /healthz(P4-I). Body now reports the last applied migration name fromSequelizeMeta(e.g."20260519000000-idempotency-keys"). Lets a rolling-deploy caller verify each pod is at the expected schema version. Null when SequelizeMeta is missing (fresh DB pre-migration) or unreadable — the probe never flips tostatus: degradedover a migration-read failure, since the DB itself is still up.- Bulk-create endpoints for 5 direct-compId entities (P3-H).
New
POST /v1/<entity>/bulkon Worker, BillingType, InventoryItem, InventoryTransaction, and PurchaseOrderVendor. Same shape as the existingPOST /v1/customer/bulk: 500-entry cap, zod-strict whitelist, transactional all-or-nothing insert, master vs. non-master scoping enforced per entry. Sharedapp/controllers/_bulk-helpers.js#makeBulkCreatefactory removes ~150 lines of would-be duplication; Customer's pre-existing handler keeps its bespoke logic until a follow-up unifies them. - Idempotency-Key support on POST routes (P3-G).
Clients may send an
Idempotency-Key: <printable-ASCII, 1-255>header on any POST under/v1/*. The first response (status + JSON body) is cached in the newdbo.IdempotencyKeytable for 24h, keyed bysha256(authKey:method:path)+ the raw key value. Retries with the same body replay the cached response and carry anIdempotency-Replay: trueresponse header; retries with a DIFFERENT body return409 { code: "idempotency_key_reused" }. No-op for POSTs without the header — legacy clients are unaffected. - Sequelize associations across the full entity graph (PR #54).
Every FK now has a
hasMany/belongsTopair indb.config.js, enablinginclude-based eager loading and the auto-generated getter/setter methods. Verified via a 24-test unit suite that walksModel.associationsand asserts each expected edge. - Integration test harness against a real Postgres (PR #55).
tests/integration/runs only whenDB_PASSWORDis set + the Sequelizeauthenticate()call succeeds; skips gracefully otherwise sonpm testkeeps working unchanged. - Pre-built Postman collection at
setup/TimeTrackerAPI.postman_collection.json(PR #59). Generated fromapp/config/openapi.jsviaopenapi-to-postmanv2; covers all 47 endpoints across 16 entity folders. Regenerate after API changes with the one-liner in the README. - TLS reverse-proxy compose layer (PR #60).
Opt-in
docker-compose.tls.ymlputs Caddy in front of the api: automatic Let's Encrypt cert provisioning + renewal, HTTP → HTTPS redirect, HTTP/2 + HTTP/3 on :443, HSTS, X-Forwarded-* headers, gzip.TLS_DOMAIN=localhostopts into Caddy's built-in CA for local self-signed dev. - Committed
docker-compose.override.yml(PR #56) — exposes Postgres on127.0.0.1:5432for host-side integration test runs.
setup/TimeTracker.sqlis now idempotent against a populated database (PR #57). Re-runningdocker compose up setupagainst an existing schema is a no-op exit-0 rather than the previous exit-3 "schema dbo already exists" failure. Removes thedocker compose down -vworkaround from the dev flow.
- Full integration-test bring-up flow documented in
tests/integration/README.md(PRs #56 + #58). Covers the override file, env vars, fresh vs re-run behavior, and the conventions for cleanup sentinels. - README gets sections for Testing, Behind TLS (production), and a pointer to the Postman collection.
- PurchaseOrder + Inventory API surface (#49, PRs #50, #51, #52):
Full CRUD endpoints for the four tables added by the
20260517000000 migration —
PurchaseOrderVendor— direct compId scopingPurchaseOrderHeader— vendor-scoped via newauth.getCompanyIdByPovId()helperPurchaseOrderLine— header-scoped via newauth.getCompanyIdByPohId()helper (two-hop FK walk through header → vendor)InventoryTransaction— direct compId scoping;invtDirectionconstrained to 0 (inbound) or 1 (outbound) at the zod boundary
JSON_BODY_LIMITenv override forexpress.json()body cap (#45, PRs #46 and #47). Default 100kb matches the express built-in; operators can raise it (JSON_BODY_LIMIT=512kb) for endpoints that legitimately accept larger payloads.
npm audit fixcleared 10 transitive-dep vulnerabilities (dottie, moment, moment-timezone, path-to-regexp, qs, underscore, validator). Direct deps bumped to latest patch within current majors: express 4.21.1 → 4.22.2, pg 8.6.0 → 8.20.0, express-promise-router 4.0.1 → 4.1.1, sequelize 6.6.5 → 6.37.8. (PR #48; closes Snyk-backlog tracker #30; supersedes / closes 11 stale Snyk PRs.)
- API surface expansion (#38, PR #39): full CRUD for ten entities
that were in
setup/TimeTracker.sqlbut lacked endpoints — Worker, Company, BillingType, InventoryItem, Job, Invoice, CustomerPayment, InvoiceJob, ProductEntry, VersionInfo. Path count went from 7 to 35. - Three centralized auth-scoping patterns in
middleware/auth.js:- Direct
compIdscoping (Worker, BillingType, InventoryItem) — same as Customer. - Customer-scoped via new
getCompanyIdByCustomerId()helper (Job, Invoice, CustomerPayment) — auth walks parent FK. - Job-scoped via new
getCompanyIdByJobId()helper (InvoiceJob, ProductEntry) — auth walks two-hop FK.
- Direct
- Specials: Company has
compIdIS the company id (master-only POST/DELETE/list; GET/PATCH scoped to own row). VersionInfo is global, no archive column, reads open to any authKey, mutations master-only, DELETE is a hard destroy. - Migration
20260517000000-purchase-orders-and-archive-columns: createsPurchaseOrderHeaders,PurchaseOrderLines,PurchaseOrderVendors, andInventoryTransactionstables (omitted from the initial PG port of the BACPAC), and retrofits the missinginvitArchandinjbArchcolumns the new soft-delete logic depends on. docker composenow applies Sequelize migrations between the SQL bootstrap and the api start (newmigrateone-shot service). Without this, migrations landed after the baseline never applied to containerized deploys. (#40, PR #41)tiniruns as PID 1 in the container image for clean signal forwarding to the Node process (server.js already had the graceful-shutdown handler; tini just makes sure it gets the signal). (#40, PR #41)- OCI
org.opencontainers.image.*labels on the runtime image (source URL, license, vendor). (#40, PR #41)
sequelize-climoved fromdevDependenciestodependenciesso the productionnpm ci --omit=devbuild can run migrations.- HEALTHCHECK in the Dockerfile uses Node's built-in
httpmodule instead ofwget; drops thewgetapt-install layer. (#40) .dockerignoreexcludestests/,vitest.config.js,README.md,CHANGELOG.md, anddocs/from the runtime image; explicitly keepsLICENSE(Apache-2.0 §4(c) requires it accompany derivative works, including container images).
- Codeberg mirror at https://codeberg.org/CryptoJones/TimeTrackerAPI; README now carries badges for both forges.
GET /healthzliveness + DB-readiness probe. No auth. Returns{status, db, uptime_s, version, elapsed_ms}as 200 ok / 503 degraded.helmetsecurity headers (X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, Referrer-Policy, etc.). CSP off by default for the JSON API; enable viaHELMET_CSP=1.express-rate-limiton/v1(default 100 requests / 15-minute window per IP, env-tunable viaRATE_LIMIT_MAX/RATE_LIMIT_WINDOW_MS)./healthzis exempt.TRUST_PROXYenv for real-IP keying behind nginx / Caddy / Cloudflare.pinostructured logger with authKey redaction;pino-httpfor per-request access logging.LOG_LEVEL/LOG_PRETTY/TRIAGE_NO_LOG-style env knobs.POST /v1/customer— first write endpoint. Body whitelist (mass-assignment defense); master keys must specifycustCompId, non-master keys default to their own.POST | GET | PATCH | DELETE /v1/timeentry/[:id]andGET /v1/timeentry/bycompany/:id— five new time-entry endpoints, the headline feature for a project named "TimeTracker". Includes theTimeEntrySequelize model,setup/TimeEntry.sqlmigration, two hot-path indexes,teMinutescomputed on close, soft-delete viateArch.- Pagination on
GET /v1/customer/bycompany/:id(?limit,?offset, default 100 / max 500). Response now includescountfor clients to paginate without an extra round-trip. - OpenAPI 3.0 spec at
/openapi.json+ Swagger UI at/docs(both unauthenticated by design). - Zod-backed request validation at the middleware boundary —
validate.body / .query / .paramsreject malformed inputs with 400- structured
issuesarray BEFORE they reach Sequelize.
- structured
- Dockerfile + docker-compose.
git clone + docker compose upbrings postgres + the schema bootstrap + the API up on port 3000. - CI: GitHub Actions + Codeberg Woodpecker pipelines running vitest on Node 20 + 22.
getCustomerByIdwas rewritten to fix a double-response fall-through bug where the master-key branch sent a response inside a.then()but didn't exit the surrounding async function, causingCannot set headers after they are senton real master-key traffic.IsMaster/GetCompanyId/GetCustomerCompanyIdno longer indexresult[0].xwithout checking the array is non-empty (eliminates a noisy log entry every time an unknown authKey hits the API).console.log/console.erroreverywhere →log.error({err}, '...')with structured fields. Tests setLOG_LEVEL=silentso error-path cases don't flood stdout.GET /v1/customer/bycompany/:idnow filters out soft-deleted customers (custArch = true). Clients were already manually filtering; this aligns server behavior with the contract.IsMasterandGetCompanyIdextracted from both controllers intoapp/middleware/auth.js— single source of truth.
body-parserdependency. Express has had it built-in asexpress.json()since 4.16 (Oct 2017); we're on ^4.21.
- Server runs as non-root inside the Docker image.
authKeyis redacted from all pino log lines viapino.redactpaths coveringreq.headers.authkey,req.headers.authKey, andreq.headers.authorization.- All mass-assignment vectors (writing
custId,custArch,teId,teMinutes,teArchvia request body) blocked at the validation middleware boundary.
Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/