diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 0000000..ea25142 --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,4 @@ +{"id":"diachron-2vh","title":"Add unit testing to golang programs","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:31:41.281891462-06:00","created_by":"mw","updated_at":"2026-01-03T12:31:41.281891462-06:00"} +{"id":"diachron-64w","title":"Add unit testing to express backend","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:31:30.439206099-06:00","created_by":"mw","updated_at":"2026-01-03T12:31:30.439206099-06:00"} +{"id":"diachron-fzd","title":"Add generic 'user' functionality","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:35:53.73213604-06:00","created_by":"mw","updated_at":"2026-01-03T12:35:53.73213604-06:00"} +{"id":"diachron-ngx","title":"Teach the master and/or build process to send messages with notify-send when builds fail or succeed. Ideally this will be fairly generic.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T14:10:11.773218844-06:00","created_by":"mw","updated_at":"2026-01-03T14:10:11.773218844-06:00"} diff --git a/.claude/instructions.md b/.claude/instructions.md new file mode 100644 index 0000000..90e5b49 --- /dev/null +++ b/.claude/instructions.md @@ -0,0 +1,2 @@ +When asked "what's next?" or during downtime, check TODO.md and suggest items to work on. + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56d71dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +**/node_modules +framework/downloads +framework/binaries +framework/.nodejs +framework/.nodejs-config diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5968670 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,122 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. + +## Project Overview + +Diachron is an opinionated TypeScript/Node.js web framework with a Go-based +master process. Key design principles: +- No development/production distinction - single mode of operation everywhere +- Everything loggable and inspectable for debuggability +- Minimal magic, explicit behavior +- PostgreSQL-only (no database abstraction) +- Inspired by "Taking PHP Seriously" essay + +## Commands + +### General + +**Install dependencies:** +```bash +./sync.sh +``` + +**Run an app:** +```bash +./master +``` + + + +### Development + +**Check shell scripts (shellcheck + shfmt) (eventually go fmt and biome or similar):** +```bash +./check.sh +``` + +**Format TypeScript code:** +```bash +cd express && ../cmd pnpm biome check --write . +``` + +**Build Go master process:** +```bash +cd master && go build +``` + +### Operational + +(to be written) + +## Architecture + +### Components + +- **express/** - TypeScript/Express.js backend application +- **master/** - Go-based master process for file watching and process management +- **framework/** - Managed binaries (Node.js, pnpm), command wrappers, and + framework-specific library code +- **monitor/** - Go file watcher that triggers rebuilds (experimental) + +### Master Process (Go) + +Responsibilities: +- Watch TypeScript source for changes and trigger rebuilds +- Manage worker processes +- Proxy web requests to backend workers +- Behaves identically in all environments (no dev/prod distinction) + +### Express App Structure + +- `app.ts` - Main Express application setup with route matching +- `routes.ts` - Route definitions +- `handlers.ts` - Route handlers +- `services.ts` - Service layer (database, logging, misc) +- `types.ts` - TypeScript type definitions (Route, Call, Handler, Result, Method) + +### Framework Command System + +Commands flow through: `./cmd` → `framework/cmd.d/*` → `framework/shims/*` → managed binaries in `framework/binaries/` + +This ensures consistent tooling versions across the team without system-wide installations. + +## Tech Stack + +- TypeScript 5.9+ / Node.js 22.15 +- Express.js 5.1 +- Go 1.23.3+ (master process) +- pnpm 10.12.4 (package manager) +- Zod (runtime validation) +- Nunjucks (templating) +- @vercel/ncc (bundling) + +## Platform Requirements + +Linux x86_64 only (currently). Requires: +- Modern libc for Go binaries +- docker compose (for full stack) +- fd, shellcheck, shfmt (for development) + +## Current Status + +Early stage - most implementations are stubs: +- Database service is placeholder +- Logging functions marked WRITEME +- No test framework configured yet + +# meta + +## formatting and sorting + +- When a typescript file exports symbols, they should be listed in order + +## guidelines for this document + +- Try to keep lines below 80 characters in length, especially prose. But if + embedded code or literals are longer, that's fine. +- Use formatting such as bold or italics sparingly +- In general, we treat this document like source code insofar as it should be + both human-readable and machine-readable +- Keep this meta section at the end of the file. diff --git a/README.md b/README.md index f392fc6..3795006 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,62 @@ diachron +## Introduction + +Is your answer to some of these questions "yes"? If so, you might like +diachron. (When it comes to that dev/test/prod one, hear us out first, ok?) + +- Do you want to share a lot of backend and frontend code? + +- Are you tired of your web stack breaking when you blink too hard? + +- Have you read [Taking PHP + Seriously](https://slack.engineering/taking-php-seriously/) and wish you had + something similar for Typescript? + +- Do you think that ORMs are not all that? Do you wish you had first class + unmediated access to your database? And do you think that database + agnosticism is overrated? + +- Do you think dev/testing/prod distinctions are a bad idea? (Hear us out on + this one.) + +- Have you ever lost hours getting everyone on your team to have the exact + same environment, yet you're not willing to take the plunge and use a tool + like [nix](https://nixos.org)? + +- Are you frustrated by unclear documentation? Is ramping up a frequent + problem? + +- Do you want a framework that's not only easy to write but also easy to get + inside and debug? + +- Have you been bogged down with details that are not relevant to the problems + you're trying to solve, the features you're trying to implement, the bugs + you're trying to fix? We're talking authentication, authorization, XSS, + https, nested paths, all that stuff. + +## Getting started + +Different situations require different getting started docs. + +- [How to create a new project](docs/new-project.md) +- [How to work on an existing project](docs/existing-project.md) + ## Requirements -To run diachron, you currently need the following requirements: +To run diachron, you currently need to have a Linux box running x86_64 with a +new enough libc to run golang binaries. Support for other platforms will come +eventually. -- docker compose -- deno +To run a more complete system, you also need to have docker compose installed. -## Development requirements +### Development requirements -To hack on diachron, you need the following: +To hack on diachron itself, you need the following: -- docker compose -- deno +- bash +- docker and docker compose +- [fd](https://github.com/sharkdp/fd) - golang, version 1.23.6 or greater - - +- shellcheck +- shfmt diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..eb1bcba --- /dev/null +++ b/TODO.md @@ -0,0 +1,177 @@ +## high importance + +- [ ] Add unit tests all over the place. + - ⚠️ Huge task - needs breakdown before starting + + +- [ ] migrations, seeding, fixtures + +```sql +CREATE SCHEMA fw; +CREATE TABLE fw.users (...); +CREATE TABLE fw.groups (...); +``` + +```sql +CREATE TABLE app.user_profiles (...); +CREATE TABLE app.customer_metadata (...); +``` + +- [ ] flesh out `mgmt` and `develop` (does not exist yet) + +4.1 What belongs in develop + +- Create migrations +- Squash migrations +- Reset DB +- Roll back migrations +- Seed large test datasets +- Run tests +- Snapshot / restore local DB state (!!!) + +`develop` fails if APP_ENV (or whatever) is `production`. Or maybe even +`testing`. + +- [ ] Add default user table(s) to database. + + +- [ ] Add authentication + - [ ] password + - [ ] third party? + + +- [ ] Add middleware concept + +- [ ] Add authorization + - for specific routes / resources / etc + +- [ ] Add basic text views + Partially done; see the /time route. But we need to figure out where to + store templates, static files, etc. + +- [ ] fix process management: if you control-c `master` process sometimes it + leaves around `master-bin`, `logger-bin`, and `diachron:nnnn` processes. + Huge problem. + +## medium importance + +- [ ] Add a log viewer + - with queries + - convert to logfmt and is there a viewer UI we could pull in and use + instead? + +- [ ] add nested routes. Note that this might be easy to do without actually + changing the logic in express/routes.ts. A function that takes an array + of routes and maps over them rewriting them. Maybe. + + - [ ] related: add something to do with default templates and stuff... I + think we can make handlers a lot shorter to write, sometimes not even + necessary at all, with some sane defaults and an easy to use override + mechanism + +- [ ] time library + +- [ ] fill in the rest of express/http-codes.ts + +- [ ] fill out express/content-types.ts + + +- [ ] identify redundant "old skool" and ajax routes, factor out their + commonalities, etc. + +- [ ] figure out and add logging to disk + +- [ ] I don't really feel close to satisfied with template location / + rendering / etc. Rethink and rework. + +- [ ] Add email verification (this is partially done already) + +- [ ] Reading .env files and dealing with the environment should be immune to + the extent possible from idiotic errors + +- [ ] Update check script: + - [x] shellcheck on shell scripts + - [x] `go vet` on go files + - [x] `golangci-lint` on go files + - [x] Run `go fmt` on all .go files + - [ ] Eventually, run unit tests + +- [ ] write docs + - upgrade docs + - starting docs + - taking over docs + - reference + - internals + +- [ ] make migration creation default to something like yyyy-mm-dd_ssss (are + 9999 migrations in a day enough?) + +- [ ] clean up `cmd` and `mgmt`: do the right thing with their commonalities + and make very plain which is which for what. Consider additional + commands. Maybe `develop` for specific development tasks, + `operate` for operational tasks, and we keep `cmd` for project-specific + commands. Something like that. + + +## low importance + +- [ ] add a prometheus-style `/metrics` endpoint to master +- [ ] create a metrics server analogous to the logging server + - accept various stats from the workers (TBD) + +- [ ] move `master-bin` into a subdir like `master/cmd` or whatever is + idiomatic for golang programs; adapt `master` wrapper shell script + accordingly + +- [ ] flesh out the `sync.sh` script + - [ ] update framework-managed node + - [ ] update framework-managed pnpm + - [ ] update pnpm-managed deps + - [ ] rebuild golang programs + +- [ ] If the number of workers is large, then there is a long lapse between + when you change a file and when the server responds + - One solution: start and stop workers serially: stop one, restart it with new + code; repeat + - Slow start them: only start a few at first + +- [ ] in express/user.ts: FIXME: set createdAt and updatedAt to start of epoch + + + + +## finished + +- [x] Reimplement fixup.sh + - [x] run shfmt on all shell scripts (and the files they `source`) + - [x] Run `go fmt` on all .go files + - [x] Run ~~prettier~~ biome on all .ts files and maybe others + +- [x] Adapt master program so that it reads configuration from command line + args instead of from environment variables + - Should have sane defaults + - Adding new arguments should be easy and obvious + +- [x] Add wrapper script to run master program (so that various assumptions related + to relative paths are safer) + +- [x] Add logging service + - New golang program, in the same directory as master + - Intended to be started by master + - Listens to a port specified command line arg + - Accepts POSTed (or possibly PUT) json messages, currently in a + to-be-defined format. We will work on this format later. + - Keeps the most recent N messages in memory. N can be a fairly large + number; let's start by assuming 1 million. + +- [x] Log to logging service from the express backend + - Fill out types and functions in `express/logging.ts` + +- [x] Add first cut at database access. Remember that ORMs are not all that! + +- [x] Create initial docker-compose.yml file for local development + - include most recent stable postgres + - include beanstalkd + - include memcached + - include redis + - include mailpit diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..23597f6 --- /dev/null +++ b/check.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$DIR" + +# Keep exclusions sorted. And list them here. +# +# - SC2002 is useless use of cat +# +exclusions="SC2002" + +source "$DIR/framework/versions" + +if [[ $# -ne 0 ]]; then + shellcheck --exclude="$exclusions" "$@" + exit $? +fi + +shell_scripts="$(fd .sh | xargs)" + +# The files we need to check all either end in .sh or else they're the files +# in framework/cmd.d and framework/shims. -x instructs shellcheck to also +# check `source`d files. + +shellcheck -x --exclude="$exclusions" "$DIR/cmd" "$DIR"/framework/cmd.d/* "$DIR"/framework/shims/* "$shell_scripts" + +pushd "$DIR/master" +docker run --rm -v $(pwd):/app -w /app golangci/golangci-lint:$golangci_lint golangci-lint run +popd diff --git a/cmd b/cmd new file mode 100755 index 0000000..552a972 --- /dev/null +++ b/cmd @@ -0,0 +1,27 @@ +#!/bin/bash + +# This file belongs to the framework. You are not expected to modify it. + +# Managed binary runner - runs framework-managed binaries like node, pnpm, tsx +# Usage: ./cmd [args...] + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ $# -lt 1 ]; then + echo "Usage: ./cmd [args...]" + echo "" + echo "Available commands:" + for cmd in "$DIR"/framework/cmd.d/*; do + if [ -x "$cmd" ]; then + basename "$cmd" + fi + done + exit 1 +fi + +subcmd="$1" +shift + +exec "$DIR"/framework/cmd.d/"$subcmd" "$@" diff --git a/develop b/develop new file mode 100755 index 0000000..5592ec1 --- /dev/null +++ b/develop @@ -0,0 +1,27 @@ +#!/bin/bash + +# This file belongs to the framework. You are not expected to modify it. + +# Development command runner - parallel to ./mgmt for development tasks +# Usage: ./develop [args...] + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ $# -lt 1 ]; then + echo "Usage: ./develop [args...]" + echo "" + echo "Available commands:" + for cmd in "$DIR"/framework/develop.d/*; do + if [ -x "$cmd" ]; then + basename "$cmd" + fi + done + exit 1 +fi + +subcmd="$1" +shift + +exec "$DIR"/framework/develop.d/"$subcmd" "$@" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3258787 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + postgres: + image: postgres:17 + ports: + - "5432:5432" + environment: + POSTGRES_USER: diachron + POSTGRES_PASSWORD: diachron + POSTGRES_DB: diachron + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7 + ports: + - "6379:6379" + + memcached: + image: memcached:1.6 + ports: + - "11211:11211" + + beanstalkd: + image: schickling/beanstalkd + ports: + - "11300:11300" + + mailpit: + image: axllent/mailpit + ports: + - "1025:1025" # SMTP + - "8025:8025" # Web UI + +volumes: + postgres_data: diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..273a65c --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,125 @@ +# The Three Types of Commands + +This framework deliberately separates *how* you interact with the system into three distinct command types. The split is not cosmetic; it encodes safety, intent, and operational assumptions directly into the tooling so that mistakes are harder to make under stress. + +The guiding idea: **production should feel boring and safe; exploration should feel powerful and a little dangerous; the application itself should not care how it is being operated.** + +--- + +## 1. Application Commands (`app`) + +**What they are** +Commands defined *by the application itself*, for its own domain needs. They are not part of the framework, even though they are built on top of it. + +The framework provides structure and affordances; the application supplies meaning. + +**Core properties** + +* Express domain behavior, not infrastructure concerns +* Safe by definition +* Deterministic and repeatable +* No environment‑dependent semantics +* Identical behavior in dev, staging, and production + +**Examples** + +* Handling HTTP requests +* Rendering templates +* Running background jobs / queues +* Sending emails triggered by application logic + +**Non‑goals** + +* No schema changes +* No data backfills +* No destructive behavior +* No operational or lifecycle management + +**Rule of thumb** +If removing the framework would require rewriting *how* it runs but not *what* it does, the command belongs here. + +--- + +## 2. Management Commands (`mgmt`) + +**What they are** +Operational, *production‑safe* commands used to evolve and maintain a live system. + +These commands assume real data exists and must not be casually destroyed. + +**Core properties** + +* Forward‑only +* Idempotent or safely repeatable +* Designed to run in production +* Explicit, auditable intent + +**Examples** + +* Applying migrations +* Running seeders that assert invariant data +* Reindexing or rebuilding derived data +* Rotating keys, recalculating counters + +**Design constraints** + +* No implicit rollbacks +* No hidden destructive actions +* Fail fast if assumptions are violated + +**Rule of thumb** +If you would run it at 3am while tired and worried, it must live here. + +--- + +## 3. Development Commands (`develop`) + +**What they are** +Sharp, *unsafe by design* tools meant exclusively for local development and experimentation. + +These commands optimize for speed, learning, and iteration — not safety. + +**Core properties** + +* Destructive operations allowed +* May reset or mutate large amounts of data +* Assume a clean or disposable environment +* Explicitly gated in production + +**Examples** + +* Dropping and recreating databases +* Rolling migrations backward +* Loading fixtures or scenarios +* Generating fake or randomized data + +**Safety model** + +* Hard to run in production +* Requires explicit opt‑in if ever enabled +* Clear, noisy warnings when invoked + +**Rule of thumb** +If it would be irresponsible to run against real user data, it belongs here. + +--- + +## Why This Split Matters + +Many frameworks blur these concerns, leading to: + +* Fearful production operations +* Overpowered dev tools leaking into prod +* Environment‑specific behavior and bugs + +By naming and enforcing these three command types: + +* Intent is visible at the CLI level +* Safety properties are architectural, not cultural +* Developers can move fast *without* normalizing risk + +--- + +## One‑Sentence Summary + +> **App commands run the system, mgmt commands evolve it safely, and develop commands let you break things on purpose — but only where it’s allowed.** diff --git a/docs/concentric-circles.md b/docs/concentric-circles.md new file mode 100644 index 0000000..668ed3a --- /dev/null +++ b/docs/concentric-circles.md @@ -0,0 +1,37 @@ +Let's consider a bullseye with the following concentric circles: + +- Ring 0: small, simple systems + - Single jurisdiction + - Email + password + - A few roles + - Naïve or soft deletion + - Minimal audit needs + +- Ring 1: grown-up systems + - Long-lived data + - Changing requirements + - Shared accounts + - GDPR-style erasure/anonymization + - Some cross-border concerns + - Historical data must remain usable + - “Oops, we should have thought about that” moments + +- Ring 2: heavy compliance + - Formal audit trails + - Legal hold + - Non-repudiation + - Regulatory reporting + - Strong identity guarantees + - Jurisdiction-aware data partitioning + +- Ring 3: banking / defense / healthcare at scale + - Cryptographic auditability + - Append-only ledgers + - Explicit legal models + - Independent compliance teams + - Lawyers embedded in engineeRing + +diachron is designed to be suitable for Rings 0 and 1. Occasionally we may +look over the fence into Ring 2, but it's not what we've principally designed +for. Please take this framing into account when evaluating diachron for +greenfield projects. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..9c558e3 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1 @@ +. diff --git a/docs/freedom-hacking-and-responsibility.md b/docs/freedom-hacking-and-responsibility.md new file mode 100644 index 0000000..1f2c568 --- /dev/null +++ b/docs/freedom-hacking-and-responsibility.md @@ -0,0 +1,142 @@ +# Freedom, Hacking, and Responsibility + +This framework is **free and open source software**. + +That fact is not incidental. It is a deliberate ethical, practical, and technical choice. + +This document explains how freedom to modify coexists with strong guidance about *how the framework is meant to be used* — without contradiction, and without apology. + +--- + +## The short version + +* This is free software. You are free to modify it. +* The framework has documented invariants for good reasons. +* You are encouraged to explore, question, and patch. +* You are discouraged from casually undermining guarantees you still expect to rely on. +* Clarity beats enforcement. + +Freedom with understanding beats both lock-in and chaos. + + +--- + +## Your Freedom + +You are free to: + +* study the source code +* run the software for any purpose +* modify it in any way +* fork it +* redistribute it, with or without changes +* submit patches, extensions, or experiments + +…subject only to the terms of the license. + +These freedoms are foundational. They are not granted reluctantly, and they are not symbolic. They exist so that: + +* you can understand what your software is really doing +* you are not trapped by vendor control +* the system can outlive its original authors + +--- + +## Freedom Is Not the Same as Endorsement + +While you are free to change anything, **not all changes are equally wise**. + +Some parts of the framework are carefully constrained because they encode: + +* security assumptions +* lifecycle invariants +* hard-won lessons from real systems under stress + +You are free to violate these constraints in your own fork. + +But the framework’s documentation will often say things like: + +* “do not modify this” +* “application code must not depend on this” +* “this table or class is framework-owned” + +These statements are **technical guidance**, not legal restrictions. + +They exist to answer the question: + +> *If you want this system to remain upgradeable, predictable, and boring — what should you leave alone?* + +--- + +## The Intended Social Contract + +The framework makes a clear offer: + +* We expose our internals so you can learn. +* We provide explicit extension points so you can adapt. +* We document invariants so you don’t have to rediscover them the hard way. + +In return, we ask that: + +* application code respects documented boundaries +* extensions use explicit seams rather than hidden hooks +* patches that change invariants are proposed consciously, not accidentally + +Nothing here is enforced by technical locks. + +It is enforced — insofar as it is enforced at all — by clarity and shared expectations. + +--- + +## Hacking Is Welcome + +Exploration is not just allowed; it is encouraged. + +Good reasons to hack on the framework include: + +* understanding how it works +* evaluating whether its constraints make sense +* adapting it to unfamiliar environments +* testing alternative designs +* discovering better abstractions + +Fork it. Instrument it. Break it. Learn from it. + +Many of the framework’s constraints exist *because* someone once ignored them and paid the price. + +--- + +## Patches, Not Patches-in-Place + +If you discover a problem or a better design: + +* patches are welcome +* discussions are welcome +* disagreements are welcome + +What is discouraged is **quietly patching around framework invariants inside application code**. + +That approach: + +* obscures intent +* creates one-off local truths +* makes systems harder to reason about + +If the framework is wrong, it should be corrected *at the framework level*, or consciously forked. + +--- + +## Why This Is Not a Contradiction + +Strong opinions and free software are not enemies. + +Freedom means you can change the software. + +Responsibility means understanding what you are changing, and why. + +A system that pretends every modification is equally safe is dishonest. + +A system that hides its internals to prevent modification is hostile. + +This framework aims for neither. + diff --git a/docs/groups-and-roles.md b/docs/groups-and-roles.md new file mode 100644 index 0000000..c6a694e --- /dev/null +++ b/docs/groups-and-roles.md @@ -0,0 +1,27 @@ +- Role: a named bundle of responsibilities (editor, admin, member) + +- Group: a scope or context (org, team, project, publication) + +- Permission / Capability (capability preferred in code): a boolean fact about + allowed behavior + + +## tips + +- In the database, capabilities are boolean values. Their names should be + verb-subject. Don't include `can` and definitely do not include `cannot`. + + ✔️ `edit_post` + ❌ `cannot_remove_comment` + +- The capabilities table is deliberately flat. If you need to group them, use + `.` as a delimiter and sort and filter accordingly in queries and in your + UI. + ✔️ `blog.edit_post` + ✔️ `blog.moderate_comment` + or + ✔️ `blog.post.edit` + ✔️ `blog.post.delete` + ✔️ `blog.comment.moderate` + ✔️ `blog.comment.edit` + are all fine. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..934502c --- /dev/null +++ b/docs/index.md @@ -0,0 +1,17 @@ +misc notes for now. of course this needs to be written up for real. + + +## execution context + +The execution context represents facts such as the runtime directory, the +operating system, hardware, and filesystem layout, distinct from environment +variables or request-scoped context. + +## philosophy + +- TODO-DESIGN.md +- concentric-circles.md +- nomenclature.md +- mutability.md +- commands.md +- groups-and-roles.md diff --git a/docs/migrations-and-seeders-and-database-table-ownership.md b/docs/migrations-and-seeders-and-database-table-ownership.md new file mode 100644 index 0000000..a073eda --- /dev/null +++ b/docs/migrations-and-seeders-and-database-table-ownership.md @@ -0,0 +1,34 @@ +Some database tables are owned by diachron and some are owned by the +application. + +This also applies to seeders: some are owned by diachron and some by the +application. + +The database's structure is managed by migrations written in SQL. + +Each migration gets its own file. These files' names should match +`yyyy-mm-dd_ss-description.sql`, eg `2026-01-01_01-users.sql`. + +Files are sorted lexicographically by name and applied in order. + +Note: in the future we may relax or modify the restriction on migration file +names, but they'll continue to be applied in lexicographical order. + +## framework and application migrations + +Migrations owned by the framework are kept in a separate directory from those +owned by applications. Pending framework migrations, if any, are applied +before pending application migrations, if any. + +diachron will go to some lengths to ensure that framework migrations do not +break applications. + +## no downward migrations + +diachron does not provide them. "The only way out is through." + +When developing locally, you can use the command `develop reset-db`. **NEVER +USE THIS IN PRODUCTION!** Always be sure that you can "get back to where you +were". Being careful when creating migrations and seeders can help, but +dumping and restoring known-good copies of the database can also take you a +long way. diff --git a/docs/mutability.md b/docs/mutability.md new file mode 100644 index 0000000..3555291 --- /dev/null +++ b/docs/mutability.md @@ -0,0 +1 @@ +Describe and define what is expected to be mutable and what is not. diff --git a/docs/new-project.md b/docs/new-project.md new file mode 100644 index 0000000..ff10bf4 --- /dev/null +++ b/docs/new-project.md @@ -0,0 +1,84 @@ +If any of the steps here don't work or are unclear in any way, it is +probably a bug and we want to fix it! + +## how to create a new diachron project + +1. Create an empty directory for your project. This directory can be inside of a + git repository but it doesn't have to be. + +2. Download the sync program and put it in the empty directory created in the + previous step. There is a sync program for every version of diachron. + You'll usually want to use the most recent stable version. [FIXME: explain + why you'd want to use something else.] And you'll want the version for + the operating system and hardware you're using. + +3. Run the `setup` program. This program is [FIXME: will be] written in + [go](https://go.dev), so as long as you have downloaded the right file, it + ought to work. + + This will create several files and directories. It will also download a number + of binaries and put them in different places in some of the directories + that are created. + +4. At this point, you should have a usable, if not very useful, diachron + application. To see what it does, run the program `develop run`; it will run a + simple web application on localhost:3000. To make changes, have a look at + the files `src/app.ts` and `src/routes.ts`. + +## where do we go from here? + +Now that we have a very simple project, we need to attend to a few other +important matters. + +### version control + +(These instructions assume you're using git. If you're using a different +version control system then you will need to make allowances. In particular, +you should convert the `.gitignore` file to whatever your version control +system uses.) + +You should add the whole directory to git and commit it. There will be two +`.gitignore` files, one in the root, and one in the `framework/` directory. + +The root `.gitignore` created for you will be a good starting point, but you +can make changes to it as you see fit. However, you should not ever modify +`framework/.gitignore`. More on this in the next section. + +### working with diachron + +There are four commands to know about: + +- `sync` is used to install all dependencies, including the ones you specify + as well as the ones that diachron provides +- `develop` is used to run "development-related" tasks. Run `develop help` to + get an overview of what it can do. +- `operate` is used to run "operations-related" tasks. Run `operate help` to + get an overview of what it can do. +- `cmd` runs diachron-managed commands, such as `pnpm`. When working on a + diachron project, you should always use these diachron-managed commands + instead of whatever else you may have available. + +### what files belong to your project, what files belong to the framework + +In a new diachron project, there are some files and directories that are +"owned" by the framework and others that are "owned" by the programmer. + +In particular, you own everything in the directory `src/`. You own +`README.md` and `package.json` and `pnpm-lock.yaml`. You own any other files +our directories you create. + +Everything else _belongs to the framework_ and you are not expected to change +it except when upgrading. + +This is just an overview. It is exhaustively documented in +[ownership.md](ownership.md). + +### updates + + +### when the docs sound a bit authoritarian... + +Finally, remember that diachron's license allows you to do whatever you like +with it, with very few limitations. This includes making changes to files +about which, in the documentation, we say "you must not change" or "you are +not expected to change." diff --git a/docs/nomenclature.md b/docs/nomenclature.md new file mode 100644 index 0000000..1b9425e --- /dev/null +++ b/docs/nomenclature.md @@ -0,0 +1,15 @@ +We use `Call` and `Result` for our own types that wrap `Request` and +`Response`. + +This hopefully will make things less confusing and avoid problems with shadowing. + +## meta + +- We use _algorithmic complexity_ for performance discussions, when + things like Big-O come up, etc + +- We use _conceptual complexity_ for design and architecture + +- We use _cognitive load_ when talking about developer experience + +- We use _operational burden_ when talking about production reality diff --git a/docs/ownership.md b/docs/ownership.md new file mode 100644 index 0000000..5eded9c --- /dev/null +++ b/docs/ownership.md @@ -0,0 +1,219 @@ +# Framework vs Application Ownership + +This document defines **ownership boundaries** between the framework and application code. These boundaries are intentional and non-negotiable: they exist to preserve upgradeability, predictability, and developer sanity under stress. + +Ownership answers a simple question: + +> **Who is allowed to change this, and under what rules?** + +The framework draws a hard line between *framework‑owned* and *application‑owned* concerns, while still encouraging extension through explicit, visible mechanisms. + +--- + +## Core Principle + +The framework is not a library of suggestions. It is a **runtime with invariants**. + +Application code: + +* **uses** the framework +* **extends** it through defined seams +* **never mutates or overrides its invariants** + +Framework code: + +* guarantees stable behavior +* owns critical lifecycle and security concerns +* must remain internally consistent across versions + +Breaking this boundary creates systems that work *until they don’t*, usually during upgrades or emergencies. + +--- + +## Database Ownership + +### Framework‑Owned Tables + +Certain database tables are **owned and managed exclusively by the framework**. + +Examples (illustrative, not exhaustive): + +* authentication primitives +* session or token state +* internal capability/permission metadata +* migration bookkeeping +* framework feature flags or invariants + +#### Rules + +Application code **must not**: + +* modify schema +* add columns +* delete rows +* update rows directly +* rely on undocumented columns or behaviors + +Application code **may**: + +* read via documented framework APIs +* reference stable identifiers explicitly exposed by the framework + +Think of these tables as **private internal state** — even though they live in your database. + +> If the framework needs you to interact with this data, it will expose an API for it. + +#### Rationale + +These tables: + +* encode security or correctness invariants +* may change structure across framework versions +* must remain globally coherent + +Treating them as app‑owned data tightly couples your app to framework internals and blocks safe upgrades. + +--- + +### Application‑Owned Tables + +All domain data belongs to the application. + +Examples: + +* users (as domain actors, not auth primitives) +* posts, orders, comments, invoices +* business‑specific joins and projections +* denormalized or performance‑oriented tables + +#### Rules + +Application code: + +* owns schema design +* owns migrations +* owns constraints and indexes +* may evolve these tables freely + +The framework: + +* never mutates application tables implicitly +* interacts only through explicit queries or contracts + +#### Integration Pattern + +Where framework concepts must relate to app data: + +* use **foreign keys to framework‑exposed identifiers**, or +* introduce **explicit join tables** owned by the application + +No hidden coupling, no magic backfills. + +--- + +## Code Ownership + +### Framework‑Owned Code + +Some classes, constants, and modules are **framework‑owned**. + +These include: + +* core request/response abstractions +* auth and user primitives +* capability/permission evaluation logic +* lifecycle hooks +* low‑level utilities relied on by the framework itself + +#### Rules + +Application code **must not**: + +* modify framework source +* monkey‑patch or override internals +* rely on undocumented behavior +* change constant values or internal defaults + +Framework code is treated as **read‑only** from the app’s perspective. + +--- + +### Extension Is Encouraged (But Explicit) + +Ownership does **not** mean rigidity. + +The framework is designed to be extended via **intentional seams**, such as: + +* subclassing +* composition +* adapters +* delegation +* configuration objects +* explicit registration APIs + +#### Preferred Patterns + +* **Subclass when behavior is stable and conceptual** +* **Compose when behavior is contextual or optional** +* **Delegate when authority should remain with the framework** + +What matters is that extension is: + +* visible in code +* locally understandable +* reversible + +No spooky action at a distance. + +--- + +## What the App Owns Completely + +The application fully owns: + +* domain models and data shapes +* SQL queries and result parsing +* business rules +* authorization policy *inputs* (not the engine) +* rendering decisions +* feature flags specific to the app +* performance trade‑offs + +The framework does not attempt to infer intent from your domain. + +--- + +## What the Framework Guarantees + +In return for respecting ownership boundaries, the framework guarantees: + +* stable semantics across versions +* forward‑only migrations for its own tables +* explicit deprecations +* no silent behavior changes +* identical runtime behavior in dev and prod + +The framework may evolve internally — **but never by reaching into your app’s data or code**. + +--- + +## A Useful Mental Model + +* Framework‑owned things are **constitutional law** +* Application‑owned things are **legislation** + +You can write any laws you want — but you don’t amend the constitution inline. + +If you need a new power, the framework should expose it deliberately. + +--- + +## Summary + +* Ownership is about **who is allowed to change what** +* Framework‑owned tables and code are read‑only to the app +* Application‑owned tables and code are sovereign +* Extension is encouraged, mutation is not +* Explicit seams beat clever hacks + +Respecting these boundaries keeps systems boring — and boring systems survive stress. diff --git a/docs/upgrades.md b/docs/upgrades.md new file mode 100644 index 0000000..9c558e3 --- /dev/null +++ b/docs/upgrades.md @@ -0,0 +1 @@ +. diff --git a/express/.gitignore b/express/.gitignore new file mode 100644 index 0000000..3bdc5ea --- /dev/null +++ b/express/.gitignore @@ -0,0 +1,2 @@ +out/ +dist/ diff --git a/express/app.ts b/express/app.ts new file mode 100644 index 0000000..eb044aa --- /dev/null +++ b/express/app.ts @@ -0,0 +1,173 @@ +import express, { + type Request as ExpressRequest, + type Response as ExpressResponse, +} from "express"; +import { match } from "path-to-regexp"; +import { Session } from "./auth"; +import { cli } from "./cli"; +import { contentTypes } from "./content-types"; +import { runWithContext } from "./context"; +import { core } from "./core"; +import { httpCodes } from "./http-codes"; +import { request } from "./request"; +import { routes } from "./routes"; + +// import { URLPattern } from 'node:url'; +import { + AuthenticationRequired, + AuthorizationDenied, + type Call, + type InternalHandler, + isRedirect, + type Method, + massageMethod, + methodParser, + type ProcessedRoute, + type Result, + type Route, +} from "./types"; + +const app = express(); + +// Parse request bodies +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +core.logging.log({ source: "logging", text: ["1"] }); +const processedRoutes: { [K in Method]: ProcessedRoute[] } = { + GET: [], + POST: [], + PUT: [], + PATCH: [], + DELETE: [], +}; + +function _isPromise(value: T | Promise): value is Promise { + return typeof (value as any)?.then === "function"; +} + +routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => { + // const pattern /*: URLPattern */ = new URLPattern({ pathname: route.path }); + const matcher = match>(route.path); + const methodList = route.methods; + + const handler: InternalHandler = async ( + expressRequest: ExpressRequest, + ): Promise => { + const method = massageMethod(expressRequest.method); + + console.log("method", method); + + if (!methodList.includes(method)) { + // XXX: Worth asserting this? + } + + console.log("request.originalUrl", expressRequest.originalUrl); + + // Authenticate the request + const auth = await request.auth.validateRequest(expressRequest); + + const req: Call = { + pattern: route.path, + path: expressRequest.originalUrl, + method, + parameters: { one: 1, two: 2 }, + request: expressRequest, + user: auth.user, + session: new Session(auth.session, auth.user), + }; + + try { + const retval = await runWithContext({ user: auth.user }, () => + route.handler(req), + ); + return retval; + } catch (error) { + // Handle authentication errors + if (error instanceof AuthenticationRequired) { + return { + code: httpCodes.clientErrors.Unauthorized, + contentType: contentTypes.application.json, + result: JSON.stringify({ + error: "Authentication required", + }), + }; + } + if (error instanceof AuthorizationDenied) { + return { + code: httpCodes.clientErrors.Forbidden, + contentType: contentTypes.application.json, + result: JSON.stringify({ error: "Access denied" }), + }; + } + throw error; + } + }; + + for (const [_idx, method] of methodList.entries()) { + const pr: ProcessedRoute = { matcher, method, handler }; + + processedRoutes[method].push(pr); + } +}); + +async function handler( + req: ExpressRequest, + _res: ExpressResponse, +): Promise { + const method = await methodParser.parseAsync(req.method); + + const byMethod = processedRoutes[method]; + console.log( + "DEBUG: req.path =", + JSON.stringify(req.path), + "method =", + method, + ); + for (const [_idx, pr] of byMethod.entries()) { + const match = pr.matcher(req.path); + console.log("DEBUG: trying pattern, match result =", match); + if (match) { + console.log("match", match); + const resp = await pr.handler(req); + + return resp; + } + } + + const retval: Result = { + code: httpCodes.clientErrors.NotFound, + contentType: contentTypes.text.plain, + result: "not found!", + }; + + return retval; +} + +app.use(async (req: ExpressRequest, res: ExpressResponse) => { + const result0 = await handler(req, res); + + const code = result0.code.code; + const result = result0.result; + + console.log(result); + + // Set any cookies from the result + if (result0.cookies) { + for (const cookie of result0.cookies) { + res.cookie(cookie.name, cookie.value, cookie.options ?? {}); + } + } + + if (isRedirect(result0)) { + res.redirect(code, result0.redirect); + } else { + res.status(code).send(result); + } +}); + +process.title = `diachron:${cli.listen.port}`; + +app.listen(cli.listen.port, cli.listen.host, () => { + console.log(`Listening on ${cli.listen.host}:${cli.listen.port}`); +}); diff --git a/express/auth/index.ts b/express/auth/index.ts new file mode 100644 index 0000000..cd23528 --- /dev/null +++ b/express/auth/index.ts @@ -0,0 +1,20 @@ +// index.ts +// +// Barrel export for auth module. +// +// NOTE: authRoutes is NOT exported here to avoid circular dependency: +// services.ts → auth/index.ts → auth/routes.ts → services.ts +// Import authRoutes directly from "./auth/routes" instead. + +export { hashPassword, verifyPassword } from "./password"; +export { type AuthResult, AuthService } from "./service"; +export { type AuthStore, InMemoryAuthStore } from "./store"; +export { generateToken, hashToken, SESSION_COOKIE_NAME } from "./token"; +export { + type AuthMethod, + Session, + type SessionData, + type TokenId, + type TokenType, + tokenLifetimes, +} from "./types"; diff --git a/express/auth/password.ts b/express/auth/password.ts new file mode 100644 index 0000000..d1e9c51 --- /dev/null +++ b/express/auth/password.ts @@ -0,0 +1,70 @@ +// password.ts +// +// Password hashing using Node.js scrypt (no external dependencies). +// Format: $scrypt$N$r$p$salt$hash (all base64) + +import { + randomBytes, + type ScryptOptions, + scrypt, + timingSafeEqual, +} from "node:crypto"; + +// Configuration +const SALT_LENGTH = 32; +const KEY_LENGTH = 64; +const SCRYPT_PARAMS: ScryptOptions = { + N: 16384, // CPU/memory cost parameter (2^14) + r: 8, // Block size + p: 1, // Parallelization +}; + +// Promisified scrypt with options support +function scryptAsync( + password: string, + salt: Buffer, + keylen: number, + options: ScryptOptions, +): Promise { + return new Promise((resolve, reject) => { + scrypt(password, salt, keylen, options, (err, derivedKey) => { + if (err) { + reject(err); + } else { + resolve(derivedKey); + } + }); + }); +} + +async function hashPassword(password: string): Promise { + const salt = randomBytes(SALT_LENGTH); + const hash = await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS); + + const { N, r, p } = SCRYPT_PARAMS; + return `$scrypt$${N}$${r}$${p}$${salt.toString("base64")}$${hash.toString("base64")}`; +} + +async function verifyPassword( + password: string, + stored: string, +): Promise { + const parts = stored.split("$"); + if (parts[1] !== "scrypt" || parts.length !== 7) { + throw new Error("Invalid password hash format"); + } + + const [, , nStr, rStr, pStr, saltB64, hashB64] = parts; + const salt = Buffer.from(saltB64, "base64"); + const storedHash = Buffer.from(hashB64, "base64"); + + const computedHash = await scryptAsync(password, salt, storedHash.length, { + N: parseInt(nStr, 10), + r: parseInt(rStr, 10), + p: parseInt(pStr, 10), + }); + + return timingSafeEqual(storedHash, computedHash); +} + +export { hashPassword, verifyPassword }; diff --git a/express/auth/routes.ts b/express/auth/routes.ts new file mode 100644 index 0000000..b1787a2 --- /dev/null +++ b/express/auth/routes.ts @@ -0,0 +1,231 @@ +// routes.ts +// +// Authentication route handlers. + +import { z } from "zod"; +import { contentTypes } from "../content-types"; +import { httpCodes } from "../http-codes"; +import { request } from "../request"; +import type { Call, Result, Route } from "../types"; +import { + forgotPasswordInputParser, + loginInputParser, + registerInputParser, + resetPasswordInputParser, +} from "./types"; + +// Helper for JSON responses +const jsonResponse = ( + code: (typeof httpCodes.success)[keyof typeof httpCodes.success], + data: object, +): Result => ({ + code, + contentType: contentTypes.application.json, + result: JSON.stringify(data), +}); + +const errorResponse = ( + code: (typeof httpCodes.clientErrors)[keyof typeof httpCodes.clientErrors], + error: string, +): Result => ({ + code, + contentType: contentTypes.application.json, + result: JSON.stringify({ error }), +}); + +// POST /auth/login +const loginHandler = async (call: Call): Promise => { + try { + const body = call.request.body; + const { email, password } = loginInputParser.parse(body); + + const result = await request.auth.login(email, password, "cookie", { + userAgent: call.request.get("User-Agent"), + ipAddress: call.request.ip, + }); + + if (!result.success) { + return errorResponse( + httpCodes.clientErrors.Unauthorized, + result.error, + ); + } + + return jsonResponse(httpCodes.success.OK, { + token: result.token, + user: { + id: result.user.id, + email: result.user.email, + displayName: result.user.displayName, + }, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Invalid input", + ); + } + throw error; + } +}; + +// POST /auth/logout +const logoutHandler = async (call: Call): Promise => { + const token = request.auth.extractToken(call.request); + if (token) { + await request.auth.logout(token); + } + + return jsonResponse(httpCodes.success.OK, { message: "Logged out" }); +}; + +// POST /auth/register +const registerHandler = async (call: Call): Promise => { + try { + const body = call.request.body; + const { email, password, displayName } = + registerInputParser.parse(body); + + const result = await request.auth.register( + email, + password, + displayName, + ); + + if (!result.success) { + return errorResponse(httpCodes.clientErrors.Conflict, result.error); + } + + // TODO: Send verification email with result.verificationToken + // For now, log it for development + console.log( + `[AUTH] Verification token for ${email}: ${result.verificationToken}`, + ); + + return jsonResponse(httpCodes.success.Created, { + message: + "Registration successful. Please check your email to verify your account.", + user: { + id: result.user.id, + email: result.user.email, + }, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Invalid input", + ); + } + throw error; + } +}; + +// POST /auth/forgot-password +const forgotPasswordHandler = async (call: Call): Promise => { + try { + const body = call.request.body; + const { email } = forgotPasswordInputParser.parse(body); + + const result = await request.auth.createPasswordResetToken(email); + + // Always return success (don't reveal if email exists) + if (result) { + // TODO: Send password reset email + console.log( + `[AUTH] Password reset token for ${email}: ${result.token}`, + ); + } + + return jsonResponse(httpCodes.success.OK, { + message: + "If an account exists with that email, a password reset link has been sent.", + }); + } catch (error) { + if (error instanceof z.ZodError) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Invalid input", + ); + } + throw error; + } +}; + +// POST /auth/reset-password +const resetPasswordHandler = async (call: Call): Promise => { + try { + const body = call.request.body; + const { token, password } = resetPasswordInputParser.parse(body); + + const result = await request.auth.resetPassword(token, password); + + if (!result.success) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + result.error, + ); + } + + return jsonResponse(httpCodes.success.OK, { + message: + "Password has been reset. You can now log in with your new password.", + }); + } catch (error) { + if (error instanceof z.ZodError) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Invalid input", + ); + } + throw error; + } +}; + +// GET /auth/verify-email?token=xxx +const verifyEmailHandler = async (call: Call): Promise => { + const url = new URL(call.path, "http://localhost"); + const token = url.searchParams.get("token"); + + if (!token) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Missing token", + ); + } + + const result = await request.auth.verifyEmail(token); + + if (!result.success) { + return errorResponse(httpCodes.clientErrors.BadRequest, result.error); + } + + return jsonResponse(httpCodes.success.OK, { + message: "Email verified successfully. You can now log in.", + }); +}; + +// Export routes +const authRoutes: Route[] = [ + { path: "/auth/login", methods: ["POST"], handler: loginHandler }, + { path: "/auth/logout", methods: ["POST"], handler: logoutHandler }, + { path: "/auth/register", methods: ["POST"], handler: registerHandler }, + { + path: "/auth/forgot-password", + methods: ["POST"], + handler: forgotPasswordHandler, + }, + { + path: "/auth/reset-password", + methods: ["POST"], + handler: resetPasswordHandler, + }, + { + path: "/auth/verify-email", + methods: ["GET"], + handler: verifyEmailHandler, + }, +]; + +export { authRoutes }; diff --git a/express/auth/service.ts b/express/auth/service.ts new file mode 100644 index 0000000..677f1d7 --- /dev/null +++ b/express/auth/service.ts @@ -0,0 +1,262 @@ +// service.ts +// +// Core authentication service providing login, logout, registration, +// password reset, and email verification. + +import type { Request as ExpressRequest } from "express"; +import { + type AnonymousUser, + anonymousUser, + type User, + type UserId, +} from "../user"; +import { hashPassword, verifyPassword } from "./password"; +import type { AuthStore } from "./store"; +import { + hashToken, + parseAuthorizationHeader, + SESSION_COOKIE_NAME, +} from "./token"; +import { type SessionData, type TokenId, tokenLifetimes } from "./types"; + +type LoginResult = + | { success: true; token: string; user: User } + | { success: false; error: string }; + +type RegisterResult = + | { success: true; user: User; verificationToken: string } + | { success: false; error: string }; + +type SimpleResult = { success: true } | { success: false; error: string }; + +// Result of validating a request/token - contains both user and session +export type AuthResult = + | { authenticated: true; user: User; session: SessionData } + | { authenticated: false; user: AnonymousUser; session: null }; + +export class AuthService { + constructor(private store: AuthStore) {} + + // === Login === + + async login( + email: string, + password: string, + authMethod: "cookie" | "bearer", + metadata?: { userAgent?: string; ipAddress?: string }, + ): Promise { + const user = await this.store.getUserByEmail(email); + if (!user) { + return { success: false, error: "Invalid credentials" }; + } + + if (!user.isActive()) { + return { success: false, error: "Account is not active" }; + } + + const passwordHash = await this.store.getUserPasswordHash(user.id); + if (!passwordHash) { + return { success: false, error: "Invalid credentials" }; + } + + const valid = await verifyPassword(password, passwordHash); + if (!valid) { + return { success: false, error: "Invalid credentials" }; + } + + const { token } = await this.store.createSession({ + userId: user.id, + tokenType: "session", + authMethod, + expiresAt: new Date(Date.now() + tokenLifetimes.session), + userAgent: metadata?.userAgent, + ipAddress: metadata?.ipAddress, + }); + + return { success: true, token, user }; + } + + // === Session Validation === + + async validateRequest(request: ExpressRequest): Promise { + // Try cookie first (for web requests) + let token = this.extractCookieToken(request); + + // Fall back to Authorization header (for API requests) + if (!token) { + token = parseAuthorizationHeader(request.get("Authorization")); + } + + if (!token) { + return { authenticated: false, user: anonymousUser, session: null }; + } + + return this.validateToken(token); + } + + async validateToken(token: string): Promise { + const tokenId = hashToken(token) as TokenId; + const session = await this.store.getSession(tokenId); + + if (!session) { + return { authenticated: false, user: anonymousUser, session: null }; + } + + if (session.tokenType !== "session") { + return { authenticated: false, user: anonymousUser, session: null }; + } + + const user = await this.store.getUserById(session.userId as UserId); + if (!user || !user.isActive()) { + return { authenticated: false, user: anonymousUser, session: null }; + } + + // Update last used (fire and forget) + this.store.updateLastUsed(tokenId).catch(() => {}); + + return { authenticated: true, user, session }; + } + + private extractCookieToken(request: ExpressRequest): string | null { + const cookies = request.get("Cookie"); + if (!cookies) { + return null; + } + + for (const cookie of cookies.split(";")) { + const [name, ...valueParts] = cookie.trim().split("="); + if (name === SESSION_COOKIE_NAME) { + return valueParts.join("="); // Handle = in token value + } + } + return null; + } + + // === Logout === + + async logout(token: string): Promise { + const tokenId = hashToken(token) as TokenId; + await this.store.deleteSession(tokenId); + } + + async logoutAllSessions(userId: UserId): Promise { + return this.store.deleteUserSessions(userId); + } + + // === Registration === + + async register( + email: string, + password: string, + displayName?: string, + ): Promise { + const existing = await this.store.getUserByEmail(email); + if (existing) { + return { success: false, error: "Email already registered" }; + } + + const passwordHash = await hashPassword(password); + const user = await this.store.createUser({ + email, + passwordHash, + displayName, + }); + + // Create email verification token + const { token: verificationToken } = await this.store.createSession({ + userId: user.id, + tokenType: "email_verify", + authMethod: "bearer", + expiresAt: new Date(Date.now() + tokenLifetimes.email_verify), + }); + + return { success: true, user, verificationToken }; + } + + // === Email Verification === + + async verifyEmail(token: string): Promise { + const tokenId = hashToken(token) as TokenId; + const session = await this.store.getSession(tokenId); + + if (!session || session.tokenType !== "email_verify") { + return { + success: false, + error: "Invalid or expired verification token", + }; + } + + if (session.isUsed) { + return { success: false, error: "Token already used" }; + } + + await this.store.updateUserEmailVerified(session.userId as UserId); + await this.store.deleteSession(tokenId); + + return { success: true }; + } + + // === Password Reset === + + async createPasswordResetToken( + email: string, + ): Promise<{ token: string } | null> { + const user = await this.store.getUserByEmail(email); + if (!user) { + // Don't reveal whether email exists + return null; + } + + const { token } = await this.store.createSession({ + userId: user.id, + tokenType: "password_reset", + authMethod: "bearer", + expiresAt: new Date(Date.now() + tokenLifetimes.password_reset), + }); + + return { token }; + } + + async resetPassword( + token: string, + newPassword: string, + ): Promise { + const tokenId = hashToken(token) as TokenId; + const session = await this.store.getSession(tokenId); + + if (!session || session.tokenType !== "password_reset") { + return { success: false, error: "Invalid or expired reset token" }; + } + + if (session.isUsed) { + return { success: false, error: "Token already used" }; + } + + const passwordHash = await hashPassword(newPassword); + await this.store.setUserPassword( + session.userId as UserId, + passwordHash, + ); + + // Invalidate all existing sessions (security: password changed) + await this.store.deleteUserSessions(session.userId as UserId); + + // Delete the reset token + await this.store.deleteSession(tokenId); + + return { success: true }; + } + + // === Token Extraction Helper (for routes) === + + extractToken(request: ExpressRequest): string | null { + // Try Authorization header first + const token = parseAuthorizationHeader(request.get("Authorization")); + if (token) { + return token; + } + + // Try cookie + return this.extractCookieToken(request); + } +} diff --git a/express/auth/store.ts b/express/auth/store.ts new file mode 100644 index 0000000..b9dbccd --- /dev/null +++ b/express/auth/store.ts @@ -0,0 +1,164 @@ +// store.ts +// +// Authentication storage interface and in-memory implementation. +// The interface allows easy migration to PostgreSQL later. + +import { AuthenticatedUser, type User, type UserId } from "../user"; +import { generateToken, hashToken } from "./token"; +import type { AuthMethod, SessionData, TokenId, TokenType } from "./types"; + +// Data for creating a new session (tokenId generated internally) +export type CreateSessionData = { + userId: string; + tokenType: TokenType; + authMethod: AuthMethod; + expiresAt: Date; + userAgent?: string; + ipAddress?: string; +}; + +// Data for creating a new user +export type CreateUserData = { + email: string; + passwordHash: string; + displayName?: string; +}; + +// Abstract interface for auth storage - implement for PostgreSQL later +export interface AuthStore { + // Session operations + createSession( + data: CreateSessionData, + ): Promise<{ token: string; session: SessionData }>; + getSession(tokenId: TokenId): Promise; + updateLastUsed(tokenId: TokenId): Promise; + deleteSession(tokenId: TokenId): Promise; + deleteUserSessions(userId: UserId): Promise; + + // User operations + getUserByEmail(email: string): Promise; + getUserById(userId: UserId): Promise; + createUser(data: CreateUserData): Promise; + getUserPasswordHash(userId: UserId): Promise; + setUserPassword(userId: UserId, passwordHash: string): Promise; + updateUserEmailVerified(userId: UserId): Promise; +} + +// In-memory implementation for development +export class InMemoryAuthStore implements AuthStore { + private sessions: Map = new Map(); + private users: Map = new Map(); + private usersByEmail: Map = new Map(); + private passwordHashes: Map = new Map(); + private emailVerified: Map = new Map(); + + async createSession( + data: CreateSessionData, + ): Promise<{ token: string; session: SessionData }> { + const token = generateToken(); + const tokenId = hashToken(token); + + const session: SessionData = { + tokenId, + userId: data.userId, + tokenType: data.tokenType, + authMethod: data.authMethod, + createdAt: new Date(), + expiresAt: data.expiresAt, + userAgent: data.userAgent, + ipAddress: data.ipAddress, + }; + + this.sessions.set(tokenId, session); + return { token, session }; + } + + async getSession(tokenId: TokenId): Promise { + const session = this.sessions.get(tokenId); + if (!session) { + return null; + } + + // Check expiration + if (new Date() > session.expiresAt) { + this.sessions.delete(tokenId); + return null; + } + + return session; + } + + async updateLastUsed(tokenId: TokenId): Promise { + const session = this.sessions.get(tokenId); + if (session) { + session.lastUsedAt = new Date(); + } + } + + async deleteSession(tokenId: TokenId): Promise { + this.sessions.delete(tokenId); + } + + async deleteUserSessions(userId: UserId): Promise { + let count = 0; + for (const [tokenId, session] of this.sessions) { + if (session.userId === userId) { + this.sessions.delete(tokenId); + count++; + } + } + return count; + } + + async getUserByEmail(email: string): Promise { + const userId = this.usersByEmail.get(email.toLowerCase()); + if (!userId) { + return null; + } + return this.users.get(userId) ?? null; + } + + async getUserById(userId: UserId): Promise { + return this.users.get(userId) ?? null; + } + + async createUser(data: CreateUserData): Promise { + const user = AuthenticatedUser.create(data.email, { + displayName: data.displayName, + status: "pending", // Pending until email verified + }); + + this.users.set(user.id, user); + this.usersByEmail.set(data.email.toLowerCase(), user.id); + this.passwordHashes.set(user.id, data.passwordHash); + this.emailVerified.set(user.id, false); + + return user; + } + + async getUserPasswordHash(userId: UserId): Promise { + return this.passwordHashes.get(userId) ?? null; + } + + async setUserPassword(userId: UserId, passwordHash: string): Promise { + this.passwordHashes.set(userId, passwordHash); + } + + async updateUserEmailVerified(userId: UserId): Promise { + this.emailVerified.set(userId, true); + + // Update user status to active + const user = this.users.get(userId); + if (user) { + // Create new user with active status + const updatedUser = AuthenticatedUser.create(user.email, { + id: user.id, + displayName: user.displayName, + status: "active", + roles: [...user.roles], + permissions: [...user.permissions], + }); + this.users.set(userId, updatedUser); + } + } +} diff --git a/express/auth/token.ts b/express/auth/token.ts new file mode 100644 index 0000000..15a203e --- /dev/null +++ b/express/auth/token.ts @@ -0,0 +1,42 @@ +// token.ts +// +// Token generation and hashing utilities for authentication. +// Raw tokens are never stored - only their SHA-256 hashes. + +import { createHash, randomBytes } from "node:crypto"; + +const TOKEN_BYTES = 32; // 256 bits of entropy + +// Generate a cryptographically secure random token +function generateToken(): string { + return randomBytes(TOKEN_BYTES).toString("base64url"); +} + +// Hash token for storage (never store raw tokens) +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +// Parse token from Authorization header +function parseAuthorizationHeader(header: string | undefined): string | null { + if (!header) { + return null; + } + + const parts = header.split(" "); + if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") { + return null; + } + + return parts[1]; +} + +// Cookie name for web sessions +const SESSION_COOKIE_NAME = "diachron_session"; + +export { + generateToken, + hashToken, + parseAuthorizationHeader, + SESSION_COOKIE_NAME, +}; diff --git a/express/auth/types.ts b/express/auth/types.ts new file mode 100644 index 0000000..997c4aa --- /dev/null +++ b/express/auth/types.ts @@ -0,0 +1,96 @@ +// types.ts +// +// Authentication types and Zod schemas. + +import { z } from "zod"; + +// Branded type for token IDs (the hash, not the raw token) +export type TokenId = string & { readonly __brand: "TokenId" }; + +// Token types for different purposes +export const tokenTypeParser = z.enum([ + "session", + "password_reset", + "email_verify", +]); +export type TokenType = z.infer; + +// Authentication method - how the token was delivered +export const authMethodParser = z.enum(["cookie", "bearer"]); +export type AuthMethod = z.infer; + +// Session data schema - what gets stored +export const sessionDataParser = z.object({ + tokenId: z.string().min(1), + userId: z.string().min(1), + tokenType: tokenTypeParser, + authMethod: authMethodParser, + createdAt: z.coerce.date(), + expiresAt: z.coerce.date(), + lastUsedAt: z.coerce.date().optional(), + userAgent: z.string().optional(), + ipAddress: z.string().optional(), + isUsed: z.boolean().optional(), // For one-time tokens +}); + +export type SessionData = z.infer; + +// Input validation schemas for auth endpoints +export const loginInputParser = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +export const registerInputParser = z.object({ + email: z.string().email(), + password: z.string().min(8), + displayName: z.string().optional(), +}); + +export const forgotPasswordInputParser = z.object({ + email: z.string().email(), +}); + +export const resetPasswordInputParser = z.object({ + token: z.string().min(1), + password: z.string().min(8), +}); + +// Token lifetimes in milliseconds +export const tokenLifetimes: Record = { + session: 30 * 24 * 60 * 60 * 1000, // 30 days + password_reset: 1 * 60 * 60 * 1000, // 1 hour + email_verify: 24 * 60 * 60 * 1000, // 24 hours +}; + +// Import here to avoid circular dependency at module load time +import type { User } from "../user"; + +// Session wrapper class providing a consistent interface for handlers. +// Always present on Call (never null), but may represent an anonymous session. +export class Session { + constructor( + private readonly data: SessionData | null, + private readonly user: User, + ) {} + + getUser(): User { + return this.user; + } + + getData(): SessionData | null { + return this.data; + } + + isAuthenticated(): boolean { + return !this.user.isAnonymous(); + } + + get tokenId(): string | undefined { + return this.data?.tokenId; + } + + get userId(): string | undefined { + return this.data?.userId; + } +} diff --git a/express/basic/login.ts b/express/basic/login.ts new file mode 100644 index 0000000..dd9ac74 --- /dev/null +++ b/express/basic/login.ts @@ -0,0 +1,62 @@ +import { SESSION_COOKIE_NAME } from "../auth/token"; +import { tokenLifetimes } from "../auth/types"; +import { request } from "../request"; +import { html, redirect, render } from "../request/util"; +import type { Call, Result, Route } from "../types"; + +const loginHandler = async (call: Call): Promise => { + if (call.method === "GET") { + const c = await render("basic/login", {}); + return html(c); + } + + // POST - handle login + const { email, password } = call.request.body; + + if (!email || !password) { + const c = await render("basic/login", { + error: "Email and password are required", + email, + }); + return html(c); + } + + const result = await request.auth.login(email, password, "cookie", { + userAgent: call.request.get("User-Agent"), + ipAddress: call.request.ip, + }); + + if (!result.success) { + const c = await render("basic/login", { + error: result.error, + email, + }); + return html(c); + } + + // Success - set cookie and redirect to home + const redirectResult = redirect("/"); + redirectResult.cookies = [ + { + name: SESSION_COOKIE_NAME, + value: result.token, + options: { + httpOnly: true, + secure: false, // Set to true in production with HTTPS + sameSite: "lax", + maxAge: tokenLifetimes.session, + path: "/", + }, + }, + ]; + + return redirectResult; +}; + +const loginRoute: Route = { + path: "/login", + methods: ["GET", "POST"], + handler: loginHandler, +}; + +export { loginRoute }; diff --git a/express/basic/logout.ts b/express/basic/logout.ts new file mode 100644 index 0000000..fcae1d8 --- /dev/null +++ b/express/basic/logout.ts @@ -0,0 +1,38 @@ +import { SESSION_COOKIE_NAME } from "../auth/token"; +import { request } from "../request"; +import { redirect } from "../request/util"; +import type { Call, Result, Route } from "../types"; + +const logoutHandler = async (call: Call): Promise => { + // Extract token from cookie and invalidate the session + const token = request.auth.extractToken(call.request); + if (token) { + await request.auth.logout(token); + } + + // Clear the cookie and redirect to login + const redirectResult = redirect("/login"); + redirectResult.cookies = [ + { + name: SESSION_COOKIE_NAME, + value: "", + options: { + httpOnly: true, + secure: false, + sameSite: "lax", + maxAge: 0, + path: "/", + }, + }, + ]; + + return redirectResult; +}; + +const logoutRoute: Route = { + path: "/logout", + methods: ["GET", "POST"], + handler: logoutHandler, +}; + +export { logoutRoute }; diff --git a/express/basic/routes.ts b/express/basic/routes.ts new file mode 100644 index 0000000..e042c43 --- /dev/null +++ b/express/basic/routes.ts @@ -0,0 +1,43 @@ +import { DateTime } from "ts-luxon"; +import { request } from "../request"; +import { html, render } from "../request/util"; +import type { Call, Result, Route } from "../types"; +import { loginRoute } from "./login"; +import { logoutRoute } from "./logout"; + +const routes: Record = { + hello: { + path: "/hello", + methods: ["GET"], + handler: async (_call: Call): Promise => { + const now = DateTime.now(); + const c = await render("basic/hello", { now }); + + return html(c); + }, + }, + home: { + path: "/", + methods: ["GET"], + handler: async (_call: Call): Promise => { + const _auth = request.auth; + const me = request.session.getUser(); + + const email = me.toString(); + const showLogin = me.isAnonymous(); + const showLogout = !me.isAnonymous(); + + const c = await render("basic/home", { + email, + showLogin, + showLogout, + }); + + return html(c); + }, + }, + login: loginRoute, + logout: logoutRoute, +}; + +export { routes }; diff --git a/express/biome.jsonc b/express/biome.jsonc new file mode 100644 index 0000000..43ce613 --- /dev/null +++ b/express/biome.jsonc @@ -0,0 +1,39 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4 + }, + + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "useBlockStatements": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/express/build.sh b/express/build.sh new file mode 100755 index 0000000..038f05f --- /dev/null +++ b/express/build.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$DIR" + +../cmd pnpm ncc build ./app.ts -o dist diff --git a/express/check.sh b/express/check.sh new file mode 100755 index 0000000..0dece7f --- /dev/null +++ b/express/check.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +check_dir="$DIR" + +out_dir="$check_dir/out" + +source "$check_dir"/../framework/shims/common +source "$check_dir"/../framework/shims/node.common + +$ROOT/cmd pnpm tsc --outDir "$out_dir" diff --git a/express/cli.ts b/express/cli.ts new file mode 100644 index 0000000..b9dbed4 --- /dev/null +++ b/express/cli.ts @@ -0,0 +1,55 @@ +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ + options: { + listen: { + type: "string", + short: "l", + }, + "log-address": { + type: "string", + default: "8085", + }, + }, + strict: true, + allowPositionals: false, +}); + +function parseListenAddress(listen: string | undefined): { + host: string; + port: number; +} { + const defaultHost = "127.0.0.1"; + const defaultPort = 3500; + + if (!listen) { + return { host: defaultHost, port: defaultPort }; + } + + const lastColon = listen.lastIndexOf(":"); + if (lastColon === -1) { + // Just a port number + const port = parseInt(listen, 10); + if (Number.isNaN(port)) { + throw new Error(`Invalid listen address: ${listen}`); + } + return { host: defaultHost, port }; + } + + const host = listen.slice(0, lastColon); + const port = parseInt(listen.slice(lastColon + 1), 10); + + if (Number.isNaN(port)) { + throw new Error(`Invalid port in listen address: ${listen}`); + } + + return { host, port }; +} + +const listenAddress = parseListenAddress(values.listen); +const logAddress = parseListenAddress(values["log-address"]); + +export const cli = { + listen: listenAddress, + logAddress, +}; diff --git a/express/config.ts b/express/config.ts new file mode 100644 index 0000000..0b3dfe0 --- /dev/null +++ b/express/config.ts @@ -0,0 +1,11 @@ +const config = { + database: { + user: "abc123", + password: "abc123", + host: "localhost", + port: "5432", + database: "abc123", + }, +}; + +export { config }; diff --git a/express/content-types.ts b/express/content-types.ts new file mode 100644 index 0000000..b704c33 --- /dev/null +++ b/express/content-types.ts @@ -0,0 +1,122 @@ +// This file belongs to the framework. You are not expected to modify it. + +export type ContentType = string; + +// tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865 +const contentTypes = { + text: { + plain: "text/plain", + html: "text/html", + css: "text/css", + javascript: "text/javascript", + xml: "text/xml", + csv: "text/csv", + markdown: "text/markdown", + calendar: "text/calendar", + }, + image: { + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + svgPlusXml: "image/svg+xml", + webp: "image/webp", + bmp: "image/bmp", + ico: "image/x-icon", + tiff: "image/tiff", + avif: "image/avif", + }, + audio: { + mpeg: "audio/mpeg", + wav: "audio/wav", + ogg: "audio/ogg", + webm: "audio/webm", + aac: "audio/aac", + midi: "audio/midi", + opus: "audio/opus", + flac: "audio/flac", + }, + video: { + mp4: "video/mp4", + webm: "video/webm", + xMsvideo: "video/x-msvideo", + mpeg: "video/mpeg", + ogg: "video/ogg", + quicktime: "video/quicktime", + xMatroska: "video/x-matroska", + }, + application: { + json: "application/json", + pdf: "application/pdf", + zip: "application/zip", + xWwwFormUrlencoded: "application/x-www-form-urlencoded", + octetStream: "application/octet-stream", + xml: "application/xml", + gzip: "application/gzip", + javascript: "application/javascript", + ld_json: "application/ld+json", + msword: "application/msword", + vndOpenxmlformatsOfficedocumentWordprocessingmlDocument: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + vndMsExcel: "application/vnd.ms-excel", + vndOpenxmlformatsOfficedocumentSpreadsheetmlSheet: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + vndMsPowerpoint: "application/vnd.ms-powerpoint", + vndOpenxmlformatsOfficedocumentPresentationmlPresentation: + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + sql: "application/sql", + graphql: "application/graphql", + wasm: "application/wasm", + xTar: "application/x-tar", + x7zCompressed: "application/x-7z-compressed", + xRarCompressed: "application/x-rar-compressed", + }, + multipart: { + formData: "multipart/form-data", + byteranges: "multipart/byteranges", + }, + font: { + woff: "font/woff", + woff2: "font/woff2", + ttf: "font/ttf", + otf: "font/otf", + }, +}; + +export { contentTypes }; + +/* + +possible additions for later + +Looking at what's there, here are a few gaps that might be worth filling: +Streaming/Modern Web: + +application/x-ndjson or application/jsonlines - newline-delimited JSON (popular for streaming APIs) +text/event-stream - Server-Sent Events + +API/Data Exchange: + +application/yaml or text/yaml - YAML files +application/protobuf - Protocol Buffers +application/msgpack - MessagePack + +Archives you're missing: + +application/x-bzip2 - bzip2 compression + +Images: + +image/heic - HEIC/HEIF (common on iOS) + +Fonts: + +application/vnd.ms-fontobject - EOT fonts (legacy but still seen) + +Text: + +text/rtf - Rich Text Format + +The most impactful would probably be text/event-stream (if you do any SSE), application/x-ndjson (common in modern APIs), and maybe text/yaml. The rest are more situational. +But honestly, what you have covers 95% of common web development scenarios. You can definitely add as you go when you encounter specific needs! + +*/ diff --git a/express/context.ts b/express/context.ts new file mode 100644 index 0000000..2fdd9ef --- /dev/null +++ b/express/context.ts @@ -0,0 +1,27 @@ +// context.ts +// +// Request-scoped context using AsyncLocalStorage. +// Allows services to access request data (like the current user) without +// needing to pass Call through every function. + +import { AsyncLocalStorage } from "node:async_hooks"; +import { anonymousUser, type User } from "./user"; + +type RequestContext = { + user: User; +}; + +const asyncLocalStorage = new AsyncLocalStorage(); + +// Run a function within a request context +function runWithContext(context: RequestContext, fn: () => T): T { + return asyncLocalStorage.run(context, fn); +} + +// Get the current user from context, or AnonymousUser if not in a request +function getCurrentUser(): User { + const context = asyncLocalStorage.getStore(); + return context?.user ?? anonymousUser; +} + +export { getCurrentUser, runWithContext, type RequestContext }; diff --git a/express/core/index.ts b/express/core/index.ts new file mode 100644 index 0000000..0aa8d49 --- /dev/null +++ b/express/core/index.ts @@ -0,0 +1,48 @@ +import nunjucks from "nunjucks"; +import { db, migrate, migrationStatus } from "../database"; +import { getLogs, log } from "../logging"; + +// FIXME: This doesn't belong here; move it somewhere else. +const conf = { + templateEngine: () => { + return { + renderTemplate: (template: string, context: object) => { + return nunjucks.renderString(template, context); + }, + }; + }, +}; + +const database = { + db, + migrate, + migrationStatus, +}; + +const logging = { + log, + getLogs, +}; + +const random = { + randomNumber: () => { + return Math.random(); + }, +}; + +const misc = { + sleep: (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)); + }, +}; + +// Keep this asciibetically sorted +const core = { + conf, + database, + logging, + misc, + random, +}; + +export { core }; diff --git a/express/database.ts b/express/database.ts new file mode 100644 index 0000000..afb8bf5 --- /dev/null +++ b/express/database.ts @@ -0,0 +1,548 @@ +// database.ts +// PostgreSQL database access with Kysely query builder and simple migrations + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + type Generated, + Kysely, + PostgresDialect, + type Selectable, + sql, +} from "kysely"; +import { Pool } from "pg"; +import type { + AuthStore, + CreateSessionData, + CreateUserData, +} from "./auth/store"; +import { generateToken, hashToken } from "./auth/token"; +import type { SessionData, TokenId } from "./auth/types"; +import type { Domain } from "./types"; +import { AuthenticatedUser, type User, type UserId } from "./user"; + +// Connection configuration +const connectionConfig = { + host: "localhost", + port: 5432, + user: "diachron", + password: "diachron", + database: "diachron", +}; + +// Database schema types for Kysely +// Generated marks columns with database defaults (optional on insert) +interface UsersTable { + id: string; + status: Generated; + display_name: string | null; + created_at: Generated; + updated_at: Generated; +} + +interface UserEmailsTable { + id: string; + user_id: string; + email: string; + normalized_email: string; + is_primary: Generated; + is_verified: Generated; + created_at: Generated; + verified_at: Date | null; + revoked_at: Date | null; +} + +interface UserCredentialsTable { + id: string; + user_id: string; + credential_type: Generated; + password_hash: string | null; + created_at: Generated; + updated_at: Generated; +} + +interface SessionsTable { + id: Generated; + token_hash: string; + user_id: string; + user_email_id: string | null; + token_type: string; + auth_method: string; + created_at: Generated; + expires_at: Date; + revoked_at: Date | null; + ip_address: string | null; + user_agent: string | null; + is_used: Generated; +} + +interface Database { + users: UsersTable; + user_emails: UserEmailsTable; + user_credentials: UserCredentialsTable; + sessions: SessionsTable; +} + +// Create the connection pool +const pool = new Pool(connectionConfig); + +// Create the Kysely instance +const db = new Kysely({ + dialect: new PostgresDialect({ pool }), +}); + +// Raw pool access for when you need it +const rawPool = pool; + +// Execute raw SQL (for when Kysely doesn't fit) +async function raw( + query: string, + params: unknown[] = [], +): Promise { + const result = await pool.query(query, params); + return result.rows as T[]; +} + +// ============================================================================ +// Migrations +// ============================================================================ + +// Migration file naming convention: +// yyyy-mm-dd_ss_description.sql +// e.g., 2025-01-15_01_initial.sql, 2025-01-15_02_add_users.sql +// +// Migrations directory: express/migrations/ + +const FRAMEWORK_MIGRATIONS_DIR = path.join(__dirname, "framework/migrations"); +const APP_MIGRATIONS_DIR = path.join(__dirname, "migrations"); +const MIGRATIONS_TABLE = "_migrations"; + +interface MigrationRecord { + id: number; + name: string; + applied_at: Date; +} + +// Ensure migrations table exists +async function ensureMigrationsTable(): Promise { + await pool.query(` + CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); +} + +// Get list of applied migrations +async function getAppliedMigrations(): Promise { + const result = await pool.query( + `SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name`, + ); + return result.rows.map((r) => r.name); +} + +// Get pending migration files +function getMigrationFiles(kind: Domain): string[] { + const dir = kind === "fw" ? FRAMEWORK_MIGRATIONS_DIR : APP_MIGRATIONS_DIR; + + if (!fs.existsSync(dir)) { + return []; + } + + const root = __dirname; + + const mm = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".sql")) + .filter((f) => /^\d{4}-\d{2}-\d{2}_\d{2}-/.test(f)) + .map((f) => `${dir}/${f}`) + .map((f) => f.replace(`${root}/`, "")) + .sort(); + + return mm; +} + +// Run a single migration +async function runMigration(filename: string): Promise { + // const filepath = path.join(MIGRATIONS_DIR, filename); + const filepath = filename; + const content = fs.readFileSync(filepath, "utf-8"); + + process.stdout.write(` Migration: ${filename}...`); + + // Run migration in a transaction + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(content); + await client.query( + `INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES ($1)`, + [filename], + ); + await client.query("COMMIT"); + console.log(" ✓"); + } catch (err) { + console.log(" ✗"); + const message = err instanceof Error ? err.message : String(err); + console.error(` Error: ${message}`); + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} + +function getAllMigrationFiles() { + const fw_files = getMigrationFiles("fw"); + const app_files = getMigrationFiles("app"); + const all = [...fw_files, ...app_files]; + + return all; +} + +// Run all pending migrations +async function migrate(): Promise { + await ensureMigrationsTable(); + + const applied = new Set(await getAppliedMigrations()); + const all = getAllMigrationFiles(); + const pending = all.filter((all) => !applied.has(all)); + + if (pending.length === 0) { + console.log("No pending migrations"); + return; + } + + console.log(`Applying ${pending.length} migration(s):`); + for (const file of pending) { + await runMigration(file); + } +} + +// List migration status +async function migrationStatus(): Promise<{ + applied: string[]; + pending: string[]; +}> { + await ensureMigrationsTable(); + const applied = new Set(await getAppliedMigrations()); + const ff = getAllMigrationFiles(); + return { + applied: ff.filter((ff) => applied.has(ff)), + pending: ff.filter((ff) => !applied.has(ff)), + }; +} + +// ============================================================================ +// PostgresAuthStore - Database-backed authentication storage +// ============================================================================ + +class PostgresAuthStore implements AuthStore { + // Session operations + + async createSession( + data: CreateSessionData, + ): Promise<{ token: string; session: SessionData }> { + const token = generateToken(); + const tokenHash = hashToken(token); + + const row = await db + .insertInto("sessions") + .values({ + token_hash: tokenHash, + user_id: data.userId, + token_type: data.tokenType, + auth_method: data.authMethod, + expires_at: data.expiresAt, + user_agent: data.userAgent ?? null, + ip_address: data.ipAddress ?? null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + const session: SessionData = { + tokenId: row.token_hash, + userId: row.user_id, + tokenType: row.token_type as SessionData["tokenType"], + authMethod: row.auth_method as SessionData["authMethod"], + createdAt: row.created_at, + expiresAt: row.expires_at, + userAgent: row.user_agent ?? undefined, + ipAddress: row.ip_address ?? undefined, + isUsed: row.is_used ?? undefined, + }; + + return { token, session }; + } + + async getSession(tokenId: TokenId): Promise { + const row = await db + .selectFrom("sessions") + .selectAll() + .where("token_hash", "=", tokenId) + .where("expires_at", ">", new Date()) + .where("revoked_at", "is", null) + .executeTakeFirst(); + + if (!row) { + return null; + } + + return { + tokenId: row.token_hash, + userId: row.user_id, + tokenType: row.token_type as SessionData["tokenType"], + authMethod: row.auth_method as SessionData["authMethod"], + createdAt: row.created_at, + expiresAt: row.expires_at, + userAgent: row.user_agent ?? undefined, + ipAddress: row.ip_address ?? undefined, + isUsed: row.is_used ?? undefined, + }; + } + + async updateLastUsed(_tokenId: TokenId): Promise { + // The new schema doesn't have last_used_at column + // This is now a no-op; session activity tracking could be added later + } + + async deleteSession(tokenId: TokenId): Promise { + // Soft delete by setting revoked_at + await db + .updateTable("sessions") + .set({ revoked_at: new Date() }) + .where("token_hash", "=", tokenId) + .execute(); + } + + async deleteUserSessions(userId: UserId): Promise { + const result = await db + .updateTable("sessions") + .set({ revoked_at: new Date() }) + .where("user_id", "=", userId) + .where("revoked_at", "is", null) + .executeTakeFirst(); + + return Number(result.numUpdatedRows); + } + + // User operations + + async getUserByEmail(email: string): Promise { + // Find user through user_emails table + const normalizedEmail = email.toLowerCase().trim(); + + const row = await db + .selectFrom("user_emails") + .innerJoin("users", "users.id", "user_emails.user_id") + .select([ + "users.id", + "users.status", + "users.display_name", + "users.created_at", + "users.updated_at", + "user_emails.email", + ]) + .where("user_emails.normalized_email", "=", normalizedEmail) + .where("user_emails.revoked_at", "is", null) + .executeTakeFirst(); + + if (!row) { + return null; + } + return this.rowToUser(row); + } + + async getUserById(userId: UserId): Promise { + // Get user with their primary email + const row = await db + .selectFrom("users") + .leftJoin("user_emails", (join) => + join + .onRef("user_emails.user_id", "=", "users.id") + .on("user_emails.is_primary", "=", true) + .on("user_emails.revoked_at", "is", null), + ) + .select([ + "users.id", + "users.status", + "users.display_name", + "users.created_at", + "users.updated_at", + "user_emails.email", + ]) + .where("users.id", "=", userId) + .executeTakeFirst(); + + if (!row) { + return null; + } + return this.rowToUser(row); + } + + async createUser(data: CreateUserData): Promise { + const userId = crypto.randomUUID(); + const emailId = crypto.randomUUID(); + const credentialId = crypto.randomUUID(); + const now = new Date(); + const normalizedEmail = data.email.toLowerCase().trim(); + + // Create user record + await db + .insertInto("users") + .values({ + id: userId, + display_name: data.displayName ?? null, + status: "pending", + created_at: now, + updated_at: now, + }) + .execute(); + + // Create user_email record + await db + .insertInto("user_emails") + .values({ + id: emailId, + user_id: userId, + email: data.email, + normalized_email: normalizedEmail, + is_primary: true, + is_verified: false, + created_at: now, + }) + .execute(); + + // Create user_credential record + await db + .insertInto("user_credentials") + .values({ + id: credentialId, + user_id: userId, + credential_type: "password", + password_hash: data.passwordHash, + created_at: now, + updated_at: now, + }) + .execute(); + + return new AuthenticatedUser({ + id: userId, + email: data.email, + displayName: data.displayName, + status: "pending", + roles: [], + permissions: [], + createdAt: now, + updatedAt: now, + }); + } + + async getUserPasswordHash(userId: UserId): Promise { + const row = await db + .selectFrom("user_credentials") + .select("password_hash") + .where("user_id", "=", userId) + .where("credential_type", "=", "password") + .executeTakeFirst(); + + return row?.password_hash ?? null; + } + + async setUserPassword(userId: UserId, passwordHash: string): Promise { + const now = new Date(); + + // Try to update existing credential + const result = await db + .updateTable("user_credentials") + .set({ password_hash: passwordHash, updated_at: now }) + .where("user_id", "=", userId) + .where("credential_type", "=", "password") + .executeTakeFirst(); + + // If no existing credential, create one + if (Number(result.numUpdatedRows) === 0) { + await db + .insertInto("user_credentials") + .values({ + id: crypto.randomUUID(), + user_id: userId, + credential_type: "password", + password_hash: passwordHash, + created_at: now, + updated_at: now, + }) + .execute(); + } + + // Update user's updated_at + await db + .updateTable("users") + .set({ updated_at: now }) + .where("id", "=", userId) + .execute(); + } + + async updateUserEmailVerified(userId: UserId): Promise { + const now = new Date(); + + // Update user_emails to mark as verified + await db + .updateTable("user_emails") + .set({ + is_verified: true, + verified_at: now, + }) + .where("user_id", "=", userId) + .where("is_primary", "=", true) + .execute(); + + // Update user status to active + await db + .updateTable("users") + .set({ + status: "active", + updated_at: now, + }) + .where("id", "=", userId) + .execute(); + } + + // Helper to convert database row to User object + private rowToUser(row: { + id: string; + status: string; + display_name: string | null; + created_at: Date; + updated_at: Date; + email: string | null; + }): User { + return new AuthenticatedUser({ + id: row.id, + email: row.email ?? "unknown@example.com", + displayName: row.display_name ?? undefined, + status: row.status as "active" | "suspended" | "pending", + roles: [], // TODO: query from RBAC tables + permissions: [], // TODO: query from RBAC tables + createdAt: row.created_at, + updatedAt: row.updated_at, + }); + } +} + +// ============================================================================ +// Exports +// ============================================================================ + +export { + db, + raw, + rawPool, + pool, + migrate, + migrationStatus, + connectionConfig, + PostgresAuthStore, + type Database, +}; diff --git a/express/deps.ts b/express/deps.ts new file mode 100644 index 0000000..4cb62f0 --- /dev/null +++ b/express/deps.ts @@ -0,0 +1,7 @@ +// Database +//export { Client as PostgresClient } from "https://deno.land/x/postgres@v0.19.3/mod.ts"; +//export type { ClientOptions as PostgresOptions } from "https://deno.land/x/postgres@v0.19.3/mod.ts"; + +// Redis +//export { connect as redisConnect } from "https://deno.land/x/redis@v0.37.1/mod.ts"; +//export type { Redis } from "https://deno.land/x/redis@v0.37.1/mod.ts"; diff --git a/express/develop/clear-db.ts b/express/develop/clear-db.ts new file mode 100644 index 0000000..2293ff6 --- /dev/null +++ b/express/develop/clear-db.ts @@ -0,0 +1,17 @@ +import { connectionConfig, migrate, pool } from "../database"; +import { dropTables, exitIfUnforced } from "./util"; + +async function main(): Promise { + exitIfUnforced(); + + try { + await dropTables(); + } finally { + await pool.end(); + } +} + +main().catch((err) => { + console.error("Failed to clear database:", err.message); + process.exit(1); +}); diff --git a/express/develop/reset-db.ts b/express/develop/reset-db.ts new file mode 100644 index 0000000..3545c58 --- /dev/null +++ b/express/develop/reset-db.ts @@ -0,0 +1,26 @@ +// reset-db.ts +// Development command to wipe the database and apply all migrations from scratch + +import { connectionConfig, migrate, pool } from "../database"; +import { dropTables, exitIfUnforced } from "./util"; + +async function main(): Promise { + exitIfUnforced(); + + try { + await dropTables(); + + console.log(""); + await migrate(); + + console.log(""); + console.log("Database reset complete."); + } finally { + await pool.end(); + } +} + +main().catch((err) => { + console.error("Failed to reset database:", err.message); + process.exit(1); +}); diff --git a/express/develop/util.ts b/express/develop/util.ts new file mode 100644 index 0000000..4d138f6 --- /dev/null +++ b/express/develop/util.ts @@ -0,0 +1,42 @@ +// FIXME: this is at the wrong level of specificity + +import { connectionConfig, migrate, pool } from "../database"; + +const exitIfUnforced = () => { + const args = process.argv.slice(2); + + // Require explicit confirmation unless --force is passed + if (!args.includes("--force")) { + console.error("This will DROP ALL TABLES in the database!"); + console.error(` Database: ${connectionConfig.database}`); + console.error( + ` Host: ${connectionConfig.host}:${connectionConfig.port}`, + ); + console.error(""); + console.error("Run with --force to proceed."); + process.exit(1); + } +}; + +const dropTables = async () => { + console.log("Dropping all tables..."); + + // Get all table names in the public schema + const result = await pool.query<{ tablename: string }>(` + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + `); + + if (result.rows.length > 0) { + // Drop all tables with CASCADE to handle foreign key constraints + const tableNames = result.rows + .map((r) => `"${r.tablename}"`) + .join(", "); + await pool.query(`DROP TABLE IF EXISTS ${tableNames} CASCADE`); + console.log(`Dropped ${result.rows.length} table(s)`); + } else { + console.log("No tables to drop"); + } +}; + +export { dropTables, exitIfUnforced }; diff --git a/express/execution-context-schema.ts b/express/execution-context-schema.ts new file mode 100644 index 0000000..694c24c --- /dev/null +++ b/express/execution-context-schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const executionContextSchema = z.object({ + diachron_root: z.string(), +}); + +export type ExecutionContext = z.infer; + +export function parseExecutionContext( + env: Record, +): ExecutionContext { + return executionContextSchema.parse(env); +} diff --git a/express/execution-context.spec.ts b/express/execution-context.spec.ts new file mode 100644 index 0000000..30f4186 --- /dev/null +++ b/express/execution-context.spec.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { ZodError } from "zod"; + +import { + executionContextSchema, + parseExecutionContext, +} from "./execution-context-schema"; + +describe("parseExecutionContext", () => { + it("parses valid executionContext with diachron_root", () => { + const env = { diachron_root: "/some/path" }; + const result = parseExecutionContext(env); + assert.deepEqual(result, { diachron_root: "/some/path" }); + }); + + it("throws ZodError when diachron_root is missing", () => { + const env = {}; + assert.throws(() => parseExecutionContext(env), ZodError); + }); + + it("strips extra fields not in schema", () => { + const env = { + diachron_root: "/some/path", + EXTRA_VAR: "should be stripped", + }; + const result = parseExecutionContext(env); + assert.deepEqual(result, { diachron_root: "/some/path" }); + assert.equal("EXTRA_VAR" in result, false); + }); +}); + +describe("executionContextSchema", () => { + it("requires diachron_root to be a string", () => { + const result = executionContextSchema.safeParse({ diachron_root: 123 }); + assert.equal(result.success, false); + }); +}); diff --git a/express/execution-context.ts b/express/execution-context.ts new file mode 100644 index 0000000..67f23e0 --- /dev/null +++ b/express/execution-context.ts @@ -0,0 +1,5 @@ +import { parseExecutionContext } from "./execution-context-schema"; + +const executionContext = parseExecutionContext(process.env); + +export { executionContext }; diff --git a/express/extensible.ts b/express/extensible.ts new file mode 100644 index 0000000..e69de29 diff --git a/express/framework/migrations/2026-01-01_01-users.sql b/express/framework/migrations/2026-01-01_01-users.sql new file mode 100644 index 0000000..83f7cb7 --- /dev/null +++ b/express/framework/migrations/2026-01-01_01-users.sql @@ -0,0 +1,29 @@ +-- 0001_users.sql +-- Create users table for authentication + +CREATE TABLE users ( + id UUID PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'active', + display_name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE user_emails ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + email TEXT NOT NULL, + normalized_email TEXT NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + is_verified BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + verified_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); + +-- Enforce uniqueness only among *active* emails +CREATE UNIQUE INDEX user_emails_unique_active +ON user_emails (normalized_email) +WHERE revoked_at IS NULL; + + diff --git a/express/framework/migrations/2026-01-01_02-sessions.sql b/express/framework/migrations/2026-01-01_02-sessions.sql new file mode 100644 index 0000000..e2911cc --- /dev/null +++ b/express/framework/migrations/2026-01-01_02-sessions.sql @@ -0,0 +1,26 @@ +-- 0002_sessions.sql +-- Create sessions table for auth tokens + +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash TEXT UNIQUE NOT NULL, + user_id UUID NOT NULL REFERENCES users(id), + user_email_id UUID REFERENCES user_emails(id), + token_type TEXT NOT NULL, + auth_method TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + ip_address INET, + user_agent TEXT, + is_used BOOLEAN DEFAULT FALSE +); + +-- Index for user session lookups (logout all, etc.) +CREATE INDEX sessions_user_id_idx ON sessions (user_id); + +-- Index for expiration cleanup +CREATE INDEX sessions_expires_at_idx ON sessions (expires_at); + +-- Index for token type filtering +CREATE INDEX sessions_token_type_idx ON sessions (token_type); diff --git a/express/framework/migrations/2026-01-24_01-roles-and-groups.sql b/express/framework/migrations/2026-01-24_01-roles-and-groups.sql new file mode 100644 index 0000000..e9e7630 --- /dev/null +++ b/express/framework/migrations/2026-01-24_01-roles-and-groups.sql @@ -0,0 +1,20 @@ +CREATE TABLE roles ( + id UUID PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + description TEXT +); + +CREATE TABLE groups ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE user_group_roles ( + user_id UUID NOT NULL REFERENCES users(id), + group_id UUID NOT NULL REFERENCES groups(id), + role_id UUID NOT NULL REFERENCES roles(id), + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + PRIMARY KEY (user_id, group_id, role_id) +); diff --git a/express/framework/migrations/2026-01-24_02-capabilities.sql b/express/framework/migrations/2026-01-24_02-capabilities.sql new file mode 100644 index 0000000..12f0329 --- /dev/null +++ b/express/framework/migrations/2026-01-24_02-capabilities.sql @@ -0,0 +1,14 @@ +CREATE TABLE capabilities ( + id UUID PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + description TEXT +); + +CREATE TABLE role_capabilities ( + role_id UUID NOT NULL REFERENCES roles(id), + capability_id UUID NOT NULL REFERENCES capabilities(id), + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + PRIMARY KEY (role_id, capability_id) +); + diff --git a/express/handlers.ts b/express/handlers.ts new file mode 100644 index 0000000..adbaee1 --- /dev/null +++ b/express/handlers.ts @@ -0,0 +1,19 @@ +import { contentTypes } from "./content-types"; +import { core } from "./core"; +import { httpCodes } from "./http-codes"; +import type { Call, Handler, Result } from "./types"; + +const multiHandler: Handler = async (call: Call): Promise => { + const code = httpCodes.success.OK; + const rn = core.random.randomNumber(); + + const retval: Result = { + code, + result: `that was ${call.method} (${rn})`, + contentType: contentTypes.text.plain, + }; + + return retval; +}; + +export { multiHandler }; diff --git a/express/http-codes.ts b/express/http-codes.ts new file mode 100644 index 0000000..89a23d8 --- /dev/null +++ b/express/http-codes.ts @@ -0,0 +1,76 @@ +// This file belongs to the framework. You are not expected to modify it. + +export type HttpCode = { + code: number; + name: string; + description?: string; +}; +type Group = "success" | "redirection" | "clientErrors" | "serverErrors"; +type CodeDefinitions = { + [K in Group]: { + [K: string]: HttpCode; + }; +}; + +// tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865 +const httpCodes: CodeDefinitions = { + success: { + OK: { code: 200, name: "OK", description: "" }, + Created: { code: 201, name: "Created" }, + Accepted: { code: 202, name: "Accepted" }, + NonAuthoritativeInformation: { + code: 203, + name: "Non-Authoritative Information", + }, + NoContent: { code: 204, name: "No Content" }, + ResetContent: { code: 205, name: "Reset Content" }, + PartialContent: { code: 206, name: "Partial Content" }, + }, + redirection: { + MultipleChoices: { code: 300, name: "Multiple Choices" }, + MovedPermanently: { code: 301, name: "Moved Permanently" }, + Found: { code: 302, name: "Found" }, + SeeOther: { code: 303, name: "See Other" }, + NotModified: { code: 304, name: "Not Modified" }, + TemporaryRedirect: { code: 307, name: "Temporary Redirect" }, + PermanentRedirect: { code: 308, name: "Permanent Redirect" }, + }, + clientErrors: { + BadRequest: { code: 400, name: "Bad Request" }, + Unauthorized: { code: 401, name: "Unauthorized" }, + PaymentRequired: { code: 402, name: "Payment Required" }, + Forbidden: { code: 403, name: "Forbidden" }, + NotFound: { code: 404, name: "Not Found" }, + MethodNotAllowed: { code: 405, name: "Method Not Allowed" }, + NotAcceptable: { code: 406, name: "Not Acceptable" }, + ProxyAuthenticationRequired: { + code: 407, + name: "Proxy Authentication Required", + }, + RequestTimeout: { code: 408, name: "Request Timeout" }, + Conflict: { code: 409, name: "Conflict" }, + Gone: { code: 410, name: "Gone" }, + LengthRequired: { code: 411, name: "Length Required" }, + PreconditionFailed: { code: 412, name: "Precondition Failed" }, + PayloadTooLarge: { code: 413, name: "Payload Too Large" }, + URITooLong: { code: 414, name: "URI Too Long" }, + UnsupportedMediaType: { code: 415, name: "Unsupported Media Type" }, + RangeNotSatisfiable: { code: 416, name: "Range Not Satisfiable" }, + ExpectationFailed: { code: 417, name: "Expectation Failed" }, + ImATeapot: { code: 418, name: "I'm a teapot" }, + UnprocessableEntity: { code: 422, name: "Unprocessable Entity" }, + TooManyRequests: { code: 429, name: "Too Many Requests" }, + }, + serverErrors: { + InternalServerError: { code: 500, name: "Internal Server Error" }, + NotImplemented: { code: 501, name: "Not Implemented" }, + BadGateway: { code: 502, name: "Bad Gateway" }, + ServiceUnavailable: { code: 503, name: "Service Unavailable" }, + GatewayTimeout: { code: 504, name: "Gateway Timeout" }, + HTTPVersionNotSupported: { + code: 505, + name: "HTTP Version Not Supported", + }, + }, +}; +export { httpCodes }; diff --git a/express/interfaces.ts b/express/interfaces.ts new file mode 100644 index 0000000..48dd302 --- /dev/null +++ b/express/interfaces.ts @@ -0,0 +1,3 @@ +type Brand = K & { readonly __brand: T }; + +export type Extensible = Brand<"Extensible", {}>; diff --git a/express/logging.ts b/express/logging.ts new file mode 100644 index 0000000..5bf417d --- /dev/null +++ b/express/logging.ts @@ -0,0 +1,73 @@ +// internal-logging.ts + +import { cli } from "./cli"; + +// FIXME: Move this to somewhere more appropriate +type AtLeastOne = [T, ...T[]]; + +type MessageSource = "logging" | "diagnostic" | "user"; + +type Message = { + // FIXME: number probably isn't what we want here + timestamp?: number; + source: MessageSource; + + text: AtLeastOne; +}; + +const _m1: Message = { timestamp: 123, source: "logging", text: ["foo"] }; +const _m2: Message = { + timestamp: 321, + source: "diagnostic", + text: ["ok", "whatever"], +}; + +type FilterArgument = { + limit?: number; + before?: number; + after?: number; + + // FIXME: add offsets to use instead of or in addition to before/after + + match?: (string | RegExp)[]; +}; + +const loggerUrl = `http://${cli.logAddress.host}:${cli.logAddress.port}`; + +const log = (message: Message) => { + const payload = { + timestamp: message.timestamp ?? Date.now(), + source: message.source, + text: message.text, + }; + + fetch(`${loggerUrl}/log`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).catch((err) => { + console.error("[logging] Failed to send log:", err.message); + }); +}; + +const getLogs = async (filter: FilterArgument): Promise => { + const params = new URLSearchParams(); + if (filter.limit) { + params.set("limit", String(filter.limit)); + } + if (filter.before) { + params.set("before", String(filter.before)); + } + if (filter.after) { + params.set("after", String(filter.after)); + } + + const url = `${loggerUrl}/logs?${params.toString()}`; + const response = await fetch(url); + return response.json(); +}; + +// FIXME: there's scope for more specialized functions although they +// probably should be defined in terms of the basic ones here. + +export { getLogs, log }; diff --git a/express/mgmt/add-user.ts b/express/mgmt/add-user.ts new file mode 100644 index 0000000..4dbc854 --- /dev/null +++ b/express/mgmt/add-user.ts @@ -0,0 +1,69 @@ +// add-user.ts +// Management command to create users from the command line + +import { hashPassword } from "../auth/password"; +import { PostgresAuthStore, pool } from "../database"; + +async function main(): Promise { + const args = process.argv.slice(2); + + if (args.length < 2) { + console.error( + "Usage: ./mgmt add-user [--display-name ] [--active]", + ); + process.exit(1); + } + + const email = args[0]; + const password = args[1]; + + // Parse optional flags + let displayName: string | undefined; + let makeActive = false; + + for (let i = 2; i < args.length; i++) { + if (args[i] === "--display-name" && args[i + 1]) { + displayName = args[i + 1]; + i++; + } else if (args[i] === "--active") { + makeActive = true; + } + } + + try { + const store = new PostgresAuthStore(); + + // Check if user already exists + const existing = await store.getUserByEmail(email); + if (existing) { + console.error(`Error: User with email '${email}' already exists`); + process.exit(1); + } + + // Hash password and create user + const passwordHash = await hashPassword(password); + const user = await store.createUser({ + email, + passwordHash, + displayName, + }); + + // Optionally activate user immediately + if (makeActive) { + await store.updateUserEmailVerified(user.id); + console.log( + `Created and activated user: ${user.email} (${user.id})`, + ); + } else { + console.log(`Created user: ${user.email} (${user.id})`); + console.log(" Status: pending (use --active to create as active)"); + } + } finally { + await pool.end(); + } +} + +main().catch((err) => { + console.error("Failed to create user:", err.message); + process.exit(1); +}); diff --git a/express/migrate.ts b/express/migrate.ts new file mode 100644 index 0000000..1d32d07 --- /dev/null +++ b/express/migrate.ts @@ -0,0 +1,45 @@ +// migrate.ts +// CLI script for running database migrations + +import { migrate, migrationStatus, pool } from "./database"; + +async function main(): Promise { + const command = process.argv[2] || "run"; + + try { + switch (command) { + case "run": + await migrate(); + break; + + case "status": { + const status = await migrationStatus(); + console.log("Applied migrations:"); + for (const name of status.applied) { + console.log(` ✓ ${name}`); + } + if (status.pending.length > 0) { + console.log("\nPending migrations:"); + for (const name of status.pending) { + console.log(` • ${name}`); + } + } else { + console.log("\nNo pending migrations"); + } + break; + } + + default: + console.error(`Unknown command: ${command}`); + console.error("Usage: migrate [run|status]"); + process.exit(1); + } + } finally { + await pool.end(); + } +} + +main().catch((err) => { + console.error("Migration failed:", err); + process.exit(1); +}); diff --git a/express/package.json b/express/package.json new file mode 100644 index 0000000..231753d --- /dev/null +++ b/express/package.json @@ -0,0 +1,35 @@ +{ + "name": "express", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "nodemon": "nodemon dist/index.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "packageManager": "pnpm@10.12.4", + "dependencies": { + "@types/node": "^24.10.1", + "@types/nunjucks": "^3.2.6", + "@vercel/ncc": "^0.38.4", + "express": "^5.1.0", + "kysely": "^0.28.9", + "nodemon": "^3.1.11", + "nunjucks": "^3.2.4", + "path-to-regexp": "^8.3.0", + "pg": "^8.16.3", + "ts-luxon": "^6.2.0", + "ts-node": "^10.9.2", + "tsx": "^4.20.6", + "typescript": "^5.9.3", + "zod": "^4.1.12" + }, + "devDependencies": { + "@biomejs/biome": "2.3.10", + "@types/express": "^5.0.5", + "@types/pg": "^8.16.0" + } +} diff --git a/express/pnpm-lock.yaml b/express/pnpm-lock.yaml new file mode 100644 index 0000000..877e164 --- /dev/null +++ b/express/pnpm-lock.yaml @@ -0,0 +1,1627 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 + '@types/nunjucks': + specifier: ^3.2.6 + version: 3.2.6 + '@vercel/ncc': + specifier: ^0.38.4 + version: 0.38.4 + express: + specifier: ^5.1.0 + version: 5.1.0 + kysely: + specifier: ^0.28.9 + version: 0.28.9 + nodemon: + specifier: ^3.1.11 + version: 3.1.11 + nunjucks: + specifier: ^3.2.4 + version: 3.2.4(chokidar@3.6.0) + path-to-regexp: + specifier: ^8.3.0 + version: 8.3.0 + pg: + specifier: ^8.16.3 + version: 8.16.3 + ts-luxon: + specifier: ^6.2.0 + version: 6.2.0 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) + tsx: + specifier: ^4.20.6 + version: 4.20.6 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + zod: + specifier: ^4.1.12 + version: 4.1.12 + devDependencies: + '@biomejs/biome': + specifier: 2.3.10 + version: 2.3.10 + '@types/express': + specifier: ^5.0.5 + version: 5.0.5 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 + +packages: + + '@biomejs/biome@2.3.10': + resolution: {integrity: sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.10': + resolution: {integrity: sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.10': + resolution: {integrity: sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.10': + resolution: {integrity: sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.3.10': + resolution: {integrity: sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.3.10': + resolution: {integrity: sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.3.10': + resolution: {integrity: sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.3.10': + resolution: {integrity: sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.10': + resolution: {integrity: sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/express-serve-static-core@5.1.0': + resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==} + + '@types/express@5.0.5': + resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + + '@types/nunjucks@3.2.6': + resolution: {integrity: sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==} + + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + '@vercel/ncc@0.38.4': + resolution: {integrity: sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==} + hasBin: true + + a-sync-waterfall@1.0.1: + resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@1.0.0: + resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.0: + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + kysely@0.28.9: + resolution: {integrity: sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==} + engines: {node: '>=20.0.0'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + nodemon@3.1.11: + resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nunjucks@3.2.4: + resolution: {integrity: sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==} + engines: {node: '>= 6.9.0'} + hasBin: true + peerDependencies: + chokidar: ^3.3.0 + peerDependenciesMeta: + chokidar: + optional: true + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + pg-cloudflare@1.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + + pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.10.1: + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.1: + resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} + engines: {node: '>= 0.10'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + engines: {node: '>= 18'} + + serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + ts-luxon@6.2.0: + resolution: {integrity: sha512-4I1tkW6gtydyLnUUIvfezBl5B3smurkgKmHdMOYI2g9Fn3Zg1lGJdhsCXu2VNl95CYbW2+SoNtStcf1CKOcQjw==} + engines: {node: '>=18'} + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + +snapshots: + + '@biomejs/biome@2.3.10': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.10 + '@biomejs/cli-darwin-x64': 2.3.10 + '@biomejs/cli-linux-arm64': 2.3.10 + '@biomejs/cli-linux-arm64-musl': 2.3.10 + '@biomejs/cli-linux-x64': 2.3.10 + '@biomejs/cli-linux-x64-musl': 2.3.10 + '@biomejs/cli-win32-arm64': 2.3.10 + '@biomejs/cli-win32-x64': 2.3.10 + + '@biomejs/cli-darwin-arm64@2.3.10': + optional: true + + '@biomejs/cli-darwin-x64@2.3.10': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.10': + optional: true + + '@biomejs/cli-linux-arm64@2.3.10': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.10': + optional: true + + '@biomejs/cli-linux-x64@2.3.10': + optional: true + + '@biomejs/cli-win32-arm64@2.3.10': + optional: true + + '@biomejs/cli-win32-x64@2.3.10': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.10.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.10.1 + + '@types/express-serve-static-core@5.1.0': + dependencies: + '@types/node': 24.10.1 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.5': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.0 + '@types/serve-static': 1.15.10 + + '@types/http-errors@2.0.5': {} + + '@types/mime@1.3.5': {} + + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + + '@types/nunjucks@3.2.6': {} + + '@types/pg@8.16.0': + dependencies: + '@types/node': 24.10.1 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 24.10.1 + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.10.1 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.10.1 + '@types/send': 0.17.6 + + '@vercel/ncc@0.38.4': {} + + a-sync-waterfall@1.0.1: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.1 + negotiator: 1.0.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@4.1.3: {} + + asap@2.0.6: {} + + balanced-match@1.0.2: {} + + binary-extensions@2.3.0: {} + + body-parser@2.2.0: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.0 + iconv-lite: 0.6.3 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 3.0.1 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + commander@5.1.0: {} + + concat-map@0.0.1: {} + + content-disposition@1.0.0: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + create-require@1.1.1: {} + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + depd@2.0.0: {} + + diff@4.0.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@5.1.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.0 + content-disposition: 1.0.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.0 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + gopd@1.2.0: {} + + has-flag@3.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + + ignore-by-default@1.0.1: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-promise@4.0.0: {} + + kysely@0.28.9: {} + + make-error@1.3.6: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + ms@2.1.3: {} + + negotiator@1.0.0: {} + + nodemon@3.1.11: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.7.3 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + nunjucks@3.2.4(chokidar@3.6.0): + dependencies: + a-sync-waterfall: 1.0.1 + asap: 2.0.6 + commander: 5.1.0 + optionalDependencies: + chokidar: 3.6.0 + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.3.0: {} + + pg-cloudflare@1.2.7: + optional: true + + pg-connection-string@2.9.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.10.1(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.7 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picomatch@2.3.1: {} + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pstree.remy@1.1.8: {} + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.1: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.7.0 + unpipe: 1.0.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + resolve-pkg-maps@1.0.0: {} + + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + semver@7.7.3: {} + + send@1.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.0 + mime-types: 3.0.1 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.0: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.0 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.3 + + split2@4.2.0: {} + + statuses@2.0.1: {} + + statuses@2.0.2: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + touch@3.1.1: {} + + ts-luxon@6.2.0: {} + + ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 24.10.1 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tsx@4.20.6: + dependencies: + esbuild: 0.25.12 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.1 + + typescript@5.9.3: {} + + undefsafe@2.0.5: {} + + undici-types@7.16.0: {} + + unpipe@1.0.0: {} + + v8-compile-cache-lib@3.0.1: {} + + vary@1.1.2: {} + + wrappy@1.0.2: {} + + xtend@4.0.2: {} + + yn@3.1.1: {} + + zod@4.1.12: {} diff --git a/express/request/index.ts b/express/request/index.ts new file mode 100644 index 0000000..466a992 --- /dev/null +++ b/express/request/index.ts @@ -0,0 +1,25 @@ +import { AuthService } from "../auth"; +import { getCurrentUser } from "../context"; +import { PostgresAuthStore } from "../database"; +import type { User } from "../user"; +import { html, redirect, render } from "./util"; + +const util = { html, redirect, render }; + +const session = { + getUser: (): User => { + return getCurrentUser(); + }, +}; + +// Initialize auth with PostgreSQL store +const authStore = new PostgresAuthStore(); +const auth = new AuthService(authStore); + +const request = { + auth, + session, + util, +}; + +export { request }; diff --git a/express/request/util.ts b/express/request/util.ts new file mode 100644 index 0000000..e1d01f9 --- /dev/null +++ b/express/request/util.ts @@ -0,0 +1,45 @@ +import { contentTypes } from "../content-types"; +import { core } from "../core"; +import { executionContext } from "../execution-context"; +import { httpCodes } from "../http-codes"; +import type { RedirectResult, Result } from "../types"; +import { loadFile } from "../util"; +import { request } from "./index"; + +type NoUser = { + [key: string]: unknown; +} & { + user?: never; +}; + +const render = async (path: string, ctx?: NoUser): Promise => { + const fullPath = `${executionContext.diachron_root}/templates/${path}.html.njk`; + const template = await loadFile(fullPath); + const user = request.session.getUser(); + const context = { user, ...ctx }; + const engine = core.conf.templateEngine(); + const retval = engine.renderTemplate(template, context); + + return retval; +}; + +const html = (payload: string): Result => { + const retval: Result = { + code: httpCodes.success.OK, + result: payload, + contentType: contentTypes.text.html, + }; + + return retval; +}; + +const redirect = (location: string): RedirectResult => { + return { + code: httpCodes.redirection.SeeOther, + contentType: contentTypes.text.plain, + result: "", + redirect: location, + }; +}; + +export { html, redirect, render }; diff --git a/express/routes.ts b/express/routes.ts new file mode 100644 index 0000000..090b7aa --- /dev/null +++ b/express/routes.ts @@ -0,0 +1,148 @@ +/// + +import nunjucks from "nunjucks"; +import { DateTime } from "ts-luxon"; +import { authRoutes } from "./auth/routes"; +import { routes as basicRoutes } from "./basic/routes"; +import { contentTypes } from "./content-types"; +import { core } from "./core"; +import { multiHandler } from "./handlers"; +import { httpCodes } from "./http-codes"; +import type { Call, Result, Route } from "./types"; + +// FIXME: Obviously put this somewhere else +const okText = (result: string): Result => { + const code = httpCodes.success.OK; + + const retval: Result = { + code, + result, + contentType: contentTypes.text.plain, + }; + + return retval; +}; + +const routes: Route[] = [ + ...authRoutes, + basicRoutes.home, + basicRoutes.hello, + basicRoutes.login, + basicRoutes.logout, + { + path: "/slow", + methods: ["GET"], + handler: async (_call: Call): Promise => { + console.log("starting slow request"); + + await core.misc.sleep(2); + + console.log("finishing slow request"); + const retval = okText("that was slow"); + + return retval; + }, + }, + { + path: "/list", + methods: ["GET"], + handler: async (_call: Call): Promise => { + const code = httpCodes.success.OK; + const lr = (rr: Route[]) => { + const ret = rr.map((r: Route) => { + return r.path; + }); + + return ret; + }; + + const rrr = lr(routes); + + const template = ` + + + +
    + {% for route in rrr %} +
  • {{ route }}
  • + {% endfor %} +
