From 19d4309272f4563d65f5b7cc674d9fb59d89f126 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 8 Feb 2025 18:27:31 -0600 Subject: [PATCH 001/137] Add several constants --- deno/content-types.ts | 36 ++++++++++++++++++++++++++++++++++++ deno/http-codes.ts | 41 +++++++++++++++++++++++++++++++++++++++++ deno/interfaces.ts | 1 + 3 files changed, 78 insertions(+) create mode 100644 deno/content-types.ts create mode 100644 deno/http-codes.ts create mode 100644 deno/interfaces.ts diff --git a/deno/content-types.ts b/deno/content-types.ts new file mode 100644 index 0000000..cd15683 --- /dev/null +++ b/deno/content-types.ts @@ -0,0 +1,36 @@ +import { Extensible } from "./interfaces"; + +const contentTypes: Extensible = { + text: { + plain: "text/plain", + html: "text/html", + css: "text/css", + javascript: "text/javascript", + xml: "text/xml", + }, + image: { + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + svgPlusXml: "image/svg+xml", + webp: "image/webp", + }, + audio: { + "mpeg": "audio/mpeg", + "wav": "audio/wav", + }, + video: { + mp4: "video/mp4", + webm: "video/webm", + xMsvideo: "video/x-msvideo", + }, + application: { + json: "application/json", + pdf: "application/pdf", + zip: "application/zip", + xWwwFormUrlencoded: "x-www-form-urlencoded", + octetStream: "octet-stream", + }, +}; + +export { contentTypes }; diff --git a/deno/http-codes.ts b/deno/http-codes.ts new file mode 100644 index 0000000..ee611d7 --- /dev/null +++ b/deno/http-codes.ts @@ -0,0 +1,41 @@ +import { Extensible } from "./interfaces"; + +type HttpCode = { + code: number; + name: string; + description?: string; +}; +type Group = "success" | "redirection" | "clientErrors" | "serverErrors"; +type CodeDefinitions = { + [K in Group]: { + [K: string]: HttpCode; + }; +}; + +const httpCodes: CodeDefinitions & Extensible = { + success: { + OK: { code: 200, name: "OK", "description": "" }, + Created: { code: 201, name: "Created" }, + Accepted: { code: 202, name: "Accepted" }, + NoContent: { code: 204, name: "No content" }, + }, + redirection: { + // later + }, + clientErrors: { + BadRequest: { code: 400, name: "Bad Request" }, + Unauthorized: { code: 401, name: "Unauthorized" }, + Forbidden: { code: 403, name: "Forbidden" }, + NotFound: { code: 404, name: "Not Found" }, + MethodNotAllowed: { code: 405, name: "Method Not Allowed" }, + NotAcceptable: { code: 406, name: "Not Acceptable" }, + // More later + }, + serverErrors: { + InternalServerError: { code: 500, name: "Internal Server Error" }, + NotImplemented: { code: 500, name: "Not implemented" }, + // more later + }, +}; + +export { httpCodes }; diff --git a/deno/interfaces.ts b/deno/interfaces.ts new file mode 100644 index 0000000..3914b1b --- /dev/null +++ b/deno/interfaces.ts @@ -0,0 +1 @@ +export interface Extensible {} From b0f69a6dbe2b0ff4b638411298615bbf20cfa1be Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 8 Feb 2025 19:30:22 -0600 Subject: [PATCH 002/137] Export types --- deno/content-types.ts | 4 +++- deno/http-codes.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/deno/content-types.ts b/deno/content-types.ts index cd15683..e3cdcd2 100644 --- a/deno/content-types.ts +++ b/deno/content-types.ts @@ -1,6 +1,8 @@ import { Extensible } from "./interfaces"; -const contentTypes: Extensible = { +export type ContentType = string + +const contentTypes= { text: { plain: "text/plain", html: "text/html", diff --git a/deno/http-codes.ts b/deno/http-codes.ts index ee611d7..572a490 100644 --- a/deno/http-codes.ts +++ b/deno/http-codes.ts @@ -1,6 +1,6 @@ import { Extensible } from "./interfaces"; -type HttpCode = { +export type HttpCode = { code: number; name: string; description?: string; From 82a0a290d44ac9a69ed0c742adee2cdf78b0e90b Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 8 Feb 2025 19:30:35 -0600 Subject: [PATCH 003/137] Add deno project files --- deno/deno.json | 12 +++ deno/deno.lock | 194 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 deno/deno.json create mode 100644 deno/deno.lock diff --git a/deno/deno.json b/deno/deno.json new file mode 100644 index 0000000..998c73b --- /dev/null +++ b/deno/deno.json @@ -0,0 +1,12 @@ +{ + "tasks": { + "dev": "deno run --watch main.ts" + }, + "imports": { + "@std/assert": "jsr:@std/assert@1", + "zod": "npm:zod@^3.24.1" + }, + "fmt": { + "indentWidth": 4 + } +} diff --git a/deno/deno.lock b/deno/deno.lock new file mode 100644 index 0000000..178d8a6 --- /dev/null +++ b/deno/deno.lock @@ -0,0 +1,194 @@ +{ + "version": "4", + "specifiers": { + "jsr:@std/assert@1": "1.0.11", + "jsr:@std/async@1": "1.0.10", + "jsr:@std/bytes@1": "1.0.5", + "jsr:@std/bytes@^1.0.2-rc.3": "1.0.5", + "jsr:@std/internal@^1.0.5": "1.0.5", + "jsr:@std/io@0.224.5": "0.224.5", + "npm:zod@^3.24.1": "3.24.1" + }, + "jsr": { + "@std/assert@1.0.11": { + "integrity": "2461ef3c368fe88bc60e186e7744a93112f16fd110022e113a0849e94d1c83c1", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/async@1.0.10": { + "integrity": "2ff1b1c7d33d1416159989b0f69e59ec7ee8cb58510df01e454def2108b3dbec" + }, + "@std/bytes@1.0.5": { + "integrity": "4465dd739d7963d964c809202ebea6d5c6b8e3829ef25c6a224290fbb8a1021e" + }, + "@std/internal@1.0.5": { + "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" + }, + "@std/io@0.224.5": { + "integrity": "cb84fe655d1273fca94efcff411465027a8b0b4225203f19d6ee98d9c8920a2d", + "dependencies": [ + "jsr:@std/bytes@^1.0.2-rc.3" + ] + } + }, + "npm": { + "zod@3.24.1": { + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==" + } + }, + "redirects": { + "https://deno.land/x/random_number/mod.ts": "https://deno.land/x/random_number@2.0.0/mod.ts" + }, + "remote": { + "https://deno.land/std@0.214.0/assert/assert.ts": "bec068b2fccdd434c138a555b19a2c2393b71dfaada02b7d568a01541e67cdc5", + "https://deno.land/std@0.214.0/assert/assertion_error.ts": "9f689a101ee586c4ce92f52fa7ddd362e86434ffdf1f848e45987dc7689976b8", + "https://deno.land/std@0.214.0/async/delay.ts": "8e1d18fe8b28ff95885e2bc54eccec1713f57f756053576d8228e6ca110793ad", + "https://deno.land/std@0.214.0/bytes/copy.ts": "f29c03168853720dfe82eaa57793d0b9e3543ebfe5306684182f0f1e3bfd422a", + "https://deno.land/std@0.214.0/crypto/_fnv/fnv32.ts": "ba2c5ef976b9f047d7ce2d33dfe18671afc75154bcf20ef89d932b2fe8820535", + "https://deno.land/std@0.214.0/crypto/_fnv/fnv64.ts": "580cadfe2ff333fe253d15df450f927c8ac7e408b704547be26aab41b5772558", + "https://deno.land/std@0.214.0/crypto/_fnv/mod.ts": "8dbb60f062a6e77b82f7a62ac11fabfba52c3cd408c21916b130d8f57a880f96", + "https://deno.land/std@0.214.0/crypto/_fnv/util.ts": "27b36ce3440d0a180af6bf1cfc2c326f68823288540a354dc1d636b781b9b75f", + "https://deno.land/std@0.214.0/crypto/_wasm/lib/deno_std_wasm_crypto.generated.mjs": "76c727912539737def4549bb62a96897f37eb334b979f49c57b8af7a1617635e", + "https://deno.land/std@0.214.0/crypto/_wasm/mod.ts": "c55f91473846827f077dfd7e5fc6e2726dee5003b6a5747610707cdc638a22ba", + "https://deno.land/std@0.214.0/crypto/crypto.ts": "4448f8461c797adba8d70a2c60f7795a546d7a0926e96366391bffdd06491c16", + "https://deno.land/std@0.214.0/datetime/_common.ts": "a62214c1924766e008e27d3d843ceba4b545dc2aa9880de0ecdef9966d5736b6", + "https://deno.land/std@0.214.0/datetime/parse.ts": "bb248bbcb3cd54bcaf504a1ee670fc4695e429d9019c06af954bbe2bcb8f1d02", + "https://deno.land/std@0.214.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376", + "https://deno.land/std@0.214.0/encoding/base64.ts": "96e61a556d933201266fea84ae500453293f2aff130057b579baafda096a96bc", + "https://deno.land/std@0.214.0/encoding/hex.ts": "4d47d3b25103cf81a2ed38f54b394d39a77b63338e1eaa04b70c614cb45ec2e6", + "https://deno.land/std@0.214.0/fmt/colors.ts": "aeaee795471b56fc62a3cb2e174ed33e91551b535f44677f6320336aabb54fbb", + "https://deno.land/std@0.214.0/io/buf_reader.ts": "c73aad99491ee6db3d6b001fa4a780e9245c67b9296f5bad9c0fa7384e35d47a", + "https://deno.land/std@0.214.0/io/buf_writer.ts": "f82f640c8b3a820f600a8da429ad0537037c7d6a78426bbca2396fb1f75d3ef4", + "https://deno.land/std@0.214.0/path/_common/assert_path.ts": "2ca275f36ac1788b2acb60fb2b79cb06027198bc2ba6fb7e163efaedde98c297", + "https://deno.land/std@0.214.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2", + "https://deno.land/std@0.214.0/path/_common/common.ts": "6157c7ec1f4db2b4a9a187efd6ce76dcaf1e61cfd49f87e40d4ea102818df031", + "https://deno.land/std@0.214.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c", + "https://deno.land/std@0.214.0/path/_common/dirname.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", + "https://deno.land/std@0.214.0/path/_common/format.ts": "92500e91ea5de21c97f5fe91e178bae62af524b72d5fcd246d6d60ae4bcada8b", + "https://deno.land/std@0.214.0/path/_common/from_file_url.ts": "d672bdeebc11bf80e99bf266f886c70963107bdd31134c4e249eef51133ceccf", + "https://deno.land/std@0.214.0/path/_common/glob_to_reg_exp.ts": "2007aa87bed6eb2c8ae8381adcc3125027543d9ec347713c1ad2c68427330770", + "https://deno.land/std@0.214.0/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", + "https://deno.land/std@0.214.0/path/_common/normalize_string.ts": "dfdf657a1b1a7db7999f7c575ee7e6b0551d9c20f19486c6c3f5ff428384c965", + "https://deno.land/std@0.214.0/path/_common/relative.ts": "faa2753d9b32320ed4ada0733261e3357c186e5705678d9dd08b97527deae607", + "https://deno.land/std@0.214.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a", + "https://deno.land/std@0.214.0/path/_common/to_file_url.ts": "7f76adbc83ece1bba173e6e98a27c647712cab773d3f8cbe0398b74afc817883", + "https://deno.land/std@0.214.0/path/_interface.ts": "a1419fcf45c0ceb8acdccc94394e3e94f99e18cfd32d509aab514c8841799600", + "https://deno.land/std@0.214.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15", + "https://deno.land/std@0.214.0/path/basename.ts": "5d341aadb7ada266e2280561692c165771d071c98746fcb66da928870cd47668", + "https://deno.land/std@0.214.0/path/common.ts": "03e52e22882402c986fe97ca3b5bb4263c2aa811c515ce84584b23bac4cc2643", + "https://deno.land/std@0.214.0/path/constants.ts": "0c206169ca104938ede9da48ac952de288f23343304a1c3cb6ec7625e7325f36", + "https://deno.land/std@0.214.0/path/dirname.ts": "85bd955bf31d62c9aafdd7ff561c4b5fb587d11a9a5a45e2b01aedffa4238a7c", + "https://deno.land/std@0.214.0/path/extname.ts": "593303db8ae8c865cbd9ceec6e55d4b9ac5410c1e276bfd3131916591b954441", + "https://deno.land/std@0.214.0/path/format.ts": "98fad25f1af7b96a48efb5b67378fcc8ed77be895df8b9c733b86411632162af", + "https://deno.land/std@0.214.0/path/from_file_url.ts": "911833ae4fd10a1c84f6271f36151ab785955849117dc48c6e43b929504ee069", + "https://deno.land/std@0.214.0/path/glob_to_regexp.ts": "83c5fd36a8c86f5e72df9d0f45317f9546afa2ce39acaafe079d43a865aced08", + "https://deno.land/std@0.214.0/path/is_absolute.ts": "4791afc8bfd0c87f0526eaa616b0d16e7b3ab6a65b62942e50eac68de4ef67d7", + "https://deno.land/std@0.214.0/path/is_glob.ts": "a65f6195d3058c3050ab905705891b412ff942a292bcbaa1a807a74439a14141", + "https://deno.land/std@0.214.0/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a", + "https://deno.land/std@0.214.0/path/join_globs.ts": "e9589869a33dc3982101898ee50903db918ca00ad2614dbe3934d597d7b1fbea", + "https://deno.land/std@0.214.0/path/mod.ts": "ffeaccb713dbe6c72e015b7c767f753f8ec5fbc3b621ff5eeee486ffc2c0ddda", + "https://deno.land/std@0.214.0/path/normalize.ts": "4155743ccceeed319b350c1e62e931600272fad8ad00c417b91df093867a8352", + "https://deno.land/std@0.214.0/path/normalize_glob.ts": "98ee8268fad271193603271c203ae973280b5abfbdd2cbca1053fd2af71869ca", + "https://deno.land/std@0.214.0/path/parse.ts": "65e8e285f1a63b714e19ef24b68f56e76934c3df0b6e65fd440d3991f4f8aefb", + "https://deno.land/std@0.214.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d", + "https://deno.land/std@0.214.0/path/posix/basename.ts": "39ee27a29f1f35935d3603ccf01d53f3d6e0c5d4d0f84421e65bd1afeff42843", + "https://deno.land/std@0.214.0/path/posix/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4", + "https://deno.land/std@0.214.0/path/posix/constants.ts": "93481efb98cdffa4c719c22a0182b994e5a6aed3047e1962f6c2c75b7592bef1", + "https://deno.land/std@0.214.0/path/posix/dirname.ts": "6535d2bdd566118963537b9dda8867ba9e2a361015540dc91f5afbb65c0cce8b", + "https://deno.land/std@0.214.0/path/posix/extname.ts": "8d36ae0082063c5e1191639699e6f77d3acf501600a3d87b74943f0ae5327427", + "https://deno.land/std@0.214.0/path/posix/format.ts": "185e9ee2091a42dd39e2a3b8e4925370ee8407572cee1ae52838aed96310c5c1", + "https://deno.land/std@0.214.0/path/posix/from_file_url.ts": "951aee3a2c46fd0ed488899d024c6352b59154c70552e90885ed0c2ab699bc40", + "https://deno.land/std@0.214.0/path/posix/glob_to_regexp.ts": "54d3ff40f309e3732ab6e5b19d7111d2d415248bcd35b67a99defcbc1972e697", + "https://deno.land/std@0.214.0/path/posix/is_absolute.ts": "cebe561ad0ae294f0ce0365a1879dcfca8abd872821519b4fcc8d8967f888ede", + "https://deno.land/std@0.214.0/path/posix/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9", + "https://deno.land/std@0.214.0/path/posix/join.ts": "aef88d5fa3650f7516730865dbb951594d1a955b785e2450dbee93b8e32694f3", + "https://deno.land/std@0.214.0/path/posix/join_globs.ts": "ee2f4676c5b8a0dfa519da58b8ade4d1c4aa8dd3fe35619edec883ae9df1f8c9", + "https://deno.land/std@0.214.0/path/posix/mod.ts": "563a18c2b3ddc62f3e4a324ff0f583e819b8602a72ad880cb98c9e2e34f8db5b", + "https://deno.land/std@0.214.0/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91", + "https://deno.land/std@0.214.0/path/posix/normalize_glob.ts": "65f0138fa518ef9ece354f32889783fc38cdf985fb02dcf1c3b14fa47d665640", + "https://deno.land/std@0.214.0/path/posix/parse.ts": "d5bac4eb21262ab168eead7e2196cb862940c84cee572eafedd12a0d34adc8fb", + "https://deno.land/std@0.214.0/path/posix/relative.ts": "3907d6eda41f0ff723d336125a1ad4349112cd4d48f693859980314d5b9da31c", + "https://deno.land/std@0.214.0/path/posix/resolve.ts": "bac20d9921beebbbb2b73706683b518b1d0c1b1da514140cee409e90d6b2913a", + "https://deno.land/std@0.214.0/path/posix/separator.ts": "c9ecae5c843170118156ac5d12dc53e9caf6a1a4c96fc8b1a0ab02dff5c847b0", + "https://deno.land/std@0.214.0/path/posix/to_file_url.ts": "7aa752ba66a35049e0e4a4be5a0a31ac6b645257d2e031142abb1854de250aaf", + "https://deno.land/std@0.214.0/path/posix/to_namespaced_path.ts": "28b216b3c76f892a4dca9734ff1cc0045d135532bfd9c435ae4858bfa5a2ebf0", + "https://deno.land/std@0.214.0/path/relative.ts": "ab739d727180ed8727e34ed71d976912461d98e2b76de3d3de834c1066667add", + "https://deno.land/std@0.214.0/path/resolve.ts": "a6f977bdb4272e79d8d0ed4333e3d71367cc3926acf15ac271f1d059c8494d8d", + "https://deno.land/std@0.214.0/path/separator.ts": "c6c890507f944a1f5cb7d53b8d638d6ce3cf0f34609c8d84a10c1eaa400b77a9", + "https://deno.land/std@0.214.0/path/to_file_url.ts": "88f049b769bce411e2d2db5bd9e6fd9a185a5fbd6b9f5ad8f52bef517c4ece1b", + "https://deno.land/std@0.214.0/path/to_namespaced_path.ts": "b706a4103b104cfadc09600a5f838c2ba94dbcdb642344557122dda444526e40", + "https://deno.land/std@0.214.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808", + "https://deno.land/std@0.214.0/path/windows/basename.ts": "e2dbf31d1d6385bfab1ce38c333aa290b6d7ae9e0ecb8234a654e583cf22f8fe", + "https://deno.land/std@0.214.0/path/windows/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4", + "https://deno.land/std@0.214.0/path/windows/constants.ts": "5afaac0a1f67b68b0a380a4ef391bf59feb55856aa8c60dfc01bd3b6abb813f5", + "https://deno.land/std@0.214.0/path/windows/dirname.ts": "33e421be5a5558a1346a48e74c330b8e560be7424ed7684ea03c12c21b627bc9", + "https://deno.land/std@0.214.0/path/windows/extname.ts": "165a61b00d781257fda1e9606a48c78b06815385e7d703232548dbfc95346bef", + "https://deno.land/std@0.214.0/path/windows/format.ts": "bbb5ecf379305b472b1082cd2fdc010e44a0020030414974d6029be9ad52aeb6", + "https://deno.land/std@0.214.0/path/windows/from_file_url.ts": "ced2d587b6dff18f963f269d745c4a599cf82b0c4007356bd957cb4cb52efc01", + "https://deno.land/std@0.214.0/path/windows/glob_to_regexp.ts": "6dcd1242bd8907aa9660cbdd7c93446e6927b201112b0cba37ca5d80f81be51b", + "https://deno.land/std@0.214.0/path/windows/is_absolute.ts": "4a8f6853f8598cf91a835f41abed42112cebab09478b072e4beb00ec81f8ca8a", + "https://deno.land/std@0.214.0/path/windows/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9", + "https://deno.land/std@0.214.0/path/windows/join.ts": "e0b3356615c1a75c56ebb6a7311157911659e11fd533d80d724800126b761ac3", + "https://deno.land/std@0.214.0/path/windows/join_globs.ts": "ee2f4676c5b8a0dfa519da58b8ade4d1c4aa8dd3fe35619edec883ae9df1f8c9", + "https://deno.land/std@0.214.0/path/windows/mod.ts": "7d6062927bda47c47847ffb55d8f1a37b0383840aee5c7dfc93984005819689c", + "https://deno.land/std@0.214.0/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780", + "https://deno.land/std@0.214.0/path/windows/normalize_glob.ts": "179c86ba89f4d3fe283d2addbe0607341f79ee9b1ae663abcfb3439db2e97810", + "https://deno.land/std@0.214.0/path/windows/parse.ts": "b9239edd892a06a06625c1b58425e199f018ce5649ace024d144495c984da734", + "https://deno.land/std@0.214.0/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7", + "https://deno.land/std@0.214.0/path/windows/resolve.ts": "75b2e3e1238d840782cee3d8864d82bfaa593c7af8b22f19c6422cf82f330ab3", + "https://deno.land/std@0.214.0/path/windows/separator.ts": "e51c5522140eff4f8402617c5c68a201fdfa3a1a8b28dc23587cff931b665e43", + "https://deno.land/std@0.214.0/path/windows/to_file_url.ts": "1cd63fd35ec8d1370feaa4752eccc4cc05ea5362a878be8dc7db733650995484", + "https://deno.land/std@0.214.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c", + "https://deno.land/x/memcached@v1.0.0/mod.ts": "3c8528631e603638ded48f8fadcf61cde00fb1d2054e8647cc033bc9f2ea5867", + "https://deno.land/x/postgres@v0.19.3/client.ts": "d141c65c20484c545a1119c9af7a52dcc24f75c1a5633de2b9617b0f4b2ed5c1", + "https://deno.land/x/postgres@v0.19.3/client/error.ts": "05b0e35d65caf0ba21f7f6fab28c0811da83cd8b4897995a2f411c2c83391036", + "https://deno.land/x/postgres@v0.19.3/connection/auth.ts": "db15c1659742ef4d2791b32834950278dc7a40cb931f8e434e6569298e58df51", + "https://deno.land/x/postgres@v0.19.3/connection/connection.ts": "198a0ecf92a0d2aa72db3bb88b8f412d3b1f6b87d464d5f7bff9aa3b6aff8370", + "https://deno.land/x/postgres@v0.19.3/connection/connection_params.ts": "463d7a9ed559f537a55d6928cab62e1c31b808d08cd0411b6ae461d0c0183c93", + "https://deno.land/x/postgres@v0.19.3/connection/message.ts": "20da5d80fc4d7ddb7b850083e0b3fa8734eb26642221dad89c62e27d78e57a4d", + "https://deno.land/x/postgres@v0.19.3/connection/message_code.ts": "12bcb110df6945152f9f6c63128786558d7ad1e61006920daaa16ef85b3bab7d", + "https://deno.land/x/postgres@v0.19.3/connection/packet.ts": "050aeff1fc13c9349e89451a155ffcd0b1343dc313a51f84439e3e45f64b56c8", + "https://deno.land/x/postgres@v0.19.3/connection/scram.ts": "532d4d58b565a2ab48fb5e1e14dc9bfb3bb283d535011e371e698eb4a89dd994", + "https://deno.land/x/postgres@v0.19.3/debug.ts": "8add17699191f11e6830b8c95d9de25857d221bb2cf6c4ae22254d395895c1f9", + "https://deno.land/x/postgres@v0.19.3/deps.ts": "c312038fe64b8368f8a294119f11d8f235fe67de84d7c3b0ef67b3a56628171a", + "https://deno.land/x/postgres@v0.19.3/mod.ts": "4930c7b44f8d16ea71026f7e3ef22a2322d84655edceacd55f7461a9218d8560", + "https://deno.land/x/postgres@v0.19.3/pool.ts": "2289f029e7a3bd3d460d4faa71399a920b7406c92a97c0715d6e31dbf1380ec3", + "https://deno.land/x/postgres@v0.19.3/query/array_parser.ts": "ff72d3e026e3022a1a223a6530be5663f8ebbd911ed978291314e7fe6c2f2464", + "https://deno.land/x/postgres@v0.19.3/query/decode.ts": "3e89ad2a662eab66a4f4e195ff0924d71d199af3c2f5637d1ae650301a03fa9b", + "https://deno.land/x/postgres@v0.19.3/query/decoders.ts": "6a73da1024086ab91e233648c850dccbde59248b90d87054bbbd7f0bf4a50681", + "https://deno.land/x/postgres@v0.19.3/query/encode.ts": "5b1c305bc7352a6f9fe37f235dddfc23e26419c77a133b4eaea42cf136481aa6", + "https://deno.land/x/postgres@v0.19.3/query/oid.ts": "21fc714ac212350ba7df496f88ea9e01a4ee0458911d0f2b6a81498e12e7af4c", + "https://deno.land/x/postgres@v0.19.3/query/query.ts": "510f9a27da87ed7b31b5cbcd14bf3028b441ac2ddc368483679d0b86a9d9f213", + "https://deno.land/x/postgres@v0.19.3/query/transaction.ts": "8f4eef68f8e9b4be216199404315e6e08fe1fe98afb2e640bffd077662f79678", + "https://deno.land/x/postgres@v0.19.3/utils/deferred.ts": "5420531adb6c3ea29ca8aac57b9b59bd3e4b9a938a4996bbd0947a858f611080", + "https://deno.land/x/postgres@v0.19.3/utils/utils.ts": "ca47193ea03ff5b585e487a06f106d367e509263a960b787197ce0c03113a738", + "https://deno.land/x/random_number@2.0.0/mod.ts": "83010e4a0192b015ba4491d8bb8c73a458f352ebc613b847ff6349961d1c7827", + "https://deno.land/x/redis@v0.37.1/backoff.ts": "33e4a6e245f8743fbae0ce583993a671a3ac2ecee433a3e7f0bd77b5dd541d84", + "https://deno.land/x/redis@v0.37.1/connection.ts": "cee30a6310298441de17d1028d4ce3fd239dcf05a92294fba7173a922d0596cb", + "https://deno.land/x/redis@v0.37.1/deps/std/async.ts": "5a588aefb041cca49f0e6b7e3c397119693a3e07bea89c54cf7fe4a412e37bbf", + "https://deno.land/x/redis@v0.37.1/deps/std/bytes.ts": "f5b437ebcac77600101a81ef457188516e4944b3c2a931dff5ced3fa0c239b62", + "https://deno.land/x/redis@v0.37.1/deps/std/io.ts": "b7505c5e738384f5f7a021d7bbd78380490c059cc7c83cd8dada1f86ec16e835", + "https://deno.land/x/redis@v0.37.1/errors.ts": "8293f56a70ea8388cb80b6e1caa15d350ed1719529fc06573b01a443d0caad69", + "https://deno.land/x/redis@v0.37.1/executor.ts": "5ac4c1f7bec44d12ebc0f3702bf074bd3ba6c1aae74953582f6358d2948718e7", + "https://deno.land/x/redis@v0.37.1/internal/encoding.ts": "0525f7f444a96b92cd36423abdfe221f8d8de4a018dc5cb6750a428a5fc897c2", + "https://deno.land/x/redis@v0.37.1/internal/symbols.ts": "e36097bab1da1c9fe84a3bb9cb0ed1ec10c3dc7dd0b557769c5c54e15d110dd2", + "https://deno.land/x/redis@v0.37.1/mod.ts": "e11d9384c2ffe1b3d81ce0ad275254519990635ad1ba39f46d49a73b3c35238d", + "https://deno.land/x/redis@v0.37.1/pipeline.ts": "974fff59bf0befa2ad7eee50ecba40006c47364f5e3285e1335c9f9541b7ebae", + "https://deno.land/x/redis@v0.37.1/protocol/deno_streams/command.ts": "5c5e5fb639cae22c1f9bfdc87631edcd67bb28bf8590161ae484d293b733aa01", + "https://deno.land/x/redis@v0.37.1/protocol/deno_streams/mod.ts": "b084bf64d6b795f6c1d0b360d9be221e246a9c033e5d88fd1e82fa14f711d25b", + "https://deno.land/x/redis@v0.37.1/protocol/deno_streams/reply.ts": "639de34541f207f793393a3cd45f9a23ef308f094d9d3d6ce62f84b175d3af47", + "https://deno.land/x/redis@v0.37.1/protocol/shared/command.ts": "e75f6be115ff73bd865e01be4e2a28077a9993b1e0c54ed96b6825bfe997d382", + "https://deno.land/x/redis@v0.37.1/protocol/shared/reply.ts": "3311ff66357bacbd60785cb43b97539c341d8a7d963bc5e80cb864ac81909ea5", + "https://deno.land/x/redis@v0.37.1/protocol/shared/types.ts": "c6bf2b9eafd69e358a972823d94b8b478c00bac195b87b33b7437de2a9bb7fb4", + "https://deno.land/x/redis@v0.37.1/pubsub.ts": "a36892455b0a4a50af169332a165b0985cc90d84486087f036e507e3137b2afb", + "https://deno.land/x/redis@v0.37.1/redis.ts": "4904772596c8a82d7112092e7edea45243eae38809b2f2ea8db61a4207fe246b", + "https://deno.land/x/redis@v0.37.1/stream.ts": "d43076815d046eb8428fcd2799544a9fd07b3480099f5fc67d2ba12fdc73725f" + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1", + "npm:zod@^3.24.1" + ] + } +} From 503e82c2f451706545145daa23cbf15beea665e8 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 8 Feb 2025 19:34:20 -0600 Subject: [PATCH 004/137] Add more files --- deno/config.ts | 11 +++++ deno/content-types.ts | 4 +- deno/deps.ts | 7 +++ deno/main.ts | 8 ++++ deno/main_test.ts | 6 +++ deno/routes.ts | 101 ++++++++++++++++++++++++++++++++++++++++++ deno/services.ts | 24 ++++++++++ 7 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 deno/config.ts create mode 100644 deno/deps.ts create mode 100644 deno/main.ts create mode 100644 deno/main_test.ts create mode 100644 deno/routes.ts create mode 100644 deno/services.ts diff --git a/deno/config.ts b/deno/config.ts new file mode 100644 index 0000000..0b3dfe0 --- /dev/null +++ b/deno/config.ts @@ -0,0 +1,11 @@ +const config = { + database: { + user: "abc123", + password: "abc123", + host: "localhost", + port: "5432", + database: "abc123", + }, +}; + +export { config }; diff --git a/deno/content-types.ts b/deno/content-types.ts index e3cdcd2..1970f4a 100644 --- a/deno/content-types.ts +++ b/deno/content-types.ts @@ -1,8 +1,8 @@ import { Extensible } from "./interfaces"; -export type ContentType = string +export type ContentType = string; -const contentTypes= { +const contentTypes = { text: { plain: "text/plain", html: "text/html", diff --git a/deno/deps.ts b/deno/deps.ts new file mode 100644 index 0000000..869fb08 --- /dev/null +++ b/deno/deps.ts @@ -0,0 +1,7 @@ +// Database +export { Client as PostgresClient } from "https://deno.land/x/postgres@v0.19.3/mod.ts"; +export type { ClientOptions as PostgresOptions } from "https://deno.land/x/postgres@v0.19.3/mod.ts"; + +// Redis +export { connect as redisConnect } from "https://deno.land/x/redis@v0.37.1/mod.ts"; +export type { Redis } from "https://deno.land/x/redis@v0.37.1/mod.ts"; diff --git a/deno/main.ts b/deno/main.ts new file mode 100644 index 0000000..3678714 --- /dev/null +++ b/deno/main.ts @@ -0,0 +1,8 @@ +export function add(a: number, b: number): number { + return a + b; +} + +// Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts +if (import.meta.main) { + console.log("Add 2 + 3 =", add(2, 3)); +} diff --git a/deno/main_test.ts b/deno/main_test.ts new file mode 100644 index 0000000..6bdafb8 --- /dev/null +++ b/deno/main_test.ts @@ -0,0 +1,6 @@ +import { assertEquals } from "@std/assert"; +import { add } from "./main.ts"; + +Deno.test(function addTest() { + assertEquals(add(2, 3), 5); +}); diff --git a/deno/routes.ts b/deno/routes.ts new file mode 100644 index 0000000..5f0f4e7 --- /dev/null +++ b/deno/routes.ts @@ -0,0 +1,101 @@ +type Method = "get" | "post" | "put" | "patch" | "delete"; + +import { HttpCode, httpCodes } from "./http-codes.ts"; +import { ContentType, contentTypes } from "./content-types"; +import { services } from "./services"; + +type Response = { + code: HttpCode; + contentType: ContentType; + result: string; +}; + +type Handler = (req: Request) => Response; + +const phandler: Handler = (req: Request) => { + const code = httpCodes.success.OK; + return { + code, + result: "it is ok ", + contentType: contentTypes.text.plain, + }; +}; + +type Route = { + path: string; + methods: Method[]; + handler: Handler; +}; + +const routes: Route[] = [ + { + path: "/ok", + methods: ["get"], + handler: (req) => { + const code = httpCodes.success.OK; + const rn = services.random.randomNumber(); + + return { + code, + result: "it is ok " + rn, + contentType: contentTypes.text.plain, + }; + }, + }, + { + path: "/alsook", + methods: ["get"], + handler: (req) => { + const code = httpCodes.success.OK; + return { + code, + result: "it is also ok", + contentType: contentTypes.text.plain, + }; + }, + }, +]; + +type DenoRequest = globalThis.Request; +type UserRequest = {}; +type Request = { + pattern: string; + path: string; + method: Method; + parameters: object; + denoRequest: globalThis.Request; +}; +type ProcessedRoute = { pattern: URLPattern; method: Method; handler: Handler }; +const processedRoutes: ProcessedRoute[] = routes.map( + (route: Route, idx: number, allRoutes: Route[]) => { + const pattern: URLPattern = new URLPattern({ pathname: route.path }); + const method: Method = route.method; + const handler = (denoRequest: DenoRequest) => { + const req: Request = { + pattern: route.pattern, + path: denoRequest.path, + method: denoRequest.method, + parameters: { one: 1, two: 2 }, + denoRequest, + }; + return route.handler(req); + }; + + return { pattern, method, handler }; + }, +); + +function handler(req: globalThis.Request): globalThis.Response { + for (const [idx, pr] of processedRoutes.entries()) { + const match = pr.pattern.exec(req.url); + if (match) { + const resp = pr.handler(req); + + return new globalThis.Response(resp.result); + } + } + + return new globalThis.Response("not found", { status: 404 }); +} + +Deno.serve(handler); diff --git a/deno/services.ts b/deno/services.ts new file mode 100644 index 0000000..9659010 --- /dev/null +++ b/deno/services.ts @@ -0,0 +1,24 @@ +// services.ts + +import { randomNumber } from "https://deno.land/x/random_number/mod.ts"; + +// import {Client} from './deps' + +import { config } from "./config.ts"; + +//const database = Client({ + +//}) + +const database = {}; + +const random = { + randomNumber, +}; + +const services = { + database, + random, +}; + +export { services }; From a441563c91975bf8a6999cc8fee7ba372dc06bd9 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 2 Mar 2025 09:16:54 -0600 Subject: [PATCH 005/137] Add wrapper script --- deno/run.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100755 deno/run.sh diff --git a/deno/run.sh b/deno/run.sh new file mode 100755 index 0000000..7dbcf2c --- /dev/null +++ b/deno/run.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +cd "$DIR" + +deno run --allow-net --unstable-sloppy-imports --watch routes.ts + + From 0c5b8b734cc6be430a92b3e8c80189d8868d607f Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 2 Mar 2025 09:17:46 -0600 Subject: [PATCH 006/137] Maybe make Extensible type workable --- deno/extensible.ts | 0 deno/interfaces.ts | 4 +++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 deno/extensible.ts diff --git a/deno/extensible.ts b/deno/extensible.ts new file mode 100644 index 0000000..e69de29 diff --git a/deno/interfaces.ts b/deno/interfaces.ts index 3914b1b..48dd302 100644 --- a/deno/interfaces.ts +++ b/deno/interfaces.ts @@ -1 +1,3 @@ -export interface Extensible {} +type Brand = K & { readonly __brand: T }; + +export type Extensible = Brand<"Extensible", {}>; From e7425a26854c2589b20316b32787f9be0bf6cd38 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 2 Mar 2025 09:18:21 -0600 Subject: [PATCH 007/137] Add logging stubs --- deno/logging.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 deno/logging.ts diff --git a/deno/logging.ts b/deno/logging.ts new file mode 100644 index 0000000..6e629f8 --- /dev/null +++ b/deno/logging.ts @@ -0,0 +1,44 @@ +// internal-logging.ts + +// FIXME: Move this to somewhere more appropriate +type AtLeastOne = [T, ...T[]]; + +type MessageSource = "logging" | "diagnostic" | "user"; + +type Message = { + // FIXME: number probably isn't what we want here + timestamp: number; + source: MessageSource; + + text: AtLeastOne; +}; + +const m1: Message = { timestamp: 123, source: "logging", text: ["foo"] }; +const m2: Message = { + timestamp: 321, + source: "diagnostic", + text: ["ok", "whatever"], +}; + +type FilterArgument = { + limit?: number; + before?: number; + after?: number; + + // FIXME: add offsets to use instead of or in addition to before/after + + match?: (string | RegExp)[]; +}; + +const log = (_message: Message) => { + // WRITEME +}; + +const getLogs = (filter: FilterArgument) => { + // WRITEME +}; + +// FIXME: there's scope for more specialized functions although they +// probably should be defined in terms of the basic ones here. + +export { getLogs, log }; From efa9a7a3de4fccd048247617df7cd4b4079cf829 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 2 Mar 2025 09:20:34 -0600 Subject: [PATCH 008/137] Fix import --- deno/content-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno/content-types.ts b/deno/content-types.ts index 1970f4a..ac73350 100644 --- a/deno/content-types.ts +++ b/deno/content-types.ts @@ -1,4 +1,4 @@ -import { Extensible } from "./interfaces"; +import { Extensible } from "./interfaces.ts"; export type ContentType = string; From a078f36d08d7dd6fae8efa8931a1be6c21d3221e Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 2 Mar 2025 09:21:21 -0600 Subject: [PATCH 009/137] Fill out some services stuff --- deno/services.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/deno/services.ts b/deno/services.ts index 9659010..9d83a00 100644 --- a/deno/services.ts +++ b/deno/services.ts @@ -1,10 +1,9 @@ // services.ts import { randomNumber } from "https://deno.land/x/random_number/mod.ts"; - -// import {Client} from './deps' - import { config } from "./config.ts"; +import { getLogs, log } from "./logging"; + //const database = Client({ @@ -12,12 +11,18 @@ import { config } from "./config.ts"; const database = {}; +const logging = { + log, + getLogs, +}; + const random = { randomNumber, }; const services = { database, + logging, random, }; From d6fccc3172a86aa20e1c59e32e38889a1d40131b Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 2 Mar 2025 09:21:57 -0600 Subject: [PATCH 010/137] Update some dependencies or something --- deno/deno.json | 3 +++ deno/deno.lock | 12 ++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/deno/deno.json b/deno/deno.json index 998c73b..aa6d055 100644 --- a/deno/deno.json +++ b/deno/deno.json @@ -1,4 +1,7 @@ { + "compilerOptions": { + "lib": ["deno.ns", "dom"] + }, "tasks": { "dev": "deno run --watch main.ts" }, diff --git a/deno/deno.lock b/deno/deno.lock index 178d8a6..3aca4c0 100644 --- a/deno/deno.lock +++ b/deno/deno.lock @@ -38,7 +38,8 @@ } }, "redirects": { - "https://deno.land/x/random_number/mod.ts": "https://deno.land/x/random_number@2.0.0/mod.ts" + "https://deno.land/x/random_number/mod.ts": "https://deno.land/x/random_number@2.0.0/mod.ts", + "https://deno.land/x/sleep/mod.ts": "https://deno.land/x/sleep@v1.3.0/mod.ts" }, "remote": { "https://deno.land/std@0.214.0/assert/assert.ts": "bec068b2fccdd434c138a555b19a2c2393b71dfaada02b7d568a01541e67cdc5", @@ -60,6 +61,7 @@ "https://deno.land/std@0.214.0/fmt/colors.ts": "aeaee795471b56fc62a3cb2e174ed33e91551b535f44677f6320336aabb54fbb", "https://deno.land/std@0.214.0/io/buf_reader.ts": "c73aad99491ee6db3d6b001fa4a780e9245c67b9296f5bad9c0fa7384e35d47a", "https://deno.land/std@0.214.0/io/buf_writer.ts": "f82f640c8b3a820f600a8da429ad0537037c7d6a78426bbca2396fb1f75d3ef4", + "https://deno.land/std@0.214.0/io/types.ts": "748bbb3ac96abda03594ef5a0db15ce5450dcc6c0d841c8906f8b10ac8d32c96", "https://deno.land/std@0.214.0/path/_common/assert_path.ts": "2ca275f36ac1788b2acb60fb2b79cb06027198bc2ba6fb7e163efaedde98c297", "https://deno.land/std@0.214.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2", "https://deno.land/std@0.214.0/path/_common/common.ts": "6157c7ec1f4db2b4a9a187efd6ce76dcaf1e61cfd49f87e40d4ea102818df031", @@ -161,15 +163,18 @@ "https://deno.land/x/postgres@v0.19.3/query/oid.ts": "21fc714ac212350ba7df496f88ea9e01a4ee0458911d0f2b6a81498e12e7af4c", "https://deno.land/x/postgres@v0.19.3/query/query.ts": "510f9a27da87ed7b31b5cbcd14bf3028b441ac2ddc368483679d0b86a9d9f213", "https://deno.land/x/postgres@v0.19.3/query/transaction.ts": "8f4eef68f8e9b4be216199404315e6e08fe1fe98afb2e640bffd077662f79678", + "https://deno.land/x/postgres@v0.19.3/query/types.ts": "540f6f973d493d63f2c0059a09f3368071f57931bba68bea408a635a3e0565d6", "https://deno.land/x/postgres@v0.19.3/utils/deferred.ts": "5420531adb6c3ea29ca8aac57b9b59bd3e4b9a938a4996bbd0947a858f611080", "https://deno.land/x/postgres@v0.19.3/utils/utils.ts": "ca47193ea03ff5b585e487a06f106d367e509263a960b787197ce0c03113a738", "https://deno.land/x/random_number@2.0.0/mod.ts": "83010e4a0192b015ba4491d8bb8c73a458f352ebc613b847ff6349961d1c7827", "https://deno.land/x/redis@v0.37.1/backoff.ts": "33e4a6e245f8743fbae0ce583993a671a3ac2ecee433a3e7f0bd77b5dd541d84", + "https://deno.land/x/redis@v0.37.1/command.ts": "2d1da4b32495ea852bdff0c2e7fd191a056779a696b9f83fb648c5ebac45cfc3", "https://deno.land/x/redis@v0.37.1/connection.ts": "cee30a6310298441de17d1028d4ce3fd239dcf05a92294fba7173a922d0596cb", "https://deno.land/x/redis@v0.37.1/deps/std/async.ts": "5a588aefb041cca49f0e6b7e3c397119693a3e07bea89c54cf7fe4a412e37bbf", "https://deno.land/x/redis@v0.37.1/deps/std/bytes.ts": "f5b437ebcac77600101a81ef457188516e4944b3c2a931dff5ced3fa0c239b62", "https://deno.land/x/redis@v0.37.1/deps/std/io.ts": "b7505c5e738384f5f7a021d7bbd78380490c059cc7c83cd8dada1f86ec16e835", "https://deno.land/x/redis@v0.37.1/errors.ts": "8293f56a70ea8388cb80b6e1caa15d350ed1719529fc06573b01a443d0caad69", + "https://deno.land/x/redis@v0.37.1/events.ts": "704767b1beed2d5acfd5e86bd1ef93befdc8a8f8c8bb4ae1b4485664a8a6a625", "https://deno.land/x/redis@v0.37.1/executor.ts": "5ac4c1f7bec44d12ebc0f3702bf074bd3ba6c1aae74953582f6358d2948718e7", "https://deno.land/x/redis@v0.37.1/internal/encoding.ts": "0525f7f444a96b92cd36423abdfe221f8d8de4a018dc5cb6750a428a5fc897c2", "https://deno.land/x/redis@v0.37.1/internal/symbols.ts": "e36097bab1da1c9fe84a3bb9cb0ed1ec10c3dc7dd0b557769c5c54e15d110dd2", @@ -179,11 +184,14 @@ "https://deno.land/x/redis@v0.37.1/protocol/deno_streams/mod.ts": "b084bf64d6b795f6c1d0b360d9be221e246a9c033e5d88fd1e82fa14f711d25b", "https://deno.land/x/redis@v0.37.1/protocol/deno_streams/reply.ts": "639de34541f207f793393a3cd45f9a23ef308f094d9d3d6ce62f84b175d3af47", "https://deno.land/x/redis@v0.37.1/protocol/shared/command.ts": "e75f6be115ff73bd865e01be4e2a28077a9993b1e0c54ed96b6825bfe997d382", + "https://deno.land/x/redis@v0.37.1/protocol/shared/protocol.ts": "5b9284ee28ec74dfc723c7c7f07dca8d5f9d303414f36689503622dfdde12551", "https://deno.land/x/redis@v0.37.1/protocol/shared/reply.ts": "3311ff66357bacbd60785cb43b97539c341d8a7d963bc5e80cb864ac81909ea5", "https://deno.land/x/redis@v0.37.1/protocol/shared/types.ts": "c6bf2b9eafd69e358a972823d94b8b478c00bac195b87b33b7437de2a9bb7fb4", "https://deno.land/x/redis@v0.37.1/pubsub.ts": "a36892455b0a4a50af169332a165b0985cc90d84486087f036e507e3137b2afb", "https://deno.land/x/redis@v0.37.1/redis.ts": "4904772596c8a82d7112092e7edea45243eae38809b2f2ea8db61a4207fe246b", - "https://deno.land/x/redis@v0.37.1/stream.ts": "d43076815d046eb8428fcd2799544a9fd07b3480099f5fc67d2ba12fdc73725f" + "https://deno.land/x/redis@v0.37.1/stream.ts": "d43076815d046eb8428fcd2799544a9fd07b3480099f5fc67d2ba12fdc73725f", + "https://deno.land/x/sleep@v1.3.0/mod.ts": "e9955ecd3228a000e29d46726cd6ab14b65cf83904e9b365f3a8d64ec61c1af3", + "https://deno.land/x/sleep@v1.3.0/sleep.ts": "b6abaca093b094b0c2bba94f287b19a60946a8d15764d168f83fcf555f5bb59e" }, "workspace": { "dependencies": [ From ad2b85bc2b164e1ea0456800496f3bf26883668e Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 10:28:17 -0600 Subject: [PATCH 011/137] Separate route definitions and where they're served from --- deno/app.ts | 45 +++++++++++++++++++++ deno/routes.ts | 104 ++++++++++++++++++++++++------------------------- deno/run.sh | 2 +- deno/types.ts | 40 +++++++++++++++++++ 4 files changed, 138 insertions(+), 53 deletions(-) create mode 100644 deno/app.ts create mode 100644 deno/types.ts diff --git a/deno/app.ts b/deno/app.ts new file mode 100644 index 0000000..db71c60 --- /dev/null +++ b/deno/app.ts @@ -0,0 +1,45 @@ +// app.ts + +import { services } from "./services"; +import{DenoRequest, Method, ProcessedRoute, Request, Route} from './types' + +import {routes} from './routes' + +services.logging.log({ foo: 1 }); +const processedRoutes: ProcessedRoute[] = routes.map( + (route: Route, idx: number, allRoutes: Route[]) => { + const pattern: URLPattern = new URLPattern({ pathname: route.path }); + const method: Method = route.method; + const handler = async (denoRequest: DenoRequest) => { + const req: Request = { + pattern: route.pattern, + path: denoRequest.path, + method: denoRequest.method, + parameters: { one: 1, two: 2 }, + denoRequest, + }; + return route.handler(req); + }; + + const retval: ProcessedRoute = { pattern, method, handler }; + + return retval + }, +); + + +async function handler(req: globalThis.Request): globalThis.Response { + for (const [idx, pr] of processedRoutes.entries()) { + const match = pr.pattern.exec(req.url); + if (match) { + const resp = await pr.handler(req); + + return new globalThis.Response(resp.result); + } + } + + return new globalThis.Response("not found", { status: 404 }); +} + +Deno.serve(handler); + diff --git a/deno/routes.ts b/deno/routes.ts index 5f0f4e7..c94b24a 100644 --- a/deno/routes.ts +++ b/deno/routes.ts @@ -1,18 +1,16 @@ -type Method = "get" | "post" | "put" | "patch" | "delete"; +/// + +import { sleep } from "https://deno.land/x/sleep/mod.ts"; import { HttpCode, httpCodes } from "./http-codes.ts"; import { ContentType, contentTypes } from "./content-types"; import { services } from "./services"; +import {DenoRequest, Handler, Response, Method, UserRequest, Request, Route, ProcessedRoute } from './types.ts' -type Response = { - code: HttpCode; - contentType: ContentType; - result: string; -}; -type Handler = (req: Request) => Response; -const phandler: Handler = (req: Request) => { + +const phandler: Handler = async (req: Request) => { const code = httpCodes.success.OK; return { code, @@ -21,17 +19,54 @@ const phandler: Handler = (req: Request) => { }; }; -type Route = { - path: string; - methods: Method[]; - handler: Handler; + + +// FIXME: Obviously put this somewhere else +const okText = (out: string) => { + const code = httpCodes.success.OK; + + return { + code, + result: out, + contentType: contentTypes.text.plain, + }; }; const routes: Route[] = [ + { + path: "/slow", + methods: ["get"], + handler: async (_req) => { + console.log("starting slow request") ; + await sleep(10); + console.log("finishing slow request"); return okText("that was slow"); + }, + }, + { + path: "/list", + methods: ["get"], + handler: async (_req) => { + const code = httpCodes.success.OK; + const lr = (rr: Route[]) => { + const ret = rr.map((r: Route) => { + return r.path; + }); + + return ret; + }; + + const listing = lr(routes).join(", "); + return { + code, + result: listing + "\n", + contentType: contentTypes.text.plain, + }; + }, + }, { path: "/ok", methods: ["get"], - handler: (req) => { + handler: async (_req) => { const code = httpCodes.success.OK; const rn = services.random.randomNumber(); @@ -45,7 +80,7 @@ const routes: Route[] = [ { path: "/alsook", methods: ["get"], - handler: (req) => { + handler: async (_req) => { const code = httpCodes.success.OK; return { code, @@ -56,46 +91,11 @@ const routes: Route[] = [ }, ]; -type DenoRequest = globalThis.Request; -type UserRequest = {}; -type Request = { - pattern: string; - path: string; - method: Method; - parameters: object; - denoRequest: globalThis.Request; -}; -type ProcessedRoute = { pattern: URLPattern; method: Method; handler: Handler }; -const processedRoutes: ProcessedRoute[] = routes.map( - (route: Route, idx: number, allRoutes: Route[]) => { - const pattern: URLPattern = new URLPattern({ pathname: route.path }); - const method: Method = route.method; - const handler = (denoRequest: DenoRequest) => { - const req: Request = { - pattern: route.pattern, - path: denoRequest.path, - method: denoRequest.method, - parameters: { one: 1, two: 2 }, - denoRequest, - }; - return route.handler(req); - }; - return { pattern, method, handler }; - }, -); -function handler(req: globalThis.Request): globalThis.Response { - for (const [idx, pr] of processedRoutes.entries()) { - const match = pr.pattern.exec(req.url); - if (match) { - const resp = pr.handler(req); - return new globalThis.Response(resp.result); - } - } - return new globalThis.Response("not found", { status: 404 }); -} -Deno.serve(handler); + + + export {routes} diff --git a/deno/run.sh b/deno/run.sh index 7dbcf2c..713aad1 100755 --- a/deno/run.sh +++ b/deno/run.sh @@ -6,6 +6,6 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" cd "$DIR" -deno run --allow-net --unstable-sloppy-imports --watch routes.ts +deno run --allow-net --unstable-sloppy-imports --watch app.ts diff --git a/deno/types.ts b/deno/types.ts new file mode 100644 index 0000000..c99938c --- /dev/null +++ b/deno/types.ts @@ -0,0 +1,40 @@ +// types.ts + +// FIXME: split this up into types used by app developers and types internal +// to the framework. + + +import { HttpCode, httpCodes } from "./http-codes.ts"; +import { ContentType, contentTypes } from "./content-types"; + + +export type Method = "get" | "post" | "put" | "patch" | "delete"; + + +export type DenoRequest = globalThis.Request; +export type UserRequest = {}; +export type Request = { + pattern: string; + path: string; + method: Method; + parameters: object; + denoRequest: globalThis.Request; +}; +export type ProcessedRoute = { pattern: URLPattern; method: Method; handler: Handler }; + + + +export type Response = { + code: HttpCode; + contentType: ContentType; + result: string; +}; + +export type Handler = (req: Request) => Promise; + +export type Route = { + path: string; + methods: Method[]; + handler: Handler; + interruptable?: boolean +}; \ No newline at end of file From d6fda3fdb3e9b0819f57c0b08550ec4e06eabb19 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 15:23:20 -0600 Subject: [PATCH 012/137] Massage types --- deno/types.ts | 97 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 57 insertions(+), 40 deletions(-) diff --git a/deno/types.ts b/deno/types.ts index c99938c..c139deb 100644 --- a/deno/types.ts +++ b/deno/types.ts @@ -1,40 +1,57 @@ -// types.ts - -// FIXME: split this up into types used by app developers and types internal -// to the framework. - - -import { HttpCode, httpCodes } from "./http-codes.ts"; -import { ContentType, contentTypes } from "./content-types"; - - -export type Method = "get" | "post" | "put" | "patch" | "delete"; - - -export type DenoRequest = globalThis.Request; -export type UserRequest = {}; -export type Request = { - pattern: string; - path: string; - method: Method; - parameters: object; - denoRequest: globalThis.Request; -}; -export type ProcessedRoute = { pattern: URLPattern; method: Method; handler: Handler }; - - - -export type Response = { - code: HttpCode; - contentType: ContentType; - result: string; -}; - -export type Handler = (req: Request) => Promise; - -export type Route = { - path: string; - methods: Method[]; - handler: Handler; - interruptable?: boolean -}; \ No newline at end of file +// types.ts + +// FIXME: split this up into types used by app developers and types internal +// to the framework. + +import { z } from "zod"; +import { HttpCode, httpCodes } from "./http-codes.ts"; +import { ContentType, contentTypes } from "./content-types.ts"; + +const methodParser = z.union([ + z.literal("get"), + z.literal("post"), + z.literal("put"), + z.literal("patch"), + z.literal("delete"), +]); + +export type Method = z.infer; + +const massageMethod = (input: string): Method => { + const r = methodParser.parse(input.toLowerCase()) + + return r; +}; + +export type DenoRequest = globalThis.Request; +export type DenoResponse=globalThis.Response; +export type UserRequest = {}; +export type Request = { + pattern: string; + path: string; + method: Method; + parameters: object; + denoRequest: globalThis.Request; +}; +export type ProcessedRoute = { + pattern: URLPattern; + method: Method; + handler: Handler; +}; + +export type Response = { + code: HttpCode; + contentType: ContentType; + result: string; +}; + +export type Handler = (req: Request) => Promise; + +export type Route = { + path: string; + methods: Method[]; + handler: Handler; + interruptable?: boolean; +}; + +export { massageMethod }; From d48a533d422db82a329fa2358ed207effb1d92ab Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 15:23:29 -0600 Subject: [PATCH 013/137] Fix import --- deno/services.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno/services.ts b/deno/services.ts index 9d83a00..cbbc4a6 100644 --- a/deno/services.ts +++ b/deno/services.ts @@ -2,7 +2,7 @@ import { randomNumber } from "https://deno.land/x/random_number/mod.ts"; import { config } from "./config.ts"; -import { getLogs, log } from "./logging"; +import { getLogs, log } from "./logging.ts"; //const database = Client({ From c19e6a953707f0ce0b85cbb083664c4a409d781a Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 15:23:38 -0600 Subject: [PATCH 014/137] Refmt --- deno/routes.ts | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/deno/routes.ts b/deno/routes.ts index c94b24a..0f82b23 100644 --- a/deno/routes.ts +++ b/deno/routes.ts @@ -3,14 +3,20 @@ import { sleep } from "https://deno.land/x/sleep/mod.ts"; import { HttpCode, httpCodes } from "./http-codes.ts"; -import { ContentType, contentTypes } from "./content-types"; -import { services } from "./services"; -import {DenoRequest, Handler, Response, Method, UserRequest, Request, Route, ProcessedRoute } from './types.ts' +import { ContentType, contentTypes } from "./content-types.ts"; +import { services } from "./services.ts"; +import { + DenoRequest, + Handler, + Method, + ProcessedRoute, + Request, + Response, + Route, + UserRequest, +} from "./types.ts"; - - - -const phandler: Handler = async (req: Request) => { +const phandler: Handler = async (_req: Request) => { const code = httpCodes.success.OK; return { code, @@ -19,8 +25,6 @@ const phandler: Handler = async (req: Request) => { }; }; - - // FIXME: Obviously put this somewhere else const okText = (out: string) => { const code = httpCodes.success.OK; @@ -37,9 +41,10 @@ const routes: Route[] = [ path: "/slow", methods: ["get"], handler: async (_req) => { - console.log("starting slow request") ; - await sleep(10); - console.log("finishing slow request"); return okText("that was slow"); + console.log("starting slow request"); + await sleep(10); + console.log("finishing slow request"); + return okText("that was slow"); }, }, { @@ -91,11 +96,4 @@ const routes: Route[] = [ }, ]; - - - - - - - - export {routes} +export { routes }; From 6d2779ba83a5f263a2bb9611bc13e53fbb30f504 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 15:23:47 -0600 Subject: [PATCH 015/137] Fix import and avoid type defn nonsense --- deno/http-codes.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/deno/http-codes.ts b/deno/http-codes.ts index 572a490..b63b33c 100644 --- a/deno/http-codes.ts +++ b/deno/http-codes.ts @@ -1,4 +1,4 @@ -import { Extensible } from "./interfaces"; +import { Extensible } from "./interfaces.ts"; export type HttpCode = { code: number; @@ -11,8 +11,10 @@ type CodeDefinitions = { [K: string]: HttpCode; }; }; +// FIXME: Figure out how to brand CodeDefinitions in a way that isn't +// tedious. -const httpCodes: CodeDefinitions & Extensible = { +const httpCodes: CodeDefinitions = { success: { OK: { code: 200, name: "OK", "description": "" }, Created: { code: 201, name: "Created" }, From c850815e970b398d363ddaa09f89b6198da3d5de Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 15:24:11 -0600 Subject: [PATCH 016/137] Make timestamp optional --- deno/logging.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno/logging.ts b/deno/logging.ts index 6e629f8..d95ecac 100644 --- a/deno/logging.ts +++ b/deno/logging.ts @@ -7,7 +7,7 @@ type MessageSource = "logging" | "diagnostic" | "user"; type Message = { // FIXME: number probably isn't what we want here - timestamp: number; + timestamp?: number; source: MessageSource; text: AtLeastOne; From 5a08baab5ee4474f670a61e37e08fcf293ee7e21 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 15:24:21 -0600 Subject: [PATCH 017/137] Pull in zod --- deno/deno.json | 2 +- deno/deno.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/deno/deno.json b/deno/deno.json index aa6d055..68a8d4b 100644 --- a/deno/deno.json +++ b/deno/deno.json @@ -7,7 +7,7 @@ }, "imports": { "@std/assert": "jsr:@std/assert@1", - "zod": "npm:zod@^3.24.1" + "zod": "npm:zod@^3.24.2" }, "fmt": { "indentWidth": 4 diff --git a/deno/deno.lock b/deno/deno.lock index 3aca4c0..f8ce1e4 100644 --- a/deno/deno.lock +++ b/deno/deno.lock @@ -7,7 +7,7 @@ "jsr:@std/bytes@^1.0.2-rc.3": "1.0.5", "jsr:@std/internal@^1.0.5": "1.0.5", "jsr:@std/io@0.224.5": "0.224.5", - "npm:zod@^3.24.1": "3.24.1" + "npm:zod@^3.24.2": "3.24.2" }, "jsr": { "@std/assert@1.0.11": { @@ -33,8 +33,8 @@ } }, "npm": { - "zod@3.24.1": { - "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==" + "zod@3.24.2": { + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==" } }, "redirects": { @@ -196,7 +196,7 @@ "workspace": { "dependencies": [ "jsr:@std/assert@1", - "npm:zod@^3.24.1" + "npm:zod@^3.24.2" ] } } From 8d494cdf3b80b168a724fe486e65bf2392605c25 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 15:24:32 -0600 Subject: [PATCH 018/137] Reformat; fix some problems --- deno/app.ts | 93 +++++++++++++++++++++++++++-------------------------- 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/deno/app.ts b/deno/app.ts index db71c60..7ec9e1b 100644 --- a/deno/app.ts +++ b/deno/app.ts @@ -1,45 +1,48 @@ -// app.ts - -import { services } from "./services"; -import{DenoRequest, Method, ProcessedRoute, Request, Route} from './types' - -import {routes} from './routes' - -services.logging.log({ foo: 1 }); -const processedRoutes: ProcessedRoute[] = routes.map( - (route: Route, idx: number, allRoutes: Route[]) => { - const pattern: URLPattern = new URLPattern({ pathname: route.path }); - const method: Method = route.method; - const handler = async (denoRequest: DenoRequest) => { - const req: Request = { - pattern: route.pattern, - path: denoRequest.path, - method: denoRequest.method, - parameters: { one: 1, two: 2 }, - denoRequest, - }; - return route.handler(req); - }; - - const retval: ProcessedRoute = { pattern, method, handler }; - - return retval - }, -); - - -async function handler(req: globalThis.Request): globalThis.Response { - for (const [idx, pr] of processedRoutes.entries()) { - const match = pr.pattern.exec(req.url); - if (match) { - const resp = await pr.handler(req); - - return new globalThis.Response(resp.result); - } - } - - return new globalThis.Response("not found", { status: 404 }); -} - -Deno.serve(handler); - +import { services } from "./services.ts"; +import { + DenoRequest, + massageMethod,DenoResponse, + Method, + ProcessedRoute, + Request, + Route, +} from "./types.ts"; + +import { routes } from "./routes.ts"; + +services.logging.log({ source: "logging", text: ["1"] }); +const processedRoutes: ProcessedRoute[] = routes.map( + (route: Route, _idx: number, _allRoutes: Route[]) => { + const pattern: URLPattern = new URLPattern({ pathname: route.path }); + const method: Method = route.method; + const handler = async (denoRequest: DenoRequest) => { + const req: Request = { + pattern: route.pattern, + path: denoRequest.path, + method: massageMethod(denoRequest.method), + parameters: { one: 1, two: 2 }, + denoRequest, + }; + return route.handler(req); + }; + + const retval: ProcessedRoute = { pattern, method, handler }; + + return retval; + }, +); + +async function handler(req: DenoRequest): Promise { + for (const [idx, pr] of processedRoutes.entries()) { + const match = pr.pattern.exec(req.url); + if (match) { + const resp = await pr.handler(req); + + return new globalThis.Response(resp.result); + } + } + + return new globalThis.Response("not found", { status: 404 }); +} + +Deno.serve(handler); From d1d4e03885fc1cb8d927698999ceb17427d70de6 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 20:25:13 -0600 Subject: [PATCH 019/137] Use caps for http method types --- deno/types.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/deno/types.ts b/deno/types.ts index c139deb..2768484 100644 --- a/deno/types.ts +++ b/deno/types.ts @@ -8,17 +8,17 @@ import { HttpCode, httpCodes } from "./http-codes.ts"; import { ContentType, contentTypes } from "./content-types.ts"; const methodParser = z.union([ - z.literal("get"), - z.literal("post"), - z.literal("put"), - z.literal("patch"), - z.literal("delete"), + z.literal("GET"), + z.literal("POST"), + z.literal("PUT"), + z.literal("PATCH"), + z.literal("DELETE"), ]); export type Method = z.infer; const massageMethod = (input: string): Method => { - const r = methodParser.parse(input.toLowerCase()) + const r = methodParser.parse(input.toUpperCase()) return r; }; From 40f3e4ef5110902a976ea315c3cc5ffaec411f67 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 21:14:39 -0600 Subject: [PATCH 020/137] Remove unused function --- deno/routes.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/deno/routes.ts b/deno/routes.ts index 0f82b23..b5b10d0 100644 --- a/deno/routes.ts +++ b/deno/routes.ts @@ -16,15 +16,6 @@ import { UserRequest, } from "./types.ts"; -const phandler: Handler = async (_req: Request) => { - const code = httpCodes.success.OK; - return { - code, - result: "it is ok ", - contentType: contentTypes.text.plain, - }; -}; - // FIXME: Obviously put this somewhere else const okText = (out: string) => { const code = httpCodes.success.OK; From 6ed81e871f824800d172d6675c93cbb046db1c90 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 21:14:47 -0600 Subject: [PATCH 021/137] fmt --- deno/deno.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deno/deno.json b/deno/deno.json index 68a8d4b..4d3c22e 100644 --- a/deno/deno.json +++ b/deno/deno.json @@ -1,7 +1,7 @@ { - "compilerOptions": { - "lib": ["deno.ns", "dom"] - }, + "compilerOptions": { + "lib": ["deno.ns", "dom"] + }, "tasks": { "dev": "deno run --watch main.ts" }, From ecdbedc1350a8a0e9f81f75b0eb3885aafacd9cd Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 21:22:31 -0600 Subject: [PATCH 022/137] Clean up how route handlers are called --- deno/app.ts | 60 ++++++++++++++++++++++++++++++++++++++---------- deno/routes.ts | 18 +++++++-------- deno/services.ts | 1 - deno/types.ts | 12 ++++++---- 4 files changed, 64 insertions(+), 27 deletions(-) diff --git a/deno/app.ts b/deno/app.ts index 7ec9e1b..d251eab 100644 --- a/deno/app.ts +++ b/deno/app.ts @@ -1,39 +1,75 @@ import { services } from "./services.ts"; import { DenoRequest, - massageMethod,DenoResponse, + DenoResponse, + InternalHandler, + massageMethod, Method, ProcessedRoute, - Request, + Request as Request, Route, } from "./types.ts"; import { routes } from "./routes.ts"; services.logging.log({ source: "logging", text: ["1"] }); -const processedRoutes: ProcessedRoute[] = routes.map( +const processedRoutes: { [K in Method]: ProcessedRoute[] } = { + "GET": [], + "POST": [], + "PUT": [], + "PATCH": [], + "DELETE": [], +}; + +function isPromise(value: T | Promise): value is Promise { + return typeof (value as any)?.then === "function"; +} + +routes.forEach( (route: Route, _idx: number, _allRoutes: Route[]) => { const pattern: URLPattern = new URLPattern({ pathname: route.path }); - const method: Method = route.method; - const handler = async (denoRequest: DenoRequest) => { + const methodList = route.methods; + + const handler: InternalHandler = async (denoRequest: DenoRequest) => { + const method = massageMethod(denoRequest.method); + + if (!methodList.includes(method)) { + // XXX: Worth asserting this? + } + + const p = new URL(denoRequest.url); + const path = p.pathname; + const req: Request = { - pattern: route.pattern, - path: denoRequest.path, - method: massageMethod(denoRequest.method), + pattern: route.path, + path, + method, parameters: { one: 1, two: 2 }, denoRequest, }; - return route.handler(req); + + const retval = route.handler(req); + if (isPromise(retval)) { + return await retval; + } else { + return retval; + } }; - const retval: ProcessedRoute = { pattern, method, handler }; + for (const [_idx, method] of methodList.entries()) { + const pr: ProcessedRoute = { pattern, method, handler }; - return retval; + processedRoutes[method].push(pr); + } }, ); async function handler(req: DenoRequest): Promise { - for (const [idx, pr] of processedRoutes.entries()) { + const m = req.method; + const m1 = massageMethod(m); + + const byMethod = processedRoutes[m1]; + for (const [_idx, pr] of byMethod.entries()) { const match = pr.pattern.exec(req.url); if (match) { const resp = await pr.handler(req); diff --git a/deno/routes.ts b/deno/routes.ts index b5b10d0..85f0f48 100644 --- a/deno/routes.ts +++ b/deno/routes.ts @@ -30,18 +30,18 @@ const okText = (out: string) => { const routes: Route[] = [ { path: "/slow", - methods: ["get"], - handler: async (_req) => { + methods: ["GET"], + handler: async (_req: Request) => { console.log("starting slow request"); - await sleep(10); + await sleep(2); console.log("finishing slow request"); return okText("that was slow"); }, }, { path: "/list", - methods: ["get"], - handler: async (_req) => { + methods: ["GET"], + handler: (_req: Request) => { const code = httpCodes.success.OK; const lr = (rr: Route[]) => { const ret = rr.map((r: Route) => { @@ -61,8 +61,8 @@ const routes: Route[] = [ }, { path: "/ok", - methods: ["get"], - handler: async (_req) => { + methods: ["GET"], + handler: (_req: Request) => { const code = httpCodes.success.OK; const rn = services.random.randomNumber(); @@ -75,8 +75,8 @@ const routes: Route[] = [ }, { path: "/alsook", - methods: ["get"], - handler: async (_req) => { + methods: ["GET"], + handler: (_req) => { const code = httpCodes.success.OK; return { code, diff --git a/deno/services.ts b/deno/services.ts index cbbc4a6..94a476d 100644 --- a/deno/services.ts +++ b/deno/services.ts @@ -4,7 +4,6 @@ import { randomNumber } from "https://deno.land/x/random_number/mod.ts"; import { config } from "./config.ts"; import { getLogs, log } from "./logging.ts"; - //const database = Client({ //}) diff --git a/deno/types.ts b/deno/types.ts index 2768484..767d03a 100644 --- a/deno/types.ts +++ b/deno/types.ts @@ -18,13 +18,13 @@ const methodParser = z.union([ export type Method = z.infer; const massageMethod = (input: string): Method => { - const r = methodParser.parse(input.toUpperCase()) + const r = methodParser.parse(input.toUpperCase()); return r; }; export type DenoRequest = globalThis.Request; -export type DenoResponse=globalThis.Response; +export type DenoResponse = globalThis.Response; export type UserRequest = {}; export type Request = { pattern: string; @@ -33,10 +33,14 @@ export type Request = { parameters: object; denoRequest: globalThis.Request; }; + +export type InternalHandler = (req: DenoRequest) => Promise; + +export type Handler = (req: Request) => Promise | Response; export type ProcessedRoute = { pattern: URLPattern; method: Method; - handler: Handler; + handler: InternalHandler; }; export type Response = { @@ -45,8 +49,6 @@ export type Response = { result: string; }; -export type Handler = (req: Request) => Promise; - export type Route = { path: string; methods: Method[]; From 72284505a043d175fc96abec57b6ffb1192e325c Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 21:29:38 -0600 Subject: [PATCH 023/137] Teach handler to accept various methods --- deno/routes.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deno/routes.ts b/deno/routes.ts index 85f0f48..cd3047a 100644 --- a/deno/routes.ts +++ b/deno/routes.ts @@ -61,14 +61,14 @@ const routes: Route[] = [ }, { path: "/ok", - methods: ["GET"], - handler: (_req: Request) => { + methods: ["GET", "POST", "PUT"], + handler: (req: Request) => { const code = httpCodes.success.OK; const rn = services.random.randomNumber(); return { code, - result: "it is ok " + rn, + result: `that was ${req.method} (${rn})`, contentType: contentTypes.text.plain, }; }, From 553cd680dc49561bfc6691046a3e215a7594abc4 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 21:46:19 -0600 Subject: [PATCH 024/137] Move handler into a different file --- deno/handlers.ts | 19 +++++++++++++++++++ deno/routes.ts | 12 ++---------- 2 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 deno/handlers.ts diff --git a/deno/handlers.ts b/deno/handlers.ts new file mode 100644 index 0000000..4d63937 --- /dev/null +++ b/deno/handlers.ts @@ -0,0 +1,19 @@ +import { contentTypes } from "./content-types.ts"; +import { httpCodes } from "./http-codes.ts"; +import { services } from "./services.ts"; +import { Request,Handler, Response } from "./types.ts"; + +const multiHandler: Handler = (req: Request): Response => { + const code = httpCodes.success.OK; + const rn = services.random.randomNumber(); + + const retval: Response = { + code, + result: `that was ${req.method} (${rn})`, + contentType: contentTypes.text.plain, + }; + + return retval; +}; + +export { multiHandler }; diff --git a/deno/routes.ts b/deno/routes.ts index cd3047a..7df6339 100644 --- a/deno/routes.ts +++ b/deno/routes.ts @@ -5,6 +5,7 @@ import { sleep } from "https://deno.land/x/sleep/mod.ts"; import { HttpCode, httpCodes } from "./http-codes.ts"; import { ContentType, contentTypes } from "./content-types.ts"; import { services } from "./services.ts"; +import { multiHandler } from "./handlers.ts"; import { DenoRequest, Handler, @@ -62,16 +63,7 @@ const routes: Route[] = [ { path: "/ok", methods: ["GET", "POST", "PUT"], - handler: (req: Request) => { - const code = httpCodes.success.OK; - const rn = services.random.randomNumber(); - - return { - code, - result: `that was ${req.method} (${rn})`, - contentType: contentTypes.text.plain, - }; - }, + handler: multiHandler, }, { path: "/alsook", From 235d2b50dd06cbd08edb6a2160cbb39502ddfbf8 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 21:46:33 -0600 Subject: [PATCH 025/137] Add a FIXME --- deno/types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deno/types.ts b/deno/types.ts index 767d03a..b98cd9e 100644 --- a/deno/types.ts +++ b/deno/types.ts @@ -3,6 +3,10 @@ // FIXME: split this up into types used by app developers and types internal // to the framework. +// FIXME: the use of types like Request and Response cause problems because it's +// easy to forget to import them and then all sorts of random typechecking errors +// start showing up even if the code is sound. So find other names for them. + import { z } from "zod"; import { HttpCode, httpCodes } from "./http-codes.ts"; import { ContentType, contentTypes } from "./content-types.ts"; From f2513f7be0f878e823a29a01d27e8aa581f97c56 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 7 Mar 2025 21:48:49 -0600 Subject: [PATCH 026/137] Use deno's lsp --- deno/.dir-locals.el | 1 + 1 file changed, 1 insertion(+) create mode 100644 deno/.dir-locals.el diff --git a/deno/.dir-locals.el b/deno/.dir-locals.el new file mode 100644 index 0000000..228c482 --- /dev/null +++ b/deno/.dir-locals.el @@ -0,0 +1 @@ +((typescript-mode . ((lsp-disabled-clients . (ts-ls))))) From 15187ed7526fbae204ac1799beb63e73ff720517 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 28 Jun 2025 15:19:06 -0600 Subject: [PATCH 027/137] Stake out directories --- framework/.nodejs-config/.gitignore | 0 framework/.nodejs/.gitignore | 0 framework/binaries/.gitignore | 0 framework/downloads/.gitignore | 0 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 framework/.nodejs-config/.gitignore create mode 100644 framework/.nodejs/.gitignore create mode 100644 framework/binaries/.gitignore create mode 100644 framework/downloads/.gitignore diff --git a/framework/.nodejs-config/.gitignore b/framework/.nodejs-config/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/framework/.nodejs/.gitignore b/framework/.nodejs/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/framework/binaries/.gitignore b/framework/binaries/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/framework/downloads/.gitignore b/framework/downloads/.gitignore new file mode 100644 index 0000000..e69de29 From de7dbf45cd54542ff362e99d9d9a1d9d2b9dea26 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 28 Jun 2025 15:19:23 -0600 Subject: [PATCH 028/137] Ignore some directories --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c93af0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +framework/node/node_modules +framework/downloads +framework/binaries +framework/.nodejs +framework/.nodejs-config From 6a4a2f7eef7f466292648523daf56653428b4faf Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 25 Oct 2025 14:18:08 -0600 Subject: [PATCH 029/137] Add cmd wrapper script --- cmd | 19 +++++++++++++++++++ cmd.d/list | 9 +++++++++ cmd.d/pnpm | 7 +++++++ cmd.d/sync | 17 +++++++++++++++++ framework/shims/node | 13 +++++++++++++ framework/shims/node.common | 21 +++++++++++++++++++++ framework/shims/pnpm | 13 +++++++++++++ 7 files changed, 99 insertions(+) create mode 100755 cmd create mode 100755 cmd.d/list create mode 100755 cmd.d/pnpm create mode 100644 cmd.d/sync create mode 100755 framework/shims/node create mode 100644 framework/shims/node.common create mode 100755 framework/shims/pnpm diff --git a/cmd b/cmd new file mode 100755 index 0000000..58a13d9 --- /dev/null +++ b/cmd @@ -0,0 +1,19 @@ +#!/bin/bash + +# 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. + +set -eu + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +subcmd="$1" + +# echo "$subcmd" + +#exit 3 + +shift + +exec "$DIR"/cmd.d/"$subcmd" "$@" diff --git a/cmd.d/list b/cmd.d/list new file mode 100755 index 0000000..74c5a42 --- /dev/null +++ b/cmd.d/list @@ -0,0 +1,9 @@ +#!/bin/bash + +set -eu + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +cd "$DIR" + +ls . \ No newline at end of file diff --git a/cmd.d/pnpm b/cmd.d/pnpm new file mode 100755 index 0000000..05bc6da --- /dev/null +++ b/cmd.d/pnpm @@ -0,0 +1,7 @@ +#!/bin/bash + +set -eu + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +"$DIR"/../framework/shims/pnpm "$@" \ No newline at end of file diff --git a/cmd.d/sync b/cmd.d/sync new file mode 100644 index 0000000..2cdbc53 --- /dev/null +++ b/cmd.d/sync @@ -0,0 +1,17 @@ +#!/bin/bash + +set -eu + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# figure out the platform we're on + +# source ../framework/versions +# [eventually: check for it in user's cache dir +# download $nodejs_version +# verify its checksum against $nodejs_checksum + + + + +cd $DIR/../framework/node \ No newline at end of file diff --git a/framework/shims/node b/framework/shims/node new file mode 100755 index 0000000..083ab35 --- /dev/null +++ b/framework/shims/node @@ -0,0 +1,13 @@ +#!/bin/bash + +set -eu + +export DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +source "$DIR/../versions" + +node_bin="$DIR/../../$nodejs_bin_dir/node" + +exec "$node_bin" "$@" + + diff --git a/framework/shims/node.common b/framework/shims/node.common new file mode 100644 index 0000000..3d4922b --- /dev/null +++ b/framework/shims/node.common @@ -0,0 +1,21 @@ +# FIXME this shouldn't be hardcoded here of course + +nodejs_binary_dir="$DIR/../binaries/node-v22.15.1-linux-x64/bin" + +# This might be too restrictive. Or not restrictive enough. +PATH="$nodejs_binary_dir":/bin:/usr/bin + + +project_root="$DIR/../.." + +node_dir="$project_root/$nodejs_binary_dir" + +export NPM_CONFIG_PREFIX="$node_dir/npm" +export NPM_CONFIG_CACHE="$node_dir/cache" +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 diff --git a/framework/shims/pnpm b/framework/shims/pnpm new file mode 100755 index 0000000..3aa9376 --- /dev/null +++ b/framework/shims/pnpm @@ -0,0 +1,13 @@ +#!/bin/bash + +set -eu + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +source "$DIR"/node.common + +# "$DIR"/../.nodejs-config/node_modules/.bin/pnpm "$@" + +echo pnpm file: "$DIR"/../shims/pnpm + +exec "$DIR"/../binaries/pnpm "$@" From 292ce4be7f0f69882572f35d57e4d736aef417b5 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 8 Nov 2025 09:54:33 -0600 Subject: [PATCH 030/137] Flesh out shims and wrappers --- cmd | 4 +++- {cmd.d => framework/cmd.d}/list | 0 framework/cmd.d/node | 7 +++++++ {cmd.d => framework/cmd.d}/pnpm | 2 +- {cmd.d => framework/cmd.d}/sync | 6 +++++- framework/shims/common | 4 ++++ framework/shims/node | 2 ++ framework/shims/npm | 15 +++++++++++++++ framework/shims/pnpm | 5 ++--- 9 files changed, 39 insertions(+), 6 deletions(-) rename {cmd.d => framework/cmd.d}/list (100%) create mode 100755 framework/cmd.d/node rename {cmd.d => framework/cmd.d}/pnpm (69%) rename {cmd.d => framework/cmd.d}/sync (77%) mode change 100644 => 100755 create mode 100644 framework/shims/common create mode 100755 framework/shims/npm diff --git a/cmd b/cmd index 58a13d9..7cf2667 100755 --- a/cmd +++ b/cmd @@ -16,4 +16,6 @@ subcmd="$1" shift -exec "$DIR"/cmd.d/"$subcmd" "$@" +echo will run "$DIR"/framework/cmd.d/"$subcmd" "$@" + +exec "$DIR"/framework/cmd.d/"$subcmd" "$@" diff --git a/cmd.d/list b/framework/cmd.d/list similarity index 100% rename from cmd.d/list rename to framework/cmd.d/list diff --git a/framework/cmd.d/node b/framework/cmd.d/node new file mode 100755 index 0000000..dea36cd --- /dev/null +++ b/framework/cmd.d/node @@ -0,0 +1,7 @@ +#!/bin/bash + +set -eu + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +"$DIR"/../shims/node "$@" diff --git a/cmd.d/pnpm b/framework/cmd.d/pnpm similarity index 69% rename from cmd.d/pnpm rename to framework/cmd.d/pnpm index 05bc6da..f31a0b7 100755 --- a/cmd.d/pnpm +++ b/framework/cmd.d/pnpm @@ -4,4 +4,4 @@ set -eu DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -"$DIR"/../framework/shims/pnpm "$@" \ No newline at end of file +"$DIR"/../shims/pnpm "$@" diff --git a/cmd.d/sync b/framework/cmd.d/sync old mode 100644 new mode 100755 similarity index 77% rename from cmd.d/sync rename to framework/cmd.d/sync index 2cdbc53..6684a6a --- a/cmd.d/sync +++ b/framework/cmd.d/sync @@ -14,4 +14,8 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -cd $DIR/../framework/node \ No newline at end of file +cd $DIR/../node + +$DIR/pnpm install + +echo we will download other files here later diff --git a/framework/shims/common b/framework/shims/common new file mode 100644 index 0000000..9244976 --- /dev/null +++ b/framework/shims/common @@ -0,0 +1,4 @@ +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +ROOT="$DIR/../../" + + diff --git a/framework/shims/node b/framework/shims/node index 083ab35..671020a 100755 --- a/framework/shims/node +++ b/framework/shims/node @@ -1,5 +1,7 @@ #!/bin/bash +# This file belongs to the framework. You are not expected to modify it. + set -eu export DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" diff --git a/framework/shims/npm b/framework/shims/npm new file mode 100755 index 0000000..d6b7e7a --- /dev/null +++ b/framework/shims/npm @@ -0,0 +1,15 @@ +#!/bin/bash + +set -eu + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +source "$DIR"/node.common + +cd $DIR/../.nodejs-config +echo in dir $(pwd) +npm "$@" + + + + diff --git a/framework/shims/pnpm b/framework/shims/pnpm index 3aa9376..33c4c8a 100755 --- a/framework/shims/pnpm +++ b/framework/shims/pnpm @@ -5,9 +5,8 @@ set -eu DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" source "$DIR"/node.common +source "$DIR"/common -# "$DIR"/../.nodejs-config/node_modules/.bin/pnpm "$@" - -echo pnpm file: "$DIR"/../shims/pnpm +cd $ROOT/framework/node exec "$DIR"/../binaries/pnpm "$@" From d4d5a72b3e091ea96a4b714cb9a400acaf7905a4 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 8 Nov 2025 09:58:45 -0600 Subject: [PATCH 031/137] Ignore addl dir --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c93af0d..21ec9a1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ framework/downloads framework/binaries framework/.nodejs framework/.nodejs-config +node_modules \ No newline at end of file From 0201d08009445703b77de5040f64845e5f0e7b2a Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 16 Nov 2025 11:13:21 -0600 Subject: [PATCH 032/137] Add ts-node and tsx command wrappers --- framework/cmd.d/ts-node | 5 +++++ framework/cmd.d/tsx | 5 +++++ 2 files changed, 10 insertions(+) create mode 100755 framework/cmd.d/ts-node create mode 100755 framework/cmd.d/tsx diff --git a/framework/cmd.d/ts-node b/framework/cmd.d/ts-node new file mode 100755 index 0000000..ed006b4 --- /dev/null +++ b/framework/cmd.d/ts-node @@ -0,0 +1,5 @@ +#!/bin/bash + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"$DIR"/../shims/pnpm ts-node "$@" diff --git a/framework/cmd.d/tsx b/framework/cmd.d/tsx new file mode 100755 index 0000000..aad4af9 --- /dev/null +++ b/framework/cmd.d/tsx @@ -0,0 +1,5 @@ +#!/bin/bash + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"$DIR"/../shims/pnpm tsx "$@" From 96d861f043e6ef2b668dec126dab988e09affde7 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 16 Nov 2025 12:25:48 -0600 Subject: [PATCH 033/137] Use variable names less likely to be shadowed --- framework/shims/common | 4 ++-- framework/shims/node.common | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/framework/shims/common b/framework/shims/common index 9244976..a1391b9 100644 --- a/framework/shims/common +++ b/framework/shims/common @@ -1,4 +1,4 @@ -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -ROOT="$DIR/../../" +common_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +ROOT="$common_DIR/../../" diff --git a/framework/shims/node.common b/framework/shims/node.common index 3d4922b..22823c7 100644 --- a/framework/shims/node.common +++ b/framework/shims/node.common @@ -1,12 +1,13 @@ -# FIXME this shouldn't be hardcoded here of course +node_common_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -nodejs_binary_dir="$DIR/../binaries/node-v22.15.1-linux-x64/bin" +# FIXME this shouldn't be hardcoded here of course +nodejs_binary_dir="$node_common_DIR/../binaries/node-v22.15.1-linux-x64/bin" # This might be too restrictive. Or not restrictive enough. PATH="$nodejs_binary_dir":/bin:/usr/bin -project_root="$DIR/../.." +project_root="$node_common_DIR/../.." node_dir="$project_root/$nodejs_binary_dir" @@ -15,7 +16,7 @@ export NPM_CONFIG_CACHE="$node_dir/cache" 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 +# echo $NPM_CONFIG_PREFIX +# echo $NPM_CONFIG_CACHE +# echo $NPM_CONFIG_TMP +# echo $NODE_PATH From c346a70cce2104ff99aba32e7757d1de419fb109 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 16 Nov 2025 15:56:27 -0600 Subject: [PATCH 034/137] Drop deno dir --- deno/.dir-locals.el | 1 - deno/app.ts | 84 ------------------ deno/config.ts | 11 --- deno/content-types.ts | 38 -------- deno/deno.json | 15 ---- deno/deno.lock | 202 ------------------------------------------ deno/deps.ts | 7 -- deno/extensible.ts | 0 deno/handlers.ts | 19 ---- deno/http-codes.ts | 43 --------- deno/interfaces.ts | 3 - deno/logging.ts | 44 --------- deno/main.ts | 8 -- deno/main_test.ts | 6 -- deno/routes.ts | 82 ----------------- deno/run.sh | 11 --- deno/services.ts | 28 ------ deno/types.ts | 63 ------------- 18 files changed, 665 deletions(-) delete mode 100644 deno/.dir-locals.el delete mode 100644 deno/app.ts delete mode 100644 deno/config.ts delete mode 100644 deno/content-types.ts delete mode 100644 deno/deno.json delete mode 100644 deno/deno.lock delete mode 100644 deno/deps.ts delete mode 100644 deno/extensible.ts delete mode 100644 deno/handlers.ts delete mode 100644 deno/http-codes.ts delete mode 100644 deno/interfaces.ts delete mode 100644 deno/logging.ts delete mode 100644 deno/main.ts delete mode 100644 deno/main_test.ts delete mode 100644 deno/routes.ts delete mode 100755 deno/run.sh delete mode 100644 deno/services.ts delete mode 100644 deno/types.ts diff --git a/deno/.dir-locals.el b/deno/.dir-locals.el deleted file mode 100644 index 228c482..0000000 --- a/deno/.dir-locals.el +++ /dev/null @@ -1 +0,0 @@ -((typescript-mode . ((lsp-disabled-clients . (ts-ls))))) diff --git a/deno/app.ts b/deno/app.ts deleted file mode 100644 index d251eab..0000000 --- a/deno/app.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { services } from "./services.ts"; -import { - DenoRequest, - DenoResponse, - InternalHandler, - massageMethod, - Method, - ProcessedRoute, - Request as Request, - Route, -} from "./types.ts"; - -import { routes } from "./routes.ts"; - -services.logging.log({ source: "logging", text: ["1"] }); -const processedRoutes: { [K in Method]: ProcessedRoute[] } = { - "GET": [], - "POST": [], - "PUT": [], - "PATCH": [], - "DELETE": [], -}; - -function isPromise(value: T | Promise): value is Promise { - return typeof (value as any)?.then === "function"; -} - -routes.forEach( - (route: Route, _idx: number, _allRoutes: Route[]) => { - const pattern: URLPattern = new URLPattern({ pathname: route.path }); - const methodList = route.methods; - - const handler: InternalHandler = async (denoRequest: DenoRequest) => { - const method = massageMethod(denoRequest.method); - - if (!methodList.includes(method)) { - // XXX: Worth asserting this? - } - - const p = new URL(denoRequest.url); - const path = p.pathname; - - const req: Request = { - pattern: route.path, - path, - method, - parameters: { one: 1, two: 2 }, - denoRequest, - }; - - const retval = route.handler(req); - if (isPromise(retval)) { - return await retval; - } else { - return retval; - } - }; - - for (const [_idx, method] of methodList.entries()) { - const pr: ProcessedRoute = { pattern, method, handler }; - - processedRoutes[method].push(pr); - } - }, -); - -async function handler(req: DenoRequest): Promise { - const m = req.method; - const m1 = massageMethod(m); - - const byMethod = processedRoutes[m1]; - for (const [_idx, pr] of byMethod.entries()) { - const match = pr.pattern.exec(req.url); - if (match) { - const resp = await pr.handler(req); - - return new globalThis.Response(resp.result); - } - } - - return new globalThis.Response("not found", { status: 404 }); -} - -Deno.serve(handler); diff --git a/deno/config.ts b/deno/config.ts deleted file mode 100644 index 0b3dfe0..0000000 --- a/deno/config.ts +++ /dev/null @@ -1,11 +0,0 @@ -const config = { - database: { - user: "abc123", - password: "abc123", - host: "localhost", - port: "5432", - database: "abc123", - }, -}; - -export { config }; diff --git a/deno/content-types.ts b/deno/content-types.ts deleted file mode 100644 index ac73350..0000000 --- a/deno/content-types.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Extensible } from "./interfaces.ts"; - -export type ContentType = string; - -const contentTypes = { - text: { - plain: "text/plain", - html: "text/html", - css: "text/css", - javascript: "text/javascript", - xml: "text/xml", - }, - image: { - jpeg: "image/jpeg", - png: "image/png", - gif: "image/gif", - svgPlusXml: "image/svg+xml", - webp: "image/webp", - }, - audio: { - "mpeg": "audio/mpeg", - "wav": "audio/wav", - }, - video: { - mp4: "video/mp4", - webm: "video/webm", - xMsvideo: "video/x-msvideo", - }, - application: { - json: "application/json", - pdf: "application/pdf", - zip: "application/zip", - xWwwFormUrlencoded: "x-www-form-urlencoded", - octetStream: "octet-stream", - }, -}; - -export { contentTypes }; diff --git a/deno/deno.json b/deno/deno.json deleted file mode 100644 index 4d3c22e..0000000 --- a/deno/deno.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "lib": ["deno.ns", "dom"] - }, - "tasks": { - "dev": "deno run --watch main.ts" - }, - "imports": { - "@std/assert": "jsr:@std/assert@1", - "zod": "npm:zod@^3.24.2" - }, - "fmt": { - "indentWidth": 4 - } -} diff --git a/deno/deno.lock b/deno/deno.lock deleted file mode 100644 index f8ce1e4..0000000 --- a/deno/deno.lock +++ /dev/null @@ -1,202 +0,0 @@ -{ - "version": "4", - "specifiers": { - "jsr:@std/assert@1": "1.0.11", - "jsr:@std/async@1": "1.0.10", - "jsr:@std/bytes@1": "1.0.5", - "jsr:@std/bytes@^1.0.2-rc.3": "1.0.5", - "jsr:@std/internal@^1.0.5": "1.0.5", - "jsr:@std/io@0.224.5": "0.224.5", - "npm:zod@^3.24.2": "3.24.2" - }, - "jsr": { - "@std/assert@1.0.11": { - "integrity": "2461ef3c368fe88bc60e186e7744a93112f16fd110022e113a0849e94d1c83c1", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/async@1.0.10": { - "integrity": "2ff1b1c7d33d1416159989b0f69e59ec7ee8cb58510df01e454def2108b3dbec" - }, - "@std/bytes@1.0.5": { - "integrity": "4465dd739d7963d964c809202ebea6d5c6b8e3829ef25c6a224290fbb8a1021e" - }, - "@std/internal@1.0.5": { - "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" - }, - "@std/io@0.224.5": { - "integrity": "cb84fe655d1273fca94efcff411465027a8b0b4225203f19d6ee98d9c8920a2d", - "dependencies": [ - "jsr:@std/bytes@^1.0.2-rc.3" - ] - } - }, - "npm": { - "zod@3.24.2": { - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==" - } - }, - "redirects": { - "https://deno.land/x/random_number/mod.ts": "https://deno.land/x/random_number@2.0.0/mod.ts", - "https://deno.land/x/sleep/mod.ts": "https://deno.land/x/sleep@v1.3.0/mod.ts" - }, - "remote": { - "https://deno.land/std@0.214.0/assert/assert.ts": "bec068b2fccdd434c138a555b19a2c2393b71dfaada02b7d568a01541e67cdc5", - "https://deno.land/std@0.214.0/assert/assertion_error.ts": "9f689a101ee586c4ce92f52fa7ddd362e86434ffdf1f848e45987dc7689976b8", - "https://deno.land/std@0.214.0/async/delay.ts": "8e1d18fe8b28ff95885e2bc54eccec1713f57f756053576d8228e6ca110793ad", - "https://deno.land/std@0.214.0/bytes/copy.ts": "f29c03168853720dfe82eaa57793d0b9e3543ebfe5306684182f0f1e3bfd422a", - "https://deno.land/std@0.214.0/crypto/_fnv/fnv32.ts": "ba2c5ef976b9f047d7ce2d33dfe18671afc75154bcf20ef89d932b2fe8820535", - "https://deno.land/std@0.214.0/crypto/_fnv/fnv64.ts": "580cadfe2ff333fe253d15df450f927c8ac7e408b704547be26aab41b5772558", - "https://deno.land/std@0.214.0/crypto/_fnv/mod.ts": "8dbb60f062a6e77b82f7a62ac11fabfba52c3cd408c21916b130d8f57a880f96", - "https://deno.land/std@0.214.0/crypto/_fnv/util.ts": "27b36ce3440d0a180af6bf1cfc2c326f68823288540a354dc1d636b781b9b75f", - "https://deno.land/std@0.214.0/crypto/_wasm/lib/deno_std_wasm_crypto.generated.mjs": "76c727912539737def4549bb62a96897f37eb334b979f49c57b8af7a1617635e", - "https://deno.land/std@0.214.0/crypto/_wasm/mod.ts": "c55f91473846827f077dfd7e5fc6e2726dee5003b6a5747610707cdc638a22ba", - "https://deno.land/std@0.214.0/crypto/crypto.ts": "4448f8461c797adba8d70a2c60f7795a546d7a0926e96366391bffdd06491c16", - "https://deno.land/std@0.214.0/datetime/_common.ts": "a62214c1924766e008e27d3d843ceba4b545dc2aa9880de0ecdef9966d5736b6", - "https://deno.land/std@0.214.0/datetime/parse.ts": "bb248bbcb3cd54bcaf504a1ee670fc4695e429d9019c06af954bbe2bcb8f1d02", - "https://deno.land/std@0.214.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376", - "https://deno.land/std@0.214.0/encoding/base64.ts": "96e61a556d933201266fea84ae500453293f2aff130057b579baafda096a96bc", - "https://deno.land/std@0.214.0/encoding/hex.ts": "4d47d3b25103cf81a2ed38f54b394d39a77b63338e1eaa04b70c614cb45ec2e6", - "https://deno.land/std@0.214.0/fmt/colors.ts": "aeaee795471b56fc62a3cb2e174ed33e91551b535f44677f6320336aabb54fbb", - "https://deno.land/std@0.214.0/io/buf_reader.ts": "c73aad99491ee6db3d6b001fa4a780e9245c67b9296f5bad9c0fa7384e35d47a", - "https://deno.land/std@0.214.0/io/buf_writer.ts": "f82f640c8b3a820f600a8da429ad0537037c7d6a78426bbca2396fb1f75d3ef4", - "https://deno.land/std@0.214.0/io/types.ts": "748bbb3ac96abda03594ef5a0db15ce5450dcc6c0d841c8906f8b10ac8d32c96", - "https://deno.land/std@0.214.0/path/_common/assert_path.ts": "2ca275f36ac1788b2acb60fb2b79cb06027198bc2ba6fb7e163efaedde98c297", - "https://deno.land/std@0.214.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2", - "https://deno.land/std@0.214.0/path/_common/common.ts": "6157c7ec1f4db2b4a9a187efd6ce76dcaf1e61cfd49f87e40d4ea102818df031", - "https://deno.land/std@0.214.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c", - "https://deno.land/std@0.214.0/path/_common/dirname.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", - "https://deno.land/std@0.214.0/path/_common/format.ts": "92500e91ea5de21c97f5fe91e178bae62af524b72d5fcd246d6d60ae4bcada8b", - "https://deno.land/std@0.214.0/path/_common/from_file_url.ts": "d672bdeebc11bf80e99bf266f886c70963107bdd31134c4e249eef51133ceccf", - "https://deno.land/std@0.214.0/path/_common/glob_to_reg_exp.ts": "2007aa87bed6eb2c8ae8381adcc3125027543d9ec347713c1ad2c68427330770", - "https://deno.land/std@0.214.0/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", - "https://deno.land/std@0.214.0/path/_common/normalize_string.ts": "dfdf657a1b1a7db7999f7c575ee7e6b0551d9c20f19486c6c3f5ff428384c965", - "https://deno.land/std@0.214.0/path/_common/relative.ts": "faa2753d9b32320ed4ada0733261e3357c186e5705678d9dd08b97527deae607", - "https://deno.land/std@0.214.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a", - "https://deno.land/std@0.214.0/path/_common/to_file_url.ts": "7f76adbc83ece1bba173e6e98a27c647712cab773d3f8cbe0398b74afc817883", - "https://deno.land/std@0.214.0/path/_interface.ts": "a1419fcf45c0ceb8acdccc94394e3e94f99e18cfd32d509aab514c8841799600", - "https://deno.land/std@0.214.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15", - "https://deno.land/std@0.214.0/path/basename.ts": "5d341aadb7ada266e2280561692c165771d071c98746fcb66da928870cd47668", - "https://deno.land/std@0.214.0/path/common.ts": "03e52e22882402c986fe97ca3b5bb4263c2aa811c515ce84584b23bac4cc2643", - "https://deno.land/std@0.214.0/path/constants.ts": "0c206169ca104938ede9da48ac952de288f23343304a1c3cb6ec7625e7325f36", - "https://deno.land/std@0.214.0/path/dirname.ts": "85bd955bf31d62c9aafdd7ff561c4b5fb587d11a9a5a45e2b01aedffa4238a7c", - "https://deno.land/std@0.214.0/path/extname.ts": "593303db8ae8c865cbd9ceec6e55d4b9ac5410c1e276bfd3131916591b954441", - "https://deno.land/std@0.214.0/path/format.ts": "98fad25f1af7b96a48efb5b67378fcc8ed77be895df8b9c733b86411632162af", - "https://deno.land/std@0.214.0/path/from_file_url.ts": "911833ae4fd10a1c84f6271f36151ab785955849117dc48c6e43b929504ee069", - "https://deno.land/std@0.214.0/path/glob_to_regexp.ts": "83c5fd36a8c86f5e72df9d0f45317f9546afa2ce39acaafe079d43a865aced08", - "https://deno.land/std@0.214.0/path/is_absolute.ts": "4791afc8bfd0c87f0526eaa616b0d16e7b3ab6a65b62942e50eac68de4ef67d7", - "https://deno.land/std@0.214.0/path/is_glob.ts": "a65f6195d3058c3050ab905705891b412ff942a292bcbaa1a807a74439a14141", - "https://deno.land/std@0.214.0/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a", - "https://deno.land/std@0.214.0/path/join_globs.ts": "e9589869a33dc3982101898ee50903db918ca00ad2614dbe3934d597d7b1fbea", - "https://deno.land/std@0.214.0/path/mod.ts": "ffeaccb713dbe6c72e015b7c767f753f8ec5fbc3b621ff5eeee486ffc2c0ddda", - "https://deno.land/std@0.214.0/path/normalize.ts": "4155743ccceeed319b350c1e62e931600272fad8ad00c417b91df093867a8352", - "https://deno.land/std@0.214.0/path/normalize_glob.ts": "98ee8268fad271193603271c203ae973280b5abfbdd2cbca1053fd2af71869ca", - "https://deno.land/std@0.214.0/path/parse.ts": "65e8e285f1a63b714e19ef24b68f56e76934c3df0b6e65fd440d3991f4f8aefb", - "https://deno.land/std@0.214.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d", - "https://deno.land/std@0.214.0/path/posix/basename.ts": "39ee27a29f1f35935d3603ccf01d53f3d6e0c5d4d0f84421e65bd1afeff42843", - "https://deno.land/std@0.214.0/path/posix/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4", - "https://deno.land/std@0.214.0/path/posix/constants.ts": "93481efb98cdffa4c719c22a0182b994e5a6aed3047e1962f6c2c75b7592bef1", - "https://deno.land/std@0.214.0/path/posix/dirname.ts": "6535d2bdd566118963537b9dda8867ba9e2a361015540dc91f5afbb65c0cce8b", - "https://deno.land/std@0.214.0/path/posix/extname.ts": "8d36ae0082063c5e1191639699e6f77d3acf501600a3d87b74943f0ae5327427", - "https://deno.land/std@0.214.0/path/posix/format.ts": "185e9ee2091a42dd39e2a3b8e4925370ee8407572cee1ae52838aed96310c5c1", - "https://deno.land/std@0.214.0/path/posix/from_file_url.ts": "951aee3a2c46fd0ed488899d024c6352b59154c70552e90885ed0c2ab699bc40", - "https://deno.land/std@0.214.0/path/posix/glob_to_regexp.ts": "54d3ff40f309e3732ab6e5b19d7111d2d415248bcd35b67a99defcbc1972e697", - "https://deno.land/std@0.214.0/path/posix/is_absolute.ts": "cebe561ad0ae294f0ce0365a1879dcfca8abd872821519b4fcc8d8967f888ede", - "https://deno.land/std@0.214.0/path/posix/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9", - "https://deno.land/std@0.214.0/path/posix/join.ts": "aef88d5fa3650f7516730865dbb951594d1a955b785e2450dbee93b8e32694f3", - "https://deno.land/std@0.214.0/path/posix/join_globs.ts": "ee2f4676c5b8a0dfa519da58b8ade4d1c4aa8dd3fe35619edec883ae9df1f8c9", - "https://deno.land/std@0.214.0/path/posix/mod.ts": "563a18c2b3ddc62f3e4a324ff0f583e819b8602a72ad880cb98c9e2e34f8db5b", - "https://deno.land/std@0.214.0/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91", - "https://deno.land/std@0.214.0/path/posix/normalize_glob.ts": "65f0138fa518ef9ece354f32889783fc38cdf985fb02dcf1c3b14fa47d665640", - "https://deno.land/std@0.214.0/path/posix/parse.ts": "d5bac4eb21262ab168eead7e2196cb862940c84cee572eafedd12a0d34adc8fb", - "https://deno.land/std@0.214.0/path/posix/relative.ts": "3907d6eda41f0ff723d336125a1ad4349112cd4d48f693859980314d5b9da31c", - "https://deno.land/std@0.214.0/path/posix/resolve.ts": "bac20d9921beebbbb2b73706683b518b1d0c1b1da514140cee409e90d6b2913a", - "https://deno.land/std@0.214.0/path/posix/separator.ts": "c9ecae5c843170118156ac5d12dc53e9caf6a1a4c96fc8b1a0ab02dff5c847b0", - "https://deno.land/std@0.214.0/path/posix/to_file_url.ts": "7aa752ba66a35049e0e4a4be5a0a31ac6b645257d2e031142abb1854de250aaf", - "https://deno.land/std@0.214.0/path/posix/to_namespaced_path.ts": "28b216b3c76f892a4dca9734ff1cc0045d135532bfd9c435ae4858bfa5a2ebf0", - "https://deno.land/std@0.214.0/path/relative.ts": "ab739d727180ed8727e34ed71d976912461d98e2b76de3d3de834c1066667add", - "https://deno.land/std@0.214.0/path/resolve.ts": "a6f977bdb4272e79d8d0ed4333e3d71367cc3926acf15ac271f1d059c8494d8d", - "https://deno.land/std@0.214.0/path/separator.ts": "c6c890507f944a1f5cb7d53b8d638d6ce3cf0f34609c8d84a10c1eaa400b77a9", - "https://deno.land/std@0.214.0/path/to_file_url.ts": "88f049b769bce411e2d2db5bd9e6fd9a185a5fbd6b9f5ad8f52bef517c4ece1b", - "https://deno.land/std@0.214.0/path/to_namespaced_path.ts": "b706a4103b104cfadc09600a5f838c2ba94dbcdb642344557122dda444526e40", - "https://deno.land/std@0.214.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808", - "https://deno.land/std@0.214.0/path/windows/basename.ts": "e2dbf31d1d6385bfab1ce38c333aa290b6d7ae9e0ecb8234a654e583cf22f8fe", - "https://deno.land/std@0.214.0/path/windows/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4", - "https://deno.land/std@0.214.0/path/windows/constants.ts": "5afaac0a1f67b68b0a380a4ef391bf59feb55856aa8c60dfc01bd3b6abb813f5", - "https://deno.land/std@0.214.0/path/windows/dirname.ts": "33e421be5a5558a1346a48e74c330b8e560be7424ed7684ea03c12c21b627bc9", - "https://deno.land/std@0.214.0/path/windows/extname.ts": "165a61b00d781257fda1e9606a48c78b06815385e7d703232548dbfc95346bef", - "https://deno.land/std@0.214.0/path/windows/format.ts": "bbb5ecf379305b472b1082cd2fdc010e44a0020030414974d6029be9ad52aeb6", - "https://deno.land/std@0.214.0/path/windows/from_file_url.ts": "ced2d587b6dff18f963f269d745c4a599cf82b0c4007356bd957cb4cb52efc01", - "https://deno.land/std@0.214.0/path/windows/glob_to_regexp.ts": "6dcd1242bd8907aa9660cbdd7c93446e6927b201112b0cba37ca5d80f81be51b", - "https://deno.land/std@0.214.0/path/windows/is_absolute.ts": "4a8f6853f8598cf91a835f41abed42112cebab09478b072e4beb00ec81f8ca8a", - "https://deno.land/std@0.214.0/path/windows/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9", - "https://deno.land/std@0.214.0/path/windows/join.ts": "e0b3356615c1a75c56ebb6a7311157911659e11fd533d80d724800126b761ac3", - "https://deno.land/std@0.214.0/path/windows/join_globs.ts": "ee2f4676c5b8a0dfa519da58b8ade4d1c4aa8dd3fe35619edec883ae9df1f8c9", - "https://deno.land/std@0.214.0/path/windows/mod.ts": "7d6062927bda47c47847ffb55d8f1a37b0383840aee5c7dfc93984005819689c", - "https://deno.land/std@0.214.0/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780", - "https://deno.land/std@0.214.0/path/windows/normalize_glob.ts": "179c86ba89f4d3fe283d2addbe0607341f79ee9b1ae663abcfb3439db2e97810", - "https://deno.land/std@0.214.0/path/windows/parse.ts": "b9239edd892a06a06625c1b58425e199f018ce5649ace024d144495c984da734", - "https://deno.land/std@0.214.0/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7", - "https://deno.land/std@0.214.0/path/windows/resolve.ts": "75b2e3e1238d840782cee3d8864d82bfaa593c7af8b22f19c6422cf82f330ab3", - "https://deno.land/std@0.214.0/path/windows/separator.ts": "e51c5522140eff4f8402617c5c68a201fdfa3a1a8b28dc23587cff931b665e43", - "https://deno.land/std@0.214.0/path/windows/to_file_url.ts": "1cd63fd35ec8d1370feaa4752eccc4cc05ea5362a878be8dc7db733650995484", - "https://deno.land/std@0.214.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c", - "https://deno.land/x/memcached@v1.0.0/mod.ts": "3c8528631e603638ded48f8fadcf61cde00fb1d2054e8647cc033bc9f2ea5867", - "https://deno.land/x/postgres@v0.19.3/client.ts": "d141c65c20484c545a1119c9af7a52dcc24f75c1a5633de2b9617b0f4b2ed5c1", - "https://deno.land/x/postgres@v0.19.3/client/error.ts": "05b0e35d65caf0ba21f7f6fab28c0811da83cd8b4897995a2f411c2c83391036", - "https://deno.land/x/postgres@v0.19.3/connection/auth.ts": "db15c1659742ef4d2791b32834950278dc7a40cb931f8e434e6569298e58df51", - "https://deno.land/x/postgres@v0.19.3/connection/connection.ts": "198a0ecf92a0d2aa72db3bb88b8f412d3b1f6b87d464d5f7bff9aa3b6aff8370", - "https://deno.land/x/postgres@v0.19.3/connection/connection_params.ts": "463d7a9ed559f537a55d6928cab62e1c31b808d08cd0411b6ae461d0c0183c93", - "https://deno.land/x/postgres@v0.19.3/connection/message.ts": "20da5d80fc4d7ddb7b850083e0b3fa8734eb26642221dad89c62e27d78e57a4d", - "https://deno.land/x/postgres@v0.19.3/connection/message_code.ts": "12bcb110df6945152f9f6c63128786558d7ad1e61006920daaa16ef85b3bab7d", - "https://deno.land/x/postgres@v0.19.3/connection/packet.ts": "050aeff1fc13c9349e89451a155ffcd0b1343dc313a51f84439e3e45f64b56c8", - "https://deno.land/x/postgres@v0.19.3/connection/scram.ts": "532d4d58b565a2ab48fb5e1e14dc9bfb3bb283d535011e371e698eb4a89dd994", - "https://deno.land/x/postgres@v0.19.3/debug.ts": "8add17699191f11e6830b8c95d9de25857d221bb2cf6c4ae22254d395895c1f9", - "https://deno.land/x/postgres@v0.19.3/deps.ts": "c312038fe64b8368f8a294119f11d8f235fe67de84d7c3b0ef67b3a56628171a", - "https://deno.land/x/postgres@v0.19.3/mod.ts": "4930c7b44f8d16ea71026f7e3ef22a2322d84655edceacd55f7461a9218d8560", - "https://deno.land/x/postgres@v0.19.3/pool.ts": "2289f029e7a3bd3d460d4faa71399a920b7406c92a97c0715d6e31dbf1380ec3", - "https://deno.land/x/postgres@v0.19.3/query/array_parser.ts": "ff72d3e026e3022a1a223a6530be5663f8ebbd911ed978291314e7fe6c2f2464", - "https://deno.land/x/postgres@v0.19.3/query/decode.ts": "3e89ad2a662eab66a4f4e195ff0924d71d199af3c2f5637d1ae650301a03fa9b", - "https://deno.land/x/postgres@v0.19.3/query/decoders.ts": "6a73da1024086ab91e233648c850dccbde59248b90d87054bbbd7f0bf4a50681", - "https://deno.land/x/postgres@v0.19.3/query/encode.ts": "5b1c305bc7352a6f9fe37f235dddfc23e26419c77a133b4eaea42cf136481aa6", - "https://deno.land/x/postgres@v0.19.3/query/oid.ts": "21fc714ac212350ba7df496f88ea9e01a4ee0458911d0f2b6a81498e12e7af4c", - "https://deno.land/x/postgres@v0.19.3/query/query.ts": "510f9a27da87ed7b31b5cbcd14bf3028b441ac2ddc368483679d0b86a9d9f213", - "https://deno.land/x/postgres@v0.19.3/query/transaction.ts": "8f4eef68f8e9b4be216199404315e6e08fe1fe98afb2e640bffd077662f79678", - "https://deno.land/x/postgres@v0.19.3/query/types.ts": "540f6f973d493d63f2c0059a09f3368071f57931bba68bea408a635a3e0565d6", - "https://deno.land/x/postgres@v0.19.3/utils/deferred.ts": "5420531adb6c3ea29ca8aac57b9b59bd3e4b9a938a4996bbd0947a858f611080", - "https://deno.land/x/postgres@v0.19.3/utils/utils.ts": "ca47193ea03ff5b585e487a06f106d367e509263a960b787197ce0c03113a738", - "https://deno.land/x/random_number@2.0.0/mod.ts": "83010e4a0192b015ba4491d8bb8c73a458f352ebc613b847ff6349961d1c7827", - "https://deno.land/x/redis@v0.37.1/backoff.ts": "33e4a6e245f8743fbae0ce583993a671a3ac2ecee433a3e7f0bd77b5dd541d84", - "https://deno.land/x/redis@v0.37.1/command.ts": "2d1da4b32495ea852bdff0c2e7fd191a056779a696b9f83fb648c5ebac45cfc3", - "https://deno.land/x/redis@v0.37.1/connection.ts": "cee30a6310298441de17d1028d4ce3fd239dcf05a92294fba7173a922d0596cb", - "https://deno.land/x/redis@v0.37.1/deps/std/async.ts": "5a588aefb041cca49f0e6b7e3c397119693a3e07bea89c54cf7fe4a412e37bbf", - "https://deno.land/x/redis@v0.37.1/deps/std/bytes.ts": "f5b437ebcac77600101a81ef457188516e4944b3c2a931dff5ced3fa0c239b62", - "https://deno.land/x/redis@v0.37.1/deps/std/io.ts": "b7505c5e738384f5f7a021d7bbd78380490c059cc7c83cd8dada1f86ec16e835", - "https://deno.land/x/redis@v0.37.1/errors.ts": "8293f56a70ea8388cb80b6e1caa15d350ed1719529fc06573b01a443d0caad69", - "https://deno.land/x/redis@v0.37.1/events.ts": "704767b1beed2d5acfd5e86bd1ef93befdc8a8f8c8bb4ae1b4485664a8a6a625", - "https://deno.land/x/redis@v0.37.1/executor.ts": "5ac4c1f7bec44d12ebc0f3702bf074bd3ba6c1aae74953582f6358d2948718e7", - "https://deno.land/x/redis@v0.37.1/internal/encoding.ts": "0525f7f444a96b92cd36423abdfe221f8d8de4a018dc5cb6750a428a5fc897c2", - "https://deno.land/x/redis@v0.37.1/internal/symbols.ts": "e36097bab1da1c9fe84a3bb9cb0ed1ec10c3dc7dd0b557769c5c54e15d110dd2", - "https://deno.land/x/redis@v0.37.1/mod.ts": "e11d9384c2ffe1b3d81ce0ad275254519990635ad1ba39f46d49a73b3c35238d", - "https://deno.land/x/redis@v0.37.1/pipeline.ts": "974fff59bf0befa2ad7eee50ecba40006c47364f5e3285e1335c9f9541b7ebae", - "https://deno.land/x/redis@v0.37.1/protocol/deno_streams/command.ts": "5c5e5fb639cae22c1f9bfdc87631edcd67bb28bf8590161ae484d293b733aa01", - "https://deno.land/x/redis@v0.37.1/protocol/deno_streams/mod.ts": "b084bf64d6b795f6c1d0b360d9be221e246a9c033e5d88fd1e82fa14f711d25b", - "https://deno.land/x/redis@v0.37.1/protocol/deno_streams/reply.ts": "639de34541f207f793393a3cd45f9a23ef308f094d9d3d6ce62f84b175d3af47", - "https://deno.land/x/redis@v0.37.1/protocol/shared/command.ts": "e75f6be115ff73bd865e01be4e2a28077a9993b1e0c54ed96b6825bfe997d382", - "https://deno.land/x/redis@v0.37.1/protocol/shared/protocol.ts": "5b9284ee28ec74dfc723c7c7f07dca8d5f9d303414f36689503622dfdde12551", - "https://deno.land/x/redis@v0.37.1/protocol/shared/reply.ts": "3311ff66357bacbd60785cb43b97539c341d8a7d963bc5e80cb864ac81909ea5", - "https://deno.land/x/redis@v0.37.1/protocol/shared/types.ts": "c6bf2b9eafd69e358a972823d94b8b478c00bac195b87b33b7437de2a9bb7fb4", - "https://deno.land/x/redis@v0.37.1/pubsub.ts": "a36892455b0a4a50af169332a165b0985cc90d84486087f036e507e3137b2afb", - "https://deno.land/x/redis@v0.37.1/redis.ts": "4904772596c8a82d7112092e7edea45243eae38809b2f2ea8db61a4207fe246b", - "https://deno.land/x/redis@v0.37.1/stream.ts": "d43076815d046eb8428fcd2799544a9fd07b3480099f5fc67d2ba12fdc73725f", - "https://deno.land/x/sleep@v1.3.0/mod.ts": "e9955ecd3228a000e29d46726cd6ab14b65cf83904e9b365f3a8d64ec61c1af3", - "https://deno.land/x/sleep@v1.3.0/sleep.ts": "b6abaca093b094b0c2bba94f287b19a60946a8d15764d168f83fcf555f5bb59e" - }, - "workspace": { - "dependencies": [ - "jsr:@std/assert@1", - "npm:zod@^3.24.2" - ] - } -} diff --git a/deno/deps.ts b/deno/deps.ts deleted file mode 100644 index 869fb08..0000000 --- a/deno/deps.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Database -export { Client as PostgresClient } from "https://deno.land/x/postgres@v0.19.3/mod.ts"; -export type { ClientOptions as PostgresOptions } from "https://deno.land/x/postgres@v0.19.3/mod.ts"; - -// Redis -export { connect as redisConnect } from "https://deno.land/x/redis@v0.37.1/mod.ts"; -export type { Redis } from "https://deno.land/x/redis@v0.37.1/mod.ts"; diff --git a/deno/extensible.ts b/deno/extensible.ts deleted file mode 100644 index e69de29..0000000 diff --git a/deno/handlers.ts b/deno/handlers.ts deleted file mode 100644 index 4d63937..0000000 --- a/deno/handlers.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { contentTypes } from "./content-types.ts"; -import { httpCodes } from "./http-codes.ts"; -import { services } from "./services.ts"; -import { Request,Handler, Response } from "./types.ts"; - -const multiHandler: Handler = (req: Request): Response => { - const code = httpCodes.success.OK; - const rn = services.random.randomNumber(); - - const retval: Response = { - code, - result: `that was ${req.method} (${rn})`, - contentType: contentTypes.text.plain, - }; - - return retval; -}; - -export { multiHandler }; diff --git a/deno/http-codes.ts b/deno/http-codes.ts deleted file mode 100644 index b63b33c..0000000 --- a/deno/http-codes.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Extensible } from "./interfaces.ts"; - -export type HttpCode = { - code: number; - name: string; - description?: string; -}; -type Group = "success" | "redirection" | "clientErrors" | "serverErrors"; -type CodeDefinitions = { - [K in Group]: { - [K: string]: HttpCode; - }; -}; -// FIXME: Figure out how to brand CodeDefinitions in a way that isn't -// tedious. - -const httpCodes: CodeDefinitions = { - success: { - OK: { code: 200, name: "OK", "description": "" }, - Created: { code: 201, name: "Created" }, - Accepted: { code: 202, name: "Accepted" }, - NoContent: { code: 204, name: "No content" }, - }, - redirection: { - // later - }, - clientErrors: { - BadRequest: { code: 400, name: "Bad Request" }, - Unauthorized: { code: 401, name: "Unauthorized" }, - Forbidden: { code: 403, name: "Forbidden" }, - NotFound: { code: 404, name: "Not Found" }, - MethodNotAllowed: { code: 405, name: "Method Not Allowed" }, - NotAcceptable: { code: 406, name: "Not Acceptable" }, - // More later - }, - serverErrors: { - InternalServerError: { code: 500, name: "Internal Server Error" }, - NotImplemented: { code: 500, name: "Not implemented" }, - // more later - }, -}; - -export { httpCodes }; diff --git a/deno/interfaces.ts b/deno/interfaces.ts deleted file mode 100644 index 48dd302..0000000 --- a/deno/interfaces.ts +++ /dev/null @@ -1,3 +0,0 @@ -type Brand = K & { readonly __brand: T }; - -export type Extensible = Brand<"Extensible", {}>; diff --git a/deno/logging.ts b/deno/logging.ts deleted file mode 100644 index d95ecac..0000000 --- a/deno/logging.ts +++ /dev/null @@ -1,44 +0,0 @@ -// internal-logging.ts - -// FIXME: Move this to somewhere more appropriate -type AtLeastOne = [T, ...T[]]; - -type MessageSource = "logging" | "diagnostic" | "user"; - -type Message = { - // FIXME: number probably isn't what we want here - timestamp?: number; - source: MessageSource; - - text: AtLeastOne; -}; - -const m1: Message = { timestamp: 123, source: "logging", text: ["foo"] }; -const m2: Message = { - timestamp: 321, - source: "diagnostic", - text: ["ok", "whatever"], -}; - -type FilterArgument = { - limit?: number; - before?: number; - after?: number; - - // FIXME: add offsets to use instead of or in addition to before/after - - match?: (string | RegExp)[]; -}; - -const log = (_message: Message) => { - // WRITEME -}; - -const getLogs = (filter: FilterArgument) => { - // WRITEME -}; - -// FIXME: there's scope for more specialized functions although they -// probably should be defined in terms of the basic ones here. - -export { getLogs, log }; diff --git a/deno/main.ts b/deno/main.ts deleted file mode 100644 index 3678714..0000000 --- a/deno/main.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function add(a: number, b: number): number { - return a + b; -} - -// Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts -if (import.meta.main) { - console.log("Add 2 + 3 =", add(2, 3)); -} diff --git a/deno/main_test.ts b/deno/main_test.ts deleted file mode 100644 index 6bdafb8..0000000 --- a/deno/main_test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { assertEquals } from "@std/assert"; -import { add } from "./main.ts"; - -Deno.test(function addTest() { - assertEquals(add(2, 3), 5); -}); diff --git a/deno/routes.ts b/deno/routes.ts deleted file mode 100644 index 7df6339..0000000 --- a/deno/routes.ts +++ /dev/null @@ -1,82 +0,0 @@ -/// - -import { sleep } from "https://deno.land/x/sleep/mod.ts"; - -import { HttpCode, httpCodes } from "./http-codes.ts"; -import { ContentType, contentTypes } from "./content-types.ts"; -import { services } from "./services.ts"; -import { multiHandler } from "./handlers.ts"; -import { - DenoRequest, - Handler, - Method, - ProcessedRoute, - Request, - Response, - Route, - UserRequest, -} from "./types.ts"; - -// FIXME: Obviously put this somewhere else -const okText = (out: string) => { - const code = httpCodes.success.OK; - - return { - code, - result: out, - contentType: contentTypes.text.plain, - }; -}; - -const routes: Route[] = [ - { - path: "/slow", - methods: ["GET"], - handler: async (_req: Request) => { - console.log("starting slow request"); - await sleep(2); - console.log("finishing slow request"); - return okText("that was slow"); - }, - }, - { - path: "/list", - methods: ["GET"], - handler: (_req: Request) => { - const code = httpCodes.success.OK; - const lr = (rr: Route[]) => { - const ret = rr.map((r: Route) => { - return r.path; - }); - - return ret; - }; - - const listing = lr(routes).join(", "); - return { - code, - result: listing + "\n", - contentType: contentTypes.text.plain, - }; - }, - }, - { - path: "/ok", - methods: ["GET", "POST", "PUT"], - handler: multiHandler, - }, - { - path: "/alsook", - methods: ["GET"], - handler: (_req) => { - const code = httpCodes.success.OK; - return { - code, - result: "it is also ok", - contentType: contentTypes.text.plain, - }; - }, - }, -]; - -export { routes }; diff --git a/deno/run.sh b/deno/run.sh deleted file mode 100755 index 713aad1..0000000 --- a/deno/run.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -set -e - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - -cd "$DIR" - -deno run --allow-net --unstable-sloppy-imports --watch app.ts - - diff --git a/deno/services.ts b/deno/services.ts deleted file mode 100644 index 94a476d..0000000 --- a/deno/services.ts +++ /dev/null @@ -1,28 +0,0 @@ -// services.ts - -import { randomNumber } from "https://deno.land/x/random_number/mod.ts"; -import { config } from "./config.ts"; -import { getLogs, log } from "./logging.ts"; - -//const database = Client({ - -//}) - -const database = {}; - -const logging = { - log, - getLogs, -}; - -const random = { - randomNumber, -}; - -const services = { - database, - logging, - random, -}; - -export { services }; diff --git a/deno/types.ts b/deno/types.ts deleted file mode 100644 index b98cd9e..0000000 --- a/deno/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -// types.ts - -// FIXME: split this up into types used by app developers and types internal -// to the framework. - -// FIXME: the use of types like Request and Response cause problems because it's -// easy to forget to import them and then all sorts of random typechecking errors -// start showing up even if the code is sound. So find other names for them. - -import { z } from "zod"; -import { HttpCode, httpCodes } from "./http-codes.ts"; -import { ContentType, contentTypes } from "./content-types.ts"; - -const methodParser = z.union([ - z.literal("GET"), - z.literal("POST"), - z.literal("PUT"), - z.literal("PATCH"), - z.literal("DELETE"), -]); - -export type Method = z.infer; - -const massageMethod = (input: string): Method => { - const r = methodParser.parse(input.toUpperCase()); - - return r; -}; - -export type DenoRequest = globalThis.Request; -export type DenoResponse = globalThis.Response; -export type UserRequest = {}; -export type Request = { - pattern: string; - path: string; - method: Method; - parameters: object; - denoRequest: globalThis.Request; -}; - -export type InternalHandler = (req: DenoRequest) => Promise; - -export type Handler = (req: Request) => Promise | Response; -export type ProcessedRoute = { - pattern: URLPattern; - method: Method; - handler: InternalHandler; -}; - -export type Response = { - code: HttpCode; - contentType: ContentType; - result: string; -}; - -export type Route = { - path: string; - methods: Method[]; - handler: Handler; - interruptable?: boolean; -}; - -export { massageMethod }; From 1a13fd090974ab4c69a66a97395bcbd1c31bc99c Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Mon, 17 Nov 2025 10:58:54 -0600 Subject: [PATCH 035/137] Add a first cut at an express-based backend --- express/.gitignore | 1 + express/app.ts | 120 +++ express/check.sh | 14 + express/config.ts | 11 + express/content-types.ts | 40 + express/deps.ts | 7 + express/extensible.ts | 0 express/handlers.ts | 19 + express/http-codes.ts | 43 ++ express/interfaces.ts | 3 + express/logging.ts | 44 ++ express/package.json | 47 ++ express/pnpm-lock.yaml | 1510 ++++++++++++++++++++++++++++++++++++++ express/routes.ts | 77 ++ express/run.sh | 32 + express/services.ts | 36 + express/show-config.sh | 12 + express/tsconfig.json | 13 + express/types.ts | 59 ++ express/watch.sh | 14 + 20 files changed, 2102 insertions(+) create mode 100644 express/.gitignore create mode 100644 express/app.ts create mode 100755 express/check.sh create mode 100644 express/config.ts create mode 100644 express/content-types.ts create mode 100644 express/deps.ts create mode 100644 express/extensible.ts create mode 100644 express/handlers.ts create mode 100644 express/http-codes.ts create mode 100644 express/interfaces.ts create mode 100644 express/logging.ts create mode 100644 express/package.json create mode 100644 express/pnpm-lock.yaml create mode 100644 express/routes.ts create mode 100755 express/run.sh create mode 100644 express/services.ts create mode 100755 express/show-config.sh create mode 100644 express/tsconfig.json create mode 100644 express/types.ts create mode 100755 express/watch.sh diff --git a/express/.gitignore b/express/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/express/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/express/app.ts b/express/app.ts new file mode 100644 index 0000000..2e14203 --- /dev/null +++ b/express/app.ts @@ -0,0 +1,120 @@ +import express, { + Request as ExpressRequest, + Response as ExpressResponse, +} from "express"; +import { match } from "path-to-regexp"; +import { contentTypes } from "./content-types"; +import { httpCodes } from "./http-codes"; +import { routes } from "./routes"; +import { services } from "./services"; +// import { URLPattern } from 'node:url'; +import { + Call, + InternalHandler, + Method, + ProcessedRoute, + Result, + Route, + massageMethod, + methodParser, +} from "./types"; + +const app = express(); + +services.logging.log({ source: "logging", text: ["1"] }); +const processedRoutes: { [K in Method]: ProcessedRoute[] } = { + GET: [], + POST: [], + PUT: [], + PATCH: [], + DELETE: [], +}; + +function isPromise(value: T | Promise): value is Promise { + return typeof (value as any)?.then === "function"; +} + +routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => { + // const pattern /*: URLPattern */ = new URLPattern({ pathname: route.path }); + const matcher = match>(route.path); + const methodList = route.methods; + + const handler: InternalHandler = async ( + request: ExpressRequest, + ): Promise => { + const method = massageMethod(request.method); + + console.log("method", method); + + if (!methodList.includes(method)) { + // XXX: Worth asserting this? + } + + console.log("request.originalUrl", request.originalUrl); + console.log("beavis"); + + // const p = new URL(request.originalUrl); + // const path = p.pathname; + + // console.log("p, path", p, path) + + console.log("ok"); + + const req: Call = { + pattern: route.path, + // path, + path: request.originalUrl, + method, + parameters: { one: 1, two: 2 }, + request, + }; + + const retval = await route.handler(req); + return retval; + }; + + for (const [_idx, method] of methodList.entries()) { + const pr: ProcessedRoute = { matcher, method, handler }; + + processedRoutes[method].push(pr); + } +}); + +async function handler( + req: ExpressRequest, + _res: ExpressResponse, +): Promise { + const method = await methodParser.parseAsync(req.method); + + const byMethod = processedRoutes[method]; + for (const [_idx, pr] of byMethod.entries()) { + const match = pr.matcher(req.url); + if (match) { + console.log("match", match); + const resp = await pr.handler(req); + + return resp; + } + } + + const retval: Result = { + code: httpCodes.clientErrors.NotFound, + contentType: contentTypes.text.plain, + result: "not found", + }; + + return retval; +} + +app.use(async (req: ExpressRequest, res: ExpressResponse) => { + const result0 = await handler(req, res); + + const code = result0.code.code; + const result = result0.result; + + console.log(result); + + res.status(code).send(result); +}); + +app.listen(3000); diff --git a/express/check.sh b/express/check.sh new file mode 100755 index 0000000..ed260a0 --- /dev/null +++ b/express/check.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -eu + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +check_dir="$DIR" + +out_dir="$check_dir/out" + +source "$check_dir"/../framework/shims/common +source "$check_dir"/../framework/shims/node.common + +$ROOT/cmd pnpm tsc --outDir "$out_dir" diff --git a/express/config.ts b/express/config.ts new file mode 100644 index 0000000..0b3dfe0 --- /dev/null +++ b/express/config.ts @@ -0,0 +1,11 @@ +const config = { + database: { + user: "abc123", + password: "abc123", + host: "localhost", + port: "5432", + database: "abc123", + }, +}; + +export { config }; diff --git a/express/content-types.ts b/express/content-types.ts new file mode 100644 index 0000000..94cdbfc --- /dev/null +++ b/express/content-types.ts @@ -0,0 +1,40 @@ +import { Extensible } from "./interfaces"; + +export type ContentType = string; + +// FIXME: Fill this out (get an AI to do it) + +const contentTypes = { + text: { + plain: "text/plain", + html: "text/html", + css: "text/css", + javascript: "text/javascript", + xml: "text/xml", + }, + image: { + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + svgPlusXml: "image/svg+xml", + webp: "image/webp", + }, + audio: { + mpeg: "audio/mpeg", + wav: "audio/wav", + }, + video: { + mp4: "video/mp4", + webm: "video/webm", + xMsvideo: "video/x-msvideo", + }, + application: { + json: "application/json", + pdf: "application/pdf", + zip: "application/zip", + xWwwFormUrlencoded: "x-www-form-urlencoded", + octetStream: "octet-stream", + }, +}; + +export { contentTypes }; diff --git a/express/deps.ts b/express/deps.ts new file mode 100644 index 0000000..4cb62f0 --- /dev/null +++ b/express/deps.ts @@ -0,0 +1,7 @@ +// Database +//export { Client as PostgresClient } from "https://deno.land/x/postgres@v0.19.3/mod.ts"; +//export type { ClientOptions as PostgresOptions } from "https://deno.land/x/postgres@v0.19.3/mod.ts"; + +// Redis +//export { connect as redisConnect } from "https://deno.land/x/redis@v0.37.1/mod.ts"; +//export type { Redis } from "https://deno.land/x/redis@v0.37.1/mod.ts"; diff --git a/express/extensible.ts b/express/extensible.ts new file mode 100644 index 0000000..e69de29 diff --git a/express/handlers.ts b/express/handlers.ts new file mode 100644 index 0000000..4ad67e8 --- /dev/null +++ b/express/handlers.ts @@ -0,0 +1,19 @@ +import { contentTypes } from "./content-types"; +import { httpCodes } from "./http-codes"; +import { services } from "./services"; +import { Call, Handler, Result } from "./types"; + +const multiHandler: Handler = async (call: Call): Promise => { + const code = httpCodes.success.OK; + const rn = services.random.randomNumber(); + + const retval: Result = { + code, + result: `that was ${call.method} (${rn})`, + contentType: contentTypes.text.plain, + }; + + return retval; +}; + +export { multiHandler }; diff --git a/express/http-codes.ts b/express/http-codes.ts new file mode 100644 index 0000000..d575331 --- /dev/null +++ b/express/http-codes.ts @@ -0,0 +1,43 @@ +import { Extensible } from "./interfaces"; + +export type HttpCode = { + code: number; + name: string; + description?: string; +}; +type Group = "success" | "redirection" | "clientErrors" | "serverErrors"; +type CodeDefinitions = { + [K in Group]: { + [K: string]: HttpCode; + }; +}; +// FIXME: Figure out how to brand CodeDefinitions in a way that isn't +// tedious. + +const httpCodes: CodeDefinitions = { + success: { + OK: { code: 200, name: "OK", description: "" }, + Created: { code: 201, name: "Created" }, + Accepted: { code: 202, name: "Accepted" }, + NoContent: { code: 204, name: "No content" }, + }, + redirection: { + // later + }, + clientErrors: { + BadRequest: { code: 400, name: "Bad Request" }, + Unauthorized: { code: 401, name: "Unauthorized" }, + Forbidden: { code: 403, name: "Forbidden" }, + NotFound: { code: 404, name: "Not Found" }, + MethodNotAllowed: { code: 405, name: "Method Not Allowed" }, + NotAcceptable: { code: 406, name: "Not Acceptable" }, + // More later + }, + serverErrors: { + InternalServerError: { code: 500, name: "Internal Server Error" }, + NotImplemented: { code: 500, name: "Not implemented" }, + // more later + }, +}; + +export { httpCodes }; diff --git a/express/interfaces.ts b/express/interfaces.ts new file mode 100644 index 0000000..48dd302 --- /dev/null +++ b/express/interfaces.ts @@ -0,0 +1,3 @@ +type Brand = K & { readonly __brand: T }; + +export type Extensible = Brand<"Extensible", {}>; diff --git a/express/logging.ts b/express/logging.ts new file mode 100644 index 0000000..d95ecac --- /dev/null +++ b/express/logging.ts @@ -0,0 +1,44 @@ +// internal-logging.ts + +// FIXME: Move this to somewhere more appropriate +type AtLeastOne = [T, ...T[]]; + +type MessageSource = "logging" | "diagnostic" | "user"; + +type Message = { + // FIXME: number probably isn't what we want here + timestamp?: number; + source: MessageSource; + + text: AtLeastOne; +}; + +const m1: Message = { timestamp: 123, source: "logging", text: ["foo"] }; +const m2: Message = { + timestamp: 321, + source: "diagnostic", + text: ["ok", "whatever"], +}; + +type FilterArgument = { + limit?: number; + before?: number; + after?: number; + + // FIXME: add offsets to use instead of or in addition to before/after + + match?: (string | RegExp)[]; +}; + +const log = (_message: Message) => { + // WRITEME +}; + +const getLogs = (filter: FilterArgument) => { + // WRITEME +}; + +// FIXME: there's scope for more specialized functions although they +// probably should be defined in terms of the basic ones here. + +export { getLogs, log }; diff --git a/express/package.json b/express/package.json new file mode 100644 index 0000000..ad36b22 --- /dev/null +++ b/express/package.json @@ -0,0 +1,47 @@ +{ + "name": "express", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "prettier": "prettier", + "nodemon": "nodemon dist/index.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "packageManager": "pnpm@10.12.4", + "dependencies": { + "@ianvs/prettier-plugin-sort-imports": "^4.7.0", + "@types/node": "^24.10.1", + "@vercel/ncc": "^0.38.4", + "express": "^5.1.0", + "nodemon": "^3.1.11", + "path-to-regexp": "^8.3.0", + "prettier": "^3.6.2", + "ts-node": "^10.9.2", + "tsx": "^4.20.6", + "typescript": "^5.9.3", + "zod": "^4.1.12" + }, + "prettier": { + "arrowParens": "always", + "bracketSpacing": true, + "trailingComma": "all", + "tabWidth": 4, + "semi": true, + "singleQuote": false, + "importOrder": [ + "", + "^[./]" + ], + "importOrderCaseSensitive": true, + "plugins": [ + "@ianvs/prettier-plugin-sort-imports" + ] + }, + "devDependencies": { + "@types/express": "^5.0.5" + } +} diff --git a/express/pnpm-lock.yaml b/express/pnpm-lock.yaml new file mode 100644 index 0000000..489ca7c --- /dev/null +++ b/express/pnpm-lock.yaml @@ -0,0 +1,1510 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@ianvs/prettier-plugin-sort-imports': + specifier: ^4.7.0 + version: 4.7.0(prettier@3.6.2) + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 + '@vercel/ncc': + specifier: ^0.38.4 + version: 0.38.4 + express: + specifier: ^5.1.0 + version: 5.1.0 + nodemon: + specifier: ^3.1.11 + version: 3.1.11 + path-to-regexp: + specifier: ^8.3.0 + version: 8.3.0 + prettier: + specifier: ^3.6.2 + version: 3.6.2 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) + tsx: + specifier: ^4.20.6 + version: 4.20.6 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + zod: + specifier: ^4.1.12 + version: 4.1.12 + devDependencies: + '@types/express': + specifier: ^5.0.5 + version: 5.0.5 + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + 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'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + 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'} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@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': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/express-serve-static-core@5.1.0': + resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==} + + '@types/express@5.0.5': + resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + '@vercel/ncc@0.38.4': + resolution: {integrity: sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==} + hasBin: true + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@1.0.0: + resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.0: + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + nodemon@3.1.11: + resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.1: + resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} + engines: {node: '>= 0.10'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + engines: {node: '>= 18'} + + serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + 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/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 + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@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/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': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.10.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.10.1 + + '@types/express-serve-static-core@5.1.0': + dependencies: + '@types/node': 24.10.1 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.5': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.0 + '@types/serve-static': 1.15.10 + + '@types/http-errors@2.0.5': {} + + '@types/mime@1.3.5': {} + + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 24.10.1 + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.10.1 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.10.1 + '@types/send': 0.17.6 + + '@vercel/ncc@0.38.4': {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.1 + negotiator: 1.0.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@4.1.3: {} + + balanced-match@1.0.2: {} + + binary-extensions@2.3.0: {} + + body-parser@2.2.0: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.0 + iconv-lite: 0.6.3 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 3.0.1 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + concat-map@0.0.1: {} + + content-disposition@1.0.0: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + create-require@1.1.1: {} + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + depd@2.0.0: {} + + diff@4.0.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@5.1.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.0 + content-disposition: 1.0.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.0 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + gopd@1.2.0: {} + + has-flag@3.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + + ignore-by-default@1.0.1: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-promise@4.0.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + make-error@1.3.6: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + ms@2.1.3: {} + + negotiator@1.0.0: {} + + nodemon@3.1.11: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.7.3 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.3.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + prettier@3.6.2: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pstree.remy@1.1.8: {} + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.1: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.7.0 + unpipe: 1.0.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + resolve-pkg-maps@1.0.0: {} + + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + semver@7.7.3: {} + + send@1.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.0 + mime-types: 3.0.1 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.0: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.0 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.3 + + statuses@2.0.1: {} + + statuses@2.0.2: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + touch@3.1.1: {} + + ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 24.10.1 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tsx@4.20.6: + dependencies: + esbuild: 0.25.12 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.1 + + typescript@5.9.3: {} + + undefsafe@2.0.5: {} + + undici-types@7.16.0: {} + + unpipe@1.0.0: {} + + v8-compile-cache-lib@3.0.1: {} + + vary@1.1.2: {} + + wrappy@1.0.2: {} + + yn@3.1.1: {} + + zod@4.1.12: {} diff --git a/express/routes.ts b/express/routes.ts new file mode 100644 index 0000000..4a67aa5 --- /dev/null +++ b/express/routes.ts @@ -0,0 +1,77 @@ +/// + +import { contentTypes } from "./content-types"; +import { multiHandler } from "./handlers"; +import { HttpCode, httpCodes } from "./http-codes"; +import { services } from "./services"; +import { Call, ProcessedRoute, Result, Route } from "./types"; + +// FIXME: Obviously put this somewhere else +const okText = (result: string): Result => { + const code = httpCodes.success.OK; + + const retval: Result = { + code, + result, + contentType: contentTypes.text.plain, + }; + + return retval; +}; + +const routes: Route[] = [ + { + path: "/slow", + methods: ["GET"], + handler: async (_call: Call): Promise => { + console.log("starting slow request"); + + await services.misc.sleep(2); + + console.log("finishing slow request"); + const retval = okText("that was slow"); + + return retval; + }, + }, + { + path: "/list", + methods: ["GET"], + handler: async (call: Call): Promise => { + const code = httpCodes.success.OK; + const lr = (rr: Route[]) => { + const ret = rr.map((r: Route) => { + return r.path; + }); + + return ret; + }; + + const listing = lr(routes).join(", "); + return { + code, + result: listing + "\n", + contentType: contentTypes.text.plain, + }; + }, + }, + { + path: "/ok", + methods: ["GET", "POST", "PUT"], + handler: multiHandler, + }, + { + path: "/alsook", + methods: ["GET"], + handler: async (_req): Promise => { + const code = httpCodes.success.OK; + return { + code, + result: "it is also ok", + contentType: contentTypes.text.plain, + }; + }, + }, +]; + +export { routes }; diff --git a/express/run.sh b/express/run.sh new file mode 100755 index 0000000..725e61c --- /dev/null +++ b/express/run.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# XXX should we default to strict or non-strict here? + +set -eu + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +run_dir="$DIR" + +source "$run_dir"/../framework/shims/common +source "$run_dir"/../framework/shims/node.common + +strict_arg="${1:---no-strict}" + +if [[ "$strict_arg" = "--strict" ]] ; then + strict="yes" +else + strict="no" +fi + +cmd="tsx" +if [[ "strict" = "yes" ]] ; then + cmd="ts-node" +fi + +cd "$run_dir" + +"$run_dir"/check.sh +#echo checked +# $ROOT/cmd "$cmd" $run_dir/app.ts +../cmd node "$run_dir"/out/app.js diff --git a/express/services.ts b/express/services.ts new file mode 100644 index 0000000..cc04dcd --- /dev/null +++ b/express/services.ts @@ -0,0 +1,36 @@ +// services.ts + +import { config } from "./config"; +import { getLogs, log } from "./logging"; + +//const database = Client({ + +//}) + +const database = {}; + +const logging = { + log, + getLogs, +}; + +const random = { + randomNumber: () => { + return Math.random(); + }, +}; + +const misc = { + sleep: (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)); + }, +}; + +const services = { + database, + logging, + misc, + random, +}; + +export { services }; diff --git a/express/show-config.sh b/express/show-config.sh new file mode 100755 index 0000000..8e80baa --- /dev/null +++ b/express/show-config.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +check_dir="$DIR" + +source "$check_dir"/../framework/shims/common +source "$check_dir"/../framework/shims/node.common + +$ROOT/cmd pnpm tsc --showConfig diff --git a/express/tsconfig.json b/express/tsconfig.json new file mode 100644 index 0000000..ccba534 --- /dev/null +++ b/express/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "target": "ES2022", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noImplicitAny": true, + "strict": true, + "types": ["node"], + "outDir": "out", + } +} diff --git a/express/types.ts b/express/types.ts new file mode 100644 index 0000000..05ce6d9 --- /dev/null +++ b/express/types.ts @@ -0,0 +1,59 @@ +// types.ts + +// FIXME: split this up into types used by app developers and types internal +// to the framework. +import { + Request as ExpressRequest, + Response as ExpressResponse, +} from "express"; +import { MatchFunction } from "path-to-regexp"; +import { z } from "zod"; +import { ContentType, contentTypes } from "./content-types"; +import { HttpCode, httpCodes } from "./http-codes"; + +const methodParser = z.union([ + z.literal("GET"), + z.literal("POST"), + z.literal("PUT"), + z.literal("PATCH"), + z.literal("DELETE"), +]); + +export type Method = z.infer; +const massageMethod = (input: string): Method => { + const r = methodParser.parse(input.toUpperCase()); + + return r; +}; + +export type Call = { + pattern: string; + path: string; + method: Method; + parameters: object; + request: ExpressRequest; +}; + +export type InternalHandler = (req: ExpressRequest) => Promise; + +export type Handler = (call: Call) => Promise; +export type ProcessedRoute = { + matcher: MatchFunction>; + method: Method; + handler: InternalHandler; +}; + +export type Result = { + code: HttpCode; + contentType: ContentType; + result: string; +}; + +export type Route = { + path: string; + methods: Method[]; + handler: Handler; + interruptable?: boolean; +}; + +export { methodParser, massageMethod }; diff --git a/express/watch.sh b/express/watch.sh new file mode 100755 index 0000000..1421258 --- /dev/null +++ b/express/watch.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +check_dir="$DIR" + +source "$check_dir"/../framework/shims/common +source "$check_dir"/../framework/shims/node.common + +# $ROOT/cmd pnpm tsc --lib ES2023 --esModuleInterop -w $check_dir/app.ts +# $ROOT/cmd pnpm tsc -w $check_dir/app.ts +$ROOT/cmd pnpm tsc -w --project ./tsconfig.json From 2641a8d29d1ad54c2ee6bb590a6a14bf5db8b9aa Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Mon, 17 Nov 2025 11:38:04 -0600 Subject: [PATCH 036/137] Update shell code Now it (mostly) passes shellcheck and is formatted with shfmt. --- cmd | 4 ++-- express/watch.sh | 2 +- framework/cmd.d/list | 4 ++-- framework/cmd.d/node | 2 +- framework/cmd.d/pnpm | 2 +- framework/cmd.d/sync | 9 +++------ framework/shims/common | 7 ++++--- framework/shims/node | 11 ++++++----- framework/shims/node.common | 6 ++++-- framework/shims/npm | 14 ++++++-------- framework/shims/pnpm | 14 +++++++++----- 11 files changed, 39 insertions(+), 36 deletions(-) diff --git a/cmd b/cmd index 7cf2667..0b6d49e 100755 --- a/cmd +++ b/cmd @@ -6,7 +6,7 @@ set -eu -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" subcmd="$1" @@ -16,6 +16,6 @@ subcmd="$1" shift -echo will run "$DIR"/framework/cmd.d/"$subcmd" "$@" +echo will run "$DIR"/framework/cmd.d/"$subcmd" "$@" exec "$DIR"/framework/cmd.d/"$subcmd" "$@" diff --git a/express/watch.sh b/express/watch.sh index 1421258..1883f04 100755 --- a/express/watch.sh +++ b/express/watch.sh @@ -11,4 +11,4 @@ source "$check_dir"/../framework/shims/node.common # $ROOT/cmd pnpm tsc --lib ES2023 --esModuleInterop -w $check_dir/app.ts # $ROOT/cmd pnpm tsc -w $check_dir/app.ts -$ROOT/cmd pnpm tsc -w --project ./tsconfig.json +$ROOT/cmd pnpm tsc --watch --project ./tsconfig.json diff --git a/framework/cmd.d/list b/framework/cmd.d/list index 74c5a42..c9fc066 100755 --- a/framework/cmd.d/list +++ b/framework/cmd.d/list @@ -2,8 +2,8 @@ set -eu -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$DIR" -ls . \ No newline at end of file +ls . diff --git a/framework/cmd.d/node b/framework/cmd.d/node index dea36cd..05f3b98 100755 --- a/framework/cmd.d/node +++ b/framework/cmd.d/node @@ -2,6 +2,6 @@ set -eu -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" "$DIR"/../shims/node "$@" diff --git a/framework/cmd.d/pnpm b/framework/cmd.d/pnpm index f31a0b7..676d8b0 100755 --- a/framework/cmd.d/pnpm +++ b/framework/cmd.d/pnpm @@ -2,6 +2,6 @@ set -eu -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" "$DIR"/../shims/pnpm "$@" diff --git a/framework/cmd.d/sync b/framework/cmd.d/sync index 6684a6a..07b25ba 100755 --- a/framework/cmd.d/sync +++ b/framework/cmd.d/sync @@ -2,7 +2,7 @@ set -eu -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # figure out the platform we're on @@ -11,11 +11,8 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # download $nodejs_version # verify its checksum against $nodejs_checksum +cd "$DIR/../node" - - -cd $DIR/../node - -$DIR/pnpm install +"$DIR"/pnpm install echo we will download other files here later diff --git a/framework/shims/common b/framework/shims/common index a1391b9..0da9917 100644 --- a/framework/shims/common +++ b/framework/shims/common @@ -1,4 +1,5 @@ -common_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -ROOT="$common_DIR/../../" - +# Fix for https://www.shellcheck.net/wiki/SC2148 +# shellcheck shell=bash +common_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export ROOT="$common_DIR/../../" diff --git a/framework/shims/node b/framework/shims/node index 671020a..c1dce5c 100755 --- a/framework/shims/node +++ b/framework/shims/node @@ -4,12 +4,13 @@ set -eu -export DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +node_shim_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export node_shim_DIR -source "$DIR/../versions" +source "$node_shim_DIR"/../versions -node_bin="$DIR/../../$nodejs_bin_dir/node" +source "$node_shim_DIR"/node.common + +node_bin="$node_shim_DIR/../../$nodejs_binary_dir/node" exec "$node_bin" "$@" - - diff --git a/framework/shims/node.common b/framework/shims/node.common index 22823c7..c3253e1 100644 --- a/framework/shims/node.common +++ b/framework/shims/node.common @@ -1,4 +1,7 @@ -node_common_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +# Fix for https://www.shellcheck.net/wiki/SC2148 +# shellcheck shell=bash + +node_common_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # FIXME this shouldn't be hardcoded here of course nodejs_binary_dir="$node_common_DIR/../binaries/node-v22.15.1-linux-x64/bin" @@ -6,7 +9,6 @@ nodejs_binary_dir="$node_common_DIR/../binaries/node-v22.15.1-linux-x64/bin" # This might be too restrictive. Or not restrictive enough. PATH="$nodejs_binary_dir":/bin:/usr/bin - project_root="$node_common_DIR/../.." node_dir="$project_root/$nodejs_binary_dir" diff --git a/framework/shims/npm b/framework/shims/npm index d6b7e7a..ddd3748 100755 --- a/framework/shims/npm +++ b/framework/shims/npm @@ -2,14 +2,12 @@ set -eu -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +npm_shim_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export npm_shim_DIR -source "$DIR"/node.common +# shellcheck source=node.common +source "$npm_shim_DIR"/node.common -cd $DIR/../.nodejs-config -echo in dir $(pwd) +cd "$npm_shim_DIR"/../.nodejs-config +echo in dir "$(pwd)" npm "$@" - - - - diff --git a/framework/shims/pnpm b/framework/shims/pnpm index 33c4c8a..f6dab88 100755 --- a/framework/shims/pnpm +++ b/framework/shims/pnpm @@ -2,11 +2,15 @@ set -eu -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +pnpm_shim_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export pnpm_shim_DIR -source "$DIR"/node.common -source "$DIR"/common +# shellcheck source=./node.common +source "$pnpm_shim_DIR"/node.common -cd $ROOT/framework/node +# shellcheck source=./common +source "$pnpm_shim_DIR"/common -exec "$DIR"/../binaries/pnpm "$@" +# cd $ROOT/framework/node + +exec "$pnpm_shim_DIR"/../binaries/pnpm "$@" From d125f61c4c74cce83678c8e0858c6981c4a790fc Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Mon, 17 Nov 2025 11:38:36 -0600 Subject: [PATCH 037/137] Stake out dir --- express/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/express/.gitignore b/express/.gitignore index 89f9ac0..3bdc5ea 100644 --- a/express/.gitignore +++ b/express/.gitignore @@ -1 +1,2 @@ out/ +dist/ From c21638c5d5be506fd16e1253df79095e2f760b01 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Mon, 17 Nov 2025 18:05:56 -0600 Subject: [PATCH 038/137] Fill out content types object --- express/content-types.ts | 90 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/express/content-types.ts b/express/content-types.ts index 94cdbfc..eca7c72 100644 --- a/express/content-types.ts +++ b/express/content-types.ts @@ -2,8 +2,7 @@ import { Extensible } from "./interfaces"; export type ContentType = string; -// FIXME: Fill this out (get an AI to do it) - +// tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865 const contentTypes = { text: { plain: "text/plain", @@ -11,6 +10,9 @@ const contentTypes = { css: "text/css", javascript: "text/javascript", xml: "text/xml", + csv: "text/csv", + markdown: "text/markdown", + calendar: "text/calendar", }, image: { jpeg: "image/jpeg", @@ -18,23 +20,103 @@ const contentTypes = { gif: "image/gif", svgPlusXml: "image/svg+xml", webp: "image/webp", + bmp: "image/bmp", + ico: "image/x-icon", + tiff: "image/tiff", + avif: "image/avif", }, audio: { mpeg: "audio/mpeg", wav: "audio/wav", + ogg: "audio/ogg", + webm: "audio/webm", + aac: "audio/aac", + midi: "audio/midi", + opus: "audio/opus", + flac: "audio/flac", }, video: { mp4: "video/mp4", webm: "video/webm", xMsvideo: "video/x-msvideo", + mpeg: "video/mpeg", + ogg: "video/ogg", + quicktime: "video/quicktime", + xMatroska: "video/x-matroska", }, application: { json: "application/json", pdf: "application/pdf", zip: "application/zip", - xWwwFormUrlencoded: "x-www-form-urlencoded", - octetStream: "octet-stream", + xWwwFormUrlencoded: "application/x-www-form-urlencoded", + octetStream: "application/octet-stream", + xml: "application/xml", + gzip: "application/gzip", + javascript: "application/javascript", + ld_json: "application/ld+json", + msword: "application/msword", + vndOpenxmlformatsOfficedocumentWordprocessingmlDocument: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + vndMsExcel: "application/vnd.ms-excel", + vndOpenxmlformatsOfficedocumentSpreadsheetmlSheet: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + vndMsPowerpoint: "application/vnd.ms-powerpoint", + vndOpenxmlformatsOfficedocumentPresentationmlPresentation: + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + sql: "application/sql", + graphql: "application/graphql", + wasm: "application/wasm", + xTar: "application/x-tar", + x7zCompressed: "application/x-7z-compressed", + xRarCompressed: "application/x-rar-compressed", + }, + multipart: { + formData: "multipart/form-data", + byteranges: "multipart/byteranges", + }, + font: { + woff: "font/woff", + woff2: "font/woff2", + ttf: "font/ttf", + otf: "font/otf", }, }; export { contentTypes }; + +/* + +possible additions for later + +Looking at what's there, here are a few gaps that might be worth filling: +Streaming/Modern Web: + +application/x-ndjson or application/jsonlines - newline-delimited JSON (popular for streaming APIs) +text/event-stream - Server-Sent Events + +API/Data Exchange: + +application/yaml or text/yaml - YAML files +application/protobuf - Protocol Buffers +application/msgpack - MessagePack + +Archives you're missing: + +application/x-bzip2 - bzip2 compression + +Images: + +image/heic - HEIC/HEIF (common on iOS) + +Fonts: + +application/vnd.ms-fontobject - EOT fonts (legacy but still seen) + +Text: + +text/rtf - Rich Text Format + +The most impactful would probably be text/event-stream (if you do any SSE), application/x-ndjson (common in modern APIs), and maybe text/yaml. The rest are more situational. +But honestly, what you have covers 95% of common web development scenarios. You can definitely add as you go when you encounter specific needs! + +*/ From bd3779acef35c4746a10076ffe6fb782b1222897 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Mon, 17 Nov 2025 18:06:59 -0600 Subject: [PATCH 039/137] Fill out http codes object --- express/http-codes.ts | 49 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/express/http-codes.ts b/express/http-codes.ts index d575331..2c12891 100644 --- a/express/http-codes.ts +++ b/express/http-codes.ts @@ -11,33 +11,66 @@ type CodeDefinitions = { [K: string]: HttpCode; }; }; -// FIXME: Figure out how to brand CodeDefinitions in a way that isn't -// tedious. +// tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865 const httpCodes: CodeDefinitions = { success: { OK: { code: 200, name: "OK", description: "" }, Created: { code: 201, name: "Created" }, Accepted: { code: 202, name: "Accepted" }, - NoContent: { code: 204, name: "No content" }, + NonAuthoritativeInformation: { + code: 203, + name: "Non-Authoritative Information", + }, + NoContent: { code: 204, name: "No Content" }, + ResetContent: { code: 205, name: "Reset Content" }, + PartialContent: { code: 206, name: "Partial Content" }, }, redirection: { - // later + MultipleChoices: { code: 300, name: "Multiple Choices" }, + MovedPermanently: { code: 301, name: "Moved Permanently" }, + Found: { code: 302, name: "Found" }, + SeeOther: { code: 303, name: "See Other" }, + NotModified: { code: 304, name: "Not Modified" }, + TemporaryRedirect: { code: 307, name: "Temporary Redirect" }, + PermanentRedirect: { code: 308, name: "Permanent Redirect" }, }, clientErrors: { BadRequest: { code: 400, name: "Bad Request" }, Unauthorized: { code: 401, name: "Unauthorized" }, + PaymentRequired: { code: 402, name: "Payment Required" }, Forbidden: { code: 403, name: "Forbidden" }, NotFound: { code: 404, name: "Not Found" }, MethodNotAllowed: { code: 405, name: "Method Not Allowed" }, NotAcceptable: { code: 406, name: "Not Acceptable" }, - // More later + ProxyAuthenticationRequired: { + code: 407, + name: "Proxy Authentication Required", + }, + RequestTimeout: { code: 408, name: "Request Timeout" }, + Conflict: { code: 409, name: "Conflict" }, + Gone: { code: 410, name: "Gone" }, + LengthRequired: { code: 411, name: "Length Required" }, + PreconditionFailed: { code: 412, name: "Precondition Failed" }, + PayloadTooLarge: { code: 413, name: "Payload Too Large" }, + URITooLong: { code: 414, name: "URI Too Long" }, + UnsupportedMediaType: { code: 415, name: "Unsupported Media Type" }, + RangeNotSatisfiable: { code: 416, name: "Range Not Satisfiable" }, + ExpectationFailed: { code: 417, name: "Expectation Failed" }, + ImATeapot: { code: 418, name: "I'm a teapot" }, + UnprocessableEntity: { code: 422, name: "Unprocessable Entity" }, + TooManyRequests: { code: 429, name: "Too Many Requests" }, }, serverErrors: { InternalServerError: { code: 500, name: "Internal Server Error" }, - NotImplemented: { code: 500, name: "Not implemented" }, - // more later + NotImplemented: { code: 501, name: "Not Implemented" }, + BadGateway: { code: 502, name: "Bad Gateway" }, + ServiceUnavailable: { code: 503, name: "Service Unavailable" }, + GatewayTimeout: { code: 504, name: "Gateway Timeout" }, + HTTPVersionNotSupported: { + code: 505, + name: "HTTP Version Not Supported", + }, }, }; - export { httpCodes }; From 666f1447f40a5b2901655f9d3ddd4e78eb4fe841 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Mon, 17 Nov 2025 19:53:57 -0600 Subject: [PATCH 040/137] Add first cut at docs to create a new project --- docs/new-project.md | 84 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/new-project.md diff --git a/docs/new-project.md b/docs/new-project.md new file mode 100644 index 0000000..ff10bf4 --- /dev/null +++ b/docs/new-project.md @@ -0,0 +1,84 @@ +If any of the steps here don't work or are unclear in any way, it is +probably a bug and we want to fix it! + +## how to create a new diachron project + +1. Create an empty directory for your project. This directory can be inside of a + git repository but it doesn't have to be. + +2. Download the sync program and put it in the empty directory created in the + previous step. There is a sync program for every version of diachron. + You'll usually want to use the most recent stable version. [FIXME: explain + why you'd want to use something else.] And you'll want the version for + the operating system and hardware you're using. + +3. Run the `setup` program. This program is [FIXME: will be] written in + [go](https://go.dev), so as long as you have downloaded the right file, it + ought to work. + + This will create several files and directories. It will also download a number + of binaries and put them in different places in some of the directories + that are created. + +4. At this point, you should have a usable, if not very useful, diachron + application. To see what it does, run the program `develop run`; it will run a + simple web application on localhost:3000. To make changes, have a look at + the files `src/app.ts` and `src/routes.ts`. + +## where do we go from here? + +Now that we have a very simple project, we need to attend to a few other +important matters. + +### version control + +(These instructions assume you're using git. If you're using a different +version control system then you will need to make allowances. In particular, +you should convert the `.gitignore` file to whatever your version control +system uses.) + +You should add the whole directory to git and commit it. There will be two +`.gitignore` files, one in the root, and one in the `framework/` directory. + +The root `.gitignore` created for you will be a good starting point, but you +can make changes to it as you see fit. However, you should not ever modify +`framework/.gitignore`. More on this in the next section. + +### working with diachron + +There are four commands to know about: + +- `sync` is used to install all dependencies, including the ones you specify + as well as the ones that diachron provides +- `develop` is used to run "development-related" tasks. Run `develop help` to + get an overview of what it can do. +- `operate` is used to run "operations-related" tasks. Run `operate help` to + get an overview of what it can do. +- `cmd` runs diachron-managed commands, such as `pnpm`. When working on a + diachron project, you should always use these diachron-managed commands + instead of whatever else you may have available. + +### what files belong to your project, what files belong to the framework + +In a new diachron project, there are some files and directories that are +"owned" by the framework and others that are "owned" by the programmer. + +In particular, you own everything in the directory `src/`. You own +`README.md` and `package.json` and `pnpm-lock.yaml`. You own any other files +our directories you create. + +Everything else _belongs to the framework_ and you are not expected to change +it except when upgrading. + +This is just an overview. It is exhaustively documented in +[ownership.md](ownership.md). + +### updates + + +### when the docs sound a bit authoritarian... + +Finally, remember that diachron's license allows you to do whatever you like +with it, with very few limitations. This includes making changes to files +about which, in the documentation, we say "you must not change" or "you are +not expected to change." From a797cae0e61f1b3ceedd31a18d37c57bc1ddb0fa Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Mon, 17 Nov 2025 19:54:10 -0600 Subject: [PATCH 041/137] Add placeholder files for docs to come --- docs/deployment.md | 1 + docs/ownership.md | 1 + docs/upgrades.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 docs/deployment.md create mode 100644 docs/ownership.md create mode 100644 docs/upgrades.md diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..9c558e3 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1 @@ +. diff --git a/docs/ownership.md b/docs/ownership.md new file mode 100644 index 0000000..9c558e3 --- /dev/null +++ b/docs/ownership.md @@ -0,0 +1 @@ +. diff --git a/docs/upgrades.md b/docs/upgrades.md new file mode 100644 index 0000000..9c558e3 --- /dev/null +++ b/docs/upgrades.md @@ -0,0 +1 @@ +. From 8ca89b75cde3fc3b75ad3ea479ffac2502e76f62 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Mon, 17 Nov 2025 19:54:35 -0600 Subject: [PATCH 042/137] Rewrite README.md --- README.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f392fc6..d1ae7fe 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,63 @@ diachron +## Introduction + +Is your answer to some of these questions "yes"? If so, you might like +diachron. (When it comes to that dev/test/prod one, hear us out first, ok?) + +- Do you want to share a lot of backend and frontend code? + +- Are you tired of your web stack breaking when you blink too hard? + +- Have you read [Taking PHP + Seriously](https://slack.engineering/taking-php-seriously/) and wish you had + something similar for Typescript? + +- Do you think that ORMs are not all that, and you had first class unmediated + access to your database? And do you think that database agnosticism is + overrated? + +-- Do you think dev/testing/prod distinctions are a bad idea? (Hear us out on + this one.) + +- Have you ever lost hours getting everyone on your team to have the exact + same environment, but you're not willing to take the plunge and use a tool + like [nix](https://nixos.org)? + +- Are you frustrated by unclear documentation? Is ramping up a frequent + problem? + +- Do you want a framework that's not only easy to write but also easy to get + inside and debug? + +- Have you been bogged down with details that are not relevant to the problems + you're trying to solve, the features you're trying to implement, the bugs + you're trying to fix? We're talking authentication, authorization, XSS, + https, nested paths, all that stuff. + +## Getting started + +Different situations require different getting started docs. + +- [How to create a new project](docs/new-project.md) +- [How to work on an existing project](docs/existing-project.md) + ## Requirements -To run diachron, you currently need the following requirements: +To run diachron, you currently need to have a Linux box running x86_64 with a +new enough libc to run golang binaries. Support for other platforms will come +eventually. + +To run a more complete system, you also need to have docker compose installed. + +### Development requirements + +To hack on diachron itself, you need the following: - docker compose -- deno - -## Development requirements - -To hack on diachron, you need the following: - -- docker compose -- deno +- [fd](https://github.com/sharkdp/fd) - golang, version 1.23.6 or greater +- shellcheck +- shfmt From b0eaf6b136b1ec03a4b3ea7cedd78a41015c90de Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Mon, 17 Nov 2025 19:54:53 -0600 Subject: [PATCH 043/137] Add stub nomenclature doc --- docs/nomenclature.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 docs/nomenclature.md diff --git a/docs/nomenclature.md b/docs/nomenclature.md new file mode 100644 index 0000000..381e5c9 --- /dev/null +++ b/docs/nomenclature.md @@ -0,0 +1,4 @@ +We use `Call` and `Result` for our own types that wrap `Request` and +`Response`. + +This hopefully will make things less confusing and avoid problems with shadowing. From 4257a9b6151936b9f3b65bab6842058b84e4be5f Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 5 Dec 2025 19:47:58 -0600 Subject: [PATCH 044/137] Improve wording in a few places --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d1ae7fe..c0a24f7 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,15 @@ diachron. (When it comes to that dev/test/prod one, hear us out first, ok?) Seriously](https://slack.engineering/taking-php-seriously/) and wish you had something similar for Typescript? -- Do you think that ORMs are not all that, and you had first class unmediated - access to your database? And do you think that database agnosticism is - overrated? +- Do you think that ORMs are not all that? Do you wish you had first class + unmediated access to your database? And do you think that database + agnosticism is overrated? --- Do you think dev/testing/prod distinctions are a bad idea? (Hear us out on +- Do you think dev/testing/prod distinctions are a bad idea? (Hear us out on this one.) - Have you ever lost hours getting everyone on your team to have the exact - same environment, but you're not willing to take the plunge and use a tool + same environment, yet you're not willing to take the plunge and use a tool like [nix](https://nixos.org)? - Are you frustrated by unclear documentation? Is ramping up a frequent From 3bece4663890a1f46b53c8b46de3e81e9b69b0c6 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 12:26:54 -0600 Subject: [PATCH 045/137] Add first cut at golang monitor program --- monitor/.gitignore | 1 + monitor/devrunner.go | 84 +++++++++++++++++++++++++++++++++++++++++ monitor/filechange.go | 6 +++ monitor/go.mod | 8 ++++ monitor/go.sum | 4 ++ monitor/main.go | 50 ++++++++++++++++++++++++ monitor/printchanges.go | 11 ++++++ monitor/watchfiles.go | 74 ++++++++++++++++++++++++++++++++++++ 8 files changed, 238 insertions(+) create mode 100644 monitor/.gitignore create mode 100644 monitor/devrunner.go create mode 100644 monitor/filechange.go create mode 100644 monitor/go.mod create mode 100644 monitor/go.sum create mode 100644 monitor/main.go create mode 100644 monitor/printchanges.go create mode 100644 monitor/watchfiles.go diff --git a/monitor/.gitignore b/monitor/.gitignore new file mode 100644 index 0000000..edffd38 --- /dev/null +++ b/monitor/.gitignore @@ -0,0 +1 @@ +monitor \ No newline at end of file diff --git a/monitor/devrunner.go b/monitor/devrunner.go new file mode 100644 index 0000000..81ccd36 --- /dev/null +++ b/monitor/devrunner.go @@ -0,0 +1,84 @@ +// a vibe coded el cheapo: https://claude.ai/chat/328ca558-1019-49b9-9f08-e85cfcea2ceb + +package main + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "sync" + "time" +) + +func runProcess(ctx context.Context, wg *sync.WaitGroup, name, command string) { + defer wg.Done() + + for { + select { + case <-ctx.Done(): + fmt.Printf("[%s] Stopping\n", name) + return + default: + fmt.Printf("[%s] Starting: %s\n", name, command) + + // Create command with context for cancellation + cmd := exec.CommandContext(ctx, "sh", "-c", command) + + // Setup stdout pipe + stdout, err := cmd.StdoutPipe() + if err != nil { + fmt.Fprintf(os.Stderr, "[%s] Error creating stdout pipe: %v\n", name, err) + return + } + + // Setup stderr pipe + stderr, err := cmd.StderrPipe() + if err != nil { + fmt.Fprintf(os.Stderr, "[%s] Error creating stderr pipe: %v\n", name, err) + return + } + + // Start the command + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "[%s] Error starting command: %v\n", name, err) + time.Sleep(time.Second) + continue + } + + // Copy output in separate goroutines + var ioWg sync.WaitGroup + ioWg.Add(2) + + go func() { + defer ioWg.Done() + io.Copy(os.Stdout, stdout) + }() + + go func() { + defer ioWg.Done() + io.Copy(os.Stderr, stderr) + }() + + // Wait for command to finish + err = cmd.Wait() + ioWg.Wait() // Ensure all output is copied + + // Check if we should restart + select { + case <-ctx.Done(): + fmt.Printf("[%s] Stopped\n", name) + return + default: + if err != nil { + fmt.Fprintf(os.Stderr, "[%s] Process exited with error: %v\n", name, err) + } else { + fmt.Printf("[%s] Process exited normally\n", name) + } + fmt.Printf("[%s] Restarting in 1 second...\n", name) + time.Sleep(time.Second) + } + } + } +} diff --git a/monitor/filechange.go b/monitor/filechange.go new file mode 100644 index 0000000..71f788f --- /dev/null +++ b/monitor/filechange.go @@ -0,0 +1,6 @@ +package main + +type FileChange struct { + Path string + Operation string +} diff --git a/monitor/go.mod b/monitor/go.mod new file mode 100644 index 0000000..10944cd --- /dev/null +++ b/monitor/go.mod @@ -0,0 +1,8 @@ +module philologue.net/diachron/monitor + +go 1.23.3 + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + golang.org/x/sys v0.13.0 // indirect +) diff --git a/monitor/go.sum b/monitor/go.sum new file mode 100644 index 0000000..c1e3272 --- /dev/null +++ b/monitor/go.sum @@ -0,0 +1,4 @@ +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/monitor/main.go b/monitor/main.go new file mode 100644 index 0000000..5c09175 --- /dev/null +++ b/monitor/main.go @@ -0,0 +1,50 @@ +package main + +import ( + // "context" + "fmt" + "os" + "os/signal" + // "sync" + "syscall" +) + +func main() { + // var program1 = os.Getenv("BUILD_COMMAND") + //var program2 = os.Getenv("RUN_COMMAND") + + var watchedDir = os.Getenv("WATCHED_DIR") + + // Create context for graceful shutdown + // ctx, cancel := context.WithCancel(context.Background()) + //defer cancel() + + // Setup signal handling + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + fileChanges := make(chan FileChange, 10) + + go watchFiles(watchedDir, fileChanges) + + go printChanges(fileChanges) + + // WaitGroup to track both processes + // var wg sync.WaitGroup + + // Start both processes + //wg.Add(2) + // go runProcess(ctx, &wg, "builder", program1) + // go runProcess(ctx, &wg, "runner", program2) + + // Wait for interrupt signal + <-sigCh + fmt.Println("\nReceived interrupt signal, shutting down...") + + // Cancel context to signal goroutines to stop + /// cancel() + + // Wait for both processes to finish + // wg.Wait() + fmt.Println("All processes terminated cleanly") +} diff --git a/monitor/printchanges.go b/monitor/printchanges.go new file mode 100644 index 0000000..dcb0ad9 --- /dev/null +++ b/monitor/printchanges.go @@ -0,0 +1,11 @@ +package main + +import ( + "fmt" +) + +func printChanges(changes <-chan FileChange) { + for change := range changes { + fmt.Printf("[%s] %s\n", change.Operation, change.Path) + } +} diff --git a/monitor/watchfiles.go b/monitor/watchfiles.go new file mode 100644 index 0000000..b339def --- /dev/null +++ b/monitor/watchfiles.go @@ -0,0 +1,74 @@ +package main + +import ( + "github.com/fsnotify/fsnotify" + "log" + "os" + "path/filepath" +) + +func watchFiles(dir string, changes chan<- FileChange) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Fatal(err) + } + defer watcher.Close() + + // Add all directories recursively + err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + err = watcher.Add(path) + if err != nil { + log.Printf("Error watching %s: %v\n", path, err) + } + } + return nil + }) + if err != nil { + log.Fatal(err) + } + + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + + // Handle different types of events + var operation string + switch { + case event.Op&fsnotify.Write == fsnotify.Write: + operation = "MODIFIED" + case event.Op&fsnotify.Create == fsnotify.Create: + operation = "CREATED" + // If a new directory is created, start watching it + if info, err := os.Stat(event.Name); err == nil && info.IsDir() { + watcher.Add(event.Name) + } + case event.Op&fsnotify.Remove == fsnotify.Remove: + operation = "REMOVED" + case event.Op&fsnotify.Rename == fsnotify.Rename: + operation = "RENAMED" + case event.Op&fsnotify.Chmod == fsnotify.Chmod: + operation = "CHMOD" + default: + operation = "UNKNOWN" + } + + changes <- FileChange{ + Path: event.Name, + Operation: operation, + } + + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Printf("Watcher error: %v\n", err) + } + } +} From a1785364729153c2d0ef8c64311445d618849139 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 12:30:58 -0600 Subject: [PATCH 046/137] Rename monitor to master --- master/.gitignore | 1 + {monitor => master}/devrunner.go | 0 {monitor => master}/filechange.go | 0 {monitor => master}/go.mod | 2 +- {monitor => master}/go.sum | 0 {monitor => master}/main.go | 0 {monitor => master}/printchanges.go | 0 {monitor => master}/watchfiles.go | 0 monitor/.gitignore | 1 - 9 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 master/.gitignore rename {monitor => master}/devrunner.go (100%) rename {monitor => master}/filechange.go (100%) rename {monitor => master}/go.mod (74%) rename {monitor => master}/go.sum (100%) rename {monitor => master}/main.go (100%) rename {monitor => master}/printchanges.go (100%) rename {monitor => master}/watchfiles.go (100%) delete mode 100644 monitor/.gitignore diff --git a/master/.gitignore b/master/.gitignore new file mode 100644 index 0000000..1f7391f --- /dev/null +++ b/master/.gitignore @@ -0,0 +1 @@ +master diff --git a/monitor/devrunner.go b/master/devrunner.go similarity index 100% rename from monitor/devrunner.go rename to master/devrunner.go diff --git a/monitor/filechange.go b/master/filechange.go similarity index 100% rename from monitor/filechange.go rename to master/filechange.go diff --git a/monitor/go.mod b/master/go.mod similarity index 74% rename from monitor/go.mod rename to master/go.mod index 10944cd..bcee3cf 100644 --- a/monitor/go.mod +++ b/master/go.mod @@ -1,4 +1,4 @@ -module philologue.net/diachron/monitor +module philologue.net/diachron/master go 1.23.3 diff --git a/monitor/go.sum b/master/go.sum similarity index 100% rename from monitor/go.sum rename to master/go.sum diff --git a/monitor/main.go b/master/main.go similarity index 100% rename from monitor/main.go rename to master/main.go diff --git a/monitor/printchanges.go b/master/printchanges.go similarity index 100% rename from monitor/printchanges.go rename to master/printchanges.go diff --git a/monitor/watchfiles.go b/master/watchfiles.go similarity index 100% rename from monitor/watchfiles.go rename to master/watchfiles.go diff --git a/monitor/.gitignore b/monitor/.gitignore deleted file mode 100644 index edffd38..0000000 --- a/monitor/.gitignore +++ /dev/null @@ -1 +0,0 @@ -monitor \ No newline at end of file From 8e5b46d4267516d64e04cf3e02b7f0cf4398a1e1 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 12:31:35 -0600 Subject: [PATCH 047/137] Add first cut at a CLAUDE.md file --- CLAUDE.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b2d4407 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# diachron overview for Claude + +## Overview + +diachron is an attempt at a web framework that attempts to solve various pain +points I've dealt with when using other frameworks. + +## Goals + +- Programs are increasingly complicated; therefore: + - We want to have everything loggable available for inspection + - At any given point in execution execution, we want to know "what the + framework thinks" + - Easy debuggability is very important +- We don't want extreme flexibility; for example, by design we only support + Postgres +- We don't aim to be fast (it's nice when we can get good performance, but it + should be sacrificed to other goals most of the time) +- Application Diachron code uses Typescript; however, it draws a lot of ideas + from the essay titled "Taking PHP Seriously" +- We want as little magic as possible +- When it comes to behavior or architecture, there must be no + development/production distinction. Just one mode of operation. + + +## architecture + +Note: for now, this document only describes the part of the framework that is +involved with processing requests and creating responses. + +### master process + +The master process is written in golang. It has the following +responsabilities: + +- Listen on one or more ports and proxy web requests to the correct backend + worker +- Watch the typescript source directory for changes; when it changes, attempt + to rebuild the typescript source + - Keep numerous copies of the transpiled system available + - If transpiliation fails, execution should not stop +- Keep track of child processes, ensuring that a sufficient number of them are + available at a given time. + +Note that the master process behaves exactly the same in production +environments as it does on a developer's desktop system. There is no +development/production distinction. + + + +## Project structure + +# meta + +## plans + +- This document includes many statements that should be taken as statements of + intent and not necessarily of current fact. In other words, when diachron + reaches a certain stage of maturity, the entire document should be true, but + initially it is not yet entirely true. + +## guidelines for this document + +- Try to keep lines below 80 characters in length, especially prose. But if + embedded code or literals are longer, that's fine. +- Use formatting such as bold or italics sparingly +- In general, we treat this document like source code insofar as it should be + both human-readable and machine-readable +- Keep this meta section at the end of the file. From 642c7d943496989f7a16c52fae7239f9f048d692 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 13:06:21 -0600 Subject: [PATCH 048/137] Update CLAUDE.md --- CLAUDE.md | 145 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 97 insertions(+), 48 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b2d4407..abdb3d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,64 +1,113 @@ -# diachron overview for Claude +# CLAUDE.md -## Overview +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. -diachron is an attempt at a web framework that attempts to solve various pain -points I've dealt with when using other frameworks. +## Project Overview -## Goals +Diachron is an opinionated TypeScript/Node.js web framework with a Go-based +master process. Key design principles: +- No development/production distinction - single mode of operation everywhere +- Everything loggable and inspectable for debuggability +- Minimal magic, explicit behavior +- PostgreSQL-only (no database abstraction) +- Inspired by "Taking PHP Seriously" essay -- Programs are increasingly complicated; therefore: - - We want to have everything loggable available for inspection - - At any given point in execution execution, we want to know "what the - framework thinks" - - Easy debuggability is very important -- We don't want extreme flexibility; for example, by design we only support - Postgres -- We don't aim to be fast (it's nice when we can get good performance, but it - should be sacrificed to other goals most of the time) -- Application Diachron code uses Typescript; however, it draws a lot of ideas - from the essay titled "Taking PHP Seriously" -- We want as little magic as possible -- When it comes to behavior or architecture, there must be no - development/production distinction. Just one mode of operation. +## Commands +### General -## architecture +**Install dependencies:** +```bash +./sync.sh +``` -Note: for now, this document only describes the part of the framework that is -involved with processing requests and creating responses. - -### master process - -The master process is written in golang. It has the following -responsabilities: - -- Listen on one or more ports and proxy web requests to the correct backend - worker -- Watch the typescript source directory for changes; when it changes, attempt - to rebuild the typescript source - - Keep numerous copies of the transpiled system available - - If transpiliation fails, execution should not stop -- Keep track of child processes, ensuring that a sufficient number of them are - available at a given time. - -Note that the master process behaves exactly the same in production -environments as it does on a developer's desktop system. There is no -development/production distinction. +**Run an app:** +```bash +./master +``` -## Project structure +### Development + +**Check shell scripts (shellcheck + shfmt) (eventually go fmt and prettier or similar):** +```bash +./check.sh +``` + +**Format TypeScript code:** +```bash +cd express && ../cmd pnpm prettier --write . +``` + +**Build Go master process:** +```bash +cd master && go build +``` + +### Operational + +(to be written) + +## Architecture + +### Components + +- **express/** - TypeScript/Express.js backend application +- **master/** - Go-based master process for file watching and process management +- **framework/** - Managed binaries (Node.js, pnpm), command wrappers, and + framework-specific library code +- **monitor/** - Go file watcher that triggers rebuilds (experimental) + +### Master Process (Go) + +Responsibilities: +- Watch TypeScript source for changes and trigger rebuilds +- Manage worker processes +- Proxy web requests to backend workers +- Behaves identically in all environments (no dev/prod distinction) + +### Express App Structure + +- `app.ts` - Main Express application setup with route matching +- `routes.ts` - Route definitions +- `handlers.ts` - Route handlers +- `services.ts` - Service layer (database, logging, misc) +- `types.ts` - TypeScript type definitions (Route, Call, Handler, Result, Method) + +### Framework Command System + +Commands flow through: `./cmd` → `framework/cmd.d/*` → `framework/shims/*` → managed binaries in `framework/binaries/` + +This ensures consistent tooling versions across the team without system-wide installations. + +## Tech Stack + +- TypeScript 5.9+ / Node.js 22.15 +- Express.js 5.1 +- Go 1.23.3+ (master process) +- pnpm 10.12.4 (package manager) +- Zod (runtime validation) +- Nunjucks (templating) +- @vercel/ncc (bundling) + +## Platform Requirements + +Linux x86_64 only (currently). Requires: +- Modern libc for Go binaries +- docker compose (for full stack) +- fd, shellcheck, shfmt (for development) + +## Current Status + +Early stage - most implementations are stubs: +- Database service is placeholder +- Logging functions marked WRITEME +- No test framework configured yet # meta -## plans - -- This document includes many statements that should be taken as statements of - intent and not necessarily of current fact. In other words, when diachron - reaches a certain stage of maturity, the entire document should be true, but - initially it is not yet entirely true. - ## guidelines for this document - Try to keep lines below 80 characters in length, especially prose. But if From 321b2abd23d5e6b1baef35656c543d49158a9e51 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 13:24:36 -0600 Subject: [PATCH 049/137] Sort of run node app --- master/main.go | 2 +- master/printchanges.go | 11 ---- master/runexpress.go | 124 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 12 deletions(-) delete mode 100644 master/printchanges.go create mode 100644 master/runexpress.go diff --git a/master/main.go b/master/main.go index 5c09175..2b011da 100644 --- a/master/main.go +++ b/master/main.go @@ -27,7 +27,7 @@ func main() { go watchFiles(watchedDir, fileChanges) - go printChanges(fileChanges) + go runExpress(fileChanges) // WaitGroup to track both processes // var wg sync.WaitGroup diff --git a/master/printchanges.go b/master/printchanges.go deleted file mode 100644 index dcb0ad9..0000000 --- a/master/printchanges.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "fmt" -) - -func printChanges(changes <-chan FileChange) { - for change := range changes { - fmt.Printf("[%s] %s\n", change.Operation, change.Path) - } -} diff --git a/master/runexpress.go b/master/runexpress.go new file mode 100644 index 0000000..9b2e2c4 --- /dev/null +++ b/master/runexpress.go @@ -0,0 +1,124 @@ +package main + +import ( + "io" + "log" + "os" + "os/exec" + "sync" + "syscall" + "time" +) + +func runExpress(changes <-chan FileChange) { + var currentProcess *exec.Cmd + var mu sync.Mutex + + // Helper to start the express process + startExpress := func() *exec.Cmd { + cmd := exec.Command("node", "../express/dist/index.js") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + log.Printf("[express] Failed to start: %v", err) + return nil + } + + log.Printf("[express] Started (pid %d)", cmd.Process.Pid) + + // Monitor the process in background + go func() { + err := cmd.Wait() + if err != nil { + log.Printf("[express] Process exited: %v", err) + } else { + log.Printf("[express] Process exited normally") + } + }() + + return cmd + } + + // Helper to stop the express process + stopExpress := func(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + + log.Printf("[express] Stopping (pid %d)", cmd.Process.Pid) + cmd.Process.Signal(syscall.SIGTERM) + + // Wait briefly for graceful shutdown + done := make(chan struct{}) + go func() { + cmd.Wait() + close(done) + }() + + select { + case <-done: + log.Printf("[express] Stopped gracefully") + case <-time.After(5 * time.Second): + log.Printf("[express] Force killing") + cmd.Process.Kill() + } + } + + // Helper to run the build + runBuild := func() bool { + log.Printf("[build] Starting ncc build...") + + cmd := exec.Command("sh", "-c", "cd ../express && ../cmd pnpm ncc build ./app.ts") + + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + + if err := cmd.Start(); err != nil { + log.Printf("[build] Failed to start: %v", err) + return false + } + + // Copy output + go io.Copy(os.Stdout, stdout) + go io.Copy(os.Stderr, stderr) + + err := cmd.Wait() + if err != nil { + log.Printf("[build] Failed: %v", err) + return false + } + + log.Printf("[build] Success") + return true + } + + // Debounce timer + var debounceTimer *time.Timer + const debounceDelay = 100 * time.Millisecond + + for change := range changes { + log.Printf("[watch] %s: %s", change.Operation, change.Path) + + // Reset debounce timer + if debounceTimer != nil { + debounceTimer.Stop() + } + + debounceTimer = time.AfterFunc(debounceDelay, func() { + if !runBuild() { + log.Printf("[master] Build failed, keeping current process") + return + } + + mu.Lock() + defer mu.Unlock() + + // Stop old process + stopExpress(currentProcess) + + // Start new process + currentProcess = startExpress() + }) + } +} From 615cd896560adec5381ac58705ab168cb0d7336b Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 13:24:50 -0600 Subject: [PATCH 050/137] Ignore more node_modules directories --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 21ec9a1..56d71dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ -framework/node/node_modules +**/node_modules framework/downloads framework/binaries framework/.nodejs framework/.nodejs-config -node_modules \ No newline at end of file From 1083655a3b3114f89c89daee3bd8fa5012e5dfe1 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 14:08:46 -0600 Subject: [PATCH 051/137] Add and use a simpler run script --- express/run.sh | 27 ++------------------------- master/runexpress.go | 2 +- 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/express/run.sh b/express/run.sh index 725e61c..3fbad7d 100755 --- a/express/run.sh +++ b/express/run.sh @@ -1,32 +1,9 @@ #!/bin/bash -# XXX should we default to strict or non-strict here? - set -eu DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -run_dir="$DIR" +cd "$DIR" -source "$run_dir"/../framework/shims/common -source "$run_dir"/../framework/shims/node.common - -strict_arg="${1:---no-strict}" - -if [[ "$strict_arg" = "--strict" ]] ; then - strict="yes" -else - strict="no" -fi - -cmd="tsx" -if [[ "strict" = "yes" ]] ; then - cmd="ts-node" -fi - -cd "$run_dir" - -"$run_dir"/check.sh -#echo checked -# $ROOT/cmd "$cmd" $run_dir/app.ts -../cmd node "$run_dir"/out/app.js +exec ../cmd node dist/index.js diff --git a/master/runexpress.go b/master/runexpress.go index 9b2e2c4..9de84b0 100644 --- a/master/runexpress.go +++ b/master/runexpress.go @@ -16,7 +16,7 @@ func runExpress(changes <-chan FileChange) { // Helper to start the express process startExpress := func() *exec.Cmd { - cmd := exec.Command("node", "../express/dist/index.js") + cmd := exec.Command("../express/run.sh") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr From 51d24209b0cfe61141b8300951be2fa61abe5124 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 14:09:51 -0600 Subject: [PATCH 052/137] Use build.sh script --- master/runexpress.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/master/runexpress.go b/master/runexpress.go index 9de84b0..f5fac71 100644 --- a/master/runexpress.go +++ b/master/runexpress.go @@ -69,7 +69,7 @@ func runExpress(changes <-chan FileChange) { runBuild := func() bool { log.Printf("[build] Starting ncc build...") - cmd := exec.Command("sh", "-c", "cd ../express && ../cmd pnpm ncc build ./app.ts") + cmd := exec.Command("../express/build.sh") stdout, _ := cmd.StdoutPipe() stderr, _ := cmd.StderrPipe() From ad95f652b8da1824fba5d36597ab2938704fe81f Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 14:10:57 -0600 Subject: [PATCH 053/137] Fix bogus path expansion --- framework/shims/node | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/framework/shims/node b/framework/shims/node index c1dce5c..090bb9a 100755 --- a/framework/shims/node +++ b/framework/shims/node @@ -11,6 +11,4 @@ source "$node_shim_DIR"/../versions source "$node_shim_DIR"/node.common -node_bin="$node_shim_DIR/../../$nodejs_binary_dir/node" - -exec "$node_bin" "$@" +exec "$nodejs_binary_dir/node" "$@" From 43ff2edad2aa105623363e5fbe8d05747d58bc61 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 14:14:03 -0600 Subject: [PATCH 054/137] Pull in nunjucks --- express/package.json | 1 + express/pnpm-lock.yaml | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/express/package.json b/express/package.json index ad36b22..2fc863d 100644 --- a/express/package.json +++ b/express/package.json @@ -18,6 +18,7 @@ "@vercel/ncc": "^0.38.4", "express": "^5.1.0", "nodemon": "^3.1.11", + "nunjucks": "^3.2.4", "path-to-regexp": "^8.3.0", "prettier": "^3.6.2", "ts-node": "^10.9.2", diff --git a/express/pnpm-lock.yaml b/express/pnpm-lock.yaml index 489ca7c..431cf7e 100644 --- a/express/pnpm-lock.yaml +++ b/express/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: nodemon: specifier: ^3.1.11 version: 3.1.11 + nunjucks: + specifier: ^3.2.4 + version: 3.2.4(chokidar@3.6.0) path-to-regexp: specifier: ^8.3.0 version: 8.3.0 @@ -331,6 +334,9 @@ packages: resolution: {integrity: sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==} hasBin: true + a-sync-waterfall@1.0.1: + resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -351,6 +357,9 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -385,6 +394,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -609,6 +622,16 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + nunjucks@3.2.4: + resolution: {integrity: sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==} + engines: {node: '>= 6.9.0'} + hasBin: true + peerDependencies: + chokidar: ^3.3.0 + peerDependenciesMeta: + chokidar: + optional: true + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -1010,6 +1033,8 @@ snapshots: '@vercel/ncc@0.38.4': {} + a-sync-waterfall@1.0.1: {} + accepts@2.0.0: dependencies: mime-types: 3.0.1 @@ -1028,6 +1053,8 @@ snapshots: arg@4.1.3: {} + asap@2.0.6: {} + balanced-match@1.0.2: {} binary-extensions@2.3.0: {} @@ -1079,6 +1106,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + commander@5.1.0: {} + concat-map@0.0.1: {} content-disposition@1.0.0: @@ -1323,6 +1352,14 @@ snapshots: normalize-path@3.0.0: {} + nunjucks@3.2.4(chokidar@3.6.0): + dependencies: + a-sync-waterfall: 1.0.1 + asap: 2.0.6 + commander: 5.1.0 + optionalDependencies: + chokidar: 3.6.0 + object-inspect@1.13.4: {} on-finished@2.4.1: From db811297244e5674a0431c21346be47389213bd8 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 14:17:09 -0600 Subject: [PATCH 055/137] Add build.sh --- express/build.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 express/build.sh diff --git a/express/build.sh b/express/build.sh new file mode 100644 index 0000000..134b2df --- /dev/null +++ b/express/build.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -eu + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +cd "$DIR" + +# outfile="dist/index-$(date +%s).js" +outfile="dist/index.js" + +../cmd pnpm ncc build ./app.ts -o "$outfile" From c330da49fc5404d3ff7f63288f2a2b3e5b918691 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 14:34:16 -0600 Subject: [PATCH 056/137] Add rudimentary command line parsing to express app --- express/app.ts | 5 ++++- express/cli.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ express/run.sh | 2 +- 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 express/cli.ts diff --git a/express/app.ts b/express/app.ts index 2e14203..d91a654 100644 --- a/express/app.ts +++ b/express/app.ts @@ -3,6 +3,7 @@ import express, { Response as ExpressResponse, } from "express"; import { match } from "path-to-regexp"; +import { cli } from "./cli"; import { contentTypes } from "./content-types"; import { httpCodes } from "./http-codes"; import { routes } from "./routes"; @@ -117,4 +118,6 @@ app.use(async (req: ExpressRequest, res: ExpressResponse) => { res.status(code).send(result); }); -app.listen(3000); +app.listen(cli.listen.port, cli.listen.host, () => { + console.log(`Listening on ${cli.listen.host}:${cli.listen.port}`); +}); diff --git a/express/cli.ts b/express/cli.ts new file mode 100644 index 0000000..dcfa6f8 --- /dev/null +++ b/express/cli.ts @@ -0,0 +1,49 @@ +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ + options: { + listen: { + type: "string", + short: "l", + }, + }, + strict: true, + allowPositionals: false, +}); + +function parseListenAddress(listen: string | undefined): { + host: string; + port: number; +} { + const defaultHost = "127.0.0.1"; + const defaultPort = 3000; + + if (!listen) { + return { host: defaultHost, port: defaultPort }; + } + + const lastColon = listen.lastIndexOf(":"); + if (lastColon === -1) { + // Just a port number + const port = parseInt(listen, 10); + if (isNaN(port)) { + throw new Error(`Invalid listen address: ${listen}`); + } + return { host: defaultHost, port }; + } + + const host = listen.slice(0, lastColon); + const port = parseInt(listen.slice(lastColon + 1), 10); + + if (isNaN(port)) { + throw new Error(`Invalid port in listen address: ${listen}`); + } + + return { host, port }; +} + +const listenAddress = parseListenAddress(values.listen); + +export const cli = { + listen: listenAddress, +}; diff --git a/express/run.sh b/express/run.sh index 3fbad7d..45e91bb 100755 --- a/express/run.sh +++ b/express/run.sh @@ -6,4 +6,4 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" cd "$DIR" -exec ../cmd node dist/index.js +exec ../cmd node dist/index.js "$@" From a840137f83915bd53de630b5d8730dca96ae00ca Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 14:34:31 -0600 Subject: [PATCH 057/137] Mark build.sh as executable --- express/build.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 express/build.sh diff --git a/express/build.sh b/express/build.sh old mode 100644 new mode 100755 From 5d5a2430ad84e3a04a9ad4a540c6e1feea59de8d Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 14:37:11 -0600 Subject: [PATCH 058/137] Fix arg in build script --- express/build.sh | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/express/build.sh b/express/build.sh index 134b2df..32dc60c 100755 --- a/express/build.sh +++ b/express/build.sh @@ -6,7 +6,4 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" cd "$DIR" -# outfile="dist/index-$(date +%s).js" -outfile="dist/index.js" - -../cmd pnpm ncc build ./app.ts -o "$outfile" +../cmd pnpm ncc build ./app.ts -o dist From 9cc1991d0769708a38053fe9c9bf5eff91661e8c Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 14:54:17 -0600 Subject: [PATCH 059/137] Name backend process --- express/app.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/express/app.ts b/express/app.ts index d91a654..1aaa046 100644 --- a/express/app.ts +++ b/express/app.ts @@ -20,6 +20,8 @@ import { methodParser, } from "./types"; +process.title = "express-diachron-app"; + const app = express(); services.logging.log({ source: "logging", text: ["1"] }); From 8722062f4a4adcfa4442e90d234c5791d3687075 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 15:12:01 -0600 Subject: [PATCH 060/137] Change process names again --- express/app.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/express/app.ts b/express/app.ts index 1aaa046..1dae9f7 100644 --- a/express/app.ts +++ b/express/app.ts @@ -20,7 +20,8 @@ import { methodParser, } from "./types"; -process.title = "express-diachron-app"; + + const app = express(); @@ -120,6 +121,8 @@ app.use(async (req: ExpressRequest, res: ExpressResponse) => { res.status(code).send(result); }); +process.title = `diachron:${cli.listen.port}`; + app.listen(cli.listen.port, cli.listen.host, () => { console.log(`Listening on ${cli.listen.host}:${cli.listen.port}`); }); From f504576f3ec06fe33d5587dcbeee7307b069d53f Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 15:43:49 -0600 Subject: [PATCH 061/137] Add first cut at a pool --- framework/cmd.d/node | 2 +- master/main.go | 42 ++++++++++++------------ master/proxy.go | 48 ++++++++++++++++++++++++++++ master/runexpress.go | 76 ++++++++++++++++++++++++++++++++------------ master/watchfiles.go | 32 +++++++++++++++++-- master/workerpool.go | 75 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 232 insertions(+), 43 deletions(-) create mode 100644 master/proxy.go create mode 100644 master/workerpool.go diff --git a/framework/cmd.d/node b/framework/cmd.d/node index 05f3b98..c412659 100755 --- a/framework/cmd.d/node +++ b/framework/cmd.d/node @@ -4,4 +4,4 @@ set -eu DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -"$DIR"/../shims/node "$@" +exec "$DIR"/../shims/node "$@" diff --git a/master/main.go b/master/main.go index 2b011da..2aaa94c 100644 --- a/master/main.go +++ b/master/main.go @@ -1,23 +1,33 @@ package main import ( - // "context" "fmt" "os" "os/signal" - // "sync" + "strconv" "syscall" ) func main() { - // var program1 = os.Getenv("BUILD_COMMAND") - //var program2 = os.Getenv("RUN_COMMAND") + watchedDir := os.Getenv("WATCHED_DIR") - var watchedDir = os.Getenv("WATCHED_DIR") + numChildProcesses := 1 + if n, err := strconv.Atoi(os.Getenv("NUM_CHILD_PROCESSES")); err == nil && n > 0 { + numChildProcesses = n + } - // Create context for graceful shutdown - // ctx, cancel := context.WithCancel(context.Background()) - //defer cancel() + basePort := 3000 + if p, err := strconv.Atoi(os.Getenv("BASE_PORT")); err == nil && p > 0 { + basePort = p + } + + listenPort := 8080 + if p, err := strconv.Atoi(os.Getenv("LISTEN_PORT")); err == nil && p > 0 { + listenPort = p + } + + // Create worker pool + pool := NewWorkerPool() // Setup signal handling sigCh := make(chan os.Signal, 1) @@ -27,24 +37,16 @@ func main() { go watchFiles(watchedDir, fileChanges) - go runExpress(fileChanges) + go runExpress(fileChanges, numChildProcesses, basePort, pool) - // WaitGroup to track both processes - // var wg sync.WaitGroup - - // Start both processes - //wg.Add(2) - // go runProcess(ctx, &wg, "builder", program1) - // go runProcess(ctx, &wg, "runner", program2) + // Start the reverse proxy + listenAddr := fmt.Sprintf(":%d", listenPort) + go startProxy(listenAddr, pool) // Wait for interrupt signal <-sigCh fmt.Println("\nReceived interrupt signal, shutting down...") - // Cancel context to signal goroutines to stop - /// cancel() - // Wait for both processes to finish - // wg.Wait() fmt.Println("All processes terminated cleanly") } diff --git a/master/proxy.go b/master/proxy.go new file mode 100644 index 0000000..1ee52b2 --- /dev/null +++ b/master/proxy.go @@ -0,0 +1,48 @@ +package main + +import ( + "log" + "net/http" + "net/http/httputil" + "net/url" +) + +// startProxy starts an HTTP reverse proxy that forwards requests to workers. +// It acquires a worker from the pool for each request and releases it when done. +func startProxy(listenAddr string, pool *WorkerPool) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Acquire a worker (blocks if none available) + workerAddr, ok := pool.Acquire() + if !ok { + http.Error(w, "Service unavailable", http.StatusServiceUnavailable) + return + } + + // Ensure we release the worker when done + defer pool.Release(workerAddr) + + // Create reverse proxy to the worker + targetURL, err := url.Parse("http://" + workerAddr) + if err != nil { + log.Printf("[proxy] Failed to parse worker URL %s: %v", workerAddr, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + proxy := httputil.NewSingleHostReverseProxy(targetURL) + + // Custom error handler + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("[proxy] Error proxying to %s: %v", workerAddr, err) + http.Error(w, "Bad gateway", http.StatusBadGateway) + } + + log.Printf("[proxy] %s %s -> %s", r.Method, r.URL.Path, workerAddr) + proxy.ServeHTTP(w, r) + }) + + log.Printf("[proxy] Listening on %s", listenAddr) + if err := http.ListenAndServe(listenAddr, handler); err != nil { + log.Fatalf("[proxy] Failed to start: %v", err) + } +} diff --git a/master/runexpress.go b/master/runexpress.go index f5fac71..a2333f8 100644 --- a/master/runexpress.go +++ b/master/runexpress.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "io" "log" "os" @@ -10,43 +11,45 @@ import ( "time" ) -func runExpress(changes <-chan FileChange) { - var currentProcess *exec.Cmd +func runExpress(changes <-chan FileChange, numProcesses int, basePort int, pool *WorkerPool) { + var currentProcesses []*exec.Cmd var mu sync.Mutex - // Helper to start the express process - startExpress := func() *exec.Cmd { - cmd := exec.Command("../express/run.sh") + // Helper to start an express process on a specific port + startExpress := func(port int) *exec.Cmd { + listenAddr := fmt.Sprintf("127.0.0.1:%d", port) + cmd := exec.Command("../express/run.sh", "--listen", listenAddr) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { - log.Printf("[express] Failed to start: %v", err) + log.Printf("[express:%d] Failed to start: %v", port, err) return nil } - log.Printf("[express] Started (pid %d)", cmd.Process.Pid) + log.Printf("[express:%d] Started (pid %d)", port, cmd.Process.Pid) // Monitor the process in background - go func() { + go func(p int) { err := cmd.Wait() if err != nil { - log.Printf("[express] Process exited: %v", err) + log.Printf("[express:%d] Process exited: %v", p, err) } else { - log.Printf("[express] Process exited normally") + log.Printf("[express:%d] Process exited normally", p) } - }() + }(port) return cmd } - // Helper to stop the express process + // Helper to stop an express process stopExpress := func(cmd *exec.Cmd) { if cmd == nil || cmd.Process == nil { return } - log.Printf("[express] Stopping (pid %d)", cmd.Process.Pid) + pid := cmd.Process.Pid + log.Printf("[express] Stopping (pid %d)", pid) cmd.Process.Signal(syscall.SIGTERM) // Wait briefly for graceful shutdown @@ -58,13 +61,38 @@ func runExpress(changes <-chan FileChange) { select { case <-done: - log.Printf("[express] Stopped gracefully") + log.Printf("[express] Stopped gracefully (pid %d)", pid) case <-time.After(5 * time.Second): - log.Printf("[express] Force killing") + log.Printf("[express] Force killing (pid %d)", pid) cmd.Process.Kill() } } + // Helper to stop all express processes + stopAllExpress := func(processes []*exec.Cmd) { + for _, cmd := range processes { + stopExpress(cmd) + } + } + + // Helper to start all express processes and update the worker pool + startAllExpress := func() []*exec.Cmd { + processes := make([]*exec.Cmd, 0, numProcesses) + addresses := make([]string, 0, numProcesses) + for i := 0; i < numProcesses; i++ { + port := basePort + i + addr := fmt.Sprintf("127.0.0.1:%d", port) + cmd := startExpress(port) + if cmd != nil { + processes = append(processes, cmd) + addresses = append(addresses, addr) + } + } + // Update the worker pool with new worker addresses + pool.SetWorkers(addresses) + return processes + } + // Helper to run the build runBuild := func() bool { log.Printf("[build] Starting ncc build...") @@ -97,6 +125,14 @@ func runExpress(changes <-chan FileChange) { var debounceTimer *time.Timer const debounceDelay = 100 * time.Millisecond + // Initial build and start + log.Printf("[master] Initial build...") + if runBuild() { + currentProcesses = startAllExpress() + } else { + log.Printf("[master] Initial build failed") + } + for change := range changes { log.Printf("[watch] %s: %s", change.Operation, change.Path) @@ -107,18 +143,18 @@ func runExpress(changes <-chan FileChange) { debounceTimer = time.AfterFunc(debounceDelay, func() { if !runBuild() { - log.Printf("[master] Build failed, keeping current process") + log.Printf("[master] Build failed, keeping current processes") return } mu.Lock() defer mu.Unlock() - // Stop old process - stopExpress(currentProcess) + // Stop all old processes + stopAllExpress(currentProcesses) - // Start new process - currentProcess = startExpress() + // Start all new processes + currentProcesses = startAllExpress() }) } } diff --git a/master/watchfiles.go b/master/watchfiles.go index b339def..c7d31b3 100644 --- a/master/watchfiles.go +++ b/master/watchfiles.go @@ -1,12 +1,32 @@ package main import ( - "github.com/fsnotify/fsnotify" "log" "os" "path/filepath" + "strings" + + "github.com/fsnotify/fsnotify" ) +// shouldIgnore returns true for paths that should not trigger rebuilds +func shouldIgnore(path string) bool { + // Ignore build output and dependencies + ignoreDirs := []string{"/dist/", "/node_modules/", "/.git/"} + for _, dir := range ignoreDirs { + if strings.Contains(path, dir) { + return true + } + } + // Also ignore if path ends with these directories + for _, dir := range []string{"/dist", "/node_modules", "/.git"} { + if strings.HasSuffix(path, dir) { + return true + } + } + return false +} + func watchFiles(dir string, changes chan<- FileChange) { watcher, err := fsnotify.NewWatcher() if err != nil { @@ -14,12 +34,15 @@ func watchFiles(dir string, changes chan<- FileChange) { } defer watcher.Close() - // Add all directories recursively + // Add all directories recursively (except ignored ones) err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { + if shouldIgnore(path) { + return filepath.SkipDir + } err = watcher.Add(path) if err != nil { log.Printf("Error watching %s: %v\n", path, err) @@ -38,6 +61,11 @@ func watchFiles(dir string, changes chan<- FileChange) { return } + // Skip ignored paths + if shouldIgnore(event.Name) { + continue + } + // Handle different types of events var operation string switch { diff --git a/master/workerpool.go b/master/workerpool.go new file mode 100644 index 0000000..e35a6f7 --- /dev/null +++ b/master/workerpool.go @@ -0,0 +1,75 @@ +package main + +import ( + "log" + "sync" +) + +// WorkerPool manages a pool of worker addresses and tracks their availability. +// Each worker can only handle one request at a time. +type WorkerPool struct { + mu sync.Mutex + workers []string + available chan string + closed bool +} + +// NewWorkerPool creates a new empty worker pool. +func NewWorkerPool() *WorkerPool { + return &WorkerPool{ + available: make(chan string, 100), // buffered to avoid blocking + } +} + +// SetWorkers updates the pool with a new set of worker addresses. +// Called when workers are started or restarted after a rebuild. +func (p *WorkerPool) SetWorkers(addrs []string) { + p.mu.Lock() + defer p.mu.Unlock() + + // Drain the old available channel + close(p.available) + for range p.available { + // drain + } + + // Create new channel and populate with new workers + p.available = make(chan string, len(addrs)+10) + p.workers = make([]string, len(addrs)) + copy(p.workers, addrs) + + for _, addr := range addrs { + p.available <- addr + } + + log.Printf("[pool] Updated workers: %v", addrs) +} + +// Acquire blocks until a worker is available and returns its address. +func (p *WorkerPool) Acquire() (string, bool) { + addr, ok := <-p.available + if ok { + log.Printf("[pool] Acquired worker %s", addr) + } + return addr, ok +} + +// Release marks a worker as available again after it finishes handling a request. +func (p *WorkerPool) Release(addr string) { + p.mu.Lock() + defer p.mu.Unlock() + + // Only release if the worker is still in our current set + for _, w := range p.workers { + if w == addr { + select { + case p.available <- addr: + log.Printf("[pool] Released worker %s", addr) + default: + // Channel full, worker may have been removed + } + return + } + } + // Worker not in current set (probably from before a rebuild), ignore +} From 7b8eaac6378dbce05a797645677f459148762ff1 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 15:45:43 -0600 Subject: [PATCH 062/137] Add TODO.md and instructions --- .claude/instructions.md | 2 ++ TODO.md | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 .claude/instructions.md create mode 100644 TODO.md diff --git a/.claude/instructions.md b/.claude/instructions.md new file mode 100644 index 0000000..90e5b49 --- /dev/null +++ b/.claude/instructions.md @@ -0,0 +1,2 @@ +When asked "what's next?" or during downtime, check TODO.md and suggest items to work on. + diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..9550158 --- /dev/null +++ b/TODO.md @@ -0,0 +1,21 @@ +- [ ] Update check script: + - [ ] Run `go fmt` on all .go files + - [ ] Run prettier on all .ts files + - [ ] Eventually, run unit tests + +- [ ] Adapt master program so that it reads configuration from command line + args instead of from environment variables + - Should have sane defaults + - Adding new arguments should be easy and obvious + +- [ ] Add wrapper script to run main program (so that various assumptions related + to relative paths are safer) + +- [ ] Add unit tests all over the place. + - ⚠️ Huge task - needs breakdown before starting + +- [ ] flesh out the `sync.sh` script + - [ ] update framework-managed node + - [ ] update framework-managed pnpm + - [ ] update pnpm-managed deps + - [ ] rebuild golang programs From 58f88e3695f75693cbe4eb44323022c205b982cc Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 16:47:50 -0600 Subject: [PATCH 063/137] Add check.sh and fixup.sh scripts --- check.sh | 30 ++++++++++++++++++++++++++++++ fixup.sh | 22 ++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100755 check.sh create mode 100755 fixup.sh diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..23597f6 --- /dev/null +++ b/check.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$DIR" + +# Keep exclusions sorted. And list them here. +# +# - SC2002 is useless use of cat +# +exclusions="SC2002" + +source "$DIR/framework/versions" + +if [[ $# -ne 0 ]]; then + shellcheck --exclude="$exclusions" "$@" + exit $? +fi + +shell_scripts="$(fd .sh | xargs)" + +# The files we need to check all either end in .sh or else they're the files +# in framework/cmd.d and framework/shims. -x instructs shellcheck to also +# check `source`d files. + +shellcheck -x --exclude="$exclusions" "$DIR/cmd" "$DIR"/framework/cmd.d/* "$DIR"/framework/shims/* "$shell_scripts" + +pushd "$DIR/master" +docker run --rm -v $(pwd):/app -w /app golangci/golangci-lint:$golangci_lint golangci-lint run +popd diff --git a/fixup.sh b/fixup.sh new file mode 100755 index 0000000..43f86f4 --- /dev/null +++ b/fixup.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$DIR" + +# uv run ruff check --select I --fix . + +# uv run ruff format . + +shell_scripts="$(fd .sh | xargs)" +shfmt -i 4 -w "$DIR/cmd" "$DIR"/framework/cmd.d/* "$DIR"/framework/shims/* +# "$shell_scripts" +for ss in $shell_scripts; do + shfmt -i 4 -w $ss +done + +pushd "$DIR/master" +go fmt +popd From cb4a7308381cf3e65fdc160c6ce9787f5bed966c Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 16:53:00 -0600 Subject: [PATCH 064/137] Make go fmt happier --- master/main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/master/main.go b/master/main.go index 2aaa94c..8867ba7 100644 --- a/master/main.go +++ b/master/main.go @@ -47,6 +47,5 @@ func main() { <-sigCh fmt.Println("\nReceived interrupt signal, shutting down...") - fmt.Println("All processes terminated cleanly") } From d35e7bace2bfe845994be4e34684104141fdf322 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 16:53:19 -0600 Subject: [PATCH 065/137] Make shfmt happier --- express/build.sh | 2 +- express/check.sh | 2 +- express/run.sh | 2 +- express/show-config.sh | 2 +- express/watch.sh | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/express/build.sh b/express/build.sh index 32dc60c..038f05f 100755 --- a/express/build.sh +++ b/express/build.sh @@ -2,7 +2,7 @@ set -eu -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$DIR" diff --git a/express/check.sh b/express/check.sh index ed260a0..0dece7f 100755 --- a/express/check.sh +++ b/express/check.sh @@ -2,7 +2,7 @@ set -eu -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" check_dir="$DIR" diff --git a/express/run.sh b/express/run.sh index 45e91bb..228defc 100755 --- a/express/run.sh +++ b/express/run.sh @@ -2,7 +2,7 @@ set -eu -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$DIR" diff --git a/express/show-config.sh b/express/show-config.sh index 8e80baa..2c553a1 100755 --- a/express/show-config.sh +++ b/express/show-config.sh @@ -2,7 +2,7 @@ set -e -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" check_dir="$DIR" diff --git a/express/watch.sh b/express/watch.sh index 1883f04..e1fba56 100755 --- a/express/watch.sh +++ b/express/watch.sh @@ -2,7 +2,7 @@ set -e -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" check_dir="$DIR" From 13d02d86be6d300859d0d67f0fc624977232bcd5 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 17:16:02 -0600 Subject: [PATCH 066/137] Pull in and set up biome --- express/biome.jsonc | 36 +++++++++++++++++ express/package.json | 1 + express/pnpm-lock.yaml | 91 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 express/biome.jsonc diff --git a/express/biome.jsonc b/express/biome.jsonc new file mode 100644 index 0000000..2c997ab --- /dev/null +++ b/express/biome.jsonc @@ -0,0 +1,36 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4 + }, + + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/express/package.json b/express/package.json index 2fc863d..4a957c2 100644 --- a/express/package.json +++ b/express/package.json @@ -43,6 +43,7 @@ ] }, "devDependencies": { + "@biomejs/biome": "2.3.10", "@types/express": "^5.0.5" } } diff --git a/express/pnpm-lock.yaml b/express/pnpm-lock.yaml index 431cf7e..240c29f 100644 --- a/express/pnpm-lock.yaml +++ b/express/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: specifier: ^4.1.12 version: 4.1.12 devDependencies: + '@biomejs/biome': + specifier: 2.3.10 + version: 2.3.10 '@types/express': specifier: ^5.0.5 version: 5.0.5 @@ -88,6 +91,59 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@biomejs/biome@2.3.10': + resolution: {integrity: sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.10': + resolution: {integrity: sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.10': + resolution: {integrity: sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.10': + resolution: {integrity: sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.3.10': + resolution: {integrity: sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.3.10': + resolution: {integrity: sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.3.10': + resolution: {integrity: sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.3.10': + resolution: {integrity: sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.10': + resolution: {integrity: sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -862,6 +918,41 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@biomejs/biome@2.3.10': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.10 + '@biomejs/cli-darwin-x64': 2.3.10 + '@biomejs/cli-linux-arm64': 2.3.10 + '@biomejs/cli-linux-arm64-musl': 2.3.10 + '@biomejs/cli-linux-x64': 2.3.10 + '@biomejs/cli-linux-x64-musl': 2.3.10 + '@biomejs/cli-win32-arm64': 2.3.10 + '@biomejs/cli-win32-x64': 2.3.10 + + '@biomejs/cli-darwin-arm64@2.3.10': + optional: true + + '@biomejs/cli-darwin-x64@2.3.10': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.10': + optional: true + + '@biomejs/cli-linux-arm64@2.3.10': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.10': + optional: true + + '@biomejs/cli-linux-x64@2.3.10': + optional: true + + '@biomejs/cli-win32-arm64@2.3.10': + optional: true + + '@biomejs/cli-win32-x64@2.3.10': + optional: true + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 From 20e5da0d544ae20fd5abddb1325ca26054b56947 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 17:16:46 -0600 Subject: [PATCH 067/137] Teach fixup.sh to use biome --- fixup.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fixup.sh b/fixup.sh index 43f86f4..7b248b8 100755 --- a/fixup.sh +++ b/fixup.sh @@ -20,3 +20,7 @@ done pushd "$DIR/master" go fmt popd + +pushd "$DIR/express" +../cmd pnpm biome check --write +popd From e2ea472a1058d9b80f9ef8992d40e68e7f1a9952 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 17:22:04 -0600 Subject: [PATCH 068/137] Make biome happier --- express/app.ts | 19 ++++++++----------- express/handlers.ts | 2 +- express/routes.ts | 2 +- express/tsconfig.json | 12 ++++++------ express/types.ts | 8 ++++---- 5 files changed, 20 insertions(+), 23 deletions(-) diff --git a/express/app.ts b/express/app.ts index 1dae9f7..6117023 100644 --- a/express/app.ts +++ b/express/app.ts @@ -1,6 +1,6 @@ import express, { - Request as ExpressRequest, - Response as ExpressResponse, + type Request as ExpressRequest, + type Response as ExpressResponse, } from "express"; import { match } from "path-to-regexp"; import { cli } from "./cli"; @@ -10,19 +10,16 @@ import { routes } from "./routes"; import { services } from "./services"; // import { URLPattern } from 'node:url'; import { - Call, - InternalHandler, - Method, - ProcessedRoute, - Result, - Route, + type Call, + type InternalHandler, + type Method, massageMethod, methodParser, + type ProcessedRoute, + type Result, + type Route, } from "./types"; - - - const app = express(); services.logging.log({ source: "logging", text: ["1"] }); diff --git a/express/handlers.ts b/express/handlers.ts index 4ad67e8..9a15231 100644 --- a/express/handlers.ts +++ b/express/handlers.ts @@ -1,7 +1,7 @@ import { contentTypes } from "./content-types"; import { httpCodes } from "./http-codes"; import { services } from "./services"; -import { Call, Handler, Result } from "./types"; +import type { Call, Handler, Result } from "./types"; const multiHandler: Handler = async (call: Call): Promise => { const code = httpCodes.success.OK; diff --git a/express/routes.ts b/express/routes.ts index 4a67aa5..7c7e7e4 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -4,7 +4,7 @@ import { contentTypes } from "./content-types"; import { multiHandler } from "./handlers"; import { HttpCode, httpCodes } from "./http-codes"; import { services } from "./services"; -import { Call, ProcessedRoute, Result, Route } from "./types"; +import { type Call, ProcessedRoute, type Result, type Route } from "./types"; // FIXME: Obviously put this somewhere else const okText = (result: string): Result => { diff --git a/express/tsconfig.json b/express/tsconfig.json index ccba534..3381805 100644 --- a/express/tsconfig.json +++ b/express/tsconfig.json @@ -1,13 +1,13 @@ { "compilerOptions": { - "esModuleInterop": true, - "target": "ES2022", - "lib": ["ES2023"], - "module": "NodeNext", - "moduleResolution": "NodeNext", + "esModuleInterop": true, + "target": "ES2022", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", "noImplicitAny": true, "strict": true, "types": ["node"], - "outDir": "out", + "outDir": "out" } } diff --git a/express/types.ts b/express/types.ts index 05ce6d9..2b6c79e 100644 --- a/express/types.ts +++ b/express/types.ts @@ -3,13 +3,13 @@ // FIXME: split this up into types used by app developers and types internal // to the framework. import { - Request as ExpressRequest, + type Request as ExpressRequest, Response as ExpressResponse, } from "express"; -import { MatchFunction } from "path-to-regexp"; +import type { MatchFunction } from "path-to-regexp"; import { z } from "zod"; -import { ContentType, contentTypes } from "./content-types"; -import { HttpCode, httpCodes } from "./http-codes"; +import { type ContentType, contentTypes } from "./content-types"; +import { type HttpCode, httpCodes } from "./http-codes"; const methodParser = z.union([ z.literal("GET"), From 30463b60a50fbc9dc39e94d05667a5f714c36ea2 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 17:31:08 -0600 Subject: [PATCH 069/137] Use CLI flags instead of environment variables for master config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace env var parsing with Go's flag package: - --watch (default: ../express) - --workers (default: 1) - --base-port (default: 3000) - --port (default: 8080) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- TODO.md | 20 +++++++++++++++++--- master/main.go | 28 +++++++++------------------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/TODO.md b/TODO.md index 9550158..cd150a3 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,17 @@ - [ ] Update check script: - - [ ] Run `go fmt` on all .go files - - [ ] Run prettier on all .ts files + - [x] shellcheck on shell scripts + - [x] `go vet` on go files + - [x] `golangci-lint` on go files + - [x] Run `go fmt` on all .go files - [ ] Eventually, run unit tests -- [ ] Adapt master program so that it reads configuration from command line +- [x] Reimplement fixup.sh + - [x] run shfmt on all shell scripts (and the files they `source`) + - [x] Run `go fmt` on all .go files + - [x] Run ~~prettier~~ biome on all .ts files and maybe others + + +- [x] Adapt master program so that it reads configuration from command line args instead of from environment variables - Should have sane defaults - Adding new arguments should be easy and obvious @@ -19,3 +27,9 @@ - [ ] update framework-managed pnpm - [ ] update pnpm-managed deps - [ ] rebuild golang programs + +- [ ] If the number of workers is large, then there is a long lapse between + when you change a file and when the server responds + - One solution: start and stop workers serially: stop one, restart it with new + code; repeat + - Slow start them: only start a few at first diff --git a/master/main.go b/master/main.go index 8867ba7..dd629da 100644 --- a/master/main.go +++ b/master/main.go @@ -1,30 +1,20 @@ package main import ( + "flag" "fmt" "os" "os/signal" - "strconv" "syscall" ) func main() { - watchedDir := os.Getenv("WATCHED_DIR") + watchDir := flag.String("watch", "../express", "directory to watch for changes") + workers := flag.Int("workers", 1, "number of worker processes") + basePort := flag.Int("base-port", 3000, "base port for worker processes") + listenPort := flag.Int("port", 8080, "port for the reverse proxy to listen on") - numChildProcesses := 1 - if n, err := strconv.Atoi(os.Getenv("NUM_CHILD_PROCESSES")); err == nil && n > 0 { - numChildProcesses = n - } - - basePort := 3000 - if p, err := strconv.Atoi(os.Getenv("BASE_PORT")); err == nil && p > 0 { - basePort = p - } - - listenPort := 8080 - if p, err := strconv.Atoi(os.Getenv("LISTEN_PORT")); err == nil && p > 0 { - listenPort = p - } + flag.Parse() // Create worker pool pool := NewWorkerPool() @@ -35,12 +25,12 @@ func main() { fileChanges := make(chan FileChange, 10) - go watchFiles(watchedDir, fileChanges) + go watchFiles(*watchDir, fileChanges) - go runExpress(fileChanges, numChildProcesses, basePort, pool) + go runExpress(fileChanges, *workers, *basePort, pool) // Start the reverse proxy - listenAddr := fmt.Sprintf(":%d", listenPort) + listenAddr := fmt.Sprintf(":%d", *listenPort) go startProxy(listenAddr, pool) // Wait for interrupt signal From 22dde8c213fd2bd55987df86061ca08a5529f90d Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 17:35:56 -0600 Subject: [PATCH 070/137] Add wrapper script for master program --- TODO.md | 6 +++++- master/.gitignore | 2 +- master/go.mod | 2 +- master/master | 8 ++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100755 master/master diff --git a/TODO.md b/TODO.md index cd150a3..077092e 100644 --- a/TODO.md +++ b/TODO.md @@ -16,9 +16,13 @@ - Should have sane defaults - Adding new arguments should be easy and obvious -- [ ] Add wrapper script to run main program (so that various assumptions related +- [x] Add wrapper script to run master program (so that various assumptions related to relative paths are safer) +- [ ] move `master-bin` into a subdir like `master/cmd` or whatever is + idiomatic for golang programs; adapt `master` wrapper shell script + accordingly + - [ ] Add unit tests all over the place. - ⚠️ Huge task - needs breakdown before starting diff --git a/master/.gitignore b/master/.gitignore index 1f7391f..824c87f 100644 --- a/master/.gitignore +++ b/master/.gitignore @@ -1 +1 @@ -master +master-bin diff --git a/master/go.mod b/master/go.mod index bcee3cf..b94b98f 100644 --- a/master/go.mod +++ b/master/go.mod @@ -1,4 +1,4 @@ -module philologue.net/diachron/master +module philologue.net/diachron/master-bin go 1.23.3 diff --git a/master/master b/master/master new file mode 100755 index 0000000..94b3e1a --- /dev/null +++ b/master/master @@ -0,0 +1,8 @@ +#!/bin/bash + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$DIR" + +./master-bin "$@" + From 5606a5961411689b5e4d6beaef371d9edf12d9c2 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 17:36:22 -0600 Subject: [PATCH 071/137] Note that you need docker as well as docker compose --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c0a24f7..5c6948c 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ To run a more complete system, you also need to have docker compose installed. To hack on diachron itself, you need the following: -- docker compose +- docker and docker compose - [fd](https://github.com/sharkdp/fd) - golang, version 1.23.6 or greater - shellcheck From 5c93c9e9828b68d51129e5ebb779f23c0f2a6048 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 20:17:15 -0600 Subject: [PATCH 072/137] Add TODO items --- TODO.md | 74 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/TODO.md b/TODO.md index 077092e..aa7d271 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,41 @@ +## high importance + +- [ ] Add unit tests all over the place. + - ⚠️ Huge task - needs breakdown before starting + +- [ ] Add logging service + - New golang program, in the same directory as master + - Intended to be started by master + - Listens to a port specified command line arg + - Accepts POSTed (or possibly PUT) json messages, currently in a + to-be-defined format. We will work on this format later. + - Keeps the most recent N messages in memory. N can be a fairly large + number; let's start by assuming 1 million. + +- [ ] Log to logging service from the express backend + - Fill out types and functions in `express/logging.ts` + + +- [ ] Create initial docker-compose.yml file for local development + - include most recent stable postgres + +- [ ] Add middleware concept + +- [ ] Add authentication + - password + - third party? + +- [ ] Add authorization + - for specific routes / resources / etc + +- [ ] Add basic text views + + + +## medium importance + +- [ ] Add email verification + - [ ] Update check script: - [x] shellcheck on shell scripts - [x] `go vet` on go files @@ -5,27 +43,18 @@ - [x] Run `go fmt` on all .go files - [ ] Eventually, run unit tests -- [x] Reimplement fixup.sh - - [x] run shfmt on all shell scripts (and the files they `source`) - - [x] Run `go fmt` on all .go files - - [x] Run ~~prettier~~ biome on all .ts files and maybe others +- [ ] write docs + - upgrade docs + - starting docs + - taking over docs -- [x] Adapt master program so that it reads configuration from command line - args instead of from environment variables - - Should have sane defaults - - Adding new arguments should be easy and obvious - -- [x] Add wrapper script to run master program (so that various assumptions related - to relative paths are safer) +## low importance - [ ] move `master-bin` into a subdir like `master/cmd` or whatever is idiomatic for golang programs; adapt `master` wrapper shell script accordingly -- [ ] Add unit tests all over the place. - - ⚠️ Huge task - needs breakdown before starting - - [ ] flesh out the `sync.sh` script - [ ] update framework-managed node - [ ] update framework-managed pnpm @@ -37,3 +66,20 @@ - One solution: start and stop workers serially: stop one, restart it with new code; repeat - Slow start them: only start a few at first + + +## finished + +- [x] Reimplement fixup.sh + - [x] run shfmt on all shell scripts (and the files they `source`) + - [x] Run `go fmt` on all .go files + - [x] Run ~~prettier~~ biome on all .ts files and maybe others + +- [x] Adapt master program so that it reads configuration from command line + args instead of from environment variables + - Should have sane defaults + - Adding new arguments should be easy and obvious + +- [x] Add wrapper script to run master program (so that various assumptions related + to relative paths are safer) + From b0ee53f7d58c610fed71001d4b99c09b1cbcb3d6 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 20:17:26 -0600 Subject: [PATCH 073/137] Listen by default on port 3500 The master process will continue to start at port 3000. In practice, this ought to make conflicts between master-superviced processes and ones run by hand less of an issue. --- express/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express/cli.ts b/express/cli.ts index dcfa6f8..7f1c121 100644 --- a/express/cli.ts +++ b/express/cli.ts @@ -16,7 +16,7 @@ function parseListenAddress(listen: string | undefined): { port: number; } { const defaultHost = "127.0.0.1"; - const defaultPort = 3000; + const defaultPort = 3500; if (!listen) { return { host: defaultHost, port: defaultPort }; From bee6938a673d3fd1690a546a755cb2039cf76829 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 20:18:37 -0600 Subject: [PATCH 074/137] Add some logging related stubs to express backend --- express/cli.ts | 6 ++++++ express/logging.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/express/cli.ts b/express/cli.ts index 7f1c121..e161360 100644 --- a/express/cli.ts +++ b/express/cli.ts @@ -6,6 +6,10 @@ const { values } = parseArgs({ type: "string", short: "l", }, + "log-address": { + type: "string", + default: "8085", + }, }, strict: true, allowPositionals: false, @@ -43,7 +47,9 @@ function parseListenAddress(listen: string | undefined): { } const listenAddress = parseListenAddress(values.listen); +const logAddress = parseListenAddress(values["log-address"]); export const cli = { listen: listenAddress, + logAddress, }; diff --git a/express/logging.ts b/express/logging.ts index d95ecac..bf76846 100644 --- a/express/logging.ts +++ b/express/logging.ts @@ -1,5 +1,7 @@ // internal-logging.ts +import { cli } from "./cli"; + // FIXME: Move this to somewhere more appropriate type AtLeastOne = [T, ...T[]]; @@ -32,6 +34,9 @@ type FilterArgument = { const log = (_message: Message) => { // WRITEME + console.log( + `will POST a message to ${cli.logAddress.host}:${cli.logAddress.port}`, + ); }; const getLogs = (filter: FilterArgument) => { From 4adf6cf358276ea30bf142399e1e893d356f0af5 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 20:37:16 -0600 Subject: [PATCH 075/137] Add another TODO item --- TODO.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/TODO.md b/TODO.md index aa7d271..d5c0b97 100644 --- a/TODO.md +++ b/TODO.md @@ -51,6 +51,10 @@ ## low importance +- [ ] add a prometheus-style `/metrics` endpoint to master +- [ ] create a metrics server analogous to the logging server + - accept various stats from the workers (TBD) + - [ ] move `master-bin` into a subdir like `master/cmd` or whatever is idiomatic for golang programs; adapt `master` wrapper shell script accordingly From dc5a70ba3372a7d82406e28d84e0d8617dc700f1 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 20:45:34 -0600 Subject: [PATCH 076/137] Add logging service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Go program (logger/) that: - Accepts POSTed JSON log messages via POST /log - Stores last N messages in a ring buffer (default 1M) - Retrieves logs via GET /logs with limit/before/after filters - Shows status via GET /status Also updates express/logging.ts to POST messages to the logger service. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- express/logging.ts | 38 +++++++++++--- logger/.gitignore | 1 + logger/go.mod | 3 ++ logger/main.go | 70 +++++++++++++++++++++++++ logger/store.go | 126 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 logger/.gitignore create mode 100644 logger/go.mod create mode 100644 logger/main.go create mode 100644 logger/store.go diff --git a/express/logging.ts b/express/logging.ts index bf76846..fc5a3a1 100644 --- a/express/logging.ts +++ b/express/logging.ts @@ -32,15 +32,39 @@ type FilterArgument = { match?: (string | RegExp)[]; }; -const log = (_message: Message) => { - // WRITEME - console.log( - `will POST a message to ${cli.logAddress.host}:${cli.logAddress.port}`, - ); +const loggerUrl = `http://${cli.logAddress.host}:${cli.logAddress.port}`; + +const log = (message: Message) => { + const payload = { + timestamp: message.timestamp ?? Date.now(), + source: message.source, + text: message.text, + }; + + fetch(`${loggerUrl}/log`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).catch((err) => { + console.error("[logging] Failed to send log:", err.message); + }); }; -const getLogs = (filter: FilterArgument) => { - // WRITEME +const getLogs = async (filter: FilterArgument): Promise => { + const params = new URLSearchParams(); + if (filter.limit) { + params.set("limit", String(filter.limit)); + } + if (filter.before) { +- params.set("before", String(filter.before)); + } + if (filter.after) { + params.set("after", String(filter.after)); + } + + const url = `${loggerUrl}/logs?${params.toString()}`; + const response = await fetch(url); + return response.json(); }; // FIXME: there's scope for more specialized functions although they diff --git a/logger/.gitignore b/logger/.gitignore new file mode 100644 index 0000000..25304db --- /dev/null +++ b/logger/.gitignore @@ -0,0 +1 @@ +logger-bin diff --git a/logger/go.mod b/logger/go.mod new file mode 100644 index 0000000..dece3c0 --- /dev/null +++ b/logger/go.mod @@ -0,0 +1,3 @@ +module philologue.net/diachron/logger-bin + +go 1.23.3 diff --git a/logger/main.go b/logger/main.go new file mode 100644 index 0000000..f5ec2f1 --- /dev/null +++ b/logger/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "strconv" +) + +func main() { + port := flag.Int("port", 8085, "port to listen on") + capacity := flag.Int("capacity", 1000000, "max messages to store") + + flag.Parse() + + store := NewLogStore(*capacity) + + http.HandleFunc("POST /log", func(w http.ResponseWriter, r *http.Request) { + var msg Message + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + store.Add(msg) + w.WriteHeader(http.StatusCreated) + }) + + http.HandleFunc("GET /logs", func(w http.ResponseWriter, r *http.Request) { + params := FilterParams{} + + if limit := r.URL.Query().Get("limit"); limit != "" { + if n, err := strconv.Atoi(limit); err == nil { + params.Limit = n + } + } + if before := r.URL.Query().Get("before"); before != "" { + if ts, err := strconv.ParseInt(before, 10, 64); err == nil { + params.Before = ts + } + } + if after := r.URL.Query().Get("after"); after != "" { + if ts, err := strconv.ParseInt(after, 10, 64); err == nil { + params.After = ts + } + } + + messages := store.GetFiltered(params) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) + }) + + http.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) { + status := map[string]any{ + "count": store.Count(), + "capacity": *capacity, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) + }) + + listenAddr := fmt.Sprintf(":%d", *port) + log.Printf("[logger] Listening on %s (capacity: %d)", listenAddr, *capacity) + if err := http.ListenAndServe(listenAddr, nil); err != nil { + log.Fatalf("[logger] Failed to start: %v", err) + } +} diff --git a/logger/store.go b/logger/store.go new file mode 100644 index 0000000..3881549 --- /dev/null +++ b/logger/store.go @@ -0,0 +1,126 @@ +package main + +import ( + "sync" +) + +// Message represents a log entry from the express backend +type Message struct { + Timestamp int64 `json:"timestamp"` + Source string `json:"source"` // "logging" | "diagnostic" | "user" + Text []string `json:"text"` +} + +// LogStore is a thread-safe ring buffer for log messages +type LogStore struct { + mu sync.RWMutex + messages []Message + head int // next write position + full bool // whether buffer has wrapped + capacity int +} + +// NewLogStore creates a new log store with the given capacity +func NewLogStore(capacity int) *LogStore { + return &LogStore{ + messages: make([]Message, capacity), + capacity: capacity, + } +} + +// Add inserts a new message into the store +func (s *LogStore) Add(msg Message) { + s.mu.Lock() + defer s.mu.Unlock() + + s.messages[s.head] = msg + s.head++ + if s.head >= s.capacity { + s.head = 0 + s.full = true + } +} + +// Count returns the number of messages in the store +func (s *LogStore) Count() int { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.full { + return s.capacity + } + return s.head +} + +// GetRecent returns the most recent n messages, newest first +func (s *LogStore) GetRecent(n int) []Message { + s.mu.RLock() + defer s.mu.RUnlock() + + count := s.Count() + if n > count { + n = count + } + if n == 0 { + return nil + } + + result := make([]Message, n) + pos := s.head - 1 + for i := 0; i < n; i++ { + if pos < 0 { + pos = s.capacity - 1 + } + result[i] = s.messages[pos] + pos-- + } + return result +} + +// Filter parameters for retrieving logs +type FilterParams struct { + Limit int // max messages to return (0 = default 100) + Before int64 // only messages before this timestamp + After int64 // only messages after this timestamp +} + +// GetFiltered returns messages matching the filter criteria +func (s *LogStore) GetFiltered(params FilterParams) []Message { + s.mu.RLock() + defer s.mu.RUnlock() + + limit := params.Limit + if limit <= 0 { + limit = 100 + } + + count := s.Count() + if count == 0 { + return nil + } + + result := make([]Message, 0, limit) + pos := s.head - 1 + + for i := 0; i < count && len(result) < limit; i++ { + if pos < 0 { + pos = s.capacity - 1 + } + msg := s.messages[pos] + + // Apply filters + if params.Before > 0 && msg.Timestamp >= params.Before { + pos-- + continue + } + if params.After > 0 && msg.Timestamp <= params.After { + pos-- + continue + } + + result = append(result, msg) + pos-- + } + + return result +} From ab74695f4cd743a5a72ba05f38863fed1f97045b Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 20:53:29 -0600 Subject: [PATCH 077/137] Have master start and manage the logger process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master now: - Starts logger on startup with configurable port and capacity - Restarts logger automatically if it crashes - Stops logger gracefully on shutdown New flags: - --logger-port (default 8085) - --logger-capacity (default 1000000) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- master/main.go | 12 +++-- master/runlogger.go | 106 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 master/runlogger.go diff --git a/master/main.go b/master/main.go index dd629da..00d8273 100644 --- a/master/main.go +++ b/master/main.go @@ -13,16 +13,22 @@ func main() { workers := flag.Int("workers", 1, "number of worker processes") basePort := flag.Int("base-port", 3000, "base port for worker processes") listenPort := flag.Int("port", 8080, "port for the reverse proxy to listen on") + loggerPort := flag.Int("logger-port", 8085, "port for the logger service") + loggerCapacity := flag.Int("logger-capacity", 1000000, "max messages for logger to store") flag.Parse() - // Create worker pool - pool := NewWorkerPool() - // Setup signal handling sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + // Start and manage the logger process + stopLogger := startLogger(*loggerPort, *loggerCapacity) + defer stopLogger() + + // Create worker pool + pool := NewWorkerPool() + fileChanges := make(chan FileChange, 10) go watchFiles(*watchDir, fileChanges) diff --git a/master/runlogger.go b/master/runlogger.go new file mode 100644 index 0000000..51552dd --- /dev/null +++ b/master/runlogger.go @@ -0,0 +1,106 @@ +package main + +import ( + "log" + "os" + "os/exec" + "strconv" + "sync" + "syscall" + "time" +) + +// startLogger starts the logger process and returns a function to stop it. +// It automatically restarts the logger if it crashes. +func startLogger(port int, capacity int) func() { + var mu sync.Mutex + var cmd *exec.Cmd + var stopping bool + + portStr := strconv.Itoa(port) + capacityStr := strconv.Itoa(capacity) + + start := func() *exec.Cmd { + c := exec.Command("../logger/logger", "--port", portStr, "--capacity", capacityStr) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + + if err := c.Start(); err != nil { + log.Printf("[logger] Failed to start: %v", err) + return nil + } + + log.Printf("[logger] Started (pid %d) on port %s", c.Process.Pid, portStr) + return c + } + + // Start initial logger + cmd = start() + + // Monitor and restart on crash + go func() { + for { + mu.Lock() + currentCmd := cmd + mu.Unlock() + + if currentCmd == nil { + time.Sleep(time.Second) + mu.Lock() + if !stopping { + cmd = start() + } + mu.Unlock() + continue + } + + err := currentCmd.Wait() + + mu.Lock() + if stopping { + mu.Unlock() + return + } + + if err != nil { + log.Printf("[logger] Process exited: %v, restarting...", err) + } else { + log.Printf("[logger] Process exited normally, restarting...") + } + + time.Sleep(time.Second) + cmd = start() + mu.Unlock() + } + }() + + // Return stop function + return func() { + mu.Lock() + defer mu.Unlock() + + stopping = true + + if cmd == nil || cmd.Process == nil { + return + } + + log.Printf("[logger] Stopping (pid %d)", cmd.Process.Pid) + cmd.Process.Signal(syscall.SIGTERM) + + // Wait briefly for graceful shutdown + done := make(chan struct{}) + go func() { + cmd.Wait() + close(done) + }() + + select { + case <-done: + log.Printf("[logger] Stopped gracefully") + case <-time.After(5 * time.Second): + log.Printf("[logger] Force killing") + cmd.Process.Kill() + } + } +} From 8be88bb696d94cdfaa7dfd8b241f3552c45ba54f Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 21:11:10 -0600 Subject: [PATCH 078/137] Move TODOs re logging to the end --- TODO.md | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/TODO.md b/TODO.md index d5c0b97..5c3b684 100644 --- a/TODO.md +++ b/TODO.md @@ -3,19 +3,6 @@ - [ ] Add unit tests all over the place. - ⚠️ Huge task - needs breakdown before starting -- [ ] Add logging service - - New golang program, in the same directory as master - - Intended to be started by master - - Listens to a port specified command line arg - - Accepts POSTed (or possibly PUT) json messages, currently in a - to-be-defined format. We will work on this format later. - - Keeps the most recent N messages in memory. N can be a fairly large - number; let's start by assuming 1 million. - -- [ ] Log to logging service from the express backend - - Fill out types and functions in `express/logging.ts` - - - [ ] Create initial docker-compose.yml file for local development - include most recent stable postgres @@ -87,3 +74,14 @@ - [x] Add wrapper script to run master program (so that various assumptions related to relative paths are safer) +- [x] Add logging service + - New golang program, in the same directory as master + - Intended to be started by master + - Listens to a port specified command line arg + - Accepts POSTed (or possibly PUT) json messages, currently in a + to-be-defined format. We will work on this format later. + - Keeps the most recent N messages in memory. N can be a fairly large + number; let's start by assuming 1 million. + +- [x] Log to logging service from the express backend + - Fill out types and functions in `express/logging.ts` From 539717efdaf98cee94880925367b98a8a448b43f Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 21:11:28 -0600 Subject: [PATCH 079/137] Add todo item --- TODO.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/TODO.md b/TODO.md index 5c3b684..7177010 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,13 @@ ## medium importance +- [ ] Add a log viewer + - with queries + - convert to logfmt and is there a viewer UI we could pull in and use + instead? + +- [ ] figure out and add logging to disk + - [ ] Add email verification - [ ] Update check script: From 03980e114b69ee8b11eff89d9ee59641dd182918 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 21:12:38 -0600 Subject: [PATCH 080/137] Add basic template rendering route --- express/package.json | 2 ++ express/pnpm-lock.yaml | 17 +++++++++++++++++ express/routes.ts | 25 +++++++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/express/package.json b/express/package.json index 4a957c2..8a884d1 100644 --- a/express/package.json +++ b/express/package.json @@ -15,12 +15,14 @@ "dependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@types/node": "^24.10.1", + "@types/nunjucks": "^3.2.6", "@vercel/ncc": "^0.38.4", "express": "^5.1.0", "nodemon": "^3.1.11", "nunjucks": "^3.2.4", "path-to-regexp": "^8.3.0", "prettier": "^3.6.2", + "ts-luxon": "^6.2.0", "ts-node": "^10.9.2", "tsx": "^4.20.6", "typescript": "^5.9.3", diff --git a/express/pnpm-lock.yaml b/express/pnpm-lock.yaml index 240c29f..c633781 100644 --- a/express/pnpm-lock.yaml +++ b/express/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@types/node': specifier: ^24.10.1 version: 24.10.1 + '@types/nunjucks': + specifier: ^3.2.6 + version: 3.2.6 '@vercel/ncc': specifier: ^0.38.4 version: 0.38.4 @@ -32,6 +35,9 @@ importers: prettier: specifier: ^3.6.2 version: 3.6.2 + ts-luxon: + specifier: ^6.2.0 + version: 6.2.0 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) @@ -371,6 +377,9 @@ packages: '@types/node@24.10.1': resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/nunjucks@3.2.6': + resolution: {integrity: sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==} + '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} @@ -814,6 +823,10 @@ packages: resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true + ts-luxon@6.2.0: + resolution: {integrity: sha512-4I1tkW6gtydyLnUUIvfezBl5B3smurkgKmHdMOYI2g9Fn3Zg1lGJdhsCXu2VNl95CYbW2+SoNtStcf1CKOcQjw==} + engines: {node: '>=18'} + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -1103,6 +1116,8 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/nunjucks@3.2.6': {} + '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} @@ -1588,6 +1603,8 @@ snapshots: touch@3.1.1: {} + ts-luxon@6.2.0: {} + ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 diff --git a/express/routes.ts b/express/routes.ts index 7c7e7e4..65b9312 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -1,5 +1,7 @@ /// +import nunjucks from "nunjucks"; +import { DateTime } from "ts-luxon"; import { contentTypes } from "./content-types"; import { multiHandler } from "./handlers"; import { HttpCode, httpCodes } from "./http-codes"; @@ -72,6 +74,29 @@ const routes: Route[] = [ }; }, }, + { + path: "/time", + methods: ["GET"], + handler: async (_req): Promise => { + const now = DateTime.now(); + const template = ` + + + + {{ now }} + + +`; + + const result = nunjucks.renderString(template, { now }); + + return { + code: httpCodes.success.OK, + contentType: contentTypes.text.html, + result, + }; + }, + }, ]; export { routes }; From 5524eaf18f20537d37b8f9ed2ea807b3e894216b Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 21:12:55 -0600 Subject: [PATCH 081/137] ? --- express/logging.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express/logging.ts b/express/logging.ts index fc5a3a1..629cfbc 100644 --- a/express/logging.ts +++ b/express/logging.ts @@ -56,7 +56,7 @@ const getLogs = async (filter: FilterArgument): Promise => { params.set("limit", String(filter.limit)); } if (filter.before) { -- params.set("before", String(filter.before)); + params.set("before", String(filter.before)); } if (filter.after) { params.set("after", String(filter.after)); From 63cf0a670dcf118dfe3650ce796bd228496f4e60 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 21:20:38 -0600 Subject: [PATCH 082/137] Update todo list --- TODO.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 7177010..5eeba36 100644 --- a/TODO.md +++ b/TODO.md @@ -5,6 +5,12 @@ - [ ] 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! - [ ] Add middleware concept @@ -16,7 +22,8 @@ - for specific routes / resources / etc - [ ] Add basic text views - + Partially done; see the /time route. But we need to figure out where to + store templates, static files, etc. ## medium importance From 6297a95d3c74dc72c02fa986849256025fb9a143 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Thu, 1 Jan 2026 21:20:45 -0600 Subject: [PATCH 083/137] Reformat more files --- fixup.sh | 2 +- master/master | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/fixup.sh b/fixup.sh index 7b248b8..bea3316 100755 --- a/fixup.sh +++ b/fixup.sh @@ -11,7 +11,7 @@ cd "$DIR" # uv run ruff format . shell_scripts="$(fd .sh | xargs)" -shfmt -i 4 -w "$DIR/cmd" "$DIR"/framework/cmd.d/* "$DIR"/framework/shims/* +shfmt -i 4 -w "$DIR/cmd" "$DIR"/framework/cmd.d/* "$DIR"/framework/shims/* "$DIR"/master/master "$DIR"/logger/logger # "$shell_scripts" for ss in $shell_scripts; do shfmt -i 4 -w $ss diff --git a/master/master b/master/master index 94b3e1a..2ffc270 100755 --- a/master/master +++ b/master/master @@ -5,4 +5,3 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$DIR" ./master-bin "$@" - From 788ea2ab195eaf1c13c533105bcef3f06b69181b Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 3 Jan 2026 12:59:47 -0600 Subject: [PATCH 084/137] Add User class with role and permission-based authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for authentication/authorization with: - Stable UUID id for database keys, email as human identifier - Account status (active/suspended/pending) - Role-based auth with role-to-permission mappings - Direct permissions in resource:action format - Methods: hasRole(), hasPermission(), can(), effectivePermissions() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .beads/issues.jsonl | 3 + express/user.ts | 188 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 .beads/issues.jsonl create mode 100644 express/user.ts diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 0000000..49047b5 --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,3 @@ +{"id":"diachron-2vh","title":"Add unit testing to golang programs","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:31:41.281891462-06:00","created_by":"mw","updated_at":"2026-01-03T12:31:41.281891462-06:00"} +{"id":"diachron-64w","title":"Add unit testing to express backend","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:31:30.439206099-06:00","created_by":"mw","updated_at":"2026-01-03T12:31:30.439206099-06:00"} +{"id":"diachron-fzd","title":"Add generic 'user' functionality","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:35:53.73213604-06:00","created_by":"mw","updated_at":"2026-01-03T12:35:53.73213604-06:00"} diff --git a/express/user.ts b/express/user.ts new file mode 100644 index 0000000..078a344 --- /dev/null +++ b/express/user.ts @@ -0,0 +1,188 @@ +// user.ts +// +// User model for authentication and authorization. +// +// Design notes: +// - `id` is the stable internal identifier (UUID when database-backed) +// - `email` is the primary human-facing identifier +// - Roles provide coarse-grained authorization (admin, editor, etc.) +// - Permissions provide fine-grained authorization (posts:create, etc.) +// - Users can have both roles (which grant permissions) and direct permissions + +import { z } from "zod"; + +// Branded type for user IDs to prevent accidental mixing with other strings +export type UserId = string & { readonly __brand: "UserId" }; + +// User account status +const userStatusParser = z.enum(["active", "suspended", "pending"]); +export type UserStatus = z.infer; + +// Role - simple string identifier +const roleParser = z.string().min(1); +export type Role = z.infer; + +// Permission format: "resource:action" e.g. "posts:create", "users:delete" +const permissionParser = z.string().regex(/^[a-z_]+:[a-z_]+$/, { + message: "Permission must be in format 'resource:action'", +}); +export type Permission = z.infer; + +// Core user data schema - this is what gets stored/serialized +const userDataParser = z.object({ + id: z.string().min(1), + email: z.email(), + displayName: z.string().optional(), + status: userStatusParser, + roles: z.array(roleParser), + permissions: z.array(permissionParser), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +export type UserData = z.infer; + +// Role-to-permission mappings +// In a real system this might be database-driven or configurable +type RolePermissionMap = Map; + +const defaultRolePermissions: RolePermissionMap = new Map([ + ["admin", ["users:read", "users:create", "users:update", "users:delete"]], + ["user", ["users:read"]], +]); + +export class User { + private readonly data: UserData; + private rolePermissions: RolePermissionMap; + + constructor(data: UserData, rolePermissions?: RolePermissionMap) { + this.data = userDataParser.parse(data); + 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 + get id(): UserId { + return this.data.id as UserId; + } + + get email(): string { + return this.data.email; + } + + get displayName(): string | undefined { + return this.data.displayName; + } + + // Status + get status(): UserStatus { + return this.data.status; + } + + isActive(): boolean { + return this.data.status === "active"; + } + + // Roles + get roles(): readonly Role[] { + return this.data.roles; + } + + hasRole(role: Role): boolean { + return this.data.roles.includes(role); + } + + hasAnyRole(roles: Role[]): boolean { + return roles.some((role) => this.hasRole(role)); + } + + hasAllRoles(roles: Role[]): boolean { + return roles.every((role) => this.hasRole(role)); + } + + // Permissions + get permissions(): readonly Permission[] { + return this.data.permissions; + } + + // Get all permissions: direct + role-derived + effectivePermissions(): Set { + const perms = new Set(this.data.permissions); + + for (const role of this.data.roles) { + const rolePerms = this.rolePermissions.get(role); + if (rolePerms) { + for (const p of rolePerms) { + perms.add(p); + } + } + } + + return perms; + } + + // Check if user has a specific permission (direct or via role) + hasPermission(permission: Permission): boolean { + // Check direct permissions first + if (this.data.permissions.includes(permission)) { + return true; + } + + // Check role-derived permissions + for (const role of this.data.roles) { + const rolePerms = this.rolePermissions.get(role); + if (rolePerms?.includes(permission)) { + return true; + } + } + + return false; + } + + // Convenience method: can user perform action on resource? + can(action: string, resource: string): boolean { + const permission = `${resource}:${action}` as Permission; + return this.hasPermission(permission); + } + + // Timestamps + get createdAt(): Date { + return this.data.createdAt; + } + + get updatedAt(): Date { + return this.data.updatedAt; + } + + // Serialization - returns plain object for storage/transmission + toJSON(): UserData { + return { ...this.data }; + } +} + +// For representing "no user" in contexts where user is optional +export const AnonymousUser = Symbol("AnonymousUser"); +export type MaybeUser = User | typeof AnonymousUser; From c246e0384f7d7a539c0ef6abfd92494ddc56d723 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 3 Jan 2026 13:59:02 -0600 Subject: [PATCH 085/137] Add authentication system with session-based auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements full auth flows with opaque tokens (not JWT) for easy revocation: - Login/logout with cookie or bearer token support - Registration with email verification - Password reset with one-time tokens - scrypt password hashing (no external deps) New files in express/auth/: - token.ts: 256-bit token generation, SHA-256 hashing - password.ts: scrypt hashing with timing-safe verification - types.ts: Session schemas, token types, input validation - store.ts: AuthStore interface + InMemoryAuthStore - service.ts: AuthService with all auth operations - routes.ts: 6 auth endpoints Modified: - types.ts: Added user field to Call, requireAuth/requirePermission helpers - app.ts: JSON body parsing, populates call.user, handles auth errors - services.ts: Added services.auth - routes.ts: Includes auth routes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- express/app.ts | 41 +++++-- express/auth/index.ts | 10 ++ express/auth/password.ts | 67 +++++++++++ express/auth/routes.ts | 231 ++++++++++++++++++++++++++++++++++++ express/auth/service.ts | 248 +++++++++++++++++++++++++++++++++++++++ express/auth/store.ts | 160 +++++++++++++++++++++++++ express/auth/token.ts | 40 +++++++ express/auth/types.ts | 64 ++++++++++ express/routes.ts | 2 + express/services.ts | 6 + express/types.ts | 39 ++++++ 11 files changed, 898 insertions(+), 10 deletions(-) create mode 100644 express/auth/index.ts create mode 100644 express/auth/password.ts create mode 100644 express/auth/routes.ts create mode 100644 express/auth/service.ts create mode 100644 express/auth/store.ts create mode 100644 express/auth/token.ts create mode 100644 express/auth/types.ts diff --git a/express/app.ts b/express/app.ts index 6117023..8349013 100644 --- a/express/app.ts +++ b/express/app.ts @@ -10,6 +10,8 @@ import { routes } from "./routes"; import { services } from "./services"; // import { URLPattern } from 'node:url'; import { + AuthenticationRequired, + AuthorizationDenied, type Call, type InternalHandler, type Method, @@ -22,6 +24,9 @@ import { const app = express(); +// Parse JSON request bodies +app.use(express.json()); + services.logging.log({ source: "logging", text: ["1"] }); const processedRoutes: { [K in Method]: ProcessedRoute[] } = { GET: [], @@ -52,26 +57,42 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => { } console.log("request.originalUrl", request.originalUrl); - console.log("beavis"); - // const p = new URL(request.originalUrl); - // const path = p.pathname; - - // console.log("p, path", p, path) - - console.log("ok"); + // Authenticate the request + const user = await services.auth.validateRequest(request); const req: Call = { pattern: route.path, - // path, path: request.originalUrl, method, parameters: { one: 1, two: 2 }, request, + user, }; - const retval = await route.handler(req); - return retval; + try { + const retval = await route.handler(req); + return retval; + } catch (error) { + // Handle authentication errors + if (error instanceof AuthenticationRequired) { + return { + code: httpCodes.clientErrors.Unauthorized, + contentType: contentTypes.application.json, + result: JSON.stringify({ + error: "Authentication required", + }), + }; + } + if (error instanceof AuthorizationDenied) { + return { + code: httpCodes.clientErrors.Forbidden, + contentType: contentTypes.application.json, + result: JSON.stringify({ error: "Access denied" }), + }; + } + throw error; + } }; for (const [_idx, method] of methodList.entries()) { diff --git a/express/auth/index.ts b/express/auth/index.ts new file mode 100644 index 0000000..383fd8e --- /dev/null +++ b/express/auth/index.ts @@ -0,0 +1,10 @@ +// index.ts +// +// Barrel export for auth module. + +export { hashPassword, verifyPassword } from "./password"; +export { authRoutes } from "./routes"; +export { AuthService } from "./service"; +export { type AuthStore, InMemoryAuthStore } from "./store"; +export { generateToken, hashToken, SESSION_COOKIE_NAME } from "./token"; +export * from "./types"; diff --git a/express/auth/password.ts b/express/auth/password.ts new file mode 100644 index 0000000..6e7b93e --- /dev/null +++ b/express/auth/password.ts @@ -0,0 +1,67 @@ +// password.ts +// +// Password hashing using Node.js scrypt (no external dependencies). +// Format: $scrypt$N$r$p$salt$hash (all base64) + +import { + randomBytes, + type ScryptOptions, + scrypt, + timingSafeEqual, +} from "node:crypto"; + +// Configuration +const SALT_LENGTH = 32; +const KEY_LENGTH = 64; +const SCRYPT_PARAMS: ScryptOptions = { + N: 16384, // CPU/memory cost parameter (2^14) + r: 8, // Block size + p: 1, // Parallelization +}; + +// Promisified scrypt with options support +function scryptAsync( + password: string, + salt: Buffer, + keylen: number, + options: ScryptOptions, +): Promise { + return new Promise((resolve, reject) => { + scrypt(password, salt, keylen, options, (err, derivedKey) => { + if (err) reject(err); + else resolve(derivedKey); + }); + }); +} + +async function hashPassword(password: string): Promise { + const salt = randomBytes(SALT_LENGTH); + const hash = await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS); + + const { N, r, p } = SCRYPT_PARAMS; + return `$scrypt$${N}$${r}$${p}$${salt.toString("base64")}$${hash.toString("base64")}`; +} + +async function verifyPassword( + password: string, + stored: string, +): Promise { + const parts = stored.split("$"); + if (parts[1] !== "scrypt" || parts.length !== 7) { + throw new Error("Invalid password hash format"); + } + + const [, , nStr, rStr, pStr, saltB64, hashB64] = parts; + const salt = Buffer.from(saltB64, "base64"); + const storedHash = Buffer.from(hashB64, "base64"); + + const computedHash = await scryptAsync(password, salt, storedHash.length, { + N: parseInt(nStr, 10), + r: parseInt(rStr, 10), + p: parseInt(pStr, 10), + }); + + return timingSafeEqual(storedHash, computedHash); +} + +export { hashPassword, verifyPassword }; diff --git a/express/auth/routes.ts b/express/auth/routes.ts new file mode 100644 index 0000000..ae93c8f --- /dev/null +++ b/express/auth/routes.ts @@ -0,0 +1,231 @@ +// routes.ts +// +// Authentication route handlers. + +import { z } from "zod"; +import { contentTypes } from "../content-types"; +import { httpCodes } from "../http-codes"; +import { services } from "../services"; +import type { Call, Result, Route } from "../types"; +import { + forgotPasswordInputParser, + loginInputParser, + registerInputParser, + resetPasswordInputParser, +} from "./types"; + +// Helper for JSON responses +const jsonResponse = ( + code: (typeof httpCodes.success)[keyof typeof httpCodes.success], + data: object, +): Result => ({ + code, + contentType: contentTypes.application.json, + result: JSON.stringify(data), +}); + +const errorResponse = ( + code: (typeof httpCodes.clientErrors)[keyof typeof httpCodes.clientErrors], + error: string, +): Result => ({ + code, + contentType: contentTypes.application.json, + result: JSON.stringify({ error }), +}); + +// POST /auth/login +const loginHandler = async (call: Call): Promise => { + try { + const body = call.request.body; + const { email, password } = loginInputParser.parse(body); + + const result = await services.auth.login(email, password, "cookie", { + userAgent: call.request.get("User-Agent"), + ipAddress: call.request.ip, + }); + + if (!result.success) { + return errorResponse( + httpCodes.clientErrors.Unauthorized, + result.error, + ); + } + + return jsonResponse(httpCodes.success.OK, { + token: result.token, + user: { + id: result.user.id, + email: result.user.email, + displayName: result.user.displayName, + }, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Invalid input", + ); + } + throw error; + } +}; + +// POST /auth/logout +const logoutHandler = async (call: Call): Promise => { + const token = services.auth.extractToken(call.request); + if (token) { + await services.auth.logout(token); + } + + return jsonResponse(httpCodes.success.OK, { message: "Logged out" }); +}; + +// POST /auth/register +const registerHandler = async (call: Call): Promise => { + try { + const body = call.request.body; + const { email, password, displayName } = + registerInputParser.parse(body); + + const result = await services.auth.register( + email, + password, + displayName, + ); + + if (!result.success) { + return errorResponse(httpCodes.clientErrors.Conflict, result.error); + } + + // TODO: Send verification email with result.verificationToken + // For now, log it for development + console.log( + `[AUTH] Verification token for ${email}: ${result.verificationToken}`, + ); + + return jsonResponse(httpCodes.success.Created, { + message: + "Registration successful. Please check your email to verify your account.", + user: { + id: result.user.id, + email: result.user.email, + }, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Invalid input", + ); + } + throw error; + } +}; + +// POST /auth/forgot-password +const forgotPasswordHandler = async (call: Call): Promise => { + try { + const body = call.request.body; + const { email } = forgotPasswordInputParser.parse(body); + + const result = await services.auth.createPasswordResetToken(email); + + // Always return success (don't reveal if email exists) + if (result) { + // TODO: Send password reset email + console.log( + `[AUTH] Password reset token for ${email}: ${result.token}`, + ); + } + + return jsonResponse(httpCodes.success.OK, { + message: + "If an account exists with that email, a password reset link has been sent.", + }); + } catch (error) { + if (error instanceof z.ZodError) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Invalid input", + ); + } + throw error; + } +}; + +// POST /auth/reset-password +const resetPasswordHandler = async (call: Call): Promise => { + try { + const body = call.request.body; + const { token, password } = resetPasswordInputParser.parse(body); + + const result = await services.auth.resetPassword(token, password); + + if (!result.success) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + result.error, + ); + } + + return jsonResponse(httpCodes.success.OK, { + message: + "Password has been reset. You can now log in with your new password.", + }); + } catch (error) { + if (error instanceof z.ZodError) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Invalid input", + ); + } + throw error; + } +}; + +// GET /auth/verify-email?token=xxx +const verifyEmailHandler = async (call: Call): Promise => { + const url = new URL(call.path, "http://localhost"); + const token = url.searchParams.get("token"); + + if (!token) { + return errorResponse( + httpCodes.clientErrors.BadRequest, + "Missing token", + ); + } + + const result = await services.auth.verifyEmail(token); + + if (!result.success) { + return errorResponse(httpCodes.clientErrors.BadRequest, result.error); + } + + return jsonResponse(httpCodes.success.OK, { + message: "Email verified successfully. You can now log in.", + }); +}; + +// Export routes +const authRoutes: Route[] = [ + { path: "/auth/login", methods: ["POST"], handler: loginHandler }, + { path: "/auth/logout", methods: ["POST"], handler: logoutHandler }, + { path: "/auth/register", methods: ["POST"], handler: registerHandler }, + { + path: "/auth/forgot-password", + methods: ["POST"], + handler: forgotPasswordHandler, + }, + { + path: "/auth/reset-password", + methods: ["POST"], + handler: resetPasswordHandler, + }, + { + path: "/auth/verify-email", + methods: ["GET"], + handler: verifyEmailHandler, + }, +]; + +export { authRoutes }; diff --git a/express/auth/service.ts b/express/auth/service.ts new file mode 100644 index 0000000..7064173 --- /dev/null +++ b/express/auth/service.ts @@ -0,0 +1,248 @@ +// service.ts +// +// Core authentication service providing login, logout, registration, +// password reset, and email verification. + +import type { Request as ExpressRequest } from "express"; +import { AnonymousUser, type MaybeUser, type User, type UserId } from "../user"; +import { hashPassword, verifyPassword } from "./password"; +import type { AuthStore } from "./store"; +import { + hashToken, + parseAuthorizationHeader, + SESSION_COOKIE_NAME, +} from "./token"; +import { type TokenId, tokenLifetimes } from "./types"; + +type LoginResult = + | { success: true; token: string; user: User } + | { success: false; error: string }; + +type RegisterResult = + | { success: true; user: User; verificationToken: string } + | { success: false; error: string }; + +type SimpleResult = { success: true } | { success: false; error: string }; + +export class AuthService { + constructor(private store: AuthStore) {} + + // === Login === + + async login( + email: string, + password: string, + authMethod: "cookie" | "bearer", + metadata?: { userAgent?: string; ipAddress?: string }, + ): Promise { + const user = await this.store.getUserByEmail(email); + if (!user) { + return { success: false, error: "Invalid credentials" }; + } + + if (!user.isActive()) { + return { success: false, error: "Account is not active" }; + } + + const passwordHash = await this.store.getUserPasswordHash(user.id); + if (!passwordHash) { + return { success: false, error: "Invalid credentials" }; + } + + const valid = await verifyPassword(password, passwordHash); + if (!valid) { + return { success: false, error: "Invalid credentials" }; + } + + const { token } = await this.store.createSession({ + userId: user.id, + tokenType: "session", + authMethod, + expiresAt: new Date(Date.now() + tokenLifetimes.session), + userAgent: metadata?.userAgent, + ipAddress: metadata?.ipAddress, + }); + + return { success: true, token, user }; + } + + // === Session Validation === + + async validateRequest(request: ExpressRequest): Promise { + // Try cookie first (for web requests) + let token = this.extractCookieToken(request); + + // Fall back to Authorization header (for API requests) + if (!token) { + token = parseAuthorizationHeader(request.get("Authorization")); + } + + if (!token) { + return AnonymousUser; + } + + return this.validateToken(token); + } + + async validateToken(token: string): Promise { + const tokenId = hashToken(token) as TokenId; + const session = await this.store.getSession(tokenId); + + if (!session) { + return AnonymousUser; + } + + if (session.tokenType !== "session") { + return AnonymousUser; + } + + const user = await this.store.getUserById(session.userId as UserId); + if (!user || !user.isActive()) { + return AnonymousUser; + } + + // Update last used (fire and forget) + this.store.updateLastUsed(tokenId).catch(() => {}); + + return user; + } + + private extractCookieToken(request: ExpressRequest): string | null { + const cookies = request.get("Cookie"); + if (!cookies) return null; + + for (const cookie of cookies.split(";")) { + const [name, ...valueParts] = cookie.trim().split("="); + if (name === SESSION_COOKIE_NAME) { + return valueParts.join("="); // Handle = in token value + } + } + return null; + } + + // === Logout === + + async logout(token: string): Promise { + const tokenId = hashToken(token) as TokenId; + await this.store.deleteSession(tokenId); + } + + async logoutAllSessions(userId: UserId): Promise { + return this.store.deleteUserSessions(userId); + } + + // === Registration === + + async register( + email: string, + password: string, + displayName?: string, + ): Promise { + const existing = await this.store.getUserByEmail(email); + if (existing) { + return { success: false, error: "Email already registered" }; + } + + const passwordHash = await hashPassword(password); + const user = await this.store.createUser({ + email, + passwordHash, + displayName, + }); + + // Create email verification token + const { token: verificationToken } = await this.store.createSession({ + userId: user.id, + tokenType: "email_verify", + authMethod: "bearer", + expiresAt: new Date(Date.now() + tokenLifetimes.email_verify), + }); + + return { success: true, user, verificationToken }; + } + + // === Email Verification === + + async verifyEmail(token: string): Promise { + const tokenId = hashToken(token) as TokenId; + const session = await this.store.getSession(tokenId); + + if (!session || session.tokenType !== "email_verify") { + return { + success: false, + error: "Invalid or expired verification token", + }; + } + + if (session.isUsed) { + return { success: false, error: "Token already used" }; + } + + await this.store.updateUserEmailVerified(session.userId as UserId); + await this.store.deleteSession(tokenId); + + return { success: true }; + } + + // === Password Reset === + + async createPasswordResetToken( + email: string, + ): Promise<{ token: string } | null> { + const user = await this.store.getUserByEmail(email); + if (!user) { + // Don't reveal whether email exists + return null; + } + + const { token } = await this.store.createSession({ + userId: user.id, + tokenType: "password_reset", + authMethod: "bearer", + expiresAt: new Date(Date.now() + tokenLifetimes.password_reset), + }); + + return { token }; + } + + async resetPassword( + token: string, + newPassword: string, + ): Promise { + const tokenId = hashToken(token) as TokenId; + const session = await this.store.getSession(tokenId); + + if (!session || session.tokenType !== "password_reset") { + return { success: false, error: "Invalid or expired reset token" }; + } + + if (session.isUsed) { + return { success: false, error: "Token already used" }; + } + + const passwordHash = await hashPassword(newPassword); + await this.store.setUserPassword( + session.userId as UserId, + passwordHash, + ); + + // Invalidate all existing sessions (security: password changed) + await this.store.deleteUserSessions(session.userId as UserId); + + // Delete the reset token + await this.store.deleteSession(tokenId); + + return { success: true }; + } + + // === Token Extraction Helper (for routes) === + + extractToken(request: ExpressRequest): string | null { + // Try Authorization header first + const token = parseAuthorizationHeader(request.get("Authorization")); + if (token) return token; + + // Try cookie + return this.extractCookieToken(request); + } +} diff --git a/express/auth/store.ts b/express/auth/store.ts new file mode 100644 index 0000000..f92dba1 --- /dev/null +++ b/express/auth/store.ts @@ -0,0 +1,160 @@ +// store.ts +// +// Authentication storage interface and in-memory implementation. +// The interface allows easy migration to PostgreSQL later. + +import { User, type UserId } from "../user"; +import { generateToken, hashToken } from "./token"; +import type { AuthMethod, SessionData, TokenId, TokenType } from "./types"; + +// Data for creating a new session (tokenId generated internally) +export type CreateSessionData = { + userId: string; + tokenType: TokenType; + authMethod: AuthMethod; + expiresAt: Date; + userAgent?: string; + ipAddress?: string; +}; + +// Data for creating a new user +export type CreateUserData = { + email: string; + passwordHash: string; + displayName?: string; +}; + +// Abstract interface for auth storage - implement for PostgreSQL later +export interface AuthStore { + // Session operations + createSession( + data: CreateSessionData, + ): Promise<{ token: string; session: SessionData }>; + getSession(tokenId: TokenId): Promise; + updateLastUsed(tokenId: TokenId): Promise; + deleteSession(tokenId: TokenId): Promise; + deleteUserSessions(userId: UserId): Promise; + + // User operations + getUserByEmail(email: string): Promise; + getUserById(userId: UserId): Promise; + createUser(data: CreateUserData): Promise; + getUserPasswordHash(userId: UserId): Promise; + setUserPassword(userId: UserId, passwordHash: string): Promise; + updateUserEmailVerified(userId: UserId): Promise; +} + +// In-memory implementation for development +export class InMemoryAuthStore implements AuthStore { + private sessions: Map = new Map(); + private users: Map = new Map(); + private usersByEmail: Map = new Map(); + private passwordHashes: Map = new Map(); + private emailVerified: Map = new Map(); + + async createSession( + data: CreateSessionData, + ): Promise<{ token: string; session: SessionData }> { + const token = generateToken(); + const tokenId = hashToken(token); + + const session: SessionData = { + tokenId, + userId: data.userId, + tokenType: data.tokenType, + authMethod: data.authMethod, + createdAt: new Date(), + expiresAt: data.expiresAt, + userAgent: data.userAgent, + ipAddress: data.ipAddress, + }; + + this.sessions.set(tokenId, session); + return { token, session }; + } + + async getSession(tokenId: TokenId): Promise { + const session = this.sessions.get(tokenId); + if (!session) return null; + + // Check expiration + if (new Date() > session.expiresAt) { + this.sessions.delete(tokenId); + return null; + } + + return session; + } + + async updateLastUsed(tokenId: TokenId): Promise { + const session = this.sessions.get(tokenId); + if (session) { + session.lastUsedAt = new Date(); + } + } + + async deleteSession(tokenId: TokenId): Promise { + this.sessions.delete(tokenId); + } + + async deleteUserSessions(userId: UserId): Promise { + let count = 0; + for (const [tokenId, session] of this.sessions) { + if (session.userId === userId) { + this.sessions.delete(tokenId); + count++; + } + } + return count; + } + + async getUserByEmail(email: string): Promise { + const userId = this.usersByEmail.get(email.toLowerCase()); + if (!userId) return null; + return this.users.get(userId) ?? null; + } + + async getUserById(userId: UserId): Promise { + return this.users.get(userId) ?? null; + } + + async createUser(data: CreateUserData): Promise { + const user = User.create(data.email, { + displayName: data.displayName, + status: "pending", // Pending until email verified + }); + + this.users.set(user.id, user); + this.usersByEmail.set(data.email.toLowerCase(), user.id); + this.passwordHashes.set(user.id, data.passwordHash); + this.emailVerified.set(user.id, false); + + return user; + } + + async getUserPasswordHash(userId: UserId): Promise { + return this.passwordHashes.get(userId) ?? null; + } + + async setUserPassword(userId: UserId, passwordHash: string): Promise { + this.passwordHashes.set(userId, passwordHash); + } + + async updateUserEmailVerified(userId: UserId): Promise { + this.emailVerified.set(userId, true); + + // Update user status to active + const user = this.users.get(userId); + if (user) { + // Create new user with active status + const updatedUser = User.create(user.email, { + id: user.id, + displayName: user.displayName, + status: "active", + roles: [...user.roles], + permissions: [...user.permissions], + }); + this.users.set(userId, updatedUser); + } + } +} diff --git a/express/auth/token.ts b/express/auth/token.ts new file mode 100644 index 0000000..babe227 --- /dev/null +++ b/express/auth/token.ts @@ -0,0 +1,40 @@ +// token.ts +// +// Token generation and hashing utilities for authentication. +// Raw tokens are never stored - only their SHA-256 hashes. + +import { createHash, randomBytes } from "node:crypto"; + +const TOKEN_BYTES = 32; // 256 bits of entropy + +// Generate a cryptographically secure random token +function generateToken(): string { + return randomBytes(TOKEN_BYTES).toString("base64url"); +} + +// Hash token for storage (never store raw tokens) +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +// Parse token from Authorization header +function parseAuthorizationHeader(header: string | undefined): string | null { + if (!header) return null; + + const parts = header.split(" "); + if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") { + return null; + } + + return parts[1]; +} + +// Cookie name for web sessions +const SESSION_COOKIE_NAME = "diachron_session"; + +export { + generateToken, + hashToken, + parseAuthorizationHeader, + SESSION_COOKIE_NAME, +}; diff --git a/express/auth/types.ts b/express/auth/types.ts new file mode 100644 index 0000000..bcc90f6 --- /dev/null +++ b/express/auth/types.ts @@ -0,0 +1,64 @@ +// types.ts +// +// Authentication types and Zod schemas. + +import { z } from "zod"; + +// Branded type for token IDs (the hash, not the raw token) +export type TokenId = string & { readonly __brand: "TokenId" }; + +// Token types for different purposes +export const tokenTypeParser = z.enum([ + "session", + "password_reset", + "email_verify", +]); +export type TokenType = z.infer; + +// Authentication method - how the token was delivered +export const authMethodParser = z.enum(["cookie", "bearer"]); +export type AuthMethod = z.infer; + +// Session data schema - what gets stored +export const sessionDataParser = z.object({ + tokenId: z.string().min(1), + userId: z.string().min(1), + tokenType: tokenTypeParser, + authMethod: authMethodParser, + createdAt: z.coerce.date(), + expiresAt: z.coerce.date(), + lastUsedAt: z.coerce.date().optional(), + userAgent: z.string().optional(), + ipAddress: z.string().optional(), + isUsed: z.boolean().optional(), // For one-time tokens +}); + +export type SessionData = z.infer; + +// Input validation schemas for auth endpoints +export const loginInputParser = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +export const registerInputParser = z.object({ + email: z.string().email(), + password: z.string().min(8), + displayName: z.string().optional(), +}); + +export const forgotPasswordInputParser = z.object({ + email: z.string().email(), +}); + +export const resetPasswordInputParser = z.object({ + token: z.string().min(1), + password: z.string().min(8), +}); + +// Token lifetimes in milliseconds +export const tokenLifetimes: Record = { + session: 30 * 24 * 60 * 60 * 1000, // 30 days + password_reset: 1 * 60 * 60 * 1000, // 1 hour + email_verify: 24 * 60 * 60 * 1000, // 24 hours +}; diff --git a/express/routes.ts b/express/routes.ts index 65b9312..3ef0093 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -2,6 +2,7 @@ import nunjucks from "nunjucks"; import { DateTime } from "ts-luxon"; +import { authRoutes } from "./auth"; import { contentTypes } from "./content-types"; import { multiHandler } from "./handlers"; import { HttpCode, httpCodes } from "./http-codes"; @@ -22,6 +23,7 @@ const okText = (result: string): Result => { }; const routes: Route[] = [ + ...authRoutes, { path: "/slow", methods: ["GET"], diff --git a/express/services.ts b/express/services.ts index cc04dcd..e8e4457 100644 --- a/express/services.ts +++ b/express/services.ts @@ -1,5 +1,6 @@ // services.ts +import { AuthService, InMemoryAuthStore } from "./auth"; import { config } from "./config"; import { getLogs, log } from "./logging"; @@ -26,11 +27,16 @@ const misc = { }, }; +// Initialize auth with in-memory store +const authStore = new InMemoryAuthStore(); +const auth = new AuthService(authStore); + const services = { database, logging, misc, random, + auth, }; export { services }; diff --git a/express/types.ts b/express/types.ts index 2b6c79e..d595c48 100644 --- a/express/types.ts +++ b/express/types.ts @@ -10,6 +10,12 @@ import type { MatchFunction } from "path-to-regexp"; import { z } from "zod"; import { type ContentType, contentTypes } from "./content-types"; import { type HttpCode, httpCodes } from "./http-codes"; +import { + AnonymousUser, + type MaybeUser, + type Permission, + type User, +} from "./user"; const methodParser = z.union([ z.literal("GET"), @@ -32,6 +38,7 @@ export type Call = { method: Method; parameters: object; request: ExpressRequest; + user: MaybeUser; }; export type InternalHandler = (req: ExpressRequest) => Promise; @@ -56,4 +63,36 @@ export type Route = { interruptable?: boolean; }; +// Authentication error classes +export class AuthenticationRequired extends Error { + constructor() { + super("Authentication required"); + this.name = "AuthenticationRequired"; + } +} + +export class AuthorizationDenied extends Error { + constructor() { + super("Authorization denied"); + this.name = "AuthorizationDenied"; + } +} + +// Helper for handlers to require authentication +export function requireAuth(call: Call): User { + if (call.user === AnonymousUser) { + throw new AuthenticationRequired(); + } + return call.user; +} + +// Helper for handlers to require specific permission +export function requirePermission(call: Call, permission: Permission): User { + const user = requireAuth(call); + if (!user.hasPermission(permission)) { + throw new AuthorizationDenied(); + } + return user; +} + export { methodParser, massageMethod }; From 39cd93c81ec155656aa6bcc529eea4c8398318bb Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 3 Jan 2026 14:12:27 -0600 Subject: [PATCH 086/137] Move services.ts --- .beads/issues.jsonl | 1 + express/{services.ts => services/index.ts} | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) rename express/{services.ts => services/index.ts} (80%) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 49047b5..ea25142 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ {"id":"diachron-2vh","title":"Add unit testing to golang programs","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:31:41.281891462-06:00","created_by":"mw","updated_at":"2026-01-03T12:31:41.281891462-06:00"} {"id":"diachron-64w","title":"Add unit testing to express backend","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:31:30.439206099-06:00","created_by":"mw","updated_at":"2026-01-03T12:31:30.439206099-06:00"} {"id":"diachron-fzd","title":"Add generic 'user' functionality","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T12:35:53.73213604-06:00","created_by":"mw","updated_at":"2026-01-03T12:35:53.73213604-06:00"} +{"id":"diachron-ngx","title":"Teach the master and/or build process to send messages with notify-send when builds fail or succeed. Ideally this will be fairly generic.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-03T14:10:11.773218844-06:00","created_by":"mw","updated_at":"2026-01-03T14:10:11.773218844-06:00"} diff --git a/express/services.ts b/express/services/index.ts similarity index 80% rename from express/services.ts rename to express/services/index.ts index e8e4457..0d22f51 100644 --- a/express/services.ts +++ b/express/services/index.ts @@ -1,8 +1,8 @@ // services.ts -import { AuthService, InMemoryAuthStore } from "./auth"; -import { config } from "./config"; -import { getLogs, log } from "./logging"; +import { AuthService, InMemoryAuthStore } from "../auth"; +import { config } from "../config"; +import { getLogs, log } from "../logging"; //const database = Client({ From c926f15aabcdac602bb1a63eaac688f0accf7042 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 3 Jan 2026 14:24:53 -0600 Subject: [PATCH 087/137] Fix circular dependency breaking ncc bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't export authRoutes from barrel file to break the cycle: services.ts → auth/index.ts → auth/routes.ts → services.ts Import authRoutes directly from ./auth/routes instead. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- express/auth/index.ts | 5 ++++- express/routes.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/express/auth/index.ts b/express/auth/index.ts index 383fd8e..35f06a9 100644 --- a/express/auth/index.ts +++ b/express/auth/index.ts @@ -1,9 +1,12 @@ // index.ts // // Barrel export for auth module. +// +// NOTE: authRoutes is NOT exported here to avoid circular dependency: +// services.ts → auth/index.ts → auth/routes.ts → services.ts +// Import authRoutes directly from "./auth/routes" instead. export { hashPassword, verifyPassword } from "./password"; -export { authRoutes } from "./routes"; export { AuthService } from "./service"; export { type AuthStore, InMemoryAuthStore } from "./store"; export { generateToken, hashToken, SESSION_COOKIE_NAME } from "./token"; diff --git a/express/routes.ts b/express/routes.ts index 3ef0093..26028b2 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -2,7 +2,7 @@ import nunjucks from "nunjucks"; import { DateTime } from "ts-luxon"; -import { authRoutes } from "./auth"; +import { authRoutes } from "./auth/routes"; import { contentTypes } from "./content-types"; import { multiHandler } from "./handlers"; import { HttpCode, httpCodes } from "./http-codes"; From e136c07928e0006098f27aa74a68303c097b418f Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 3 Jan 2026 17:06:54 -0600 Subject: [PATCH 088/137] Add some stub user stuff --- express/routes.ts | 23 +++++++++++++++++++++++ express/services/index.ts | 11 ++++++++++- express/user.ts | 11 +++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/express/routes.ts b/express/routes.ts index 26028b2..3e31e7f 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -59,6 +59,29 @@ const routes: Route[] = [ }; }, }, + { + path: "/whoami", + methods: ["GET"], + handler: async (_call: Call): Promise => { + const me = services.session.getUser(); + const template = ` + + + + {{ me }} + + +`; + + const result = nunjucks.renderString(template, { me }); + + return { + code: httpCodes.success.OK, + contentType: contentTypes.text.html, + result, + }; + }, + }, { path: "/ok", methods: ["GET", "POST", "PUT"], diff --git a/express/services/index.ts b/express/services/index.ts index 0d22f51..ec28bcb 100644 --- a/express/services/index.ts +++ b/express/services/index.ts @@ -3,6 +3,7 @@ import { AuthService, InMemoryAuthStore } from "../auth"; import { config } from "../config"; import { getLogs, log } from "../logging"; +import { AnonymousUser, anonymousUser, type User } from "../user"; //const database = Client({ @@ -27,16 +28,24 @@ const misc = { }, }; +const session = { + getUser: (): User => { + return anonymousUser; + }, +}; + // Initialize auth with in-memory store const authStore = new InMemoryAuthStore(); const auth = new AuthService(authStore); +// Keep this asciibetically sorted const services = { + auth, database, logging, misc, random, - auth, + session, }; export { services }; diff --git a/express/user.ts b/express/user.ts index 078a344..6c32646 100644 --- a/express/user.ts +++ b/express/user.ts @@ -181,8 +181,19 @@ export class User { toJSON(): UserData { return { ...this.data }; } + + toString(): string { + return `User(id ${this.id})`; + } } // For representing "no user" in contexts where user is optional export const AnonymousUser = Symbol("AnonymousUser"); + +export const anonymousUser = User.create("anonymous@example.com", { + id: "-1", + displayName: "Anonymous User", + // FIXME: set createdAt and updatedAt to start of epoch +}); + export type MaybeUser = User | typeof AnonymousUser; From 34ec5be7ec30d9c16fd025b2b55d13be750534b4 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 3 Jan 2026 17:20:49 -0600 Subject: [PATCH 089/137] Pull in kysely and pg deps --- express/package.json | 5 +- express/pnpm-lock.yaml | 131 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/express/package.json b/express/package.json index 8a884d1..156b976 100644 --- a/express/package.json +++ b/express/package.json @@ -18,9 +18,11 @@ "@types/nunjucks": "^3.2.6", "@vercel/ncc": "^0.38.4", "express": "^5.1.0", + "kysely": "^0.28.9", "nodemon": "^3.1.11", "nunjucks": "^3.2.4", "path-to-regexp": "^8.3.0", + "pg": "^8.16.3", "prettier": "^3.6.2", "ts-luxon": "^6.2.0", "ts-node": "^10.9.2", @@ -46,6 +48,7 @@ }, "devDependencies": { "@biomejs/biome": "2.3.10", - "@types/express": "^5.0.5" + "@types/express": "^5.0.5", + "@types/pg": "^8.16.0" } } diff --git a/express/pnpm-lock.yaml b/express/pnpm-lock.yaml index c633781..d615bb3 100644 --- a/express/pnpm-lock.yaml +++ b/express/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: express: specifier: ^5.1.0 version: 5.1.0 + kysely: + specifier: ^0.28.9 + version: 0.28.9 nodemon: specifier: ^3.1.11 version: 3.1.11 @@ -32,6 +35,9 @@ importers: path-to-regexp: specifier: ^8.3.0 version: 8.3.0 + pg: + specifier: ^8.16.3 + version: 8.16.3 prettier: specifier: ^3.6.2 version: 3.6.2 @@ -57,6 +63,9 @@ importers: '@types/express': specifier: ^5.0.5 version: 5.0.5 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 packages: @@ -380,6 +389,9 @@ packages: '@types/nunjucks@3.2.6': resolution: {integrity: sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} @@ -645,6 +657,10 @@ packages: engines: {node: '>=6'} hasBin: true + kysely@0.28.9: + resolution: {integrity: sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==} + engines: {node: '>=20.0.0'} + make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -715,6 +731,40 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pg-cloudflare@1.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + + pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.10.1: + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -722,6 +772,22 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prettier@3.6.2: resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} @@ -799,6 +865,10 @@ packages: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -875,6 +945,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -1118,6 +1192,12 @@ snapshots: '@types/nunjucks@3.2.6': {} + '@types/pg@8.16.0': + dependencies: + '@types/node': 24.10.1 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} @@ -1421,6 +1501,8 @@ snapshots: jsesc@3.1.0: {} + kysely@0.28.9: {} + make-error@1.3.6: {} math-intrinsics@1.1.0: {} @@ -1480,10 +1562,55 @@ snapshots: path-to-regexp@8.3.0: {} + pg-cloudflare@1.2.7: + optional: true + + pg-connection-string@2.9.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.10.1(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.7 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prettier@3.6.2: {} proxy-addr@2.0.7: @@ -1587,6 +1714,8 @@ snapshots: dependencies: semver: 7.7.3 + split2@4.2.0: {} + statuses@2.0.1: {} statuses@2.0.2: {} @@ -1650,6 +1779,8 @@ snapshots: wrappy@1.0.2: {} + xtend@4.0.2: {} + yn@3.1.1: {} zod@4.1.12: {} From e9ccf6d75709fc5311c734ab93ef71e51459bd78 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 4 Jan 2026 09:43:20 -0600 Subject: [PATCH 090/137] Add PostgreSQL database layer with Kysely and migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add database.ts with connection pool, Kysely query builder, and migration runner - Create migrations for users and sessions tables (0001, 0002) - Implement PostgresAuthStore to replace InMemoryAuthStore - Wire up database service in services/index.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- express/database.ts | 385 +++++++++++++++++++++++++++ express/migrations/0001_users.sql | 21 ++ express/migrations/0002_sessions.sql | 24 ++ express/services/index.ts | 17 +- 4 files changed, 439 insertions(+), 8 deletions(-) create mode 100644 express/database.ts create mode 100644 express/migrations/0001_users.sql create mode 100644 express/migrations/0002_sessions.sql diff --git a/express/database.ts b/express/database.ts new file mode 100644 index 0000000..24f2861 --- /dev/null +++ b/express/database.ts @@ -0,0 +1,385 @@ +// database.ts +// PostgreSQL database access with Kysely query builder and simple migrations + +import * as fs from "fs"; +import * as path from "path"; +import { Generated, Kysely, PostgresDialect, Selectable, sql } from "kysely"; +import { Pool } from "pg"; +import type { + AuthStore, + CreateSessionData, + CreateUserData, +} from "./auth/store"; +import { generateToken, hashToken } from "./auth/token"; +import type { SessionData, TokenId } from "./auth/types"; +import { User, type UserId } from "./user"; + +// Connection configuration +const connectionConfig = { + host: "localhost", + port: 5432, + user: "diachron", + password: "diachron", + database: "diachron", +}; + +// Database schema types for Kysely +// Generated marks columns with database defaults (optional on insert) +interface UsersTable { + id: string; + email: string; + password_hash: string; + display_name: string | null; + status: Generated; + roles: Generated; + permissions: Generated; + email_verified: Generated; + created_at: Generated; + updated_at: Generated; +} + +interface SessionsTable { + token_id: string; + user_id: string; + token_type: string; + auth_method: string; + created_at: Generated; + expires_at: Date; + last_used_at: Date | null; + user_agent: string | null; + ip_address: string | null; + is_used: Generated; +} + +interface Database { + users: UsersTable; + sessions: SessionsTable; +} + +// Create the connection pool +const pool = new Pool(connectionConfig); + +// Create the Kysely instance +const db = new Kysely({ + dialect: new PostgresDialect({ pool }), +}); + +// Raw pool access for when you need it +const rawPool = pool; + +// Execute raw SQL (for when Kysely doesn't fit) +async function raw( + query: string, + params: unknown[] = [], +): Promise { + const result = await pool.query(query, params); + return result.rows as T[]; +} + +// ============================================================================ +// Migrations +// ============================================================================ + +// Migration file naming convention: +// NNNN_description.sql +// e.g., 0001_initial.sql, 0002_add_users.sql +// +// Migrations directory: express/migrations/ + +const MIGRATIONS_DIR = path.join(__dirname, "migrations"); +const MIGRATIONS_TABLE = "_migrations"; + +interface MigrationRecord { + id: number; + name: string; + applied_at: Date; +} + +// Ensure migrations table exists +async function ensureMigrationsTable(): Promise { + await pool.query(` + CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); +} + +// Get list of applied migrations +async function getAppliedMigrations(): Promise { + const result = await pool.query( + `SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name`, + ); + return result.rows.map((r) => r.name); +} + +// Get pending migration files +function getMigrationFiles(): string[] { + if (!fs.existsSync(MIGRATIONS_DIR)) { + return []; + } + return fs + .readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith(".sql")) + .filter((f) => /^\d{4}_/.test(f)) + .sort(); +} + +// Run a single migration +async function runMigration(filename: string): Promise { + const filepath = path.join(MIGRATIONS_DIR, filename); + const content = fs.readFileSync(filepath, "utf-8"); + + // Run migration in a transaction + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(content); + await client.query( + `INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES ($1)`, + [filename], + ); + await client.query("COMMIT"); + console.log(`Applied migration: ${filename}`); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} + +// Run all pending migrations +async function migrate(): Promise { + await ensureMigrationsTable(); + + const applied = new Set(await getAppliedMigrations()); + const files = getMigrationFiles(); + const pending = files.filter((f) => !applied.has(f)); + + if (pending.length === 0) { + console.log("No pending migrations"); + return; + } + + console.log(`Running ${pending.length} migration(s)...`); + for (const file of pending) { + await runMigration(file); + } + console.log("Migrations complete"); +} + +// List migration status +async function migrationStatus(): Promise<{ + applied: string[]; + pending: string[]; +}> { + await ensureMigrationsTable(); + const applied = new Set(await getAppliedMigrations()); + const files = getMigrationFiles(); + return { + applied: files.filter((f) => applied.has(f)), + pending: files.filter((f) => !applied.has(f)), + }; +} + +// ============================================================================ +// PostgresAuthStore - Database-backed authentication storage +// ============================================================================ + +class PostgresAuthStore implements AuthStore { + // Session operations + + async createSession( + data: CreateSessionData, + ): Promise<{ token: string; session: SessionData }> { + const token = generateToken(); + const tokenId = hashToken(token); + + const row = await db + .insertInto("sessions") + .values({ + token_id: tokenId, + user_id: data.userId, + token_type: data.tokenType, + auth_method: data.authMethod, + expires_at: data.expiresAt, + user_agent: data.userAgent ?? null, + ip_address: data.ipAddress ?? null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + const session: SessionData = { + tokenId: row.token_id, + userId: row.user_id, + tokenType: row.token_type as SessionData["tokenType"], + authMethod: row.auth_method as SessionData["authMethod"], + createdAt: row.created_at, + expiresAt: row.expires_at, + lastUsedAt: row.last_used_at ?? undefined, + userAgent: row.user_agent ?? undefined, + ipAddress: row.ip_address ?? undefined, + isUsed: row.is_used ?? undefined, + }; + + return { token, session }; + } + + async getSession(tokenId: TokenId): Promise { + const row = await db + .selectFrom("sessions") + .selectAll() + .where("token_id", "=", tokenId) + .where("expires_at", ">", new Date()) + .executeTakeFirst(); + + if (!row) return null; + + return { + tokenId: row.token_id, + userId: row.user_id, + tokenType: row.token_type as SessionData["tokenType"], + authMethod: row.auth_method as SessionData["authMethod"], + createdAt: row.created_at, + expiresAt: row.expires_at, + lastUsedAt: row.last_used_at ?? undefined, + userAgent: row.user_agent ?? undefined, + ipAddress: row.ip_address ?? undefined, + isUsed: row.is_used ?? undefined, + }; + } + + async updateLastUsed(tokenId: TokenId): Promise { + await db + .updateTable("sessions") + .set({ last_used_at: new Date() }) + .where("token_id", "=", tokenId) + .execute(); + } + + async deleteSession(tokenId: TokenId): Promise { + await db + .deleteFrom("sessions") + .where("token_id", "=", tokenId) + .execute(); + } + + async deleteUserSessions(userId: UserId): Promise { + const result = await db + .deleteFrom("sessions") + .where("user_id", "=", userId) + .executeTakeFirst(); + + return Number(result.numDeletedRows); + } + + // User operations + + async getUserByEmail(email: string): Promise { + const row = await db + .selectFrom("users") + .selectAll() + .where(sql`LOWER(email)`, "=", email.toLowerCase()) + .executeTakeFirst(); + + if (!row) return null; + return this.rowToUser(row); + } + + async getUserById(userId: UserId): Promise { + const row = await db + .selectFrom("users") + .selectAll() + .where("id", "=", userId) + .executeTakeFirst(); + + if (!row) return null; + return this.rowToUser(row); + } + + async createUser(data: CreateUserData): Promise { + const id = crypto.randomUUID(); + const now = new Date(); + + const row = await db + .insertInto("users") + .values({ + id, + email: data.email, + password_hash: data.passwordHash, + display_name: data.displayName ?? null, + status: "pending", + roles: [], + permissions: [], + email_verified: false, + created_at: now, + updated_at: now, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return this.rowToUser(row); + } + + async getUserPasswordHash(userId: UserId): Promise { + const row = await db + .selectFrom("users") + .select("password_hash") + .where("id", "=", userId) + .executeTakeFirst(); + + return row?.password_hash ?? null; + } + + async setUserPassword(userId: UserId, passwordHash: string): Promise { + await db + .updateTable("users") + .set({ password_hash: passwordHash, updated_at: new Date() }) + .where("id", "=", userId) + .execute(); + } + + async updateUserEmailVerified(userId: UserId): Promise { + await db + .updateTable("users") + .set({ + email_verified: true, + status: "active", + updated_at: new Date(), + }) + .where("id", "=", userId) + .execute(); + } + + // Helper to convert database row to User object + private rowToUser(row: Selectable): User { + return new User({ + id: row.id, + email: row.email, + displayName: row.display_name ?? undefined, + status: row.status as "active" | "suspended" | "pending", + roles: row.roles, + permissions: row.permissions, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); + } +} + +// ============================================================================ +// Exports +// ============================================================================ + +export { + db, + raw, + rawPool, + pool, + migrate, + migrationStatus, + connectionConfig, + PostgresAuthStore, + type Database, +}; diff --git a/express/migrations/0001_users.sql b/express/migrations/0001_users.sql new file mode 100644 index 0000000..8aa8a5d --- /dev/null +++ b/express/migrations/0001_users.sql @@ -0,0 +1,21 @@ +-- 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); diff --git a/express/migrations/0002_sessions.sql b/express/migrations/0002_sessions.sql new file mode 100644 index 0000000..2708f8f --- /dev/null +++ b/express/migrations/0002_sessions.sql @@ -0,0 +1,24 @@ +-- 0002_sessions.sql +-- Create sessions table for auth tokens + +CREATE TABLE sessions ( + token_id TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_type TEXT NOT NULL, + auth_method TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ, + user_agent TEXT, + ip_address TEXT, + is_used BOOLEAN DEFAULT FALSE +); + +-- Index for user session lookups (logout all, etc.) +CREATE INDEX sessions_user_id_idx ON sessions (user_id); + +-- Index for expiration cleanup +CREATE INDEX sessions_expires_at_idx ON sessions (expires_at); + +-- Index for token type filtering +CREATE INDEX sessions_token_type_idx ON sessions (token_type); diff --git a/express/services/index.ts b/express/services/index.ts index ec28bcb..2e854e5 100644 --- a/express/services/index.ts +++ b/express/services/index.ts @@ -1,15 +1,16 @@ // services.ts -import { AuthService, InMemoryAuthStore } from "../auth"; +import { AuthService } from "../auth"; import { config } from "../config"; +import { db, migrate, migrationStatus, PostgresAuthStore } from "../database"; import { getLogs, log } from "../logging"; import { AnonymousUser, anonymousUser, type User } from "../user"; -//const database = Client({ - -//}) - -const database = {}; +const database = { + db, + migrate, + migrationStatus, +}; const logging = { log, @@ -34,8 +35,8 @@ const session = { }, }; -// Initialize auth with in-memory store -const authStore = new InMemoryAuthStore(); +// Initialize auth with PostgreSQL store +const authStore = new PostgresAuthStore(); const auth = new AuthService(authStore); // Keep this asciibetically sorted From ad6d40520648457290bf97578741eae1b03cd7d5 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 4 Jan 2026 09:50:05 -0600 Subject: [PATCH 091/137] Add session data to Call type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthService.validateRequest now returns AuthResult with both user and session - Call type includes session: SessionData | null - Handlers can access session metadata (createdAt, authMethod, etc.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- express/app.ts | 5 +++-- express/auth/index.ts | 2 +- express/auth/service.ts | 21 +++++++++++++-------- express/types.ts | 2 ++ 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/express/app.ts b/express/app.ts index 8349013..14da28e 100644 --- a/express/app.ts +++ b/express/app.ts @@ -59,7 +59,7 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => { console.log("request.originalUrl", request.originalUrl); // Authenticate the request - const user = await services.auth.validateRequest(request); + const auth = await services.auth.validateRequest(request); const req: Call = { pattern: route.path, @@ -67,7 +67,8 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => { method, parameters: { one: 1, two: 2 }, request, - user, + user: auth.user, + session: auth.session, }; try { diff --git a/express/auth/index.ts b/express/auth/index.ts index 35f06a9..d68d1fc 100644 --- a/express/auth/index.ts +++ b/express/auth/index.ts @@ -7,7 +7,7 @@ // Import authRoutes directly from "./auth/routes" instead. export { hashPassword, verifyPassword } from "./password"; -export { AuthService } from "./service"; +export { AuthService, type AuthResult } from "./service"; export { type AuthStore, InMemoryAuthStore } from "./store"; export { generateToken, hashToken, SESSION_COOKIE_NAME } from "./token"; export * from "./types"; diff --git a/express/auth/service.ts b/express/auth/service.ts index 7064173..f5deb29 100644 --- a/express/auth/service.ts +++ b/express/auth/service.ts @@ -12,7 +12,7 @@ import { parseAuthorizationHeader, SESSION_COOKIE_NAME, } from "./token"; -import { type TokenId, tokenLifetimes } from "./types"; +import { type SessionData, type TokenId, tokenLifetimes } from "./types"; type LoginResult = | { success: true; token: string; user: User } @@ -24,6 +24,11 @@ type RegisterResult = type SimpleResult = { success: true } | { success: false; error: string }; +// Result of validating a request/token - contains both user and session +export type AuthResult = + | { authenticated: true; user: User; session: SessionData } + | { authenticated: false; user: typeof AnonymousUser; session: null }; + export class AuthService { constructor(private store: AuthStore) {} @@ -68,7 +73,7 @@ export class AuthService { // === Session Validation === - async validateRequest(request: ExpressRequest): Promise { + async validateRequest(request: ExpressRequest): Promise { // Try cookie first (for web requests) let token = this.extractCookieToken(request); @@ -78,33 +83,33 @@ export class AuthService { } if (!token) { - return AnonymousUser; + return { authenticated: false, user: AnonymousUser, session: null }; } return this.validateToken(token); } - async validateToken(token: string): Promise { + async validateToken(token: string): Promise { const tokenId = hashToken(token) as TokenId; const session = await this.store.getSession(tokenId); if (!session) { - return AnonymousUser; + return { authenticated: false, user: AnonymousUser, session: null }; } if (session.tokenType !== "session") { - return AnonymousUser; + return { authenticated: false, user: AnonymousUser, session: null }; } const user = await this.store.getUserById(session.userId as UserId); if (!user || !user.isActive()) { - return AnonymousUser; + return { authenticated: false, user: AnonymousUser, session: null }; } // Update last used (fire and forget) this.store.updateLastUsed(tokenId).catch(() => {}); - return user; + return { authenticated: true, user, session }; } private extractCookieToken(request: ExpressRequest): string | null { diff --git a/express/types.ts b/express/types.ts index d595c48..a1ed455 100644 --- a/express/types.ts +++ b/express/types.ts @@ -8,6 +8,7 @@ import { } from "express"; import type { MatchFunction } from "path-to-regexp"; import { z } from "zod"; +import type { SessionData } from "./auth/types"; import { type ContentType, contentTypes } from "./content-types"; import { type HttpCode, httpCodes } from "./http-codes"; import { @@ -39,6 +40,7 @@ export type Call = { parameters: object; request: ExpressRequest; user: MaybeUser; + session: SessionData | null; }; export type InternalHandler = (req: ExpressRequest) => Promise; From 74d75d08dddb0929d03ae34c07c1d85c0f72cd01 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 4 Jan 2026 15:22:27 -0600 Subject: [PATCH 092/137] Add Session class to provide getUser() on call.session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps SessionData and user into a Session class that handlers can use via call.session.getUser() instead of accessing services directly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- express/app.ts | 3 ++- express/auth/index.ts | 9 ++++++++- express/auth/types.ts | 32 ++++++++++++++++++++++++++++++++ express/routes.ts | 24 ++++++++++++++++++++---- express/types.ts | 4 ++-- 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/express/app.ts b/express/app.ts index 14da28e..1967cd3 100644 --- a/express/app.ts +++ b/express/app.ts @@ -3,6 +3,7 @@ import express, { type Response as ExpressResponse, } from "express"; import { match } from "path-to-regexp"; +import { Session } from "./auth"; import { cli } from "./cli"; import { contentTypes } from "./content-types"; import { httpCodes } from "./http-codes"; @@ -68,7 +69,7 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => { parameters: { one: 1, two: 2 }, request, user: auth.user, - session: auth.session, + session: new Session(auth.session, auth.user), }; try { diff --git a/express/auth/index.ts b/express/auth/index.ts index d68d1fc..c81666f 100644 --- a/express/auth/index.ts +++ b/express/auth/index.ts @@ -10,4 +10,11 @@ export { hashPassword, verifyPassword } from "./password"; export { AuthService, type AuthResult } from "./service"; export { type AuthStore, InMemoryAuthStore } from "./store"; export { generateToken, hashToken, SESSION_COOKIE_NAME } from "./token"; -export * from "./types"; +export { + type AuthMethod, + type SessionData, + type TokenId, + type TokenType, + Session, + tokenLifetimes, +} from "./types"; diff --git a/express/auth/types.ts b/express/auth/types.ts index bcc90f6..c8112dd 100644 --- a/express/auth/types.ts +++ b/express/auth/types.ts @@ -62,3 +62,35 @@ export const tokenLifetimes: Record = { password_reset: 1 * 60 * 60 * 1000, // 1 hour email_verify: 24 * 60 * 60 * 1000, // 24 hours }; + +// Import here to avoid circular dependency at module load time +import { AnonymousUser, type MaybeUser } from "../user"; + +// Session wrapper class providing a consistent interface for handlers. +// Always present on Call (never null), but may represent an anonymous session. +export class Session { + constructor( + private readonly data: SessionData | null, + private readonly user: MaybeUser, + ) {} + + getUser(): MaybeUser { + return this.user; + } + + getData(): SessionData | null { + return this.data; + } + + isAuthenticated(): boolean { + return this.user !== AnonymousUser; + } + + get tokenId(): string | undefined { + return this.data?.tokenId; + } + + get userId(): string | undefined { + return this.data?.userId; + } +} diff --git a/express/routes.ts b/express/routes.ts index 3e31e7f..7d2504a 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -51,19 +51,35 @@ const routes: Route[] = [ return ret; }; + const rrr = lr(routes) + + const template=` + + + +
    + {% for route in rrr %} +
  • {{ route }}
  • + {% endfor %} +
+ + +` +const result = nunjucks.renderString(template,{rrr}) + const listing = lr(routes).join(", "); return { code, - result: listing + "\n", - contentType: contentTypes.text.plain, + result, + contentType: contentTypes.text.html, }; }, }, { path: "/whoami", methods: ["GET"], - handler: async (_call: Call): Promise => { - const me = services.session.getUser(); + handler: async (call: Call): Promise => { + const me = call.session.getUser(); const template = ` diff --git a/express/types.ts b/express/types.ts index a1ed455..ce9b917 100644 --- a/express/types.ts +++ b/express/types.ts @@ -8,7 +8,7 @@ import { } from "express"; import type { MatchFunction } from "path-to-regexp"; import { z } from "zod"; -import type { SessionData } from "./auth/types"; +import type { Session } from "./auth/types"; import { type ContentType, contentTypes } from "./content-types"; import { type HttpCode, httpCodes } from "./http-codes"; import { @@ -40,7 +40,7 @@ export type Call = { parameters: object; request: ExpressRequest; user: MaybeUser; - session: SessionData | null; + session: Session; }; export type InternalHandler = (req: ExpressRequest) => Promise; From 661def8a5c085b7f5729a6a5d69e8f39cac5f148 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 4 Jan 2026 15:24:29 -0600 Subject: [PATCH 093/137] Refmt --- express/auth/index.ts | 4 ++-- express/database.ts | 8 +++++++- express/routes.ts | 10 +++++----- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/express/auth/index.ts b/express/auth/index.ts index c81666f..cd23528 100644 --- a/express/auth/index.ts +++ b/express/auth/index.ts @@ -7,14 +7,14 @@ // Import authRoutes directly from "./auth/routes" instead. export { hashPassword, verifyPassword } from "./password"; -export { AuthService, type AuthResult } from "./service"; +export { type AuthResult, AuthService } from "./service"; export { type AuthStore, InMemoryAuthStore } from "./store"; export { generateToken, hashToken, SESSION_COOKIE_NAME } from "./token"; export { type AuthMethod, + Session, type SessionData, type TokenId, type TokenType, - Session, tokenLifetimes, } from "./types"; diff --git a/express/database.ts b/express/database.ts index 24f2861..095bb99 100644 --- a/express/database.ts +++ b/express/database.ts @@ -2,8 +2,14 @@ // PostgreSQL database access with Kysely query builder and simple migrations import * as fs from "fs"; +import { + type Generated, + Kysely, + PostgresDialect, + type Selectable, + sql, +} from "kysely"; import * as path from "path"; -import { Generated, Kysely, PostgresDialect, Selectable, sql } from "kysely"; import { Pool } from "pg"; import type { AuthStore, diff --git a/express/routes.ts b/express/routes.ts index 7d2504a..7ad3978 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -51,9 +51,9 @@ const routes: Route[] = [ return ret; }; - const rrr = lr(routes) - - const template=` + const rrr = lr(routes); + + const template = ` @@ -64,8 +64,8 @@ const routes: Route[] = [ -` -const result = nunjucks.renderString(template,{rrr}) +`; + const result = nunjucks.renderString(template, { rrr }); const listing = lr(routes).join(", "); return { From 17ea6ba02da680fe4b609a096896834f298b8e04 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Fri, 9 Jan 2026 11:44:09 -0600 Subject: [PATCH 094/137] Consider block stmts without braces to be errors --- express/biome.jsonc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/express/biome.jsonc b/express/biome.jsonc index 2c997ab..43ce613 100644 --- a/express/biome.jsonc +++ b/express/biome.jsonc @@ -17,7 +17,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "style": { + "useBlockStatements": "error" + } } }, "javascript": { From 6c0895de07d53584b43e78028674142149c584b9 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 10 Jan 2026 08:51:20 -0600 Subject: [PATCH 095/137] Fix formatting --- express/auth/password.ts | 7 +++++-- express/auth/service.ts | 8 ++++++-- express/auth/store.ts | 8 ++++++-- express/auth/token.ts | 4 +++- express/database.ts | 12 +++++++++--- 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/express/auth/password.ts b/express/auth/password.ts index 6e7b93e..d1e9c51 100644 --- a/express/auth/password.ts +++ b/express/auth/password.ts @@ -28,8 +28,11 @@ function scryptAsync( ): Promise { return new Promise((resolve, reject) => { scrypt(password, salt, keylen, options, (err, derivedKey) => { - if (err) reject(err); - else resolve(derivedKey); + if (err) { + reject(err); + } else { + resolve(derivedKey); + } }); }); } diff --git a/express/auth/service.ts b/express/auth/service.ts index f5deb29..c168b6f 100644 --- a/express/auth/service.ts +++ b/express/auth/service.ts @@ -114,7 +114,9 @@ export class AuthService { private extractCookieToken(request: ExpressRequest): string | null { const cookies = request.get("Cookie"); - if (!cookies) return null; + if (!cookies) { + return null; + } for (const cookie of cookies.split(";")) { const [name, ...valueParts] = cookie.trim().split("="); @@ -245,7 +247,9 @@ export class AuthService { extractToken(request: ExpressRequest): string | null { // Try Authorization header first const token = parseAuthorizationHeader(request.get("Authorization")); - if (token) return token; + if (token) { + return token; + } // Try cookie return this.extractCookieToken(request); diff --git a/express/auth/store.ts b/express/auth/store.ts index f92dba1..f3f2684 100644 --- a/express/auth/store.ts +++ b/express/auth/store.ts @@ -75,7 +75,9 @@ export class InMemoryAuthStore implements AuthStore { async getSession(tokenId: TokenId): Promise { const session = this.sessions.get(tokenId); - if (!session) return null; + if (!session) { + return null; + } // Check expiration if (new Date() > session.expiresAt) { @@ -110,7 +112,9 @@ export class InMemoryAuthStore implements AuthStore { async getUserByEmail(email: string): Promise { const userId = this.usersByEmail.get(email.toLowerCase()); - if (!userId) return null; + if (!userId) { + return null; + } return this.users.get(userId) ?? null; } diff --git a/express/auth/token.ts b/express/auth/token.ts index babe227..15a203e 100644 --- a/express/auth/token.ts +++ b/express/auth/token.ts @@ -19,7 +19,9 @@ function hashToken(token: string): string { // Parse token from Authorization header function parseAuthorizationHeader(header: string | undefined): string | null { - if (!header) return null; + if (!header) { + return null; + } const parts = header.split(" "); if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") { diff --git a/express/database.ts b/express/database.ts index 095bb99..69f057c 100644 --- a/express/database.ts +++ b/express/database.ts @@ -241,7 +241,9 @@ class PostgresAuthStore implements AuthStore { .where("expires_at", ">", new Date()) .executeTakeFirst(); - if (!row) return null; + if (!row) { + return null; + } return { tokenId: row.token_id, @@ -290,7 +292,9 @@ class PostgresAuthStore implements AuthStore { .where(sql`LOWER(email)`, "=", email.toLowerCase()) .executeTakeFirst(); - if (!row) return null; + if (!row) { + return null; + } return this.rowToUser(row); } @@ -301,7 +305,9 @@ class PostgresAuthStore implements AuthStore { .where("id", "=", userId) .executeTakeFirst(); - if (!row) return null; + if (!row) { + return null; + } return this.rowToUser(row); } From c7b8cd33dafcdf0274d883b03a356b295cb2c454 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 10 Jan 2026 08:54:34 -0600 Subject: [PATCH 096/137] Clean up imports --- express/auth/service.ts | 2 +- express/content-types.ts | 2 -- express/database.ts | 4 ++-- express/http-codes.ts | 2 -- express/routes.ts | 4 ++-- express/services/index.ts | 3 +-- express/types.ts | 9 +++------ 7 files changed, 9 insertions(+), 17 deletions(-) diff --git a/express/auth/service.ts b/express/auth/service.ts index c168b6f..e8d9025 100644 --- a/express/auth/service.ts +++ b/express/auth/service.ts @@ -4,7 +4,7 @@ // password reset, and email verification. import type { Request as ExpressRequest } from "express"; -import { AnonymousUser, type MaybeUser, type User, type UserId } from "../user"; +import { AnonymousUser, type User, type UserId } from "../user"; import { hashPassword, verifyPassword } from "./password"; import type { AuthStore } from "./store"; import { diff --git a/express/content-types.ts b/express/content-types.ts index eca7c72..219ce49 100644 --- a/express/content-types.ts +++ b/express/content-types.ts @@ -1,5 +1,3 @@ -import { Extensible } from "./interfaces"; - export type ContentType = string; // tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865 diff --git a/express/database.ts b/express/database.ts index 69f057c..a5b317d 100644 --- a/express/database.ts +++ b/express/database.ts @@ -1,7 +1,8 @@ // database.ts // PostgreSQL database access with Kysely query builder and simple migrations -import * as fs from "fs"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { type Generated, Kysely, @@ -9,7 +10,6 @@ import { type Selectable, sql, } from "kysely"; -import * as path from "path"; import { Pool } from "pg"; import type { AuthStore, diff --git a/express/http-codes.ts b/express/http-codes.ts index 2c12891..912aa8a 100644 --- a/express/http-codes.ts +++ b/express/http-codes.ts @@ -1,5 +1,3 @@ -import { Extensible } from "./interfaces"; - export type HttpCode = { code: number; name: string; diff --git a/express/routes.ts b/express/routes.ts index 7ad3978..63f3060 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -5,9 +5,9 @@ import { DateTime } from "ts-luxon"; import { authRoutes } from "./auth/routes"; import { contentTypes } from "./content-types"; import { multiHandler } from "./handlers"; -import { HttpCode, httpCodes } from "./http-codes"; +import { httpCodes } from "./http-codes"; import { services } from "./services"; -import { type Call, ProcessedRoute, type Result, type Route } from "./types"; +import type { Call, Result, Route } from "./types"; // FIXME: Obviously put this somewhere else const okText = (result: string): Result => { diff --git a/express/services/index.ts b/express/services/index.ts index 2e854e5..b7daf88 100644 --- a/express/services/index.ts +++ b/express/services/index.ts @@ -1,10 +1,9 @@ // services.ts import { AuthService } from "../auth"; -import { config } from "../config"; import { db, migrate, migrationStatus, PostgresAuthStore } from "../database"; import { getLogs, log } from "../logging"; -import { AnonymousUser, anonymousUser, type User } from "../user"; +import { anonymousUser, type User } from "../user"; const database = { db, diff --git a/express/types.ts b/express/types.ts index ce9b917..528eabc 100644 --- a/express/types.ts +++ b/express/types.ts @@ -2,15 +2,12 @@ // FIXME: split this up into types used by app developers and types internal // to the framework. -import { - type Request as ExpressRequest, - Response as ExpressResponse, -} from "express"; +import type { Request as ExpressRequest } from "express"; import type { MatchFunction } from "path-to-regexp"; import { z } from "zod"; import type { Session } from "./auth/types"; -import { type ContentType, contentTypes } from "./content-types"; -import { type HttpCode, httpCodes } from "./http-codes"; +import type { ContentType } from "./content-types"; +import type { HttpCode } from "./http-codes"; import { AnonymousUser, type MaybeUser, From 49dc0e3fe08ddfd369ae83b7f359ed3f6bb932a3 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 10 Jan 2026 08:54:51 -0600 Subject: [PATCH 097/137] Mark several unused vars as such --- express/app.ts | 2 +- express/logging.ts | 4 ++-- express/routes.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/express/app.ts b/express/app.ts index 1967cd3..592a1cd 100644 --- a/express/app.ts +++ b/express/app.ts @@ -37,7 +37,7 @@ const processedRoutes: { [K in Method]: ProcessedRoute[] } = { DELETE: [], }; -function isPromise(value: T | Promise): value is Promise { +function _isPromise(value: T | Promise): value is Promise { return typeof (value as any)?.then === "function"; } diff --git a/express/logging.ts b/express/logging.ts index 629cfbc..5bf417d 100644 --- a/express/logging.ts +++ b/express/logging.ts @@ -15,8 +15,8 @@ type Message = { text: AtLeastOne; }; -const m1: Message = { timestamp: 123, source: "logging", text: ["foo"] }; -const m2: Message = { +const _m1: Message = { timestamp: 123, source: "logging", text: ["foo"] }; +const _m2: Message = { timestamp: 321, source: "diagnostic", text: ["ok", "whatever"], diff --git a/express/routes.ts b/express/routes.ts index 63f3060..dcb7214 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -41,7 +41,7 @@ const routes: Route[] = [ { path: "/list", methods: ["GET"], - handler: async (call: Call): Promise => { + handler: async (_call: Call): Promise => { const code = httpCodes.success.OK; const lr = (rr: Route[]) => { const ret = rr.map((r: Route) => { @@ -67,7 +67,7 @@ const routes: Route[] = [ `; const result = nunjucks.renderString(template, { rrr }); - const listing = lr(routes).join(", "); + const _listing = lr(routes).join(", "); return { code, result, From 241d3e799e1bcebe815760e65f811ace4344991b Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 10 Jan 2026 08:55:00 -0600 Subject: [PATCH 098/137] Use less ambiguous funcion --- express/cli.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/express/cli.ts b/express/cli.ts index e161360..b9dbed4 100644 --- a/express/cli.ts +++ b/express/cli.ts @@ -30,7 +30,7 @@ function parseListenAddress(listen: string | undefined): { if (lastColon === -1) { // Just a port number const port = parseInt(listen, 10); - if (isNaN(port)) { + if (Number.isNaN(port)) { throw new Error(`Invalid listen address: ${listen}`); } return { host: defaultHost, port }; @@ -39,7 +39,7 @@ function parseListenAddress(listen: string | undefined): { const host = listen.slice(0, lastColon); const port = parseInt(listen.slice(lastColon + 1), 10); - if (isNaN(port)) { + if (Number.isNaN(port)) { throw new Error(`Invalid port in listen address: ${listen}`); } From 8cd4b42cc6c558f20863418e2b614202d9249e12 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 10 Jan 2026 09:05:05 -0600 Subject: [PATCH 099/137] Add scripts to run migrations and to connect to the db --- express/migrate.ts | 44 +++++++++++++++++++++++++++++++++++++++++ framework/cmd.d/db | 9 +++++++++ framework/cmd.d/migrate | 9 +++++++++ 3 files changed, 62 insertions(+) create mode 100644 express/migrate.ts create mode 100755 framework/cmd.d/db create mode 100755 framework/cmd.d/migrate diff --git a/express/migrate.ts b/express/migrate.ts new file mode 100644 index 0000000..5d20333 --- /dev/null +++ b/express/migrate.ts @@ -0,0 +1,44 @@ +// migrate.ts +// CLI script for running database migrations + +import { migrate, migrationStatus, pool } from "./database"; + +async function main(): Promise { + const command = process.argv[2] || "run"; + + try { + switch (command) { + case "run": + await migrate(); + break; + + case "status": + const status = await migrationStatus(); + console.log("Applied migrations:"); + for (const name of status.applied) { + console.log(` ✓ ${name}`); + } + if (status.pending.length > 0) { + console.log("\nPending migrations:"); + for (const name of status.pending) { + console.log(` • ${name}`); + } + } else { + console.log("\nNo pending migrations"); + } + break; + + default: + console.error(`Unknown command: ${command}`); + console.error("Usage: migrate [run|status]"); + process.exit(1); + } + } finally { + await pool.end(); + } +} + +main().catch((err) => { + console.error("Migration failed:", err); + process.exit(1); +}); diff --git a/framework/cmd.d/db b/framework/cmd.d/db new file mode 100755 index 0000000..dd947c0 --- /dev/null +++ b/framework/cmd.d/db @@ -0,0 +1,9 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$DIR/../.." + +# FIXME: don't hard code this of course +PGPASSWORD=diachron psql -U diachron -h localhost diachron \ No newline at end of file diff --git a/framework/cmd.d/migrate b/framework/cmd.d/migrate new file mode 100755 index 0000000..93edf74 --- /dev/null +++ b/framework/cmd.d/migrate @@ -0,0 +1,9 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$DIR/../.." + +cd "$ROOT/express" +"$DIR"/tsx migrate.ts "$@" From b235a6be9a1c0cbd73b180a85f13443e28391496 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 10 Jan 2026 13:05:39 -0600 Subject: [PATCH 100/137] Add block for declared var --- express/migrate.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/express/migrate.ts b/express/migrate.ts index 5d20333..1d32d07 100644 --- a/express/migrate.ts +++ b/express/migrate.ts @@ -12,7 +12,7 @@ async function main(): Promise { await migrate(); break; - case "status": + case "status": { const status = await migrationStatus(); console.log("Applied migrations:"); for (const name of status.applied) { @@ -27,6 +27,7 @@ async function main(): Promise { console.log("\nNo pending migrations"); } break; + } default: console.error(`Unknown command: ${command}`); From df2d4eea3f0eed71c1504dcd2ff6da0547320cfd Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 10 Jan 2026 13:37:39 -0600 Subject: [PATCH 101/137] Add initial way to get info about execution context --- express/execution-context-schema.ts | 11 +++++++++ express/execution-context.spec.ts | 35 +++++++++++++++++++++++++++++ express/execution-context.ts | 5 +++++ master/master | 2 ++ 4 files changed, 53 insertions(+) create mode 100644 express/execution-context-schema.ts create mode 100644 express/execution-context.spec.ts create mode 100644 express/execution-context.ts diff --git a/express/execution-context-schema.ts b/express/execution-context-schema.ts new file mode 100644 index 0000000..d191be3 --- /dev/null +++ b/express/execution-context-schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const executionContextSchema = z.object({ + diachron_root: z.string(), +}); + +export type ExecutionContext = z.infer; + +export function parseExecutionContext(env: Record): ExecutionContext { + return executionContextSchema.parse(env); +} diff --git a/express/execution-context.spec.ts b/express/execution-context.spec.ts new file mode 100644 index 0000000..3a6f005 --- /dev/null +++ b/express/execution-context.spec.ts @@ -0,0 +1,35 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { ZodError } from "zod"; + +import { parseExecutionContext, executionContextSchema } from "./execution-context-schema"; + +describe("parseExecutionContext", () => { + it("parses valid executionContext with diachron_root", () => { + const env = { diachron_root: "/some/path" }; + const result = parseExecutionContext(env); + assert.deepEqual(result, { diachron_root: "/some/path" }); + }); + + it("throws ZodError when diachron_root is missing", () => { + const env = {}; + assert.throws(() => parseExecutionContext(env), ZodError); + }); + + it("strips extra fields not in schema", () => { + const env = { + diachron_root: "/some/path", + EXTRA_VAR: "should be stripped", + }; + const result = parseExecutionContext(env); + assert.deepEqual(result, { diachron_root: "/some/path" }); + assert.equal("EXTRA_VAR" in result, false); + }); +}); + +describe("executionContextSchema", () => { + it("requires diachron_root to be a string", () => { + const result = executionContextSchema.safeParse({ diachron_root: 123 }); + assert.equal(result.success, false); + }); +}); diff --git a/express/execution-context.ts b/express/execution-context.ts new file mode 100644 index 0000000..67f23e0 --- /dev/null +++ b/express/execution-context.ts @@ -0,0 +1,5 @@ +import { parseExecutionContext } from "./execution-context-schema"; + +const executionContext = parseExecutionContext(process.env); + +export { executionContext }; diff --git a/master/master b/master/master index 2ffc270..85f88a5 100755 --- a/master/master +++ b/master/master @@ -4,4 +4,6 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$DIR" +export diachron_root="$DIR/.." + ./master-bin "$@" From 05eaf938faf1504be3e4bd52be90b35fd3db916d Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 10 Jan 2026 13:38:10 -0600 Subject: [PATCH 102/137] Add test command For now this just runs typescript tests. Eventually it'll do more than that. --- framework/cmd.d/test | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100755 framework/cmd.d/test diff --git a/framework/cmd.d/test b/framework/cmd.d/test new file mode 100755 index 0000000..eb2c630 --- /dev/null +++ b/framework/cmd.d/test @@ -0,0 +1,7 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"$DIR"/../shims/pnpm tsx --test "$@" From 9e3329fa58f3cc5edbc4840305f5d04a9798e579 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 10 Jan 2026 13:38:42 -0600 Subject: [PATCH 103/137] . --- framework/cmd.d/db | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/cmd.d/db b/framework/cmd.d/db index dd947c0..eef7f71 100755 --- a/framework/cmd.d/db +++ b/framework/cmd.d/db @@ -6,4 +6,4 @@ 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 \ No newline at end of file +PGPASSWORD=diachron psql -U diachron -h localhost diachron From 6e96c3345714ad62230236f8355c3b2fdd7bb420 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 10 Jan 2026 13:50:44 -0600 Subject: [PATCH 104/137] Add very basic support for finding and rendering templates --- express/basic/routes.ts | 18 +++++++++++++++++ express/routes.ts | 2 ++ express/util.ts | 36 ++++++++++++++++++++++++++++++++++ templates/basic/hello.html.njk | 11 +++++++++++ 4 files changed, 67 insertions(+) create mode 100644 express/basic/routes.ts create mode 100644 express/util.ts create mode 100644 templates/basic/hello.html.njk diff --git a/express/basic/routes.ts b/express/basic/routes.ts new file mode 100644 index 0000000..6df97b9 --- /dev/null +++ b/express/basic/routes.ts @@ -0,0 +1,18 @@ +import { DateTime } from "ts-luxon"; +import type { Call, Result, Route } from "../types"; +import { html, render } from "../util"; + +const routes: Record = { + hello: { + path: "/hello", + methods: ["GET"], + handler: async (call: Call): Promise => { + const now = DateTime.now(); + const c = await render("basic/hello", { now }); + + return html(c); + }, + }, +}; + +export { routes }; diff --git a/express/routes.ts b/express/routes.ts index dcb7214..2dc9585 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -3,6 +3,7 @@ import nunjucks from "nunjucks"; import { DateTime } from "ts-luxon"; import { authRoutes } from "./auth/routes"; +import { routes as basicRoutes } from "./basic/routes"; import { contentTypes } from "./content-types"; import { multiHandler } from "./handlers"; import { httpCodes } from "./http-codes"; @@ -24,6 +25,7 @@ const okText = (result: string): Result => { const routes: Route[] = [ ...authRoutes, + basicRoutes.hello, { path: "/slow", methods: ["GET"], diff --git a/express/util.ts b/express/util.ts new file mode 100644 index 0000000..3d141d8 --- /dev/null +++ b/express/util.ts @@ -0,0 +1,36 @@ +import { readFile } from "node:fs/promises"; +import nunjucks from "nunjucks"; +import { contentTypes } from "./content-types"; +import { executionContext } from "./execution-context"; +import { httpCodes } from "./http-codes"; +import type { Result } from "./types"; + +// FIXME: Handle the error here +const loadFile = async (path: string): Promise => { + // Specifying 'utf8' returns a string; otherwise, it returns a Buffer + const data = await readFile(path, "utf8"); + + return data; +}; + +const render = async (path: string, ctx: object): Promise => { + const fullPath = `${executionContext.diachron_root}/templates/${path}.html.njk`; + + const template = await loadFile(fullPath); + + const retval = nunjucks.renderString(template, ctx); + + return retval; +}; + +const html = (payload: string): Result => { + const retval: Result = { + code: httpCodes.success.OK, + result: payload, + contentType: contentTypes.text.html, + }; + + return retval; +}; + +export { render, html }; diff --git a/templates/basic/hello.html.njk b/templates/basic/hello.html.njk new file mode 100644 index 0000000..bf2f094 --- /dev/null +++ b/templates/basic/hello.html.njk @@ -0,0 +1,11 @@ + + + +

+ Hello. +

+

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

+ + From 47f6bee75f50ab0dd2108eccc73de4f3db9aed9c Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 10 Jan 2026 13:55:42 -0600 Subject: [PATCH 105/137] 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 --- framework/cmd.d/test | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/framework/cmd.d/test b/framework/cmd.d/test index eb2c630..5196eea 100755 --- a/framework/cmd.d/test +++ b/framework/cmd.d/test @@ -2,6 +2,14 @@ set -eu +shopt -s globstar nullglob + DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -"$DIR"/../shims/pnpm tsx --test "$@" +cd "$DIR/../../express" + +if [ $# -eq 0 ]; then + "$DIR"/../shims/pnpm tsx --test ./**/*.spec.ts ./**/*.test.ts +else + "$DIR"/../shims/pnpm tsx --test "$@" +fi From 7cecf5326df66bbbc463debdc4301dd8d2c9abed Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 10 Jan 2026 14:02:38 -0600 Subject: [PATCH 106/137] Make biome happier --- express/execution-context-schema.ts | 4 +++- express/execution-context.spec.ts | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/express/execution-context-schema.ts b/express/execution-context-schema.ts index d191be3..694c24c 100644 --- a/express/execution-context-schema.ts +++ b/express/execution-context-schema.ts @@ -6,6 +6,8 @@ export const executionContextSchema = z.object({ export type ExecutionContext = z.infer; -export function parseExecutionContext(env: Record): ExecutionContext { +export function parseExecutionContext( + env: Record, +): ExecutionContext { return executionContextSchema.parse(env); } diff --git a/express/execution-context.spec.ts b/express/execution-context.spec.ts index 3a6f005..30f4186 100644 --- a/express/execution-context.spec.ts +++ b/express/execution-context.spec.ts @@ -1,8 +1,11 @@ -import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { describe, it } from "node:test"; import { ZodError } from "zod"; -import { parseExecutionContext, executionContextSchema } from "./execution-context-schema"; +import { + executionContextSchema, + parseExecutionContext, +} from "./execution-context-schema"; describe("parseExecutionContext", () => { it("parses valid executionContext with diachron_root", () => { From 1c1eeddcbe9d09efe46931c3b3afe5e3a2662ca1 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 10:07:02 -0600 Subject: [PATCH 107/137] 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 --- express/app.ts | 23 ++++++++++--- express/basic/login.ts | 62 ++++++++++++++++++++++++++++++++++ express/basic/routes.ts | 13 ++++++- express/routes.ts | 2 ++ express/types.ts | 23 +++++++++++++ express/util.ts | 19 ++++++++--- templates/basic/login.html.njk | 55 ++++++++++++++++++++++++++++++ 7 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 express/basic/login.ts create mode 100644 templates/basic/login.html.njk diff --git a/express/app.ts b/express/app.ts index 592a1cd..2e4cc0b 100644 --- a/express/app.ts +++ b/express/app.ts @@ -15,6 +15,7 @@ import { AuthorizationDenied, type Call, type InternalHandler, + isRedirect, type Method, massageMethod, methodParser, @@ -25,8 +26,9 @@ import { const app = express(); -// Parse JSON request bodies +// Parse request bodies app.use(express.json()); +app.use(express.urlencoded({ extended: true })); services.logging.log({ source: "logging", text: ["1"] }); const processedRoutes: { [K in Method]: ProcessedRoute[] } = { @@ -111,8 +113,10 @@ async function handler( const method = await methodParser.parseAsync(req.method); const byMethod = processedRoutes[method]; + console.log("DEBUG: req.path =", JSON.stringify(req.path), "method =", method); for (const [_idx, pr] of byMethod.entries()) { - const match = pr.matcher(req.url); + const match = pr.matcher(req.path); + console.log("DEBUG: trying pattern, match result =", match); if (match) { console.log("match", match); const resp = await pr.handler(req); @@ -124,7 +128,7 @@ async function handler( const retval: Result = { code: httpCodes.clientErrors.NotFound, contentType: contentTypes.text.plain, - result: "not found", + result: "not found!", }; return retval; @@ -138,7 +142,18 @@ app.use(async (req: ExpressRequest, res: ExpressResponse) => { console.log(result); - res.status(code).send(result); + // Set any cookies from the result + if (result0.cookies) { + for (const cookie of result0.cookies) { + res.cookie(cookie.name, cookie.value, cookie.options ?? {}); + } + } + + if (isRedirect(result0)) { + res.redirect(code, result0.redirect); + } else { + res.status(code).send(result); + } }); process.title = `diachron:${cli.listen.port}`; diff --git a/express/basic/login.ts b/express/basic/login.ts new file mode 100644 index 0000000..abbaee9 --- /dev/null +++ b/express/basic/login.ts @@ -0,0 +1,62 @@ +import { SESSION_COOKIE_NAME } from "../auth/token"; +import { tokenLifetimes } from "../auth/types"; +import { services } from "../services"; +import type { Call, Result, Route } from "../types"; +import { html, redirect, render } from "../util"; + +const loginHandler = async (call: Call): Promise => { + if (call.method === "GET") { + const c = await render("basic/login", {}); + return html(c); + } + + // POST - handle login + const { email, password } = call.request.body; + + if (!email || !password) { + const c = await render("basic/login", { + error: "Email and password are required", + email, + }); + return html(c); + } + + const result = await services.auth.login(email, password, "cookie", { + userAgent: call.request.get("User-Agent"), + ipAddress: call.request.ip, + }); + + if (!result.success) { + const c = await render("basic/login", { + error: result.error, + email, + }); + return html(c); + } + + // Success - set cookie and redirect to home + const redirectResult = redirect("/"); + redirectResult.cookies = [ + { + name: SESSION_COOKIE_NAME, + value: result.token, + options: { + httpOnly: true, + secure: false, // Set to true in production with HTTPS + sameSite: "lax", + maxAge: tokenLifetimes.session, + path: "/", + }, + }, + ]; + + return redirectResult; +}; + +const loginRoute: Route = { + path: "/login", + methods: ["GET", "POST"], + handler: loginHandler, +}; + +export { loginRoute }; diff --git a/express/basic/routes.ts b/express/basic/routes.ts index 6df97b9..fc01d60 100644 --- a/express/basic/routes.ts +++ b/express/basic/routes.ts @@ -1,18 +1,29 @@ import { DateTime } from "ts-luxon"; import type { Call, Result, Route } from "../types"; import { html, render } from "../util"; +import { loginRoute } from "./login"; const routes: Record = { hello: { path: "/hello", methods: ["GET"], - handler: async (call: Call): Promise => { + handler: async (_call: Call): Promise => { const now = DateTime.now(); const c = await render("basic/hello", { now }); return html(c); }, }, + home: { + path:'/', + methods:['GET'], + handler:async(_call:Call): Promise => { + const c = await render('basic/home') + + return html(c) + } + }, + login: loginRoute, }; export { routes }; diff --git a/express/routes.ts b/express/routes.ts index 2dc9585..ba3688a 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -25,7 +25,9 @@ const okText = (result: string): Result => { const routes: Route[] = [ ...authRoutes, + basicRoutes.home, basicRoutes.hello, + basicRoutes.login, { path: "/slow", methods: ["GET"], diff --git a/express/types.ts b/express/types.ts index 528eabc..222db51 100644 --- a/express/types.ts +++ b/express/types.ts @@ -49,12 +49,35 @@ export type ProcessedRoute = { handler: InternalHandler; }; +export type CookieOptions = { + httpOnly?: boolean; + secure?: boolean; + sameSite?: "strict" | "lax" | "none"; + maxAge?: number; + path?: string; +}; + +export type Cookie = { + name: string; + value: string; + options?: CookieOptions; +}; + export type Result = { code: HttpCode; contentType: ContentType; result: string; + cookies?: Cookie[]; }; +export type RedirectResult = Result & { + redirect: string; +}; + +export function isRedirect(result: Result): result is RedirectResult { + return "redirect" in result; +} + export type Route = { path: string; methods: Method[]; diff --git a/express/util.ts b/express/util.ts index 3d141d8..78e9d9b 100644 --- a/express/util.ts +++ b/express/util.ts @@ -3,7 +3,7 @@ import nunjucks from "nunjucks"; import { contentTypes } from "./content-types"; import { executionContext } from "./execution-context"; import { httpCodes } from "./http-codes"; -import type { Result } from "./types"; +import type { Result, RedirectResult } from "./types"; // FIXME: Handle the error here const loadFile = async (path: string): Promise => { @@ -13,12 +13,14 @@ const loadFile = async (path: string): Promise => { return data; }; -const render = async (path: string, ctx: object): Promise => { +const render = async (path: string, ctx?: object): Promise => { const fullPath = `${executionContext.diachron_root}/templates/${path}.html.njk`; const template = await loadFile(fullPath); - const retval = nunjucks.renderString(template, ctx); + const c = ctx===undefined ? {} : ctx; + + const retval = nunjucks.renderString(template, c); return retval; }; @@ -33,4 +35,13 @@ const html = (payload: string): Result => { return retval; }; -export { render, html }; +const redirect = (location: string): RedirectResult => { + return { + code: httpCodes.redirection.SeeOther, + contentType: contentTypes.text.plain, + result: "", + redirect: location, + }; +}; + +export { render, html, redirect }; diff --git a/templates/basic/login.html.njk b/templates/basic/login.html.njk new file mode 100644 index 0000000..6b9ae4f --- /dev/null +++ b/templates/basic/login.html.njk @@ -0,0 +1,55 @@ + + + Login + + + +

Login

+ {% if error %} +
{{ error }}
+ {% endif %} +
+ + + +
+ + From afcb447b2b692283460236f0a10ed7fdf8207ef1 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 14:38:19 -0600 Subject: [PATCH 108/137] Add a command to add a new user --- express/mgmt/add-user.ts | 67 +++++++++++++++++++++++++++++++++++++++ framework/mgmt.d/add-user | 9 ++++++ mgmt | 25 +++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 express/mgmt/add-user.ts create mode 100755 framework/mgmt.d/add-user create mode 100755 mgmt diff --git a/express/mgmt/add-user.ts b/express/mgmt/add-user.ts new file mode 100644 index 0000000..892787f --- /dev/null +++ b/express/mgmt/add-user.ts @@ -0,0 +1,67 @@ +// add-user.ts +// Management command to create users from the command line + +import { pool, PostgresAuthStore } from "../database"; +import { hashPassword } from "../auth/password"; + +async function main(): Promise { + const args = process.argv.slice(2); + + if (args.length < 2) { + console.error( + "Usage: ./mgmt add-user [--display-name ] [--active]", + ); + process.exit(1); + } + + const email = args[0]; + const password = args[1]; + + // Parse optional flags + let displayName: string | undefined; + let makeActive = false; + + for (let i = 2; i < args.length; i++) { + if (args[i] === "--display-name" && args[i + 1]) { + displayName = args[i + 1]; + i++; + } else if (args[i] === "--active") { + makeActive = true; + } + } + + try { + const store = new PostgresAuthStore(); + + // Check if user already exists + const existing = await store.getUserByEmail(email); + if (existing) { + console.error(`Error: User with email '${email}' already exists`); + process.exit(1); + } + + // Hash password and create user + const passwordHash = await hashPassword(password); + const user = await store.createUser({ + email, + passwordHash, + displayName, + }); + + // Optionally activate user immediately + if (makeActive) { + await store.updateUserEmailVerified(user.id); + console.log(`Created and activated user: ${user.email} (${user.id})`); + } else { + console.log(`Created user: ${user.email} (${user.id})`); + console.log(" Status: pending (use --active to create as active)"); + } + } finally { + await pool.end(); + } +} + +main().catch((err) => { + console.error("Failed to create user:", err.message); + process.exit(1); +}); diff --git a/framework/mgmt.d/add-user b/framework/mgmt.d/add-user new file mode 100755 index 0000000..c3c71bd --- /dev/null +++ b/framework/mgmt.d/add-user @@ -0,0 +1,9 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$DIR/../.." + +cd "$ROOT/express" +"$DIR"/../cmd.d/tsx mgmt/add-user.ts "$@" diff --git a/mgmt b/mgmt new file mode 100755 index 0000000..58fc7d0 --- /dev/null +++ b/mgmt @@ -0,0 +1,25 @@ +#!/bin/bash + +# Management command runner - parallel to ./cmd for operational tasks +# Usage: ./mgmt [args...] + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ $# -lt 1 ]; then + echo "Usage: ./mgmt [args...]" + echo "" + echo "Available commands:" + for cmd in "$DIR"/framework/mgmt.d/*; do + if [ -x "$cmd" ]; then + basename "$cmd" + fi + done + exit 1 +fi + +subcmd="$1" +shift + +exec "$DIR"/framework/mgmt.d/"$subcmd" "$@" From 55f5cc699de93a8ded5b94074b1f7dfe44738e3a Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 14:56:10 -0600 Subject: [PATCH 109/137] 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 --- express/app.ts | 6 +++++- express/context.ts | 27 +++++++++++++++++++++++++++ express/services/index.ts | 7 ++++--- 3 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 express/context.ts diff --git a/express/app.ts b/express/app.ts index 2e4cc0b..862f59f 100644 --- a/express/app.ts +++ b/express/app.ts @@ -6,6 +6,7 @@ import { match } from "path-to-regexp"; import { Session } from "./auth"; import { cli } from "./cli"; import { contentTypes } from "./content-types"; +import { runWithContext } from "./context"; import { httpCodes } from "./http-codes"; import { routes } from "./routes"; import { services } from "./services"; @@ -75,7 +76,10 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => { }; try { - const retval = await route.handler(req); + const retval = await runWithContext( + { user: auth.user }, + () => route.handler(req), + ); return retval; } catch (error) { // Handle authentication errors diff --git a/express/context.ts b/express/context.ts new file mode 100644 index 0000000..9f52711 --- /dev/null +++ b/express/context.ts @@ -0,0 +1,27 @@ +// context.ts +// +// Request-scoped context using AsyncLocalStorage. +// Allows services to access request data (like the current user) without +// needing to pass Call through every function. + +import { AsyncLocalStorage } from "node:async_hooks"; +import { AnonymousUser, type MaybeUser } from "./user"; + +type RequestContext = { + user: MaybeUser; +}; + +const asyncLocalStorage = new AsyncLocalStorage(); + +// Run a function within a request context +function runWithContext(context: RequestContext, fn: () => T): T { + return asyncLocalStorage.run(context, fn); +} + +// Get the current user from context, or AnonymousUser if not in a request +function getCurrentUser(): MaybeUser { + const context = asyncLocalStorage.getStore(); + return context?.user ?? AnonymousUser; +} + +export { getCurrentUser, runWithContext, type RequestContext }; diff --git a/express/services/index.ts b/express/services/index.ts index b7daf88..3a9ff69 100644 --- a/express/services/index.ts +++ b/express/services/index.ts @@ -1,9 +1,10 @@ // services.ts import { AuthService } from "../auth"; +import { getCurrentUser } from "../context"; import { db, migrate, migrationStatus, PostgresAuthStore } from "../database"; import { getLogs, log } from "../logging"; -import { anonymousUser, type User } from "../user"; +import type { MaybeUser } from "../user"; const database = { db, @@ -29,8 +30,8 @@ const misc = { }; const session = { - getUser: (): User => { - return anonymousUser; + getUser: (): MaybeUser => { + return getCurrentUser(); }, }; From 14d20be9a277b67248c272d6f32f3762cfc0ea45 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 14:57:26 -0600 Subject: [PATCH 110/137] Note that file belongs to the framework --- mgmt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mgmt b/mgmt index 58fc7d0..703c2ed 100755 --- a/mgmt +++ b/mgmt @@ -1,5 +1,7 @@ #!/bin/bash +# This file belongs to the framework. You are not expected to modify it. + # Management command runner - parallel to ./cmd for operational tasks # Usage: ./mgmt [args...] From 7399cbe785fd4d852fa67e54d56887322588f7c6 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 14:57:51 -0600 Subject: [PATCH 111/137] Add / template --- express/basic/routes.ts | 9 ++++++++- templates/basic/home.html.njk | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 templates/basic/home.html.njk diff --git a/express/basic/routes.ts b/express/basic/routes.ts index fc01d60..4c207fc 100644 --- a/express/basic/routes.ts +++ b/express/basic/routes.ts @@ -3,6 +3,9 @@ import type { Call, Result, Route } from "../types"; import { html, render } from "../util"; import { loginRoute } from "./login"; +import { services } from '../services'; + + const routes: Record = { hello: { path: "/hello", @@ -18,7 +21,11 @@ const routes: Record = { path:'/', methods:['GET'], handler:async(_call:Call): Promise => { - const c = await render('basic/home') + const auth = services.auth + const me = services.session.getUser() + + const email = me.toString() + const c = await render('basic/home',{email}) return html(c) } diff --git a/templates/basic/home.html.njk b/templates/basic/home.html.njk new file mode 100644 index 0000000..3dd682d --- /dev/null +++ b/templates/basic/home.html.njk @@ -0,0 +1,11 @@ + + + +

+ home +

+

+ {{ email }} +

+ + From 4a4dc11aa48d35b7c78138d6c7769959c29ee8b5 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 15:17:58 -0600 Subject: [PATCH 112/137] Fix formatting --- express/app.ts | 12 ++++++++---- express/basic/routes.ts | 22 ++++++++++------------ express/mgmt/add-user.ts | 6 ++++-- express/util.ts | 6 +++--- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/express/app.ts b/express/app.ts index 862f59f..417ad99 100644 --- a/express/app.ts +++ b/express/app.ts @@ -76,9 +76,8 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => { }; try { - const retval = await runWithContext( - { user: auth.user }, - () => route.handler(req), + const retval = await runWithContext({ user: auth.user }, () => + route.handler(req), ); return retval; } catch (error) { @@ -117,7 +116,12 @@ async function handler( const method = await methodParser.parseAsync(req.method); const byMethod = processedRoutes[method]; - console.log("DEBUG: req.path =", JSON.stringify(req.path), "method =", method); + console.log( + "DEBUG: req.path =", + JSON.stringify(req.path), + "method =", + method, + ); for (const [_idx, pr] of byMethod.entries()) { const match = pr.matcher(req.path); console.log("DEBUG: trying pattern, match result =", match); diff --git a/express/basic/routes.ts b/express/basic/routes.ts index 4c207fc..a27b6cd 100644 --- a/express/basic/routes.ts +++ b/express/basic/routes.ts @@ -1,11 +1,9 @@ import { DateTime } from "ts-luxon"; +import { services } from "../services"; import type { Call, Result, Route } from "../types"; import { html, render } from "../util"; import { loginRoute } from "./login"; -import { services } from '../services'; - - const routes: Record = { hello: { path: "/hello", @@ -18,17 +16,17 @@ const routes: Record = { }, }, home: { - path:'/', - methods:['GET'], - handler:async(_call:Call): Promise => { - const auth = services.auth - const me = services.session.getUser() + path: "/", + methods: ["GET"], + handler: async (_call: Call): Promise => { + const auth = services.auth; + const me = services.session.getUser(); - const email = me.toString() - const c = await render('basic/home',{email}) + const email = me.toString(); + const c = await render("basic/home", { email }); - return html(c) - } + return html(c); + }, }, login: loginRoute, }; diff --git a/express/mgmt/add-user.ts b/express/mgmt/add-user.ts index 892787f..4dbc854 100644 --- a/express/mgmt/add-user.ts +++ b/express/mgmt/add-user.ts @@ -1,8 +1,8 @@ // add-user.ts // Management command to create users from the command line -import { pool, PostgresAuthStore } from "../database"; import { hashPassword } from "../auth/password"; +import { PostgresAuthStore, pool } from "../database"; async function main(): Promise { const args = process.argv.slice(2); @@ -51,7 +51,9 @@ async function main(): Promise { // Optionally activate user immediately if (makeActive) { await store.updateUserEmailVerified(user.id); - console.log(`Created and activated user: ${user.email} (${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)"); diff --git a/express/util.ts b/express/util.ts index 78e9d9b..c80b7a7 100644 --- a/express/util.ts +++ b/express/util.ts @@ -3,7 +3,7 @@ import nunjucks from "nunjucks"; import { contentTypes } from "./content-types"; import { executionContext } from "./execution-context"; import { httpCodes } from "./http-codes"; -import type { Result, RedirectResult } from "./types"; +import type { RedirectResult, Result } from "./types"; // FIXME: Handle the error here const loadFile = async (path: string): Promise => { @@ -18,7 +18,7 @@ const render = async (path: string, ctx?: object): Promise => { const template = await loadFile(fullPath); - const c = ctx===undefined ? {} : ctx; + const c = ctx === undefined ? {} : ctx; const retval = nunjucks.renderString(template, c); @@ -26,7 +26,7 @@ const render = async (path: string, ctx?: object): Promise => { }; const html = (payload: string): Result => { - const retval: Result = { + const retval: Result = { code: httpCodes.success.OK, result: payload, contentType: contentTypes.text.html, From 096a1235b5b8115b87f056e452afc5b7f2f6024f Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 15:31:59 -0600 Subject: [PATCH 113/137] Add basic logout --- express/basic/logout.ts | 38 +++++++++++++++++++++++++++++++++++ express/basic/routes.ts | 2 ++ express/routes.ts | 1 + templates/basic/home.html.njk | 2 ++ 4 files changed, 43 insertions(+) create mode 100644 express/basic/logout.ts diff --git a/express/basic/logout.ts b/express/basic/logout.ts new file mode 100644 index 0000000..a1f7dc8 --- /dev/null +++ b/express/basic/logout.ts @@ -0,0 +1,38 @@ +import { SESSION_COOKIE_NAME } from "../auth/token"; +import { services } from "../services"; +import type { Call, Result, Route } from "../types"; +import { redirect } from "../util"; + +const logoutHandler = async (call: Call): Promise => { + // Extract token from cookie and invalidate the session + const token = services.auth.extractToken(call.request); + if (token) { + await services.auth.logout(token); + } + + // Clear the cookie and redirect to login + const redirectResult = redirect("/login"); + redirectResult.cookies = [ + { + name: SESSION_COOKIE_NAME, + value: "", + options: { + httpOnly: true, + secure: false, + sameSite: "lax", + maxAge: 0, + path: "/", + }, + }, + ]; + + return redirectResult; +}; + +const logoutRoute: Route = { + path: "/logout", + methods: ["GET", "POST"], + handler: logoutHandler, +}; + +export { logoutRoute }; diff --git a/express/basic/routes.ts b/express/basic/routes.ts index a27b6cd..d5b1f20 100644 --- a/express/basic/routes.ts +++ b/express/basic/routes.ts @@ -3,6 +3,7 @@ import { services } from "../services"; import type { Call, Result, Route } from "../types"; import { html, render } from "../util"; import { loginRoute } from "./login"; +import { logoutRoute } from "./logout"; const routes: Record = { hello: { @@ -29,6 +30,7 @@ const routes: Record = { }, }, login: loginRoute, + logout: logoutRoute, }; export { routes }; diff --git a/express/routes.ts b/express/routes.ts index ba3688a..8fa77ca 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -28,6 +28,7 @@ const routes: Route[] = [ basicRoutes.home, basicRoutes.hello, basicRoutes.login, + basicRoutes.logout, { path: "/slow", methods: ["GET"], diff --git a/templates/basic/home.html.njk b/templates/basic/home.html.njk index 3dd682d..20af87f 100644 --- a/templates/basic/home.html.njk +++ b/templates/basic/home.html.njk @@ -5,7 +5,9 @@ home

+ {{ email }}

+ logout From de70be996e64943e7922875bcb014a929cad1aa3 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 15:33:01 -0600 Subject: [PATCH 114/137] Add docker-compose.yml for initial use --- docker-compose.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3258787 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + postgres: + image: postgres:17 + ports: + - "5432:5432" + environment: + POSTGRES_USER: diachron + POSTGRES_PASSWORD: diachron + POSTGRES_DB: diachron + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7 + ports: + - "6379:6379" + + memcached: + image: memcached:1.6 + ports: + - "11211:11211" + + beanstalkd: + image: schickling/beanstalkd + ports: + - "11300:11300" + + mailpit: + image: axllent/mailpit + ports: + - "1025:1025" # SMTP + - "8025:8025" # Web UI + +volumes: + postgres_data: From e34d47b35231b44b7e69c5f8a737f83e7b3313c9 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 15:36:15 -0600 Subject: [PATCH 115/137] Add various todo items --- TODO.md | 55 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index 5eeba36..481a988 100644 --- a/TODO.md +++ b/TODO.md @@ -3,21 +3,20 @@ - [ ] Add unit tests all over the place. - ⚠️ 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! -- [ ] Add middleware concept + + +- [ ] Add default user table(s) to database. + - [ ] Add authentication - password - third party? + +- [ ] Add middleware concept + - [ ] Add authorization - for specific routes / resources / etc @@ -25,6 +24,9 @@ Partially done; see the /time route. But we need to figure out where to store templates, static files, etc. +- [ ] fix process management: if you control-c `master` process sometimes it + leaves around `master-bin`, `logger-bin`, and `diachron:nnnn` processes. + Huge problem. ## medium importance @@ -32,10 +34,26 @@ - with queries - convert to logfmt and is there a viewer UI we could pull in and use instead? - + +- [ ] add nested routes. Note that this might be easy to do without actually + changing the logic in express/routes.ts. A function that takes an array + of routes and maps over them rewriting them. Maybe. + + - [ ] related: add something to do with default templates and stuff... I + think we can make handlers a lot shorter to write, sometimes not even + necessary at all, with some sane defaults and an easy to use override + mechanism + + - [ ] 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: - [x] shellcheck on shell scripts @@ -48,7 +66,11 @@ - upgrade docs - starting docs - taking over docs + - reference + - internals +- [ ] make migration creation default to something like yyyy-mm-dd_ssss (are + 9999 migrations in a day enough?) ## low importance @@ -72,6 +94,10 @@ code; repeat - Slow start them: only start a few at first +- [ ] in express/user.ts: FIXME: set createdAt and updatedAt to start of epoch + + + ## finished @@ -99,3 +125,12 @@ - [x] Log to logging service from the express backend - Fill out types and functions in `express/logging.ts` + +- [x] Add first cut at database access. Remember that ORMs are not all that! + +- [x] Create initial docker-compose.yml file for local development + - include most recent stable postgres + - include beanstalkd + - include memcached + - include redis + - include mailpit From f383c6a4650748c3c5c2a55e941a45eda6011daf Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 15:48:32 -0600 Subject: [PATCH 116/137] Add logger wrapper script --- logger/logger | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100755 logger/logger diff --git a/logger/logger b/logger/logger new file mode 100755 index 0000000..14db0b7 --- /dev/null +++ b/logger/logger @@ -0,0 +1,7 @@ +#!/bin/bash + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$DIR" + +./logger-bin "$@" From 1da81089cd0742b7a16e230dddb2e87809e4f83f Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 15:51:42 -0600 Subject: [PATCH 117/137] Add sync.sh script This downloads and installs dependencies necessary to run or develop. Add docker-compose.yml for initial use --- framework/shims/node | 4 +-- framework/shims/node.common | 24 ++++++-------- framework/versions | 16 +++++++++ sync.sh | 66 +++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 17 deletions(-) create mode 100644 framework/versions create mode 100755 sync.sh diff --git a/framework/shims/node b/framework/shims/node index 090bb9a..5ac98a8 100755 --- a/framework/shims/node +++ b/framework/shims/node @@ -5,10 +5,8 @@ set -eu 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 exec "$nodejs_binary_dir/node" "$@" diff --git a/framework/shims/node.common b/framework/shims/node.common index c3253e1..3745766 100644 --- a/framework/shims/node.common +++ b/framework/shims/node.common @@ -2,23 +2,19 @@ # shellcheck shell=bash node_common_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +project_root="$node_common_DIR/../.." -# FIXME this shouldn't be hardcoded here of course -nodejs_binary_dir="$node_common_DIR/../binaries/node-v22.15.1-linux-x64/bin" +# shellcheck source=../versions +source "$node_common_DIR"/../versions + +nodejs_binary_dir="$project_root/$nodejs_bin_dir" # This might be too restrictive. Or not restrictive enough. PATH="$nodejs_binary_dir":/bin:/usr/bin -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_dir/npm" -export NPM_CONFIG_CACHE="$node_dir/cache" -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 +export NPM_CONFIG_PREFIX="$node_dist_dir/npm" +export NPM_CONFIG_CACHE="$node_dist_dir/cache" +export NPM_CONFIG_TMP="$node_dist_dir/tmp" +export NODE_PATH="$node_dist_dir/node_modules" diff --git a/framework/versions b/framework/versions new file mode 100644 index 0000000..0177ebf --- /dev/null +++ b/framework/versions @@ -0,0 +1,16 @@ +# shellcheck shell=bash + +# This file belongs to the framework. You are not expected to modify it. + +nodejs_binary_linux_x86_64=https://nodejs.org/dist/v22.15.1/node-v22.15.1-linux-x64.tar.xz +nodejs_checksum_linux_x86_64=7dca2ab34ec817aa4781e2e99dfd34d349eff9be86e5d5fbaa7e96cae8ee3179 +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 + +pnpm_binary_linux_x86_64=https://github.com/pnpm/pnpm/releases/download/v10.12.4/pnpm-linux-x64 +pnpm_checksum_linux_x86_64=sha256:ac2768434cbc6c5fec71da4abae7845802bbfe326d590aa0004cdf8bf0815b26 + +golangci_lint=v2.7.2-alpine \ No newline at end of file diff --git a/sync.sh b/sync.sh new file mode 100755 index 0000000..5aa223d --- /dev/null +++ b/sync.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# Note: This is kind of AI slop and needs to be more carefully reviewed. + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=framework/versions +source "$DIR/framework/versions" + +# Ensure correct node version is installed +node_installed_checksum_file="$DIR/framework/binaries/.node.checksum" +node_installed_checksum="" +if [ -f "$node_installed_checksum_file" ]; then + node_installed_checksum=$(cat "$node_installed_checksum_file") +fi + +if [ "$node_installed_checksum" != "$nodejs_checksum_linux_x86_64" ]; then + echo "Downloading Node.js..." + node_archive="$DIR/framework/downloads/node.tar.xz" + curl -fsSL "$nodejs_binary_linux_x86_64" -o "$node_archive" + + echo "Verifying checksum..." + echo "$nodejs_checksum_linux_x86_64 $node_archive" | sha256sum -c - + + echo "Extracting Node.js..." + tar -xf "$node_archive" -C "$DIR/framework/binaries" + rm "$node_archive" + + echo "$nodejs_checksum_linux_x86_64" >"$node_installed_checksum_file" +fi + +# Ensure correct pnpm version is installed +pnpm_binary="$DIR/framework/binaries/pnpm" +pnpm_installed_checksum_file="$DIR/framework/binaries/.pnpm.checksum" +pnpm_installed_checksum="" +if [ -f "$pnpm_installed_checksum_file" ]; then + pnpm_installed_checksum=$(cat "$pnpm_installed_checksum_file") +fi + +# pnpm checksum includes "sha256:" prefix, strip it for sha256sum +pnpm_checksum="${pnpm_checksum_linux_x86_64#sha256:}" + +if [ "$pnpm_installed_checksum" != "$pnpm_checksum" ]; then + echo "Downloading pnpm..." + curl -fsSL "$pnpm_binary_linux_x86_64" -o "$pnpm_binary" + + echo "Verifying checksum..." + echo "$pnpm_checksum $pnpm_binary" | sha256sum -c - + + chmod +x "$pnpm_binary" + + echo "$pnpm_checksum" >"$pnpm_installed_checksum_file" +fi + +# Get golang binaries in place +cd "$DIR/master" +go build + +cd "$DIR/logger" +go build + +# Update framework code +cd "$DIR/express" +../cmd pnpm install From 70ddcb2a943d0eca5e6d8c739f1c5f03e86b023b Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 16:04:22 -0600 Subject: [PATCH 118/137] Note that we need bash --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 5c6948c..3795006 100644 --- a/README.md +++ b/README.md @@ -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: +- bash - docker and docker compose - [fd](https://github.com/sharkdp/fd) - golang, version 1.23.6 or greater - shellcheck - shfmt - - From 93ab4b5d5385f365cce1e485adff9c5728112d40 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 16:07:24 -0600 Subject: [PATCH 119/137] Update node version --- framework/versions | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/framework/versions b/framework/versions index 0177ebf..f6af704 100644 --- a/framework/versions +++ b/framework/versions @@ -2,8 +2,9 @@ # This file belongs to the framework. You are not expected to modify it. -nodejs_binary_linux_x86_64=https://nodejs.org/dist/v22.15.1/node-v22.15.1-linux-x64.tar.xz -nodejs_checksum_linux_x86_64=7dca2ab34ec817aa4781e2e99dfd34d349eff9be86e5d5fbaa7e96cae8ee3179 +# 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" From 6ace2163ed2de7ccd28fc72a7c5740d783e10272 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 11 Jan 2026 16:07:32 -0600 Subject: [PATCH 120/137] Update pnpm version --- framework/versions | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/framework/versions b/framework/versions index f6af704..de9fbd4 100644 --- a/framework/versions +++ b/framework/versions @@ -11,7 +11,9 @@ nodejs_bin_dir="$nodejs_dist_dir/bin" caddy_binary_linux_x86_64=fixme caddy_checksum_linux_x86_64=fixmetoo -pnpm_binary_linux_x86_64=https://github.com/pnpm/pnpm/releases/download/v10.12.4/pnpm-linux-x64 -pnpm_checksum_linux_x86_64=sha256:ac2768434cbc6c5fec71da4abae7845802bbfe326d590aa0004cdf8bf0815b26 +# https://github.com/pnpm/pnpm/releases +pnpm_binary_linux_x86_64=https://github.com/pnpm/pnpm/releases/download/v10.28.0/pnpm-linux-x64 +pnpm_checksum_linux_x86_64=sha256:348e863d17a62411a65f900e8d91395acabae9e9237653ccc3c36cb385965f28 + golangci_lint=v2.7.2-alpine \ No newline at end of file From 03cc4cf4ebfc81e42feb2d8a04f87eb7ce393c0f Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 17 Jan 2026 13:19:40 -0600 Subject: [PATCH 121/137] Remove prettier; we've been using biome for a while --- CLAUDE.md | 4 +- express/package.json | 19 ----- express/pnpm-lock.yaml | 159 ----------------------------------------- 3 files changed, 2 insertions(+), 180 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index abdb3d9..81fbee3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,14 +31,14 @@ master process. Key design principles: ### 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 ./check.sh ``` **Format TypeScript code:** ```bash -cd express && ../cmd pnpm prettier --write . +cd express && ../cmd pnpm biome check --write . ``` **Build Go master process:** diff --git a/express/package.json b/express/package.json index 156b976..231753d 100644 --- a/express/package.json +++ b/express/package.json @@ -5,7 +5,6 @@ "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "prettier": "prettier", "nodemon": "nodemon dist/index.js" }, "keywords": [], @@ -13,7 +12,6 @@ "license": "ISC", "packageManager": "pnpm@10.12.4", "dependencies": { - "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@types/node": "^24.10.1", "@types/nunjucks": "^3.2.6", "@vercel/ncc": "^0.38.4", @@ -23,29 +21,12 @@ "nunjucks": "^3.2.4", "path-to-regexp": "^8.3.0", "pg": "^8.16.3", - "prettier": "^3.6.2", "ts-luxon": "^6.2.0", "ts-node": "^10.9.2", "tsx": "^4.20.6", "typescript": "^5.9.3", "zod": "^4.1.12" }, - "prettier": { - "arrowParens": "always", - "bracketSpacing": true, - "trailingComma": "all", - "tabWidth": 4, - "semi": true, - "singleQuote": false, - "importOrder": [ - "", - "^[./]" - ], - "importOrderCaseSensitive": true, - "plugins": [ - "@ianvs/prettier-plugin-sort-imports" - ] - }, "devDependencies": { "@biomejs/biome": "2.3.10", "@types/express": "^5.0.5", diff --git a/express/pnpm-lock.yaml b/express/pnpm-lock.yaml index d615bb3..877e164 100644 --- a/express/pnpm-lock.yaml +++ b/express/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@ianvs/prettier-plugin-sort-imports': - specifier: ^4.7.0 - version: 4.7.0(prettier@3.6.2) '@types/node': specifier: ^24.10.1 version: 24.10.1 @@ -38,9 +35,6 @@ importers: pg: specifier: ^8.16.3 version: 8.16.3 - prettier: - specifier: ^3.6.2 - version: 3.6.2 ts-luxon: specifier: ^6.2.0 version: 6.2.0 @@ -69,43 +63,6 @@ importers: packages: - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - 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'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - 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': resolution: {integrity: sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==} engines: {node: '>=14.21.3'} @@ -319,27 +276,6 @@ packages: cpu: [x64] 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': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -347,9 +283,6 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} @@ -649,14 +582,6 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - kysely@0.28.9: resolution: {integrity: sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==} engines: {node: '>=20.0.0'} @@ -765,9 +690,6 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -788,11 +710,6 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} 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: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -958,53 +875,6 @@ packages: snapshots: - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - 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/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': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.3.10 @@ -1122,31 +992,10 @@ snapshots: '@esbuild/win32-x64@0.25.12': 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/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': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -1497,10 +1346,6 @@ snapshots: is-promise@4.0.0: {} - js-tokens@4.0.0: {} - - jsesc@3.1.0: {} - kysely@0.28.9: {} make-error@1.3.6: {} @@ -1597,8 +1442,6 @@ snapshots: dependencies: split2: 4.2.0 - picocolors@1.1.1: {} - picomatch@2.3.1: {} postgres-array@2.0.0: {} @@ -1611,8 +1454,6 @@ snapshots: dependencies: xtend: 4.0.2 - prettier@3.6.2: {} - proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 From 7ed05695b9f05144656ed3de546cea8ce7c8b9fc Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 17 Jan 2026 15:43:52 -0600 Subject: [PATCH 122/137] Separate happy path utility functions for requests --- express/basic/login.ts | 2 +- express/basic/logout.ts | 2 +- express/basic/routes.ts | 2 +- express/request/util.ts | 46 +++++++++++++++++++++++++++++++++++++++ express/services/index.ts | 12 ++++++++++ express/util.ts | 37 +++---------------------------- 6 files changed, 64 insertions(+), 37 deletions(-) create mode 100644 express/request/util.ts diff --git a/express/basic/login.ts b/express/basic/login.ts index abbaee9..7977a2b 100644 --- a/express/basic/login.ts +++ b/express/basic/login.ts @@ -2,7 +2,7 @@ import { SESSION_COOKIE_NAME } from "../auth/token"; import { tokenLifetimes } from "../auth/types"; import { services } from "../services"; import type { Call, Result, Route } from "../types"; -import { html, redirect, render } from "../util"; +import { html, redirect, render } from "../request/util"; const loginHandler = async (call: Call): Promise => { if (call.method === "GET") { diff --git a/express/basic/logout.ts b/express/basic/logout.ts index a1f7dc8..85a3e80 100644 --- a/express/basic/logout.ts +++ b/express/basic/logout.ts @@ -1,7 +1,7 @@ import { SESSION_COOKIE_NAME } from "../auth/token"; import { services } from "../services"; import type { Call, Result, Route } from "../types"; -import { redirect } from "../util"; +import { redirect } from "../request/util"; const logoutHandler = async (call: Call): Promise => { // Extract token from cookie and invalidate the session diff --git a/express/basic/routes.ts b/express/basic/routes.ts index d5b1f20..b68b139 100644 --- a/express/basic/routes.ts +++ b/express/basic/routes.ts @@ -1,7 +1,7 @@ import { DateTime } from "ts-luxon"; import { services } from "../services"; import type { Call, Result, Route } from "../types"; -import { html, render } from "../util"; +import { html, render } from "../request/util"; import { loginRoute } from "./login"; import { logoutRoute } from "./logout"; diff --git a/express/request/util.ts b/express/request/util.ts new file mode 100644 index 0000000..e5b445d --- /dev/null +++ b/express/request/util.ts @@ -0,0 +1,46 @@ +import { contentTypes } from "../content-types"; +import { executionContext } from "../execution-context"; +import { httpCodes } from "../http-codes"; +import type { RedirectResult, Result } from "../types"; +import {services} from '../services' + +import{loadFile}from'../util' + + +type NoUser={ + [key: string]: unknown; +} & { + user?: never; +} + +const render = async (path: string, ctx?: NoUser): Promise => { + const fullPath = `${executionContext.diachron_root}/templates/${path}.html.njk`; + const template = await loadFile(fullPath); + const user = services.session.getUser(); + const context = {user, ...ctx} + const engine = services.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 { render, html, redirect }; diff --git a/express/services/index.ts b/express/services/index.ts index 3a9ff69..daed08f 100644 --- a/express/services/index.ts +++ b/express/services/index.ts @@ -5,6 +5,17 @@ import { getCurrentUser } from "../context"; import { db, migrate, migrationStatus, PostgresAuthStore } from "../database"; import { getLogs, log } from "../logging"; import type { MaybeUser } from "../user"; +import nunjucks from 'nunjucks' + +const conf = { + templateEngine: () => { + return { + renderTemplate: (template: string, context: object) => { + return nunjucks.renderString(template, context); + }, + } + } +}; const database = { db, @@ -42,6 +53,7 @@ const auth = new AuthService(authStore); // Keep this asciibetically sorted const services = { auth, + conf, database, logging, misc, diff --git a/express/util.ts b/express/util.ts index c80b7a7..6aba4e9 100644 --- a/express/util.ts +++ b/express/util.ts @@ -1,9 +1,5 @@ import { readFile } from "node:fs/promises"; import nunjucks from "nunjucks"; -import { contentTypes } from "./content-types"; -import { executionContext } from "./execution-context"; -import { httpCodes } from "./http-codes"; -import type { RedirectResult, Result } from "./types"; // FIXME: Handle the error here const loadFile = async (path: string): Promise => { @@ -13,35 +9,8 @@ const loadFile = async (path: string): Promise => { return data; }; -const render = async (path: string, ctx?: object): Promise => { - const fullPath = `${executionContext.diachron_root}/templates/${path}.html.njk`; - const template = await loadFile(fullPath); - const c = ctx === undefined ? {} : ctx; - - const retval = nunjucks.renderString(template, c); - - 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 { render, html, redirect }; +export { + loadFile, +} From 00d84d66868e4f3ce2e9bd72f3a88182f7f4cc44 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 17 Jan 2026 15:45:02 -0600 Subject: [PATCH 123/137] Note that files belong to framework --- express/content-types.ts | 2 ++ express/http-codes.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/express/content-types.ts b/express/content-types.ts index 219ce49..b704c33 100644 --- a/express/content-types.ts +++ b/express/content-types.ts @@ -1,3 +1,5 @@ +// This file belongs to the framework. You are not expected to modify it. + export type ContentType = string; // tx claude https://claude.ai/share/344fc7bd-5321-4763-af2f-b82275e9f865 diff --git a/express/http-codes.ts b/express/http-codes.ts index 912aa8a..89a23d8 100644 --- a/express/http-codes.ts +++ b/express/http-codes.ts @@ -1,3 +1,5 @@ +// This file belongs to the framework. You are not expected to modify it. + export type HttpCode = { code: number; name: string; From a345a2adfb9feb5be841176ab199aba3879f20be Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 17 Jan 2026 16:10:24 -0600 Subject: [PATCH 124/137] Add directive --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 81fbee3..5968670 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,10 @@ Early stage - most implementations are stubs: # meta +## formatting and sorting + +- When a typescript file exports symbols, they should be listed in order + ## guidelines for this document - Try to keep lines below 80 characters in length, especially prose. But if From e59bb35ac9ebdbb6091749febaf28599d6b191bd Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 17 Jan 2026 16:10:38 -0600 Subject: [PATCH 125/137] Update todo list --- TODO.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 481a988..cdf1df1 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,7 @@ ## high importance +- [ ] nix services/ and split it up into core/ request/ + - [ ] Add unit tests all over the place. - ⚠️ Huge task - needs breakdown before starting @@ -11,8 +13,8 @@ - [ ] Add authentication - - password - - third party? + - [ ] password + - [ ] third party? - [ ] Add middleware concept @@ -43,7 +45,14 @@ 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 - + +- [ ] 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 @@ -72,6 +81,13 @@ - [ ] make migration creation default to something like yyyy-mm-dd_ssss (are 9999 migrations in a day enough?) +- [ ] clean up `cmd` and `mgmt`: do the right thing with their commonalities + and make very plain which is which for what. Consider additional + commands. Maybe `develop` for specific development tasks, + `operate` for operational tasks, and we keep `cmd` for project-specific + commands. Something like that. + + ## low importance - [ ] add a prometheus-style `/metrics` endpoint to master From 8a7682e953a1df0487f003225f46733477c6e873 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 17 Jan 2026 16:19:32 -0600 Subject: [PATCH 126/137] Split services into core and request --- TODO.md | 2 -- express/app.ts | 18 ++++++++-------- express/auth/routes.ts | 16 +++++++-------- express/basic/login.ts | 6 +++--- express/basic/logout.ts | 8 ++++---- express/basic/routes.ts | 8 ++++---- express/{services => core}/index.ts | 32 ++++++++--------------------- express/handlers.ts | 4 ++-- express/request/index.ts | 25 ++++++++++++++++++++++ express/request/util.ts | 19 ++++++++--------- express/routes.ts | 4 ++-- express/util.ts | 7 +------ 12 files changed, 76 insertions(+), 73 deletions(-) rename express/{services => core}/index.ts (53%) create mode 100644 express/request/index.ts diff --git a/TODO.md b/TODO.md index cdf1df1..b4f24ad 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,5 @@ ## high importance -- [ ] nix services/ and split it up into core/ request/ - - [ ] Add unit tests all over the place. - ⚠️ Huge task - needs breakdown before starting diff --git a/express/app.ts b/express/app.ts index 417ad99..eb044aa 100644 --- a/express/app.ts +++ b/express/app.ts @@ -7,9 +7,11 @@ import { Session } from "./auth"; import { cli } from "./cli"; import { contentTypes } from "./content-types"; import { runWithContext } from "./context"; +import { core } from "./core"; import { httpCodes } from "./http-codes"; +import { request } from "./request"; import { routes } from "./routes"; -import { services } from "./services"; + // import { URLPattern } from 'node:url'; import { AuthenticationRequired, @@ -31,7 +33,7 @@ const app = express(); 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[] } = { GET: [], POST: [], @@ -50,9 +52,9 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => { const methodList = route.methods; const handler: InternalHandler = async ( - request: ExpressRequest, + expressRequest: ExpressRequest, ): Promise => { - const method = massageMethod(request.method); + const method = massageMethod(expressRequest.method); console.log("method", method); @@ -60,17 +62,17 @@ routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => { // XXX: Worth asserting this? } - console.log("request.originalUrl", request.originalUrl); + console.log("request.originalUrl", expressRequest.originalUrl); // Authenticate the request - const auth = await services.auth.validateRequest(request); + const auth = await request.auth.validateRequest(expressRequest); const req: Call = { pattern: route.path, - path: request.originalUrl, + path: expressRequest.originalUrl, method, parameters: { one: 1, two: 2 }, - request, + request: expressRequest, user: auth.user, session: new Session(auth.session, auth.user), }; diff --git a/express/auth/routes.ts b/express/auth/routes.ts index ae93c8f..b1787a2 100644 --- a/express/auth/routes.ts +++ b/express/auth/routes.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { contentTypes } from "../content-types"; import { httpCodes } from "../http-codes"; -import { services } from "../services"; +import { request } from "../request"; import type { Call, Result, Route } from "../types"; import { forgotPasswordInputParser, @@ -39,7 +39,7 @@ const loginHandler = async (call: Call): Promise => { const body = call.request.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"), ipAddress: call.request.ip, }); @@ -72,9 +72,9 @@ const loginHandler = async (call: Call): Promise => { // POST /auth/logout const logoutHandler = async (call: Call): Promise => { - const token = services.auth.extractToken(call.request); + const token = request.auth.extractToken(call.request); if (token) { - await services.auth.logout(token); + await request.auth.logout(token); } return jsonResponse(httpCodes.success.OK, { message: "Logged out" }); @@ -87,7 +87,7 @@ const registerHandler = async (call: Call): Promise => { const { email, password, displayName } = registerInputParser.parse(body); - const result = await services.auth.register( + const result = await request.auth.register( email, password, displayName, @@ -128,7 +128,7 @@ const forgotPasswordHandler = async (call: Call): Promise => { const body = call.request.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) if (result) { @@ -159,7 +159,7 @@ const resetPasswordHandler = async (call: Call): Promise => { const body = call.request.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) { return errorResponse( @@ -195,7 +195,7 @@ const verifyEmailHandler = async (call: Call): Promise => { ); } - const result = await services.auth.verifyEmail(token); + const result = await request.auth.verifyEmail(token); if (!result.success) { return errorResponse(httpCodes.clientErrors.BadRequest, result.error); diff --git a/express/basic/login.ts b/express/basic/login.ts index 7977a2b..dd9ac74 100644 --- a/express/basic/login.ts +++ b/express/basic/login.ts @@ -1,8 +1,8 @@ import { SESSION_COOKIE_NAME } from "../auth/token"; import { tokenLifetimes } from "../auth/types"; -import { services } from "../services"; -import type { Call, Result, Route } from "../types"; +import { request } from "../request"; import { html, redirect, render } from "../request/util"; +import type { Call, Result, Route } from "../types"; const loginHandler = async (call: Call): Promise => { if (call.method === "GET") { @@ -21,7 +21,7 @@ const loginHandler = async (call: Call): Promise => { return html(c); } - const result = await services.auth.login(email, password, "cookie", { + const result = await request.auth.login(email, password, "cookie", { userAgent: call.request.get("User-Agent"), ipAddress: call.request.ip, }); diff --git a/express/basic/logout.ts b/express/basic/logout.ts index 85a3e80..fcae1d8 100644 --- a/express/basic/logout.ts +++ b/express/basic/logout.ts @@ -1,13 +1,13 @@ import { SESSION_COOKIE_NAME } from "../auth/token"; -import { services } from "../services"; -import type { Call, Result, Route } from "../types"; +import { request } from "../request"; import { redirect } from "../request/util"; +import type { Call, Result, Route } from "../types"; const logoutHandler = async (call: Call): Promise => { // Extract token from cookie and invalidate the session - const token = services.auth.extractToken(call.request); + const token = request.auth.extractToken(call.request); if (token) { - await services.auth.logout(token); + await request.auth.logout(token); } // Clear the cookie and redirect to login diff --git a/express/basic/routes.ts b/express/basic/routes.ts index b68b139..c051397 100644 --- a/express/basic/routes.ts +++ b/express/basic/routes.ts @@ -1,7 +1,7 @@ import { DateTime } from "ts-luxon"; -import { services } from "../services"; -import type { Call, Result, Route } from "../types"; +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"; @@ -20,8 +20,8 @@ const routes: Record = { path: "/", methods: ["GET"], handler: async (_call: Call): Promise => { - const auth = services.auth; - const me = services.session.getUser(); + const _auth = request.auth; + const me = request.session.getUser(); const email = me.toString(); const c = await render("basic/home", { email }); diff --git a/express/services/index.ts b/express/core/index.ts similarity index 53% rename from express/services/index.ts rename to express/core/index.ts index daed08f..0aa8d49 100644 --- a/express/services/index.ts +++ b/express/core/index.ts @@ -1,20 +1,16 @@ -// services.ts - -import { AuthService } from "../auth"; -import { getCurrentUser } from "../context"; -import { db, migrate, migrationStatus, PostgresAuthStore } from "../database"; +import nunjucks from "nunjucks"; +import { db, migrate, migrationStatus } from "../database"; import { getLogs, log } from "../logging"; -import type { MaybeUser } from "../user"; -import nunjucks from 'nunjucks' +// FIXME: This doesn't belong here; move it somewhere else. const conf = { - templateEngine: () => { + templateEngine: () => { return { renderTemplate: (template: string, context: object) => { return nunjucks.renderString(template, context); }, - } - } + }; + }, }; const database = { @@ -40,25 +36,13 @@ const misc = { }, }; -const session = { - getUser: (): MaybeUser => { - return getCurrentUser(); - }, -}; - -// Initialize auth with PostgreSQL store -const authStore = new PostgresAuthStore(); -const auth = new AuthService(authStore); - // Keep this asciibetically sorted -const services = { - auth, +const core = { conf, database, logging, misc, random, - session, }; -export { services }; +export { core }; diff --git a/express/handlers.ts b/express/handlers.ts index 9a15231..adbaee1 100644 --- a/express/handlers.ts +++ b/express/handlers.ts @@ -1,11 +1,11 @@ import { contentTypes } from "./content-types"; +import { core } from "./core"; import { httpCodes } from "./http-codes"; -import { services } from "./services"; import type { Call, Handler, Result } from "./types"; const multiHandler: Handler = async (call: Call): Promise => { const code = httpCodes.success.OK; - const rn = services.random.randomNumber(); + const rn = core.random.randomNumber(); const retval: Result = { code, diff --git a/express/request/index.ts b/express/request/index.ts new file mode 100644 index 0000000..6f98811 --- /dev/null +++ b/express/request/index.ts @@ -0,0 +1,25 @@ +import { AuthService } from "../auth"; +import { getCurrentUser } from "../context"; +import { PostgresAuthStore } from "../database"; +import type { MaybeUser } from "../user"; +import { html, redirect, render } from "./util"; + +const util = { html, redirect, render }; + +const session = { + getUser: (): MaybeUser => { + return getCurrentUser(); + }, +}; + +// Initialize auth with PostgreSQL store +const authStore = new PostgresAuthStore(); +const auth = new AuthService(authStore); + +const request = { + auth, + session, + util, +}; + +export { request }; diff --git a/express/request/util.ts b/express/request/util.ts index e5b445d..e1d01f9 100644 --- a/express/request/util.ts +++ b/express/request/util.ts @@ -1,24 +1,23 @@ 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 {services} from '../services' +import { loadFile } from "../util"; +import { request } from "./index"; -import{loadFile}from'../util' - - -type NoUser={ +type NoUser = { [key: string]: unknown; } & { user?: never; -} +}; const render = async (path: string, ctx?: NoUser): Promise => { const fullPath = `${executionContext.diachron_root}/templates/${path}.html.njk`; const template = await loadFile(fullPath); - const user = services.session.getUser(); - const context = {user, ...ctx} - const engine = services.conf.templateEngine() + const user = request.session.getUser(); + const context = { user, ...ctx }; + const engine = core.conf.templateEngine(); const retval = engine.renderTemplate(template, context); return retval; @@ -43,4 +42,4 @@ const redirect = (location: string): RedirectResult => { }; }; -export { render, html, redirect }; +export { html, redirect, render }; diff --git a/express/routes.ts b/express/routes.ts index 8fa77ca..090b7aa 100644 --- a/express/routes.ts +++ b/express/routes.ts @@ -5,9 +5,9 @@ import { DateTime } from "ts-luxon"; import { authRoutes } from "./auth/routes"; import { routes as basicRoutes } from "./basic/routes"; import { contentTypes } from "./content-types"; +import { core } from "./core"; import { multiHandler } from "./handlers"; import { httpCodes } from "./http-codes"; -import { services } from "./services"; import type { Call, Result, Route } from "./types"; // FIXME: Obviously put this somewhere else @@ -35,7 +35,7 @@ const routes: Route[] = [ handler: async (_call: Call): Promise => { console.log("starting slow request"); - await services.misc.sleep(2); + await core.misc.sleep(2); console.log("finishing slow request"); const retval = okText("that was slow"); diff --git a/express/util.ts b/express/util.ts index 6aba4e9..e818ad8 100644 --- a/express/util.ts +++ b/express/util.ts @@ -1,5 +1,4 @@ import { readFile } from "node:fs/promises"; -import nunjucks from "nunjucks"; // FIXME: Handle the error here const loadFile = async (path: string): Promise => { @@ -9,8 +8,4 @@ const loadFile = async (path: string): Promise => { return data; }; - - -export { - loadFile, -} +export { loadFile }; From 350bf7c865b02d32d66d8838a584e689721c48ee Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 17 Jan 2026 16:30:55 -0600 Subject: [PATCH 127/137] Run shell scripts through shfmt --- sync.sh | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/sync.sh b/sync.sh index 5aa223d..40d102e 100755 --- a/sync.sh +++ b/sync.sh @@ -13,22 +13,22 @@ source "$DIR/framework/versions" 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") + 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 "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 "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 "Extracting Node.js..." + tar -xf "$node_archive" -C "$DIR/framework/binaries" + rm "$node_archive" - echo "$nodejs_checksum_linux_x86_64" >"$node_installed_checksum_file" + echo "$nodejs_checksum_linux_x86_64" >"$node_installed_checksum_file" fi # Ensure correct pnpm version is installed @@ -36,22 +36,22 @@ 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") + 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 "Downloading pnpm..." + curl -fsSL "$pnpm_binary_linux_x86_64" -o "$pnpm_binary" - echo "Verifying checksum..." - echo "$pnpm_checksum $pnpm_binary" | sha256sum -c - + echo "Verifying checksum..." + echo "$pnpm_checksum $pnpm_binary" | sha256sum -c - - chmod +x "$pnpm_binary" + chmod +x "$pnpm_binary" - echo "$pnpm_checksum" >"$pnpm_installed_checksum_file" + echo "$pnpm_checksum" >"$pnpm_installed_checksum_file" fi # Get golang binaries in place From d9216790586b0b2d823dbee28fcb981f3fb91f83 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 17 Jan 2026 17:45:36 -0600 Subject: [PATCH 128/137] Rework user types: create AuthenticatedUser and AnonymousUser class Both are subclasses of an abstract User class which contains almost everything interesting. --- express/auth/service.ts | 17 ++++--- express/auth/store.ts | 6 +-- express/auth/types.ts | 8 ++-- express/basic/routes.ts | 9 +++- express/context.ts | 8 ++-- express/database.ts | 4 +- express/request/index.ts | 4 +- express/types.ts | 11 ++--- express/user.ts | 97 +++++++++++++++++++++++++++------------- 9 files changed, 102 insertions(+), 62 deletions(-) diff --git a/express/auth/service.ts b/express/auth/service.ts index e8d9025..677f1d7 100644 --- a/express/auth/service.ts +++ b/express/auth/service.ts @@ -4,7 +4,12 @@ // password reset, and email verification. 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 type { AuthStore } from "./store"; import { @@ -27,7 +32,7 @@ type SimpleResult = { success: true } | { success: false; error: string }; // Result of validating a request/token - contains both user and session export type AuthResult = | { authenticated: true; user: User; session: SessionData } - | { authenticated: false; user: typeof AnonymousUser; session: null }; + | { authenticated: false; user: AnonymousUser; session: null }; export class AuthService { constructor(private store: AuthStore) {} @@ -83,7 +88,7 @@ export class AuthService { } if (!token) { - return { authenticated: false, user: AnonymousUser, session: null }; + return { authenticated: false, user: anonymousUser, session: null }; } return this.validateToken(token); @@ -94,16 +99,16 @@ export class AuthService { const session = await this.store.getSession(tokenId); if (!session) { - return { authenticated: false, user: AnonymousUser, session: null }; + return { authenticated: false, user: anonymousUser, session: null }; } 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); if (!user || !user.isActive()) { - return { authenticated: false, user: AnonymousUser, session: null }; + return { authenticated: false, user: anonymousUser, session: null }; } // Update last used (fire and forget) diff --git a/express/auth/store.ts b/express/auth/store.ts index f3f2684..b9dbccd 100644 --- a/express/auth/store.ts +++ b/express/auth/store.ts @@ -3,7 +3,7 @@ // Authentication storage interface and in-memory implementation. // 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 type { AuthMethod, SessionData, TokenId, TokenType } from "./types"; @@ -123,7 +123,7 @@ export class InMemoryAuthStore implements AuthStore { } async createUser(data: CreateUserData): Promise { - const user = User.create(data.email, { + const user = AuthenticatedUser.create(data.email, { displayName: data.displayName, status: "pending", // Pending until email verified }); @@ -151,7 +151,7 @@ export class InMemoryAuthStore implements AuthStore { const user = this.users.get(userId); if (user) { // Create new user with active status - const updatedUser = User.create(user.email, { + const updatedUser = AuthenticatedUser.create(user.email, { id: user.id, displayName: user.displayName, status: "active", diff --git a/express/auth/types.ts b/express/auth/types.ts index c8112dd..997c4aa 100644 --- a/express/auth/types.ts +++ b/express/auth/types.ts @@ -64,17 +64,17 @@ export const tokenLifetimes: Record = { }; // 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. // Always present on Call (never null), but may represent an anonymous session. export class Session { constructor( private readonly data: SessionData | null, - private readonly user: MaybeUser, + private readonly user: User, ) {} - getUser(): MaybeUser { + getUser(): User { return this.user; } @@ -83,7 +83,7 @@ export class Session { } isAuthenticated(): boolean { - return this.user !== AnonymousUser; + return !this.user.isAnonymous(); } get tokenId(): string | undefined { diff --git a/express/basic/routes.ts b/express/basic/routes.ts index c051397..e042c43 100644 --- a/express/basic/routes.ts +++ b/express/basic/routes.ts @@ -24,7 +24,14 @@ const routes: Record = { const me = request.session.getUser(); const email = me.toString(); - const c = await render("basic/home", { email }); + const showLogin = me.isAnonymous(); + const showLogout = !me.isAnonymous(); + + const c = await render("basic/home", { + email, + showLogin, + showLogout, + }); return html(c); }, diff --git a/express/context.ts b/express/context.ts index 9f52711..2fdd9ef 100644 --- a/express/context.ts +++ b/express/context.ts @@ -5,10 +5,10 @@ // needing to pass Call through every function. import { AsyncLocalStorage } from "node:async_hooks"; -import { AnonymousUser, type MaybeUser } from "./user"; +import { anonymousUser, type User } from "./user"; type RequestContext = { - user: MaybeUser; + user: User; }; const asyncLocalStorage = new AsyncLocalStorage(); @@ -19,9 +19,9 @@ function runWithContext(context: RequestContext, fn: () => T): T { } // Get the current user from context, or AnonymousUser if not in a request -function getCurrentUser(): MaybeUser { +function getCurrentUser(): User { const context = asyncLocalStorage.getStore(); - return context?.user ?? AnonymousUser; + return context?.user ?? anonymousUser; } export { getCurrentUser, runWithContext, type RequestContext }; diff --git a/express/database.ts b/express/database.ts index a5b317d..a932a40 100644 --- a/express/database.ts +++ b/express/database.ts @@ -18,7 +18,7 @@ import type { } from "./auth/store"; import { generateToken, hashToken } from "./auth/token"; import type { SessionData, TokenId } from "./auth/types"; -import { User, type UserId } from "./user"; +import { AuthenticatedUser, type User, type UserId } from "./user"; // Connection configuration const connectionConfig = { @@ -367,7 +367,7 @@ class PostgresAuthStore implements AuthStore { // Helper to convert database row to User object private rowToUser(row: Selectable): User { - return new User({ + return new AuthenticatedUser({ id: row.id, email: row.email, displayName: row.display_name ?? undefined, diff --git a/express/request/index.ts b/express/request/index.ts index 6f98811..466a992 100644 --- a/express/request/index.ts +++ b/express/request/index.ts @@ -1,13 +1,13 @@ import { AuthService } from "../auth"; import { getCurrentUser } from "../context"; import { PostgresAuthStore } from "../database"; -import type { MaybeUser } from "../user"; +import type { User } from "../user"; import { html, redirect, render } from "./util"; const util = { html, redirect, render }; const session = { - getUser: (): MaybeUser => { + getUser: (): User => { return getCurrentUser(); }, }; diff --git a/express/types.ts b/express/types.ts index 222db51..e62b77c 100644 --- a/express/types.ts +++ b/express/types.ts @@ -8,12 +8,7 @@ import { z } from "zod"; import type { Session } from "./auth/types"; import type { ContentType } from "./content-types"; import type { HttpCode } from "./http-codes"; -import { - AnonymousUser, - type MaybeUser, - type Permission, - type User, -} from "./user"; +import type { Permission, User } from "./user"; const methodParser = z.union([ z.literal("GET"), @@ -36,7 +31,7 @@ export type Call = { method: Method; parameters: object; request: ExpressRequest; - user: MaybeUser; + user: User; session: Session; }; @@ -102,7 +97,7 @@ export class AuthorizationDenied extends Error { // Helper for handlers to require authentication export function requireAuth(call: Call): User { - if (call.user === AnonymousUser) { + if (call.user.isAnonymous()) { throw new AuthenticationRequired(); } return call.user; diff --git a/express/user.ts b/express/user.ts index 6c32646..1803350 100644 --- a/express/user.ts +++ b/express/user.ts @@ -51,39 +51,15 @@ const defaultRolePermissions: RolePermissionMap = new Map([ ["user", ["users:read"]], ]); -export class User { - private readonly data: UserData; - private rolePermissions: RolePermissionMap; +export abstract class User { + protected readonly data: UserData; + protected rolePermissions: RolePermissionMap; constructor(data: UserData, rolePermissions?: RolePermissionMap) { this.data = userDataParser.parse(data); this.rolePermissions = rolePermissions ?? defaultRolePermissions; } - // 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 get id(): UserId { return this.data.id as UserId; @@ -185,15 +161,72 @@ export class User { toString(): string { return `User(id ${this.id})`; } + + abstract isAnonymous(): boolean; +} + +export class AuthenticatedUser extends User { + // Factory for creating new users with sensible defaults + static create( + email: string, + options?: { + id?: string; + displayName?: string; + status?: UserStatus; + roles?: Role[]; + permissions?: Permission[]; + }, + ): User { + const now = new Date(); + return new AuthenticatedUser({ + id: options?.id ?? crypto.randomUUID(), + email, + displayName: options?.displayName, + status: options?.status ?? "active", + roles: options?.roles ?? [], + permissions: options?.permissions ?? [], + createdAt: now, + updatedAt: now, + }); + } + + isAnonymous(): boolean { + return false; + } } // For representing "no user" in contexts where user is optional -export 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", displayName: "Anonymous User", - // FIXME: set createdAt and updatedAt to start of epoch }); - -export type MaybeUser = User | typeof AnonymousUser; From 960f78a1ade7575c953b9d0db6dd4c0f3723c38d Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 24 Jan 2026 14:50:15 -0600 Subject: [PATCH 129/137] Update initial tables --- express/database.ts | 6 ++-- express/migrations/0001_users.sql | 21 -------------- express/migrations/2026-01-01_01-users.sql | 29 +++++++++++++++++++ ...essions.sql => 2026-01-01_02-sessions.sql} | 9 +++--- .../2026-01-24_01-roles-and-groups.sql | 20 +++++++++++++ .../migrations/2026-01-24_02-capabilities.sql | 14 +++++++++ 6 files changed, 71 insertions(+), 28 deletions(-) delete mode 100644 express/migrations/0001_users.sql create mode 100644 express/migrations/2026-01-01_01-users.sql rename express/migrations/{0002_sessions.sql => 2026-01-01_02-sessions.sql} (77%) create mode 100644 express/migrations/2026-01-24_01-roles-and-groups.sql create mode 100644 express/migrations/2026-01-24_02-capabilities.sql diff --git a/express/database.ts b/express/database.ts index a932a40..49fbabd 100644 --- a/express/database.ts +++ b/express/database.ts @@ -87,8 +87,8 @@ async function raw( // ============================================================================ // Migration file naming convention: -// NNNN_description.sql -// e.g., 0001_initial.sql, 0002_add_users.sql +// yyyy-mm-dd_ss_description.sql +// e.g., 2025-01-15_01_initial.sql, 2025-01-15_02_add_users.sql // // Migrations directory: express/migrations/ @@ -128,7 +128,7 @@ function getMigrationFiles(): string[] { return fs .readdirSync(MIGRATIONS_DIR) .filter((f) => f.endsWith(".sql")) - .filter((f) => /^\d{4}_/.test(f)) + .filter((f) => /^\d{4}-\d{2}-\d{2}_\d{2}-/.test(f)) .sort(); } diff --git a/express/migrations/0001_users.sql b/express/migrations/0001_users.sql deleted file mode 100644 index 8aa8a5d..0000000 --- a/express/migrations/0001_users.sql +++ /dev/null @@ -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); diff --git a/express/migrations/2026-01-01_01-users.sql b/express/migrations/2026-01-01_01-users.sql new file mode 100644 index 0000000..83f7cb7 --- /dev/null +++ b/express/migrations/2026-01-01_01-users.sql @@ -0,0 +1,29 @@ +-- 0001_users.sql +-- Create users table for authentication + +CREATE TABLE users ( + id UUID PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'active', + display_name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE user_emails ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + email TEXT NOT NULL, + normalized_email TEXT NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + is_verified BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + verified_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); + +-- Enforce uniqueness only among *active* emails +CREATE UNIQUE INDEX user_emails_unique_active +ON user_emails (normalized_email) +WHERE revoked_at IS NULL; + + diff --git a/express/migrations/0002_sessions.sql b/express/migrations/2026-01-01_02-sessions.sql similarity index 77% rename from express/migrations/0002_sessions.sql rename to express/migrations/2026-01-01_02-sessions.sql index 2708f8f..2ad9b33 100644 --- a/express/migrations/0002_sessions.sql +++ b/express/migrations/2026-01-01_02-sessions.sql @@ -2,15 +2,16 @@ -- Create sessions table for auth tokens CREATE TABLE sessions ( - token_id TEXT PRIMARY KEY, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + user_email_id UUID REFERENCES user_emails(id), token_type TEXT NOT NULL, auth_method TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, - last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + ip_address INET, user_agent TEXT, - ip_address TEXT, is_used BOOLEAN DEFAULT FALSE ); diff --git a/express/migrations/2026-01-24_01-roles-and-groups.sql b/express/migrations/2026-01-24_01-roles-and-groups.sql new file mode 100644 index 0000000..e9e7630 --- /dev/null +++ b/express/migrations/2026-01-24_01-roles-and-groups.sql @@ -0,0 +1,20 @@ +CREATE TABLE roles ( + id UUID PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + description TEXT +); + +CREATE TABLE groups ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE user_group_roles ( + user_id UUID NOT NULL REFERENCES users(id), + group_id UUID NOT NULL REFERENCES groups(id), + role_id UUID NOT NULL REFERENCES roles(id), + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + PRIMARY KEY (user_id, group_id, role_id) +); diff --git a/express/migrations/2026-01-24_02-capabilities.sql b/express/migrations/2026-01-24_02-capabilities.sql new file mode 100644 index 0000000..12f0329 --- /dev/null +++ b/express/migrations/2026-01-24_02-capabilities.sql @@ -0,0 +1,14 @@ +CREATE TABLE capabilities ( + id UUID PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + description TEXT +); + +CREATE TABLE role_capabilities ( + role_id UUID NOT NULL REFERENCES roles(id), + capability_id UUID NOT NULL REFERENCES capabilities(id), + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + PRIMARY KEY (role_id, capability_id) +); + From 474420ac1eacbbaeed4396f40753de8b18940841 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 24 Jan 2026 15:12:11 -0600 Subject: [PATCH 130/137] Add development command to reset the database and rerun migrations --- develop | 27 +++++++++++++++++++ express/database.ts | 10 +++++--- express/develop/reset-db.ts | 50 ++++++++++++++++++++++++++++++++++++ framework/develop.d/reset-db | 9 +++++++ 4 files changed, 93 insertions(+), 3 deletions(-) create mode 100755 develop create mode 100644 express/develop/reset-db.ts create mode 100755 framework/develop.d/reset-db diff --git a/develop b/develop new file mode 100755 index 0000000..5592ec1 --- /dev/null +++ b/develop @@ -0,0 +1,27 @@ +#!/bin/bash + +# This file belongs to the framework. You are not expected to modify it. + +# Development command runner - parallel to ./mgmt for development tasks +# Usage: ./develop [args...] + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ $# -lt 1 ]; then + echo "Usage: ./develop [args...]" + echo "" + echo "Available commands:" + for cmd in "$DIR"/framework/develop.d/*; do + if [ -x "$cmd" ]; then + basename "$cmd" + fi + done + exit 1 +fi + +subcmd="$1" +shift + +exec "$DIR"/framework/develop.d/"$subcmd" "$@" diff --git a/express/database.ts b/express/database.ts index 49fbabd..999cdc4 100644 --- a/express/database.ts +++ b/express/database.ts @@ -137,6 +137,8 @@ async function runMigration(filename: string): Promise { const filepath = path.join(MIGRATIONS_DIR, filename); const content = fs.readFileSync(filepath, "utf-8"); + process.stdout.write(` Migration: ${filename}...`); + // Run migration in a transaction const client = await pool.connect(); try { @@ -147,8 +149,11 @@ async function runMigration(filename: string): Promise { [filename], ); await client.query("COMMIT"); - console.log(`Applied migration: ${filename}`); + console.log(" ✓"); } catch (err) { + console.log(" ✗"); + const message = err instanceof Error ? err.message : String(err); + console.error(` Error: ${message}`); await client.query("ROLLBACK"); throw err; } finally { @@ -169,11 +174,10 @@ async function migrate(): Promise { return; } - console.log(`Running ${pending.length} migration(s)...`); + console.log(`Applying ${pending.length} migration(s):`); for (const file of pending) { await runMigration(file); } - console.log("Migrations complete"); } // List migration status diff --git a/express/develop/reset-db.ts b/express/develop/reset-db.ts new file mode 100644 index 0000000..abd7dd2 --- /dev/null +++ b/express/develop/reset-db.ts @@ -0,0 +1,50 @@ +// reset-db.ts +// Development command to wipe the database and apply all migrations from scratch + +import { migrate, pool, connectionConfig } from "../database"; + +async function main(): Promise { + 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); + } + + try { + 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"); + } + + console.log(""); + await migrate(); + + console.log(""); + console.log("Database reset complete."); + } finally { + await pool.end(); + } +} + +main().catch((err) => { + console.error("Failed to reset database:", err.message); + process.exit(1); +}); diff --git a/framework/develop.d/reset-db b/framework/develop.d/reset-db new file mode 100755 index 0000000..ac11200 --- /dev/null +++ b/framework/develop.d/reset-db @@ -0,0 +1,9 @@ +#!/bin/bash + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$DIR/../.." + +cd "$ROOT/express" +"$DIR"/../cmd.d/tsx develop/reset-db.ts "$@" From 579a19669e42c9711327e22fdc6914e4f91c8017 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 24 Jan 2026 15:48:22 -0600 Subject: [PATCH 131/137] Match user and session schema changes --- express/database.ts | 229 ++++++++++++++---- express/migrations/2026-01-01_02-sessions.sql | 3 +- 2 files changed, 180 insertions(+), 52 deletions(-) diff --git a/express/database.ts b/express/database.ts index 999cdc4..163f579 100644 --- a/express/database.ts +++ b/express/database.ts @@ -33,32 +33,52 @@ const connectionConfig = { // Generated marks columns with database defaults (optional on insert) interface UsersTable { id: string; - email: string; - password_hash: string; - display_name: string | null; status: Generated; - roles: Generated; - permissions: Generated; - email_verified: Generated; + display_name: string | null; + created_at: Generated; + updated_at: Generated; +} + +interface UserEmailsTable { + id: string; + user_id: string; + email: string; + normalized_email: string; + is_primary: Generated; + is_verified: Generated; + created_at: Generated; + verified_at: Date | null; + revoked_at: Date | null; +} + +interface UserCredentialsTable { + id: string; + user_id: string; + credential_type: Generated; + password_hash: string | null; created_at: Generated; updated_at: Generated; } interface SessionsTable { - token_id: string; + id: Generated; + token_hash: string; user_id: string; + user_email_id: string | null; token_type: string; auth_method: string; created_at: Generated; expires_at: Date; - last_used_at: Date | null; - user_agent: string | null; + revoked_at: Date | null; ip_address: string | null; + user_agent: string | null; is_used: Generated; } interface Database { users: UsersTable; + user_emails: UserEmailsTable; + user_credentials: UserCredentialsTable; sessions: SessionsTable; } @@ -205,12 +225,12 @@ class PostgresAuthStore implements AuthStore { data: CreateSessionData, ): Promise<{ token: string; session: SessionData }> { const token = generateToken(); - const tokenId = hashToken(token); + const tokenHash = hashToken(token); const row = await db .insertInto("sessions") .values({ - token_id: tokenId, + token_hash: tokenHash, user_id: data.userId, token_type: data.tokenType, auth_method: data.authMethod, @@ -222,13 +242,12 @@ class PostgresAuthStore implements AuthStore { .executeTakeFirstOrThrow(); const session: SessionData = { - tokenId: row.token_id, + tokenId: row.token_hash, userId: row.user_id, tokenType: row.token_type as SessionData["tokenType"], authMethod: row.auth_method as SessionData["authMethod"], createdAt: row.created_at, expiresAt: row.expires_at, - lastUsedAt: row.last_used_at ?? undefined, userAgent: row.user_agent ?? undefined, ipAddress: row.ip_address ?? undefined, isUsed: row.is_used ?? undefined, @@ -241,8 +260,9 @@ class PostgresAuthStore implements AuthStore { const row = await db .selectFrom("sessions") .selectAll() - .where("token_id", "=", tokenId) + .where("token_hash", "=", tokenId) .where("expires_at", ">", new Date()) + .where("revoked_at", "is", null) .executeTakeFirst(); if (!row) { @@ -250,50 +270,62 @@ class PostgresAuthStore implements AuthStore { } return { - tokenId: row.token_id, + tokenId: row.token_hash, userId: row.user_id, tokenType: row.token_type as SessionData["tokenType"], authMethod: row.auth_method as SessionData["authMethod"], createdAt: row.created_at, expiresAt: row.expires_at, - lastUsedAt: row.last_used_at ?? undefined, userAgent: row.user_agent ?? undefined, ipAddress: row.ip_address ?? undefined, isUsed: row.is_used ?? undefined, }; } - async updateLastUsed(tokenId: TokenId): Promise { - await db - .updateTable("sessions") - .set({ last_used_at: new Date() }) - .where("token_id", "=", tokenId) - .execute(); + async updateLastUsed(_tokenId: TokenId): Promise { + // The new schema doesn't have last_used_at column + // This is now a no-op; session activity tracking could be added later } async deleteSession(tokenId: TokenId): Promise { + // Soft delete by setting revoked_at await db - .deleteFrom("sessions") - .where("token_id", "=", tokenId) + .updateTable("sessions") + .set({ revoked_at: new Date() }) + .where("token_hash", "=", tokenId) .execute(); } async deleteUserSessions(userId: UserId): Promise { const result = await db - .deleteFrom("sessions") + .updateTable("sessions") + .set({ revoked_at: new Date() }) .where("user_id", "=", userId) + .where("revoked_at", "is", null) .executeTakeFirst(); - return Number(result.numDeletedRows); + return Number(result.numUpdatedRows); } // User operations async getUserByEmail(email: string): Promise { + // Find user through user_emails table + const normalizedEmail = email.toLowerCase().trim(); + const row = await db - .selectFrom("users") - .selectAll() - .where(sql`LOWER(email)`, "=", email.toLowerCase()) + .selectFrom("user_emails") + .innerJoin("users", "users.id", "user_emails.user_id") + .select([ + "users.id", + "users.status", + "users.display_name", + "users.created_at", + "users.updated_at", + "user_emails.email", + ]) + .where("user_emails.normalized_email", "=", normalizedEmail) + .where("user_emails.revoked_at", "is", null) .executeTakeFirst(); if (!row) { @@ -303,10 +335,24 @@ class PostgresAuthStore implements AuthStore { } async getUserById(userId: UserId): Promise { + // Get user with their primary email const row = await db .selectFrom("users") - .selectAll() - .where("id", "=", userId) + .leftJoin("user_emails", (join) => + join + .onRef("user_emails.user_id", "=", "users.id") + .on("user_emails.is_primary", "=", true) + .on("user_emails.revoked_at", "is", null), + ) + .select([ + "users.id", + "users.status", + "users.display_name", + "users.created_at", + "users.updated_at", + "user_emails.email", + ]) + .where("users.id", "=", userId) .executeTakeFirst(); if (!row) { @@ -316,68 +362,149 @@ class PostgresAuthStore implements AuthStore { } async createUser(data: CreateUserData): Promise { - const id = crypto.randomUUID(); + const userId = crypto.randomUUID(); + const emailId = crypto.randomUUID(); + const credentialId = crypto.randomUUID(); const now = new Date(); + const normalizedEmail = data.email.toLowerCase().trim(); - const row = await db + // Create user record + await db .insertInto("users") .values({ - id, - email: data.email, - password_hash: data.passwordHash, + id: userId, display_name: data.displayName ?? null, status: "pending", - roles: [], - permissions: [], - email_verified: false, created_at: now, updated_at: now, }) - .returningAll() - .executeTakeFirstOrThrow(); + .execute(); - 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 { const row = await db - .selectFrom("users") + .selectFrom("user_credentials") .select("password_hash") - .where("id", "=", userId) + .where("user_id", "=", userId) + .where("credential_type", "=", "password") .executeTakeFirst(); return row?.password_hash ?? null; } async setUserPassword(userId: UserId, passwordHash: string): Promise { + const now = new Date(); + + // Try to update existing credential + const result = await db + .updateTable("user_credentials") + .set({ password_hash: passwordHash, updated_at: now }) + .where("user_id", "=", userId) + .where("credential_type", "=", "password") + .executeTakeFirst(); + + // If no existing credential, create one + if (Number(result.numUpdatedRows) === 0) { + await db + .insertInto("user_credentials") + .values({ + id: crypto.randomUUID(), + user_id: userId, + credential_type: "password", + password_hash: passwordHash, + created_at: now, + updated_at: now, + }) + .execute(); + } + + // Update user's updated_at await db .updateTable("users") - .set({ password_hash: passwordHash, updated_at: new Date() }) + .set({ updated_at: now }) .where("id", "=", userId) .execute(); } async updateUserEmailVerified(userId: UserId): Promise { + const now = new Date(); + + // Update user_emails to mark as verified + await db + .updateTable("user_emails") + .set({ + is_verified: true, + verified_at: now, + }) + .where("user_id", "=", userId) + .where("is_primary", "=", true) + .execute(); + + // Update user status to active await db .updateTable("users") .set({ - email_verified: true, status: "active", - updated_at: new Date(), + updated_at: now, }) .where("id", "=", userId) .execute(); } // Helper to convert database row to User object - private rowToUser(row: Selectable): User { + private rowToUser(row: { + id: string; + status: string; + display_name: string | null; + created_at: Date; + updated_at: Date; + email: string | null; + }): User { return new AuthenticatedUser({ id: row.id, - email: row.email, + email: row.email ?? "unknown@example.com", displayName: row.display_name ?? undefined, status: row.status as "active" | "suspended" | "pending", - roles: row.roles, - permissions: row.permissions, + roles: [], // TODO: query from RBAC tables + permissions: [], // TODO: query from RBAC tables createdAt: row.created_at, updatedAt: row.updated_at, }); diff --git a/express/migrations/2026-01-01_02-sessions.sql b/express/migrations/2026-01-01_02-sessions.sql index 2ad9b33..e2911cc 100644 --- a/express/migrations/2026-01-01_02-sessions.sql +++ b/express/migrations/2026-01-01_02-sessions.sql @@ -2,7 +2,8 @@ -- Create sessions table for auth tokens CREATE TABLE sessions ( - id UUID PRIMARY KEY, + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash TEXT UNIQUE NOT NULL, user_id UUID NOT NULL REFERENCES users(id), user_email_id UUID REFERENCES user_emails(id), token_type TEXT NOT NULL, From 8704c4a8d5270dd7c1c224486e654b6a5004f2b7 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 24 Jan 2026 16:38:33 -0600 Subject: [PATCH 132/137] Separate framework and app migrations Also add a new develop command: clear-db. --- express/database.ts | 42 ++++++++++++++----- express/develop/clear-db.ts | 17 ++++++++ express/develop/reset-db.ts | 32 ++------------ express/develop/util.ts | 42 +++++++++++++++++++ .../migrations/2026-01-01_01-users.sql | 0 .../migrations/2026-01-01_02-sessions.sql | 0 .../2026-01-24_01-roles-and-groups.sql | 0 .../migrations/2026-01-24_02-capabilities.sql | 0 express/types.ts | 2 + framework/develop.d/clear-db | 11 +++++ 10 files changed, 107 insertions(+), 39 deletions(-) create mode 100644 express/develop/clear-db.ts create mode 100644 express/develop/util.ts rename express/{ => framework}/migrations/2026-01-01_01-users.sql (100%) rename express/{ => framework}/migrations/2026-01-01_02-sessions.sql (100%) rename express/{ => framework}/migrations/2026-01-24_01-roles-and-groups.sql (100%) rename express/{ => framework}/migrations/2026-01-24_02-capabilities.sql (100%) create mode 100755 framework/develop.d/clear-db diff --git a/express/database.ts b/express/database.ts index 163f579..afb8bf5 100644 --- a/express/database.ts +++ b/express/database.ts @@ -18,6 +18,7 @@ import type { } from "./auth/store"; import { generateToken, hashToken } from "./auth/token"; import type { SessionData, TokenId } from "./auth/types"; +import type { Domain } from "./types"; import { AuthenticatedUser, type User, type UserId } from "./user"; // Connection configuration @@ -112,7 +113,8 @@ async function raw( // // 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"; interface MigrationRecord { @@ -141,20 +143,30 @@ async function getAppliedMigrations(): Promise { } // Get pending migration files -function getMigrationFiles(): string[] { - if (!fs.existsSync(MIGRATIONS_DIR)) { +function getMigrationFiles(kind: Domain): string[] { + const dir = kind === "fw" ? FRAMEWORK_MIGRATIONS_DIR : APP_MIGRATIONS_DIR; + + if (!fs.existsSync(dir)) { return []; } - return fs - .readdirSync(MIGRATIONS_DIR) + + const root = __dirname; + + const mm = fs + .readdirSync(dir) .filter((f) => f.endsWith(".sql")) .filter((f) => /^\d{4}-\d{2}-\d{2}_\d{2}-/.test(f)) + .map((f) => `${dir}/${f}`) + .map((f) => f.replace(`${root}/`, "")) .sort(); + + return mm; } // Run a single migration async function runMigration(filename: string): Promise { - const filepath = path.join(MIGRATIONS_DIR, filename); + // const filepath = path.join(MIGRATIONS_DIR, filename); + const filepath = filename; const content = fs.readFileSync(filepath, "utf-8"); process.stdout.write(` Migration: ${filename}...`); @@ -181,13 +193,21 @@ async function runMigration(filename: string): Promise { } } +function getAllMigrationFiles() { + const fw_files = getMigrationFiles("fw"); + const app_files = getMigrationFiles("app"); + const all = [...fw_files, ...app_files]; + + return all; +} + // Run all pending migrations async function migrate(): Promise { await ensureMigrationsTable(); const applied = new Set(await getAppliedMigrations()); - const files = getMigrationFiles(); - const pending = files.filter((f) => !applied.has(f)); + const all = getAllMigrationFiles(); + const pending = all.filter((all) => !applied.has(all)); if (pending.length === 0) { console.log("No pending migrations"); @@ -207,10 +227,10 @@ async function migrationStatus(): Promise<{ }> { await ensureMigrationsTable(); const applied = new Set(await getAppliedMigrations()); - const files = getMigrationFiles(); + const ff = getAllMigrationFiles(); return { - applied: files.filter((f) => applied.has(f)), - pending: files.filter((f) => !applied.has(f)), + applied: ff.filter((ff) => applied.has(ff)), + pending: ff.filter((ff) => !applied.has(ff)), }; } diff --git a/express/develop/clear-db.ts b/express/develop/clear-db.ts new file mode 100644 index 0000000..2293ff6 --- /dev/null +++ b/express/develop/clear-db.ts @@ -0,0 +1,17 @@ +import { connectionConfig, migrate, pool } from "../database"; +import { dropTables, exitIfUnforced } from "./util"; + +async function main(): Promise { + exitIfUnforced(); + + try { + await dropTables(); + } finally { + await pool.end(); + } +} + +main().catch((err) => { + console.error("Failed to clear database:", err.message); + process.exit(1); +}); diff --git a/express/develop/reset-db.ts b/express/develop/reset-db.ts index abd7dd2..3545c58 100644 --- a/express/develop/reset-db.ts +++ b/express/develop/reset-db.ts @@ -1,38 +1,14 @@ // reset-db.ts // Development command to wipe the database and apply all migrations from scratch -import { migrate, pool, connectionConfig } from "../database"; +import { connectionConfig, migrate, pool } from "../database"; +import { dropTables, exitIfUnforced } from "./util"; async function main(): Promise { - 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); - } + exitIfUnforced(); try { - 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"); - } + await dropTables(); console.log(""); await migrate(); diff --git a/express/develop/util.ts b/express/develop/util.ts new file mode 100644 index 0000000..4d138f6 --- /dev/null +++ b/express/develop/util.ts @@ -0,0 +1,42 @@ +// FIXME: this is at the wrong level of specificity + +import { connectionConfig, migrate, pool } from "../database"; + +const exitIfUnforced = () => { + const args = process.argv.slice(2); + + // Require explicit confirmation unless --force is passed + if (!args.includes("--force")) { + console.error("This will DROP ALL TABLES in the database!"); + console.error(` Database: ${connectionConfig.database}`); + console.error( + ` Host: ${connectionConfig.host}:${connectionConfig.port}`, + ); + console.error(""); + console.error("Run with --force to proceed."); + process.exit(1); + } +}; + +const dropTables = async () => { + console.log("Dropping all tables..."); + + // Get all table names in the public schema + const result = await pool.query<{ tablename: string }>(` + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + `); + + if (result.rows.length > 0) { + // Drop all tables with CASCADE to handle foreign key constraints + const tableNames = result.rows + .map((r) => `"${r.tablename}"`) + .join(", "); + await pool.query(`DROP TABLE IF EXISTS ${tableNames} CASCADE`); + console.log(`Dropped ${result.rows.length} table(s)`); + } else { + console.log("No tables to drop"); + } +}; + +export { dropTables, exitIfUnforced }; diff --git a/express/migrations/2026-01-01_01-users.sql b/express/framework/migrations/2026-01-01_01-users.sql similarity index 100% rename from express/migrations/2026-01-01_01-users.sql rename to express/framework/migrations/2026-01-01_01-users.sql diff --git a/express/migrations/2026-01-01_02-sessions.sql b/express/framework/migrations/2026-01-01_02-sessions.sql similarity index 100% rename from express/migrations/2026-01-01_02-sessions.sql rename to express/framework/migrations/2026-01-01_02-sessions.sql diff --git a/express/migrations/2026-01-24_01-roles-and-groups.sql b/express/framework/migrations/2026-01-24_01-roles-and-groups.sql similarity index 100% rename from express/migrations/2026-01-24_01-roles-and-groups.sql rename to express/framework/migrations/2026-01-24_01-roles-and-groups.sql diff --git a/express/migrations/2026-01-24_02-capabilities.sql b/express/framework/migrations/2026-01-24_02-capabilities.sql similarity index 100% rename from express/migrations/2026-01-24_02-capabilities.sql rename to express/framework/migrations/2026-01-24_02-capabilities.sql diff --git a/express/types.ts b/express/types.ts index e62b77c..2cb2b8d 100644 --- a/express/types.ts +++ b/express/types.ts @@ -112,4 +112,6 @@ export function requirePermission(call: Call, permission: Permission): User { return user; } +export type Domain = "app" | "fw"; + export { methodParser, massageMethod }; diff --git a/framework/develop.d/clear-db b/framework/develop.d/clear-db new file mode 100755 index 0000000..467d490 --- /dev/null +++ b/framework/develop.d/clear-db @@ -0,0 +1,11 @@ +#!/bin/bash + +# This file belongs to the framework. You are not expected to modify it. + +set -eu + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$DIR/../.." + +cd "$ROOT/express" +"$DIR"/../cmd.d/tsx develop/clear-db.ts "$@" From e30bf5d96d9cbc65713c8966df4dc6bea9a39c10 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 24 Jan 2026 16:39:13 -0600 Subject: [PATCH 133/137] Fix regexp in fixup.sh --- fixup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixup.sh b/fixup.sh index bea3316..5194cef 100755 --- a/fixup.sh +++ b/fixup.sh @@ -10,7 +10,7 @@ cd "$DIR" # 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 # "$shell_scripts" for ss in $shell_scripts; do From 4f37a72d7ba5baf16f190cbfe075baf332ca96d5 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 24 Jan 2026 16:54:54 -0600 Subject: [PATCH 134/137] Clean commands up --- cmd | 22 ++++++++++++++-------- framework/{cmd.d => common.d}/db | 0 framework/{cmd.d => common.d}/migrate | 0 framework/develop.d/db | 1 + framework/develop.d/migrate | 1 + framework/mgmt.d/db | 1 + framework/mgmt.d/migrate | 1 + 7 files changed, 18 insertions(+), 8 deletions(-) rename framework/{cmd.d => common.d}/db (100%) rename framework/{cmd.d => common.d}/migrate (100%) create mode 120000 framework/develop.d/db create mode 120000 framework/develop.d/migrate create mode 120000 framework/mgmt.d/db create mode 120000 framework/mgmt.d/migrate diff --git a/cmd b/cmd index 0b6d49e..552a972 100755 --- a/cmd +++ b/cmd @@ -2,20 +2,26 @@ # 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 [args...] set -eu DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ $# -lt 1 ]; then + echo "Usage: ./cmd [args...]" + echo "" + echo "Available commands:" + for cmd in "$DIR"/framework/cmd.d/*; do + if [ -x "$cmd" ]; then + basename "$cmd" + fi + done + exit 1 +fi + subcmd="$1" - -# echo "$subcmd" - -#exit 3 - shift -echo will run "$DIR"/framework/cmd.d/"$subcmd" "$@" - exec "$DIR"/framework/cmd.d/"$subcmd" "$@" diff --git a/framework/cmd.d/db b/framework/common.d/db similarity index 100% rename from framework/cmd.d/db rename to framework/common.d/db diff --git a/framework/cmd.d/migrate b/framework/common.d/migrate similarity index 100% rename from framework/cmd.d/migrate rename to framework/common.d/migrate diff --git a/framework/develop.d/db b/framework/develop.d/db new file mode 120000 index 0000000..0e89a26 --- /dev/null +++ b/framework/develop.d/db @@ -0,0 +1 @@ +../common.d/db \ No newline at end of file diff --git a/framework/develop.d/migrate b/framework/develop.d/migrate new file mode 120000 index 0000000..5f4d586 --- /dev/null +++ b/framework/develop.d/migrate @@ -0,0 +1 @@ +../common.d/migrate \ No newline at end of file diff --git a/framework/mgmt.d/db b/framework/mgmt.d/db new file mode 120000 index 0000000..0e89a26 --- /dev/null +++ b/framework/mgmt.d/db @@ -0,0 +1 @@ +../common.d/db \ No newline at end of file diff --git a/framework/mgmt.d/migrate b/framework/mgmt.d/migrate new file mode 120000 index 0000000..5f4d586 --- /dev/null +++ b/framework/mgmt.d/migrate @@ -0,0 +1 @@ +../common.d/migrate \ No newline at end of file From 421628d49e46be3fce4922fd22f8541b58a235df Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 25 Jan 2026 12:11:34 -0600 Subject: [PATCH 135/137] Add various doc updates They are still very far from complete. --- docs/commands.md | 125 ++++++++++ docs/concentric-circles.md | 37 +++ docs/freedom-hacking-and-responsibility.md | 142 +++++++++++ docs/groups-and-roles.md | 27 +++ docs/index.md | 17 ++ ...nd-seeders-and-database-table-ownership.md | 34 +++ docs/mutability.md | 1 + docs/nomenclature.md | 11 + docs/ownership.md | 220 +++++++++++++++++- 9 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 docs/commands.md create mode 100644 docs/concentric-circles.md create mode 100644 docs/freedom-hacking-and-responsibility.md create mode 100644 docs/groups-and-roles.md create mode 100644 docs/index.md create mode 100644 docs/migrations-and-seeders-and-database-table-ownership.md create mode 100644 docs/mutability.md diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..273a65c --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,125 @@ +# The Three Types of Commands + +This framework deliberately separates *how* you interact with the system into three distinct command types. The split is not cosmetic; it encodes safety, intent, and operational assumptions directly into the tooling so that mistakes are harder to make under stress. + +The guiding idea: **production should feel boring and safe; exploration should feel powerful and a little dangerous; the application itself should not care how it is being operated.** + +--- + +## 1. Application Commands (`app`) + +**What they are** +Commands defined *by the application itself*, for its own domain needs. They are not part of the framework, even though they are built on top of it. + +The framework provides structure and affordances; the application supplies meaning. + +**Core properties** + +* Express domain behavior, not infrastructure concerns +* Safe by definition +* Deterministic and repeatable +* No environment‑dependent semantics +* Identical behavior in dev, staging, and production + +**Examples** + +* Handling HTTP requests +* Rendering templates +* Running background jobs / queues +* Sending emails triggered by application logic + +**Non‑goals** + +* No schema changes +* No data backfills +* No destructive behavior +* No operational or lifecycle management + +**Rule of thumb** +If removing the framework would require rewriting *how* it runs but not *what* it does, the command belongs here. + +--- + +## 2. Management Commands (`mgmt`) + +**What they are** +Operational, *production‑safe* commands used to evolve and maintain a live system. + +These commands assume real data exists and must not be casually destroyed. + +**Core properties** + +* Forward‑only +* Idempotent or safely repeatable +* Designed to run in production +* Explicit, auditable intent + +**Examples** + +* Applying migrations +* Running seeders that assert invariant data +* Reindexing or rebuilding derived data +* Rotating keys, recalculating counters + +**Design constraints** + +* No implicit rollbacks +* No hidden destructive actions +* Fail fast if assumptions are violated + +**Rule of thumb** +If you would run it at 3am while tired and worried, it must live here. + +--- + +## 3. Development Commands (`develop`) + +**What they are** +Sharp, *unsafe by design* tools meant exclusively for local development and experimentation. + +These commands optimize for speed, learning, and iteration — not safety. + +**Core properties** + +* Destructive operations allowed +* May reset or mutate large amounts of data +* Assume a clean or disposable environment +* Explicitly gated in production + +**Examples** + +* Dropping and recreating databases +* Rolling migrations backward +* Loading fixtures or scenarios +* Generating fake or randomized data + +**Safety model** + +* Hard to run in production +* Requires explicit opt‑in if ever enabled +* Clear, noisy warnings when invoked + +**Rule of thumb** +If it would be irresponsible to run against real user data, it belongs here. + +--- + +## Why This Split Matters + +Many frameworks blur these concerns, leading to: + +* Fearful production operations +* Overpowered dev tools leaking into prod +* Environment‑specific behavior and bugs + +By naming and enforcing these three command types: + +* Intent is visible at the CLI level +* Safety properties are architectural, not cultural +* Developers can move fast *without* normalizing risk + +--- + +## One‑Sentence Summary + +> **App commands run the system, mgmt commands evolve it safely, and develop commands let you break things on purpose — but only where it’s allowed.** diff --git a/docs/concentric-circles.md b/docs/concentric-circles.md new file mode 100644 index 0000000..668ed3a --- /dev/null +++ b/docs/concentric-circles.md @@ -0,0 +1,37 @@ +Let's consider a bullseye with the following concentric circles: + +- Ring 0: small, simple systems + - Single jurisdiction + - Email + password + - A few roles + - Naïve or soft deletion + - Minimal audit needs + +- Ring 1: grown-up systems + - Long-lived data + - Changing requirements + - Shared accounts + - GDPR-style erasure/anonymization + - Some cross-border concerns + - Historical data must remain usable + - “Oops, we should have thought about that” moments + +- Ring 2: heavy compliance + - Formal audit trails + - Legal hold + - Non-repudiation + - Regulatory reporting + - Strong identity guarantees + - Jurisdiction-aware data partitioning + +- Ring 3: banking / defense / healthcare at scale + - Cryptographic auditability + - Append-only ledgers + - Explicit legal models + - Independent compliance teams + - Lawyers embedded in engineeRing + +diachron is designed to be suitable for Rings 0 and 1. Occasionally we may +look over the fence into Ring 2, but it's not what we've principally designed +for. Please take this framing into account when evaluating diachron for +greenfield projects. diff --git a/docs/freedom-hacking-and-responsibility.md b/docs/freedom-hacking-and-responsibility.md new file mode 100644 index 0000000..1f2c568 --- /dev/null +++ b/docs/freedom-hacking-and-responsibility.md @@ -0,0 +1,142 @@ +# Freedom, Hacking, and Responsibility + +This framework is **free and open source software**. + +That fact is not incidental. It is a deliberate ethical, practical, and technical choice. + +This document explains how freedom to modify coexists with strong guidance about *how the framework is meant to be used* — without contradiction, and without apology. + +--- + +## The short version + +* This is free software. You are free to modify it. +* The framework has documented invariants for good reasons. +* You are encouraged to explore, question, and patch. +* You are discouraged from casually undermining guarantees you still expect to rely on. +* Clarity beats enforcement. + +Freedom with understanding beats both lock-in and chaos. + + +--- + +## Your Freedom + +You are free to: + +* study the source code +* run the software for any purpose +* modify it in any way +* fork it +* redistribute it, with or without changes +* submit patches, extensions, or experiments + +…subject only to the terms of the license. + +These freedoms are foundational. They are not granted reluctantly, and they are not symbolic. They exist so that: + +* you can understand what your software is really doing +* you are not trapped by vendor control +* the system can outlive its original authors + +--- + +## Freedom Is Not the Same as Endorsement + +While you are free to change anything, **not all changes are equally wise**. + +Some parts of the framework are carefully constrained because they encode: + +* security assumptions +* lifecycle invariants +* hard-won lessons from real systems under stress + +You are free to violate these constraints in your own fork. + +But the framework’s documentation will often say things like: + +* “do not modify this” +* “application code must not depend on this” +* “this table or class is framework-owned” + +These statements are **technical guidance**, not legal restrictions. + +They exist to answer the question: + +> *If you want this system to remain upgradeable, predictable, and boring — what should you leave alone?* + +--- + +## The Intended Social Contract + +The framework makes a clear offer: + +* We expose our internals so you can learn. +* We provide explicit extension points so you can adapt. +* We document invariants so you don’t have to rediscover them the hard way. + +In return, we ask that: + +* application code respects documented boundaries +* extensions use explicit seams rather than hidden hooks +* patches that change invariants are proposed consciously, not accidentally + +Nothing here is enforced by technical locks. + +It is enforced — insofar as it is enforced at all — by clarity and shared expectations. + +--- + +## Hacking Is Welcome + +Exploration is not just allowed; it is encouraged. + +Good reasons to hack on the framework include: + +* understanding how it works +* evaluating whether its constraints make sense +* adapting it to unfamiliar environments +* testing alternative designs +* discovering better abstractions + +Fork it. Instrument it. Break it. Learn from it. + +Many of the framework’s constraints exist *because* someone once ignored them and paid the price. + +--- + +## Patches, Not Patches-in-Place + +If you discover a problem or a better design: + +* patches are welcome +* discussions are welcome +* disagreements are welcome + +What is discouraged is **quietly patching around framework invariants inside application code**. + +That approach: + +* obscures intent +* creates one-off local truths +* makes systems harder to reason about + +If the framework is wrong, it should be corrected *at the framework level*, or consciously forked. + +--- + +## Why This Is Not a Contradiction + +Strong opinions and free software are not enemies. + +Freedom means you can change the software. + +Responsibility means understanding what you are changing, and why. + +A system that pretends every modification is equally safe is dishonest. + +A system that hides its internals to prevent modification is hostile. + +This framework aims for neither. + diff --git a/docs/groups-and-roles.md b/docs/groups-and-roles.md new file mode 100644 index 0000000..c6a694e --- /dev/null +++ b/docs/groups-and-roles.md @@ -0,0 +1,27 @@ +- Role: a named bundle of responsibilities (editor, admin, member) + +- Group: a scope or context (org, team, project, publication) + +- Permission / Capability (capability preferred in code): a boolean fact about + allowed behavior + + +## tips + +- In the database, capabilities are boolean values. Their names should be + verb-subject. Don't include `can` and definitely do not include `cannot`. + + ✔️ `edit_post` + ❌ `cannot_remove_comment` + +- The capabilities table is deliberately flat. If you need to group them, use + `.` as a delimiter and sort and filter accordingly in queries and in your + UI. + ✔️ `blog.edit_post` + ✔️ `blog.moderate_comment` + or + ✔️ `blog.post.edit` + ✔️ `blog.post.delete` + ✔️ `blog.comment.moderate` + ✔️ `blog.comment.edit` + are all fine. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..934502c --- /dev/null +++ b/docs/index.md @@ -0,0 +1,17 @@ +misc notes for now. of course this needs to be written up for real. + + +## execution context + +The execution context represents facts such as the runtime directory, the +operating system, hardware, and filesystem layout, distinct from environment +variables or request-scoped context. + +## philosophy + +- TODO-DESIGN.md +- concentric-circles.md +- nomenclature.md +- mutability.md +- commands.md +- groups-and-roles.md diff --git a/docs/migrations-and-seeders-and-database-table-ownership.md b/docs/migrations-and-seeders-and-database-table-ownership.md new file mode 100644 index 0000000..a073eda --- /dev/null +++ b/docs/migrations-and-seeders-and-database-table-ownership.md @@ -0,0 +1,34 @@ +Some database tables are owned by diachron and some are owned by the +application. + +This also applies to seeders: some are owned by diachron and some by the +application. + +The database's structure is managed by migrations written in SQL. + +Each migration gets its own file. These files' names should match +`yyyy-mm-dd_ss-description.sql`, eg `2026-01-01_01-users.sql`. + +Files are sorted lexicographically by name and applied in order. + +Note: in the future we may relax or modify the restriction on migration file +names, but they'll continue to be applied in lexicographical order. + +## framework and application migrations + +Migrations owned by the framework are kept in a separate directory from those +owned by applications. Pending framework migrations, if any, are applied +before pending application migrations, if any. + +diachron will go to some lengths to ensure that framework migrations do not +break applications. + +## no downward migrations + +diachron does not provide them. "The only way out is through." + +When developing locally, you can use the command `develop reset-db`. **NEVER +USE THIS IN PRODUCTION!** Always be sure that you can "get back to where you +were". Being careful when creating migrations and seeders can help, but +dumping and restoring known-good copies of the database can also take you a +long way. diff --git a/docs/mutability.md b/docs/mutability.md new file mode 100644 index 0000000..3555291 --- /dev/null +++ b/docs/mutability.md @@ -0,0 +1 @@ +Describe and define what is expected to be mutable and what is not. diff --git a/docs/nomenclature.md b/docs/nomenclature.md index 381e5c9..1b9425e 100644 --- a/docs/nomenclature.md +++ b/docs/nomenclature.md @@ -2,3 +2,14 @@ We use `Call` and `Result` for our own types that wrap `Request` and `Response`. This hopefully will make things less confusing and avoid problems with shadowing. + +## meta + +- We use _algorithmic complexity_ for performance discussions, when + things like Big-O come up, etc + +- We use _conceptual complexity_ for design and architecture + +- We use _cognitive load_ when talking about developer experience + +- We use _operational burden_ when talking about production reality diff --git a/docs/ownership.md b/docs/ownership.md index 9c558e3..5eded9c 100644 --- a/docs/ownership.md +++ b/docs/ownership.md @@ -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 *framework‑owned* and *application‑owned* concerns, while still encouraging extension through explicit, visible mechanisms. + +--- + +## Core Principle + +The framework is not a library of suggestions. It is a **runtime with invariants**. + +Application code: + +* **uses** the framework +* **extends** it through defined seams +* **never mutates or overrides its invariants** + +Framework code: + +* guarantees stable behavior +* owns critical lifecycle and security concerns +* must remain internally consistent across versions + +Breaking this boundary creates systems that work *until they don’t*, usually during upgrades or emergencies. + +--- + +## Database Ownership + +### Framework‑Owned Tables + +Certain database tables are **owned and managed exclusively by the framework**. + +Examples (illustrative, not exhaustive): + +* authentication primitives +* session or token state +* internal capability/permission metadata +* migration bookkeeping +* framework feature flags or invariants + +#### Rules + +Application code **must not**: + +* modify schema +* add columns +* delete rows +* update rows directly +* rely on undocumented columns or behaviors + +Application code **may**: + +* read via documented framework APIs +* reference stable identifiers explicitly exposed by the framework + +Think of these tables as **private internal state** — even though they live in your database. + +> If the framework needs you to interact with this data, it will expose an API for it. + +#### Rationale + +These tables: + +* encode security or correctness invariants +* may change structure across framework versions +* must remain globally coherent + +Treating them as app‑owned data tightly couples your app to framework internals and blocks safe upgrades. + +--- + +### Application‑Owned Tables + +All domain data belongs to the application. + +Examples: + +* users (as domain actors, not auth primitives) +* posts, orders, comments, invoices +* business‑specific joins and projections +* denormalized or performance‑oriented tables + +#### Rules + +Application code: + +* owns schema design +* owns migrations +* owns constraints and indexes +* may evolve these tables freely + +The framework: + +* never mutates application tables implicitly +* interacts only through explicit queries or contracts + +#### Integration Pattern + +Where framework concepts must relate to app data: + +* use **foreign keys to framework‑exposed identifiers**, or +* introduce **explicit join tables** owned by the application + +No hidden coupling, no magic backfills. + +--- + +## Code Ownership + +### Framework‑Owned Code + +Some classes, constants, and modules are **framework‑owned**. + +These include: + +* core request/response abstractions +* auth and user primitives +* capability/permission evaluation logic +* lifecycle hooks +* low‑level utilities relied on by the framework itself + +#### Rules + +Application code **must not**: + +* modify framework source +* monkey‑patch or override internals +* rely on undocumented behavior +* change constant values or internal defaults + +Framework code is treated as **read‑only** from the app’s perspective. + +--- + +### Extension Is Encouraged (But Explicit) + +Ownership does **not** mean rigidity. + +The framework is designed to be extended via **intentional seams**, such as: + +* subclassing +* composition +* adapters +* delegation +* configuration objects +* explicit registration APIs + +#### Preferred Patterns + +* **Subclass when behavior is stable and conceptual** +* **Compose when behavior is contextual or optional** +* **Delegate when authority should remain with the framework** + +What matters is that extension is: + +* visible in code +* locally understandable +* reversible + +No spooky action at a distance. + +--- + +## What the App Owns Completely + +The application fully owns: + +* domain models and data shapes +* SQL queries and result parsing +* business rules +* authorization policy *inputs* (not the engine) +* rendering decisions +* feature flags specific to the app +* performance trade‑offs + +The framework does not attempt to infer intent from your domain. + +--- + +## What the Framework Guarantees + +In return for respecting ownership boundaries, the framework guarantees: + +* stable semantics across versions +* forward‑only migrations for its own tables +* explicit deprecations +* no silent behavior changes +* identical runtime behavior in dev and prod + +The framework may evolve internally — **but never by reaching into your app’s data or code**. + +--- + +## A Useful Mental Model + +* Framework‑owned things are **constitutional law** +* Application‑owned things are **legislation** + +You can write any laws you want — but you don’t amend the constitution inline. + +If you need a new power, the framework should expose it deliberately. + +--- + +## Summary + +* Ownership is about **who is allowed to change what** +* Framework‑owned tables and code are read‑only to the app +* Application‑owned tables and code are sovereign +* Extension is encouraged, mutation is not +* Explicit seams beat clever hacks + +Respecting these boundaries keeps systems boring — and boring systems survive stress. From 478305bc4f498c5e893a5c14397bab0c30a90620 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 25 Jan 2026 12:12:02 -0600 Subject: [PATCH 136/137] Update /home template --- templates/basic/home.html.njk | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/templates/basic/home.html.njk b/templates/basic/home.html.njk index 20af87f..e9608cf 100644 --- a/templates/basic/home.html.njk +++ b/templates/basic/home.html.njk @@ -8,6 +8,12 @@ {{ email }}

+ {% if showLogin %} + login + {% endif %} + + {% if showLogout %} logout + {% endif %} From cd19a32be55ce0b5fe9405764bdc5ea88543057a Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 25 Jan 2026 12:12:15 -0600 Subject: [PATCH 137/137] Add more todo items --- TODO.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/TODO.md b/TODO.md index b4f24ad..eb1bcba 100644 --- a/TODO.md +++ b/TODO.md @@ -4,8 +4,33 @@ - ⚠️ Huge task - needs breakdown before starting +- [ ] migrations, seeding, fixtures +```sql +CREATE SCHEMA fw; +CREATE TABLE fw.users (...); +CREATE TABLE fw.groups (...); +``` +```sql +CREATE TABLE app.user_profiles (...); +CREATE TABLE app.customer_metadata (...); +``` + +- [ ] flesh out `mgmt` and `develop` (does not exist yet) + +4.1 What belongs in develop + +- Create migrations +- Squash migrations +- Reset DB +- Roll back migrations +- Seed large test datasets +- Run tests +- Snapshot / restore local DB state (!!!) + +`develop` fails if APP_ENV (or whatever) is `production`. Or maybe even +`testing`. - [ ] Add default user table(s) to database. @@ -44,6 +69,8 @@ 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