Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 702e42de09 | |||
| 98bdd2c1b5 | |||
| 12913402a6 | |||
| 7dee80a976 | |||
| c0fe8b0a82 | |||
| fb7d20a7db | |||
| 0919adf4ef | |||
| f5f4455b81 | |||
| 3f30f889c3 | |||
| ae1479f55b | |||
| bb6021adf7 | |||
| c4e6212189 | |||
| 6f6aafad39 | |||
| 4aada6eed9 | |||
| a574effb37 |
@@ -10,9 +10,11 @@ jobs:
|
||||
steps:
|
||||
- name: Run Renovate against this repo
|
||||
env:
|
||||
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e GITHUB_COM_TOKEN \
|
||||
-e LOG_LEVEL=info \
|
||||
-e RENOVATE_ENDPOINT=https://gitea.larvit.se/api/v1 \
|
||||
-e RENOVATE_GIT_AUTHOR="Renovate Bot <renovate@larvit.se>" \
|
||||
|
||||
@@ -94,6 +94,7 @@ From here, render real pages against the app shell and fetch upstream data — s
|
||||
- [instant revoke](#instant-revoke-the-optional-denylist)
|
||||
- [three tiers](#three-tiers-of-may-i)
|
||||
- [OAuth2 (Hydra)](#oauth2-provider-hydra)
|
||||
- [security model](#security-model)
|
||||
- [Email](#email)
|
||||
- [Architecture](#architecture)
|
||||
- [Stateless](#stateless)
|
||||
@@ -836,14 +837,29 @@ both default to `localhost` (the dev override sets `APP_URL=http://localhost:300
|
||||
|
||||
A clean clone needs **none** of the above — `docker compose up` brings up the whole stack
|
||||
with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see
|
||||
[Quick start](#quick-start)). Exactly **two** things can't be auto-generated, and **both
|
||||
are production-only** — neither blocks a clean clone:
|
||||
[Quick start](#quick-start)). What can't be auto-generated is **production-only** — none of it
|
||||
blocks a clean clone:
|
||||
|
||||
1. **Production secrets** — every value below ships as a committed dev throwaway that works
|
||||
out of the box and **must** be replaced before a deploy faces the internet. Only the first
|
||||
is enforced: `REQUIRE_SECURE_SECRETS=true` refuses to boot on a missing or throwaway
|
||||
`CSRF_SECRET` and **nothing else** — the rest fail silently, so treat this as a checklist.
|
||||
|
||||
| Secret | Where | Protects |
|
||||
| --- | --- | --- |
|
||||
| `CSRF_SECRET` | web env | signs our double-submit CSRF token |
|
||||
| JWT signing key | mount a real `jwks.json` or set `…_JWKS_URL` | mints/verifies the session JWT — see [rotation](#jwt-signing-key--rotation) |
|
||||
| `SECRETS_COOKIE` | kratos env | signs Kratos' session + anti-CSRF cookies |
|
||||
| `SECRETS_CIPHER` | kratos env (32 chars) | encrypts credentials at rest |
|
||||
| `SECRETS_SYSTEM` | hydra env | encrypts OAuth2 tokens + consent at rest |
|
||||
| `POSTGRES_USER` / `POSTGRES_PASSWORD` | compose env | the Ory databases (default `ory`/`ory`) |
|
||||
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | bootstrap env | the seeded first admin login (default `admin@plainpages.local` / `admin`) |
|
||||
|
||||
`CSRF_SECRET`, the Postgres pair and the admin pair are interpolated from the host
|
||||
environment. The three Ory secrets are **not**: `compose.yml` passes only `DSN` to
|
||||
`kratos`/`hydra`, so add them to those services' `environment:` (or an `env_file:`) or they
|
||||
silently stay on the throwaways.
|
||||
|
||||
1. **Production secrets** — replace the committed dev throwaway `CSRF_SECRET` (env), plus
|
||||
the **JWT signing key** (mount a real `jwks.json` or set `…_JWKS_URL` — see
|
||||
[JWT signing key & rotation](#jwt-signing-key--rotation)). Set
|
||||
`REQUIRE_SECURE_SECRETS=true` and the app refuses to boot until `CSRF_SECRET` is supplied
|
||||
and differs from the throwaway.
|
||||
2. **SSO provider client id/secret** — **optional**; password login works without them.
|
||||
Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see
|
||||
[Social sign-in (SSO)](#social-sign-in-sso)).
|
||||
@@ -997,6 +1013,76 @@ generated `client_secret` **once**, on the confirmation page — confidential cl
|
||||
delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration;
|
||||
writes go only to Hydra.
|
||||
|
||||
### Security model
|
||||
|
||||
Everything above is *how* auth works. This is what to check a change against: who is trusted,
|
||||
what defends what, and which guarantees are deliberately not offered.
|
||||
|
||||
**Trust boundaries.**
|
||||
|
||||
- **The browser is not trusted.** Cookies, form fields, URLs and headers are attacker-controlled
|
||||
until verified or escaped. Nothing is believed because of where it arrived from.
|
||||
- **The session JWT is trusted only after verification** — signature against the JWKS key its
|
||||
`kid` names (or the sole key, when the token carries no `kid`), then a **mandatory** `exp`,
|
||||
plus `nbf` and the optional `iss`/`aud`. Before that it is bytes.
|
||||
- **The private container network is the *only* thing guarding the Ory APIs.** Kratos admin
|
||||
(`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching them
|
||||
*is* full identity and permission control. Keto **read** (`4466`) cannot write, but discloses
|
||||
the entire authorization graph, so treat it the same. `compose.yml` publishes none of the six
|
||||
Ory ports (guarded by `src/compose.test.ts`); dev publishes only the two a browser must reach.
|
||||
Never expose one, and never front one with a proxy that lacks its own auth.
|
||||
- **Plugins are trusted code**, in-process and unsandboxed — a plugin can do anything the host
|
||||
can. Vetting happens when you mount one, not at runtime (AGENTS.md: crash isolation is a
|
||||
deliberate non-goal).
|
||||
- **Attribute-based row rules belong upstream**, in the service that owns the data;
|
||||
relationship-based ones go to Keto — see [three tiers](#three-tiers-of-may-i).
|
||||
|
||||
**The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own
|
||||
`sub`, `email` and `roles`. `HttpOnly` keeps page JavaScript out of the cookie, not the user.
|
||||
Never put anything in a claim you wouldn't show them.
|
||||
|
||||
**What defends what.**
|
||||
|
||||
| Threat | Defense |
|
||||
| --- | --- |
|
||||
| Forged or tampered token | signature verified by `kid`; `alg` allowlist is `RS256`/`ES256` only — **never `HS*` or `none`**, either of which lets a forged token verify (`src/auth/jwt.ts`) |
|
||||
| `alg` confusion | a key that pins an `alg` must match the header's; the key type must always match the family |
|
||||
| Replayed expired token | `exp` is mandatory — a token without one is rejected, never treated as eternal; `JWT_CLOCK_SKEW_SEC` leeway |
|
||||
| Token minted for another deployment | optional `JWT_ISSUER` / `JWT_AUDIENCE` pinning |
|
||||
| Stolen session cookie | `HttpOnly`, `SameSite=Lax`, `Secure` (`SECURE_COOKIES`) — but see the real session lifetime below |
|
||||
| CSRF on our own forms | signed double-submit token, **opt-in per handler** via `ctx.verifyCsrf` (`src/auth/csrf.ts`) + `SameSite=Lax`; Kratos' flows carry Kratos' own token |
|
||||
| XSS | EJS `<%= %>` escapes; the CSP blocks inline script ([headers](#production--deployment)) — the `*.html` slots stay [raw by contract](#escaping--the-trust-boundary) |
|
||||
| Clickjacking | `frame-ancestors 'none'` + `X-Frame-Options: DENY` |
|
||||
| Open redirect via `return_to` | validated host-relative (`localPath`, `src/http/safe-url.ts`) |
|
||||
| Privilege escalation | roles authored only in Keto, re-read at every mint — see [login & the session JWT](#login-and-the-session-jwt) |
|
||||
| Downgrade / MIME sniffing | HSTS when `SECURE_COOKIES=true`, `X-Content-Type-Options: nosniff` |
|
||||
| A hung Ory parking requests | `ORY_TIMEOUT_SEC` per outbound call |
|
||||
|
||||
**The JWT's ~10m TTL is not the session lifetime.** The browser also holds Kratos'
|
||||
`plainpages_session` cookie (30 days, sliding), and *that* is what silently re-mints a lapsed
|
||||
JWT. So a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our
|
||||
two cookies obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
|
||||
|
||||
**Fail closed — with one deliberate exception.** A token that cannot be verified (missing,
|
||||
malformed, bad signature, wrong `iss`/`aud`) yields *anonymous*, never a partly-trusted user,
|
||||
and anonymous or under-privileged is denied (`requireSession` bounces to `/login`; `can`/`check`
|
||||
return `false`). An **expired** token instead triggers a re-mint: re-validation against the live
|
||||
Kratos session, roles re-read from Keto, or a cleared cookie if that session is dead; Ory
|
||||
unreachable ⇒ anonymous. None of this is a session kill — a *revoked* state exists only with the
|
||||
[denylist](#instant-revoke-the-optional-denylist) on (off by default), and it resolves through
|
||||
that same re-mint. **Offboarding:** with the denylist on, revoking a role downgrades the user at
|
||||
once (on the instance that handled it) and deactivating or deleting the identity ends the
|
||||
session; with it off, both land within one token TTL.
|
||||
|
||||
**Not guaranteed** — accepted, and stated where each mechanism is: role changes
|
||||
[lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the
|
||||
denylist is [single-instance and skips group changes](#instant-revoke-the-optional-denylist).
|
||||
Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing
|
||||
**every** committed dev secret — see
|
||||
[what you must supply](#what-you-must-supply-the-only-manual-prep). `REQUIRE_SECURE_SECRETS`
|
||||
guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres' or the demo admin's
|
||||
throwaways.
|
||||
|
||||
## Email
|
||||
|
||||
The only emails are the **recovery** and **verification** codes from Kratos' self-service
|
||||
@@ -1118,8 +1204,9 @@ docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v
|
||||
|
||||
**Full browser flow** (`full-flow.spec.ts`) — the real Playwright UI against the live stack:
|
||||
the themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider,
|
||||
`e2e-tests/mock-oidc.ts`), **menu filtering by role**, the **users/groups/roles** admin CRUD, a
|
||||
permission-gated **plugin page**, and **logout**. Because the themed form posts straight to
|
||||
`e2e-tests/mock-oidc.ts`), **menu filtering by role**, the **users/groups/roles** admin CRUD, the
|
||||
**OAuth2-clients** admin screen (register → one-time secret → delete; Hydra is part of this stack
|
||||
for it), a permission-gated **plugin page**, and **logout**. Because the themed form posts straight to
|
||||
Kratos and cookies are host-scoped, a tiny same-origin gateway (`e2e-tests/proxy.ts`) fronts web +
|
||||
Kratos on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production
|
||||
reverse proxy would.
|
||||
@@ -1249,8 +1336,11 @@ Renovate merges it once `CI / full-gate (push)` is green (rebasing stale branche
|
||||
fast-forward-only merge still holds) — routine bumps land untouched; only a red gate needs a
|
||||
human. One-time setup: reuse the shared `renovate@larvit.se` bot — give it write access to
|
||||
this repo and store its Gitea PAT as the Actions **secret** `RENOVATE_TOKEN`. Until it
|
||||
exists, the nightly job fails loud (and, like the other secrets, a `GITEA_` prefix is
|
||||
rejected).
|
||||
exists, the nightly job fails loud (and, like the other secrets, a `GITEA_`/`GITHUB_`
|
||||
prefix is rejected). Also store a **scopeless** (read-only) github.com PAT as the secret
|
||||
`RENOVATE_GITHUB_TOKEN` — the workflow hands it to Renovate as `GITHUB_COM_TOKEN`, so
|
||||
lookups of github.com-hosted deps (actions, Playwright, changelogs) run authenticated
|
||||
instead of tripping the anonymous 60-requests/hour limit.
|
||||
|
||||
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`, `needs:
|
||||
renovate`) cuts **one** `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main`
|
||||
@@ -1393,7 +1483,7 @@ container-relative; with the dev bind-mount they edit the real file).
|
||||
2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`.
|
||||
(web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a
|
||||
brief Kratos blip only touches login/re-mint.)
|
||||
3. **Verify** new logins mint the new `kid` — decode the `plainpages_session` cookie's JWT
|
||||
3. **Verify** new logins mint the new `kid` — decode the `plainpages_jwt` cookie's JWT
|
||||
header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients
|
||||
present the new key.
|
||||
4. **Wait ~12 min**, then **prune** the superseded key:
|
||||
|
||||
+12
-13
@@ -1,21 +1,16 @@
|
||||
# Full browser E2E — the real Playwright UI flow against the live stack: password +
|
||||
# mocked-SSO login, menu filtering by role, users/groups/roles CRUD, a plugin page, logout. A tiny
|
||||
# same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
||||
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
||||
# login, menu filtering by role, users/groups/roles/OAuth2-clients CRUD, a plugin page, logout. A
|
||||
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
||||
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
|
||||
services:
|
||||
web:
|
||||
# First-party + SSO flows need Kratos + Keto + bootstrap, not Hydra — drop it so the stack is
|
||||
# leaner. SSO is enabled here only (clean clone stays password-only): the mock provider's whole
|
||||
# array is the env-settable form Kratos offers, mapped through the committed claims jsonnet.
|
||||
depends_on: !override
|
||||
bootstrap:
|
||||
condition: service_completed_successfully
|
||||
kratos:
|
||||
condition: service_healthy
|
||||
keto:
|
||||
condition: service_healthy
|
||||
# The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients
|
||||
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
|
||||
# (clean clone stays password-only): the mock provider's whole array is the env-settable form
|
||||
# Kratos offers, mapped through the committed claims jsonnet.
|
||||
depends_on:
|
||||
shifts-upstream:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
@@ -52,6 +47,10 @@ services:
|
||||
SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS: >-
|
||||
[{"id":"mock","provider":"generic","label":"Mock SSO","client_id":"plainpages-e2e","client_secret":"e2e-secret","issuer_url":"http://mock-oidc:9000","scope":["openid","email"],"mapper_url":"file:///etc/config/kratos/oidc/claims.jsonnet"}]
|
||||
|
||||
# --dev permits the http issuer (the base file drops it for an https prod issuer).
|
||||
hydra:
|
||||
command: serve all --dev -c /etc/config/hydra/hydra.yml
|
||||
|
||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||
shifts-upstream:
|
||||
image: node:24.18.1-alpine3.24
|
||||
|
||||
@@ -85,6 +85,35 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
await expect(page.locator("main")).toContainText(role);
|
||||
});
|
||||
|
||||
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
||||
const name = `e2e-client-${suffix}`;
|
||||
await page.goto("/admin/clients");
|
||||
await page.getByRole("link", { name: "Register client" }).click();
|
||||
await page.fill('input[name="name"]', name);
|
||||
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
|
||||
await page.locator('.form-card button[type="submit"]').click();
|
||||
|
||||
// Hydra returns the secret exactly once, so the POST renders the detail directly (no PRG).
|
||||
await expect(page.locator("h1")).toHaveText("Client registered");
|
||||
const clientId = await page.locator("#cid").inputValue();
|
||||
expect(clientId).toBeTruthy();
|
||||
await expect(page.locator("#csecret")).toHaveValue(/.+/);
|
||||
|
||||
// Listed; the row header links to the plain detail, which never shows the secret again.
|
||||
await page.goto("/admin/clients");
|
||||
const row = page.locator("tr", { hasText: name });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole("link", { name }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/admin/clients/${clientId}`));
|
||||
await expect(page.locator("#csecret")).toHaveCount(0);
|
||||
|
||||
// Delete through the confirm interstitial (danger link on the detail → confirm form's button).
|
||||
await page.getByRole("link", { name: "Delete client" }).click();
|
||||
await page.getByRole("button", { name: "Delete client" }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/clients(\?|$)/);
|
||||
await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => {
|
||||
await page.goto("/scheduling/shifts");
|
||||
await expect(page.locator("h1")).toHaveText("Shifts");
|
||||
|
||||
Generated
+369
-76
@@ -9,13 +9,13 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "3.1.10",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
@@ -47,66 +47,356 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
||||
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jake": "^10.8.5"
|
||||
},
|
||||
"bin": {
|
||||
"ejs": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/filelist": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
||||
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jake": {
|
||||
"version": "10.9.4",
|
||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
|
||||
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"async": "^3.2.6",
|
||||
"filelist": "^1.0.4",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"jake": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": ">=0.12.18"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-static": {
|
||||
@@ -115,36 +405,39 @@
|
||||
"integrity": "sha512-dC3VJwRFsjEVX7Iaq4rY88pm7Fi2OmOb8P0WRzXsUMgbt7sCmFX8bLhaDBeNW6JdRjuele+jKqqFaam4yr+Ygg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
"tsc": "bin/tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
|
||||
+2
-2
@@ -19,12 +19,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "3.1.10",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,10 @@ test("verifyToken: a valid token → User, selecting the verify key by kid acros
|
||||
assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] });
|
||||
});
|
||||
|
||||
test("verifyToken rejects expiry and future nbf, with clock-skew leeway", async () => {
|
||||
test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
|
||||
const opts = { clockSkewSec: 60, now: NOW };
|
||||
// No exp ⇒ rejected outright: an exp-less token must never read as eternal.
|
||||
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: undefined }), jwks, opts), /missing exp/);
|
||||
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 120 }), jwks, opts), /expired/);
|
||||
// exp 30s in the past but inside the 60s skew → still accepted.
|
||||
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import ejs from "ejs";
|
||||
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
|
||||
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// plugin may deliberately shadow a core partial). The router calls this for a `view` RouteResult.
|
||||
|
||||
import { isAbsolute, join, relative } from "node:path";
|
||||
import * as ejs from "ejs";
|
||||
import ejs from "ejs";
|
||||
|
||||
const CONTROL_CHARS = /[\x00-\x1f]/;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import ejs from "ejs";
|
||||
|
||||
const authCard = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "auth-card.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, data);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import ejs from "ejs";
|
||||
|
||||
const dataTable = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "data-table.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(dataTable, data);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import ejs from "ejs";
|
||||
|
||||
const field = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "field.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, data);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import ejs from "ejs";
|
||||
|
||||
const filterBar = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "filter-bar.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(filterBar, data);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import ejs from "ejs";
|
||||
import { ICON_NAMES, buildIconSprite } from "./icons.ts";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import ejs from "ejs";
|
||||
|
||||
const menu = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "menu.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, data);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import ejs from "ejs";
|
||||
|
||||
const navTree = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "nav-tree.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, data);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import ejs from "ejs";
|
||||
|
||||
const pagination = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "pagination.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(pagination, data);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import ejs from "ejs";
|
||||
|
||||
const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "shell.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, data);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import ejs from "ejs";
|
||||
|
||||
const themeSwitch = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "theme-switch.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(themeSwitch, data);
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
- [x] CI/CD - Sync docker images to docker hub after each re-tag to git tags. (`release.yml` pushes the same `X.Y.Z`/`X.Y`/`X`/`latest` tags to `docker.io/larvit/plainpages` after the Gitea re-tag — releases only, no hash tags; auth via the `DOCKERHUB_USER` variable + `DOCKERHUB_TOKEN` secret; documented in README → CI/CD.)
|
||||
- [x] Write a short text on how to use this docker image to publish on docker hub and save it to README-dockerhub.md (tagline, tags, clone-free quick start — the image ships the Ory config, extracted via `docker run … tar` + a self-contained compose.yml — env table, first plugin; pasted into the Docker Hub overview by hand — noted in README → CI/CD.)
|
||||
- [x] CI/CD - Setup renovate bot. Check how other repos on this Gitea is setup you can get access to, there should be a number of renovate bot activated ones. (`renovate.yml` runs the self-hosted `renovate/renovate` image nightly against `renovate.json` — this repo only, via the shared `renovate@larvit.se` bot + `RENOVATE_TOKEN` secret, mirroring the `pwrpln/core` pattern; standard managers cover npm/Dockerfiles/compose/gitea-action pins, two custom regex managers cover the image tags embedded in workflow `run:` steps, the Ory + Playwright lockstep sets are grouped, every bump stays an exact pin, and each PR automerges once the gate is green; documented in README → CI/CD.)
|
||||
- [ ] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret.
|
||||
- [x] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret. (The renovate job forwards the `RENOVATE_GITHUB_TOKEN` secret — a scopeless read-only github.com PAT; Gitea rejects `GITHUB_`-prefixed secret names — into the container as `GITHUB_COM_TOKEN`; documented in README → CI/CD.)
|
||||
- [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.)
|
||||
- [ ] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser.
|
||||
- [ ] Build and publish docker image as CI/CD.
|
||||
- [ ] The human developer understands the security model in the auth in this project.
|
||||
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.)
|
||||
- [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.)
|
||||
- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by a test — the mandatory-`exp` guard was the one gap, now asserted in `src/auth/jwt-middleware.test.ts`; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply".)
|
||||
- [ ] Add i18n support.
|
||||
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer.
|
||||
|
||||
## Architectural review findings (2026-07-02)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user