Compare commits

..

6 Commits

Author SHA1 Message Date
lilleman 1fc6033dd5 Give the JsonWebKey alias the kid a JWKS selects a key by
CI / full-gate (push) Successful in 3m51s
Mirror / github-mirror (push) Waiting to run
2026-11-08 23:26:03 +01:00
lilleman 1304cdae56 Read JsonWebKey from webcrypto, where @types/node 26 exports it 2026-11-08 23:26:03 +01:00
renovate-bot 3f07c3e649 Update Node.js to v26
Release-Bump: major
2026-11-08 23:26:03 +01:00
lilleman 0a9c79d08f Refuse a malformed admin DSN before postgres 4 connects to the host it misreads
CI / full-gate (push) Successful in 3m58s
Mirror / github-mirror (push) Waiting to run
2026-11-08 23:26:03 +01:00
renovate-bot 44f94c7ab0 Update dependency postgres to v4
Release-Bump: major
2026-11-08 23:26:03 +01:00
renovate-bot 2f75995b3d Update renovate/renovate Docker tag to v44.222.3
CI / full-gate (push) Successful in 3m51s
Mirror / github-mirror (push) Waiting to run
2026-11-08 23:25:58 +01:00
29 changed files with 103 additions and 185 deletions
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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"
+3 -3
View File
@@ -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
-15
View File
@@ -12,12 +12,6 @@ The host's contract version is now 0.5.0, so discovery refuses a plugin whose `a
`0.4.x`. Change the literal in each plugin's manifest when you upgrade, and add `assetHref` to any
`RequestContext` your tests build by hand: `assetHref: (href) => href`.
### The core dashboard view is `dashboard`, not `index`
`views/index.ejs` is now `views/dashboard.ejs`. The `apiVersion` check can't see this: a plugin view
that still does `include("index")` fails when it renders, with the 500 page. Change it to
`include("dashboard")`.
### `KetoClient.transactTuples` applies inserts and deletes as one transaction
`transactTuples({ delete, insert })` sends both lists to Keto in one `PATCH /admin/relation-tuples`,
@@ -25,15 +19,6 @@ so all of them apply or none does. A `KetoClient` you build by hand, such as a t
method. The admin example saves a user's or group's permissions through it, so a Keto failure leaves
every grant as it was and answers with the error page.
### The admin example deletes a group's grants and members in one Keto transaction
A Keto failure while deleting a group now leaves the group as it was, with its grants and members,
and answers with the error page. It used to leave some grants gone and the members still listed.
### The admin example keeps a user's permissions section when it refuses a self-deactivate or self-delete
The refusal page used to drop the permissions section of the user form.
### The admin example lists a user's or group's grants that no installed plugin declares, with a Remove
A direct grant of a name no installed plugin declares, such as one left by an uninstalled plugin,
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -1789,7 +1789,7 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
stylesheet-floor (the browser-floor check)
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
views/ Core EJS in the one app shell: home, dashboard, auth, oauth-consent, error, 403/404/500/503,
views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503,
and partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card,
alert, menu/popover, theme switch, language picker, icon sprite). Domain screens live
in plugins.
+1 -1
View File
@@ -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:
+1 -29
View File
@@ -103,7 +103,7 @@ Read most (opens and searches):
- `src/http/app.test.ts` 1
- `src/ui/chrome.test.ts` 1
- `src/ui/chrome.ts` 1
- `views/dashboard.ejs` 1
- `views/index.ejs` 1
- `views/partials/shell.ejs` 1
## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z
@@ -485,31 +485,3 @@ Read most (opens and searches):
- `src/http/app.test.ts` 2
- `todo.md` 2
- `src/plugin-host/plugin.ts` 1
## Reading cost, chunk 8d5c5ad2ab7c, finished 2026-11-08T21:39:34Z
145906 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app-admin.test.ts` 8
- `README.md` 6
- `CHANGELOG.md` 5
- `compose.yml` 5
- `docs/decisions.md` 5
- `examples/plugins/auth-admin/admin-grants.test.ts` 4
- `examples/plugins/auth-admin/admin-users.ts` 4
- `src/auth/keto-client.ts` 4
- `todo.md` 4
- `examples/plugins/auth-admin/README.md` 3
## Reading cost, chunk ef984469151f, finished 2026-11-08T22:01:41Z
38668 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 7
- `AGENTS.md` 1
- `README.md` 1
- `docs/decisions.md` 1
+3 -3
View File
@@ -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:
+8 -8
View File
@@ -7,7 +7,7 @@
"name": "plainpages-e2e",
"devDependencies": {
"@playwright/test": "1.64.1",
"@types/node": "24.19.2",
"@types/node": "26.6.6",
"typescript": "7.0.2"
}
},
@@ -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": {
@@ -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"
}
+1 -1
View File
@@ -9,7 +9,7 @@
},
"devDependencies": {
"@playwright/test": "1.64.1",
"@types/node": "24.19.2",
"@types/node": "26.6.6",
"typescript": "7.0.2"
}
}
+6 -7
View File
@@ -431,20 +431,19 @@ export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
}) }, view: "confirm" };
}, "write");
// POST /auth-admin/groups/:name/delete — one Keto transaction removes the group's member tuples
// and the grants it holds, so a Keto failure leaves the group as it was.
// POST /auth-admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
export const groupsDelete = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST
const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
// A Keto set exists only through its tuples, so leaving the grants behind would resurrect every
// permission the moment someone re-created a group with the same name.
// Drop what the group *holds* before what it *contains*: a Keto set exists only through its
// tuples, so leaving the grants behind would resurrect every permission the moment someone
// re-created a group with the same name.
const subject = groupSubject(name);
const held = await heldPermissions(keto, subject);
const grants = held.map((permission) => grantTuple(permission, subject));
const members = await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS });
await keto.transactTuples({ delete: [...grants, ...members], insert: [] });
for (const permission of held) await keto.deleteTuple(grantTuple(permission, subject));
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
return { redirect: ADMIN_GROUPS_BASE };
});
+8 -15
View File
@@ -391,7 +391,8 @@ export const usersUpdate = withTarget(async (deps, identity, id) => {
try {
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
} catch (err) {
if (err instanceof KratosError) return refuseForm(deps, identity, id, ctx.t("admin.users.error.save"));
// Re-render with the picker, or the permissions section vanishes off the page on a failed save.
if (err instanceof KratosError) return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), error: ctx.t("admin.users.error.save"), identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
throw err;
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
@@ -399,10 +400,9 @@ export const usersUpdate = withTarget(async (deps, identity, id) => {
// POST /auth-admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
export const usersState = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin, revoke, user } = deps;
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDeactivate"));
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDeactivate"), identity }), status: 400 };
const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
if (nextState === "inactive") revoke?.(id);
@@ -412,7 +412,7 @@ export const usersState = withTarget(async (deps, identity, id) => {
// GET /auth-admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
if (id === deps.user.id) return refuseForm(deps, identity, id, deps.ctx.t("admin.users.error.selfDelete"));
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 });
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
const view = toUserView(identity);
const tt = deps.ctx.t;
@@ -424,10 +424,9 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => {
}, "write");
// POST /auth-admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
export const usersDelete = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin, revoke, user } = deps;
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST
if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDelete"));
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 };
await kratosAdmin.deleteIdentity(id);
revoke?.(id);
ctx.log.info("admin: user deleted", { actor: user.id, target: id });
@@ -442,13 +441,7 @@ export const usersRecovery = withTarget(async (deps, identity, id) => {
return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
});
// The 400 re-render of the user's form with `error` above it. `usersPermissions` refuses through its
// own `refuse`, which puts the error in the permissions section instead.
async function refuseForm(deps: UsersDeps, identity: Identity, id: string, error: string): Promise<RouteResult> {
return { ...formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), error, identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
}
// The picker as a spreadable fragment, so every form re-render keeps the section.
// The picker as a spreadable fragment, so a re-render never silently drops the section.
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
const permissions = await userPermissionPicker(deps, id);
return permissions ? { permissions } : {};
+13 -13
View File
@@ -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"
}
+2 -2
View File
@@ -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"
}
+1 -1
View File
@@ -26,7 +26,7 @@ test("every author-facing apiVersion sample matches the shipped contract", () =>
"examples/plugins/auth-admin/plugin.ts",
"examples/plugins/scheduling/plugin.ts",
"release-tooling/dockerhub-overview.md.tmpl",
"views/dashboard.ejs",
"views/index.ejs",
]) {
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
+2 -1
View File
@@ -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");
+2 -2
View File
@@ -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");
+5 -1
View File
@@ -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<JsonWebKey | null>;
}
+2 -2
View File
@@ -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";
+2 -1
View File
@@ -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";
+2 -1
View File
@@ -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
-59
View File
@@ -11,7 +11,6 @@ import { staticJwks } from "../auth/jwks.ts";
import type { OAuth2Client } from "../auth/hydra-admin.ts";
import type { RelationTuple } from "../auth/keto-client.ts";
import type { Identity } from "../auth/kratos-admin.ts";
import { KratosError } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import adminManifest from "../../examples/plugins/auth-admin/plugin.ts";
@@ -214,11 +213,6 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/auth-admin/groups/eng/delete")).text(), /Cancel/);
const bystanders: RelationTuple[] = [
{ namespace: "Group", object: "ops", relation: "members", subject_id: `user:${ada}` },
{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` },
];
tuples.push(...bystanders);
const del = await post("/auth-admin/groups/eng/delete", `_csrf=${token}`);
assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/auth-admin/groups");
@@ -226,7 +220,6 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
// …and the permissions it held go with it. A Keto set exists only through its tuples, so an
// orphaned grant would resurrect the moment someone re-created a group with the same name.
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
assert.ok(bystanders.every((tp) => tuples.includes(tp)), "only the deleted group's own tuples go");
// An invalid group name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/auth-admin/groups/Bad%20Name")).status, 404);
@@ -375,58 +368,6 @@ test("admin permission grants: a Keto failure applies none of the changes and re
assert.equal(tuples.filter((tp) => tp.subject_set?.object === "eng").length, 0);
});
test("admin users: a refused self-deactivate or self-delete keeps the permissions section", async (t) => {
const identities: Identity[] = [{ id: "admin1", traits: { email: "admin@x" } }];
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto: fakeKeto(), kratosAdmin });
assert.notEqual(baselineOf(await (await get("/auth-admin/users/admin1")).text()), "", "the plain form carries the section");
const refusals: [Response, RegExp][] = [
[await post("/auth-admin/users/admin1/state", `_csrf=${token}`), /deactivate your own account/],
[await get("/auth-admin/users/admin1/delete"), /delete your own account/],
[await post("/auth-admin/users/admin1/delete", `_csrf=${token}`), /delete your own account/],
];
for (const [res, reason] of refusals) {
assert.equal(res.status, 400);
const html = await res.text();
assert.match(html, reason);
assert.notEqual(baselineOf(html), "");
}
});
test("admin users: a Kratos rejection of a save re-renders the form with its permissions, any other failure is a 500", async (t) => {
const identities: Identity[] = [{ id: "ada", traits: { email: "ada@example.com" } }];
let failure: Error = new KratosError("rejected", 400, "");
const kratosAdmin = stubAdmin({
getIdentity: async (id) => identities.find((i) => i.id === id) ?? null,
listIdentities: async () => ({ identities, nextPageToken: null }),
updateIdentity: async () => { throw failure; },
});
const { post, token } = await adminHarness(t, { keto: fakeKeto(), kratosAdmin });
const rejected = await post("/auth-admin/users/ada", `_csrf=${token}&first=Ada`);
assert.equal(rejected.status, 400);
assert.notEqual(baselineOf(await rejected.text()), "");
failure = new Error("kratos unreachable");
assert.equal((await post("/auth-admin/users/ada", `_csrf=${token}&first=Ada`)).status, 500);
});
test("admin group delete: a Keto failure leaves the group's grants and members in place", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` },
{ namespace: "Permission", object: "users:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } },
];
const keto = fakeKeto(tuples, { transactTuples: async () => { throw new Error("keto unreachable"); } });
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { post, token } = await adminHarness(t, { keto, kratosAdmin });
const res = await post("/auth-admin/groups/eng/delete", `_csrf=${token}`);
assert.equal(res.status, 500);
assert.equal(tuples.length, 2);
});
// A double-clicked Save posts twice from one page; the second post finds the first one's result.
// A save that changes nothing has nothing to refuse and nothing to revoke.
test("admin permission grants: a save that changes nothing is not refused for a stale page and leaves live tokens alone", async (t) => {
+1 -1
View File
@@ -406,7 +406,7 @@ test("/error renders a themed sign-in error page (Kratos' flow error sink), not
test("renders the 500 HTML page when a handler throws", async () => {
const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker
writeFileSync(join(dir, "dashboard.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
try {
await new Promise<void>((resolve) => app.listen(0, resolve));
+1 -1
View File
@@ -165,7 +165,7 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
return null;
}
return { data: { chrome: ctx.chrome }, view: "dashboard" };
return { data: { chrome: ctx.chrome }, view: "index" };
};
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
+1
View File
@@ -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<typeof postgres> {
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)");
+2 -1
View File
@@ -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";
+30 -11
View File
@@ -6,17 +6,19 @@
`Bar = 1`
`Next ID = 72`
`Next ID = 71`
## Items
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 62 | 0.5.0 | | | **Delete a group's grants and its membership set in one Keto transaction, so a failure cannot leave it half-deleted.** | 1 | 2 | 2 | 3 | 2 | code health by 2, 2 by 3 | 16.2 |
| 63 | 0.5.0 | | | **Correct the stale and false comments and docs the comprehension seats flagged, so each states what the code does today.** | 2 | 2 | 1 | 3 | 3 | code health by 3, 9 by 2 | 15.4 |
| 68 | 0.5.0 | | | **Make `serveHome` and `serveDashboard` return their result to the request pipeline, as every other stage does, instead of sending it themselves.** | 2 | 1 | 3 | 4 | 3 | code health by 3 | 13.5 |
| 69 | 0.5.0 | | | **Rename `views/index.ejs` to `views/dashboard.ejs`, so the core view's file names the page it renders.** | 1 | 2 | 2 | 3 | 2 | code health by 2 | 12.0 |
| 64 | 0.5.0 | | | **Keep the permissions section on the user form when a self-deactivate or self-delete is refused.** | 1 | 2 | 1 | 2 | 2 | 2 by 3, code health by 1 | 10.2 |
| 66 | 0.5.0 | | | **Make `src/auth/bootstrap.ts` read its settings through `src/config.ts` and call Kratos and Keto through their client modules.** | 2 | 1 | 3 | 4 | 4 | code health by 3 | 9.0 |
| 70 | 0.5.0 | | | **Give the duplicated paging loop, Link-header parser and `FieldConfig` type one home each, and rename `buildPluginChrome` to `buildPageChrome`.** | 2 | 1 | 1 | 2 | 3 | code health by 2 | 9.0 |
| 71 | 0.5.0 | | | **Delete, with a group, every membership tuple in another group that names it, so re-creating the name restores no nested membership.** | 1 | 2 | 2 | 3 | 2 | 2 by 3 | 4.2 |
| 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
| 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
| 65 | 0.6.0 | | | **Export `KetoError` from `@plainpages/plugin-api`, so a plugin can tell a Keto failure apart with `instanceof`.** | 1 | 2 | 1 | 2 | 1 | 7 by 3, 1 by 1 | 6.7 |
@@ -30,6 +32,14 @@
## Details
### 62. Delete a group's grants and its membership set in one Keto transaction, so a failure cannot leave it half-deleted.
`groupsDelete` in `examples/plugins/auth-admin/admin-groups.ts` drops each held grant with its own
`deleteTuple`, then the membership tuple, so a Keto failure part-way leaves a group with some grants
gone and its members still listed. Send all the deletes through `KetoClient.transactTuples`, as
`applyGrants` in `admin-grants.ts` does. The comment on delete order stays true inside one
transaction.
### 63. Correct the stale and false comments and docs the comprehension seats flagged, so each states what the code does today.
Prose only; no behaviour changes. Make each listed place easier to read, since the seats named
@@ -70,6 +80,24 @@ result with whatever names its renderer and let the one send stage render it. Ma
`prepareRequest` and `handleRequest` easier to read, places the seats found hard. The 19-field
`Host` bag is left alone: no checkable target for it came out of the scoring run.
### 69. Rename `views/index.ejs` to `views/dashboard.ejs`, so the core view's file names the page it renders.
`index.ejs` renders `/dashboard` (`src/http/app.ts` ~L168); `home.ejs` renders `/` and matches the
manifest's `home` slot, so it keeps its name. Core `views/` is an `include()` root for plugin views
(`src/plugin-host/view-resolver.ts`), so a plugin may include `index` today: the rename changes the
contract surface (AGENTS.md → Rules) and ships in a minor. Update `docs/reading-cost.md` and the
README file map. Make `views/index.ejs` easier to find, a place the seats found hard.
### 64. Keep the permissions section on the user form when a self-deactivate or self-delete is refused.
In `examples/plugins/auth-admin/admin-users.ts`, the self-refusals in `usersState` (~L405),
`usersDeleteConfirm` (~L415) and `usersDelete` (~L429) call `formResult` without
`...(await pickerOrNothing(deps, id))` or `canWrite`, so the re-rendered form loses its permissions
section; `usersUpdate` and `usersRecovery` keep it, and `pickerOrNothing`'s comment promises it.
The groups side re-renders through `groupDetailPage`, which keeps it. Make `usersPermissions` and
the `pickerOrNothing` contract easier to read, a place the seats found hard. Not exempt: a
comprehension finding, not a 🔴, and no maintain goal is broken.
### 66. Make `src/auth/bootstrap.ts` read its settings through `src/config.ts` and call Kratos and Keto through their client modules.
`seedAdminAndPermissions` (~L221-233) reads `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `KETO_WRITE_URL`,
@@ -93,15 +121,6 @@ easier to read, a place the seats found hard.
included; rename it, its test and the README ~L1027 mention. Make `buildPluginChrome` easier to
read, a place the seats found hard.
### 71. Delete, with a group, every membership tuple in another group that names it, so re-creating the name restores no nested membership.
Groups nest: a parent holds `Group:<parent>#members` with `subject_set` `Group:<child>#members`.
`groupsDelete` in `examples/plugins/auth-admin/admin-groups.ts` drops the child's own member tuples
and held grants, but not those parent tuples, so a group re-created with the deleted name is back
in every parent, the orphan class its comment already guards against for grants. List them with
`pagedTuples` filtered on `subject_set` (`namespace: GROUP_NS`, `object: name`, `relation: MEMBERS`)
and add them to the same `transactTuples` delete; extend the comment to say so.
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is