Tighten code comments + README (todo §0): denser, drop redundant prose; no behavior change
This commit is contained in:
@@ -1,11 +1,9 @@
|
|||||||
# Node 24 runs TypeScript directly (type stripping) — no build step.
|
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||||
# Pinned to an exact, human-readable version (node / alpine).
|
|
||||||
FROM node:24.16.0-alpine3.24
|
FROM node:24.16.0-alpine3.24
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Reproducible install from the committed lockfile. Dev deps (typescript, types)
|
# Reproducible install from the lockfile. Dev deps kept so typecheck/test run in-image.
|
||||||
# are kept so `npm run typecheck` / `npm test` work in the same image.
|
|
||||||
COPY package.json package-lock.json .npmrc ./
|
COPY package.json package-lock.json .npmrc ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
|||||||
122
README.md
122
README.md
@@ -23,42 +23,39 @@ or migrate.
|
|||||||
|
|
||||||
## Who this is for
|
## Who this is for
|
||||||
|
|
||||||
**Experienced developers building back-office, admin, and dashboard products** —
|
**Experienced developers building back-office, admin, and dashboard products** — for
|
||||||
for their own use or for a client. You know your way around HTTP, Docker, and an
|
their own use or for a client. You know HTTP, Docker, and identity providers, and
|
||||||
identity provider, and you'd rather assemble pages from solid building blocks than
|
you'd rather assemble pages from building blocks than fight a framework or hand-roll
|
||||||
fight a framework or hand-roll auth for the tenth time. Plainpages hands you the
|
auth for the tenth time. Plainpages hands you the boring-but-hard parts (auth, authz,
|
||||||
boring-but-hard parts (auth, authz, menu, design system, plugin host) and stays out
|
menu, design system, plugin host) and stays out of your domain logic. It's not a
|
||||||
of the way of your domain logic. It does not try to be a no-code tool or hide its
|
no-code tool and doesn't hide its moving parts: if "Ory is down ⇒ no logins" (see
|
||||||
moving parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--permissions-planned))
|
[Auth](#auth-sessions--permissions-planned)) reads as obvious rather than a surprise,
|
||||||
reads as an obvious consequence rather than a surprise, you're the audience.
|
you're the audience.
|
||||||
|
|
||||||
## Project goals
|
## Project goals
|
||||||
|
|
||||||
Beyond the priorities above, Plainpages deliberately targets **low-end systems, odd
|
Plainpages deliberately targets **low-end systems, odd hardware, and low-bandwidth
|
||||||
hardware, and low-bandwidth environments** — a tablet on a factory floor, an old
|
environments** — a tablet on a factory floor, an old thin client at a reception desk,
|
||||||
thin client at a reception desk, a remote site on a flaky link. That's *why* the
|
a remote site on a flaky link. That's *why* the baseline is boring, standards-compliant
|
||||||
baseline is standards-compliant, boring **HTML + CSS** with zero JavaScript: it
|
**HTML + CSS** with zero JavaScript: it loads fast, degrades gracefully, and works on
|
||||||
loads fast, degrades gracefully, and works on whatever browser the site already
|
whatever browser is already there. Where a modern **CSS** feature removes the need for
|
||||||
has. Where a modern **CSS** feature removes the need to ship JavaScript (theme
|
JavaScript (theme switching, popovers, disclosure) we use it — the trade we avoid is
|
||||||
switching, popovers, disclosure), we'll happily use it — the trade we avoid is
|
|
||||||
shipping a client-side runtime, not using the platform.
|
shipping a client-side runtime, not using the platform.
|
||||||
|
|
||||||
> **Status.** This README describes the target architecture (the project's scope).
|
> **Status.** This README describes the target architecture. What exists today is the
|
||||||
> What exists in the repo today is the **scaffold** — a Node 24 + EJS HTTP server
|
> **scaffold** — a Node 24 + EJS HTTP server with static serving — plus the **design
|
||||||
> with static serving — plus the **design foundation** in `html-css-foundation/`
|
> foundation** in `html-css-foundation/` (a complete zero-JS app shell + auth screens).
|
||||||
> (a complete zero-JS app shell + auth screens). The plugin host and Ory
|
> The plugin host and Ory integration (Kratos/Keto/Hydra + their Postgres) are the
|
||||||
> integration (Kratos/Keto/Hydra + their Postgres) are the roadmap below, not yet
|
> roadmap below. Sections marked _(planned)_ are not built yet.
|
||||||
> implemented. Sections marked _(planned)_ are not built yet.
|
|
||||||
|
|
||||||
## The MVP — "clone, one command, hack on a plugin" _(planned)_
|
## The MVP — "clone, one command, hack on a plugin" _(planned)_
|
||||||
|
|
||||||
The bar for a first usable release: **clone, run one command, get a working
|
The bar for a first usable release: **clone, run one command, get a working
|
||||||
register/login, and start building your own plugin** — no manual key generation, no
|
register/login, and start building your own plugin** — no manual key generation, no
|
||||||
hand-edited Ory config, no separate database. That one command brings up the whole
|
hand-edited Ory config, no separate database. That command brings up the whole stack
|
||||||
stack (web + Ory + Postgres), generates signing keys, seeds an admin on first boot,
|
(web + Ory + Postgres), generates signing keys, seeds an admin on first boot, and drops
|
||||||
and drops you at a login screen; from there you copy the example plugin folder and
|
you at a login screen; from there you copy the example plugin folder and write your own
|
||||||
write your own page. SSO and the OAuth2-provider role (Hydra) come after — not
|
page. SSO and the OAuth2-provider role (Hydra) come after — not required to start.
|
||||||
required to start.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -117,9 +114,9 @@ file as they land — planned.)_
|
|||||||
|
|
||||||
Read from the environment once at boot (`src/config.ts`) and validated there — a bad
|
Read from the environment once at boot (`src/config.ts`) and validated there — a bad
|
||||||
URL, an out-of-range `PORT`, or a missing/throwaway production secret fails loud before
|
URL, an out-of-range `PORT`, or a missing/throwaway production secret fails loud before
|
||||||
the server starts. A clean clone needs **none** of these set; every value defaults to
|
the server starts. A clean clone needs **none** of these; every value defaults to the
|
||||||
the dev stack. In production (`NODE_ENV=production`) the two secrets must be supplied
|
dev stack. In production (`NODE_ENV=production`) the two secrets must be supplied and
|
||||||
and may not stay at their dev throwaways — everything else still defaults.
|
must differ from their dev throwaways — everything else still defaults.
|
||||||
|
|
||||||
| Var | Default | Notes |
|
| Var | Default | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -219,13 +216,12 @@ the work is extracting it into reusable EJS partials + TS helpers:
|
|||||||
## Interactivity: zero-JS spine, opt-in enhancement
|
## Interactivity: zero-JS spine, opt-in enhancement
|
||||||
|
|
||||||
The core and all building blocks **work with zero JavaScript** — menus, theme
|
The core and all building blocks **work with zero JavaScript** — menus, theme
|
||||||
switching, and filtering are pure CSS + GET forms (server-side). This is the robust
|
switching, and filtering are pure CSS + GET forms. On the [low-end, low-bandwidth
|
||||||
default for back-office and industrial use, and on the [low-end, low-bandwidth
|
targets](#project-goals) we care about this is usually *faster*: a round-trip returning
|
||||||
targets](#project-goals) we care about it's usually *faster*: a full round-trip
|
a small, pre-rendered HTML page beats a client-side runtime that must boot, fetch JSON,
|
||||||
that returns a small, already-rendered HTML page beats a client-side runtime that
|
and re-render before anything shows. List state (`?q=…&status=…&sort=…&page=…`) lives
|
||||||
must boot, fetch JSON, and re-render before the user sees anything. List state
|
**in the URL**, so a view is bookmarkable, shareable, and reproducible — the URL is the
|
||||||
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable,
|
only state the UI keeps.
|
||||||
shareable, and reproducible — the URL is the only state the UI keeps.
|
|
||||||
|
|
||||||
Plugins that genuinely need it — live dashboards, bulk actions, client-side
|
Plugins that genuinely need it — live dashboards, bulk actions, client-side
|
||||||
validation — may **opt into progressive enhancement** (htmx, Alpine, or vanilla
|
validation — may **opt into progressive enhancement** (htmx, Alpine, or vanilla
|
||||||
@@ -239,15 +235,14 @@ fine-grained, must-be-fresh check.
|
|||||||
|
|
||||||
### Login → session JWT (the Kratos session tokenizer)
|
### Login → session JWT (the Kratos session tokenizer)
|
||||||
|
|
||||||
The themed sign-in / register / reset / SSO screens drive Kratos self-service
|
The themed sign-in / register / reset / SSO screens drive Kratos self-service flows.
|
||||||
flows. **SSO is entirely optional and self-configuring:** each provider's button
|
**SSO is optional and self-configuring:** each provider's button renders only when its
|
||||||
renders only when its credentials are present, and if no provider is configured the
|
credentials are present, and the whole SSO section disappears when none are configured —
|
||||||
SSO section disappears altogether — leaving plain password login. A developer never
|
leaving plain password login. A developer never has to touch SSO to get started. On
|
||||||
has to touch SSO to get started. On success, instead of keeping the opaque Kratos
|
success, rather than keeping the opaque Kratos cookie and calling `whoami` on every
|
||||||
cookie and calling `whoami` on every request, the app **exchanges the session for a
|
request, the app **exchanges the session for a signed JWT once** via the Kratos
|
||||||
signed JWT once**
|
**session tokenizer** (`whoami` with a `tokenize_as` template) and stores it as the
|
||||||
via the Kratos **session tokenizer** — `whoami` with a `tokenize_as` template — and
|
session cookie.
|
||||||
stores that JWT as the session cookie.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
|
── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
|
||||||
@@ -266,12 +261,12 @@ stores that JWT as the session cookie.
|
|||||||
|
|
||||||
**Keto is the single source of truth for roles.** Coarse roles are Keto relations
|
**Keto is the single source of truth for roles.** Coarse roles are Keto relations
|
||||||
(e.g. `role:admin#member@user:alice`); the admin screens write them *only* to Keto.
|
(e.g. `role:admin#member@user:alice`); the admin screens write them *only* to Keto.
|
||||||
But the tokenizer's claims mapper can only read the **identity**, not call Keto — so
|
But the tokenizer's claims mapper can read only the **identity**, not call Keto — so at
|
||||||
at login the app reads the user's roles from Keto and refreshes a **derived
|
login the app reads the roles from Keto and refreshes a **derived projection**: a
|
||||||
projection** — a read-only copy of those roles written onto the identity's
|
read-only copy written onto the identity's `metadata_admin` for the tokenizer to see,
|
||||||
`metadata_admin` so the tokenizer can see them — which the tokenizer template then
|
which the template maps into the JWT `roles` claim. That projection is a per-login
|
||||||
maps into the JWT `roles` claim. That projection is a per-login cache, authoritative
|
cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals on
|
||||||
nowhere; nothing edits it by hand, and a stale one self-heals on the next login.
|
the next login.
|
||||||
|
|
||||||
Cost: **one Keto read + one identity refresh per login** — never per request. JWKS
|
Cost: **one Keto read + one identity refresh per login** — never per request. JWKS
|
||||||
is cached, so even signature verification hits the network only on key rotation. The
|
is cached, so even signature verification hits the network only on key rotation. The
|
||||||
@@ -280,20 +275,19 @@ moment authz is recomputed from Keto.
|
|||||||
|
|
||||||
#### Two trade-offs — both deliberate
|
#### Two trade-offs — both deliberate
|
||||||
|
|
||||||
This design buys an I/O-free hot path that scales to **tens of thousands of
|
This design buys an I/O-free hot path that scales to **tens of thousands of concurrent
|
||||||
concurrent users** on modest hardware. In return:
|
users** on modest hardware. In return:
|
||||||
|
|
||||||
- **Role changes lag by up to one TTL (~10m).** Because gating reads the JWT, not
|
- **Role changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a
|
||||||
Keto, a granted or revoked role only takes effect when the token is next minted
|
granted or revoked role only takes effect when the token is next minted (re-login or
|
||||||
(re-login or TTL refresh). For an admin tool this is intentional: the alternative
|
TTL refresh). For an admin tool this is intentional — the alternative is a Keto call
|
||||||
is a Keto call on every request, which we explicitly traded away. If a deployment
|
per request, which we traded away. For instant revoke, the optional revocation
|
||||||
needs instant revoke, the optional revocation denylist (roadmap) closes the gap
|
denylist (roadmap) closes the gap for security-critical cases without putting Keto
|
||||||
for the security-critical cases without putting Keto back on the hot path.
|
back on the hot path.
|
||||||
- **Ory is on the critical path for sign-in.** If Kratos is down, no one can log
|
- **Ory is on the critical path for sign-in.** If Kratos is down no one can log in; if
|
||||||
in; if it stays down past the TTL, existing sessions can't refresh and the UI
|
it stays down past the TTL, existing sessions can't refresh and the UI goes dark.
|
||||||
goes dark. This is the direct consequence of being stateless and delegating
|
That's the direct consequence of being stateless and delegating identity — no local
|
||||||
identity — there is no local fallback, by design. Run Ory with the same
|
fallback, by design. Run Ory with the availability you'd give any auth provider.
|
||||||
availability you'd give any auth provider.
|
|
||||||
|
|
||||||
### Three tiers of "may I?"
|
### Three tiers of "may I?"
|
||||||
|
|
||||||
|
|||||||
11
src/app.ts
11
src/app.ts
@@ -8,8 +8,7 @@ import { serveStatic } from "./static.ts";
|
|||||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
export interface AppOptions {
|
export interface AppOptions {
|
||||||
// Cache compiled templates (compile once vs. re-read+recompile per request).
|
// Cache compiled templates: on in production, off in dev so edits show live.
|
||||||
// Defaults to on in production, off in dev so source edits show up live.
|
|
||||||
cache?: boolean;
|
cache?: boolean;
|
||||||
publicDir?: string;
|
publicDir?: string;
|
||||||
viewsDir?: string;
|
viewsDir?: string;
|
||||||
@@ -35,8 +34,8 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The single request shape handlers receive (§2/§4 router passes it on); routing
|
// The request shape handlers receive (§2/§4 router passes it on); routing
|
||||||
// reads its parsed URL instead of building a throwaway one.
|
// reuses its parsed URL instead of building a throwaway.
|
||||||
const { pathname } = buildContext(req, res).url;
|
const { pathname } = buildContext(req, res).url;
|
||||||
|
|
||||||
if (pathname.startsWith("/public/")) {
|
if (pathname.startsWith("/public/")) {
|
||||||
@@ -54,8 +53,8 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
if (res.headersSent) return void res.end(); // a partial body is already on the wire
|
if (res.headersSent) return void res.end(); // a partial body is already on the wire
|
||||||
try {
|
try {
|
||||||
// Render first: if the error page itself fails, headers stay unsent and we
|
// Render before writing: if the 500 page itself throws, headers stay unsent
|
||||||
// fall back to plain text below rather than emit a half-written response.
|
// and we fall back to plain text below instead of a half-written response.
|
||||||
sendHtml(res, 500, await render("500", { title: "Server error" }));
|
sendHtml(res, 500, await render("500", { title: "Server error" }));
|
||||||
} catch (renderErr) {
|
} catch (renderErr) {
|
||||||
console.error(renderErr);
|
console.error(renderErr);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
// Config loaded once from the environment at boot (todo §0): Ory endpoints, the
|
// Config loaded once from the environment at boot (todo §0): Ory endpoints, cookie/CSRF
|
||||||
// cookie/CSRF secrets, the JWKS location, and the listen port. Fail-loud — a missing
|
// secrets, JWKS location, listen port. Fail-loud — a missing prod secret, a bad URL, or
|
||||||
// production secret, a bad URL, or an out-of-range port throws here, before the server
|
// an out-of-range port throws here at boot, never at request time.
|
||||||
// starts, never at request time.
|
|
||||||
//
|
//
|
||||||
// Clean-clone philosophy (README): every value has a working dev default so `docker
|
// Clean-clone (README): every value has a working dev default, so `docker compose up`
|
||||||
// compose up` runs with zero config; in production only the secrets must be supplied
|
// runs with zero config; in production the secrets must be supplied (dev throwaways
|
||||||
// (the dev throwaways are refused), everything else still defaults to the Ory services.
|
// refused), everything else still defaults to the Ory services.
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
cookieSecret: string;
|
cookieSecret: string;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
// The request context threaded to every route handler (plugin + built-in). Built
|
// The request context threaded to every route handler (plugin + built-in), built once
|
||||||
// once per request by `buildContext`: the router supplies matched path `params`,
|
// per request by `buildContext`: the router supplies matched path `params`, the §4 JWT
|
||||||
// the §4 JWT middleware supplies the `user` (null/[] until then). Handlers read the
|
// middleware supplies `user` (null until then). The host's single handler argument.
|
||||||
// request and write the response through it — the host's single handler argument.
|
|
||||||
|
|
||||||
// The authenticated user, projected from the verified session JWT claims (§4):
|
// The authenticated user, projected from verified session JWT claims (§4):
|
||||||
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
|
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
|
||||||
export interface User {
|
export interface User {
|
||||||
email: string;
|
email: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Cookie helpers — parse the request `Cookie` header, build secure-by-default
|
// Cookie helpers — parse the request `Cookie` header, build secure-by-default
|
||||||
// `Set-Cookie` headers. Stdlib only (no `cookie` dep); §4 stores/clears the session
|
// `Set-Cookie` headers. Stdlib only (no `cookie` dep); §4 stores/clears the session
|
||||||
// JWT + CSRF token with these. Values round-trip via percent-encoding (serialize
|
// JWT + CSRF token here. Values round-trip via percent-encoding; JWT `-_.` chars are
|
||||||
// encodes, parse decodes); JWT `-_.` chars are URI-unreserved, so JWTs stay readable.
|
// URI-unreserved, so JWTs stay readable.
|
||||||
|
|
||||||
export interface CookieOptions {
|
export interface CookieOptions {
|
||||||
domain?: string;
|
domain?: string;
|
||||||
@@ -22,7 +22,7 @@ const minExpires = Date.UTC(1601, 0, 1);
|
|||||||
const maxExpires = Date.UTC(9999, 11, 31, 23, 59, 59, 999);
|
const maxExpires = Date.UTC(9999, 11, 31, 23, 59, 59, 999);
|
||||||
|
|
||||||
function decode(value: string): string {
|
function decode(value: string): string {
|
||||||
if (!value.includes("%")) return value; // optimization only: an unencoded value has no escapes to decode
|
if (!value.includes("%")) return value; // fast path: nothing to decode
|
||||||
try {
|
try {
|
||||||
return decodeURIComponent(value);
|
return decodeURIComponent(value);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -30,10 +30,9 @@ function decode(value: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse a request `Cookie` header into a name→value map. First occurrence of a
|
// Parse a `Cookie` header into a name→value map. First occurrence of a name wins.
|
||||||
// name wins (a later duplicate can't shadow it). The result is a null-prototype
|
// Null-prototype result, so a `__proto__`/`constructor` key can't pollute. Header
|
||||||
// object, so an attacker-supplied `__proto__`/`constructor` key can't pollute.
|
// length is bounded upstream by Node's `maxHeaderSize` (~16 KB).
|
||||||
// Input length is bounded upstream by Node's HTTP `maxHeaderSize` (~16 KB default).
|
|
||||||
export function parseCookies(header: string | undefined): Record<string, string> {
|
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||||
const out: Record<string, string> = Object.create(null);
|
const out: Record<string, string> = Object.create(null);
|
||||||
if (!header) return out;
|
if (!header) return out;
|
||||||
@@ -50,10 +49,9 @@ export function parseCookies(header: string | undefined): Record<string, string>
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate a Domain/Path attribute: non-empty (an empty one emits a junk `Path=`
|
// Validate a Domain/Path attribute: non-empty (fail loud on a misconfig) and free of
|
||||||
// browsers ignore — fail loud on a misconfig), and free of chars that could inject
|
// chars that could inject extra attributes or split the header (CRLF). Cheap insurance
|
||||||
// extra attributes or split the response header (CRLF). These come from config, but
|
// against Set-Cookie injection, even though these come from config.
|
||||||
// validating is cheap insurance against Set-Cookie injection.
|
|
||||||
function assertAttrSafe(label: string, value: string): void {
|
function assertAttrSafe(label: string, value: string): void {
|
||||||
if (value === "" || /[;\x00-\x1f\x7f]/.test(value)) throw new Error(`invalid cookie ${label}: ${JSON.stringify(value)}`);
|
if (value === "" || /[;\x00-\x1f\x7f]/.test(value)) throw new Error(`invalid cookie ${label}: ${JSON.stringify(value)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/jwt.ts
21
src/jwt.ts
@@ -2,16 +2,13 @@ import { createPublicKey, verify } from "node:crypto";
|
|||||||
import type { JsonWebKey, KeyObject } from "node:crypto";
|
import type { JsonWebKey, KeyObject } from "node:crypto";
|
||||||
|
|
||||||
// JWS signature verification with the Node stdlib — no `jose`/JWT dep (todo §0):
|
// JWS signature verification with the Node stdlib — no `jose`/JWT dep (todo §0):
|
||||||
// `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures the
|
// `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures
|
||||||
// Kratos tokenizer produces — all we need, no supply-chain surface (see AGENTS.md).
|
// the Kratos tokenizer produces (see AGENTS.md). Signature only — §4 adds claim checks
|
||||||
//
|
// (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and `token` bounds.
|
||||||
// Signature only. §4 builds the rest on top: claim checks (exp/iss/aud, clock skew),
|
|
||||||
// JWKS-by-`kid` fetch/cache/rotation, and bounding `token` type/length at the boundary.
|
|
||||||
|
|
||||||
// JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER.
|
// JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER.
|
||||||
// Widen support by extending this map. Security invariant: never add an `HS*` (symmetric)
|
// Extend this map to widen support. Security invariant: never add `HS*`/`none` — this map
|
||||||
// entry — this map is the allowlist, and one would let an attacker-supplied HMAC key verify.
|
// is the allowlist, and a symmetric entry lets an attacker-supplied HMAC key verify.
|
||||||
// `none` is absent for the same reason.
|
|
||||||
const algParams: Record<string, { hash: string; keyType: "ec" | "rsa"; dsaEncoding?: "ieee-p1363" }> = {
|
const algParams: Record<string, { hash: string; keyType: "ec" | "rsa"; dsaEncoding?: "ieee-p1363" }> = {
|
||||||
ES256: { dsaEncoding: "ieee-p1363", hash: "SHA256", keyType: "ec" },
|
ES256: { dsaEncoding: "ieee-p1363", hash: "SHA256", keyType: "ec" },
|
||||||
RS256: { hash: "RSA-SHA256", keyType: "rsa" },
|
RS256: { hash: "RSA-SHA256", keyType: "rsa" },
|
||||||
@@ -30,8 +27,8 @@ export interface DecodedJws {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unpadded base64url alphabet — `Buffer.from(_,"base64url")` is lax (drops junk, tolerates
|
// Unpadded base64url alphabet — `Buffer.from(_,"base64url")` is lax (drops junk, tolerates
|
||||||
// non-canonical padding), so reject non-canonical segments up front. §4 reads `kid` from the
|
// bad padding), so reject non-canonical segments up front. §4 reads `kid` from the still-
|
||||||
// still-unverified header, so this stops laundered bytes reaching key selection.
|
// unverified header, so this stops laundered bytes reaching key selection.
|
||||||
const base64url = /^[A-Za-z0-9_-]+$/;
|
const base64url = /^[A-Za-z0-9_-]+$/;
|
||||||
|
|
||||||
function decodeSegment(segment: string): unknown {
|
function decodeSegment(segment: string): unknown {
|
||||||
@@ -68,8 +65,8 @@ export function decodeJws(token: string): DecodedJws {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify a compact JWS against one JWK public key; returns the decoded JWS or throws.
|
// Verify a compact JWS against one JWK public key; returns the decoded JWS or throws.
|
||||||
// Signature only — the caller validates claims. The returned header is post-verification,
|
// Signature only — caller validates claims. Returned header is post-verification, so §4
|
||||||
// so §4 can trust its `alg`/`kid` when logging.
|
// can trust its `alg`/`kid` when logging.
|
||||||
export function verifyJws(token: string, jwk: JsonWebKey): DecodedJws {
|
export function verifyJws(token: string, jwk: JsonWebKey): DecodedJws {
|
||||||
const decoded = decodeJws(token);
|
const decoded = decodeJws(token);
|
||||||
const { header, signingInput, signature } = decoded;
|
const { header, signingInput, signature } = decoded;
|
||||||
|
|||||||
@@ -22,9 +22,8 @@ export function contentTypeFor(filePath: string): string {
|
|||||||
return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolves a request path inside `dir`, or null if it would escape (traversal) or
|
// Resolve a request path inside `dir`, or null if it escapes (traversal) or carries a
|
||||||
// carries a control char (NUL etc.) — rejecting those here makes the guard explicit
|
// control char (NUL etc.) — an explicit guard rather than relying on `stat` to throw.
|
||||||
// rather than relying on a downstream `stat` to throw.
|
|
||||||
export function resolveStaticPath(dir: string, requestedPath: string): string | null {
|
export function resolveStaticPath(dir: string, requestedPath: string): string | null {
|
||||||
if (/[\x00-\x1f]/.test(requestedPath)) return null;
|
if (/[\x00-\x1f]/.test(requestedPath)) return null;
|
||||||
const filePath = join(dir, requestedPath);
|
const filePath = join(dir, requestedPath);
|
||||||
@@ -52,8 +51,8 @@ export async function serveStatic(dir: string, requestedPath: string, res: Serve
|
|||||||
if (!info.isFile()) return plain(res, 404, "Not Found");
|
if (!info.isFile()) return plain(res, 404, "Not Found");
|
||||||
res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) });
|
res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) });
|
||||||
if (head) return void res.end(); // headers only — skip opening the file
|
if (head) return void res.end(); // headers only — skip opening the file
|
||||||
// Headers are already sent, so a mid-stream read error can't become an HTTP error —
|
// Headers are already sent, so a mid-stream read error can't become an HTTP status —
|
||||||
// log it and destroy the response to signal a truncated body, not a hung socket.
|
// log and destroy the response to signal a truncated body, not a hung socket.
|
||||||
createReadStream(filePath)
|
createReadStream(filePath)
|
||||||
.on("error", (err) => {
|
.on("error", (err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
5
todo.md
5
todo.md
@@ -18,9 +18,12 @@ everything via Docker.
|
|||||||
- [x] Error templates: add 403 + 500 (404 exists). → `views/403.ejs` + `views/500.ejs`; 500 wired into `app.ts` error handler (HTML, plain-text fallback).
|
- [x] Error templates: add 403 + 500 (404 exists). → `views/403.ejs` + `views/500.ejs`; 500 wired into `app.ts` error handler (HTML, plain-text fallback).
|
||||||
- [x] Config/env loader: Ory endpoints, cookie/CSRF secret, JWKS location, ports. → `src/config.ts` (`loadConfig`); validated at boot, dev defaults for clean-clone, prod requires real secrets; wired into `server.ts`.
|
- [x] Config/env loader: Ory endpoints, cookie/CSRF secret, JWKS location, ports. → `src/config.ts` (`loadConfig`); validated at boot, dev defaults for clean-clone, prod requires real secrets; wired into `server.ts`.
|
||||||
- [x] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Both: no bugs/security issues. Addressed: wired `buildContext` into `app.ts`; graceful SIGTERM/SIGINT shutdown; EJS template caching in prod. Deferred `core/`/`shell/` split (premature for an 8-file scaffold; revisit at §2/§4).
|
- [x] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Both: no bugs/security issues. Addressed: wired `buildContext` into `app.ts`; graceful SIGTERM/SIGINT shutdown; EJS template caching in prod. Deferred `core/`/`shell/` split (premature for an 8-file scaffold; revisit at §2/§4).
|
||||||
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
- [x] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. → Tightened comments across `src/*.ts`, Dockerfile, and trimmed verbose/duplicated prose in README; tests + typecheck green.
|
||||||
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
|
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
|
||||||
|
|
||||||
|
### 0.1 Extra input from human
|
||||||
|
- [ ] Remove all usage of NODE_ENV - add a new core principle to the project that the app should at all times be unaware of what environment it is running in. Configuration should be explicit, like "disable email" or "cache templates".
|
||||||
|
|
||||||
## 1. Building blocks — extract from `html-css-foundation/` (no Ory needed; render mock data)
|
## 1. Building blocks — extract from `html-css-foundation/` (no Ory needed; render mock data)
|
||||||
- [ ] Move `styles.css` + `auth.css` into `public/css/`; remove existing `style.css`.
|
- [ ] Move `styles.css` + `auth.css` into `public/css/`; remove existing `style.css`.
|
||||||
- [ ] Lucide icon sprite from `lucide-static` (dep added) → `views/partials/icons.ejs`; serve/inline only the icons used.
|
- [ ] Lucide icon sprite from `lucide-static` (dep added) → `views/partials/icons.ejs`; serve/inline only the icons used.
|
||||||
|
|||||||
Reference in New Issue
Block a user