Compare commits
9 Commits
76fa6a96ea
...
v0.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
| bebefeafde | |||
| 465055e699 | |||
| db56a32e29 | |||
| 51219a3ee5 | |||
| f5c3d93837 | |||
| ea320f6a28 | |||
| d3151f222a | |||
| 54956fe627 | |||
| 9912dd64f1 |
@@ -19,4 +19,4 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||||
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
||||||
node:24.20.0-alpine3.24 node registry-cleanup/cleanup.ts
|
node:24.21.0-alpine3.24 node registry-cleanup/cleanup.ts
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
GIT_TAG: ${{ github.ref_name }}
|
GIT_TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
|
docker run --rm -v "$PWD:/repo" -w /repo node:24.21.0-alpine3.24 \
|
||||||
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
||||||
- name: Promote the commit-hash image to semver + latest
|
- name: Promote the commit-hash image to semver + latest
|
||||||
env:
|
env:
|
||||||
@@ -98,9 +98,9 @@ jobs:
|
|||||||
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
||||||
VERSION=${VERSION#v}
|
VERSION=${VERSION#v}
|
||||||
# An empty dispatch input falls back to the branch name, so gate this like a tag.
|
# 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.20.0-alpine3.24 \
|
docker run --rm -v "$PWD:/repo" -w /repo node:24.21.0-alpine3.24 \
|
||||||
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
|
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||||
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
|
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
|
||||||
node:24.20.0-alpine3.24 \
|
node:24.21.0-alpine3.24 \
|
||||||
node release-tooling/dockerhub-overview.ts "$VERSION"
|
node release-tooling/dockerhub-overview.ts "$VERSION"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
-e RENOVATE_PLATFORM=gitea \
|
-e RENOVATE_PLATFORM=gitea \
|
||||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||||
-e RENOVATE_TOKEN \
|
-e RENOVATE_TOKEN \
|
||||||
renovate/renovate:44.59.3
|
renovate/renovate:44.69.13
|
||||||
|
|
||||||
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
# 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
|
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
||||||
@@ -58,11 +58,11 @@ jobs:
|
|||||||
if [ -z "$BUMPS" ]; then
|
if [ -z "$BUMPS" ]; then
|
||||||
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
||||||
fi
|
fi
|
||||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
|
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.21.0-alpine3.24 \
|
||||||
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
||||||
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
|
# 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 \
|
git show origin/main:src/plugin-host/plugin.ts \
|
||||||
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
|
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.21.0-alpine3.24 \
|
||||||
node release-tooling/contract-version.ts "$NEXT" -
|
node release-tooling/contract-version.ts "$NEXT" -
|
||||||
echo "Releasing $LATEST -> $NEXT"
|
echo "Releasing $LATEST -> $NEXT"
|
||||||
git tag "$NEXT" origin/main
|
git tag "$NEXT" origin/main
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
The release version **is** the plugin contract version (`HOST_API_VERSION`), so a minor is a
|
||||||
|
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
|
||||||
|
at boot. Entries start at 0.3.0.
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
**Breaking.** Set `apiVersion: "0.3.0"`, and name a gate on every route and nav node.
|
||||||
|
|
||||||
|
### A session is a gate of its own
|
||||||
|
|
||||||
|
`session: true` takes any signed-in user, with no grant to hold — for a page whose data is the
|
||||||
|
visitor's own (their upstream account, their own tokens), where there is no distinction a permission
|
||||||
|
could name. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
|
||||||
|
permission gate does.
|
||||||
|
|
||||||
|
Every route and nav node now names **exactly one** of `public: true`, `session: true` or
|
||||||
|
`permission: "<resource>:<action>"`, and a gate is spelled `true`:
|
||||||
|
|
||||||
|
- Naming **none** is refused. It used to mean public, so a forgotten gate published a page; it now
|
||||||
|
fails the boot instead.
|
||||||
|
- Naming **two** is refused, as before.
|
||||||
|
- Spelling one anything but `true` is refused — `public: false` and `session: "yes"` both set no gate
|
||||||
|
while reading as if they set one.
|
||||||
|
|
||||||
|
A section header gates nothing itself, so it takes `public: true` and lets each child decide; the
|
||||||
|
host still drops a header whose children all filtered out.
|
||||||
|
|
||||||
|
`Gate` is exported from `@plainpages/plugin-api`, and `Route` and `NavNode` extend it.
|
||||||
|
|
||||||
|
### Filter bars take a multi-select
|
||||||
|
|
||||||
|
The `filter-bar` partial gains a `multiselect` control — the same checkboxes on the same query
|
||||||
|
parameter as `chips`, but behind a button once the list is too long to lay on the bar. Config is
|
||||||
|
`{ name, legend?, note?, value?, options }`, and the panel says what a capped list left out.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- An identity carrying no email no longer yields a session at all. Login used to mint a JWT for one,
|
||||||
|
which every later request then rejected as anonymous — leaving the browser holding a dead cookie
|
||||||
|
and no way to tell why.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Node 24.20.0.
|
||||||
|
|
||||||
|
### Upgrading a plugin
|
||||||
|
|
||||||
|
1. Set `apiVersion: "0.3.0"`.
|
||||||
|
2. Give every route and nav node a gate. Anything that relied on omitting one was public — say
|
||||||
|
`public: true` outright.
|
||||||
|
|
||||||
|
A page that scopes rows to the signed-in visitor should join on `ctx.user.id`. An email address is
|
||||||
|
user-changeable and can be reassigned to someone else, who would then inherit the previous holder's
|
||||||
|
rows. The reference plugin's new `/scheduling/mine` page shows the shape.
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||||
FROM node:24.20.0-alpine3.24
|
FROM node:24.21.0-alpine3.24
|
||||||
|
|
||||||
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
|
# 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).
|
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
|
|||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.3.0",
|
||||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||||
@@ -350,7 +350,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
import { listThings, createThings } from "./handlers.ts";
|
import { listThings, createThings } from "./handlers.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning)
|
apiVersion: "0.3.0", // semver string of the host contract this plugin was built against (see Versioning)
|
||||||
|
|
||||||
// Nav fragment, merged into the global menu and gate-filtered per user.
|
// Nav fragment, merged into the global menu and gate-filtered per user.
|
||||||
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
||||||
@@ -471,7 +471,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
import { landing, board } from "./pages.ts";
|
import { landing, board } from "./pages.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.3.0",
|
||||||
home: landing, // owns "/" — the public front page
|
home: landing, // owns "/" — the public front page
|
||||||
dashboard: board, // owns "/dashboard" — the post-login app home
|
dashboard: board, // owns "/dashboard" — the post-login app home
|
||||||
});
|
});
|
||||||
@@ -626,6 +626,7 @@ provider/consumer semantics in `checkApiVersion`:
|
|||||||
The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies
|
The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies
|
||||||
the compatibility. One digit carries the whole release, so a **minor** means either the plugin
|
the compatibility. One digit carries the whole release, so a **minor** means either the plugin
|
||||||
contract changed or a dependency moved far enough to warrant one.
|
contract changed or a dependency moved far enough to warrant one.
|
||||||
|
[`CHANGELOG.md`](CHANGELOG.md) is what a minor sends you to: what broke, and what to change.
|
||||||
|
|
||||||
|
|
||||||
### Conflict rules
|
### Conflict rules
|
||||||
@@ -756,7 +757,7 @@ camel humps both becoming underscores — so `upstream` on the `scheduling` plug
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.3.0",
|
||||||
settings: [
|
settings: [
|
||||||
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
|
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
|
||||||
{ key: "pageSize", type: "number", default: 25 },
|
{ key: "pageSize", type: "number", default: 25 },
|
||||||
@@ -810,7 +811,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
let sql: ReturnType<typeof postgres>;
|
let sql: ReturnType<typeof postgres>;
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.3.0",
|
||||||
storage: true,
|
storage: true,
|
||||||
hooks: {
|
hooks: {
|
||||||
onBoot: async (boot) => {
|
onBoot: async (boot) => {
|
||||||
@@ -1730,6 +1731,7 @@ e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,aut
|
|||||||
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
|
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
|
||||||
(the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl)
|
(the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl)
|
||||||
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
|
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
|
||||||
|
CHANGELOG.md What changed per release, and how to upgrade a plugin across a minor
|
||||||
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
|
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
|
||||||
.gitea/workflows/ Gitea Actions — see CI/CD
|
.gitea/workflows/ Gitea Actions — see CI/CD
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
# 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.
|
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.20.0-alpine3.24
|
image: node:24.21.0-alpine3.24
|
||||||
command: node /srv/server.ts
|
command: node /srv/server.ts
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
@@ -58,7 +58,7 @@ services:
|
|||||||
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
||||||
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.31.0
|
image: axllent/mailpit:v1.31.1
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
|
# 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.
|
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
|
||||||
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
FROM mcr.microsoft.com/playwright:v1.63.0-noble
|
||||||
|
|
||||||
WORKDIR /e2e-tests
|
WORKDIR /e2e-tests
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ services:
|
|||||||
|
|
||||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.20.0-alpine3.24
|
image: node:24.21.0-alpine3.24
|
||||||
command: ["node", "/server.ts"]
|
command: ["node", "/server.ts"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
- ./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
|
# 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.
|
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
||||||
mock-oidc:
|
mock-oidc:
|
||||||
image: node:24.20.0-alpine3.24
|
image: node:24.21.0-alpine3.24
|
||||||
command: ["node", "/mock-oidc.ts"]
|
command: ["node", "/mock-oidc.ts"]
|
||||||
environment:
|
environment:
|
||||||
ISSUER: http://mock-oidc:9000
|
ISSUER: http://mock-oidc:9000
|
||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
|
|
||||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||||
proxy:
|
proxy:
|
||||||
image: node:24.20.0-alpine3.24
|
image: node:24.21.0-alpine3.24
|
||||||
command: ["node", "/proxy.ts"]
|
command: ["node", "/proxy.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
|
|||||||
Generated
+12
-30
@@ -6,17 +6,17 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "plainpages-e2e",
|
"name": "plainpages-e2e",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.1"
|
"@playwright/test": "1.63.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.62.1",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.62.1"
|
"playwright": "1.63.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -25,44 +25,26 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.62.1",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.62.1"
|
"playwright-core": "1.63.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "2.3.2"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.62.1",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
"test": "playwright test"
|
"test": "playwright test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.1"
|
"@playwright/test": "1.63.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const clients = on("oauth2-clients");
|
|||||||
const pluginSettings = on("plugin-settings");
|
const pluginSettings = on("plugin-settings");
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
nav: [ADMIN_NAV],
|
nav: [ADMIN_NAV],
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera
|
|||||||
| `GET /shifts` | `Accept: application/json`, optional `?assigneeId=<id>` | `200` | JSON array of `{ id, title, assignee, assigneeId, start, end }` (all strings; missing fields coerce to `""`). With `assigneeId`, only that person's rows |
|
| `GET /shifts` | `Accept: application/json`, optional `?assigneeId=<id>` | `200` | JSON array of `{ id, title, assignee, assigneeId, start, end }` (all strings; missing fields coerce to `""`). With `assigneeId`, only that person's rows |
|
||||||
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
|
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
|
||||||
|
|
||||||
|
`POST /shifts` carries the assignee as a **display name only**, so a shift created through this
|
||||||
|
plugin's form belongs to nobody and surfaces on no one's "My shifts" — don't go hunting for it
|
||||||
|
there. Resolving a name to an identity id needs a directory this demo has none of; a real backend
|
||||||
|
does that join at create time and stores the `assigneeId` alongside the name.
|
||||||
|
|
||||||
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
|
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
|
||||||
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
|
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const messages = {
|
|||||||
"scheduling.new.title": "New shift",
|
"scheduling.new.title": "New shift",
|
||||||
"scheduling.overview.lead":
|
"scheduling.overview.lead":
|
||||||
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.",
|
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.",
|
||||||
|
"scheduling.overview.mine": "See my shifts",
|
||||||
"scheduling.overview.signIn": "Sign in to view shifts",
|
"scheduling.overview.signIn": "Sign in to view shifts",
|
||||||
"scheduling.overview.title": "Scheduling",
|
"scheduling.overview.title": "Scheduling",
|
||||||
"scheduling.overview.view": "View shifts",
|
"scheduling.overview.view": "View shifts",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const messages: SchedulingMessages = {
|
|||||||
"scheduling.new.title": "Nytt pass",
|
"scheduling.new.title": "Nytt pass",
|
||||||
"scheduling.overview.lead":
|
"scheduling.overview.lead":
|
||||||
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.",
|
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.",
|
||||||
|
"scheduling.overview.mine": "Visa mina pass",
|
||||||
"scheduling.overview.signIn": "Logga in för att se passen",
|
"scheduling.overview.signIn": "Logga in för att se passen",
|
||||||
"scheduling.overview.title": "Schemaläggning",
|
"scheduling.overview.title": "Schemaläggning",
|
||||||
"scheduling.overview.view": "Visa pass",
|
"scheduling.overview.view": "Visa pass",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ let upstreamUrl = "";
|
|||||||
const upstream = createUpstream(() => upstreamUrl);
|
const upstream = createUpstream(() => upstreamUrl);
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
|
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
|
||||||
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
||||||
|
|||||||
@@ -115,14 +115,21 @@ test("listShifts degrades to a recoverable error page when the upstream is down
|
|||||||
|
|
||||||
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
|
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
|
||||||
|
|
||||||
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
test("overview renders a public page for anyone, and its CTA names the best gate the visitor passes", async () => {
|
||||||
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
|
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
|
||||||
assert.equal(anon.view, "overview");
|
assert.equal(anon.view, "overview");
|
||||||
assert.equal(anon.data["chrome"], CHROME);
|
assert.equal(anon.data["chrome"], CHROME);
|
||||||
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
||||||
|
assert.equal(anon.data["signedIn"], false);
|
||||||
|
|
||||||
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
|
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
|
||||||
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
||||||
|
|
||||||
|
// Signed in but ungranted: the page must not invite them to sign in again.
|
||||||
|
const member = asView(await overview()(fakeCtx({ user: { email: "m@example.test", id: "01a06091-baa3-7a1f-9c62-0e3ab6d2f5c1", permissions: [] } })));
|
||||||
|
assert.equal(member.data["canRead"], false);
|
||||||
|
assert.equal(member.data["signedIn"], true);
|
||||||
|
assert.equal(member.data["mineHref"], "/scheduling/mine");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- create handler ----
|
// ---- create handler ----
|
||||||
|
|||||||
@@ -236,7 +236,9 @@ export function overview(): RouteHandler {
|
|||||||
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
|
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
|
||||||
canRead: can(ctx, READ),
|
canRead: can(ctx, READ),
|
||||||
chrome: ctx.chrome,
|
chrome: ctx.chrome,
|
||||||
|
mineHref: ctx.localeHref(MINE_PATH),
|
||||||
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
|
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
|
||||||
|
signedIn: ctx.user !== null,
|
||||||
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
|
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
|
||||||
title: ctx.t("scheduling.overview.title"),
|
title: ctx.t("scheduling.overview.title"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,12 +3,16 @@
|
|||||||
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
|
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
|
||||||
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
|
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
|
||||||
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
||||||
Data: chrome, title, breadcrumbs, canRead, shiftsHref, signInHref
|
Data: chrome, title, breadcrumbs, canRead, mineHref, shiftsHref, signedIn, signInHref
|
||||||
%><%
|
%><%
|
||||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
|
// One CTA per gate the visitor passes: the list needs the permission, "My shifts" only a session,
|
||||||
|
// and sign-in is offered to nobody who already has one.
|
||||||
const cta = canRead
|
const cta = canRead
|
||||||
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
|
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
|
||||||
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
|
: signedIn
|
||||||
|
? '<a class="btn btn-primary" href="' + mineHref + '">' + t("scheduling.overview.mine") + '</a>'
|
||||||
|
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions: "",
|
actions: "",
|
||||||
|
|||||||
Generated
+4
-4
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.4",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.3",
|
"version": "24.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
|
||||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
"integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.4",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns
|
|||||||
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
|
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
|
||||||
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
|
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
|
||||||
// makes an accidental edit fail here rather than at release time.
|
// makes an accidental edit fail here rather than at release time.
|
||||||
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.4.0");
|
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.3.0");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("every author-facing apiVersion sample matches the shipped contract", () => {
|
test("every author-facing apiVersion sample matches the shipped contract", () => {
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ services:
|
|||||||
|
|
||||||
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.31.0
|
image: axllent/mailpit:v1.31.1
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
|
|||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.3.0",
|
||||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
|||||||
import type { StorageCredentials } from "./storage.ts";
|
import type { StorageCredentials } from "./storage.ts";
|
||||||
|
|
||||||
// The Plainpages release this contract ships in — see README → Contract versioning.
|
// The Plainpages release this contract ships in — see README → Contract versioning.
|
||||||
export const HOST_API_VERSION = "0.4.0";
|
export const HOST_API_VERSION = "0.3.0";
|
||||||
|
|
||||||
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression.
|
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression.
|
||||||
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`.
|
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`.
|
||||||
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag.
|
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag.
|
||||||
|
- [ ] Map Kratos' 401 on a self-service flow init, so an anonymous `GET /settings` with no `?flow` renders instead of 500ing. `flowPage` (`src/auth/routes.ts`) maps 403/404/410 → restart the flow, 400 `session_already_available` → `/auth/complete`, and ≥500 → the themed 503, then rethrows everything else — and Kratos answers the settings-flow init with 401 when there is no session. `/settings` is correctly `public` (the recovery flow lands there with a live Kratos session but no app JWT), so the gate is not the fix: a 401 should redirect to `/login` with the page as `return_to`. No E2E covers an anonymous hit on a flow page that needs a session.
|
||||||
|
|
||||||
### Architectural review findings (2026-07-02)
|
### Architectural review findings (2026-07-02)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
<p>${t("dashboard.starter.intro")}</p>
|
<p>${t("dashboard.starter.intro")}</p>
|
||||||
<p>${t("dashboard.starter.replace")}</p>
|
<p>${t("dashboard.starter.replace")}</p>
|
||||||
<pre class="code-block"><code>export default definePlugin({
|
<pre class="code-block"><code>export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.3.0",
|
||||||
// view names plugins/<id>/views/<view>.ejs, rendered in this same shell
|
// view names plugins/<id>/views/<view>.ejs, rendered in this same shell
|
||||||
dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
|
dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
|
||||||
});</code></pre>
|
});</code></pre>
|
||||||
|
|||||||
Reference in New Issue
Block a user