44 Commits

Author SHA1 Message Date
811c446895 Pull in kysely-codegen 2026-01-25 12:28:44 -06:00
5a8c0028d7 Add user_credentials migration 2026-01-25 12:14:34 -06:00
f7e6e56aca Merge branch 'experiments' 2026-01-25 12:12:35 -06:00
cd19a32be5 Add more todo items 2026-01-25 12:12:15 -06:00
478305bc4f Update /home template 2026-01-25 12:12:02 -06:00
421628d49e Add various doc updates
They are still very far from complete.
2026-01-25 12:11:34 -06:00
4f37a72d7b Clean commands up 2026-01-24 16:54:54 -06:00
e30bf5d96d Fix regexp in fixup.sh 2026-01-24 16:39:13 -06:00
8704c4a8d5 Separate framework and app migrations
Also add a new develop command: clear-db.
2026-01-24 16:38:33 -06:00
579a19669e Match user and session schema changes 2026-01-24 15:48:22 -06:00
474420ac1e Add development command to reset the database and rerun migrations 2026-01-24 15:13:34 -06:00
960f78a1ad Update initial tables 2026-01-24 15:13:30 -06:00
d921679058 Rework user types: create AuthenticatedUser and AnonymousUser class
Both are subclasses of an abstract User class which contains almost everything
interesting.
2026-01-17 17:45:36 -06:00
350bf7c865 Run shell scripts through shfmt 2026-01-17 16:30:55 -06:00
8a7682e953 Split services into core and request 2026-01-17 16:20:55 -06:00
e59bb35ac9 Update todo list 2026-01-17 16:10:38 -06:00
a345a2adfb Add directive 2026-01-17 16:10:24 -06:00
00d84d6686 Note that files belong to framework 2026-01-17 15:45:02 -06:00
7ed05695b9 Separate happy path utility functions for requests 2026-01-17 15:43:52 -06:00
03cc4cf4eb Remove prettier; we've been using biome for a while 2026-01-17 13:19:40 -06:00
2121a6b5de Merge remote-tracking branch 'crondiad/experiments' into experiments 2026-01-11 16:08:03 -06:00
Michael Wolf
6ace2163ed Update pnpm version 2026-01-11 16:07:32 -06:00
Michael Wolf
93ab4b5d53 Update node version 2026-01-11 16:07:24 -06:00
Michael Wolf
70ddcb2a94 Note that we need bash 2026-01-11 16:06:48 -06:00
Michael Wolf
1da81089cd Add sync.sh script
This downloads and installs dependencies necessary to run or develop.

Add docker-compose.yml for initial use
2026-01-11 16:06:43 -06:00
f383c6a465 Add logger wrapper script 2026-01-11 15:48:32 -06:00
e34d47b352 Add various todo items 2026-01-11 15:36:15 -06:00
de70be996e Add docker-compose.yml for initial use 2026-01-11 15:33:01 -06:00
096a1235b5 Add basic logout 2026-01-11 15:31:59 -06:00
4a4dc11aa4 Fix formatting 2026-01-11 15:17:58 -06:00
7399cbe785 Add / template 2026-01-11 14:57:51 -06:00
14d20be9a2 Note that file belongs to the framework 2026-01-11 14:57:26 -06:00
55f5cc699d Add request-scoped context for session.getUser()
Use AsyncLocalStorage to provide request context so services can access
the current user without needing Call passed through every function.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:56:10 -06:00
afcb447b2b Add a command to add a new user 2026-01-11 14:38:19 -06:00
1c1eeddcbe Add basic login screen with form-based authentication
Adds /login route with HTML template that handles GET (show form) and
POST (authenticate). On successful login, sets session cookie and
redirects to /. Also adds framework support for redirects and cookies
in route handlers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 10:07:02 -06:00
7cecf5326d Make biome happier 2026-01-10 14:02:38 -06:00
47f6bee75f Improve test command to find spec/test files recursively
Use globstar for recursive matching and support both *.spec.ts
and *.test.ts patterns in any subdirectory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 13:55:42 -06:00
6e96c33457 Add very basic support for finding and rendering templates 2026-01-10 13:50:44 -06:00
9e3329fa58 . 2026-01-10 13:38:42 -06:00
05eaf938fa Add test command
For now this just runs typescript tests.  Eventually it'll do more than that.
2026-01-10 13:38:10 -06:00
df2d4eea3f Add initial way to get info about execution context 2026-01-10 13:37:39 -06:00
b235a6be9a Add block for declared var 2026-01-10 13:05:39 -06:00
8cd4b42cc6 Add scripts to run migrations and to connect to the db 2026-01-10 09:05:05 -06:00
a0043fd475 Fix go version 2025-02-08 13:37:29 -06:00
73 changed files with 2462 additions and 388 deletions

1
.go-version Normal file
View File

@@ -0,0 +1 @@
1.23.6

View File

@@ -31,14 +31,14 @@ master process. Key design principles:
### Development ### Development
**Check shell scripts (shellcheck + shfmt) (eventually go fmt and prettier or similar):** **Check shell scripts (shellcheck + shfmt) (eventually go fmt and biome or similar):**
```bash ```bash
./check.sh ./check.sh
``` ```
**Format TypeScript code:** **Format TypeScript code:**
```bash ```bash
cd express && ../cmd pnpm prettier --write . cd express && ../cmd pnpm biome check --write .
``` ```
**Build Go master process:** **Build Go master process:**
@@ -108,6 +108,10 @@ Early stage - most implementations are stubs:
# meta # meta
## formatting and sorting
- When a typescript file exports symbols, they should be listed in order
## guidelines for this document ## guidelines for this document
- Try to keep lines below 80 characters in length, especially prose. But if - Try to keep lines below 80 characters in length, especially prose. But if

View File