+ + +`; + const result = nunjucks.renderString(template, { rrr }); + + const _listing = lr(routes).join(", "); + return { + code, + result, + contentType: contentTypes.text.html, + }; + }, + }, + { + path: "/whoami", + methods: ["GET"], + handler: async (call: Call): Promise => { + const me = call.session.getUser(); + const template = ` + + + + {{ me }} + + +`; + + const result = nunjucks.renderString(template, { me }); + + return { + code: httpCodes.success.OK, + contentType: contentTypes.text.html, + result, + }; + }, + }, + { + path: "/ok", + methods: ["GET", "POST", "PUT"], + handler: multiHandler, + }, + { + path: "/alsook", + methods: ["GET"], + handler: async (_req): Promise => { + const code = httpCodes.success.OK; + return { + code, + result: "it is also ok", + contentType: contentTypes.text.plain, + }; + }, + }, + { + path: "/time", + methods: ["GET"], + handler: async (_req): Promise => { + const now = DateTime.now(); + const template = ` + + + + {{ now }} + + +`; + + const result = nunjucks.renderString(template, { now }); + + return { + code: httpCodes.success.OK, + contentType: contentTypes.text.html, + result, + }; + }, + }, +]; + +export { routes }; diff --git a/express/run.sh b/express/run.sh new file mode 100755 index 0000000..228defc --- /dev/null +++ b/express/run.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$DIR" + +exec ../cmd node dist/index.js "$@" diff --git a/express/show-config.sh b/express/show-config.sh new file mode 100755 index 0000000..2c553a1 --- /dev/null +++ b/express/show-config.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +check_dir="$DIR" + +source "$check_dir"/../framework/shims/common +source "$check_dir"/../framework/shims/node.common + +$ROOT/cmd pnpm tsc --showConfig diff --git a/express/tsconfig.json b/express/tsconfig.json new file mode 100644 index 0000000..3381805 --- /dev/null +++ b/express/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "target": "ES2022", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noImplicitAny": true, + "strict": true, + "types": ["node"], + "outDir": "out" + } +} diff --git a/express/types.ts b/express/types.ts new file mode 100644 index 0000000..2cb2b8d --- /dev/null +++ b/express/types.ts @@ -0,0 +1,117 @@ +// types.ts + +// FIXME: split this up into types used by app developers and types internal +// to the framework. +import type { Request as ExpressRequest } from "express"; +import type { MatchFunction } from "path-to-regexp"; +import { z } from "zod"; +import type { Session } from "./auth/types"; +import type { ContentType } from "./content-types"; +import type { HttpCode } from "./http-codes"; +import type { Permission, User } from "./user"; + +const methodParser = z.union([ + z.literal("GET"), + z.literal("POST"), + z.literal("PUT"), + z.literal("PATCH"), + z.literal("DELETE"), +]); + +export type Method = z.infer; +const massageMethod = (input: string): Method => { + const r = methodParser.parse(input.toUpperCase()); + + return r; +}; + +export type Call = { + pattern: string; + path: string; + method: Method; + parameters: object; + request: ExpressRequest; + user: User; + session: Session; +}; + +export type InternalHandler = (req: ExpressRequest) => Promise; + +export type Handler = (call: Call) => Promise; +export type ProcessedRoute = { + matcher: MatchFunction>; + method: Method; + handler: InternalHandler; +}; + +export type CookieOptions = { + httpOnly?: boolean; + secure?: boolean; + sameSite?: "strict" | "lax" | "none"; + maxAge?: number; + path?: string; +}; + +export type Cookie = { + name: string; + value: string; + options?: CookieOptions; +}; + +export type Result = { + code: HttpCode; + contentType: ContentType; + result: string; + cookies?: Cookie[]; +}; + +export type RedirectResult = Result & { + redirect: string; +}; + +export function isRedirect(result: Result): result is RedirectResult { + return "redirect" in result; +} + +export type Route = { + path: string; + methods: Method[]; + handler: Handler; + interruptable?: boolean; +}; + +// Authentication error classes +export class AuthenticationRequired extends Error { + constructor() { + super("Authentication required"); + this.name = "AuthenticationRequired"; + } +} + +export class AuthorizationDenied extends Error { + constructor() { + super("Authorization denied"); + this.name = "AuthorizationDenied"; + } +} + +// Helper for handlers to require authentication +export function requireAuth(call: Call): User { + if (call.user.isAnonymous()) { + throw new AuthenticationRequired(); + } + return call.user; +} + +// Helper for handlers to require specific permission +export function requirePermission(call: Call, permission: Permission): User { + const user = requireAuth(call); + if (!user.hasPermission(permission)) { + throw new AuthorizationDenied(); + } + return user; +} + +export type Domain = "app" | "fw"; + +export { methodParser, massageMethod }; diff --git a/express/user.ts b/express/user.ts new file mode 100644 index 0000000..1803350 --- /dev/null +++ b/express/user.ts @@ -0,0 +1,232 @@ +// user.ts +// +// User model for authentication and authorization. +// +// Design notes: +// - `id` is the stable internal identifier (UUID when database-backed) +// - `email` is the primary human-facing identifier +// - Roles provide coarse-grained authorization (admin, editor, etc.) +// - Permissions provide fine-grained authorization (posts:create, etc.) +// - Users can have both roles (which grant permissions) and direct permissions + +import { z } from "zod"; + +// Branded type for user IDs to prevent accidental mixing with other strings +export type UserId = string & { readonly __brand: "UserId" }; + +// User account status +const userStatusParser = z.enum(["active", "suspended", "pending"]); +export type UserStatus = z.infer; + +// Role - simple string identifier +const roleParser = z.string().min(1); +export type Role = z.infer; + +// Permission format: "resource:action" e.g. "posts:create", "users:delete" +const permissionParser = z.string().regex(/^[a-z_]+:[a-z_]+$/, { + message: "Permission must be in format 'resource:action'", +}); +export type Permission = z.infer; + +// Core user data schema - this is what gets stored/serialized +const userDataParser = z.object({ + id: z.string().min(1), + email: z.email(), + displayName: z.string().optional(), + status: userStatusParser, + roles: z.array(roleParser), + permissions: z.array(permissionParser), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +export type UserData = z.infer; + +// Role-to-permission mappings +// In a real system this might be database-driven or configurable +type RolePermissionMap = Map; + +const defaultRolePermissions: RolePermissionMap = new Map([ + ["admin", ["users:read", "users:create", "users:update", "users:delete"]], + ["user", ["users:read"]], +]); + +export abstract class User { + protected readonly data: UserData; + protected rolePermissions: RolePermissionMap; + + constructor(data: UserData, rolePermissions?: RolePermissionMap) { + this.data = userDataParser.parse(data); + this.rolePermissions = rolePermissions ?? defaultRolePermissions; + } + + // Identity + get id(): UserId { + return this.data.id as UserId; + } + + get email(): string { + return this.data.email; + } + + get displayName(): string | undefined { + return this.data.displayName; + } + + // Status + get status(): UserStatus { + return this.data.status; + } + + isActive(): boolean { + return this.data.status === "active"; + } + + // Roles + get roles(): readonly Role[] { + return this.data.roles; + } + + hasRole(role: Role): boolean { + return this.data.roles.includes(role); + } + + hasAnyRole(roles: Role[]): boolean { + return roles.some((role) => this.hasRole(role)); + } + + hasAllRoles(roles: Role[]): boolean { + return roles.every((role) => this.hasRole(role)); + } + + // Permissions + get permissions(): readonly Permission[] { + return this.data.permissions; + } + + // Get all permissions: direct + role-derived + effectivePermissions(): Set { + const perms = new Set(this.data.permissions); + + for (const role of this.data.roles) { + const rolePerms = this.rolePermissions.get(role); + if (rolePerms) { + for (const p of rolePerms) { + perms.add(p); + } + } + } + + return perms; + } + + // Check if user has a specific permission (direct or via role) + hasPermission(permission: Permission): boolean { + // Check direct permissions first + if (this.data.permissions.includes(permission)) { + return true; + } + + // Check role-derived permissions + for (const role of this.data.roles) { + const rolePerms = this.rolePermissions.get(role); + if (rolePerms?.includes(permission)) { + return true; + } + } + + return false; + } + + // Convenience method: can user perform action on resource? + can(action: string, resource: string): boolean { + const permission = `${resource}:${action}` as Permission; + return this.hasPermission(permission); + } + + // Timestamps + get createdAt(): Date { + return this.data.createdAt; + } + + get updatedAt(): Date { + return this.data.updatedAt; + } + + // Serialization - returns plain object for storage/transmission + toJSON(): UserData { + return { ...this.data }; + } + + toString(): string { + return `User(id ${this.id})`; + } + + abstract isAnonymous(): boolean; +} + +export class AuthenticatedUser extends User { + // Factory for creating new users with sensible defaults + static create( + email: string, + options?: { + id?: string; + displayName?: string; + status?: UserStatus; + roles?: Role[]; + permissions?: Permission[]; + }, + ): User { + const now = new Date(); + return new AuthenticatedUser({ + id: options?.id ?? crypto.randomUUID(), + email, + displayName: options?.displayName, + status: options?.status ?? "active", + roles: options?.roles ?? [], + permissions: options?.permissions ?? [], + createdAt: now, + updatedAt: now, + }); + } + + isAnonymous(): boolean { + return false; + } +} + +// For representing "no user" in contexts where user is optional +export class AnonymousUser extends User { + // FIXME: this is C&Ped with only minimal changes. No bueno. + static create( + email: string, + options?: { + id?: string; + displayName?: string; + status?: UserStatus; + roles?: Role[]; + permissions?: Permission[]; + }, + ): AnonymousUser { + const now = new Date(0); + return new AnonymousUser({ + id: options?.id ?? crypto.randomUUID(), + email, + displayName: options?.displayName, + status: options?.status ?? "active", + roles: options?.roles ?? [], + permissions: options?.permissions ?? [], + createdAt: now, + updatedAt: now, + }); + } + + isAnonymous(): boolean { + return true; + } +} + +export const anonymousUser = AnonymousUser.create("anonymous@example.com", { + id: "-1", + displayName: "Anonymous User", +}); diff --git a/express/util.ts b/express/util.ts new file mode 100644 index 0000000..e818ad8 --- /dev/null +++ b/express/util.ts @@ -0,0 +1,11 @@ +import { readFile } from "node:fs/promises"; + +// FIXME: Handle the error here +const loadFile = async (path: string): Promise => { + // Specifying 'utf8' returns a string; otherwise, it returns a Buffer + const data = await readFile(path, "utf8"); + + return data; +}; + +export { loadFile }; diff --git a/express/watch.sh b/express/watch.sh new file mode 100755 index 0000000..e1fba56 --- /dev/null +++ b/express/watch.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +check_dir="$DIR" + +source "$check_dir"/../framework/shims/common +source "$check_dir"/../framework/shims/node.common + +# $ROOT/cmd pnpm tsc --lib ES2023 --esModuleInterop -w $check_dir/app.ts +# $ROOT/cmd pnpm tsc -w $check_dir/app.ts +$ROOT/cmd pnpm tsc --watch --project ./tsconfig.json diff --git a/fixup.sh b/fixup.sh new file mode 100755 index 0000000..5194cef --- /dev/null +++ b/fixup.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$DIR" + +# uv run ruff check --select I --fix . + +# uv run ruff format . + +shell_scripts="$(fd '.sh$' | xargs)" +shfmt -i 4 -w "$DIR/cmd" "$DIR"/framework/cmd.d/* "$DIR"/framework/shims/* "$DIR"/master/master "$DIR"/logger/logger +# "$shell_scripts" +for ss in $shell_scripts; do + shfmt -i 4 -w $ss +done + +pushd "$DIR/master" +go fmt +popd + +pushd "$DIR/express" +../cmd pnpm biome check --write +popd diff --git a/framework/.nodejs-config/.gitignore b/framework/.nodejs-config/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/framework/.nodejs/.gitignore b/framework/.nodejs/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/framework/binaries/.gitignore b/framework/binaries/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/framework/cmd.d/list b/framework/cmd.d/list new file mode 100755 index 0000000..c9fc066 --- /dev/null +++ b/framework/cmd.d/list @@ -0,0 +1,9 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$DIR" + +ls . diff --git a/framework/cmd.d/node b/framework/cmd.d/node new file mode 100755 index 0000000..c412659 --- /dev/null +++ b/framework/cmd.d/node @@ -0,0 +1,7 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +exec "$DIR"/../shims/node "$@" diff --git a/framework/cmd.d/pnpm b/framework/cmd.d/pnpm new file mode 100755 index 0000000..676d8b0 --- /dev/null +++ b/framework/cmd.d/pnpm @@ -0,0 +1,7 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"$DIR"/../shims/pnpm "$@" diff --git a/framework/cmd.d/sync b/framework/cmd.d/sync new file mode 100755 index 0000000..07b25ba --- /dev/null +++ b/framework/cmd.d/sync @@ -0,0 +1,18 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# figure out the platform we're on + +# source ../framework/versions +# [eventually: check for it in user's cache dir +# download $nodejs_version +# verify its checksum against $nodejs_checksum + +cd "$DIR/../node" + +"$DIR"/pnpm install + +echo we will download other files here later diff --git a/framework/cmd.d/test b/framework/cmd.d/test new file mode 100755 index 0000000..5196eea --- /dev/null +++ b/framework/cmd.d/test @@ -0,0 +1,15 @@ +#!/bin/bash + +set -eu + +shopt -s globstar nullglob + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$DIR/../../express" + +if [ $# -eq 0 ]; then + "$DIR"/../shims/pnpm tsx --test ./**/*.spec.ts ./**/*.test.ts +else + "$DIR"/../shims/pnpm tsx --test "$@" +fi diff --git a/framework/cmd.d/ts-node b/framework/cmd.d/ts-node new file mode 100755 index 0000000..ed006b4 --- /dev/null +++ b/framework/cmd.d/ts-node @@ -0,0 +1,5 @@ +#!/bin/bash + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"$DIR"/../shims/pnpm ts-node "$@" diff --git a/framework/cmd.d/tsx b/framework/cmd.d/tsx new file mode 100755 index 0000000..aad4af9 --- /dev/null +++ b/framework/cmd.d/tsx @@ -0,0 +1,5 @@ +#!/bin/bash + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"$DIR"/../shims/pnpm tsx "$@" diff --git a/framework/common.d/db b/framework/common.d/db new file mode 100755 index 0000000..eef7f71 --- /dev/null +++ b/framework/common.d/db @@ -0,0 +1,9 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$DIR/../.." + +# FIXME: don't hard code this of course +PGPASSWORD=diachron psql -U diachron -h localhost diachron diff --git a/framework/common.d/migrate b/framework/common.d/migrate new file mode 100755 index 0000000..93edf74 --- /dev/null +++ b/framework/common.d/migrate @@ -0,0 +1,9 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$DIR/../.." + +cd "$ROOT/express" +"$DIR"/tsx migrate.ts "$@" diff --git a/framework/develop.d/clear-db b/framework/develop.d/clear-db new file mode 100755 index 0000000..467d490 --- /dev/null +++ b/framework/develop.d/clear-db @@ -0,0 +1,11 @@ +#!/bin/bash + +# This file belongs to the framework. You are not expected to modify it. + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$DIR/../.." + +cd "$ROOT/express" +"$DIR"/../cmd.d/tsx develop/clear-db.ts "$@" diff --git a/framework/develop.d/db b/framework/develop.d/db new file mode 120000 index 0000000..0e89a26 --- /dev/null +++ b/framework/develop.d/db @@ -0,0 +1 @@ +../common.d/db \ No newline at end of file diff --git a/framework/develop.d/migrate b/framework/develop.d/migrate new file mode 120000 index 0000000..5f4d586 --- /dev/null +++ b/framework/develop.d/migrate @@ -0,0 +1 @@ +../common.d/migrate \ No newline at end of file diff --git a/framework/develop.d/reset-db b/framework/develop.d/reset-db new file mode 100755 index 0000000..ac11200 --- /dev/null +++ b/framework/develop.d/reset-db @@ -0,0 +1,9 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$DIR/../.." + +cd "$ROOT/express" +"$DIR"/../cmd.d/tsx develop/reset-db.ts "$@" diff --git a/framework/downloads/.gitignore b/framework/downloads/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/framework/mgmt.d/add-user b/framework/mgmt.d/add-user new file mode 100755 index 0000000..c3c71bd --- /dev/null +++ b/framework/mgmt.d/add-user @@ -0,0 +1,9 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$DIR/../.." + +cd "$ROOT/express" +"$DIR"/../cmd.d/tsx mgmt/add-user.ts "$@" diff --git a/framework/mgmt.d/db b/framework/mgmt.d/db new file mode 120000 index 0000000..0e89a26 --- /dev/null +++ b/framework/mgmt.d/db @@ -0,0 +1 @@ +../common.d/db \ No newline at end of file diff --git a/framework/mgmt.d/migrate b/framework/mgmt.d/migrate new file mode 120000 index 0000000..5f4d586 --- /dev/null +++ b/framework/mgmt.d/migrate @@ -0,0 +1 @@ +../common.d/migrate \ No newline at end of file diff --git a/framework/shims/common b/framework/shims/common new file mode 100644 index 0000000..0da9917 --- /dev/null +++ b/framework/shims/common @@ -0,0 +1,5 @@ +# Fix for https://www.shellcheck.net/wiki/SC2148 +# shellcheck shell=bash + +common_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export ROOT="$common_DIR/../../" diff --git a/framework/shims/node b/framework/shims/node new file mode 100755 index 0000000..5ac98a8 --- /dev/null +++ b/framework/shims/node @@ -0,0 +1,12 @@ +#!/bin/bash + +# This file belongs to the framework. You are not expected to modify it. + +set -eu + +node_shim_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=node.common +source "$node_shim_DIR"/node.common + +exec "$nodejs_binary_dir/node" "$@" diff --git a/framework/shims/node.common b/framework/shims/node.common new file mode 100644 index 0000000..3745766 --- /dev/null +++ b/framework/shims/node.common @@ -0,0 +1,20 @@ +# Fix for https://www.shellcheck.net/wiki/SC2148 +# shellcheck shell=bash + +node_common_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +project_root="$node_common_DIR/../.." + +# shellcheck source=../versions +source "$node_common_DIR"/../versions + +nodejs_binary_dir="$project_root/$nodejs_bin_dir" + +# This might be too restrictive. Or not restrictive enough. +PATH="$nodejs_binary_dir":/bin:/usr/bin + +node_dist_dir="$project_root/$nodejs_dist_dir" + +export NPM_CONFIG_PREFIX="$node_dist_dir/npm" +export NPM_CONFIG_CACHE="$node_dist_dir/cache" +export NPM_CONFIG_TMP="$node_dist_dir/tmp" +export NODE_PATH="$node_dist_dir/node_modules" diff --git a/framework/shims/npm b/framework/shims/npm new file mode 100755 index 0000000..ddd3748 --- /dev/null +++ b/framework/shims/npm @@ -0,0 +1,13 @@ +#!/bin/bash + +set -eu + +npm_shim_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export npm_shim_DIR + +# shellcheck source=node.common +source "$npm_shim_DIR"/node.common + +cd "$npm_shim_DIR"/../.nodejs-config +echo in dir "$(pwd)" +npm "$@" diff --git a/framework/shims/pnpm b/framework/shims/pnpm new file mode 100755 index 0000000..f6dab88 --- /dev/null +++ b/framework/shims/pnpm @@ -0,0 +1,16 @@ +#!/bin/bash + +set -eu + +pnpm_shim_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export pnpm_shim_DIR + +# shellcheck source=./node.common +source "$pnpm_shim_DIR"/node.common + +# shellcheck source=./common +source "$pnpm_shim_DIR"/common + +# cd $ROOT/framework/node + +exec "$pnpm_shim_DIR"/../binaries/pnpm "$@" diff --git a/framework/versions b/framework/versions new file mode 100644 index 0000000..de9fbd4 --- /dev/null +++ b/framework/versions @@ -0,0 +1,19 @@ +# shellcheck shell=bash + +# This file belongs to the framework. You are not expected to modify it. + +# https://nodejs.org/dist +nodejs_binary_linux_x86_64=https://nodejs.org/dist/v24.12.0/node-v24.12.0-linux-x64.tar.xz +nodejs_checksum_linux_x86_64=bdebee276e58d0ef5448f3d5ac12c67daa963dd5e0a9bb621a53d1cefbc852fd +nodejs_dist_dir=framework/binaries/node-v22.15.1-linux-x64 +nodejs_bin_dir="$nodejs_dist_dir/bin" + +caddy_binary_linux_x86_64=fixme +caddy_checksum_linux_x86_64=fixmetoo + +# https://github.com/pnpm/pnpm/releases +pnpm_binary_linux_x86_64=https://github.com/pnpm/pnpm/releases/download/v10.28.0/pnpm-linux-x64 +pnpm_checksum_linux_x86_64=sha256:348e863d17a62411a65f900e8d91395acabae9e9237653ccc3c36cb385965f28 + + +golangci_lint=v2.7.2-alpine \ No newline at end of file diff --git a/logger/.gitignore b/logger/.gitignore new file mode 100644 index 0000000..25304db --- /dev/null +++ b/logger/.gitignore @@ -0,0 +1 @@ +logger-bin diff --git a/logger/go.mod b/logger/go.mod new file mode 100644 index 0000000..dece3c0 --- /dev/null +++ b/logger/go.mod @@ -0,0 +1,3 @@ +module philologue.net/diachron/logger-bin + +go 1.23.3 diff --git a/logger/logger b/logger/logger new file mode 100755 index 0000000..14db0b7 --- /dev/null +++ b/logger/logger @@ -0,0 +1,7 @@ +#!/bin/bash + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$DIR" + +./logger-bin "$@" diff --git a/logger/main.go b/logger/main.go new file mode 100644 index 0000000..f5ec2f1 --- /dev/null +++ b/logger/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "strconv" +) + +func main() { + port := flag.Int("port", 8085, "port to listen on") + capacity := flag.Int("capacity", 1000000, "max messages to store") + + flag.Parse() + + store := NewLogStore(*capacity) + + http.HandleFunc("POST /log", func(w http.ResponseWriter, r *http.Request) { + var msg Message + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + store.Add(msg) + w.WriteHeader(http.StatusCreated) + }) + + http.HandleFunc("GET /logs", func(w http.ResponseWriter, r *http.Request) { + params := FilterParams{} + + if limit := r.URL.Query().Get("limit"); limit != "" { + if n, err := strconv.Atoi(limit); err == nil { + params.Limit = n + } + } + if before := r.URL.Query().Get("before"); before != "" { + if ts, err := strconv.ParseInt(before, 10, 64); err == nil { + params.Before = ts + } + } + if after := r.URL.Query().Get("after"); after != "" { + if ts, err := strconv.ParseInt(after, 10, 64); err == nil { + params.After = ts + } + } + + messages := store.GetFiltered(params) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) + }) + + http.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) { + status := map[string]any{ + "count": store.Count(), + "capacity": *capacity, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) + }) + + listenAddr := fmt.Sprintf(":%d", *port) + log.Printf("[logger] Listening on %s (capacity: %d)", listenAddr, *capacity) + if err := http.ListenAndServe(listenAddr, nil); err != nil { + log.Fatalf("[logger] Failed to start: %v", err) + } +} diff --git a/logger/store.go b/logger/store.go new file mode 100644 index 0000000..3881549 --- /dev/null +++ b/logger/store.go @@ -0,0 +1,126 @@ +package main + +import ( + "sync" +) + +// Message represents a log entry from the express backend +type Message struct { + Timestamp int64 `json:"timestamp"` + Source string `json:"source"` // "logging" | "diagnostic" | "user" + Text []string `json:"text"` +} + +// LogStore is a thread-safe ring buffer for log messages +type LogStore struct { + mu sync.RWMutex + messages []Message + head int // next write position + full bool // whether buffer has wrapped + capacity int +} + +// NewLogStore creates a new log store with the given capacity +func NewLogStore(capacity int) *LogStore { + return &LogStore{ + messages: make([]Message, capacity), + capacity: capacity, + } +} + +// Add inserts a new message into the store +func (s *LogStore) Add(msg Message) { + s.mu.Lock() + defer s.mu.Unlock() + + s.messages[s.head] = msg + s.head++ + if s.head >= s.capacity { + s.head = 0 + s.full = true + } +} + +// Count returns the number of messages in the store +func (s *LogStore) Count() int { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.full { + return s.capacity + } + return s.head +} + +// GetRecent returns the most recent n messages, newest first +func (s *LogStore) GetRecent(n int) []Message { + s.mu.RLock() + defer s.mu.RUnlock() + + count := s.Count() + if n > count { + n = count + } + if n == 0 { + return nil + } + + result := make([]Message, n) + pos := s.head - 1 + for i := 0; i < n; i++ { + if pos < 0 { + pos = s.capacity - 1 + } + result[i] = s.messages[pos] + pos-- + } + return result +} + +// Filter parameters for retrieving logs +type FilterParams struct { + Limit int // max messages to return (0 = default 100) + Before int64 // only messages before this timestamp + After int64 // only messages after this timestamp +} + +// GetFiltered returns messages matching the filter criteria +func (s *LogStore) GetFiltered(params FilterParams) []Message { + s.mu.RLock() + defer s.mu.RUnlock() + + limit := params.Limit + if limit <= 0 { + limit = 100 + } + + count := s.Count() + if count == 0 { + return nil + } + + result := make([]Message, 0, limit) + pos := s.head - 1 + + for i := 0; i < count && len(result) < limit; i++ { + if pos < 0 { + pos = s.capacity - 1 + } + msg := s.messages[pos] + + // Apply filters + if params.Before > 0 && msg.Timestamp >= params.Before { + pos-- + continue + } + if params.After > 0 && msg.Timestamp <= params.After { + pos-- + continue + } + + result = append(result, msg) + pos-- + } + + return result +} diff --git a/master/.gitignore b/master/.gitignore new file mode 100644 index 0000000..824c87f --- /dev/null +++ b/master/.gitignore @@ -0,0 +1 @@ +master-bin diff --git a/master/devrunner.go b/master/devrunner.go new file mode 100644 index 0000000..81ccd36 --- /dev/null +++ b/master/devrunner.go @@ -0,0 +1,84 @@ +// a vibe coded el cheapo: https://claude.ai/chat/328ca558-1019-49b9-9f08-e85cfcea2ceb + +package main + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "sync" + "time" +) + +func runProcess(ctx context.Context, wg *sync.WaitGroup, name, command string) { + defer wg.Done() + + for { + select { + case <-ctx.Done(): + fmt.Printf("[%s] Stopping\n", name) + return + default: + fmt.Printf("[%s] Starting: %s\n", name, command) + + // Create command with context for cancellation + cmd := exec.CommandContext(ctx, "sh", "-c", command) + + // Setup stdout pipe + stdout, err := cmd.StdoutPipe() + if err != nil { + fmt.Fprintf(os.Stderr, "[%s] Error creating stdout pipe: %v\n", name, err) + return + } + + // Setup stderr pipe + stderr, err := cmd.StderrPipe() + if err != nil { + fmt.Fprintf(os.Stderr, "[%s] Error creating stderr pipe: %v\n", name, err) + return + } + + // Start the command + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "[%s] Error starting command: %v\n", name, err) + time.Sleep(time.Second) + continue + } + + // Copy output in separate goroutines + var ioWg sync.WaitGroup + ioWg.Add(2) + + go func() { + defer ioWg.Done() + io.Copy(os.Stdout, stdout) + }() + + go func() { + defer ioWg.Done() + io.Copy(os.Stderr, stderr) + }() + + // Wait for command to finish + err = cmd.Wait() + ioWg.Wait() // Ensure all output is copied + + // Check if we should restart + select { + case <-ctx.Done(): + fmt.Printf("[%s] Stopped\n", name) + return + default: + if err != nil { + fmt.Fprintf(os.Stderr, "[%s] Process exited with error: %v\n", name, err) + } else { + fmt.Printf("[%s] Process exited normally\n", name) + } + fmt.Printf("[%s] Restarting in 1 second...\n", name) + time.Sleep(time.Second) + } + } + } +} diff --git a/master/filechange.go b/master/filechange.go new file mode 100644 index 0000000..71f788f --- /dev/null +++ b/master/filechange.go @@ -0,0 +1,6 @@ +package main + +type FileChange struct { + Path string + Operation string +} diff --git a/master/go.mod b/master/go.mod new file mode 100644 index 0000000..b94b98f --- /dev/null +++ b/master/go.mod @@ -0,0 +1,8 @@ +module philologue.net/diachron/master-bin + +go 1.23.3 + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + golang.org/x/sys v0.13.0 // indirect +) diff --git a/master/go.sum b/master/go.sum new file mode 100644 index 0000000..c1e3272 --- /dev/null +++ b/master/go.sum @@ -0,0 +1,4 @@ +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/master/main.go b/master/main.go new file mode 100644 index 0000000..00d8273 --- /dev/null +++ b/master/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/signal" + "syscall" +) + +func main() { + watchDir := flag.String("watch", "../express", "directory to watch for changes") + workers := flag.Int("workers", 1, "number of worker processes") + basePort := flag.Int("base-port", 3000, "base port for worker processes") + listenPort := flag.Int("port", 8080, "port for the reverse proxy to listen on") + loggerPort := flag.Int("logger-port", 8085, "port for the logger service") + loggerCapacity := flag.Int("logger-capacity", 1000000, "max messages for logger to store") + + flag.Parse() + + // Setup signal handling + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + // Start and manage the logger process + stopLogger := startLogger(*loggerPort, *loggerCapacity) + defer stopLogger() + + // Create worker pool + pool := NewWorkerPool() + + fileChanges := make(chan FileChange, 10) + + go watchFiles(*watchDir, fileChanges) + + go runExpress(fileChanges, *workers, *basePort, pool) + + // Start the reverse proxy + listenAddr := fmt.Sprintf(":%d", *listenPort) + go startProxy(listenAddr, pool) + + // Wait for interrupt signal + <-sigCh + fmt.Println("\nReceived interrupt signal, shutting down...") + + fmt.Println("All processes terminated cleanly") +} diff --git a/master/master b/master/master new file mode 100755 index 0000000..85f88a5 --- /dev/null +++ b/master/master @@ -0,0 +1,9 @@ +#!/bin/bash + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$DIR" + +export diachron_root="$DIR/.." + +./master-bin "$@" diff --git a/master/proxy.go b/master/proxy.go new file mode 100644 index 0000000..1ee52b2 --- /dev/null +++ b/master/proxy.go @@ -0,0 +1,48 @@ +package main + +import ( + "log" + "net/http" + "net/http/httputil" + "net/url" +) + +// startProxy starts an HTTP reverse proxy that forwards requests to workers. +// It acquires a worker from the pool for each request and releases it when done. +func startProxy(listenAddr string, pool *WorkerPool) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Acquire a worker (blocks if none available) + workerAddr, ok := pool.Acquire() + if !ok { + http.Error(w, "Service unavailable", http.StatusServiceUnavailable) + return + } + + // Ensure we release the worker when done + defer pool.Release(workerAddr) + + // Create reverse proxy to the worker + targetURL, err := url.Parse("http://" + workerAddr) + if err != nil { + log.Printf("[proxy] Failed to parse worker URL %s: %v", workerAddr, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + proxy := httputil.NewSingleHostReverseProxy(targetURL) + + // Custom error handler + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("[proxy] Error proxying to %s: %v", workerAddr, err) + http.Error(w, "Bad gateway", http.StatusBadGateway) + } + + log.Printf("[proxy] %s %s -> %s", r.Method, r.URL.Path, workerAddr) + proxy.ServeHTTP(w, r) + }) + + log.Printf("[proxy] Listening on %s", listenAddr) + if err := http.ListenAndServe(listenAddr, handler); err != nil { + log.Fatalf("[proxy] Failed to start: %v", err) + } +} diff --git a/master/runexpress.go b/master/runexpress.go new file mode 100644 index 0000000..a2333f8 --- /dev/null +++ b/master/runexpress.go @@ -0,0 +1,160 @@ +package main + +import ( + "fmt" + "io" + "log" + "os" + "os/exec" + "sync" + "syscall" + "time" +) + +func runExpress(changes <-chan FileChange, numProcesses int, basePort int, pool *WorkerPool) { + var currentProcesses []*exec.Cmd + var mu sync.Mutex + + // Helper to start an express process on a specific port + startExpress := func(port int) *exec.Cmd { + listenAddr := fmt.Sprintf("127.0.0.1:%d", port) + cmd := exec.Command("../express/run.sh", "--listen", listenAddr) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + log.Printf("[express:%d] Failed to start: %v", port, err) + return nil + } + + log.Printf("[express:%d] Started (pid %d)", port, cmd.Process.Pid) + + // Monitor the process in background + go func(p int) { + err := cmd.Wait() + if err != nil { + log.Printf("[express:%d] Process exited: %v", p, err) + } else { + log.Printf("[express:%d] Process exited normally", p) + } + }(port) + + return cmd + } + + // Helper to stop an express process + stopExpress := func(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + + pid := cmd.Process.Pid + log.Printf("[express] Stopping (pid %d)", pid) + cmd.Process.Signal(syscall.SIGTERM) + + // Wait briefly for graceful shutdown + done := make(chan struct{}) + go func() { + cmd.Wait() + close(done) + }() + + select { + case <-done: + log.Printf("[express] Stopped gracefully (pid %d)", pid) + case <-time.After(5 * time.Second): + log.Printf("[express] Force killing (pid %d)", pid) + cmd.Process.Kill() + } + } + + // Helper to stop all express processes + stopAllExpress := func(processes []*exec.Cmd) { + for _, cmd := range processes { + stopExpress(cmd) + } + } + + // Helper to start all express processes and update the worker pool + startAllExpress := func() []*exec.Cmd { + processes := make([]*exec.Cmd, 0, numProcesses) + addresses := make([]string, 0, numProcesses) + for i := 0; i < numProcesses; i++ { + port := basePort + i + addr := fmt.Sprintf("127.0.0.1:%d", port) + cmd := startExpress(port) + if cmd != nil { + processes = append(processes, cmd) + addresses = append(addresses, addr) + } + } + // Update the worker pool with new worker addresses + pool.SetWorkers(addresses) + return processes + } + + // Helper to run the build + runBuild := func() bool { + log.Printf("[build] Starting ncc build...") + + cmd := exec.Command("../express/build.sh") + + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + + if err := cmd.Start(); err != nil { + log.Printf("[build] Failed to start: %v", err) + return false + } + + // Copy output + go io.Copy(os.Stdout, stdout) + go io.Copy(os.Stderr, stderr) + + err := cmd.Wait() + if err != nil { + log.Printf("[build] Failed: %v", err) + return false + } + + log.Printf("[build] Success") + return true + } + + // Debounce timer + var debounceTimer *time.Timer + const debounceDelay = 100 * time.Millisecond + + // Initial build and start + log.Printf("[master] Initial build...") + if runBuild() { + currentProcesses = startAllExpress() + } else { + log.Printf("[master] Initial build failed") + } + + for change := range changes { + log.Printf("[watch] %s: %s", change.Operation, change.Path) + + // Reset debounce timer + if debounceTimer != nil { + debounceTimer.Stop() + } + + debounceTimer = time.AfterFunc(debounceDelay, func() { + if !runBuild() { + log.Printf("[master] Build failed, keeping current processes") + return + } + + mu.Lock() + defer mu.Unlock() + + // Stop all old processes + stopAllExpress(currentProcesses) + + // Start all new processes + currentProcesses = startAllExpress() + }) + } +} diff --git a/master/runlogger.go b/master/runlogger.go new file mode 100644 index 0000000..51552dd --- /dev/null +++ b/master/runlogger.go @@ -0,0 +1,106 @@ +package main + +import ( + "log" + "os" + "os/exec" + "strconv" + "sync" + "syscall" + "time" +) + +// startLogger starts the logger process and returns a function to stop it. +// It automatically restarts the logger if it crashes. +func startLogger(port int, capacity int) func() { + var mu sync.Mutex + var cmd *exec.Cmd + var stopping bool + + portStr := strconv.Itoa(port) + capacityStr := strconv.Itoa(capacity) + + start := func() *exec.Cmd { + c := exec.Command("../logger/logger", "--port", portStr, "--capacity", capacityStr) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + + if err := c.Start(); err != nil { + log.Printf("[logger] Failed to start: %v", err) + return nil + } + + log.Printf("[logger] Started (pid %d) on port %s", c.Process.Pid, portStr) + return c + } + + // Start initial logger + cmd = start() + + // Monitor and restart on crash + go func() { + for { + mu.Lock() + currentCmd := cmd + mu.Unlock() + + if currentCmd == nil { + time.Sleep(time.Second) + mu.Lock() + if !stopping { + cmd = start() + } + mu.Unlock() + continue + } + + err := currentCmd.Wait() + + mu.Lock() + if stopping { + mu.Unlock() + return + } + + if err != nil { + log.Printf("[logger] Process exited: %v, restarting...", err) + } else { + log.Printf("[logger] Process exited normally, restarting...") + } + + time.Sleep(time.Second) + cmd = start() + mu.Unlock() + } + }() + + // Return stop function + return func() { + mu.Lock() + defer mu.Unlock() + + stopping = true + + if cmd == nil || cmd.Process == nil { + return + } + + log.Printf("[logger] Stopping (pid %d)", cmd.Process.Pid) + cmd.Process.Signal(syscall.SIGTERM) + + // Wait briefly for graceful shutdown + done := make(chan struct{}) + go func() { + cmd.Wait() + close(done) + }() + + select { + case <-done: + log.Printf("[logger] Stopped gracefully") + case <-time.After(5 * time.Second): + log.Printf("[logger] Force killing") + cmd.Process.Kill() + } + } +} diff --git a/master/watchfiles.go b/master/watchfiles.go new file mode 100644 index 0000000..c7d31b3 --- /dev/null +++ b/master/watchfiles.go @@ -0,0 +1,102 @@ +package main + +import ( + "log" + "os" + "path/filepath" + "strings" + + "github.com/fsnotify/fsnotify" +) + +// shouldIgnore returns true for paths that should not trigger rebuilds +func shouldIgnore(path string) bool { + // Ignore build output and dependencies + ignoreDirs := []string{"/dist/", "/node_modules/", "/.git/"} + for _, dir := range ignoreDirs { + if strings.Contains(path, dir) { + return true + } + } + // Also ignore if path ends with these directories + for _, dir := range []string{"/dist", "/node_modules", "/.git"} { + if strings.HasSuffix(path, dir) { + return true + } + } + return false +} + +func watchFiles(dir string, changes chan<- FileChange) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Fatal(err) + } + defer watcher.Close() + + // Add all directories recursively (except ignored ones) + err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + if shouldIgnore(path) { + return filepath.SkipDir + } + err = watcher.Add(path) + if err != nil { + log.Printf("Error watching %s: %v\n", path, err) + } + } + return nil + }) + if err != nil { + log.Fatal(err) + } + + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + + // Skip ignored paths + if shouldIgnore(event.Name) { + continue + } + + // Handle different types of events + var operation string + switch { + case event.Op&fsnotify.Write == fsnotify.Write: + operation = "MODIFIED" + case event.Op&fsnotify.Create == fsnotify.Create: + operation = "CREATED" + // If a new directory is created, start watching it + if info, err := os.Stat(event.Name); err == nil && info.IsDir() { + watcher.Add(event.Name) + } + case event.Op&fsnotify.Remove == fsnotify.Remove: + operation = "REMOVED" + case event.Op&fsnotify.Rename == fsnotify.Rename: + operation = "RENAMED" + case event.Op&fsnotify.Chmod == fsnotify.Chmod: + operation = "CHMOD" + default: + operation = "UNKNOWN" + } + + changes <- FileChange{ + Path: event.Name, + Operation: operation, + } + + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Printf("Watcher error: %v\n", err) + } + } +} diff --git a/master/workerpool.go b/master/workerpool.go new file mode 100644 index 0000000..e35a6f7 --- /dev/null +++ b/master/workerpool.go @@ -0,0 +1,75 @@ +package main + +import ( + "log" + "sync" +) + +// WorkerPool manages a pool of worker addresses and tracks their availability. +// Each worker can only handle one request at a time. +type WorkerPool struct { + mu sync.Mutex + workers []string + available chan string + closed bool +} + +// NewWorkerPool creates a new empty worker pool. +func NewWorkerPool() *WorkerPool { + return &WorkerPool{ + available: make(chan string, 100), // buffered to avoid blocking + } +} + +// SetWorkers updates the pool with a new set of worker addresses. +// Called when workers are started or restarted after a rebuild. +func (p *WorkerPool) SetWorkers(addrs []string) { + p.mu.Lock() + defer p.mu.Unlock() + + // Drain the old available channel + close(p.available) + for range p.available { + // drain + } + + // Create new channel and populate with new workers + p.available = make(chan string, len(addrs)+10) + p.workers = make([]string, len(addrs)) + copy(p.workers, addrs) + + for _, addr := range addrs { + p.available <- addr + } + + log.Printf("[pool] Updated workers: %v", addrs) +} + +// Acquire blocks until a worker is available and returns its address. +func (p *WorkerPool) Acquire() (string, bool) { + addr, ok := <-p.available + if ok { + log.Printf("[pool] Acquired worker %s", addr) + } + return addr, ok +} + +// Release marks a worker as available again after it finishes handling a request. +func (p *WorkerPool) Release(addr string) { + p.mu.Lock() + defer p.mu.Unlock() + + // Only release if the worker is still in our current set + for _, w := range p.workers { + if w == addr { + select { + case p.available <- addr: + log.Printf("[pool] Released worker %s", addr) + default: + // Channel full, worker may have been removed + } + return + } + } + // Worker not in current set (probably from before a rebuild), ignore +} diff --git a/mgmt b/mgmt new file mode 100755 index 0000000..703c2ed --- /dev/null +++ b/mgmt @@ -0,0 +1,27 @@ +#!/bin/bash + +# This file belongs to the framework. You are not expected to modify it. + +# Management command runner - parallel to ./cmd for operational tasks +# Usage: ./mgmt [args...] + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ $# -lt 1 ]; then + echo "Usage: ./mgmt [args...]" + echo "" + echo "Available commands:" + for cmd in "$DIR"/framework/mgmt.d/*; do + if [ -x "$cmd" ]; then + basename "$cmd" + fi + done + exit 1 +fi + +subcmd="$1" +shift + +exec "$DIR"/framework/mgmt.d/"$subcmd" "$@" diff --git a/sync.sh b/sync.sh new file mode 100755 index 0000000..40d102e --- /dev/null +++ b/sync.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# Note: This is kind of AI slop and needs to be more carefully reviewed. + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=framework/versions +source "$DIR/framework/versions" + +# Ensure correct node version is installed +node_installed_checksum_file="$DIR/framework/binaries/.node.checksum" +node_installed_checksum="" +if [ -f "$node_installed_checksum_file" ]; then + node_installed_checksum=$(cat "$node_installed_checksum_file") +fi + +if [ "$node_installed_checksum" != "$nodejs_checksum_linux_x86_64" ]; then + echo "Downloading Node.js..." + node_archive="$DIR/framework/downloads/node.tar.xz" + curl -fsSL "$nodejs_binary_linux_x86_64" -o "$node_archive" + + echo "Verifying checksum..." + echo "$nodejs_checksum_linux_x86_64 $node_archive" | sha256sum -c - + + echo "Extracting Node.js..." + tar -xf "$node_archive" -C "$DIR/framework/binaries" + rm "$node_archive" + + echo "$nodejs_checksum_linux_x86_64" >"$node_installed_checksum_file" +fi + +# Ensure correct pnpm version is installed +pnpm_binary="$DIR/framework/binaries/pnpm" +pnpm_installed_checksum_file="$DIR/framework/binaries/.pnpm.checksum" +pnpm_installed_checksum="" +if [ -f "$pnpm_installed_checksum_file" ]; then + pnpm_installed_checksum=$(cat "$pnpm_installed_checksum_file") +fi + +# pnpm checksum includes "sha256:" prefix, strip it for sha256sum +pnpm_checksum="${pnpm_checksum_linux_x86_64#sha256:}" + +if [ "$pnpm_installed_checksum" != "$pnpm_checksum" ]; then + echo "Downloading pnpm..." + curl -fsSL "$pnpm_binary_linux_x86_64" -o "$pnpm_binary" + + echo "Verifying checksum..." + echo "$pnpm_checksum $pnpm_binary" | sha256sum -c - + + chmod +x "$pnpm_binary" + + echo "$pnpm_checksum" >"$pnpm_installed_checksum_file" +fi + +# Get golang binaries in place +cd "$DIR/master" +go build + +cd "$DIR/logger" +go build + +# Update framework code +cd "$DIR/express" +../cmd pnpm install diff --git a/templates/basic/hello.html.njk b/templates/basic/hello.html.njk new file mode 100644 index 0000000..bf2f094 --- /dev/null +++ b/templates/basic/hello.html.njk @@ -0,0 +1,11 @@ + + + +

+ Hello. +

+

+ The current time is {{ now }}. +

+ + diff --git a/templates/basic/home.html.njk b/templates/basic/home.html.njk new file mode 100644 index 0000000..e9608cf --- /dev/null +++ b/templates/basic/home.html.njk @@ -0,0 +1,19 @@ + + + +

+ home +

+

+ + {{ email }} +

+ {% if showLogin %} + login + {% endif %} + + {% if showLogout %} + logout + {% endif %} + + diff --git a/templates/basic/login.html.njk b/templates/basic/login.html.njk new file mode 100644 index 0000000..6b9ae4f --- /dev/null +++ b/templates/basic/login.html.njk @@ -0,0 +1,55 @@ + + + Login + + + +

Login

+ {% if error %} +
{{ error }}
+ {% endif %} +
+ + + +
+ +