[WSAPI-2] Bootstrap bun/TypeScript service scaffold + Docker stack #1

Merged
chris merged 12 commits from feature/WSAPI-2 into main 2026-09-08 12:44:02 -06:00
Owner

Ticket

WSAPI-2 Bootstrap bun/TypeScript service scaffold + Docker stack

Summary

Foundational scaffold for the Wealthsimple API service — no Wealthsimple logic yet, just a working, deployable bun/TypeScript service:

  • package.json/tsconfig.json with strict typing, 4-space indent, and lint/format tooling (ESLint flat config + Prettier), adapted from budget-tracker-api/irrigo-api conventions.
  • Shared lib/logger (pino, LOG_LEVEL-driven), lib/shutdown (idempotent SIGTERM/SIGINT teardown handler, injectable log/exit), and lib/port (PORT validated as a finite integer in [1, 65535] instead of silently binding a random port).
  • routes/health (GET /health → 200 { status: 'ok' }), a create-app Express factory (request logging, JSON body parsing, x-powered-by disabled), and a root index.ts entrypoint.
  • Dockerfile + docker-compose.yml: single api service, runs as the image's non-root bun user, joined only to the pre-existing external api-shared Docker network, no ports: key — no public exposure, reachable only from other containers on that network.
  • CLAUDE.md, docs/CI.md, and .forgejo/workflows/server.yml (checkout → setup-bun → cache → install → test → type-check → lint → format:check), mirroring budget-tracker-api's CI conventions for this standalone repo.
  • Smoke test (create-app/.test.ts) asserting GET /health returns 200 against the real assembled app, wired into bun test in CI.

Prerequisite infra (already done, outside this diff): the external api-shared Docker network was created on the host and budget-tracker-api/home-assistant-api were already joined to it and verified to resolve each other by hostname. This PR's docker-compose.yml just references api-shared as external: true — cross-container reachability (budget-tracker-api → wealthsimple-api:8080/health → 200) was verified live during implementation.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn

## Ticket [WSAPI-2](http://192.168.2.100:7123/home/browse/WSAPI-2/) Bootstrap bun/TypeScript service scaffold + Docker stack ## Summary Foundational scaffold for the Wealthsimple API service — no Wealthsimple logic yet, just a working, deployable bun/TypeScript service: - `package.json`/`tsconfig.json` with strict typing, 4-space indent, and lint/format tooling (ESLint flat config + Prettier), adapted from `budget-tracker-api`/`irrigo-api` conventions. - Shared `lib/logger` (pino, `LOG_LEVEL`-driven), `lib/shutdown` (idempotent SIGTERM/SIGINT teardown handler, injectable log/exit), and `lib/port` (PORT validated as a finite integer in `[1, 65535]` instead of silently binding a random port). - `routes/health` (`GET /health` → `200 { status: 'ok' }`), a `create-app` Express factory (request logging, JSON body parsing, `x-powered-by` disabled), and a root `index.ts` entrypoint. - `Dockerfile` + `docker-compose.yml`: single `api` service, runs as the image's non-root `bun` user, joined only to the pre-existing external `api-shared` Docker network, **no `ports:` key** — no public exposure, reachable only from other containers on that network. - `CLAUDE.md`, `docs/CI.md`, and `.forgejo/workflows/server.yml` (checkout → setup-bun → cache → install → test → type-check → lint → format:check), mirroring `budget-tracker-api`'s CI conventions for this standalone repo. - Smoke test (`create-app/.test.ts`) asserting `GET /health` returns 200 against the real assembled app, wired into `bun test` in CI. **Prerequisite infra (already done, outside this diff):** the external `api-shared` Docker network was created on the host and `budget-tracker-api`/`home-assistant-api` were already joined to it and verified to resolve each other by hostname. This PR's `docker-compose.yml` just references `api-shared` as `external: true` — cross-container reachability (`budget-tracker-api` → `wealthsimple-api:8080/health` → `200`) was verified live during implementation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Threads an optional `log` through ShutdownOptions/createShutdownHandler/
onShutdown so tests can silence shutdown logging instead of it leaking real
pino JSON (including a stack trace from the throwing-teardown case) into
test output. Also tightens `Teardown` from `() => unknown | Promise<unknown>`
(which collapses to `() => unknown`) to `() => void | Promise<void>`, and
corrects a stale comment claiming this repo's compose stack runs
NODE_ENV=production (it defaults to development; the TTY check is what
actually keeps container output as JSON).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
- lib/logger/.test.ts: force the transport off when capturing log lines —
  buildOptions() sets a transport in any interactive TTY shell, and pino
  silently ignores `write` once a transport is set, so the pid/hostname
  assertion previously only passed by accident of the shell it ran in.
- tsconfig.json: add an explicit `include` (TS's default glob skips
  dotfiles, so every `.test.ts` was silently excluded from `type-check`)
  and `types: ["bun"]`; fixes the noUncheckedIndexedAccess fallout in
  lib/logger/.test.ts and the verbatimModuleSyntax violation in
  routes/health/.test.ts now that tsc actually sees these files.