@@ -54,10 +54,9 @@ To run a more complete system, you also need to have docker compose installed.
To hack on diachron itself, you need the following: To hack on diachron itself, you need the following:
- bash
- docker and docker compose - docker and docker compose
- [fd](https://github.com/sharkdp/fd) - [fd](https://github.com/sharkdp/fd)
- golang, version 1.23.6 or greater - golang, version 1.23.6 or greater
- shellcheck - shellcheck
- shfmt - shfmt

98
TODO.md
View File

@@ -3,20 +3,44 @@
- [ ] Add unit tests all over the place. - [ ] Add unit tests all over the place.
- ⚠️ Huge task - needs breakdown before starting - ⚠️ Huge task - needs breakdown before starting
- [ ] Create initial docker-compose.yml file for local development
- include most recent stable postgres
- include beanstalkd
- include memcached
- include redis
- include mailpit
- [ ] Add first cut at database access. Remember that ORMs are not all that! - [ ] 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 middleware concept
- [ ] Add authentication - [ ] Add authentication
- password - [ ] password
- third party? - [ ] third party?
- [ ] Add middleware concept
- [ ] Add authorization - [ ] Add authorization
- for specific routes / resources / etc - for specific routes / resources / etc
@@ -25,6 +49,9 @@
Partially done; see the /time route. But we need to figure out where to Partially done; see the /time route. But we need to figure out where to
store templates, static files, etc. 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 ## medium importance
@@ -33,9 +60,34 @@
- convert to logfmt and is there a viewer UI we could pull in and use - convert to logfmt and is there a viewer UI we could pull in and use
instead? 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 - [ ] figure out and add logging to disk
- [ ] Add email verification - [ ] 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: - [ ] Update check script:
- [x] shellcheck on shell scripts - [x] shellcheck on shell scripts
@@ -48,6 +100,17 @@
- upgrade docs - upgrade docs
- starting docs - starting docs
- taking over 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 ## low importance
@@ -72,6 +135,10 @@
code; repeat code; repeat
- Slow start them: only start a few at first - Slow start them: only start a few at first
- [ ] in express/user.ts: FIXME: set createdAt and updatedAt to start of epoch
## finished ## finished
@@ -99,3 +166,12 @@
- [x] Log to logging service from the express backend - [x] Log to logging service from the express backend
- Fill out types and functions in `express/logging.ts` - 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

22
cmd
View File

@@ -2,20 +2,26 @@
# This file belongs to the framework. You are not expected to modify it. # This file belongs to the framework. You are not expected to modify it.
# FIXME: Obviously this file isn't nearly robust enough. Make it so. # Managed binary runner - runs framework-managed binaries like node, pnpm, tsx
# Usage: ./cmd <command> [args...]
set -eu set -eu
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ $# -lt 1 ]; then
echo "Usage: ./cmd <command> [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" subcmd="$1"
# echo "$subcmd"
#exit 3
shift shift
echo will run "$DIR"/framework/cmd.d/"$subcmd" "$@"
exec "$DIR"/framework/cmd.d/"$subcmd" "$@" exec "$DIR"/framework/cmd.d/"$subcmd" "$@"

27
develop Executable file
View File

@@ -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 <command> [args...]
set -eu
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ $# -lt 1 ]; then
echo "Usage: ./develop <command> [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" "$@"

35
docker-compose.yml Normal file
View File

@@ -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:

125
docs/commands.md Normal file
View File

@@ -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 environmentdependent semantics
* Identical behavior in dev, staging, and production
**Examples**
* Handling HTTP requests
* Rendering templates
* Running background jobs / queues
* Sending emails triggered by application logic
**Nongoals**
* 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, *productionsafe* commands used to evolve and maintain a live system.
These commands assume real data exists and must not be casually destroyed.
**Core properties**
* Forwardonly
* 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 optin 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
* Environmentspecific 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
---
## OneSentence Summary
> **App commands run the system, mgmt commands evolve it safely, and develop commands let you break things on purpose — but only where its allowed.**

View File

@@ -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.

View File

@@ -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 frameworks 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 dont 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 frameworks 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.

27
docs/groups-and-roles.md Normal file
View File

@@ -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.

17
docs/index.md Normal file
View File

@@ -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

View File

@@ -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.

1
docs/mutability.md Normal file
View File

@@ -0,0 +1 @@
Describe and define what is expected to be mutable and what is not.

View File

@@ -2,3 +2,14 @@ We use `Call` and `Result` for our own types that wrap `Request` and
`Response`. `Response`.
This hopefully will make things less confusing and avoid problems with shadowing. 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

View File

@@ -1 +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 *frameworkowned* and *applicationowned* 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 dont*, usually during upgrades or emergencies.
---
## Database Ownership
### FrameworkOwned 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 appowned data tightly couples your app to framework internals and blocks safe upgrades.
---
### ApplicationOwned Tables
All domain data belongs to the application.
Examples:
* users (as domain actors, not auth primitives)
* posts, orders, comments, invoices
* businessspecific joins and projections
* denormalized or performanceoriented 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 frameworkexposed identifiers**, or
* introduce **explicit join tables** owned by the application
No hidden coupling, no magic backfills.
---
## Code Ownership
### FrameworkOwned Code
Some classes, constants, and modules are **frameworkowned**.
These include:
* core request/response abstractions
* auth and user primitives
* capability/permission evaluation logic
* lifecycle hooks
* lowlevel utilities relied on by the framework itself
#### Rules
Application code **must not**:
* modify framework source
* monkeypatch or override internals
* rely on undocumented behavior
* change constant values or internal defaults
Framework code is treated as **readonly** from the apps 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 tradeoffs
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
* forwardonly 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 apps data or code**.
---
## A Useful Mental Model
* Frameworkowned things are **constitutional law**
* Applicationowned things are **legislation**
You can write any laws you want — but you dont 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**
* Frameworkowned tables and code are readonly to the app
* Applicationowned 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.

View File

@@ -6,15 +6,19 @@ import { match } from "path-to-regexp";
import { Session } from "./auth"; import { Session } from "./auth";
import { cli } from "./cli"; import { cli } from "./cli";
import { contentTypes } from "./content-types"; import { contentTypes } from "./content-types";
import { runWithContext } from "./context";
import { core } from "./core";
import { httpCodes } from "./http-codes"; import { httpCodes } from "./http-codes";
import { request } from "./request";
import { routes } from "./routes"; import { routes } from "./routes";
import { services } from "./services";
// import { URLPattern } from 'node:url'; // import { URLPattern } from 'node:url';
import { import {
AuthenticationRequired, AuthenticationRequired,
AuthorizationDenied, AuthorizationDenied,
type Call, type Call,
type InternalHandler, type InternalHandler,
isRedirect,
type Method, type Method,
massageMethod, massageMethod,
methodParser, methodParser,
@@ -25,10 +29,11 @@ import {
const app = express(); const app = express();
// Parse JSON request bodies // Parse request bodies
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true }));
services.logging.log({ source: "logging", text: ["1"] }); core.logging.log({ source: "logging", text: ["1"] });
const processedRoutes: { [K in Method]: ProcessedRoute[] } = { const processedRoutes: { [K in Method]: ProcessedRoute[] } = {
GET: [], GET: [],
POST: [], POST: [],
@@ -47,9 +52,9 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => {
const methodList = route.methods; const methodList = route.methods;
const handler: InternalHandler = async ( const handler: InternalHandler = async (
request: ExpressRequest, expressRequest: ExpressRequest,
): Promise<Result> => { ): Promise<Result> => {
const method = massageMethod(request.method); const method = massageMethod(expressRequest.method);
console.log("method", method); console.log("method", method);
@@ -57,23 +62,25 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => {
// XXX: Worth asserting this? // XXX: Worth asserting this?
} }
console.log("request.originalUrl", request.originalUrl); console.log("request.originalUrl", expressRequest.originalUrl);
// Authenticate the request // Authenticate the request
const auth = await services.auth.validateRequest(request); const auth = await request.auth.validateRequest(expressRequest);
const req: Call = { const req: Call = {
pattern: route.path, pattern: route.path,
path: request.originalUrl, path: expressRequest.originalUrl,
method, method,
parameters: { one: 1, two: 2 }, parameters: { one: 1, two: 2 },
request, request: expressRequest,
user: auth.user, user: auth.user,
session: new Session(auth.session, auth.user), session: new Session(auth.session, auth.user),
}; };
try { try {
const retval = await route.handler(req); const retval = await runWithContext({ user: auth.user }, () =>
route.handler(req),
);
return retval; return retval;
} catch (error) { } catch (error) {
// Handle authentication errors // Handle authentication errors
@@ -111,8 +118,15 @@ async function handler(
const method = await methodParser.parseAsync(req.method); const method = await methodParser.parseAsync(req.method);
const byMethod = processedRoutes[method]; const byMethod = processedRoutes[method];
console.log(
"DEBUG: req.path =",
JSON.stringify(req.path),
"method =",
method,
);
for (const [_idx, pr] of byMethod.entries()) { for (const [_idx, pr] of byMethod.entries()) {
const match = pr.matcher(req.url); const match = pr.matcher(req.path);
console.log("DEBUG: trying pattern, match result =", match);
if (match) { if (match) {
console.log("match", match); console.log("match", match);
const resp = await pr.handler(req); const resp = await pr.handler(req);
@@ -124,7 +138,7 @@ async function handler(
const retval: Result = { const retval: Result = {
code: httpCodes.clientErrors.NotFound, code: httpCodes.clientErrors.NotFound,
contentType: contentTypes.text.plain, contentType: contentTypes.text.plain,
result: "not found", result: "not found!",
}; };
return retval; return retval;
@@ -138,7 +152,18 @@ app.use(async (req: ExpressRequest, res: ExpressResponse) => {
console.log(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); res.status(code).send(result);
}
}); });
process.title = `diachron:${cli.listen.port}`; process.title = `diachron:${cli.listen.port}`;

View File

@@ -5,7 +5,7 @@
import { z } from "zod"; import { z } from "zod";
import { contentTypes } from "../content-types"; import { contentTypes } from "../content-types";
import { httpCodes } from "../http-codes"; import { httpCodes } from "../http-codes";
import { services } from "../services"; import { request } from "../request";
import type { Call, Result, Route } from "../types"; import type { Call, Result, Route } from "../types";
import { import {
forgotPasswordInputParser, forgotPasswordInputParser,
@@ -39,7 +39,7 @@ const loginHandler = async (call: Call): Promise<Result> => {
const body = call.request.body; const body = call.request.body;
const { email, password } = loginInputParser.parse(body); const { email, password } = loginInputParser.parse(body);
const result = await services.auth.login(email, password, "cookie", { const result = await request.auth.login(email, password, "cookie", {
userAgent: call.request.get("User-Agent"), userAgent: call.request.get("User-Agent"),
ipAddress: call.request.ip, ipAddress: call.request.ip,
}); });
@@ -72,9 +72,9 @@ const loginHandler = async (call: Call): Promise<Result> => {
// POST /auth/logout // POST /auth/logout
const logoutHandler = async (call: Call): Promise<Result> => { const logoutHandler = async (call: Call): Promise<Result> => {
const token = services.auth.extractToken(call.request); const token = request.auth.extractToken(call.request);
if (token) { if (token) {
await services.auth.logout(token); await request.auth.logout(token);
} }
return jsonResponse(httpCodes.success.OK, { message: "Logged out" }); return jsonResponse(httpCodes.success.OK, { message: "Logged out" });
@@ -87,7 +87,7 @@ const registerHandler = async (call: Call): Promise<Result> => {
const { email, password, displayName } = const { email, password, displayName } =
registerInputParser.parse(body); registerInputParser.parse(body);
const result = await services.auth.register( const result = await request.auth.register(
email, email,
password, password,
displayName, displayName,
@@ -128,7 +128,7 @@ const forgotPasswordHandler = async (call: Call): Promise<Result> => {
const body = call.request.body; const body = call.request.body;
const { email } = forgotPasswordInputParser.parse(body); const { email } = forgotPasswordInputParser.parse(body);
const result = await services.auth.createPasswordResetToken(email); const result = await request.auth.createPasswordResetToken(email);
// Always return success (don't reveal if email exists) // Always return success (don't reveal if email exists)
if (result) { if (result) {
@@ -159,7 +159,7 @@ const resetPasswordHandler = async (call: Call): Promise<Result> => {
const body = call.request.body; const body = call.request.body;
const { token, password } = resetPasswordInputParser.parse(body); const { token, password } = resetPasswordInputParser.parse(body);
const result = await services.auth.resetPassword(token, password); const result = await request.auth.resetPassword(token, password);
if (!result.success) { if (!result.success) {
return errorResponse( return errorResponse(
@@ -195,7 +195,7 @@ const verifyEmailHandler = async (call: Call): Promise<Result> => {
); );
} }
const result = await services.auth.verifyEmail(token); const result = await request.auth.verifyEmail(token);
if (!result.success) { if (!result.success) {
return errorResponse(httpCodes.clientErrors.BadRequest, result.error); return errorResponse(httpCodes.clientErrors.BadRequest, result.error);

View File

@@ -4,7 +4,12 @@
// password reset, and email verification. // password reset, and email verification.
import type { Request as ExpressRequest } from "express"; import type { Request as ExpressRequest } from "express";
import { AnonymousUser, type User, type UserId } from "../user"; import {
type AnonymousUser,
anonymousUser,
type User,
type UserId,
} from "../user";
import { hashPassword, verifyPassword } from "./password"; import { hashPassword, verifyPassword } from "./password";
import type { AuthStore } from "./store"; import type { AuthStore } from "./store";
import { import {
@@ -27,7 +32,7 @@ type SimpleResult = { success: true } | { success: false; error: string };
// Result of validating a request/token - contains both user and session // Result of validating a request/token - contains both user and session
export type AuthResult = export type AuthResult =
| { authenticated: true; user: User; session: SessionData } | { authenticated: true; user: User; session: SessionData }
| { authenticated: false; user: typeof AnonymousUser; session: null }; | { authenticated: false; user: AnonymousUser; session: null };
export class AuthService { export class AuthService {
constructor(private store: AuthStore) {} constructor(private store: AuthStore) {}
@@ -83,7 +88,7 @@ export class AuthService {
} }
if (!token) { if (!token) {
return { authenticated: false, user: AnonymousUser, session: null }; return { authenticated: false, user: anonymousUser, session: null };
} }
return this.validateToken(token); return this.validateToken(token);
@@ -94,16 +99,16 @@ export class AuthService {
const session = await this.store.getSession(tokenId); const session = await this.store.getSession(tokenId);
if (!session) { if (!session) {
return { authenticated: false, user: AnonymousUser, session: null }; return { authenticated: false, user: anonymousUser, session: null };
} }
if (session.tokenType !== "session") { if (session.tokenType !== "session") {
return { authenticated: false, user: AnonymousUser, session: null }; return { authenticated: false, user: anonymousUser, session: null };
} }
const user = await this.store.getUserById(session.userId as UserId); const user = await this.store.getUserById(session.userId as UserId);
if (!user || !user.isActive()) { if (!user || !user.isActive()) {
return { authenticated: false, user: AnonymousUser, session: null }; return { authenticated: false, user: anonymousUser, session: null };
} }
// Update last used (fire and forget) // Update last used (fire and forget)

View File

@@ -3,7 +3,7 @@
// Authentication storage interface and in-memory implementation. // Authentication storage interface and in-memory implementation.
// The interface allows easy migration to PostgreSQL later. // The interface allows easy migration to PostgreSQL later.
import { User, type UserId } from "../user"; import { AuthenticatedUser, type User, type UserId } from "../user";
import { generateToken, hashToken } from "./token"; import { generateToken, hashToken } from "./token";
import type { AuthMethod, SessionData, TokenId, TokenType } from "./types"; import type { AuthMethod, SessionData, TokenId, TokenType } from "./types";
@@ -123,7 +123,7 @@ export class InMemoryAuthStore implements AuthStore {
} }
async createUser(data: CreateUserData): Promise<User> { async createUser(data: CreateUserData): Promise<User> {
const user = User.create(data.email, { const user = AuthenticatedUser.create(data.email, {
displayName: data.displayName, displayName: data.displayName,
status: "pending", // Pending until email verified status: "pending", // Pending until email verified
}); });
@@ -151,7 +151,7 @@ export class InMemoryAuthStore implements AuthStore {
const user = this.users.get(userId); const user = this.users.get(userId);
if (user) { if (user) {
// Create new user with active status // Create new user with active status
const updatedUser = User.create(user.email, { const updatedUser = AuthenticatedUser.create(user.email, {
id: user.id, id: user.id,
displayName: user.displayName, displayName: user.displayName,
status: "active", status: "active",

View File

@@ -64,17 +64,17 @@ export const tokenLifetimes: Record<TokenType, number> = {
}; };
// Import here to avoid circular dependency at module load time // Import here to avoid circular dependency at module load time
import { AnonymousUser, type MaybeUser } from "../user"; import type { User } from "../user";
// Session wrapper class providing a consistent interface for handlers. // Session wrapper class providing a consistent interface for handlers.
// Always present on Call (never null), but may represent an anonymous session. // Always present on Call (never null), but may represent an anonymous session.
export class Session { export class Session {
constructor( constructor(
private readonly data: SessionData | null, private readonly data: SessionData | null,
private readonly user: MaybeUser, private readonly user: User,
) {} ) {}
getUser(): MaybeUser { getUser(): User {
return this.user; return this.user;
} }
@@ -83,7 +83,7 @@ export class Session {
} }
isAuthenticated(): boolean { isAuthenticated(): boolean {
return this.user !== AnonymousUser; return !this.user.isAnonymous();
} }
get tokenId(): string | undefined { get tokenId(): string | undefined {

62
express/basic/login.ts Normal file
View File

@@ -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<Result> => {
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 };

38
express/basic/logout.ts Normal file
View File

@@ -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<Result> => {
// 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 };

43
express/basic/routes.ts Normal file
View File

@@ -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<string, Route> = {
hello: {
path: "/hello",
methods: ["GET"],
handler: async (_call: Call): Promise<Result> => {
const now = DateTime.now();
const c = await render("basic/hello", { now });
return html(c);
},
},
home: {
path: "/",
methods: ["GET"],
handler: async (_call: Call): Promise<Result> => {
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 };

View File

@@ -1,3 +1,5 @@
// This file belongs to the framework. You are not expected to modify it.
export type ContentType = string; export type ContentType = string;
// tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865 // tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865

27
express/context.ts Normal file
View File

@@ -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<RequestContext>();
// Run a function within a request context
function runWithContext<T>(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 };

View File

@@ -1,9 +1,17 @@
// services.ts import nunjucks from "nunjucks";
import { db, migrate, migrationStatus } from "../database";
import { AuthService } from "../auth";
import { db, migrate, migrationStatus, PostgresAuthStore } from "../database";
import { getLogs, log } from "../logging"; import { getLogs, log } from "../logging";
import { anonymousUser, type User } from "../user";
// 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 = { const database = {
db, db,
@@ -28,24 +36,13 @@ const misc = {
}, },
}; };
const session = {
getUser: (): User => {
return anonymousUser;
},
};
// Initialize auth with PostgreSQL store
const authStore = new PostgresAuthStore();
const auth = new AuthService(authStore);
// Keep this asciibetically sorted // Keep this asciibetically sorted
const services = { const core = {
auth, conf,
database, database,
logging, logging,
misc, misc,
random, random,
session,
}; };
export { services }; export { core };

View File

@@ -18,7 +18,8 @@ import type {
} from "./auth/store"; } from "./auth/store";
import { generateToken, hashToken } from "./auth/token"; import { generateToken, hashToken } from "./auth/token";
import type { SessionData, TokenId } from "./auth/types"; import type { SessionData, TokenId } from "./auth/types";
import { User, type UserId } from "./user"; import type { Domain } from "./types";
import { AuthenticatedUser, type User, type UserId } from "./user";
// Connection configuration // Connection configuration
const connectionConfig = { const connectionConfig = {
@@ -33,32 +34,52 @@ const connectionConfig = {
// Generated<T> marks columns with database defaults (optional on insert) // Generated<T> marks columns with database defaults (optional on insert)
interface UsersTable { interface UsersTable {
id: string; id: string;
email: string;
password_hash: string;
display_name: string | null;
status: Generated<string>; status: Generated<string>;
roles: Generated<string[]>; display_name: string | null;
permissions: Generated<string[]>; created_at: Generated<Date>;
email_verified: Generated<boolean>; updated_at: Generated<Date>;
}
interface UserEmailsTable {
id: string;
user_id: string;
email: string;
normalized_email: string;
is_primary: Generated<boolean>;
is_verified: Generated<boolean>;
created_at: Generated<Date>;
verified_at: Date | null;
revoked_at: Date | null;
}
interface UserCredentialsTable {
id: string;
user_id: string;
credential_type: Generated<string>;
password_hash: string | null;
created_at: Generated<Date>; created_at: Generated<Date>;
updated_at: Generated<Date>; updated_at: Generated<Date>;
} }
interface SessionsTable { interface SessionsTable {
token_id: string; id: Generated<string>;
token_hash: string;
user_id: string; user_id: string;
user_email_id: string | null;
token_type: string; token_type: string;
auth_method: string; auth_method: string;
created_at: Generated<Date>; created_at: Generated<Date>;
expires_at: Date; expires_at: Date;
last_used_at: Date | null; revoked_at: Date | null;
user_agent: string | null;
ip_address: string | null; ip_address: string | null;
user_agent: string | null;
is_used: Generated<boolean | null>; is_used: Generated<boolean | null>;
} }
interface Database { interface Database {
users: UsersTable; users: UsersTable;
user_emails: UserEmailsTable;
user_credentials: UserCredentialsTable;
sessions: SessionsTable; sessions: SessionsTable;
} }
@@ -87,12 +108,13 @@ async function raw<T = unknown>(
// ============================================================================ // ============================================================================
// Migration file naming convention: // Migration file naming convention:
// NNNN_description.sql // yyyy-mm-dd_ss_description.sql
// e.g., 0001_initial.sql, 0002_add_users.sql // e.g., 2025-01-15_01_initial.sql, 2025-01-15_02_add_users.sql
// //
// Migrations directory: express/migrations/ // Migrations directory: express/migrations/
const MIGRATIONS_DIR = path.join(__dirname, "migrations"); const FRAMEWORK_MIGRATIONS_DIR = path.join(__dirname, "framework/migrations");
const APP_MIGRATIONS_DIR = path.join(__dirname, "migrations");
const MIGRATIONS_TABLE = "_migrations"; const MIGRATIONS_TABLE = "_migrations";
interface MigrationRecord { interface MigrationRecord {
@@ -121,22 +143,34 @@ async function getAppliedMigrations(): Promise<string[]> {
} }
// Get pending migration files // Get pending migration files
function getMigrationFiles(): string[] { function getMigrationFiles(kind: Domain): string[] {
if (!fs.existsSync(MIGRATIONS_DIR)) { const dir = kind === "fw" ? FRAMEWORK_MIGRATIONS_DIR : APP_MIGRATIONS_DIR;
if (!fs.existsSync(dir)) {
return []; return [];
} }
return fs
.readdirSync(MIGRATIONS_DIR) const root = __dirname;
const mm = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".sql")) .filter((f) => f.endsWith(".sql"))
.filter((f) => /^\d{4}_/.test(f)) .filter((f) => /^\d{4}-\d{2}-\d{2}_\d{2}-/.test(f))
.map((f) => `${dir}/${f}`)
.map((f) => f.replace(`${root}/`, ""))
.sort(); .sort();
return mm;
} }
// Run a single migration // Run a single migration
async function runMigration(filename: string): Promise<void> { async function runMigration(filename: string): Promise<void> {
const filepath = path.join(MIGRATIONS_DIR, filename); // const filepath = path.join(MIGRATIONS_DIR, filename);
const filepath = filename;
const content = fs.readFileSync(filepath, "utf-8"); const content = fs.readFileSync(filepath, "utf-8");
process.stdout.write(` Migration: ${filename}...`);
// Run migration in a transaction // Run migration in a transaction
const client = await pool.connect(); const client = await pool.connect();
try { try {
@@ -147,8 +181,11 @@ async function runMigration(filename: string): Promise<void> {
[filename], [filename],
); );
await client.query("COMMIT"); await client.query("COMMIT");
console.log(`Applied migration: ${filename}`); console.log(" ✓");
} catch (err) { } catch (err) {
console.log(" ✗");
const message = err instanceof Error ? err.message : String(err);
console.error(` Error: ${message}`);
await client.query("ROLLBACK"); await client.query("ROLLBACK");
throw err; throw err;
} finally { } finally {
@@ -156,24 +193,31 @@ async function runMigration(filename: string): Promise<void> {
} }
} }
function getAllMigrationFiles() {
const fw_files = getMigrationFiles("fw");
const app_files = getMigrationFiles("app");
const all = [...fw_files, ...app_files];
return all;
}
// Run all pending migrations // Run all pending migrations
async function migrate(): Promise<void> { async function migrate(): Promise<void> {
await ensureMigrationsTable(); await ensureMigrationsTable();
const applied = new Set(await getAppliedMigrations()); const applied = new Set(await getAppliedMigrations());
const files = getMigrationFiles(); const all = getAllMigrationFiles();
const pending = files.filter((f) => !applied.has(f)); const pending = all.filter((all) => !applied.has(all));
if (pending.length === 0) { if (pending.length === 0) {
console.log("No pending migrations"); console.log("No pending migrations");
return; return;
} }
console.log(`Running ${pending.length} migration(s)...`); console.log(`Applying ${pending.length} migration(s):`);
for (const file of pending) { for (const file of pending) {
await runMigration(file); await runMigration(file);
} }
console.log("Migrations complete");
} }
// List migration status // List migration status
@@ -183,10 +227,10 @@ async function migrationStatus(): Promise<{
}> { }> {
await ensureMigrationsTable(); await ensureMigrationsTable();
const applied = new Set(await getAppliedMigrations()); const applied = new Set(await getAppliedMigrations());
const files = getMigrationFiles(); const ff = getAllMigrationFiles();
return { return {
applied: files.filter((f) => applied.has(f)), applied: ff.filter((ff) => applied.has(ff)),
pending: files.filter((f) => !applied.has(f)), pending: ff.filter((ff) => !applied.has(ff)),
}; };
} }
@@ -201,12 +245,12 @@ class PostgresAuthStore implements AuthStore {
data: CreateSessionData, data: CreateSessionData,
): Promise<{ token: string; session: SessionData }> { ): Promise<{ token: string; session: SessionData }> {
const token = generateToken(); const token = generateToken();
const tokenId = hashToken(token); const tokenHash = hashToken(token);
const row = await db const row = await db
.insertInto("sessions") .insertInto("sessions")
.values({ .values({
token_id: tokenId, token_hash: tokenHash,
user_id: data.userId, user_id: data.userId,
token_type: data.tokenType, token_type: data.tokenType,
auth_method: data.authMethod, auth_method: data.authMethod,
@@ -218,13 +262,12 @@ class PostgresAuthStore implements AuthStore {
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
const session: SessionData = { const session: SessionData = {
tokenId: row.token_id, tokenId: row.token_hash,
userId: row.user_id, userId: row.user_id,
tokenType: row.token_type as SessionData["tokenType"], tokenType: row.token_type as SessionData["tokenType"],
authMethod: row.auth_method as SessionData["authMethod"], authMethod: row.auth_method as SessionData["authMethod"],
createdAt: row.created_at, createdAt: row.created_at,
expiresAt: row.expires_at, expiresAt: row.expires_at,
lastUsedAt: row.last_used_at ?? undefined,
userAgent: row.user_agent ?? undefined, userAgent: row.user_agent ?? undefined,
ipAddress: row.ip_address ?? undefined, ipAddress: row.ip_address ?? undefined,
isUsed: row.is_used ?? undefined, isUsed: row.is_used ?? undefined,
@@ -237,8 +280,9 @@ class PostgresAuthStore implements AuthStore {
const row = await db const row = await db
.selectFrom("sessions") .selectFrom("sessions")
.selectAll() .selectAll()
.where("token_id", "=", tokenId) .where("token_hash", "=", tokenId)
.where("expires_at", ">", new Date()) .where("expires_at", ">", new Date())
.where("revoked_at", "is", null)
.executeTakeFirst(); .executeTakeFirst();
if (!row) { if (!row) {
@@ -246,50 +290,62 @@ class PostgresAuthStore implements AuthStore {
} }
return { return {
tokenId: row.token_id, tokenId: row.token_hash,
userId: row.user_id, userId: row.user_id,
tokenType: row.token_type as SessionData["tokenType"], tokenType: row.token_type as SessionData["tokenType"],
authMethod: row.auth_method as SessionData["authMethod"], authMethod: row.auth_method as SessionData["authMethod"],
createdAt: row.created_at, createdAt: row.created_at,
expiresAt: row.expires_at, expiresAt: row.expires_at,
lastUsedAt: row.last_used_at ?? undefined,
userAgent: row.user_agent ?? undefined, userAgent: row.user_agent ?? undefined,
ipAddress: row.ip_address ?? undefined, ipAddress: row.ip_address ?? undefined,
isUsed: row.is_used ?? undefined, isUsed: row.is_used ?? undefined,
}; };
} }
async updateLastUsed(tokenId: TokenId): Promise<void> { async updateLastUsed(_tokenId: TokenId): Promise<void> {
await db // The new schema doesn't have last_used_at column
.updateTable("sessions") // This is now a no-op; session activity tracking could be added later
.set({ last_used_at: new Date() })
.where("token_id", "=", tokenId)
.execute();
} }
async deleteSession(tokenId: TokenId): Promise<void> { async deleteSession(tokenId: TokenId): Promise<void> {
// Soft delete by setting revoked_at
await db await db
.deleteFrom("sessions") .updateTable("sessions")
.where("token_id", "=", tokenId) .set({ revoked_at: new Date() })
.where("token_hash", "=", tokenId)
.execute(); .execute();
} }
async deleteUserSessions(userId: UserId): Promise<number> { async deleteUserSessions(userId: UserId): Promise<number> {
const result = await db const result = await db
.deleteFrom("sessions") .updateTable("sessions")
.set({ revoked_at: new Date() })
.where("user_id", "=", userId) .where("user_id", "=", userId)
.where("revoked_at", "is", null)
.executeTakeFirst(); .executeTakeFirst();
return Number(result.numDeletedRows); return Number(result.numUpdatedRows);
} }
// User operations // User operations
async getUserByEmail(email: string): Promise<User | null> { async getUserByEmail(email: string): Promise<User | null> {
// Find user through user_emails table
const normalizedEmail = email.toLowerCase().trim();
const row = await db const row = await db
.selectFrom("users") .selectFrom("user_emails")
.selectAll() .innerJoin("users", "users.id", "user_emails.user_id")
.where(sql`LOWER(email)`, "=", email.toLowerCase()) .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(); .executeTakeFirst();
if (!row) { if (!row) {
@@ -299,10 +355,24 @@ class PostgresAuthStore implements AuthStore {
} }
async getUserById(userId: UserId): Promise<User | null> { async getUserById(userId: UserId): Promise<User | null> {
// Get user with their primary email
const row = await db const row = await db
.selectFrom("users") .selectFrom("users")
.selectAll() .leftJoin("user_emails", (join) =>
.where("id", "=", userId) 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(); .executeTakeFirst();
if (!row) { if (!row) {
@@ -312,68 +382,149 @@ class PostgresAuthStore implements AuthStore {
} }
async createUser(data: CreateUserData): Promise<User> { async createUser(data: CreateUserData): Promise<User> {
const id = crypto.randomUUID(); const userId = crypto.randomUUID();
const emailId = crypto.randomUUID();
const credentialId = crypto.randomUUID();
const now = new Date(); const now = new Date();
const normalizedEmail = data.email.toLowerCase().trim();
const row = await db // Create user record
await db
.insertInto("users") .insertInto("users")
.values({ .values({
id, id: userId,
email: data.email,
password_hash: data.passwordHash,
display_name: data.displayName ?? null, display_name: data.displayName ?? null,
status: "pending", status: "pending",
roles: [],
permissions: [],
email_verified: false,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}) })
.returningAll() .execute();
.executeTakeFirstOrThrow();
return this.rowToUser(row); // 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<string | null> { async getUserPasswordHash(userId: UserId): Promise<string | null> {
const row = await db const row = await db
.selectFrom("users") .selectFrom("user_credentials")
.select("password_hash") .select("password_hash")
.where("id", "=", userId) .where("user_id", "=", userId)
.where("credential_type", "=", "password")
.executeTakeFirst(); .executeTakeFirst();
return row?.password_hash ?? null; return row?.password_hash ?? null;
} }
async setUserPassword(userId: UserId, passwordHash: string): Promise<void> { async setUserPassword(userId: UserId, passwordHash: string): Promise<void> {
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 await db
.updateTable("users") .updateTable("users")
.set({ password_hash: passwordHash, updated_at: new Date() }) .set({ updated_at: now })
.where("id", "=", userId) .where("id", "=", userId)
.execute(); .execute();
} }
async updateUserEmailVerified(userId: UserId): Promise<void> { async updateUserEmailVerified(userId: UserId): Promise<void> {
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 await db
.updateTable("users") .updateTable("users")
.set({ .set({
email_verified: true,
status: "active", status: "active",
updated_at: new Date(), updated_at: now,
}) })
.where("id", "=", userId) .where("id", "=", userId)
.execute(); .execute();
} }
// Helper to convert database row to User object // Helper to convert database row to User object
private rowToUser(row: Selectable<UsersTable>): User { private rowToUser(row: {
return new User({ id: string;
status: string;
display_name: string | null;
created_at: Date;
updated_at: Date;
email: string | null;
}): User {
return new AuthenticatedUser({
id: row.id, id: row.id,
email: row.email, email: row.email ?? "unknown@example.com",
displayName: row.display_name ?? undefined, displayName: row.display_name ?? undefined,
status: row.status as "active" | "suspended" | "pending", status: row.status as "active" | "suspended" | "pending",
roles: row.roles, roles: [], // TODO: query from RBAC tables
permissions: row.permissions, permissions: [], // TODO: query from RBAC tables
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}); });

View File

@@ -0,0 +1,17 @@
import { connectionConfig, migrate, pool } from "../database";
import { dropTables, exitIfUnforced } from "./util";
async function main(): Promise<void> {
exitIfUnforced();
try {
await dropTables();
} finally {
await pool.end();
}
}
main().catch((err) => {
console.error("Failed to clear database:", err.message);
process.exit(1);
});

View File

@@ -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<void> {
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);
});

42
express/develop/util.ts Normal file
View File

@@ -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 };

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
export const executionContextSchema = z.object({
diachron_root: z.string(),
});
export type ExecutionContext = z.infer<typeof executionContextSchema>;
export function parseExecutionContext(
env: Record<string, string | undefined>,
): ExecutionContext {
return executionContextSchema.parse(env);
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1,5 @@
import { parseExecutionContext } from "./execution-context-schema";
const executionContext = parseExecutionContext(process.env);
export { executionContext };

View File

@@ -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;

View File

@@ -2,15 +2,17 @@
-- Create sessions table for auth tokens -- Create sessions table for auth tokens
CREATE TABLE sessions ( CREATE TABLE sessions (
token_id TEXT PRIMARY KEY, id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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, token_type TEXT NOT NULL,
auth_method TEXT NOT NULL, auth_method TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ,
ip_address INET,
user_agent TEXT, user_agent TEXT,
ip_address TEXT,
is_used BOOLEAN DEFAULT FALSE is_used BOOLEAN DEFAULT FALSE
); );

View File

@@ -0,0 +1,17 @@
-- 0003_user_credentials.sql
-- Create user_credentials table for password storage (extensible for other auth methods)
CREATE TABLE user_credentials (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
credential_type TEXT NOT NULL DEFAULT 'password',
password_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Each user can have at most one credential per type
CREATE UNIQUE INDEX user_credentials_user_type_idx ON user_credentials (user_id, credential_type);
-- Index for user lookups
CREATE INDEX user_credentials_user_id_idx ON user_credentials (user_id);

View File

@@ -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)
);

View File

@@ -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)
);

View File

@@ -1,11 +1,11 @@
import { contentTypes } from "./content-types"; import { contentTypes } from "./content-types";
import { core } from "./core";
import { httpCodes } from "./http-codes"; import { httpCodes } from "./http-codes";
import { services } from "./services";
import type { Call, Handler, Result } from "./types"; import type { Call, Handler, Result } from "./types";
const multiHandler: Handler = async (call: Call): Promise<Result> => { const multiHandler: Handler = async (call: Call): Promise<Result> => {
const code = httpCodes.success.OK; const code = httpCodes.success.OK;
const rn = services.random.randomNumber(); const rn = core.random.randomNumber();
const retval: Result = { const retval: Result = {
code, code,

View File

@@ -1,3 +1,5 @@
// This file belongs to the framework. You are not expected to modify it.
export type HttpCode = { export type HttpCode = {
code: number; code: number;
name: string; name: string;

69
express/mgmt/add-user.ts Normal file
View File

@@ -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<void> {
const args = process.argv.slice(2);
if (args.length < 2) {
console.error(
"Usage: ./mgmt add-user <email> <password> [--display-name <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);
});

45
express/migrate.ts Normal file
View File

@@ -0,0 +1,45 @@
// migrate.ts
// CLI script for running database migrations
import { migrate, migrationStatus, pool } from "./database";
async function main(): Promise<void> {
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);
});

View File

@@ -1,21 +0,0 @@
-- 0001_users.sql
-- Create users table for authentication
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
status TEXT NOT NULL DEFAULT 'pending',
roles TEXT[] NOT NULL DEFAULT '{}',
permissions TEXT[] NOT NULL DEFAULT '{}',
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for email lookups (login)
CREATE INDEX users_email_idx ON users (LOWER(email));
-- Index for status filtering
CREATE INDEX users_status_idx ON users (status);

View File

@@ -5,7 +5,6 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"prettier": "prettier",
"nodemon": "nodemon dist/index.js" "nodemon": "nodemon dist/index.js"
}, },
"keywords": [], "keywords": [],
@@ -13,7 +12,6 @@
"license": "ISC", "license": "ISC",
"packageManager": "pnpm@10.12.4", "packageManager": "pnpm@10.12.4",
"dependencies": { "dependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/nunjucks": "^3.2.6", "@types/nunjucks": "^3.2.6",
"@vercel/ncc": "^0.38.4", "@vercel/ncc": "^0.38.4",
@@ -23,32 +21,16 @@
"nunjucks": "^3.2.4", "nunjucks": "^3.2.4",
"path-to-regexp": "^8.3.0", "path-to-regexp": "^8.3.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"prettier": "^3.6.2",
"ts-luxon": "^6.2.0", "ts-luxon": "^6.2.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"prettier": {
"arrowParens": "always",
"bracketSpacing": true,
"trailingComma": "all",
"tabWidth": 4,
"semi": true,
"singleQuote": false,
"importOrder": [
"<THIRD_PARTY_MODULES>",
"^[./]"
],
"importOrderCaseSensitive": true,
"plugins": [
"@ianvs/prettier-plugin-sort-imports"
]
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.3.10", "@biomejs/biome": "2.3.10",
"@types/express": "^5.0.5", "@types/express": "^5.0.5",
"@types/pg": "^8.16.0" "@types/pg": "^8.16.0",
"kysely-codegen": "^0.19.0"
} }
} }

525
express/pnpm-lock.yaml generated
View File

@@ -8,9 +8,6 @@ importers:
.: .:
dependencies: dependencies:
'@ianvs/prettier-plugin-sort-imports':
specifier: ^4.7.0
version: 4.7.0(prettier@3.6.2)
'@types/node': '@types/node':
specifier: ^24.10.1 specifier: ^24.10.1
version: 24.10.1 version: 24.10.1
@@ -38,9 +35,6 @@ importers:
pg: pg:
specifier: ^8.16.3 specifier: ^8.16.3
version: 8.16.3 version: 8.16.3
prettier:
specifier: ^3.6.2
version: 3.6.2
ts-luxon: ts-luxon:
specifier: ^6.2.0 specifier: ^6.2.0
version: 6.2.0 version: 6.2.0
@@ -66,46 +60,20 @@ importers:
'@types/pg': '@types/pg':
specifier: ^8.16.0 specifier: ^8.16.0
version: 8.16.0 version: 8.16.0
kysely-codegen:
specifier: ^0.19.0
version: 0.19.0(kysely@0.28.9)(pg@8.16.3)(typescript@5.9.3)
packages: packages:
'@babel/code-frame@7.27.1': '@babel/code-frame@7.28.6':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
engines: {node: '>=6.9.0'}
'@babel/generator@7.28.5':
resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==}
engines: {node: '>=6.9.0'}
'@babel/helper-globals@7.28.0':
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5': '@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/parser@7.28.5':
resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.28.5':
resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==}
engines: {node: '>=6.9.0'}
'@babel/types@7.28.5':
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
'@biomejs/biome@2.3.10': '@biomejs/biome@2.3.10':
resolution: {integrity: sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==} resolution: {integrity: sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==}
engines: {node: '>=14.21.3'} engines: {node: '>=14.21.3'}
@@ -319,27 +287,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@ianvs/prettier-plugin-sort-imports@4.7.0':
resolution: {integrity: sha512-soa2bPUJAFruLL4z/CnMfSEKGznm5ebz29fIa9PxYtu8HHyLKNE1NXAs6dylfw1jn/ilEIfO2oLLN6uAafb7DA==}
peerDependencies:
'@prettier/plugin-oxc': ^0.0.4
'@vue/compiler-sfc': 2.7.x || 3.x
content-tag: ^4.0.0
prettier: 2 || 3 || ^4.0.0-0
prettier-plugin-ember-template-tag: ^2.1.0
peerDependenciesMeta:
'@prettier/plugin-oxc':
optional: true
'@vue/compiler-sfc':
optional: true
content-tag:
optional: true
prettier-plugin-ember-template-tag:
optional: true
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/resolve-uri@3.1.2': '@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@@ -347,9 +294,6 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5': '@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@jridgewell/trace-mapping@0.3.9': '@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
@@ -427,6 +371,14 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
ansi-styles@3.2.1:
resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
engines: {node: '>=4'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
anymatch@3.1.3: anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -434,6 +386,9 @@ packages:
arg@4.1.3: arg@4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
asap@2.0.6: asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
@@ -467,10 +422,35 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chokidar@3.6.0: chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.3:
resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
commander@5.1.0: commander@5.1.0:
resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -494,6 +474,15 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
cosmiconfig@9.0.0:
resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
engines: {node: '>=14'}
peerDependencies:
typescript: '>=4.9.5'
peerDependenciesMeta:
typescript:
optional: true
create-require@1.1.1: create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
@@ -510,10 +499,26 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
diff@3.5.0:
resolution: {integrity: sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==}
engines: {node: '>=0.3.1'}
diff@4.0.2: diff@4.0.2:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'} engines: {node: '>=0.3.1'}
dotenv-expand@12.0.3:
resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==}
engines: {node: '>=12'}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -525,6 +530,13 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
es-define-property@1.0.1: es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -545,6 +557,10 @@ packages:
escape-html@1.0.3: escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
etag@1.8.1: etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -569,6 +585,9 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -588,10 +607,18 @@ packages:
get-tsconfig@4.13.0: get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
git-diff@2.0.6:
resolution: {integrity: sha512-/Iu4prUrydE3Pb3lCBMbcSNIf81tgGt0W1ZwknnyF62t3tHmtiJTRj0f+1ZIhp3+Rh0ktz1pJVoa7ZXUCskivA==}
engines: {node: '>= 4.8.0'}
glob-parent@5.1.2: glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
gopd@1.2.0: gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -600,6 +627,10 @@ packages:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'} engines: {node: '>=4'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-symbols@1.1.0: has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -623,17 +654,36 @@ packages:
ignore-by-default@1.0.1: ignore-by-default@1.0.1:
resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
interpret@1.4.0:
resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==}
engines: {node: '>= 0.10'}
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
is-binary-path@2.1.0: is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'} engines: {node: '>=8'}
is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
is-extglob@2.1.1: is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -652,15 +702,59 @@ packages:
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
jsesc@3.1.0: js-yaml@4.1.1:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
engines: {node: '>=6'}
hasBin: true hasBin: true
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
kysely-codegen@0.19.0:
resolution: {integrity: sha512-ZpdQQnpfY0kh45CA6yPA9vdFsBE+b06Fx7QVcbL5rX//yjbA0yYGZGhnH7GTd4P4BY/HIv5uAfuOD83JVZf95w==}
engines: {node: '>=20.0.0'}
hasBin: true
peerDependencies:
'@libsql/kysely-libsql': '>=0.3.0 <0.5.0'
'@tediousjs/connection-string': '>=0.5.0 <0.6.0'
better-sqlite3: '>=7.6.2 <13.0.0'
kysely: '>=0.27.0 <1.0.0'
kysely-bun-sqlite: '>=0.3.2 <1.0.0'
kysely-bun-worker: '>=1.2.0 <2.0.0'
mysql2: '>=2.3.3 <4.0.0'
pg: '>=8.8.0 <9.0.0'
tarn: '>=3.0.0 <4.0.0'
tedious: '>=18.0.0 <20.0.0'
peerDependenciesMeta:
'@libsql/kysely-libsql':
optional: true
'@tediousjs/connection-string':
optional: true
better-sqlite3:
optional: true
kysely-bun-sqlite:
optional: true
kysely-bun-worker:
optional: true
mysql2:
optional: true
pg:
optional: true
tarn:
optional: true
tedious:
optional: true
kysely@0.28.9: kysely@0.28.9:
resolution: {integrity: sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==} resolution: {integrity: sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
loglevel@1.9.2:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
make-error@1.3.6: make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
@@ -676,6 +770,10 @@ packages:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'} engines: {node: '>=18'}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.54.0: mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -687,6 +785,9 @@ packages:
minimatch@3.1.2: minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -724,10 +825,25 @@ packages:
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parseurl@1.3.3: parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
path-to-regexp@8.3.0: path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
@@ -772,6 +888,10 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
postgres-array@2.0.0: postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -788,11 +908,6 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
engines: {node: '>=14'}
hasBin: true
proxy-addr@2.0.7: proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -816,9 +931,22 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'} engines: {node: '>=8.10.0'}
rechoir@0.6.2:
resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==}
engines: {node: '>= 0.10'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
resolve-pkg-maps@1.0.0: resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
hasBin: true
router@2.2.0: router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@@ -845,6 +973,15 @@ packages:
setprototypeof@1.2.0: setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
shelljs.exec@1.1.8:
resolution: {integrity: sha512-vFILCw+lzUtiwBAHV8/Ex8JsFjelFMdhONIsgKNLgTzeRckp2AOYRQtHJE/9LhNvdMmE27AGtzWx0+DHpwIwSw==}
engines: {node: '>= 4.0.0'}
shelljs@0.8.5:
resolution: {integrity: sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==}
engines: {node: '>=4'}
hasBin: true
side-channel-list@1.0.0: side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -881,6 +1018,14 @@ packages:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'} engines: {node: '>=4'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
to-regex-range@5.0.1: to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
@@ -958,53 +1103,14 @@ packages:
snapshots: snapshots:
'@babel/code-frame@7.27.1': '@babel/code-frame@7.28.6':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
js-tokens: 4.0.0 js-tokens: 4.0.0
picocolors: 1.1.1 picocolors: 1.1.1
'@babel/generator@7.28.5':
dependencies:
'@babel/parser': 7.28.5
'@babel/types': 7.28.5
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/helper-globals@7.28.0': {}
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.28.5':
dependencies:
'@babel/types': 7.28.5
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/parser': 7.28.5
'@babel/types': 7.28.5
'@babel/traverse@7.28.5':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/generator': 7.28.5
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.28.5
'@babel/template': 7.27.2
'@babel/types': 7.28.5
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
'@babel/types@7.28.5':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@biomejs/biome@2.3.10': '@biomejs/biome@2.3.10':
optionalDependencies: optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.3.10 '@biomejs/cli-darwin-arm64': 2.3.10
@@ -1122,31 +1228,10 @@ snapshots:
'@esbuild/win32-x64@0.25.12': '@esbuild/win32-x64@0.25.12':
optional: true optional: true
'@ianvs/prettier-plugin-sort-imports@4.7.0(prettier@3.6.2)':
dependencies:
'@babel/generator': 7.28.5
'@babel/parser': 7.28.5
'@babel/traverse': 7.28.5
'@babel/types': 7.28.5
prettier: 3.6.2
semver: 7.7.3
transitivePeerDependencies:
- supports-color
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping@0.3.9': '@jridgewell/trace-mapping@0.3.9':
dependencies: dependencies:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
@@ -1232,6 +1317,14 @@ snapshots:
acorn@8.15.0: {} acorn@8.15.0: {}
ansi-styles@3.2.1:
dependencies:
color-convert: 1.9.3
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
anymatch@3.1.3: anymatch@3.1.3:
dependencies: dependencies:
normalize-path: 3.0.0 normalize-path: 3.0.0
@@ -1239,6 +1332,8 @@ snapshots:
arg@4.1.3: {} arg@4.1.3: {}
argparse@2.0.1: {}
asap@2.0.6: {} asap@2.0.6: {}
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
@@ -1280,6 +1375,19 @@ snapshots:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
callsites@3.1.0: {}
chalk@2.4.2:
dependencies:
ansi-styles: 3.2.1
escape-string-regexp: 1.0.5
supports-color: 5.5.0
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chokidar@3.6.0: chokidar@3.6.0:
dependencies: dependencies:
anymatch: 3.1.3 anymatch: 3.1.3
@@ -1292,6 +1400,18 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.3: {}
color-name@1.1.4: {}
commander@5.1.0: {} commander@5.1.0: {}
concat-map@0.0.1: {} concat-map@0.0.1: {}
@@ -1306,6 +1426,15 @@ snapshots:
cookie@0.7.2: {} cookie@0.7.2: {}
cosmiconfig@9.0.0(typescript@5.9.3):
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.1
js-yaml: 4.1.1
parse-json: 5.2.0
optionalDependencies:
typescript: 5.9.3
create-require@1.1.1: {} create-require@1.1.1: {}
debug@4.4.3(supports-color@5.5.0): debug@4.4.3(supports-color@5.5.0):
@@ -1316,8 +1445,18 @@ snapshots:
depd@2.0.0: {} depd@2.0.0: {}
diff@3.5.0: {}
diff@4.0.2: {} diff@4.0.2: {}
dotenv-expand@12.0.3:
dependencies:
dotenv: 16.6.1
dotenv@16.6.1: {}
dotenv@17.2.3: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -1328,6 +1467,12 @@ snapshots:
encodeurl@2.0.0: {} encodeurl@2.0.0: {}
env-paths@2.2.1: {}
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
es-define-property@1.0.1: {} es-define-property@1.0.1: {}
es-errors@1.3.0: {} es-errors@1.3.0: {}
@@ -1367,6 +1512,8 @@ snapshots:
escape-html@1.0.3: {} escape-html@1.0.3: {}
escape-string-regexp@1.0.5: {}
etag@1.8.1: {} etag@1.8.1: {}
express@5.1.0: express@5.1.0:
@@ -1420,6 +1567,8 @@ snapshots:
fresh@2.0.0: {} fresh@2.0.0: {}
fs.realpath@1.0.0: {}
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -1447,14 +1596,33 @@ snapshots:
dependencies: dependencies:
resolve-pkg-maps: 1.0.0 resolve-pkg-maps: 1.0.0
git-diff@2.0.6:
dependencies:
chalk: 2.4.2
diff: 3.5.0
loglevel: 1.9.2
shelljs: 0.8.5
shelljs.exec: 1.1.8
glob-parent@5.1.2: glob-parent@5.1.2:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
minimatch: 3.1.2
once: 1.4.0
path-is-absolute: 1.0.1
gopd@1.2.0: {} gopd@1.2.0: {}
has-flag@3.0.0: {} has-flag@3.0.0: {}
has-flag@4.0.0: {}
has-symbols@1.1.0: {} has-symbols@1.1.0: {}
hasown@2.0.2: hasown@2.0.2:
@@ -1479,14 +1647,32 @@ snapshots:
ignore-by-default@1.0.1: {} ignore-by-default@1.0.1: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
resolve-from: 4.0.0
inflight@1.0.6:
dependencies:
once: 1.4.0
wrappy: 1.0.2
inherits@2.0.4: {} inherits@2.0.4: {}
interpret@1.4.0: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
is-arrayish@0.2.1: {}
is-binary-path@2.1.0: is-binary-path@2.1.0:
dependencies: dependencies:
binary-extensions: 2.3.0 binary-extensions: 2.3.0
is-core-module@2.16.1:
dependencies:
hasown: 2.0.2
is-extglob@2.1.1: {} is-extglob@2.1.1: {}
is-glob@4.0.3: is-glob@4.0.3:
@@ -1499,10 +1685,35 @@ snapshots:
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
jsesc@3.1.0: {} js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
json-parse-even-better-errors@2.3.1: {}
kysely-codegen@0.19.0(kysely@0.28.9)(pg@8.16.3)(typescript@5.9.3):
dependencies:
chalk: 4.1.2
cosmiconfig: 9.0.0(typescript@5.9.3)
dotenv: 17.2.3
dotenv-expand: 12.0.3
git-diff: 2.0.6
kysely: 0.28.9
micromatch: 4.0.8
minimist: 1.2.8
pluralize: 8.0.0
zod: 4.1.12
optionalDependencies:
pg: 8.16.3
transitivePeerDependencies:
- typescript
kysely@0.28.9: {} kysely@0.28.9: {}
lines-and-columns@1.2.4: {}
loglevel@1.9.2: {}
make-error@1.3.6: {} make-error@1.3.6: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
@@ -1511,6 +1722,11 @@ snapshots:
merge-descriptors@2.0.0: {} merge-descriptors@2.0.0: {}
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.1
mime-db@1.54.0: {} mime-db@1.54.0: {}
mime-types@3.0.1: mime-types@3.0.1:
@@ -1521,6 +1737,8 @@ snapshots:
dependencies: dependencies:
brace-expansion: 1.1.12 brace-expansion: 1.1.12
minimist@1.2.8: {}
ms@2.1.3: {} ms@2.1.3: {}
negotiator@1.0.0: {} negotiator@1.0.0: {}
@@ -1558,8 +1776,23 @@ snapshots:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
parse-json@5.2.0:
dependencies:
'@babel/code-frame': 7.28.6
error-ex: 1.3.4
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parseurl@1.3.3: {} parseurl@1.3.3: {}
path-is-absolute@1.0.1: {}
path-parse@1.0.7: {}
path-to-regexp@8.3.0: {} path-to-regexp@8.3.0: {}
pg-cloudflare@1.2.7: pg-cloudflare@1.2.7:
@@ -1601,6 +1834,8 @@ snapshots:
picomatch@2.3.1: {} picomatch@2.3.1: {}
pluralize@8.0.0: {}
postgres-array@2.0.0: {} postgres-array@2.0.0: {}
postgres-bytea@1.0.1: {} postgres-bytea@1.0.1: {}
@@ -1611,8 +1846,6 @@ snapshots:
dependencies: dependencies:
xtend: 4.0.2 xtend: 4.0.2
prettier@3.6.2: {}
proxy-addr@2.0.7: proxy-addr@2.0.7:
dependencies: dependencies:
forwarded: 0.2.0 forwarded: 0.2.0
@@ -1637,8 +1870,20 @@ snapshots:
dependencies: dependencies:
picomatch: 2.3.1 picomatch: 2.3.1
rechoir@0.6.2:
dependencies:
resolve: 1.22.11
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
router@2.2.0: router@2.2.0:
dependencies: dependencies:
debug: 4.4.3(supports-color@5.5.0) debug: 4.4.3(supports-color@5.5.0)
@@ -1682,6 +1927,14 @@ snapshots:
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
shelljs.exec@1.1.8: {}
shelljs@0.8.5:
dependencies:
glob: 7.2.3
interpret: 1.4.0
rechoir: 0.6.2
side-channel-list@1.0.0: side-channel-list@1.0.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -1724,6 +1977,12 @@ snapshots:
dependencies: dependencies:
has-flag: 3.0.0 has-flag: 3.0.0
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-preserve-symlinks-flag@1.0.0: {}
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0

25
express/request/index.ts Normal file
View File

@@ -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 };

45
express/request/util.ts Normal file
View File

@@ -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<string> => {
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 };

View File

@@ -3,10 +3,11 @@
import nunjucks from "nunjucks"; import nunjucks from "nunjucks";
import { DateTime } from "ts-luxon"; import { DateTime } from "ts-luxon";
import { authRoutes } from "./auth/routes"; import { authRoutes } from "./auth/routes";
import { routes as basicRoutes } from "./basic/routes";
import { contentTypes } from "./content-types"; import { contentTypes } from "./content-types";
import { core } from "./core";
import { multiHandler } from "./handlers"; import { multiHandler } from "./handlers";
import { httpCodes } from "./http-codes"; import { httpCodes } from "./http-codes";
import { services } from "./services";
import type { Call, Result, Route } from "./types"; import type { Call, Result, Route } from "./types";
// FIXME: Obviously put this somewhere else // FIXME: Obviously put this somewhere else
@@ -24,13 +25,17 @@ const okText = (result: string): Result => {
const routes: Route[] = [ const routes: Route[] = [
...authRoutes, ...authRoutes,
basicRoutes.home,
basicRoutes.hello,
basicRoutes.login,
basicRoutes.logout,
{ {
path: "/slow", path: "/slow",
methods: ["GET"], methods: ["GET"],
handler: async (_call: Call): Promise<Result> => { handler: async (_call: Call): Promise<Result> => {
console.log("starting slow request"); console.log("starting slow request");
await services.misc.sleep(2); await core.misc.sleep(2);
console.log("finishing slow request"); console.log("finishing slow request");
const retval = okText("that was slow"); const retval = okText("that was slow");

View File

@@ -8,12 +8,7 @@ import { z } from "zod";
import type { Session } from "./auth/types"; import type { Session } from "./auth/types";
import type { ContentType } from "./content-types"; import type { ContentType } from "./content-types";
import type { HttpCode } from "./http-codes"; import type { HttpCode } from "./http-codes";
import { import type { Permission, User } from "./user";
AnonymousUser,
type MaybeUser,
type Permission,
type User,
} from "./user";
const methodParser = z.union([ const methodParser = z.union([
z.literal("GET"), z.literal("GET"),
@@ -36,7 +31,7 @@ export type Call = {
method: Method; method: Method;
parameters: object; parameters: object;
request: ExpressRequest; request: ExpressRequest;
user: MaybeUser; user: User;
session: Session; session: Session;
}; };
@@ -49,12 +44,35 @@ export type ProcessedRoute = {
handler: InternalHandler; 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 = { export type Result = {
code: HttpCode; code: HttpCode;
contentType: ContentType; contentType: ContentType;
result: string; 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 = { export type Route = {
path: string; path: string;
methods: Method[]; methods: Method[];
@@ -79,7 +97,7 @@ export class AuthorizationDenied extends Error {
// Helper for handlers to require authentication // Helper for handlers to require authentication
export function requireAuth(call: Call): User { export function requireAuth(call: Call): User {
if (call.user === AnonymousUser) { if (call.user.isAnonymous()) {
throw new AuthenticationRequired(); throw new AuthenticationRequired();
} }
return call.user; return call.user;
@@ -94,4 +112,6 @@ export function requirePermission(call: Call, permission: Permission): User {
return user; return user;
} }
export type Domain = "app" | "fw";
export { methodParser, massageMethod }; export { methodParser, massageMethod };

View File

@@ -51,39 +51,15 @@ const defaultRolePermissions: RolePermissionMap = new Map([
["user", ["users:read"]], ["user", ["users:read"]],
]); ]);
export class User { export abstract class User {
private readonly data: UserData; protected readonly data: UserData;
private rolePermissions: RolePermissionMap; protected rolePermissions: RolePermissionMap;
constructor(data: UserData, rolePermissions?: RolePermissionMap) { constructor(data: UserData, rolePermissions?: RolePermissionMap) {
this.data = userDataParser.parse(data); this.data = userDataParser.parse(data);
this.rolePermissions = rolePermissions ?? defaultRolePermissions; this.rolePermissions = rolePermissions ?? defaultRolePermissions;
} }
// 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 User({
id: options?.id ?? crypto.randomUUID(),
email,
displayName: options?.displayName,
status: options?.status ?? "active",
roles: options?.roles ?? [],
permissions: options?.permissions ?? [],
createdAt: now,
updatedAt: now,
});
}
// Identity // Identity
get id(): UserId { get id(): UserId {
return this.data.id as UserId; return this.data.id as UserId;
@@ -185,15 +161,72 @@ export class User {
toString(): string { toString(): string {
return `User(id ${this.id})`; 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 // For representing "no user" in contexts where user is optional
export const AnonymousUser = Symbol("AnonymousUser"); 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,
});
}
export const anonymousUser = User.create("anonymous@example.com", { isAnonymous(): boolean {
return true;
}
}
export const anonymousUser = AnonymousUser.create("anonymous@example.com", {
id: "-1", id: "-1",
displayName: "Anonymous User", displayName: "Anonymous User",
// FIXME: set createdAt and updatedAt to start of epoch
}); });
export type MaybeUser = User | typeof AnonymousUser;

11
express/util.ts Normal file
View File

@@ -0,0 +1,11 @@
import { readFile } from "node:fs/promises";
// FIXME: Handle the error here
const loadFile = async (path: string): Promise<string> => {
// Specifying 'utf8' returns a string; otherwise, it returns a Buffer
const data = await readFile(path, "utf8");
return data;
};
export { loadFile };

View File

@@ -10,7 +10,7 @@ cd "$DIR"
# uv run ruff format . # uv run ruff format .
shell_scripts="$(fd .sh | xargs)" shell_scripts="$(fd '.sh$' | xargs)"
shfmt -i 4 -w "$DIR/cmd" "$DIR"/framework/cmd.d/* "$DIR"/framework/shims/* "$DIR"/master/master "$DIR"/logger/logger shfmt -i 4 -w "$DIR/cmd" "$DIR"/framework/cmd.d/* "$DIR"/framework/shims/* "$DIR"/master/master "$DIR"/logger/logger
# "$shell_scripts" # "$shell_scripts"
for ss in $shell_scripts; do for ss in $shell_scripts; do

15
framework/cmd.d/test Executable file
View File

@@ -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

9
framework/common.d/db Executable file
View File

@@ -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

9
framework/common.d/migrate Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
set -eu
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$DIR/../.."
cd "$ROOT/express"
"$DIR"/tsx migrate.ts "$@"

11
framework/develop.d/clear-db Executable file
View File

@@ -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 "$@"

1
framework/develop.d/db Symbolic link
View File

@@ -0,0 +1 @@
../common.d/db

1
framework/develop.d/migrate Symbolic link
View File

@@ -0,0 +1 @@
../common.d/migrate

9
framework/develop.d/reset-db Executable file
View File

@@ -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 "$@"

9
framework/mgmt.d/add-user Executable file
View File

@@ -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 "$@"

1
framework/mgmt.d/db Symbolic link
View File

@@ -0,0 +1 @@
../common.d/db

1
framework/mgmt.d/migrate Symbolic link
View File

@@ -0,0 +1 @@
../common.d/migrate

View File

@@ -5,10 +5,8 @@
set -eu set -eu
node_shim_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" node_shim_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export node_shim_DIR
source "$node_shim_DIR"/../versions
# shellcheck source=node.common
source "$node_shim_DIR"/node.common source "$node_shim_DIR"/node.common
exec "$nodejs_binary_dir/node" "$@" exec "$nodejs_binary_dir/node" "$@"

View File

@@ -2,23 +2,19 @@
# shellcheck shell=bash # shellcheck shell=bash
node_common_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" node_common_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
project_root="$node_common_DIR/../.."
# FIXME this shouldn't be hardcoded here of course # shellcheck source=../versions
nodejs_binary_dir="$node_common_DIR/../binaries/node-v22.15.1-linux-x64/bin" source "$node_common_DIR"/../versions
nodejs_binary_dir="$project_root/$nodejs_bin_dir"
# This might be too restrictive. Or not restrictive enough. # This might be too restrictive. Or not restrictive enough.
PATH="$nodejs_binary_dir":/bin:/usr/bin PATH="$nodejs_binary_dir":/bin:/usr/bin
project_root="$node_common_DIR/../.." node_dist_dir="$project_root/$nodejs_dist_dir"
node_dir="$project_root/$nodejs_binary_dir" export NPM_CONFIG_PREFIX="$node_dist_dir/npm"
export NPM_CONFIG_CACHE="$node_dist_dir/cache"
export NPM_CONFIG_PREFIX="$node_dir/npm" export NPM_CONFIG_TMP="$node_dist_dir/tmp"
export NPM_CONFIG_CACHE="$node_dir/cache" export NODE_PATH="$node_dist_dir/node_modules"
export NPM_CONFIG_TMP="$node_dir/tmp"
export NODE_PATH="$node_dir/node_modules"
# echo $NPM_CONFIG_PREFIX
# echo $NPM_CONFIG_CACHE
# echo $NPM_CONFIG_TMP
# echo $NODE_PATH

19
framework/versions Normal file
View File

@@ -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

7
logger/logger Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
./logger-bin "$@"

View File

@@ -4,4 +4,6 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR" cd "$DIR"
export diachron_root="$DIR/.."
./master-bin "$@" ./master-bin "$@"

27
mgmt Executable file
View File

@@ -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 <command> [args...]
set -eu
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ $# -lt 1 ]; then
echo "Usage: ./mgmt <command> [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" "$@"

66
sync.sh Executable file
View File

@@ -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

View File

@@ -0,0 +1,11 @@
<html>
<head></head>
<body>
<p>
Hello.
</p>
<p>
The current time is {{ now }}.
</p>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<html>
<head></head>
<body>
<p>
home
</p>
<p>
{{ email }}
</p>
{% if showLogin %}
<a href="/login">login</a>
{% endif %}
{% if showLogout %}
<a href="/logout">logout</a>
{% endif %}
</body>
</html>

View File

@@ -0,0 +1,55 @@
<html>
<head>
<title>Login</title>
<style>
body {
font-family: system-ui, sans-serif;
max-width: 400px;
margin: 50px auto;
padding: 20px;
}
form {
display: flex;
flex-direction: column;
gap: 15px;
}
label {
display: flex;
flex-direction: column;
gap: 5px;
}
input {
padding: 8px;
font-size: 16px;
}
button {
padding: 10px;
font-size: 16px;
cursor: pointer;
}
.error {
color: red;
padding: 10px;
background: #fee;
border: 1px solid #fcc;
}
</style>
</head>
<body>
<h1>Login</h1>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="POST" action="/login">
<label>
Email
<input type="email" name="email" required value="{{ email | default('') }}">
</label>
<label>
Password
<input type="password" name="password" required>
</label>
<button type="submit">Login</button>
</form>
</body>
</html>