Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 702e42de09 | |||
| 98bdd2c1b5 | |||
| 12913402a6 | |||
| 7dee80a976 | |||
| c0fe8b0a82 | |||
| fb7d20a7db | |||
| 0919adf4ef | |||
| f5f4455b81 | |||
| 3f30f889c3 | |||
| ae1479f55b | |||
| bb6021adf7 | |||
| c4e6212189 | |||
| 6f6aafad39 | |||
| 4aada6eed9 | |||
| a574effb37 | |||
| c259497627 | |||
| 8e74532a77 | |||
| 62f95afe63 | |||
| ffeec70f8f | |||
| 62d4c8b7cd | |||
| 12cc2d54c2 | |||
| bb6adc40af | |||
| c64156a9d5 | |||
| de2ad42f5a | |||
| 9213e5a0de | |||
| 45054db5e6 | |||
| 23bafd247d | |||
| 7c66599f35 | |||
| 6db0f57bf4 | |||
| 175717f04d | |||
| 6c850b8923 | |||
| af4a70d904 | |||
| a0244a32cd | |||
| 6559f40142 | |||
| d3154819f8 | |||
| 1cba6d470c | |||
| 194c090bd1 | |||
| ff5094f7e9 | |||
| 419ee1750b | |||
| cedac950be | |||
| ff455f1ef2 | |||
| 5bd26d773d | |||
| 4f60bad119 | |||
| aea568ea1c | |||
| 145db5b4cd | |||
| 7c39056188 | |||
| 9719586f51 | |||
| 67d8a095a5 | |||
| 476ef6fce2 | |||
| 93fa751d6d |
@@ -8,6 +8,8 @@ jobs:
|
|||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.2.2
|
- uses: actions/checkout@v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # ci.sh's docs-only check needs history; checkout defaults to depth 1
|
||||||
- run: bash ci.sh
|
- run: bash ci.sh
|
||||||
- name: Push app image tagged with the commit hash
|
- name: Push app image tagged with the commit hash
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -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.16.0-alpine3.24 node registry-cleanup/cleanup.ts
|
node:24.18.1-alpine3.24 node registry-cleanup/cleanup.ts
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
name: Renovate
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '17 4 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: docker-host
|
||||||
|
steps:
|
||||||
|
- name: Run Renovate against this repo
|
||||||
|
env:
|
||||||
|
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
|
||||||
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-e GITHUB_COM_TOKEN \
|
||||||
|
-e LOG_LEVEL=info \
|
||||||
|
-e RENOVATE_ENDPOINT=https://gitea.larvit.se/api/v1 \
|
||||||
|
-e RENOVATE_GIT_AUTHOR="Renovate Bot <renovate@larvit.se>" \
|
||||||
|
-e RENOVATE_PLATFORM=gitea \
|
||||||
|
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||||
|
-e RENOVATE_TOKEN \
|
||||||
|
renovate/renovate:44.6.0
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
|
||||||
|
# (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's
|
||||||
|
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
|
||||||
|
# pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the
|
||||||
|
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
|
||||||
|
auto-release:
|
||||||
|
runs-on: docker-host
|
||||||
|
needs: renovate
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Tag a release for what Renovate merged
|
||||||
|
env:
|
||||||
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git fetch --force --quiet origin '+refs/heads/main:refs/remotes/origin/main' '+refs/tags/*:refs/tags/*'
|
||||||
|
if [ "$(git log -1 --format='%ae' origin/main)" != "renovate@larvit.se" ]; then
|
||||||
|
echo "main tip not authored by Renovate — a human owns this release; skipping"; exit 0
|
||||||
|
fi
|
||||||
|
LATEST=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)
|
||||||
|
LATEST=${LATEST:-v0.0.0}
|
||||||
|
if [ -z "$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' --format='%H')" ]; then
|
||||||
|
echo "No untagged renovate commits since ${LATEST} — nothing to release"; exit 0
|
||||||
|
fi
|
||||||
|
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
|
||||||
|
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
|
||||||
|
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.18.1-alpine3.24 \
|
||||||
|
node auto-release/next-version.ts "$LATEST" $BUMPS)
|
||||||
|
echo "Releasing $LATEST -> $NEXT"
|
||||||
|
git tag "$NEXT" origin/main
|
||||||
|
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
|
||||||
@@ -7,7 +7,7 @@ commands and layout.
|
|||||||
|
|
||||||
Use the file `todo.md`.
|
Use the file `todo.md`.
|
||||||
|
|
||||||
For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, run the stability reviewer agent in a loop and address all feedback until there is none. If you are not very confident of how to address it, ask the user. Check the completed task in this file. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green.
|
For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, check the completed task in `todo.md`. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green.
|
||||||
|
|
||||||
## Project priorities (do not erode)
|
## Project priorities (do not erode)
|
||||||
|
|
||||||
@@ -89,8 +89,10 @@ them. Revisit only if the stated reason stops holding.
|
|||||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
||||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
|
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
|
||||||
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
|
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
|
||||||
and tokens sit in that file between login and logout. Accepted for a single-maintainer
|
and tokens sit in that file between login and logout. Same class: concurrent runs share the
|
||||||
cadence; serialize with a workflow `concurrency` group if it ever bites.
|
workspace dir, so ci.sh's web-image build races another run's container creation on the
|
||||||
|
`<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
|
||||||
|
`concurrency` group if it ever bites.
|
||||||
|
|
||||||
## Docker only — no host tooling
|
## Docker only — no host tooling
|
||||||
|
|
||||||
@@ -127,6 +129,14 @@ When editing: put content in the section it belongs to (don't prepend rationale
|
|||||||
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
|
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
|
||||||
one home, linking to it rather than restating (credentials, env vars, rotation steps).
|
one home, linking to it rather than restating (credentials, env vars, rotation steps).
|
||||||
|
|
||||||
|
**Don't document internals here.** How a script reaches a decision, why one run behaved
|
||||||
|
differently from another, what a function guards — a developer doesn't need it day to day and
|
||||||
|
can read it off the code or a run's log in seconds. Prose like that only makes the README
|
||||||
|
longer and harder to consume, for humans and machines alike. It belongs in the code it
|
||||||
|
describes, or nowhere. The README earns its length on what you cannot dig out: how to use and
|
||||||
|
operate Plainpages, the external contracts, and one-time setup (secrets, accounts, tokens).
|
||||||
|
Same test before adding a row to a table or the file map — a clause, not a paragraph.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
|
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
|
||||||
@@ -142,6 +152,10 @@ one home, linking to it rather than restating (credentials, env vars, rotation s
|
|||||||
- Tests use the built-in `node --test` runner — no test framework dependency.
|
- Tests use the built-in `node --test` runner — no test framework dependency.
|
||||||
- English everywhere. Keep code comments short and information-dense. Self explained code
|
- English everywhere. Keep code comments short and information-dense. Self explained code
|
||||||
without any comment at all is the preferred solution.
|
without any comment at all is the preferred solution.
|
||||||
|
- Do not comment about history in the code or README. Like "This function included X before,
|
||||||
|
but it moved to Y".
|
||||||
|
- Do not comment about the absence of things, if it is not very unexpected. Banned is things
|
||||||
|
like "This function does not calculate pi, that is done in function Z".
|
||||||
- Pin all dependencies and Docker images to exact, human-readable **semantic
|
- Pin all dependencies and Docker images to exact, human-readable **semantic
|
||||||
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
|
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
|
||||||
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
|
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
|
||||||
@@ -156,9 +170,8 @@ one home, linking to it rather than restating (credentials, env vars, rotation s
|
|||||||
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
|
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
|
||||||
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
|
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
|
||||||
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
|
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
|
||||||
- Run the stability reviewer agent after every implementation of something that can be like
|
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
|
||||||
a PR. That includes any change pushed directly to main.
|
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
|
||||||
Skip this if the changes are purely documentation and/or comments.
|
|
||||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
|
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
|
||||||
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
|
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
|
||||||
and not "ids=x,y".
|
and not "ids=x,y".
|
||||||
+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.16.0-alpine3.24
|
FROM node:24.18.1-alpine3.24
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# Plainpages
|
||||||
|
|
||||||
|
A self-hostable foundation for server-rendered web apps — public or gated pages from a
|
||||||
|
zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in.
|
||||||
|
Every domain feature is a drop-in plugin folder; the app is stateless, no build step.
|
||||||
|
|
||||||
|
**Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>**
|
||||||
|
([GitHub mirror](https://github.com/larvit/plainpages))
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
`X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build.
|
||||||
|
Pin the exact `X.Y.Z` you deploy.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
This image is the Plainpages web app plus its one-shot bootstrap seeder. It runs
|
||||||
|
alongside its Ory sidecars (Kratos, Keto) and Postgres — and it **ships their config**,
|
||||||
|
so there is nothing to clone. In an empty directory, save this as `compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: larvit/plainpages:0.0.2
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
APP_URL: http://localhost:3000
|
||||||
|
depends_on:
|
||||||
|
bootstrap:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
kratos:
|
||||||
|
condition: service_healthy
|
||||||
|
keto:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
|
||||||
|
- ./plugins:/app/plugins
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
|
||||||
|
bootstrap:
|
||||||
|
image: larvit/plainpages:0.0.2
|
||||||
|
command: node src/auth/bootstrap.ts
|
||||||
|
depends_on:
|
||||||
|
kratos:
|
||||||
|
condition: service_healthy
|
||||||
|
keto:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
|
||||||
|
- ./plugins:/app/plugins:ro
|
||||||
|
restart: "on-failure:5"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:18.4-alpine3.23
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ory
|
||||||
|
POSTGRES_PASSWORD: ory
|
||||||
|
POSTGRES_USER: ory
|
||||||
|
volumes:
|
||||||
|
- ./ory/postgres/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
- pgdata:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ory -d ory"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
kratos-migrate:
|
||||||
|
image: oryd/kratos:v26.2.0
|
||||||
|
command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DSN: postgres://ory:ory@postgres:5432/kratos?sslmode=disable
|
||||||
|
volumes:
|
||||||
|
- ./ory/kratos:/etc/config/kratos:ro
|
||||||
|
restart: on-failure
|
||||||
|
|
||||||
|
kratos:
|
||||||
|
image: oryd/kratos:v26.2.0
|
||||||
|
command: serve -c /etc/config/kratos/kratos.yml --watch-courier
|
||||||
|
ports:
|
||||||
|
- "4433:4433" # the login form POSTs straight to Kratos from the browser
|
||||||
|
depends_on:
|
||||||
|
kratos-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
DSN: postgres://ory:ory@postgres:5432/kratos?sslmode=disable
|
||||||
|
volumes:
|
||||||
|
- ./ory/kratos:/etc/config/kratos:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4433/health/ready"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
keto-migrate:
|
||||||
|
image: oryd/keto:v26.2.0
|
||||||
|
command: -c /etc/config/keto/keto.yml migrate up -y
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DSN: postgres://ory:ory@postgres:5432/keto?sslmode=disable
|
||||||
|
volumes:
|
||||||
|
- ./ory/keto:/etc/config/keto:ro
|
||||||
|
restart: on-failure
|
||||||
|
|
||||||
|
keto:
|
||||||
|
image: oryd/keto:v26.2.0
|
||||||
|
command: serve -c /etc/config/keto/keto.yml
|
||||||
|
depends_on:
|
||||||
|
keto-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
DSN: postgres://ory:ory@postgres:5432/keto?sslmode=disable
|
||||||
|
volumes:
|
||||||
|
- ./ory/keto:/etc/config/keto:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4466/health/ready"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:v1.30.1
|
||||||
|
ports:
|
||||||
|
- "8025:8025"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract the Ory config the image ships, then start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm larvit/plainpages:0.0.2 tar -cf - ory | tar -xf -
|
||||||
|
mkdir -p plugins
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://localhost:3000> and sign in as `admin@plainpages.local` / `admin`.
|
||||||
|
|
||||||
|
This quick start runs http-on-localhost with dev-throwaway secrets, and omits Hydra (the
|
||||||
|
OAuth2 provider — only needed when other apps log in *through* Plainpages). For
|
||||||
|
production — https, real secrets (`CSRF_SECRET`, Postgres credentials, a fresh JWT
|
||||||
|
signing key), Hydra — see the repo README → Production & deployment.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Every behaviour is an explicit env toggle read at boot — no `NODE_ENV`. The common ones:
|
||||||
|
|
||||||
|
| Var | Default | What |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | `admin@plainpages.local` / `admin` | the seeded first admin (bootstrap service) |
|
||||||
|
| `APP_URL` | unset | canonical public URL; off-host visitors are redirected to it |
|
||||||
|
| `CACHE_TEMPLATES` | `false` | cache compiled templates (`true` in prod) |
|
||||||
|
| `CSRF_SECRET` | dev throwaway | signs the CSRF token — set a real one in prod |
|
||||||
|
| `KRATOS_*` / `KETO_*` / `HYDRA_*` URLs | compose defaults | the Ory sidecar endpoints |
|
||||||
|
| `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs |
|
||||||
|
| `OTLP_ENDPOINT` | unset | export logs + traces to an OpenTelemetry Collector |
|
||||||
|
| `REQUIRE_SECURE_SECRETS` | `false` | `true` ⇒ refuse to boot on a missing/throwaway `CSRF_SECRET` |
|
||||||
|
| `SECURE_COOKIES` | `false` | mark cookies `Secure` (`true` behind https) |
|
||||||
|
|
||||||
|
Full list (JWT/JWKS, timeouts, instant revoke): repo README → Configuration.
|
||||||
|
|
||||||
|
## Your first plugin
|
||||||
|
|
||||||
|
Everything domain-specific is a plugin folder — the compose above mounts `./plugins`
|
||||||
|
into the app. Create `plugins/hello/plugin.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { definePlugin } from "#plugin-api";
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart (`docker compose restart web`) and visit <http://localhost:3000/hello>. Views,
|
||||||
|
forms, permissions, and the runnable reference plugin: repo README → Building plugins.
|
||||||
@@ -94,6 +94,7 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
- [instant revoke](#instant-revoke-the-optional-denylist)
|
- [instant revoke](#instant-revoke-the-optional-denylist)
|
||||||
- [three tiers](#three-tiers-of-may-i)
|
- [three tiers](#three-tiers-of-may-i)
|
||||||
- [OAuth2 (Hydra)](#oauth2-provider-hydra)
|
- [OAuth2 (Hydra)](#oauth2-provider-hydra)
|
||||||
|
- [security model](#security-model)
|
||||||
- [Email](#email)
|
- [Email](#email)
|
||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
- [Stateless](#stateless)
|
- [Stateless](#stateless)
|
||||||
@@ -836,14 +837,29 @@ both default to `localhost` (the dev override sets `APP_URL=http://localhost:300
|
|||||||
|
|
||||||
A clean clone needs **none** of the above — `docker compose up` brings up the whole stack
|
A clean clone needs **none** of the above — `docker compose up` brings up the whole stack
|
||||||
with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see
|
with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see
|
||||||
[Quick start](#quick-start)). Exactly **two** things can't be auto-generated, and **both
|
[Quick start](#quick-start)). What can't be auto-generated is **production-only** — none of it
|
||||||
are production-only** — neither blocks a clean clone:
|
blocks a clean clone:
|
||||||
|
|
||||||
|
1. **Production secrets** — every value below ships as a committed dev throwaway that works
|
||||||
|
out of the box and **must** be replaced before a deploy faces the internet. Only the first
|
||||||
|
is enforced: `REQUIRE_SECURE_SECRETS=true` refuses to boot on a missing or throwaway
|
||||||
|
`CSRF_SECRET` and **nothing else** — the rest fail silently, so treat this as a checklist.
|
||||||
|
|
||||||
|
| Secret | Where | Protects |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `CSRF_SECRET` | web env | signs our double-submit CSRF token |
|
||||||
|
| JWT signing key | mount a real `jwks.json` or set `…_JWKS_URL` | mints/verifies the session JWT — see [rotation](#jwt-signing-key--rotation) |
|
||||||
|
| `SECRETS_COOKIE` | kratos env | signs Kratos' session + anti-CSRF cookies |
|
||||||
|
| `SECRETS_CIPHER` | kratos env (32 chars) | encrypts credentials at rest |
|
||||||
|
| `SECRETS_SYSTEM` | hydra env | encrypts OAuth2 tokens + consent at rest |
|
||||||
|
| `POSTGRES_USER` / `POSTGRES_PASSWORD` | compose env | the Ory databases (default `ory`/`ory`) |
|
||||||
|
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | bootstrap env | the seeded first admin login (default `admin@plainpages.local` / `admin`) |
|
||||||
|
|
||||||
|
`CSRF_SECRET`, the Postgres pair and the admin pair are interpolated from the host
|
||||||
|
environment. The three Ory secrets are **not**: `compose.yml` passes only `DSN` to
|
||||||
|
`kratos`/`hydra`, so add them to those services' `environment:` (or an `env_file:`) or they
|
||||||
|
silently stay on the throwaways.
|
||||||
|
|
||||||
1. **Production secrets** — replace the committed dev throwaway `CSRF_SECRET` (env), plus
|
|
||||||
the **JWT signing key** (mount a real `jwks.json` or set `…_JWKS_URL` — see
|
|
||||||
[JWT signing key & rotation](#jwt-signing-key--rotation)). Set
|
|
||||||
`REQUIRE_SECURE_SECRETS=true` and the app refuses to boot until `CSRF_SECRET` is supplied
|
|
||||||
and differs from the throwaway.
|
|
||||||
2. **SSO provider client id/secret** — **optional**; password login works without them.
|
2. **SSO provider client id/secret** — **optional**; password login works without them.
|
||||||
Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see
|
Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see
|
||||||
[Social sign-in (SSO)](#social-sign-in-sso)).
|
[Social sign-in (SSO)](#social-sign-in-sso)).
|
||||||
@@ -997,6 +1013,76 @@ generated `client_secret` **once**, on the confirmation page — confidential cl
|
|||||||
delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration;
|
delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration;
|
||||||
writes go only to Hydra.
|
writes go only to Hydra.
|
||||||
|
|
||||||
|
### Security model
|
||||||
|
|
||||||
|
Everything above is *how* auth works. This is what to check a change against: who is trusted,
|
||||||
|
what defends what, and which guarantees are deliberately not offered.
|
||||||
|
|
||||||
|
**Trust boundaries.**
|
||||||
|
|
||||||
|
- **The browser is not trusted.** Cookies, form fields, URLs and headers are attacker-controlled
|
||||||
|
until verified or escaped. Nothing is believed because of where it arrived from.
|
||||||
|
- **The session JWT is trusted only after verification** — signature against the JWKS key its
|
||||||
|
`kid` names (or the sole key, when the token carries no `kid`), then a **mandatory** `exp`,
|
||||||
|
plus `nbf` and the optional `iss`/`aud`. Before that it is bytes.
|
||||||
|
- **The private container network is the *only* thing guarding the Ory APIs.** Kratos admin
|
||||||
|
(`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching them
|
||||||
|
*is* full identity and permission control. Keto **read** (`4466`) cannot write, but discloses
|
||||||
|
the entire authorization graph, so treat it the same. `compose.yml` publishes none of the six
|
||||||
|
Ory ports (guarded by `src/compose.test.ts`); dev publishes only the two a browser must reach.
|
||||||
|
Never expose one, and never front one with a proxy that lacks its own auth.
|
||||||
|
- **Plugins are trusted code**, in-process and unsandboxed — a plugin can do anything the host
|
||||||
|
can. Vetting happens when you mount one, not at runtime (AGENTS.md: crash isolation is a
|
||||||
|
deliberate non-goal).
|
||||||
|
- **Attribute-based row rules belong upstream**, in the service that owns the data;
|
||||||
|
relationship-based ones go to Keto — see [three tiers](#three-tiers-of-may-i).
|
||||||
|
|
||||||
|
**The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own
|
||||||
|
`sub`, `email` and `roles`. `HttpOnly` keeps page JavaScript out of the cookie, not the user.
|
||||||
|
Never put anything in a claim you wouldn't show them.
|
||||||
|
|
||||||
|
**What defends what.**
|
||||||
|
|
||||||
|
| Threat | Defense |
|
||||||
|
| --- | --- |
|
||||||
|
| Forged or tampered token | signature verified by `kid`; `alg` allowlist is `RS256`/`ES256` only — **never `HS*` or `none`**, either of which lets a forged token verify (`src/auth/jwt.ts`) |
|
||||||
|
| `alg` confusion | a key that pins an `alg` must match the header's; the key type must always match the family |
|
||||||
|
| Replayed expired token | `exp` is mandatory — a token without one is rejected, never treated as eternal; `JWT_CLOCK_SKEW_SEC` leeway |
|
||||||
|
| Token minted for another deployment | optional `JWT_ISSUER` / `JWT_AUDIENCE` pinning |
|
||||||
|
| Stolen session cookie | `HttpOnly`, `SameSite=Lax`, `Secure` (`SECURE_COOKIES`) — but see the real session lifetime below |
|
||||||
|
| CSRF on our own forms | signed double-submit token, **opt-in per handler** via `ctx.verifyCsrf` (`src/auth/csrf.ts`) + `SameSite=Lax`; Kratos' flows carry Kratos' own token |
|
||||||
|
| XSS | EJS `<%= %>` escapes; the CSP blocks inline script ([headers](#production--deployment)) — the `*.html` slots stay [raw by contract](#escaping--the-trust-boundary) |
|
||||||
|
| Clickjacking | `frame-ancestors 'none'` + `X-Frame-Options: DENY` |
|
||||||
|
| Open redirect via `return_to` | validated host-relative (`localPath`, `src/http/safe-url.ts`) |
|
||||||
|
| Privilege escalation | roles authored only in Keto, re-read at every mint — see [login & the session JWT](#login-and-the-session-jwt) |
|
||||||
|
| Downgrade / MIME sniffing | HSTS when `SECURE_COOKIES=true`, `X-Content-Type-Options: nosniff` |
|
||||||
|
| A hung Ory parking requests | `ORY_TIMEOUT_SEC` per outbound call |
|
||||||
|
|
||||||
|
**The JWT's ~10m TTL is not the session lifetime.** The browser also holds Kratos'
|
||||||
|
`plainpages_session` cookie (30 days, sliding), and *that* is what silently re-mints a lapsed
|
||||||
|
JWT. So a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our
|
||||||
|
two cookies obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
|
||||||
|
|
||||||
|
**Fail closed — with one deliberate exception.** A token that cannot be verified (missing,
|
||||||
|
malformed, bad signature, wrong `iss`/`aud`) yields *anonymous*, never a partly-trusted user,
|
||||||
|
and anonymous or under-privileged is denied (`requireSession` bounces to `/login`; `can`/`check`
|
||||||
|
return `false`). An **expired** token instead triggers a re-mint: re-validation against the live
|
||||||
|
Kratos session, roles re-read from Keto, or a cleared cookie if that session is dead; Ory
|
||||||
|
unreachable ⇒ anonymous. None of this is a session kill — a *revoked* state exists only with the
|
||||||
|
[denylist](#instant-revoke-the-optional-denylist) on (off by default), and it resolves through
|
||||||
|
that same re-mint. **Offboarding:** with the denylist on, revoking a role downgrades the user at
|
||||||
|
once (on the instance that handled it) and deactivating or deleting the identity ends the
|
||||||
|
session; with it off, both land within one token TTL.
|
||||||
|
|
||||||
|
**Not guaranteed** — accepted, and stated where each mechanism is: role changes
|
||||||
|
[lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the
|
||||||
|
denylist is [single-instance and skips group changes](#instant-revoke-the-optional-denylist).
|
||||||
|
Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing
|
||||||
|
**every** committed dev secret — see
|
||||||
|
[what you must supply](#what-you-must-supply-the-only-manual-prep). `REQUIRE_SECURE_SECRETS`
|
||||||
|
guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres' or the demo admin's
|
||||||
|
throwaways.
|
||||||
|
|
||||||
## Email
|
## Email
|
||||||
|
|
||||||
The only emails are the **recovery** and **verification** codes from Kratos' self-service
|
The only emails are the **recovery** and **verification** codes from Kratos' self-service
|
||||||
@@ -1118,8 +1204,9 @@ docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v
|
|||||||
|
|
||||||
**Full browser flow** (`full-flow.spec.ts`) — the real Playwright UI against the live stack:
|
**Full browser flow** (`full-flow.spec.ts`) — the real Playwright UI against the live stack:
|
||||||
the themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider,
|
the themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider,
|
||||||
`e2e-tests/mock-oidc.ts`), **menu filtering by role**, the **users/groups/roles** admin CRUD, a
|
`e2e-tests/mock-oidc.ts`), **menu filtering by role**, the **users/groups/roles** admin CRUD, the
|
||||||
permission-gated **plugin page**, and **logout**. Because the themed form posts straight to
|
**OAuth2-clients** admin screen (register → one-time secret → delete; Hydra is part of this stack
|
||||||
|
for it), a permission-gated **plugin page**, and **logout**. Because the themed form posts straight to
|
||||||
Kratos and cookies are host-scoped, a tiny same-origin gateway (`e2e-tests/proxy.ts`) fronts web +
|
Kratos and cookies are host-scoped, a tiny same-origin gateway (`e2e-tests/proxy.ts`) fronts web +
|
||||||
Kratos on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production
|
Kratos on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production
|
||||||
reverse proxy would.
|
reverse proxy would.
|
||||||
@@ -1173,10 +1260,11 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
|||||||
|
|
||||||
| Workflow | Trigger | Does |
|
| Workflow | Trigger | Does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`), then build + push the app image |
|
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
|
||||||
| `release.yml` | push of a `vX.Y.Z` tag | re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`; sync those tags to Docker Hub |
|
| `release.yml` | push of a `vX.Y.Z` tag | re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`; sync those tags to Docker Hub |
|
||||||
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags to the [GitHub mirror](https://github.com/larvit/plainpages) |
|
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags to the [GitHub mirror](https://github.com/larvit/plainpages) |
|
||||||
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
|
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
|
||||||
|
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut one release tag for the run |
|
||||||
|
|
||||||
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
||||||
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
||||||
@@ -1202,12 +1290,9 @@ this step runs
|
|||||||
inside the required gate, a missing/expired token (or registry outage) fails every branch's
|
inside the required gate, a missing/expired token (or registry outage) fails every branch's
|
||||||
gate and blocks **all** merges until restored — set the secrets before this lands, and use a
|
gate and blocks **all** merges until restored — set the secrets before this lands, and use a
|
||||||
non-expiring token or track its expiry. Retention: hash tags accumulate one image per gated
|
non-expiring token or track its expiry. Retention: hash tags accumulate one image per gated
|
||||||
push, so the nightly `registry-cleanup.yml` prunes them precisely
|
push, so the nightly `registry-cleanup.yml` prunes them
|
||||||
([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts), run in a `node:24` container):
|
([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts) defines what survives).
|
||||||
a hash tag survives only while its commit is a **branch head** or carries a **`vX.Y.Z`
|
It reuses `DOCKER_REGISTRY_USER`/`DOCKER_REGISTRY_TOKEN` — no extra setup. Don't
|
||||||
release tag**; deleted alongside are the untagged `sha256:…` child manifests (arch image +
|
|
||||||
provenance) that no surviving tag references. Named tags (`1.2.3`, `latest`, …) are never
|
|
||||||
touched. It reuses `DOCKER_REGISTRY_USER`/`DOCKER_REGISTRY_TOKEN` — no extra setup. Don't
|
|
||||||
add a pattern-based org cleanup rule for this package (and remove it if one exists): its
|
add a pattern-based org cleanup rule for this package (and remove it if one exists): its
|
||||||
age/count heuristics can't see branch heads or release tags and would delete images the
|
age/count heuristics can't see branch heads or release tags and would delete images the
|
||||||
workflow protects.
|
workflow protects.
|
||||||
@@ -1224,7 +1309,10 @@ repository on Docker Hub, generate a read/write access token **scoped to that re
|
|||||||
account-wide PAT can push to every repo under the account), and store the account name as
|
account-wide PAT can push to every repo under the account), and store the account name as
|
||||||
the Actions **variable** `DOCKERHUB_USER` and the token as the Actions **secret**
|
the Actions **variable** `DOCKERHUB_USER` and the token as the Actions **secret**
|
||||||
`DOCKERHUB_TOKEN`. Until they exist, a release run fails at the Docker Hub step — after the
|
`DOCKERHUB_TOKEN`. Until they exist, a release run fails at the Docker Hub step — after the
|
||||||
Gitea re-tag has succeeded — so set them, then re-run the workflow.
|
Gitea re-tag has succeeded — so set them, then re-run the workflow. The Docker Hub
|
||||||
|
repository **description** is maintained by hand: its source is
|
||||||
|
[`README-dockerhub.md`](README-dockerhub.md) — paste it into the repository overview on
|
||||||
|
Docker Hub when it changes.
|
||||||
|
|
||||||
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is a
|
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is a
|
||||||
read-only mirror; after every merge, `mirror.yml` force-pushes `main` and all tags there,
|
read-only mirror; after every merge, `mirror.yml` force-pushes `main` and all tags there,
|
||||||
@@ -1235,6 +1323,39 @@ as the Gitea Actions secret `MIRROR_GITHUB_TOKEN` (repo Settings → Actions →
|
|||||||
rejects secret names starting with `GITHUB_`/`GITEA_`). Trigger the workflow manually for
|
rejects secret names starting with `GITHUB_`/`GITEA_`). Trigger the workflow manually for
|
||||||
the first sync — until the secret exists, the mirror job fails loud on each merge.
|
the first sync — until the secret exists, the mirror job fails loud on each merge.
|
||||||
|
|
||||||
|
**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com)
|
||||||
|
nightly (self-hosted, this repo only) against [`renovate.json`](renovate.json), opening PRs
|
||||||
|
that bump npm deps (both `package.json`s), Docker base images (both Dockerfiles +
|
||||||
|
`compose*.yml`), Gitea action versions, and the image tags pinned inside workflow `run:`
|
||||||
|
steps (a custom regex manager, so nothing pinned drifts unmanaged). Version-locked sets move
|
||||||
|
together in one PR — the Ory images (kratos/keto/hydra) and the Playwright runner + its
|
||||||
|
browser image — and every bump keeps the existing **exact semver pin** exact, never widening
|
||||||
|
to a range or adding a digest. Each PR
|
||||||
|
runs through the normal gate on its `renovate/*` branch and, with `"automerge": true`,
|
||||||
|
Renovate merges it once `CI / full-gate (push)` is green (rebasing stale branches so the
|
||||||
|
fast-forward-only merge still holds) — routine bumps land untouched; only a red gate needs a
|
||||||
|
human. One-time setup: reuse the shared `renovate@larvit.se` bot — give it write access to
|
||||||
|
this repo and store its Gitea PAT as the Actions **secret** `RENOVATE_TOKEN`. Until it
|
||||||
|
exists, the nightly job fails loud (and, like the other secrets, a `GITEA_`/`GITHUB_`
|
||||||
|
prefix is rejected). Also store a **scopeless** (read-only) github.com PAT as the secret
|
||||||
|
`RENOVATE_GITHUB_TOKEN` — the workflow hands it to Renovate as `GITHUB_COM_TOKEN`, so
|
||||||
|
lookups of github.com-hosted deps (actions, Playwright, changelogs) run authenticated
|
||||||
|
instead of tripping the anonymous 60-requests/hour limit.
|
||||||
|
|
||||||
|
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`, `needs:
|
||||||
|
renovate`) cuts **one** `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main`
|
||||||
|
since the last tag (it targets `origin/main`, and **skips** when the tip isn't a Renovate commit —
|
||||||
|
a human owns that release — or when nothing new merged). Renovate stamps every commit with a
|
||||||
|
`Release-Bump: <updateType>` trailer (`commitBody` in `renovate.json`), and
|
||||||
|
[`auto-release/next-version.ts`](auto-release/next-version.ts) (unit-tested) turns the highest
|
||||||
|
trailer on those commits into the next version — pre-1.0 it never auto-crosses into `1.0.0`,
|
||||||
|
which stays a deliberate hand-cut tag. It's
|
||||||
|
**tag-only** (no source commits): the tag hands off to `release.yml`, which promotes the
|
||||||
|
already-built image, and is pushed with renovate-bot's PAT so `release.yml` actually fires (a tag
|
||||||
|
pushed by the built-in Actions token wouldn't trigger it). The plugin-contract version
|
||||||
|
(`HOST_API_VERSION`) is deliberately **not** touched here — it moves only when the plugin API
|
||||||
|
itself changes, by hand.
|
||||||
|
|
||||||
**One-time server setup** — register an
|
**One-time server setup** — register an
|
||||||
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode with the label
|
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode with the label
|
||||||
`docker-host` (config: `labels: ["docker-host:host"]`) on a machine with Docker Engine +
|
`docker-host` (config: `labels: ["docker-host:host"]`) on a machine with Docker Engine +
|
||||||
@@ -1362,7 +1483,7 @@ container-relative; with the dev bind-mount they edit the real file).
|
|||||||
2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`.
|
2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`.
|
||||||
(web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a
|
(web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a
|
||||||
brief Kratos blip only touches login/re-mint.)
|
brief Kratos blip only touches login/re-mint.)
|
||||||
3. **Verify** new logins mint the new `kid` — decode the `plainpages_session` cookie's JWT
|
3. **Verify** new logins mint the new `kid` — decode the `plainpages_jwt` cookie's JWT
|
||||||
header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients
|
header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients
|
||||||
present the new key.
|
present the new key.
|
||||||
4. **Wait ~12 min**, then **prune** the superseded key:
|
4. **Wait ~12 min**, then **prune** the superseded key:
|
||||||
@@ -1460,6 +1581,7 @@ e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + aut
|
|||||||
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
|
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
|
||||||
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
|
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
|
||||||
mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD
|
mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD
|
||||||
|
README-dockerhub.md The Docker Hub repository description (docker.io/larvit/plainpages) — pasted into the Docker Hub overview by hand when it changes; see CI/CD
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extending the core
|
## Extending the core
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { bumpFromUpdateType, maxLevel, nextVersion } from "./next-version.ts";
|
||||||
|
|
||||||
|
test("bumpFromUpdateType: only major/minor keep their level; everything else is patch", () => {
|
||||||
|
assert.equal(bumpFromUpdateType("major"), "major");
|
||||||
|
assert.equal(bumpFromUpdateType("minor"), "minor");
|
||||||
|
assert.equal(bumpFromUpdateType("patch"), "patch");
|
||||||
|
assert.equal(bumpFromUpdateType("digest"), "patch");
|
||||||
|
assert.equal(bumpFromUpdateType("pin"), "patch");
|
||||||
|
assert.equal(bumpFromUpdateType("lockFileMaintenance"), "patch");
|
||||||
|
assert.equal(bumpFromUpdateType(""), "patch");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maxLevel: defaults to patch, escalates on the highest level present", () => {
|
||||||
|
assert.equal(maxLevel([]), "patch");
|
||||||
|
assert.equal(maxLevel(["patch"]), "patch");
|
||||||
|
assert.equal(maxLevel(["patch", "minor"]), "minor");
|
||||||
|
assert.equal(maxLevel(["minor", "major", "patch"]), "major");
|
||||||
|
assert.equal(maxLevel(["digest", "pin"]), "patch");
|
||||||
|
assert.equal(maxLevel(["", "bogus"]), "patch"); // unknown → patch, never throws
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nextVersion pre-1.0 (major===0): shift down so we never auto-cross into 1.0.0", () => {
|
||||||
|
// dep major → 0.x minor (the 0.x "breaking" slot); dep minor/patch → 0.x patch
|
||||||
|
assert.equal(nextVersion("v0.0.2", "major"), "v0.1.0");
|
||||||
|
assert.equal(nextVersion("v0.0.2", "minor"), "v0.0.3");
|
||||||
|
assert.equal(nextVersion("v0.0.2", "patch"), "v0.0.3");
|
||||||
|
assert.equal(nextVersion("v0.3.4", "major"), "v0.4.0");
|
||||||
|
assert.equal(nextVersion("v0.3.4", "minor"), "v0.3.5");
|
||||||
|
assert.equal(nextVersion("v0.3.4", "patch"), "v0.3.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nextVersion at/after 1.0.0: literal semver", () => {
|
||||||
|
assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0");
|
||||||
|
assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0");
|
||||||
|
assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nextVersion rejects a tag that is not vX.Y.Z", () => {
|
||||||
|
assert.throws(() => nextVersion("1.2.3", "patch"), /vX\.Y\.Z/);
|
||||||
|
assert.throws(() => nextVersion("vx.y.z", "patch"), /vX\.Y\.Z/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job,
|
||||||
|
// README → CI/CD). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
|
||||||
|
// workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit
|
||||||
|
// tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell.
|
||||||
|
|
||||||
|
export type Bump = "major" | "minor" | "patch";
|
||||||
|
|
||||||
|
// A dependency change is always at least a patch; only a real major/minor escalates.
|
||||||
|
export function bumpFromUpdateType(updateType: string): Bump {
|
||||||
|
if (updateType === "major") return "major";
|
||||||
|
if (updateType === "minor") return "minor";
|
||||||
|
return "patch";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maxLevel(updateTypes: string[]): Bump {
|
||||||
|
let level: Bump = "patch";
|
||||||
|
for (const updateType of updateTypes) {
|
||||||
|
const bump = bumpFromUpdateType(updateType);
|
||||||
|
if (bump === "major") return "major";
|
||||||
|
if (bump === "minor") level = "minor";
|
||||||
|
}
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-1.0 (major===0) shifts every level down one notch, so a dependency major only bumps the 0.x
|
||||||
|
// minor and we never auto-cross into 1.0.0 — that stays a deliberate human milestone.
|
||||||
|
export function nextVersion(latestTag: string, level: Bump): string {
|
||||||
|
const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(latestTag);
|
||||||
|
if (!match) throw new Error(`latest tag must be vX.Y.Z, got ${JSON.stringify(latestTag)}`);
|
||||||
|
const major = Number(match[1]);
|
||||||
|
const minor = Number(match[2]);
|
||||||
|
const patch = Number(match[3]);
|
||||||
|
const effective: Bump = major === 0 ? (level === "major" ? "minor" : "patch") : level;
|
||||||
|
if (effective === "major") return `v${major + 1}.0.0`;
|
||||||
|
if (effective === "minor") return `v${major}.${minor + 1}.0`;
|
||||||
|
return `v${major}.${minor}.${patch + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI: node auto-release/next-version.ts <latestTag> [updateType...] → prints the next tag.
|
||||||
|
if (process.argv[1]?.endsWith("/next-version.ts")) {
|
||||||
|
const [, , latestTag, ...updateTypes] = process.argv;
|
||||||
|
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
|
||||||
|
}
|
||||||
@@ -12,6 +12,26 @@ cd "$(dirname "$0")"
|
|||||||
|
|
||||||
step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
|
step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
|
||||||
|
|
||||||
|
# Docs-only fast path: nothing but *.md changed since main, so there is nothing here to break.
|
||||||
|
# The working tree counts too — a dirty tree carrying real code must never skip. Anything
|
||||||
|
# undeterminable (no git, no reachable main, no merge-base) falls through to the gate, never a skip.
|
||||||
|
docs_only() {
|
||||||
|
local base changed
|
||||||
|
git rev-parse --git-dir >/dev/null 2>&1 || return 1
|
||||||
|
git fetch --no-tags --quiet origin +refs/heads/main:refs/remotes/origin/main 2>/dev/null || true
|
||||||
|
base=$(git merge-base refs/remotes/origin/main HEAD 2>/dev/null) || return 1
|
||||||
|
changed=$(
|
||||||
|
{ git diff --name-only "$base" HEAD && git status --porcelain --untracked-files=all | cut -c4-; } 2>/dev/null
|
||||||
|
) || return 1
|
||||||
|
[ -n "$changed" ] || return 1
|
||||||
|
! printf '%s\n' "$changed" | grep -qvE '\.md$'
|
||||||
|
}
|
||||||
|
|
||||||
|
if docs_only; then
|
||||||
|
step "Only *.md changed since main — nothing to test, skipping the gate"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Pins that MUST move in lockstep: a browser/runner mismatch yields confusing E2E failures.
|
# Pins that MUST move in lockstep: a browser/runner mismatch yields confusing E2E failures.
|
||||||
step "Playwright pin lockstep (e2e-tests/Dockerfile image == e2e-tests/package.json @playwright/test)"
|
step "Playwright pin lockstep (e2e-tests/Dockerfile image == e2e-tests/package.json @playwright/test)"
|
||||||
# `|| true` so a no-match doesn't trip `set -e`/`pipefail` before the explicit check below can report.
|
# `|| true` so a no-match doesn't trip `set -e`/`pipefail` before the explicit check below can report.
|
||||||
@@ -20,6 +40,12 @@ pkg=$(grep -oE '"@playwright/test": "[0-9.]+"' e2e-tests/package.json | grep -oE
|
|||||||
[ -n "$img" ] && [ "$img" = "$pkg" ] || { echo "Playwright pin mismatch/unreadable: image v$img vs @playwright/test $pkg"; exit 1; }
|
[ -n "$img" ] && [ "$img" = "$pkg" ] || { echo "Playwright pin mismatch/unreadable: image v$img vs @playwright/test $pkg"; exit 1; }
|
||||||
echo "ok ($img)"
|
echo "ok ($img)"
|
||||||
|
|
||||||
|
# Explicit rebuild: without it a stale web image from a previous branch supplies node_modules
|
||||||
|
# (the source is bind-mounted but deps are baked in), so a dep bump gets typechecked/tested
|
||||||
|
# against the OLD packages. Cheap when deps are unchanged (npm ci layer is cache-keyed).
|
||||||
|
step "Build web image"
|
||||||
|
docker compose build web
|
||||||
|
|
||||||
step "Typecheck"
|
step "Typecheck"
|
||||||
docker compose run --rm --no-deps web npm run typecheck
|
docker compose run --rm --no-deps web npm run typecheck
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ services:
|
|||||||
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||||
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
|
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.16.0-alpine3.24
|
image: node:24.18.1-alpine3.24
|
||||||
command: node /srv/server.ts
|
command: node /srv/server.ts
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
@@ -36,7 +36,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.30.1
|
image: axllent/mailpit:v1.30.6
|
||||||
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.49.1-noble
|
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
||||||
|
|
||||||
WORKDIR /e2e-tests
|
WORKDIR /e2e-tests
|
||||||
|
|
||||||
|
|||||||
+15
-16
@@ -1,21 +1,16 @@
|
|||||||
# Full browser E2E — the real Playwright UI flow against the live stack: password +
|
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
||||||
# mocked-SSO login, menu filtering by role, users/groups/roles CRUD, a plugin page, logout. A tiny
|
# login, menu filtering by role, users/groups/roles/OAuth2-clients CRUD, a plugin page, logout. A
|
||||||
# same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
||||||
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
|
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
|
||||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
|
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
|
||||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
|
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
# First-party + SSO flows need Kratos + Keto + bootstrap, not Hydra — drop it so the stack is
|
# The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients
|
||||||
# leaner. SSO is enabled here only (clean clone stays password-only): the mock provider's whole
|
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
|
||||||
# array is the env-settable form Kratos offers, mapped through the committed claims jsonnet.
|
# (clean clone stays password-only): the mock provider's whole array is the env-settable form
|
||||||
depends_on: !override
|
# Kratos offers, mapped through the committed claims jsonnet.
|
||||||
bootstrap:
|
depends_on:
|
||||||
condition: service_completed_successfully
|
|
||||||
kratos:
|
|
||||||
condition: service_healthy
|
|
||||||
keto:
|
|
||||||
condition: service_healthy
|
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
@@ -52,9 +47,13 @@ services:
|
|||||||
SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS: >-
|
SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS: >-
|
||||||
[{"id":"mock","provider":"generic","label":"Mock SSO","client_id":"plainpages-e2e","client_secret":"e2e-secret","issuer_url":"http://mock-oidc:9000","scope":["openid","email"],"mapper_url":"file:///etc/config/kratos/oidc/claims.jsonnet"}]
|
[{"id":"mock","provider":"generic","label":"Mock SSO","client_id":"plainpages-e2e","client_secret":"e2e-secret","issuer_url":"http://mock-oidc:9000","scope":["openid","email"],"mapper_url":"file:///etc/config/kratos/oidc/claims.jsonnet"}]
|
||||||
|
|
||||||
|
# --dev permits the http issuer (the base file drops it for an https prod issuer).
|
||||||
|
hydra:
|
||||||
|
command: serve all --dev -c /etc/config/hydra/hydra.yml
|
||||||
|
|
||||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.16.0-alpine3.24
|
image: node:24.18.1-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
|
||||||
@@ -67,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.16.0-alpine3.24
|
image: node:24.18.1-alpine3.24
|
||||||
command: ["node", "/mock-oidc.ts"]
|
command: ["node", "/mock-oidc.ts"]
|
||||||
environment:
|
environment:
|
||||||
ISSUER: http://mock-oidc:9000
|
ISSUER: http://mock-oidc:9000
|
||||||
@@ -82,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.16.0-alpine3.24
|
image: node:24.18.1-alpine3.24
|
||||||
command: ["node", "/proxy.ts"]
|
command: ["node", "/proxy.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
|
|||||||
@@ -85,6 +85,35 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page.locator("main")).toContainText(role);
|
await expect(page.locator("main")).toContainText(role);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
||||||
|
const name = `e2e-client-${suffix}`;
|
||||||
|
await page.goto("/admin/clients");
|
||||||
|
await page.getByRole("link", { name: "Register client" }).click();
|
||||||
|
await page.fill('input[name="name"]', name);
|
||||||
|
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
|
||||||
|
await page.locator('.form-card button[type="submit"]').click();
|
||||||
|
|
||||||
|
// Hydra returns the secret exactly once, so the POST renders the detail directly (no PRG).
|
||||||
|
await expect(page.locator("h1")).toHaveText("Client registered");
|
||||||
|
const clientId = await page.locator("#cid").inputValue();
|
||||||
|
expect(clientId).toBeTruthy();
|
||||||
|
await expect(page.locator("#csecret")).toHaveValue(/.+/);
|
||||||
|
|
||||||
|
// Listed; the row header links to the plain detail, which never shows the secret again.
|
||||||
|
await page.goto("/admin/clients");
|
||||||
|
const row = page.locator("tr", { hasText: name });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.getByRole("link", { name }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/admin/clients/${clientId}`));
|
||||||
|
await expect(page.locator("#csecret")).toHaveCount(0);
|
||||||
|
|
||||||
|
// Delete through the confirm interstitial (danger link on the detail → confirm form's button).
|
||||||
|
await page.getByRole("link", { name: "Delete client" }).click();
|
||||||
|
await page.getByRole("button", { name: "Delete client" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/admin\/clients(\?|$)/);
|
||||||
|
await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => {
|
test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => {
|
||||||
await page.goto("/scheduling/shifts");
|
await page.goto("/scheduling/shifts");
|
||||||
await expect(page.locator("h1")).toHaveText("Shifts");
|
await expect(page.locator("h1")).toHaveText("Shifts");
|
||||||
|
|||||||
Generated
+15
-15
@@ -8,23 +8,23 @@
|
|||||||
"name": "plainpages-e2e",
|
"name": "plainpages-e2e",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.49.1"
|
"@playwright/test": "1.62.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.49.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.49.1"
|
"playwright": "1.62.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
@@ -43,35 +43,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.49.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.49.1"
|
"playwright-core": "1.62.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"fsevents": "2.3.2"
|
"fsevents": "2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.49.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
"test": "playwright test"
|
"test": "playwright test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.49.1"
|
"@playwright/test": "1.62.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"errors":null,"message":"not found","url":"https://gitea.larvit.se/api/swagger"}
|
||||||
Generated
+377
-84
@@ -9,13 +9,13 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "3.1.10",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.18.0"
|
"lucide-static": "1.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.13.2",
|
"@types/node": "24.13.3",
|
||||||
"typescript": "5.9.3"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
@@ -38,113 +38,406 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.2",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/async": {
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
"version": "3.2.6",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
"license": "MIT"
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||||
"version": "1.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
"license": "MIT"
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/@typescript/typescript-darwin-x64": {
|
||||||
"version": "2.1.1",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
"license": "MIT",
|
"cpu": [
|
||||||
"dependencies": {
|
"x64"
|
||||||
"balanced-match": "^1.0.0"
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-loong64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-s390x": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-sunos-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ejs": {
|
"node_modules/ejs": {
|
||||||
"version": "3.1.10",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
||||||
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
|
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
|
||||||
"jake": "^10.8.5"
|
|
||||||
},
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"ejs": "bin/cli.js"
|
"ejs": "bin/cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.12.18"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/filelist": {
|
|
||||||
"version": "1.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
|
||||||
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"minimatch": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jake": {
|
|
||||||
"version": "10.9.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
|
|
||||||
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"async": "^3.2.6",
|
|
||||||
"filelist": "^1.0.4",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"jake": "bin/cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-static": {
|
"node_modules/lucide-static": {
|
||||||
"version": "1.18.0",
|
"version": "1.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.28.0.tgz",
|
||||||
"integrity": "sha512-0WRXLQnjbte5SXuzom6yfeGlVSFsEsC9rzxn66DZN0pXows3+N34CQHy3BHI1qA3uH7u/SUzx8LQhjeAnxd8JQ==",
|
"integrity": "sha512-dC3VJwRFsjEVX7Iaq4rY88pm7Fi2OmOb8P0WRzXsUMgbt7sCmFX8bLhaDBeNW6JdRjuele+jKqqFaam4yr+Ygg==",
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/minimatch": {
|
|
||||||
"version": "5.1.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
|
||||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc"
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.17"
|
"node": ">=16.20.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-x64": "7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
|
|||||||
+5
-5
@@ -15,16 +15,16 @@
|
|||||||
"dev": "node --watch src/server.ts",
|
"dev": "node --watch src/server.ts",
|
||||||
"gen-jwks": "node src/auth/gen-jwks.ts",
|
"gen-jwks": "node src/auth/gen-jwks.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\""
|
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.test.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "3.1.10",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.18.0"
|
"lucide-static": "1.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.13.2",
|
"@types/node": "24.13.3",
|
||||||
"typescript": "5.9.3"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended"],
|
||||||
|
"automerge": true,
|
||||||
|
"commitBody": "Release-Bump: {{{updateType}}}",
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Ory services share one release train - update kratos, keto and hydra together",
|
||||||
|
"matchDatasources": ["docker"],
|
||||||
|
"matchPackageNames": ["oryd/kratos", "oryd/keto", "oryd/hydra"],
|
||||||
|
"groupName": "Ory stack"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Playwright runner and its browser image are version-locked - bump together",
|
||||||
|
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
|
||||||
|
"groupName": "Playwright"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"customManagers": [
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"description": "Pin the Renovate image the Renovate workflow runs",
|
||||||
|
"managerFilePatterns": [".gitea/workflows/renovate.yml"],
|
||||||
|
"matchStrings": ["renovate/renovate:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||||
|
"depNameTemplate": "renovate/renovate",
|
||||||
|
"datasourceTemplate": "docker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)",
|
||||||
|
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"],
|
||||||
|
"matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||||
|
"depNameTemplate": "node",
|
||||||
|
"datasourceTemplate": "docker"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -29,8 +29,10 @@ test("verifyToken: a valid token → User, selecting the verify key by kid acros
|
|||||||
assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] });
|
assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("verifyToken rejects expiry and future nbf, with clock-skew leeway", async () => {
|
test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
|
||||||
const opts = { clockSkewSec: 60, now: NOW };
|
const opts = { clockSkewSec: 60, now: NOW };
|
||||||
|
// No exp ⇒ rejected outright: an exp-less token must never read as eternal.
|
||||||
|
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: undefined }), jwks, opts), /missing exp/);
|
||||||
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 120 }), jwks, opts), /expired/);
|
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 120 }), jwks, opts), /expired/);
|
||||||
// exp 30s in the past but inside the 60s skew → still accepted.
|
// exp 30s in the past but inside the 60s skew → still accepted.
|
||||||
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
|
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Guards the docs-only fast path: `ci.sh` no-ops when nothing but *.md changed since main. The
|
||||||
|
// decision lives in ci.sh alone so `bash ci.sh` reproduces CI locally, and the workflow must still
|
||||||
|
// push the commit-hash image when it no-ops — release.yml re-tags that exact image, and
|
||||||
|
// fast-forward-only merges make every branch head a main commit.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
|
||||||
|
const workflow = read(".gitea/workflows/ci.yml");
|
||||||
|
const gate = read("ci.sh");
|
||||||
|
const step = (needle: string) => {
|
||||||
|
const found = workflow.split("\n - ").slice(1).filter((s) => s.includes(needle));
|
||||||
|
assert.equal(found.length, 1, `exactly one workflow step contains ${needle}`);
|
||||||
|
return found[0]!;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("the skip decision lives in ci.sh, so the workflow only runs it", () => {
|
||||||
|
assert.match(gate, /docs_only\(\)/);
|
||||||
|
assert.doesNotMatch(workflow, /docs_only|merge-base|GITHUB_OUTPUT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkout is unshallow — the docs-only check needs the branch's history", () => {
|
||||||
|
assert.match(step("actions/checkout"), /fetch-depth: 0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the commit-hash image is pushed even when the gate no-ops", () => {
|
||||||
|
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only *.md counts as docs, and a dirty working tree counts as changed", () => {
|
||||||
|
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
|
||||||
|
assert.match(gate, /git status --porcelain/, "uncommitted code can never be skipped over");
|
||||||
|
});
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
|
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
|
||||||
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// plugin may deliberately shadow a core partial). The router calls this for a `view` RouteResult.
|
// plugin may deliberately shadow a core partial). The router calls this for a `view` RouteResult.
|
||||||
|
|
||||||
import { isAbsolute, join, relative } from "node:path";
|
import { isAbsolute, join, relative } from "node:path";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const CONTROL_CHARS = /[\x00-\x1f]/;
|
const CONTROL_CHARS = /[\x00-\x1f]/;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const authCard = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "auth-card.ejs");
|
const authCard = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "auth-card.ejs");
|
||||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, data);
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, data);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const dataTable = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "data-table.ejs");
|
const dataTable = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "data-table.ejs");
|
||||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(dataTable, data);
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(dataTable, data);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const field = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "field.ejs");
|
const field = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "field.ejs");
|
||||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, data);
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, data);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const filterBar = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "filter-bar.ejs");
|
const filterBar = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "filter-bar.ejs");
|
||||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(filterBar, data);
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(filterBar, data);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
import { ICON_NAMES, buildIconSprite } from "./icons.ts";
|
import { ICON_NAMES, buildIconSprite } from "./icons.ts";
|
||||||
|
|
||||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const menu = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "menu.ejs");
|
const menu = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "menu.ejs");
|
||||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, data);
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, data);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const navTree = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "nav-tree.ejs");
|
const navTree = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "nav-tree.ejs");
|
||||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, data);
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, data);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const pagination = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "pagination.ejs");
|
const pagination = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "pagination.ejs");
|
||||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(pagination, data);
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(pagination, data);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "shell.ejs");
|
const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "shell.ejs");
|
||||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, data);
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, data);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const themeSwitch = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "theme-switch.ejs");
|
const themeSwitch = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "theme-switch.ejs");
|
||||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(themeSwitch, data);
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(themeSwitch, data);
|
||||||
|
|||||||
@@ -9,10 +9,15 @@
|
|||||||
- [x] CI/CD - Build docker images as part of the requirements to be able to merge to main. Push them with the git commit hash as docker tag. Push to container registry at Gitea. (`ci.yml` builds + pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` after a green gate — with ff-only merges that is the main commit's image; auth via the `DOCKER_REGISTRY_USER` variable + `DOCKER_REGISTRY_TOKEN` secret, retention via an org cleanup rule; documented in README → CI/CD.)
|
- [x] CI/CD - Build docker images as part of the requirements to be able to merge to main. Push them with the git commit hash as docker tag. Push to container registry at Gitea. (`ci.yml` builds + pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` after a green gate — with ff-only merges that is the main commit's image; auth via the `DOCKER_REGISTRY_USER` variable + `DOCKER_REGISTRY_TOKEN` secret, retention via an org cleanup rule; documented in README → CI/CD.)
|
||||||
- [x] CI/CD - Re-tag docker images from git hash to semver when a semver git tag is pushed. (`release.yml` on a `vX.Y.Z` tag pulls the commit-hash image and re-tags it `X.Y.Z`/`X.Y`/`X`/`latest`, failing loud if the gated image is missing; tag pushes also trigger the GitHub mirror; documented in README → CI/CD.)
|
- [x] CI/CD - Re-tag docker images from git hash to semver when a semver git tag is pushed. (`release.yml` on a `vX.Y.Z` tag pulls the commit-hash image and re-tags it `X.Y.Z`/`X.Y`/`X`/`latest`, failing loud if the gated image is missing; tag pushes also trigger the GitHub mirror; documented in README → CI/CD.)
|
||||||
- [x] CI/CD - Sync docker images to docker hub after each re-tag to git tags. (`release.yml` pushes the same `X.Y.Z`/`X.Y`/`X`/`latest` tags to `docker.io/larvit/plainpages` after the Gitea re-tag — releases only, no hash tags; auth via the `DOCKERHUB_USER` variable + `DOCKERHUB_TOKEN` secret; documented in README → CI/CD.)
|
- [x] CI/CD - Sync docker images to docker hub after each re-tag to git tags. (`release.yml` pushes the same `X.Y.Z`/`X.Y`/`X`/`latest` tags to `docker.io/larvit/plainpages` after the Gitea re-tag — releases only, no hash tags; auth via the `DOCKERHUB_USER` variable + `DOCKERHUB_TOKEN` secret; documented in README → CI/CD.)
|
||||||
- [ ] CI/CD - Setup renovate bot.
|
- [x] Write a short text on how to use this docker image to publish on docker hub and save it to README-dockerhub.md (tagline, tags, clone-free quick start — the image ships the Ory config, extracted via `docker run … tar` + a self-contained compose.yml — env table, first plugin; pasted into the Docker Hub overview by hand — noted in README → CI/CD.)
|
||||||
- [ ] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser.
|
- [x] CI/CD - Setup renovate bot. Check how other repos on this Gitea is setup you can get access to, there should be a number of renovate bot activated ones. (`renovate.yml` runs the self-hosted `renovate/renovate` image nightly against `renovate.json` — this repo only, via the shared `renovate@larvit.se` bot + `RENOVATE_TOKEN` secret, mirroring the `pwrpln/core` pattern; standard managers cover npm/Dockerfiles/compose/gitea-action pins, two custom regex managers cover the image tags embedded in workflow `run:` steps, the Ory + Playwright lockstep sets are grouped, every bump stays an exact pin, and each PR automerges once the gate is green; documented in README → CI/CD.)
|
||||||
- [ ] Build and publish docker image as CI/CD.
|
- [x] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret. (The renovate job forwards the `RENOVATE_GITHUB_TOKEN` secret — a scopeless read-only github.com PAT; Gitea rejects `GITHUB_`-prefixed secret names — into the container as `GITHUB_COM_TOKEN`; documented in README → CI/CD.)
|
||||||
|
- [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.)
|
||||||
|
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.)
|
||||||
|
- [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.)
|
||||||
|
- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by a test — the mandatory-`exp` guard was the one gap, now asserted in `src/auth/jwt-middleware.test.ts`; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply".)
|
||||||
- [ ] Add i18n support.
|
- [ ] Add i18n support.
|
||||||
|
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer.
|
||||||
|
|
||||||
## Architectural review findings (2026-07-02)
|
## Architectural review findings (2026-07-02)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -24,5 +24,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["config", "examples/config", "examples/plugins", "plugins", "registry-cleanup", "src"]
|
"include": ["auto-release", "config", "examples/config", "examples/plugins", "plugins", "registry-cleanup", "src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<symbol id="i-arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></symbol>
|
<symbol id="i-arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></symbol>
|
||||||
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
|
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
|
||||||
<symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol>
|
<symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol>
|
||||||
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v4" /><path d="M16 2v4" /><rect width="18" height="18" x="3" y="4" rx="2" /><path d="M3 10h18" /></symbol>
|
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
|
||||||
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M5 21v-6" /><path d="M12 21V3" /><path d="M19 21V9" /></symbol>
|
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M5 21v-6" /><path d="M12 21V3" /><path d="M19 21V9" /></symbol>
|
||||||
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></symbol>
|
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></symbol>
|
||||||
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
|
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
|
||||||
|
|||||||
Reference in New Issue
Block a user