{"id":50792856,"url":"https://github.com/cryptojones/timetrackerapi","last_synced_at":"2026-06-12T12:02:24.171Z","repository":{"id":40336123,"uuid":"334356291","full_name":"CryptoJones/TimeTrackerAPI","owner":"CryptoJones","description":"Open-source Node.js + PostgreSQL re-write of Atbash Services' TimeTrackerAPI. REST endpoints + API-key auth via the authKey header.","archived":false,"fork":false,"pushed_at":"2026-05-26T03:40:59.000Z","size":9050,"stargazers_count":0,"open_issues_count":0,"forks_count":1,"subscribers_count":1,"default_branch":"master","last_synced_at":"2026-05-26T05:26:35.368Z","etag":null,"topics":["express","nodejs","postgresql","rest-api","sequelize","time-tracking"],"latest_commit_sha":null,"homepage":"http://node.timetrackerapi.com","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/CryptoJones.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2021-01-30T07:36:24.000Z","updated_at":"2026-05-26T03:41:09.000Z","dependencies_parsed_at":"2024-12-07T06:23:57.143Z","dependency_job_id":"9460c6e1-1162-4bd9-8a0a-dc549a80c281","html_url":"https://github.com/CryptoJones/TimeTrackerAPI","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/CryptoJones/TimeTrackerAPI","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CryptoJones%2FTimeTrackerAPI","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CryptoJones%2FTimeTrackerAPI/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CryptoJones%2FTimeTrackerAPI/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CryptoJones%2FTimeTrackerAPI/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/CryptoJones","download_url":"https://codeload.github.com/CryptoJones/TimeTrackerAPI/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CryptoJones%2FTimeTrackerAPI/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34243053,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-12T02:00:06.859Z","response_time":109,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["express","nodejs","postgresql","rest-api","sequelize","time-tracking"],"created_at":"2026-06-12T12:01:45.886Z","updated_at":"2026-06-12T12:02:24.018Z","avatar_url":"https://github.com/CryptoJones.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# TimeTrackerAPI\n\nOpen-source rewrite of Atbash Services' TimeTrackerAPI on **Node.js + PostgreSQL**.\n\n[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg?logo=apache)](LICENSE)\n[![Node.js](https://img.shields.io/badge/Node.js-20%2B-339933?logo=node.js\u0026logoColor=white)](https://nodejs.org/)\n[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-14%2B-4169E1?logo=postgresql\u0026logoColor=white)](https://www.postgresql.org/)\n[![Tests](https://github.com/CryptoJones/TimeTrackerAPI/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/CryptoJones/TimeTrackerAPI/actions/workflows/test.yml)\n[![Codeberg](https://img.shields.io/badge/Codeberg-CryptoJones%2FTimeTrackerAPI-2185D0?logo=codeberg\u0026logoColor=white)](https://codeberg.org/CryptoJones/TimeTrackerAPI)\n[![GitHub](https://img.shields.io/badge/GitHub-CryptoJones%2FTimeTrackerAPI-181717?logo=github\u0026logoColor=white)](https://github.com/CryptoJones/TimeTrackerAPI)\n\n\u003e Mirrored on both [GitHub](https://github.com/CryptoJones/TimeTrackerAPI) and\n\u003e [Codeberg](https://codeberg.org/CryptoJones/TimeTrackerAPI). Issues filed\n\u003e on either forge are welcome; commits are pushed to both.\n\nWorking example at [node.timetrackerapi.com](http://node.timetrackerapi.com).\n\n## Endpoints\n\n| Endpoint                            | Auth required | Description                                  |\n|-------------------------------------|---------------|----------------------------------------------|\n| `GET /healthz`                      | no            | Liveness + DB-readiness probe (returns `{status, db, uptime_s, version, elapsed_ms, migration}`; 200 ok / 503 degraded). `migration` carries the last applied migration name from `SequelizeMeta`, useful for verifying rolling-deploy schema versions. |\n| `GET /metrics`                      | no (or bearer)| Prometheus scrape endpoint. Default Node.js metrics + per-request `http_requests_total` / `http_request_duration_seconds`. Authentication is OPTIONAL: leave `METRICS_BEARER_TOKEN` unset for an open scrape (private-network deployment) or set it to require `Authorization: Bearer \u003ctoken\u003e`. |\n| `GET /docs`                         | no            | Interactive Swagger UI for the full API. |\n| `GET /openapi.json`                 | no            | Raw OpenAPI 3.0 spec (machine-readable). |\n| `GET /v1/whoami`                    | header        | Returns `{authenticated, isMaster, companyId}` for the calling `authKey`. Header MUST be present (403 if missing) but the key need NOT resolve — an unknown key returns 200 with `authenticated: false`. Useful for SDK clients to distinguish \"network plumbing wrong\" from \"credential wrong\" without inferring from a domain endpoint's 4xx. |\n| `GET /v1/customer/:id`              | yes (`authKey`) | Single customer lookup. Master key sees all; non-master only sees customers in its own company. |\n| `GET /v1/customer/bycompany/:id`    | yes (`authKey`) | Customers in a company (paginated). Master sees any; non-master only its own. Query params: `limit` (default 100, max 500), `offset` (default 0). Archived customers (`custArch = true`) are filtered out. |\n| `POST /v1/customer`                 | yes (`authKey`) | Create a customer. Master key may target any `custCompId`; non-master keys can only create within their own company (and `custCompId` defaults to that). Returns 201 + the created customer. |\n| `GET /v1/customer/search`           | yes (`authKey`) | Case-insensitive substring search across `custCompanyName`, `custFName`, `custLName`. Query params: `q` (2-char minimum), `companyId` (master-only — non-master keys are auto-scoped and a mismatched `companyId` returns 403), `limit` (default 100, max 500), `offset` (default 0). |\n| `GET /v1/customer/export.csv`       | yes (`authKey`) | CSV export of customers in a company. Master keys must supply `companyId`; non-master keys are auto-scoped. `limit` (default 5000, max 5000). Cells starting with `=`, `+`, `-`, `@`, tab, or CR are prefixed with a single quote to defuse OWASP CSV-formula injection on spreadsheet-app open. |\n| `POST /v1/timeentry`                | yes (`authKey`) | Create a time entry. Body: `teCustId` (required), `teStartedAt` (required, ISO 8601), `teEndedAt` (optional — in-flight entries allowed), `teDescription`, `teBillable` (default true). `teMinutes` is computed server-side on close. |\n| `GET /v1/timeentry/:id`             | yes (`authKey`) | Single time entry lookup. Company-scoped. Archived (soft-deleted) entries return 404. |\n| `GET /v1/timeentry/bycompany/:id`   | yes (`authKey`) | List time entries for a company. Query params: `customerId` (filter), `from` / `to` (ISO 8601 date range on `teStartedAt`), `limit` (default 100, max 500). Ordered most-recent first. |\n| `GET /v1/timeentry/export.csv`      | yes (`authKey`) | CSV export of time entries. Same auth contract + CSV-injection guard as `/v1/customer/export.csv`. Query params: `companyId` (master-only), `customerId` (filter), `from` / `to` (ISO 8601), `limit` (default 5000, max 5000). |\n| `PATCH /v1/timeentry/:id`           | yes (`authKey`) | Partial update. Updatable: `teDescription`, `teStartedAt`, `teEndedAt`, `teBillable`. `teMinutes` is recomputed on bound change. |\n| `DELETE /v1/timeentry/:id`          | yes (`authKey`) | Soft-delete (sets `teArch = true`). Entries are never physically removed via the API. |\n| `* /v1/worker/*`                    | yes (`authKey`) | Full CRUD for Workers (`workerId`, `workerFName`, `workerLName`, `workerTitle`, `workerDefaultBillType`, `workerCompId`, `workerArch`). Direct company scoping via `workerCompId`. Endpoints: `POST /v1/worker`, `GET /v1/worker/:id`, `GET /v1/worker/bycompany/:id`, `PATCH /v1/worker/:id`, `DELETE /v1/worker/:id`. |\n| `* /v1/billingtype/*`               | yes (`authKey`) | Full CRUD for BillingTypes (hourly rates a Worker can default to). Same shape as Worker. |\n| `* /v1/inventoryitem/*`             | yes (`authKey`) | Full CRUD for InventoryItems. Same shape as Worker. |\n| `* /v1/company/*`                   | yes (`authKey`) | Companies. Master keys only for `POST /v1/company`, `DELETE /v1/company/:id`, and `GET /v1/company` (list); non-master keys may `GET /v1/company/:id` and `PATCH /v1/company/:id` for their own row only. |\n| `* /v1/job/*`                       | yes (`authKey`) | Jobs (customer-scoped via `jobCustId` → `custCompId`). Endpoints: `POST`, `GET /:id`, `GET /bycustomer/:id`, `PATCH /:id`, `DELETE /:id`. |\n| `* /v1/invoice/*`                   | yes (`authKey`) | Invoices (customer-scoped). Same shape as Job. |\n| `* /v1/customerpayment/*`           | yes (`authKey`) | Customer payments (customer-scoped). `GET /bycustomer/:id` lists newest first. |\n| `* /v1/invoicejob/*`                | yes (`authKey`) | Invoice line items (job-scoped via `injbJobId` → Job → Customer.custCompId). `GET /byinvoice/:id` lists per invoice. |\n| `* /v1/productentry/*`              | yes (`authKey`) | Product entries consumed on a Job (job-scoped). `GET /byjob/:id` lists per job. |\n| `* /v1/versioninfo/*`               | yes (`authKey`) | Schema/build version records. Reads open to any `authKey`; mutations require a master key. `DELETE` is a hard destroy (no archive column on this table). |\n| `* /v1/purchaseordervendor/*`       | yes (`authKey`) | Vendors that POs are issued to. Direct company scoping via `povCompId`. Standard CRUD + `bycompany`. |\n| `* /v1/purchaseorderheader/*`       | yes (`authKey`) | Purchase orders. Vendor-scoped — auth resolves via `pohPovId → vendor.povCompId`. `GET /byvendor/:id` lists POs for a vendor, newest first. |\n| `* /v1/purchaseorderline/*`         | yes (`authKey`) | PO line items. Header-scoped via `polpoh → header → vendor → company`. `GET /byheader/:id` lists line items on a PO. |\n| `* /v1/inventorytransaction/*`      | yes (`authKey`) | Inventory movement log. Direct company scoping via `invtCompanyId`. `invtDirection` is `0` (inbound) or `1` (outbound). PATCH/DELETE exposed for surface parity; audit-grade deployments may want to disable them at the proxy. |\n| `POST /v1/\u003centity\u003e/bulk`            | yes (`authKey`) | Transactional all-or-nothing bulk-create on all 13 soft-deletable entities (customer, worker, billingtype, inventoryitem, inventorytransaction, purchaseordervendor, job, invoice, customerpayment, invoicejob, productentry, purchaseorderheader, purchaseorderline). Body: `{ \u003centityKey\u003e: [{...}, ...] }` capped at 500 entries. Same auth scoping as the single-create POST. If any entry fails to insert, the whole batch rolls back. |\n\n### Cross-cutting headers + behaviors\n\n- **`Idempotency-Key` (request header, optional)** — set on any POST to make it\n  idempotent for 24h. Identical retry replays the cached response with\n  `Idempotency-Replay: true`. Same key + different body → `409\n  { code: \"idempotency_key_reused\" }`. Printable ASCII, 1-255 chars.\n- **`Link` (response header, RFC 5988)** — every paginated list endpoint emits\n  `next` / `prev` / `first` / `last` URLs when applicable, so clients can walk\n  the result set without doing offset arithmetic.\n- **`X-Request-Id` (response header, also accepted on request)** — every\n  response carries a UUID correlator; the same id appears in every structured\n  log line for that request. Supply your own X-Request-Id on the way in to\n  propagate trace context from a reverse proxy / mesh.\n- **`RateLimit-*` (response headers, RFC standard)** — `RateLimit-Limit`,\n  `RateLimit-Remaining`, `RateLimit-Reset` on every /v1/* response.\n- **`Retry-After` (response header on 429, RFC 7231)** — seconds the\n  client should wait before retrying when the quota is exhausted.\n  Cross-origin browser JS can read this via the CORS expose-headers\n  list (it's not on the CORS safelist) so SDKs can honor the server's\n  back-off instead of falling back to a fixed-delay retry.\n- Browser JS reading any of the above on a cross-origin response works\n  out-of-the-box: the CORS layer's `Access-Control-Expose-Headers` covers them.\n\nEvery v1 request must include the API key in the `authKey` HTTP header.\nThe `/healthz` endpoint is intentionally unauthenticated so it can be\nhit by orchestrators (Docker `HEALTHCHECK`, Kubernetes liveness, uptime\nmonitors) without sharing a credential.\n\n### Secure-404 on cross-tenant access\n\nSingle-row GET / PATCH / DELETE endpoints return `404 Not Found` —\nnot `403 Forbidden` — when a non-master key references a row in a\ndifferent company's scope. The two outcomes look identical from the\nclient's side so a scoped caller can't probe sequential IDs to\nenumerate the size of another tenant's table by status code. Master\nkeys still see all rows. The same pattern applies across all 16\nsingle-row entity endpoints; the auth-scope check that produces it\nis the same `getCompanyId(...) !== row.\u003centity\u003eCompId` comparison\nthe controllers use for the 403 paths on other surfaces.\n\n![example image](https://github.com/CryptoJones/TimeTrackerAPI/blob/master/setup/postman_example.PNG?raw=true)\n\n*(authKey example using Postman)*\n\nA pre-built Postman collection covering every endpoint lives at\n[`setup/TimeTrackerAPI.postman_collection.json`](setup/TimeTrackerAPI.postman_collection.json).\nImport it via Postman → File → Import. Generated from the\n`/openapi.json` spec via [`openapi-to-postmanv2`](https://github.com/postmanlabs/openapi-to-postman),\nso it stays in sync with whatever the server actually serves —\nregenerate after API changes with:\n\n```bash\nnode -e \"require('fs').writeFileSync('/tmp/oas.json', JSON.stringify(require('./app/config/openapi.js')))\" \u0026\u0026 \\\n    npx --yes openapi-to-postmanv2 -s /tmp/oas.json -o setup/TimeTrackerAPI.postman_collection.json -p\n```\n\n---\n\n## Requirements\n\n- **Node.js 20+** (matches `engines.node` in `package.json`; CI tests against 20 and 22)\n- **PostgreSQL 14+**\n- A modern Linux distribution (any currently supported LTS — Ubuntu 22.04 / 24.04, Debian 12, RHEL 9, etc.)\n\n---\n\n## Quick start\n\n### Docker (one-line)\n\n```bash\ngit clone https://github.com/CryptoJones/TimeTrackerAPI.git\ncd TimeTrackerAPI\ncp .env.example .env\n# edit .env: at minimum set DB_PASSWORD\ndocker compose up --build\n```\n\nThis brings up postgres + the schema bootstrap (both SQL files) + the\nAPI on port 3000. `GET http://localhost:3000/healthz` should return\n`{\"status\":\"ok\",...}` within ~15 seconds.\n\n### Bare-metal\n\n```bash\n# 1. Clone\ngit clone https://github.com/CryptoJones/TimeTrackerAPI.git\ncd TimeTrackerAPI\n\n# 2. Install dependencies (no sudo)\nnpm install\n\n# 3. Provision the database\nsudo -u postgres psql \u003c\u003c'SQL'\nCREATE USER timetracker WITH PASSWORD 'change-me-strong-password';\nCREATE DATABASE timetracker WITH OWNER timetracker;\nSQL\nsudo -u postgres psql -d timetracker -f setup/TimeTracker.sql\nsudo -u postgres psql -d timetracker -f setup/TimeEntry.sql\n\n# Record the baseline as the migration starting point\nnpm run migrate\n\n# 4. Configure environment\ncp .env.example .env\n$EDITOR .env       # set DB_PASSWORD, optionally PORT / CORS_ORIGIN\n\n# 5. Run\nnpm start\n```\n\nThe server listens on `http://0.0.0.0:3000` by default. No root required.\n\n### Behind TLS (production)\n\nThe repo ships an opt-in TLS reverse-proxy layer using Caddy in\n`docker-compose.tls.yml`. Caddy handles automatic Let's Encrypt\nprovisioning + renewal, HTTP→HTTPS redirect, and HTTP/2 +\nHTTP/3 on :443. The api service is rebound off the host port so\nCaddy is the only thing the public reaches.\n\n```bash\n# In .env: set DB_PASSWORD, TLS_DOMAIN (your FQDN), and TLS_EMAIL.\nsudo docker compose \\\n    -f docker-compose.yml \\\n    -f docker-compose.tls.yml \\\n    up -d\n```\n\nFor local TLS testing set `TLS_DOMAIN=localhost`; Caddy uses its\nbuilt-in CA (self-signed) instead of ACME. Don't combine\n`docker-compose.tls.yml` with `docker-compose.override.yml` on a\npublic host — the override exposes Postgres on :5432.\n\n---\n\n## Testing\n\n```bash\nnpm test            # unit + API suite (mocks the DB; no infra needed)\nnpm run test:watch  # vitest watch mode\n```\n\nFor integration tests against a real Postgres — see\n[`tests/integration/README.md`](tests/integration/README.md).\nShort version:\n\n```bash\ncp .env.example .env   # set DB_PASSWORD\nsudo docker compose up -d postgres setup migrate\nDB_HOST=localhost DB_PORT=5432 DB_NAME=timetracker \\\n    DB_USER=timetracker DB_PASSWORD=$(grep ^DB_PASSWORD= .env | cut -d= -f2-) \\\n    npx vitest run tests/integration\nsudo docker compose down -v   # cleanup\n```\n\nThe committed `docker-compose.override.yml` exposes Postgres on\n`127.0.0.1:5432` for these host-side test runs; without it the\npostgres container is reachable only from other compose services.\n\n---\n\n## Environment variables\n\nAll configuration lives in environment variables (loaded from `.env`\nlocally via `dotenv`, or set directly by your process manager in\nproduction). See `.env.example` for the canonical reference.\n\n| Variable | Default | Purpose |\n|---|---|---|\n| `NODE_ENV` | (unset) | Set to `production` to enable strict startup checks (e.g. refuse to start when `DB_PASSWORD` is empty). |\n| `PORT` | `3000` | HTTP listen port. Use a non-privileged port (\u003e1024). `0` asks the kernel to pick a free port. |\n| `HOST` | `0.0.0.0` | Bind address. `127.0.0.1` for localhost-only. |\n| `CORS_ORIGIN` | (unset → disabled) | Comma-separated list of allowed origins, e.g. `https://app.example.com,https://admin.example.com`. Leave unset to disable cross-origin requests entirely. |\n| `TRUST_PROXY` | (unset → off) | When the API runs behind nginx/caddy/cloudflare, set to `true` (trust any proxy) or a hop count (`1`) so rate-limit and log IPs resolve to the real client. Never set when the API is directly internet-facing. |\n| `DB_HOST` | `localhost` | PostgreSQL host. |\n| `DB_PORT` | `5432` | PostgreSQL port. |\n| `DB_NAME` | `timetracker` | Database name. |\n| `DB_USER` | `timetracker` | Database user (must have access to the `dbo` schema). |\n| `DB_PASSWORD` | (empty) | Database password. **Required.** With `NODE_ENV=production` the server refuses to start on empty; in dev it warns and keeps going. |\n| `DB_LOG_QUERIES` | (unset → off) | Set to `1` to route Sequelize query logs through pino at debug level. Off by default so SQL + bound parameters (which include hashed `authKey` values) don't escape pino's redact paths. |\n| `LOG_LEVEL` | `info` | pino log level: `trace`/`debug`/`info`/`warn`/`error`/`fatal`/`silent`. |\n| `LOG_PRETTY` | (unset → JSON) | Set to `1` for human-readable colorized output via pino-pretty (dev only — leave unset in production so log shippers get the structured JSON they expect). |\n| `JSON_BODY_LIMIT` | `100kb` | Max request body size for `express.json()`. Accepts the same forms as the `bytes` module (e.g. `512kb`, `1mb`). Bumping is rarely needed — the largest schema-allowed body is well under the default. |\n| `HELMET_CSP` | (unset → off) | Set to `1` to re-enable helmet's Content-Security-Policy. Off by default because this is a JSON API and a misconfigured CSP would break Swagger UI at `/docs`. |\n| `RATE_LIMIT_MAX` | `100` | Per-key request budget for `/v1/*` in the rolling window. Set to `0` to disable rate limiting entirely (e.g. for load tests). |\n| `RATE_LIMIT_WINDOW_MS` | `900000` | Rolling rate-limit window in milliseconds (default 15 min). |\n| `METRICS_BEARER_TOKEN` | (unset → open) | When set, the Prometheus scrape at `/metrics` requires `Authorization: Bearer \u003ctoken\u003e`. Leave unset for a private-network deployment where the reverse proxy gates exposure. Constant-time compared. |\n| `PUBLIC_BASE_URL` | (unset) | Canonical `scheme://host` the API is publicly reachable at. Used as the base for absolute URLs in the RFC 5988 `Link` header (pagination next/prev/first/last). Pin in production so a client sending a malicious `Host` header can't get it echoed back. Unset = derive from `req.protocol` + `req.get('host')`. |\n| `SHUTDOWN_TIMEOUT_MS` | `25000` | How long the graceful-shutdown drain may run before the server force-exits with code 1 — set this under whatever your orchestrator's `SIGTERM`→`SIGKILL` window is (k8s default is 30s). |\n| `TLS_DOMAIN` | (unset) | Required for `docker-compose.tls.yml`. Domain Caddy provisions a Let's Encrypt cert for; `localhost` gives a self-signed cert via Caddy's internal CA. |\n| `TLS_EMAIL` | (unset) | Optional email forwarded to Let's Encrypt for cert-expiry notices. |\n\n`.env` is gitignored. Never commit a populated `.env`.\n\n---\n\n## Database migrations\n\nSchema changes after the baseline `setup/*.sql` files use\n`sequelize-cli` migrations under [`app/migrations/`](app/migrations/).\n\n```bash\nnpm run migrate          # apply all pending migrations\nnpm run migrate:undo     # roll back the most recent one\nnpm run migrate:status   # show what has and hasn't been applied\nnpm run migrate:generate add-new-column   # scaffold a new migration\n```\n\nSee [`app/migrations/README.md`](app/migrations/README.md) for the\nauthoring conventions (schema-qualify `dbo`, always provide a `down`,\nno model references in migration code, etc.).\n\n## Security notes\n\n- **Do not run this service as root.** The default port (`3000`) is\n  non-privileged on purpose. If you need to expose the API on `:443`,\n  put nginx, Caddy, or another reverse proxy in front and terminate TLS\n  there.\n- **Rotate the `authKey` regularly** and limit which users have access\n  to the `apikey` / `apimaster` tables.\n- **Use a strong, unique `DB_PASSWORD`** and restrict the database user\n  to the minimum required privileges — `SUPERUSER` is convenient for\n  local development but should not be the production grant.\n\n---\n\n## License\n\nApache License 2.0. See [LICENSE](LICENSE).\n\nProudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcryptojones%2Ftimetrackerapi","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcryptojones%2Ftimetrackerapi","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcryptojones%2Ftimetrackerapi/lists"}