- Extract PORT parsing into lib/port (resolvePort), validated as a finite
  integer in [1, 65535] so an empty or malformed PORT fails loud instead of
  silently binding a random port while the compose healthcheck keeps
  hitting 8080; index.ts now uses it. Covered by lib/port/.test.ts.
- create-app/.test.ts: repointed off the health-route duplicate (already
  covered by routes/health/.test.ts) onto create-app's actual
  responsibilities — express.json() parsing and the injected logger
  actually being wired into pino-http.
- lib/shutdown/.test.ts: added coverage for onShutdown itself (previously
  only createShutdownHandler was tested), exercising real SIGTERM/SIGINT
  registration with injected exit/log.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
CI ran `bunx tsc --noEmit` directly, contradicting CLAUDE.md's own "never
invoke tsc directly" rule. Also adds a "Running locally" section covering
.env setup, bun run dev / docker compose up, and that api-shared is an
external network created outside this repo — without it a fresh clone's
first `docker compose up` fails with an unexplained "network not found".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
- .dockerignore: exclude .git, node_modules, and .env* (re-allowing
  .env.example) from the build context — closes off a real future leak,
  since this service's whole purpose is eventually holding real
  Wealthsimple credentials in .env, and `COPY . /app` has no other guard.
- Dockerfile: chown the app directory to the image's built-in `bun` user
  (uid/gid 1000, matching the typical single-user host) and run as that
  user instead of root, since docker-compose.yml bind-mounts the host
  checkout read-write.
- create-app/index.ts: app.disable('x-powered-by') so the framework
  fingerprint isn't advertised on every response.
- docker-compose.yml: healthcheck now targets ${PORT:-8080} instead of a
  hardcoded 8080, so it can't silently drift from an overridden PORT.

Verified via `docker compose build && up`: container runs as uid 1000
(bun), healthy, x-powered-by absent, and cross-container reachability from
budget-tracker-api over api-shared still returns 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
- tsconfig.json: drop `jsx` and `allowJs` — dead in a backend-only service
  with no .tsx/.js source (allowJs was also why eslint.config.js got pulled
  into the tsc project service).
- eslint.config.js: drop the no-op `**/*.js.map` and `bun.lock` ignore
  entries (ESLint never processes either), and drop `**/*.js` so
  eslint.config.js itself is actually linted instead of silently excluded
  — verified via `bunx eslint eslint.config.js`. Also rewords the
  leading-underscore rule comment, which claimed the codebase "already
  uses" a convention (an error handler's `_next`) that doesn't exist yet
  in this scaffold-only repo, to forward-looking phrasing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
- eslint.config.js: drop the stale `ignores: ['**/.test.ts', ...]` left
  over from before tsconfig.json's `include` fix — type-aware rules now
  actually cover test files, surfacing two real require-await violations
  in lib/shutdown/.test.ts (unnecessary `async` on synchronous teardowns),
  fixed alongside.
- lib/shutdown/index.ts: `Teardown` over-corrected to `() => void |
  Promise<void>`, which (unlike a bare `void`-returning function type)
  does NOT tolerate a non-void return through the union — rejecting a
  normal teardown like `() => server.close()` (returns `this`, not void).
  Changed to `() => unknown`: non-dead, and the return value is discarded
  by createShutdownHandler regardless.
- docker-compose.yml: reverted the healthcheck's `${PORT:-8080}` Compose
  interpolation (resolved from the host/shell env at config-parse time)
  back to reading `process.env.PORT` inside the container at runtime,
  matching how the app itself resolves PORT — avoids a healthcheck/server
  port mismatch if an operator has PORT exported in their own shell.
  .env.example's PORT comment updated to match.
- docs/CI.md: workflow table still read `bunx tsc --noEmit`; updated to
  `bun run type-check` to match the actual workflow step (and CLAUDE.md's
  own "never invoke tsc directly" rule).
- CLAUDE.md: architecture map was missing lib/port/, added earlier this
  round.
- create-app/.test.ts: added an `x-powered-by` header assertion — the
  disable had no test.
- lib/shutdown/.test.ts: onShutdown test cleanup now removes only the
  listeners each test itself registered (via a before/after snapshot),
  instead of unconditionally calling `process.removeAllListeners`, which
  could strip a handler registered by something else.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
[WSAPI-2] Fix inaccurate comment on Teardown type.
All checks were successful
server / check (pull_request) Successful in 12s
8ceba9faa6
The comment claimed `void | Promise<void>` "collapses to () => unknown too"
— it doesn't collapse to anything; it's a distinct union that simply lacks
the void-returning-function leniency TypeScript gives a bare `void` return
type. Teardown = () => unknown is unchanged and still correct.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
chris merged commit 2869be2ba9 into main 2026-09-08 12:44:02 -06:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
chris/wealthsimple-api!1
No description provided.