diff --git a/.gitea/workflows/registry-cleanup.yml b/.gitea/workflows/registry-cleanup.yml index a33c701..d54211b 100644 --- a/.gitea/workflows/registry-cleanup.yml +++ b/.gitea/workflows/registry-cleanup.yml @@ -19,4 +19,4 @@ jobs: run: | docker run --rm -v "$PWD:/repo" -w /repo \ -e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \ - node:24.22.2-alpine3.24 node registry-cleanup/cleanup.ts + node:26.13.0-alpine3.24 node registry-cleanup/cleanup.ts diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1ccaf1b..7afdae3 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -22,7 +22,7 @@ jobs: GIT_TAG: ${{ github.ref_name }} run: | set -euo pipefail - docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \ + docker run --rm -v "$PWD:/repo" -w /repo node:26.13.0-alpine3.24 \ node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts - name: Promote the commit-hash image to semver + latest env: @@ -100,9 +100,9 @@ jobs: VERSION=${INPUT_VERSION:-${GIT_TAG#v}} VERSION=${VERSION#v} # An empty dispatch input falls back to the branch name, so gate this like a tag. - docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \ + docker run --rm -v "$PWD:/repo" -w /repo node:26.13.0-alpine3.24 \ node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts docker run --rm -v "$PWD:/repo" -w /repo \ -e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \ - node:24.22.2-alpine3.24 \ + node:26.13.0-alpine3.24 \ node release-tooling/dockerhub-overview.ts "$VERSION" diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml index 9e50a47..d342461 100644 --- a/.gitea/workflows/renovate.yml +++ b/.gitea/workflows/renovate.yml @@ -21,7 +21,7 @@ jobs: -e RENOVATE_PLATFORM=gitea \ -e RENOVATE_REPOSITORIES=${{ github.repository }} \ -e RENOVATE_TOKEN \ - renovate/renovate:44.199.2 + renovate/renovate:44.222.3 # After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the # last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the @@ -60,11 +60,11 @@ jobs: if [ -z "$BUMPS" ]; then echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0 fi - NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \ + NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:26.13.0-alpine3.24 \ node release-tooling/next-version.ts "$LATEST" $BUMPS) # Read the constant off origin/main, not the checkout, which lags the merges this run made. git show origin/main:src/plugin-host/plugin.ts \ - | docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \ + | docker run -i --rm -v "$PWD:/repo" -w /repo node:26.13.0-alpine3.24 \ node release-tooling/contract-version.ts "$NEXT" - echo "Releasing $LATEST -> $NEXT" git tag "$NEXT" origin/main diff --git a/Dockerfile b/Dockerfile index 9e5900f..bb0e137 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag. -FROM node:24.22.2-alpine3.24 +FROM node:26.13.0-alpine3.24 # Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules # instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root). diff --git a/compose.override.yml b/compose.override.yml index bbfcdcc..12e6118 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -49,7 +49,7 @@ services: # backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service — # stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead. shifts-upstream: - image: node:24.22.2-alpine3.24 + image: node:26.13.0-alpine3.24 command: node /srv/server.ts restart: unless-stopped volumes: diff --git a/e2e-tests/Dockerfile b/e2e-tests/Dockerfile index 37132f7..e2c43dd 100644 --- a/e2e-tests/Dockerfile +++ b/e2e-tests/Dockerfile @@ -1,6 +1,6 @@ # Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/. # Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network. -FROM mcr.microsoft.com/playwright:v1.64.1-noble +FROM mcr.microsoft.com/playwright:v1.65.0-noble WORKDIR /e2e-tests diff --git a/e2e-tests/compose.full.yml b/e2e-tests/compose.full.yml index 65db792..d8d7ab1 100644 --- a/e2e-tests/compose.full.yml +++ b/e2e-tests/compose.full.yml @@ -53,7 +53,7 @@ services: # The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows. shifts-upstream: - image: node:24.22.2-alpine3.24 + image: node:26.13.0-alpine3.24 command: ["node", "/server.ts"] volumes: - ./examples/shifts-upstream/server.ts:/server.ts:ro @@ -66,7 +66,7 @@ services: # Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos # verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos. mock-oidc: - image: node:24.22.2-alpine3.24 + image: node:26.13.0-alpine3.24 command: ["node", "/mock-oidc.ts"] environment: ISSUER: http://mock-oidc:9000 @@ -92,7 +92,7 @@ services: # Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts). proxy: - image: node:24.22.2-alpine3.24 + image: node:26.13.0-alpine3.24 command: ["node", "/proxy.ts"] depends_on: web: diff --git a/e2e-tests/console-guard.ts b/e2e-tests/console-guard.ts index ef9389f..5910d77 100644 --- a/e2e-tests/console-guard.ts +++ b/e2e-tests/console-guard.ts @@ -12,10 +12,14 @@ import { expect, test as base, type BrowserContext, type Page } from "@playwrigh const problems: string[] = []; const allowed: RegExp[] = []; -// The one message the stack itself provokes: the runner reaches `web`/`proxy` by container name over +// The messages the stack itself provokes: the runner reaches `web`/`proxy` by container name over // plain http, and only a `localhost` origin is trustworthy without TLS — so Chromium drops the COOP -// header the app sends and says so on every page. Over https, where a deployment serves, it applies. -const EXPECTED = [/^console\.error: The Cross-Origin-Opener-Policy header has been ignored/]; +// header the app sends, and WebKit reports the page not cross-origin isolated, on every page. Over +// https, where a deployment serves, the header applies. +const EXPECTED = [ + /^console\.error: The Cross-Origin-Opener-Policy header has been ignored/, + /^console\.warning: This document is not cross-origin isolated because the document is not a secure context/, +]; // Allow a message for the current test only, when the page under test provokes it on purpose. export function allowConsole(...patterns: RegExp[]): void { diff --git a/e2e-tests/package-lock.json b/e2e-tests/package-lock.json index 912cafa..ae802fc 100644 --- a/e2e-tests/package-lock.json +++ b/e2e-tests/package-lock.json @@ -6,19 +6,19 @@ "": { "name": "plainpages-e2e", "devDependencies": { - "@playwright/test": "1.64.1", - "@types/node": "24.19.2", + "@playwright/test": "1.65.0", + "@types/node": "26.6.6", "typescript": "7.0.2" } }, "node_modules/@playwright/test": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", - "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.64.1" + "playwright": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -28,13 +28,13 @@ } }, "node_modules/@types/node": { - "version": "24.19.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", - "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.9.0" } }, "node_modules/@typescript/typescript-aix-ppc64": { @@ -378,13 +378,13 @@ } }, "node_modules/playwright": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", - "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.64.1" + "playwright-core": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -394,9 +394,9 @@ } }, "node_modules/playwright-core": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", - "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -442,9 +442,9 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" } diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 4853a39..be2a4de 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -8,8 +8,8 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@playwright/test": "1.64.1", - "@types/node": "24.19.2", + "@playwright/test": "1.65.0", + "@types/node": "26.6.6", "typescript": "7.0.2" } } diff --git a/package-lock.json b/package-lock.json index baad311..d1f8c78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,12 @@ "@larvit/log": "2.4.0", "ejs": "7.0.2", "lucide-static": "1.70.0", - "postgres": "3.4.9" + "postgres": "4.1.0" }, "devDependencies": { "@mdn/browser-compat-data": "8.1.8", "@types/ejs": "3.1.5", - "@types/node": "24.19.2", + "@types/node": "26.6.6", "acorn": "8.19.0", "typescript": "7.0.2" }, @@ -46,13 +46,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.19.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", - "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.9.0" } }, "node_modules/@typescript/typescript-aix-ppc64": { @@ -427,12 +427,12 @@ "license": "ISC" }, "node_modules/postgres": { - "version": "3.4.9", - "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", - "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-4.1.0.tgz", + "integrity": "sha512-s+0WH67YAyLdBQUb6XczbziBhY+C9uiQsg9S8R5RHdZhk6sdyK6202E+T2tXpiInSEEjDLRSJ3OpYBe3PN6MsQ==", "license": "Unlicense", "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "type": "individual", @@ -475,9 +475,9 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" } diff --git a/package.json b/package.json index c475fb5..1322ea8 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,12 @@ "@larvit/log": "2.4.0", "ejs": "7.0.2", "lucide-static": "1.70.0", - "postgres": "3.4.9" + "postgres": "4.1.0" }, "devDependencies": { "@mdn/browser-compat-data": "8.1.8", "@types/ejs": "3.1.5", - "@types/node": "24.19.2", + "@types/node": "26.6.6", "acorn": "8.19.0", "typescript": "7.0.2" } diff --git a/src/auth/gen-jwks.test.ts b/src/auth/gen-jwks.test.ts index b129805..06c2e90 100644 --- a/src/auth/gen-jwks.test.ts +++ b/src/auth/gen-jwks.test.ts @@ -4,8 +4,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import { createPrivateKey, sign, type JsonWebKey } from "node:crypto"; +import { createPrivateKey, sign } from "node:crypto"; import { generateJwks, rotateJwks } from "./gen-jwks.ts"; +import type { JsonWebKey } from "./jwks.ts"; import { verifyJws } from "./jwt.ts"; const b64url = (s: string) => Buffer.from(s).toString("base64url"); diff --git a/src/auth/jwks.test.ts b/src/auth/jwks.test.ts index e6330e3..b74b778 100644 --- a/src/auth/jwks.test.ts +++ b/src/auth/jwks.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; -import { generateKeyPairSync, type JsonWebKey } from "node:crypto"; +import { generateKeyPairSync } from "node:crypto"; import { dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { cachingJwks, createJwksProvider, loadJwks, staticJwks } from "./jwks.ts"; +import { cachingJwks, createJwksProvider, loadJwks, staticJwks, type JsonWebKey } from "./jwks.ts"; const jwk = (kid: string): JsonWebKey => ({ ...(generateKeyPairSync("ec", { namedCurve: "P-256" }).publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid }); const committed = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "ory/kratos/tokenizer/jwks.json"); diff --git a/src/auth/jwks.ts b/src/auth/jwks.ts index 2cf5487..fec3df9 100644 --- a/src/auth/jwks.ts +++ b/src/auth/jwks.ts @@ -1,4 +1,4 @@ -import type { JsonWebKey } from "node:crypto"; +import type { webcrypto } from "node:crypto"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { currentLog } from "../logger.ts"; @@ -8,6 +8,10 @@ import { currentLog } from "../logger.ts"; // (or re-reads a mounted file), caches for a TTL, and reloads once on a `kid` miss so a rotated- // in key is picked up without a restart (README: zero-downtime rotation). `createJwksProvider` // picks the right one from the configured URL scheme and primes it at boot (fail loud). +// node:crypto exports the JWK type only under webcrypto since @types/node 26, and that type lacks +// the `kid` a JWKS selects a key by. +export type JsonWebKey = webcrypto.JsonWebKey & { kid?: string }; + export interface JwksProvider { getKey(kid: string | undefined): Promise; } diff --git a/src/auth/jwt-middleware.test.ts b/src/auth/jwt-middleware.test.ts index 61cb9f0..3eec5ee 100644 --- a/src/auth/jwt-middleware.test.ts +++ b/src/auth/jwt-middleware.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; -import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto"; +import { generateKeyPairSync, sign, type KeyObject } from "node:crypto"; import { test } from "node:test"; -import { staticJwks } from "./jwks.ts"; +import { staticJwks, type JsonWebKey } from "./jwks.ts"; import { authenticate, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts"; import { SESSION_COOKIE } from "./login.ts"; diff --git a/src/auth/jwt.test.ts b/src/auth/jwt.test.ts index 83d8f64..04da47d 100644 --- a/src/auth/jwt.test.ts +++ b/src/auth/jwt.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { generateKeyPairSync, sign } from "node:crypto"; -import type { JsonWebKey, KeyObject } from "node:crypto"; +import type { KeyObject } from "node:crypto"; +import type { JsonWebKey } from "./jwks.ts"; import { test } from "node:test"; import { decodeJws, verifyJws } from "./jwt.ts"; diff --git a/src/auth/jwt.ts b/src/auth/jwt.ts index d3017d2..ca211ed 100644 --- a/src/auth/jwt.ts +++ b/src/auth/jwt.ts @@ -1,5 +1,6 @@ import { createPublicKey, verify } from "node:crypto"; -import type { JsonWebKey, KeyObject } from "node:crypto"; +import type { KeyObject } from "node:crypto"; +import type { JsonWebKey } from "./jwks.ts"; // JWS signature verification with the Node stdlib — no `jose`/JWT dep: // `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures diff --git a/src/plugin-host/storage-provisioning.ts b/src/plugin-host/storage-provisioning.ts index 4c89b52..d13ea78 100644 --- a/src/plugin-host/storage-provisioning.ts +++ b/src/plugin-host/storage-provisioning.ts @@ -20,6 +20,7 @@ export interface ProvisionResult { // so it is swallowed here. The driver, not `new URL`, decides what parses: it takes a multi-host DSN. function connect(adminUrl: string): ReturnType { try { + new URL(adminUrl); // postgres 4 connects to whatever a malformed DSN parses to, and its error names that host return postgres(adminUrl, { connect_timeout: 10, max: 1 }); } catch { throw new Error("PLUGIN_DB_ADMIN_URL must be a Postgres URL (its value is not shown; percent-encode special characters in a password)"); diff --git a/src/test-tooling/app-harness.ts b/src/test-tooling/app-harness.ts index 23f7ca9..827e08a 100644 --- a/src/test-tooling/app-harness.ts +++ b/src/test-tooling/app-harness.ts @@ -1,4 +1,5 @@ -import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto"; +import { generateKeyPairSync, sign } from "node:crypto"; +import type { JsonWebKey } from "../auth/jwks.ts"; import type { AddressInfo } from "node:net"; import type { TestContext } from "node:test"; import type { HydraAdmin } from "../auth/hydra-admin.ts";