Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a00d86fb83 | |||
| e8fac7fd5c | |||
| d6a33e8cc9 | |||
| 0bf7901a14 | |||
| a0ad0ddf2d | |||
| 5088352fcf | |||
| 428800fd62 |
@@ -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:26.13.0-alpine3.24 node registry-cleanup/cleanup.ts
|
||||
node:24.22.2-alpine3.24 node registry-cleanup/cleanup.ts
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:26.13.0-alpine3.24 \
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-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:26.13.0-alpine3.24 \
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-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:26.13.0-alpine3.24 \
|
||||
node:24.22.2-alpine3.24 \
|
||||
node release-tooling/dockerhub-overview.ts "$VERSION"
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
-e RENOVATE_PLATFORM=gitea \
|
||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||
-e RENOVATE_TOKEN \
|
||||
renovate/renovate:44.222.3
|
||||
renovate/renovate:44.199.2
|
||||
|
||||
# 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:26.13.0-alpine3.24 \
|
||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-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:26.13.0-alpine3.24 \
|
||||
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$NEXT" -
|
||||
echo "Releasing $LATEST -> $NEXT"
|
||||
git tag "$NEXT" origin/main
|
||||
|
||||
@@ -12,6 +12,12 @@ 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`,
|
||||
@@ -19,6 +25,15 @@ 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
@@ -1,5 +1,5 @@
|
||||
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||
FROM node:26.13.0-alpine3.24
|
||||
FROM node:24.22.2-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).
|
||||
|
||||
@@ -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, index, auth, oauth-consent, error, 403/404/500/503,
|
||||
views/ Core EJS in the one app shell: home, dashboard, 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.
|
||||
|
||||
@@ -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:26.13.0-alpine3.24
|
||||
image: node:24.22.2-alpine3.24
|
||||
command: node /srv/server.ts
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
+29
-1
@@ -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/index.ejs` 1
|
||||
- `views/dashboard.ejs` 1
|
||||
- `views/partials/shell.ejs` 1
|
||||
|
||||
## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z
|
||||
@@ -485,3 +485,31 @@ 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
|
||||
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
|
||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||
shifts-upstream:
|
||||
image: node:26.13.0-alpine3.24
|
||||
image: node:24.22.2-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:26.13.0-alpine3.24
|
||||
image: node:24.22.2-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:26.13.0-alpine3.24
|
||||
image: node:24.22.2-alpine3.24
|
||||
command: ["node", "/proxy.ts"]
|
||||
depends_on:
|
||||
web:
|
||||
|
||||
Generated
+8
-8
@@ -7,7 +7,7 @@
|
||||
"name": "plainpages-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.64.1",
|
||||
"@types/node": "26.6.6",
|
||||
"@types/node": "24.19.2",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
},
|
||||
@@ -28,13 +28,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
|
||||
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
|
||||
"version": "24.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
||||
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.9.0"
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
@@ -442,9 +442,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.64.1",
|
||||
"@types/node": "26.6.6",
|
||||
"@types/node": "24.19.2",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,19 +431,20 @@ export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
|
||||
}) }, view: "confirm" };
|
||||
}, "write");
|
||||
|
||||
// POST /auth-admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
|
||||
// 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.
|
||||
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);
|
||||
// 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.
|
||||
// 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);
|
||||
for (const permission of held) await keto.deleteTuple(grantTuple(permission, subject));
|
||||
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
|
||||
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: [] });
|
||||
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
|
||||
return { redirect: ADMIN_GROUPS_BASE };
|
||||
});
|
||||
|
||||
@@ -391,8 +391,7 @@ export const usersUpdate = withTarget(async (deps, identity, id) => {
|
||||
try {
|
||||
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
|
||||
} catch (err) {
|
||||
// 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 };
|
||||
if (err instanceof KratosError) return refuseForm(deps, identity, id, ctx.t("admin.users.error.save"));
|
||||
throw err;
|
||||
}
|
||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||
@@ -400,9 +399,10 @@ 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 ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||
export const usersState = withTarget(async (deps, identity, id) => {
|
||||
const { ctx, kratosAdmin, revoke, user } = deps;
|
||||
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
|
||||
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDeactivate"), identity }), status: 400 };
|
||||
if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDeactivate"));
|
||||
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 ({ ctx, kratosAdmin, revoke, user },
|
||||
|
||||
// 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 Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 });
|
||||
if (id === deps.user.id) return refuseForm(deps, identity, id, deps.ctx.t("admin.users.error.selfDelete"));
|
||||
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
|
||||
const view = toUserView(identity);
|
||||
const tt = deps.ctx.t;
|
||||
@@ -424,9 +424,10 @@ 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 ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||
export const usersDelete = withTarget(async (deps, identity, id) => {
|
||||
const { ctx, kratosAdmin, revoke, user } = deps;
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 };
|
||||
if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDelete"));
|
||||
await kratosAdmin.deleteIdentity(id);
|
||||
revoke?.(id);
|
||||
ctx.log.info("admin: user deleted", { actor: user.id, target: id });
|
||||
@@ -441,7 +442,13 @@ export const usersRecovery = withTarget(async (deps, identity, id) => {
|
||||
return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
|
||||
});
|
||||
|
||||
// The picker as a spreadable fragment, so a re-render never silently drops the section.
|
||||
// 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.
|
||||
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
|
||||
const permissions = await userPermissionPicker(deps, id);
|
||||
return permissions ? { permissions } : {};
|
||||
|
||||
Generated
+13
-13
@@ -9,12 +9,12 @@
|
||||
"@larvit/log": "2.4.0",
|
||||
"ejs": "7.0.2",
|
||||
"lucide-static": "1.70.0",
|
||||
"postgres": "4.1.0"
|
||||
"postgres": "3.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdn/browser-compat-data": "8.1.8",
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/node": "26.6.6",
|
||||
"@types/node": "24.19.2",
|
||||
"acorn": "8.19.0",
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
@@ -46,13 +46,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
|
||||
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
|
||||
"version": "24.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
||||
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.9.0"
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
@@ -427,12 +427,12 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/postgres": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-4.1.0.tgz",
|
||||
"integrity": "sha512-s+0WH67YAyLdBQUb6XczbziBhY+C9uiQsg9S8R5RHdZhk6sdyK6202E+T2tXpiInSEEjDLRSJ3OpYBe3PN6MsQ==",
|
||||
"version": "3.4.9",
|
||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
|
||||
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
|
||||
"license": "Unlicense",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
@@ -475,9 +475,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
+2
-2
@@ -19,12 +19,12 @@
|
||||
"@larvit/log": "2.4.0",
|
||||
"ejs": "7.0.2",
|
||||
"lucide-static": "1.70.0",
|
||||
"postgres": "4.1.0"
|
||||
"postgres": "3.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdn/browser-compat-data": "8.1.8",
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/node": "26.6.6",
|
||||
"@types/node": "24.19.2",
|
||||
"acorn": "8.19.0",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
|
||||
@@ -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/index.ejs",
|
||||
"views/dashboard.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`);
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createPrivateKey, sign } from "node:crypto";
|
||||
import { createPrivateKey, sign, type JsonWebKey } 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");
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { generateKeyPairSync, type JsonWebKey } 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, type JsonWebKey } from "./jwks.ts";
|
||||
import { cachingJwks, createJwksProvider, loadJwks, staticJwks } 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");
|
||||
|
||||
+1
-5
@@ -1,4 +1,4 @@
|
||||
import type { webcrypto } from "node:crypto";
|
||||
import type { JsonWebKey } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { currentLog } from "../logger.ts";
|
||||
@@ -8,10 +8,6 @@ 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>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { generateKeyPairSync, sign, type KeyObject } from "node:crypto";
|
||||
import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto";
|
||||
import { test } from "node:test";
|
||||
import { staticJwks, type JsonWebKey } from "./jwks.ts";
|
||||
import { staticJwks } from "./jwks.ts";
|
||||
import { authenticate, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts";
|
||||
import { SESSION_COOKIE } from "./login.ts";
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { generateKeyPairSync, sign } from "node:crypto";
|
||||
import type { KeyObject } from "node:crypto";
|
||||
import type { JsonWebKey } from "./jwks.ts";
|
||||
import type { JsonWebKey, KeyObject } from "node:crypto";
|
||||
import { test } from "node:test";
|
||||
import { decodeJws, verifyJws } from "./jwt.ts";
|
||||
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
import { createPublicKey, verify } from "node:crypto";
|
||||
import type { KeyObject } from "node:crypto";
|
||||
import type { JsonWebKey } from "./jwks.ts";
|
||||
import type { JsonWebKey, KeyObject } from "node:crypto";
|
||||
|
||||
// JWS signature verification with the Node stdlib — no `jose`/JWT dep:
|
||||
// `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures
|
||||
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
@@ -213,6 +214,11 @@ 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");
|
||||
@@ -220,6 +226,7 @@ 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);
|
||||
@@ -368,6 +375,58 @@ 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) => {
|
||||
|
||||
@@ -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, "index.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
|
||||
writeFileSync(join(dir, "dashboard.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
@@ -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: "index" };
|
||||
return { data: { chrome: ctx.chrome }, view: "dashboard" };
|
||||
};
|
||||
|
||||
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
|
||||
|
||||
@@ -20,7 +20,6 @@ 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)");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { generateKeyPairSync, sign } from "node:crypto";
|
||||
import type { JsonWebKey } from "../auth/jwks.ts";
|
||||
import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { TestContext } from "node:test";
|
||||
import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
||||
|
||||
@@ -6,19 +6,17 @@
|
||||
|
||||
`Bar = 1`
|
||||
|
||||
`Next ID = 71`
|
||||
`Next ID = 72`
|
||||
|
||||
## 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 |
|
||||
@@ -32,14 +30,6 @@
|
||||
|
||||
## 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
|
||||
@@ -80,24 +70,6 @@ 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`,
|
||||
@@ -121,6 +93,15 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user