diff --git a/.env.example b/.env.example index 361a595..c3f336b 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,6 @@ # Required when NODE_ENV=production # DATABASE_URL=postgresql://ldacapi:ldacapi@localhost:5432/ldacapi # OPENSEARCH_URL=http://localhost:9200 -# TOKEN_ADMIN=1234-1234-1234-1234 # OCFL_ROOT=storage/ocfl # Working directory for OCFL writes. Defaults to `scratch` beside OCFL_ROOT @@ -41,3 +40,10 @@ # Required in production when sign-in is on; otherwise throwaway keys are generated at each start. Generate with `pnpm generate-keys` # OIDC_SIGNING_KEYS='{"keys":[...]}' # OIDC_COOKIE_KEYS= + +# The origin (no path) of the admin pages, at /admin/. Must differ from OIDC_ISSUER. Defaults to http://admin.localhost:8080; +# in production, https, and the admin pages are off while it is unset. The reverse proxy must route it to the API with its Host header unchanged +# ADMIN_ORIGIN=http://admin.localhost:8080 + +# The `sub`s of admins, who can use the admin pages, comma-separated. Unset means nobody +# ADMIN_USERS= diff --git a/.gitignore b/.gitignore index ea5a8ae..9f6a659 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ node_modules/ # Generated files /src/generated/prisma /storage/ +# Built from src/admin/admin.css by `pnpm build` (Tailwind); served at /admin/admin.css +/src/admin/public/admin.css # AI .claude/worktrees diff --git a/GLOSSARY.md b/GLOSSARY.md index 1540249..2bde55d 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -13,3 +13,13 @@ _Avoid_: IdP, CILogon (one instance of it), login provider **User**: A person who has signed in at the upstream provider, identified by that provider's subject identifier. REMS knows the same person by the same identifier. _Avoid_: Account, member + +### Admin + +**Admin**: +A user who can use the admin pages, currently one whose `sub` is in `ADMIN_USERS`. +_Avoid_: Administrator, superuser, staff + +**Admin pages**: +The server-rendered admin UI at `/admin/` on the admin origin (`ADMIN_ORIGIN`). +_Avoid_: Portal, admin API diff --git a/README.md b/README.md index af51d81..8b4ff4c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ app mounts as a Fastify plugin. ldacapi adds what LDaCA needs on top: index, used by arocapi's entity and file routes) and, as one search document per entity, into arocapi's OpenSearch search backend (`src/indexer/`). The search fields are declared in `src/indexer/search.ts`. -- **Admin API**: list, rebuild and delete indexes (`src/routes/admin.ts`). +- **Admin pages**: server-rendered pages at `/admin/` on their own origin, + where admins, who sign in through our own OIDC provider, see and rebuild or + delete indexes (`src/admin/`). - **Access control**: licences are checked against REMS, with sign-in through an OIDC provider (`src/auth.ts`, `src/routes/auth.ts`). - **File downloads**: short-lived signed URLs that stream files out of OCFL @@ -48,7 +50,8 @@ No configuration is needed for development. To override a default, copy | Script | Does | | -------------------- | -------------------------------------------------------------------------------- | -| `pnpm dev` | Run the API, restarting on changes | +| `pnpm dev` | Run the API, restarting on changes, and rebuild the admin pages' stylesheet | +| `pnpm build` | Build the admin pages' stylesheet (run before deploying) | | `pnpm test` | Run the tests (needs `docker compose up`) | | `pnpm lint` | Biome, Knip and TypeScript | | `pnpm bootstrap` | `db:deploy`, `seed` and `index` in one go | @@ -70,28 +73,6 @@ The indexer skips a crate that has no metadata licence (the `license` of `ro-crate-metadata.json`) or no content licence (the `license` of the root dataset) unless `DEFAULT_METADATA_LICENSE` or `DEFAULT_LICENSE` is set. -## Admin API - -The admin routes need the admin token (`TOKEN_ADMIN`, `1234-1234-1234-1234` in -development) as a bearer token. - -```sh -TOKEN=1234-1234-1234-1234 - -# Index state of every crate, or of one crate -curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/admin/index/ -curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/admin/index/ - -# Re-index everything, or one crate (optionally one index: structural or search) -curl -X POST -H "Authorization: Bearer $TOKEN" 'http://localhost:8080/api/admin/index/?force' -curl -X POST -H "Authorization: Bearer $TOKEN" 'http://localhost:8080/api/admin/index//?force' - -# Delete the indexes of one crate -curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/admin/index/ -``` - -URL-encode crate ids, which are usually URIs. - ## Configuration Configuration comes only from environment variables, validated at startup @@ -104,7 +85,6 @@ set. | `NODE_ENV` | `development` | | `DATABASE_URL` | `postgresql://ldacapi:ldacapi@localhost:5432/ldacapi` | | `OPENSEARCH_URL` | `http://localhost:9200` | -| `TOKEN_ADMIN` | `1234-1234-1234-1234` | | `OCFL_ROOT` | `storage/ocfl` | | `OCFL_SCRATCH` | `scratch` beside `OCFL_ROOT` | | `LDACAPI_PORT` | `8080` | @@ -119,9 +99,11 @@ set. | `OIDC_ISSUER` | `http://localhost:8080` | | `OIDC_CLIENTS` | `[]` | | `OIDC_SIGNING_KEYS`, `OIDC_COOKIE_KEYS` | generated at each start outside production | +| `ADMIN_ORIGIN` | `http://admin.localhost:8080` outside production | +| `ADMIN_USERS` | none (nobody is an admin) | -With `NODE_ENV=production`, `DATABASE_URL`, `OPENSEARCH_URL`, `TOKEN_ADMIN` -and `OCFL_ROOT` have no defaults and must be set. +With `NODE_ENV=production`, `DATABASE_URL`, `OPENSEARCH_URL` and `OCFL_ROOT` +have no defaults and must be set. ### Sign-in @@ -175,6 +157,52 @@ Discovery is at `/.well-known/openid-configuration`. Oni's `api.oidc.endpoint` is `OIDC_ISSUER` and its `api.oidc.clientId` is a `client_id` in `OIDC_CLIENTS`. +### Admin pages + +The admin pages are at `/admin/`. `ADMIN_ORIGIN` is an origin, +with no path, such as `https://admin.example.org`, and must differ from +`OIDC_ISSUER`: crate files are served on the API's origin with their own +content type, so HTML or SVG in a crate could run script there, and a separate +origin keeps that away from admin. It defaults to `http://admin.localhost:8080` +outside production, which browsers resolve to this machine; in production it +must be https. The admin pages are on only when sign-in is and `ADMIN_ORIGIN` +is set; otherwise startup logs a warning and `/admin/` is not found. On the +admin origin only `/admin/` is served, and on any other host `/admin/` is not +found. Visiting them signs you in through our own OIDC provider, as Oni does, +so you need no other password or key. + +The admin pages run in the API's process, so the reverse proxy must route the +admin hostname to the API too, and pass the browser's `Host` header through +unchanged: the API tells the two origins apart by `Host`, not by +`X-Forwarded-Host`. + +`ADMIN_USERS` is a comma-separated list of the `sub`s of admins. Anyone else who +signs in gets a page saying they aren't an admin, showing the `sub` to add. +Every request checks the list, so removing someone takes effect as soon as the +API restarts. An admin session lasts 8 hours, and signing out of the admin +pages also ends the session at our provider, ending on a signed-out page with a +link to sign in again. + +The crates page shows every crate's state in each index. It can index, +reindex or delete one crate's indexes, or index, reindex or delete all of them. +An action on a crate covers the crate and the crates under it: those whose id +is the crate's id followed by `/`. A crate that is already indexed needs +reindexing. Indexing is refused while any crate it covers is being deleted, +queued or indexing, deleting is refused while any crate it covers is queued or +indexing, and an action on a crate that isn't in the repository is refused. The page reloads +itself until the work finishes. Each action, accepted or refused, is logged at +`info` with the admin's `sub`. + +The admin pages are a client of our provider that needs no entry in +`OIDC_CLIENTS`; their client secret and session keys are derived from +`OIDC_COOKIE_KEYS`, so they rotate with it. + +The admin pages are styled with [Tailwind](https://tailwindcss.com/), built by +`@tailwindcss/cli` from `src/admin/admin.css`, which scans the Eta templates in +`src/admin/views/`, into `src/admin/public/admin.css`. That file is not checked in: a +deploy must run `pnpm build` to create it, and `pnpm dev` rebuilds it as you +edit the templates. + ## Database The schema is managed with Prisma migrations in `prisma/migrations`. arocapi diff --git a/biome.jsonc b/biome.jsonc index cd7fc18..c612eb6 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -20,6 +20,7 @@ } } }, + "css": { "parser": { "tailwindDirectives": true } }, "javascript": { "formatter": { "quoteStyle": "single" diff --git a/docs/adr/0001-own-oidc-provider.md b/docs/adr/0001-own-oidc-provider.md index 012d27c..631c5af 100644 --- a/docs/adr/0001-own-oidc-provider.md +++ b/docs/adr/0001-own-oidc-provider.md @@ -12,7 +12,7 @@ The API is an OIDC provider, built on `oidc-provider`, with its own issuer. It s - The upstream provider must speak OIDC, and there is never a page for choosing one. - Our `sub` is the upstream provider's `sub`, because REMS signs in at the same upstream provider and knows users by it. Changing the upstream provider gives every user a new identity, in REMS as well as here. -- Only clients listed in configuration can get tokens. Oni is a public client using PKCE. +- Only clients listed in configuration, and the admin pages' built-in confidential client, can get tokens. Oni is a public client using PKCE. - Access tokens are short-lived JWTs that the API checks itself. Revoking a grant doesn't cut off access tokens already issued from it; they keep working until they expire, at most 15 minutes later. - Discovery is at `/.well-known/openid-configuration`, and the other endpoints are under `/oauth/`, as in the spec. - Provider state lives in Postgres. The keys that sign tokens and cookies come from the environment, so rotating them is a configuration change. diff --git a/docs/adr/0002-server-rendered-admin-pages.md b/docs/adr/0002-server-rendered-admin-pages.md new file mode 100644 index 0000000..2e55d01 --- /dev/null +++ b/docs/adr/0002-server-rendered-admin-pages.md @@ -0,0 +1,28 @@ +# The admin pages are server-rendered and the only way into admin + +## Context + +Admins manage the API's indexes. A separate browser app calling an admin API with one shared key exposes the key to anyone who loads the app, records nobody's identity, and can only take access away from everyone at once. The API is already an OIDC provider that signs users in at the upstream provider (ADR 0001), and it can reach the repository and indexer directly. It also serves crate files inline with their own content type and no sandbox, so HTML or SVG in a crate runs script on the API's origin. + +## Decision + +The API serves its own admin pages at `/admin/`, rendered on the server, in the API's process but on their own origin, `ADMIN_ORIGIN`, which must differ from the issuer's. Every admin route is constrained to the admin origin's host, and nothing else is served there, so script in a crate file runs on another origin than admin, with the browser's boundary between them. They are registered only when sign-in and `ADMIN_ORIGIN` are configured. They are the only way into admin: there is no admin API and no shared admin key, and the pages call the repository directly. + +- The admin pages are a built-in confidential client of our provider, not listed in `OIDC_CLIENTS`. Its client secret is derived from `OIDC_COOKIE_KEYS` with HKDF, and it uses only the authorisation code grant, asking for `openid profile`. +- Sign-in is a server-side authorisation code flow with PKCE, state and nonce. The browser goes through the provider as usual, but the token request reaches the provider in-process, with its metadata built from configuration rather than discovery, so the API never calls its own public URL. Only the ID token is kept. +- Each sign-in's state, PKCE verifier, nonce and page to return to are held in a short-lived encrypted cookie. The session is an encrypted cookie holding the `sub`, sign-in time and ID token, with an absolute lifetime of 8 hours. Both cookies are on the admin origin, scoped to `/admin`, `HttpOnly`, `Secure` on an https admin origin, and `SameSite=Lax`, because sign-in returns through a redirect chain that started at the upstream provider, where a `Strict` cookie wouldn't be sent. Their keys are derived from every key in `OIDC_COOKIE_KEYS`, so they rotate with it. +- An admin is a user whose `sub` is in `ADMIN_USERS`, checked on every request. Others who sign in get a 403 page showing their `sub`. +- Signing out clears the session and ends the session at our provider, via its end-session endpoint with the ID token as `id_token_hint`, which returns to a signed-out page that needs no session. Returning to an admin page instead would sign the admin straight back in while their upstream session lasts. +- Every action is a POST form that redirects back to the page with a one-time flash message, so reloading doesn't repeat it. An action on a crate covers it and the crates under it, and each action, accepted or refused, is logged with the admin's `sub`. A POST without a session signs in and returns to the home page, as only a GET can be repeated. +- Every POST must carry an `Origin` matching the admin origin. Responses carry a Content-Security-Policy allowing only same-origin scripts and styles, and no framing. +- Pages are rendered with `@fastify/view` and Eta templates in `src/admin/views/`, which escape every value by default; unescaped output is only for a layout's body or an included template. + +## Consequences + +- There is no shared admin secret, and each admin acts as themselves. +- Removing an admin from `ADMIN_USERS` takes effect when the API restarts, whatever sessions they hold. +- Signing out of the admin pages also signs the user out of our provider, which Oni relies on; Oni keeps working until its tokens can't be refreshed. It doesn't sign them out at the upstream provider. +- Admin needs sign-in configured, and so a working upstream provider, even locally. +- Admin needs a second hostname, which the reverse proxy routes to the API with its `Host` header unchanged. Locally it is `admin.localhost`, which browsers resolve to the machine. +- A non-human caller has no way into admin. +- Templates aren't type-checked or linted. Each page's data is typed where the TypeScript passes it to its template, and what the templates compute is kept to looping and printing. diff --git a/knip.jsonc b/knip.jsonc index 491f38b..da2a9cf 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@6/schema-jsonc.json", - // src/ocfl.ts is loaded dynamically by initRepository() - "entry": ["src/ocfl.ts"], + // src/ocfl.ts is loaded dynamically by initRepository(); admin.js is served to browsers + "entry": ["src/ocfl.ts", "src/admin/public/admin.js"], "tags": ["-lintignore"] } diff --git a/package.json b/package.json index 5eb2c16..7f3f088 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,10 @@ "postinstall": "prisma generate", "test": "vitest run", "test:watch": "vitest", - "dev": "node --env-file-if-exists=.env --watch src/index.ts", + "build": "tailwindcss -i src/admin/admin.css -o src/admin/public/admin.css --minify", + "dev": "pnpm run --parallel \"/^dev:/\"", + "dev:api": "node --env-file-if-exists=.env --watch src/index.ts", + "dev:css": "tailwindcss -i src/admin/admin.css -o src/admin/public/admin.css --watch=always", "seed": "node --env-file-if-exists=.env scripts/seed.ts", "index": "node --env-file-if-exists=.env scripts/index.ts", "bootstrap": "pnpm db:deploy && pnpm seed && pnpm index", @@ -44,16 +47,19 @@ "license": "GPL-3.0-or-later", "packageManager": "pnpm@12.14.0", "dependencies": { - "@fastify/bearer-auth": "^10.1.4", "@fastify/cors": "^11.3.1", + "@fastify/formbody": "^9.0.1", + "@fastify/secure-session": "^8.4.1", "@fastify/sensible": "^6.0.7", + "@fastify/static": "^10.1.7", + "@fastify/view": "^12.0.1", "@ocfl/ocfl-fs": "^0.4.0", "@opensearch-project/opensearch": "^3.9.0", "@prisma/adapter-pg": "^7.10.1", "@prisma/client": "^7.10.1", "arocapi": "5.0.0-beta.7", + "eta": "^4.6.0", "fastify": "^5.12.7", - "fastify-type-provider-zod": "^7.0.0", "jose": "^6.2.13", "oidc-provider": "^9.12.2", "openid-client": "^6.8.8", @@ -67,11 +73,13 @@ "@commitlint/cli": "^21.2.4", "@commitlint/config-conventional": "^21.2.4", "@ocfl/ocfl": "^0.4.0", + "@tailwindcss/cli": "^4.3.3", "@types/node": "^24.19.2", "@types/oidc-provider": "^9.12.1", "knip": "^6.42.1", "lefthook": "^2.2.2", "pino-pretty": "^13.2.0", + "tailwindcss": "^4.3.3", "typescript": "^7.0.2", "vitest": "^5.0.5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b530447..58357b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,15 +166,24 @@ importers: .: dependencies: - '@fastify/bearer-auth': - specifier: ^10.1.4 - version: 10.1.4 '@fastify/cors': specifier: ^11.3.1 version: 11.3.1 + '@fastify/formbody': + specifier: ^9.0.1 + version: 9.0.1 + '@fastify/secure-session': + specifier: ^8.4.1 + version: 8.4.1(bare-events@2.9.2) '@fastify/sensible': specifier: ^6.0.7 version: 6.0.7 + '@fastify/static': + specifier: ^10.1.7 + version: 10.1.7 + '@fastify/view': + specifier: ^12.0.1 + version: 12.0.1 '@ocfl/ocfl-fs': specifier: ^0.4.0 version: 0.4.0 @@ -190,12 +199,12 @@ importers: arocapi: specifier: 5.0.0-beta.7 version: 5.0.0-beta.7(@fastify/swagger@9.9.1)(@ocfl/ocfl-fs@0.4.0)(@opensearch-project/opensearch@3.9.0)(@prisma/client@7.10.1(prisma@7.10.1(@types/node@24.19.2)(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@7.0.2))(typescript@7.0.2))(fastify@5.12.7)(openapi-types@12.1.3) + eta: + specifier: ^4.6.0 + version: 4.6.0 fastify: specifier: ^5.12.7 version: 5.12.7 - fastify-type-provider-zod: - specifier: ^7.0.0 - version: 7.0.0(@fastify/swagger@9.9.1)(fastify@5.12.7)(openapi-types@12.1.3)(zod@4.6.7) jose: specifier: ^6.2.13 version: 6.2.13 @@ -230,6 +239,9 @@ importers: '@ocfl/ocfl': specifier: ^0.4.0 version: 0.4.0 + '@tailwindcss/cli': + specifier: ^4.3.3 + version: 4.3.3 '@types/node': specifier: ^24.19.2 version: 24.19.2 @@ -245,6 +257,9 @@ importers: pino-pretty: specifier: ^13.2.0 version: 13.2.0 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -427,11 +442,14 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@fastify/accept-negotiator@2.1.2': + resolution: {integrity: sha512-LV7MZqTwXTEt3GxBT6BU8ACztWnv++hSkMEsP3usIHiI0tA4F5EO4THhDCOr5J/ginM9DCnLCmZ6u3JLA9x8Iw==} + '@fastify/ajv-compiler@4.0.6': resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} - '@fastify/bearer-auth@10.1.4': - resolution: {integrity: sha512-lOHC/Ek4Zlqv7qb1EAYze3wmzq8s8chrrik4vCIzFfOGysSLP5HM5vbcHfywf7JVZZp+BcPcaIRBwUAqHNStiA==} + '@fastify/cookie@11.1.3': + resolution: {integrity: sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==} '@fastify/cors@11.3.1': resolution: {integrity: sha512-gZXNzd6FLHzOIfvoK4+Az3Wko2V6Y0MIsLxpyo4AEYl7a9q1JBPwPaxLea4nkr++8FCiJiPpYYo6E02uZW0dqw==} @@ -442,6 +460,9 @@ packages: '@fastify/fast-json-stringify-compiler@5.1.0': resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + '@fastify/formbody@9.0.1': + resolution: {integrity: sha512-QaUet4NQavwnl6iPYMaf5hJC2N7kRPOmC58RhDiFThfg7Hcc8X2LdpcEfmaOEBimq5z7yZLy1e1q+PIv3o3tFQ==} + '@fastify/forwarded@3.0.2': resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} @@ -451,12 +472,31 @@ packages: '@fastify/proxy-addr@5.1.1': resolution: {integrity: sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==} + '@fastify/secure-session@8.4.1': + resolution: {integrity: sha512-V9khuSlcbpupL3ZVKvAsnPtxFWDj41946W17vmRYvL4Ov57lh7ZVyYYioB4SYNf7xdV8qa+ZRQ86Ni9sBE2oQQ==} + hasBin: true + + '@fastify/send@4.2.0': + resolution: {integrity: sha512-B5Hr+IFsPbqJJQu6FHE2MFpnHfbPlCOVQUPTdm6PxCcilYGrEQsulbO0z9blgbjndIyLJwa2NWFIIjXpPhbwUg==} + '@fastify/sensible@6.0.7': resolution: {integrity: sha512-e4+bJEcAPGdKsIljulWtEKeECuYGLkVTB1dgWh19m2u4ttETS/5EWZhw6KPXlgi6ZiSet5IqYrcMRMrRub0Hrg==} + '@fastify/static@10.1.7': + resolution: {integrity: sha512-7+lUiPRraENCiULgjhcqZyWSW6qnwIT87Is0u3c2Z3iGEEEGB45wJhYxzrSdzu12gHM4qcviEYcPEiCe4J6MCQ==} + '@fastify/swagger@9.9.1': resolution: {integrity: sha512-RlpdOjU6NC29799y5v0beSlmEu8spEbx7cYNzVaCCihEM0xh9hIdxSQDzom2U9W9ar0KuMA6JnXuFCrcuz9B5A==} + '@fastify/view@12.0.1': + resolution: {integrity: sha512-f5iQVXSUVtWIJ+DfGzQ+hoD6390X2QRibmOfs4LxToxupmAMz4bnHnEQoE4VMqL+yqRlsKY17gOW0CEL5W0HKg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -720,6 +760,94 @@ packages: cpu: [x64] os: [win32] + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -970,6 +1098,99 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/cli@4.3.3': + resolution: {integrity: sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==} + hasBin: true + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + '@tybys/wasm-util@0.10.4': resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} @@ -1316,9 +1537,89 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + b4a@1.9.0: + resolution: {integrity: sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bare-addon-resolve@1.11.0: + resolution: {integrity: sha512-bTUBSqBjzQQzo2/iIk6KxAClZfahy38EPsJV2bPJ+p2+kS8h+hT5TE5QggpkFUrCnLL4HDTA4DDM+YiNdyol0w==} + peerDependencies: + bare-url: '*' + peerDependenciesMeta: + bare-url: + optional: true + + bare-ansi-escapes@2.2.3: + resolution: {integrity: sha512-02ES4/E2RbrtZSnHJ9LntBhYkLA6lPpSEeP8iqS3MccBIVhVBlEmruF1I7HZqx5Q8aiTeYfQVeqmrU9YO2yYoQ==} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-assert@1.3.0: + resolution: {integrity: sha512-tF3A1xa5WHzOwn2Y6Pm+mV1NGOztWUKyUQycmtMCnIcEx/0xv5MMgv2qerXQVvhkv6IZJNaEbiGBJNFD6RmunA==} + + bare-events@2.9.2: + resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-inspect@3.1.10: + resolution: {integrity: sha512-Qu01XZNTIT2/bS9k7c6A3OXuAzBnWwDCejGri76BUltdFqVe6Et/IxVOSSbcDpreSSWwqQ5pAu/bxFE6hPDWRQ==} + engines: {bare: '>=1.18.0'} + + bare-module-resolve@1.12.6: + resolution: {integrity: sha512-tsoOqHmtF6VmNX4LYDBZwvQVkSmEagnbykNjK31LMxz/amyKfjCs4K976n7uJd1bvWfxbpLQ3Fy7qq8an6jR6Q==} + peerDependencies: + bare-url: '*' + peerDependenciesMeta: + bare-url: + optional: true + + bare-semver@1.1.0: + resolution: {integrity: sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==} + + bare-stream@2.13.4: + resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-type@1.4.0: + resolution: {integrity: sha512-6anDbVGruQLK8m6zxlCPNkUuxXnzChT1aOmJbFRjEGn8MWZqviFP6vGi9NAnG8aRXyRAfXIGLlBJwYGa4Ylcug==} + engines: {bare: '>=1.32.0'} + better-result@2.10.0: resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + c12@3.3.4: resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} peerDependencies: @@ -1392,6 +1693,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + cookies@0.9.2: resolution: {integrity: sha512-8BIbcC6tPZMv2/PD4PrB3CtKks7LmAWhA7FRZK/6/b/WJ2RUz00KCga7gq50S4/RhZ22kxefRFGgsuts0v3eoA==} engines: {node: '>= 0.8'} @@ -1510,6 +1815,11 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1541,6 +1851,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.26.0: + resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} + engines: {node: '>=10.13.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1568,6 +1882,13 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eta@4.6.0: + resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==} + engines: {node: '>=20'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -1588,6 +1909,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-json-stringify@7.0.1: resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} @@ -1632,6 +1956,10 @@ packages: picomatch: optional: true + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + find-my-way@9.7.0: resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} engines: {node: '>=20'} @@ -1683,6 +2011,10 @@ packages: resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + global-directory@5.0.0: resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} engines: {node: '>=20'} @@ -1740,6 +2072,18 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -1866,36 +2210,73 @@ packages: light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-arm64@1.33.0: resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-darwin-x64@1.33.0: resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-freebsd-x64@1.33.0: resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} @@ -1903,6 +2284,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} @@ -1910,6 +2298,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} @@ -1917,6 +2312,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} @@ -1924,18 +2326,34 @@ packages: os: [linux] libc: [musl] + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss-win32-x64-msvc@1.33.0: resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} @@ -1949,10 +2367,17 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + lru.min@1.1.5: resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} @@ -1960,6 +2385,10 @@ packages: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -1976,9 +2405,26 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2001,6 +2447,9 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2066,6 +2515,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2109,6 +2562,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} @@ -2211,6 +2668,10 @@ packages: remeda@2.33.4: resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + require-addon@1.3.0: + resolution: {integrity: sha512-j8pifaPNZI7ShKY3ihXZwF0qaEjua8zmlqPvDil0a8SBnyRvjCbAiEQuzO0UcoXH1sqU5U/ilXSjYd1W5NYwAA==} + engines: {bare: '>=1.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2304,6 +2765,10 @@ packages: resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} engines: {node: '>= 18'} + sodium-native@5.2.0: + resolution: {integrity: sha512-ntJqNSc9bWmWqcJkx8NA5eS0HUwQnmDlluUzoKCOQUWey8i7LXptk+E/qo2T2RPO9uK2br+3N0zYQme8qPXG+g==} + engines: {bare: '>=1.16.0'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -2336,6 +2801,9 @@ packages: std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -2352,6 +2820,19 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -2368,6 +2849,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + toad-cache@3.7.4: resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} engines: {node: '>=20'} @@ -2508,6 +2993,9 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-runtime@1.4.0: + resolution: {integrity: sha512-0ugbP4CJW4e2D20jvEcC4973dCgIaHI4Rw1PT+26U9zEve7FyYdWAIwUnoeOYvoCfn+wXHoHTKb1KhkYlb60Pw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2740,15 +3228,17 @@ snapshots: tslib: 2.8.1 optional: true + '@fastify/accept-negotiator@2.1.2': {} + '@fastify/ajv-compiler@4.0.6': dependencies: ajv: 8.20.0 ajv-formats: 3.0.1 fast-uri: 4.2.1 - '@fastify/bearer-auth@10.1.4': + '@fastify/cookie@11.1.3': dependencies: - '@fastify/error': 4.2.0 + cookie: 2.0.1 fastify-plugin: 6.0.1 '@fastify/cors@11.3.1': @@ -2762,6 +3252,11 @@ snapshots: dependencies: fast-json-stringify: 7.0.1 + '@fastify/formbody@9.0.1': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 6.0.1 + '@fastify/forwarded@3.0.2': {} '@fastify/merge-json-schemas@0.2.1': @@ -2773,6 +3268,26 @@ snapshots: '@fastify/forwarded': 3.0.2 ipaddr.js: 2.5.0 + '@fastify/secure-session@8.4.1(bare-events@2.9.2)': + dependencies: + '@fastify/cookie': 11.1.3 + fastify-plugin: 6.0.1 + sodium-native: 5.2.0(bare-events@2.9.2) + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bare-events + - bare-url + - react-native-b4a + + '@fastify/send@4.2.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + '@fastify/sensible@6.0.7': dependencies: '@lukeed/ms': 2.0.2 @@ -2783,6 +3298,16 @@ snapshots: type-is: 2.1.0 vary: 1.1.2 + '@fastify/static@10.1.7': + dependencies: + '@fastify/accept-negotiator': 2.1.2 + '@fastify/error': 4.2.0 + '@fastify/send': 4.2.0 + content-disposition: 3.0.0 + fastify-plugin: 6.0.1 + fastq: 1.20.3 + glob: 13.0.6 + '@fastify/swagger@9.9.1': dependencies: fastify-plugin: 6.0.1 @@ -2793,6 +3318,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@fastify/view@12.0.1': + dependencies: + fastify-plugin: 6.0.1 + toad-cache: 3.7.4 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.6.0': {} @@ -2952,6 +3492,66 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.24.2': optional: true + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + '@pinojs/redact@0.4.0': {} '@prisma/adapter-pg@7.10.1': @@ -3169,6 +3769,77 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tailwindcss/cli@4.3.3': + dependencies: + '@parcel/watcher': 2.5.1 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + enhanced-resolve: 5.26.0 + mri: 1.2.0 + picocolors: 1.1.1 + tailwindcss: 4.3.3 + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.26.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.2 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + '@tybys/wasm-util@0.10.4': dependencies: tslib: 2.8.1 @@ -3508,8 +4179,73 @@ snapshots: aws4@1.13.2: {} + b4a@1.9.0: {} + + balanced-match@4.0.4: {} + + bare-addon-resolve@1.11.0: + dependencies: + bare-module-resolve: 1.12.6 + bare-semver: 1.1.0 + + bare-ansi-escapes@2.2.3(bare-events@2.9.2): + dependencies: + bare-stream: 2.13.4(bare-events@2.9.2) + transitivePeerDependencies: + - bare-abort-controller + - bare-events + - react-native-b4a + + bare-assert@1.3.0(bare-events@2.9.2): + dependencies: + bare-inspect: 3.1.10(bare-events@2.9.2) + bare-type: 1.4.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bare-events + - react-native-b4a + + bare-events@2.9.2: {} + + bare-inspect@3.1.10(bare-events@2.9.2): + dependencies: + bare-ansi-escapes: 2.2.3(bare-events@2.9.2) + bare-type: 1.4.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bare-events + - react-native-b4a + + bare-module-resolve@1.12.6: + dependencies: + bare-semver: 1.1.0 + + bare-semver@1.1.0: {} + + bare-stream@2.13.4(bare-events@2.9.2): + dependencies: + b4a: 1.9.0 + streamx: 2.28.1 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - react-native-b4a + + bare-type@1.4.0: {} + better-result@2.10.0: {} + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + c12@3.3.4: dependencies: chokidar: 5.0.0 @@ -3572,6 +4308,8 @@ snapshots: cookie@1.1.1: {} + cookie@2.0.1: {} + cookies@0.9.2: dependencies: depd: 2.0.0 @@ -3674,6 +4412,8 @@ snapshots: destroy@1.2.0: {} + detect-libc@1.0.3: {} + detect-libc@2.1.2: {} dotenv@17.4.2: {} @@ -3697,6 +4437,11 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.26.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + env-paths@2.2.1: {} env-paths@3.0.0: {} @@ -3717,6 +4462,14 @@ snapshots: dependencies: '@types/estree': 1.0.9 + eta@4.6.0: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + expect-type@1.4.0: {} exsolve@1.1.3: {} @@ -3731,6 +4484,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-json-stringify@7.0.1: dependencies: '@fastify/merge-json-schemas': 0.2.1 @@ -3790,6 +4545,10 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + find-my-way@9.7.0: dependencies: fast-deep-equal: 3.1.3 @@ -3835,6 +4594,12 @@ snapshots: giget@3.3.1: {} + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + global-directory@5.0.0: dependencies: ini: 6.0.0 @@ -3889,6 +4654,14 @@ snapshots: is-arrayish@0.2.1: {} + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + is-plain-obj@4.1.0: {} is-property@1.0.2: {} @@ -4022,39 +4795,88 @@ snapshots: process-warning: 4.0.1 set-cookie-parser: 2.7.2 + lightningcss-android-arm64@1.32.0: + optional: true + lightningcss-android-arm64@1.33.0: optional: true + lightningcss-darwin-arm64@1.32.0: + optional: true + lightningcss-darwin-arm64@1.33.0: optional: true + lightningcss-darwin-x64@1.32.0: + optional: true + lightningcss-darwin-x64@1.33.0: optional: true + lightningcss-freebsd-x64@1.32.0: + optional: true + lightningcss-freebsd-x64@1.33.0: optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + lightningcss-linux-arm64-gnu@1.33.0: optional: true + lightningcss-linux-arm64-musl@1.32.0: + optional: true + lightningcss-linux-arm64-musl@1.33.0: optional: true + lightningcss-linux-x64-gnu@1.32.0: + optional: true + lightningcss-linux-x64-gnu@1.33.0: optional: true + lightningcss-linux-x64-musl@1.32.0: + optional: true + lightningcss-linux-x64-musl@1.33.0: optional: true + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + lightningcss-win32-arm64-msvc@1.33.0: optional: true + lightningcss-win32-x64-msvc@1.32.0: + optional: true + lightningcss-win32-x64-msvc@1.33.0: optional: true + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 @@ -4077,14 +4899,25 @@ snapshots: long@5.3.2: {} + lru-cache@11.5.3: {} + lru.min@1.1.5: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 media-typer@1.1.1: {} + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -4097,8 +4930,18 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@3.0.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + minimist@1.2.8: {} + minipass@7.1.3: {} + + mri@1.2.0: {} + ms@2.1.3: {} mysql2@3.24.4(@types/node@24.19.2): @@ -4120,6 +4963,8 @@ snapshots: negotiator@0.6.3: {} + node-addon-api@7.1.1: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -4218,6 +5063,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + pathe@2.0.3: {} perfect-debounce@2.1.0: {} @@ -4259,6 +5109,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.7: {} pino-abstract-transport@3.0.0: @@ -4378,6 +5230,12 @@ snapshots: remeda@2.33.4: {} + require-addon@1.3.0: + dependencies: + bare-addon-resolve: 1.11.0 + transitivePeerDependencies: + - bare-url + require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -4456,6 +5314,18 @@ snapshots: smol-toml@1.9.0: {} + sodium-native@5.2.0(bare-events@2.9.2): + dependencies: + bare-assert: 1.3.0(bare-events@2.9.2) + require-addon: 1.3.0 + which-runtime: 1.4.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bare-events + - bare-url + - react-native-b4a + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -4478,6 +5348,15 @@ snapshots: std-env@4.3.0: {} + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -4495,6 +5374,23 @@ snapshots: strip-json-comments@5.0.3: {} + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + teex@1.0.1: + dependencies: + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.9.0 + transitivePeerDependencies: + - react-native-b4a + thread-stream@4.2.0: dependencies: real-require: 1.0.0 @@ -4508,6 +5404,10 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + toad-cache@3.7.4: {} toidentifier@1.0.1: {} @@ -4601,6 +5501,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-runtime@1.4.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 16c912c..0deed1b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,5 +8,7 @@ allowBuilds: "@prisma/engines": true prisma: true lefthook: true + # ships prebuilt binaries; its install script only builds from source + "@parcel/watcher": false minimumReleaseAgeExclude: - arocapi@5.0.0-beta.7 diff --git a/src/admin/actions.test.ts b/src/admin/actions.test.ts new file mode 100644 index 0000000..ad88490 --- /dev/null +++ b/src/admin/actions.test.ts @@ -0,0 +1,368 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Client } from '@opensearch-project/opensearch'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ALICE, adminTestbed, ocflRepository, PEOPLE, repositoryIn, repositoryOf } from '../../test/admin.ts'; +import type { buildTestApp, TestApp, TestRepository } from '../../test/app.ts'; +import type { Browser } from '../../test/oidc.ts'; +import { hasState, type Repository, State } from '../repository.ts'; + +const BUNDLED = 'arcp://name,bundled'; +const EXTRA = `${PEOPLE}/extra`; +const PEOPLE_TOO = `${PEOPLE}2`; + +const testbed = adminTestbed(); +let storage: string; +let opensearch: Client; + +const start = (repository: TestRepository) => testbed.start({ OCFL_ROOT: join(storage, 'ocfl'), ADMIN_USERS: ALICE.sub }, { repository }); + +const signIn = async (app: TestApp) => { + const { browser, page } = await testbed.signInAsAdmin(app, ALICE); + expect(page.statusCode).toBe(200); + return browser; +}; + +/** Submits an action's form, as its button does, and follows the redirect back */ +const act = async (browser: Browser, action: string, form: Record = {}) => { + const response = await browser.post(`/admin/${action}`, form); + expect(response.statusCode).toBe(303); + expect(response.headers.location).toBe('/admin/'); + return browser.get('/admin/'); +}; + +const flash = (body: string) => + body + .match(/<[^>]*role="status"[^>]*>([\s\S]*?)<\/[a-z]+>/)?.[1] + ?.replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim(); + +beforeAll(async () => { + storage = await mkdtemp(join(tmpdir(), 'ldacapi-actions-')); + execFileSync('node', ['scripts/seed.ts'], { env: { ...process.env, OCFL_ROOT: join(storage, 'ocfl') }, stdio: 'ignore' }); +}); + +afterAll(async () => { + await opensearch?.close(); + await rm(storage, { recursive: true, force: true }); +}); + +describe('index and delete actions', () => { + let app: TestApp; + let repository: Repository; + let prisma: Awaited>['prisma']; + let entityIndex: string; + + const entityCount = (crateId: string) => prisma.entity.count({ where: { rootCollection: crateId } }); + + const searchCount = async (crateId: string) => { + await opensearch.indices.refresh({ index: entityIndex, ignore_unavailable: true }); + const { body } = await opensearch.count({ index: entityIndex, ignore_unavailable: true, body: { query: { term: { rootCollection: crateId } } } }); + return body.count; + }; + + /** Waits until no crate is queued, indexing or deleting, so an earlier action can't leak into the next */ + const waitUntilSettled = () => + vi.waitFor( + async () => { + const busy = (await Array.fromAsync(repository.objects())).filter(({ state }) => hasState(state, State.QUEUED, State.INDEXING, State.DELETING)); + expect(busy).toEqual([]); + }, + { timeout: 10_000, interval: 100 }, + ); + + const waitUntilIndexed = async (...crateIds: string[]) => { + await waitUntilSettled(); + for (const crateId of crateIds) { + expect(await repository.getState(crateId)).toEqual({ structural: 'indexed', search: 'indexed' }); + } + }; + + beforeAll(async () => { + let config: Awaited>['config']; + ({ app, repository, prisma, config } = await start(ocflRepository)); + entityIndex = config.search.entityIndex; + opensearch = new Client({ node: config.opensearchUrl }); + }); + + beforeEach(async () => { + await repository.deleteIndex(); + }); + + afterEach(waitUntilSettled); + + it('indexes one crate', async () => { + const browser = await signIn(app); + + const page = await act(browser, 'index', { crateId: BUNDLED }); + + expect(flash(page.body)).toBe(`Indexing ${BUNDLED} has been queued.`); + await waitUntilIndexed(BUNDLED); + expect(await entityCount(BUNDLED)).toBeGreaterThan(0); + expect(await searchCount(BUNDLED)).toBeGreaterThan(0); + expect(await entityCount(PEOPLE)).toBe(0); + expect(await searchCount(PEOPLE)).toBe(0); + }); + + it('refuses to index an indexed crate, asking for a reindex', async () => { + const browser = await signIn(app); + await act(browser, 'index', { crateId: BUNDLED }); + await waitUntilIndexed(BUNDLED); + + const page = await act(browser, 'index', { crateId: BUNDLED }); + + expect(flash(page.body)).toBe(`Indexing ${BUNDLED} was refused: it is already indexed, so reindex it instead.`); + }); + + it('reindexes an indexed crate', async () => { + const browser = await signIn(app); + await act(browser, 'index', { crateId: BUNDLED }); + await waitUntilIndexed(BUNDLED); + const entities = await entityCount(BUNDLED); + const documents = await searchCount(BUNDLED); + + const page = await act(browser, 'index', { crateId: BUNDLED, force: 'true' }); + + expect(flash(page.body)).toBe(`Reindexing ${BUNDLED} has been queued.`); + await waitUntilIndexed(BUNDLED); + expect(await entityCount(BUNDLED)).toBe(entities); + expect(await searchCount(BUNDLED)).toBe(documents); + }); + + it('indexes every crate', async () => { + const browser = await signIn(app); + + const page = await act(browser, 'index'); + + expect(flash(page.body)).toBe('Indexing every crate has been queued.'); + await waitUntilIndexed(BUNDLED, PEOPLE); + }); + + it('reindexes every crate', async () => { + const browser = await signIn(app); + await act(browser, 'index'); + await waitUntilIndexed(BUNDLED, PEOPLE); + const entities = await entityCount(PEOPLE); + + const page = await act(browser, 'index', { force: 'true' }); + + expect(flash(page.body)).toBe('Reindexing every crate has been queued.'); + await waitUntilIndexed(BUNDLED, PEOPLE); + expect(await entityCount(PEOPLE)).toBe(entities); + }); + + it('refuses to index every crate again while indexing is in progress, and still indexes every crate', async () => { + const browser = await signIn(app); + await act(browser, 'index'); + + const page = await act(browser, 'index'); + + expect(flash(page.body)).toBe('Indexing every crate was refused: indexing is already in progress.'); + await waitUntilIndexed(BUNDLED, PEOPLE); + }); + + it('offers to reindex every crate beside indexing every crate', async () => { + const page = await (await signIn(app)).get('/admin/'); + + expect(page.body).toMatch( + /
]*>]*>Reindex all<\/button>/, + ); + }); + + it('shows every crate in progress, and keeps refreshing, as soon as indexing is queued', async () => { + const browser = await signIn(app); + + const page = await act(browser, 'index'); + + expect(page.body).toMatch(/ { + const browser = await signIn(app); + await act(browser, 'index'); + await waitUntilIndexed(BUNDLED, PEOPLE); + + const page = await act(browser, 'delete', { crateId: BUNDLED }); + + expect(flash(page.body)).toBe(`Deleting the indexes of ${BUNDLED} has started.`); + await vi.waitFor(async () => { + expect(await entityCount(BUNDLED)).toBe(0); + expect(await searchCount(BUNDLED)).toBe(0); + }); + expect(await entityCount(PEOPLE)).toBeGreaterThan(0); + expect(await searchCount(PEOPLE)).toBeGreaterThan(0); + }); + + it('deletes every index', async () => { + const browser = await signIn(app); + await act(browser, 'index'); + await waitUntilIndexed(BUNDLED, PEOPLE); + + const page = await act(browser, 'delete'); + + expect(flash(page.body)).toBe('Deleting every index has started.'); + await vi.waitFor(async () => { + expect(await prisma.entity.count()).toBe(0); + expect(await searchCount(BUNDLED)).toBe(0); + expect(await searchCount(PEOPLE)).toBe(0); + }); + }); + + it('reads a crate’s state as it was, not as indexing later changes it', async () => { + const indexing = repository.createIndex([BUNDLED]); + const state = await repository.getState(BUNDLED); + const read = { ...state }; + + await indexing; + + expect(read).toEqual({ structural: 'queued', search: 'queued' }); + expect(state).toEqual(read); + }); + + it('shows the message once, so reloading doesn’t repeat the action', async () => { + const browser = await signIn(app); + await act(browser, 'index', { crateId: BUNDLED }); + + const reloaded = await browser.get('/admin/'); + + expect(reloaded.statusCode).toBe(200); + expect(flash(reloaded.body)).toBeUndefined(); + }); + + it('asks before deleting, by a script the page loads', async () => { + const browser = await signIn(app); + const page = await browser.get('/admin/'); + + const deletes = [...page.body.matchAll(/]*action="\/admin\/delete"[^>]*>/g)].map(([form]) => form); + expect(deletes.length).toBeGreaterThan(1); + for (const form of deletes) { + expect(form).toMatch(/data-confirm="[^"]+"/); + } + expect((await browser.get('/admin/admin.js')).body).toMatch(/\[data-confirm\][\s\S]*confirm\(/); + }); +}); + +describe('which crates an action covers', () => { + const crates = () => + repositoryOf([ + { id: PEOPLE, state: {} }, + { id: EXTRA, state: {} }, + { id: PEOPLE_TOO, state: {} }, + ]); + + it('indexes a crate and the crates under it, not crates whose id merely starts with its id', async () => { + const repository = crates(); + const browser = await signIn((await start(repository)).app); + + await act(browser, 'index', { crateId: PEOPLE }); + + expect(repository.createIndex).toHaveBeenCalledWith([PEOPLE, EXTRA], undefined, false); + }); + + it('deletes the same crates as indexing would', async () => { + const repository = crates(); + const browser = await signIn((await start(repository)).app); + + const page = await act(browser, 'delete', { crateId: PEOPLE }); + + expect(flash(page.body)).toBe(`Deleting the indexes of ${PEOPLE} and the crates under it has started.`); + expect(repository.deleteIndex.mock.calls).toEqual([[PEOPLE], [EXTRA]]); + }); + + it.each(['index', 'delete'])('refuses to %s a crate that isn’t in the repository', async (action) => { + const repository = crates(); + const browser = await signIn((await start(repository)).app); + + const page = await act(browser, action, { crateId: 'arcp://name,nobody' }); + + expect(flash(page.body)).toMatch(/^(Indexing|Deleting the indexes of) arcp:\/\/name,nobody was refused: it was not found\.$/); + expect(repository.createIndex).not.toHaveBeenCalled(); + expect(repository.deleteIndex).not.toHaveBeenCalled(); + }); +}); + +describe('protecting the actions', () => { + it('refuses to index a crate while a crate under it is being deleted', async () => { + const repository = repositoryOf([ + { id: PEOPLE, state: {} }, + { id: EXTRA, state: { search: 'deleting' } }, + ]); + const browser = await signIn((await start(repository)).app); + + const page = await act(browser, 'index', { crateId: PEOPLE }); + + expect(flash(page.body)).toBe(`Indexing ${PEOPLE} was refused: deleting is in progress.`); + expect(repository.createIndex).not.toHaveBeenCalled(); + }); + + it.each([ + ['one crate', 'queued', { crateId: PEOPLE }, `Deleting the indexes of ${PEOPLE} and the crates under it`], + ['one crate', 'indexing', { crateId: PEOPLE }, `Deleting the indexes of ${PEOPLE} and the crates under it`], + ['every crate', 'indexing', {}, 'Deleting every index'], + ])('refuses to delete %s while a crate it covers is %s', async (_, state, form, asked) => { + const repository = repositoryOf([ + { id: PEOPLE, state: {} }, + { id: EXTRA, state: { structural: state } }, + ]); + const browser = await signIn((await start(repository)).app); + + const page = await act(browser, 'delete', form); + + expect(flash(page.body)).toBe(`${asked} was refused: indexing is in progress.`); + expect(repository.deleteIndex).not.toHaveBeenCalled(); + }); + + it.each([ + ['one crate', { crateId: PEOPLE, force: 'true' }, `Reindexing ${PEOPLE}`], + ['every crate', {}, 'Indexing every crate'], + ])('refuses to index %s while deleting is in progress', async (_, form, asked) => { + const repository = repositoryIn('deleting'); + const { app } = await start(repository); + const browser = await signIn(app); + + const page = await act(browser, 'index', form); + + expect(flash(page.body)).toBe(`${asked} was refused: deleting is in progress.`); + expect(repository.createIndex).not.toHaveBeenCalled(); + }); + + it.each([ + ['one crate', 'queued', { crateId: PEOPLE }, `Indexing ${PEOPLE}`], + ['one crate', 'indexing', { crateId: PEOPLE, force: 'true' }, `Reindexing ${PEOPLE}`], + ['every crate', 'indexing', {}, 'Indexing every crate'], + ['every crate', 'queued', { force: 'true' }, 'Reindexing every crate'], + ])('refuses to index %s while a crate it covers is %s', async (_, state, form, asked) => { + const repository = repositoryOf([ + { id: PEOPLE, state: {} }, + { id: EXTRA, state: { search: state } }, + ]); + const browser = await signIn((await start(repository)).app); + + const page = await act(browser, 'index', form); + + expect(flash(page.body)).toBe(`${asked} was refused: indexing is already in progress.`); + expect(repository.createIndex).not.toHaveBeenCalled(); + }); + + it.each([ + ['without an Origin', {}], + ['from another origin', { origin: 'https://evil.example.org' }], + ])('refuses a POST %s and changes nothing', async (_, headers) => { + const repository = repositoryIn('indexed'); + const { app } = await start(repository); + const browser = await signIn(app); + + for (const action of ['index', 'delete']) { + expect((await browser.post(`/admin/${action}`, { crateId: PEOPLE, force: 'true' }, headers)).statusCode).toBe(403); + } + + expect(repository.createIndex).not.toHaveBeenCalled(); + expect(repository.deleteIndex).not.toHaveBeenCalled(); + expect(flash((await browser.get('/admin/')).body)).toBeUndefined(); + }); +}); diff --git a/src/admin/actions.ts b/src/admin/actions.ts new file mode 100644 index 0000000..f6c1a28 --- /dev/null +++ b/src/admin/actions.ts @@ -0,0 +1,59 @@ +import type { FastifyBaseLogger } from 'fastify'; +import { hasState, type Repository, type RepositoryObject, State } from '../repository.ts'; +import { isWithin } from './crates.ts'; + +/** What an action asked for and whether it was accepted, shown once on the next page */ +export type Flash = { accepted: boolean; message: string }; + +const refused = (asked: string, reason: string): Flash => ({ accepted: false, message: `${asked} was refused: ${reason}.` }); + +/** The crates an action on `crateId` covers, it and the crates under it, or every crate; undefined if `crateId` isn't a crate */ +const covered = async (repository: Repository, crateId: string | undefined): Promise => { + const objects = await Array.fromAsync(repository.objects()); + if (crateId === undefined) { + return objects; + } + return objects.some(({ id }) => id === crateId) ? objects.filter(({ id }) => isWithin(id, crateId)) : undefined; +}; + +/** Queues indexing `crateId` and the crates under it, or every crate; `force` rebuilds indexes that already exist */ +export const indexCrates = async (repository: Repository, log: FastifyBaseLogger, crateId: string | undefined, force: boolean): Promise => { + const asked = `${force ? 'Reindexing' : 'Indexing'} ${crateId ?? 'every crate'}`; + const crates = await covered(repository, crateId); + if (!crates) { + return refused(asked, 'it was not found'); + } + if (crates.some(({ state }) => hasState(state, State.DELETING))) { + return refused(asked, 'deleting is in progress'); + } + if (crates.some(({ state }) => hasState(state, State.QUEUED, State.INDEXING))) { + return refused(asked, 'indexing is already in progress'); + } + if (!force && crates.some(({ id, state }) => id === crateId && hasState(state, State.INDEXED))) { + return refused(asked, 'it is already indexed, so reindex it instead'); + } + repository + .createIndex( + crates.map(({ id }) => id), + undefined, + force, + ) + .catch((err) => log.error({ err, crateId }, 'Indexing failed')); + return { accepted: true, message: `${asked} has been queued.` }; +}; + +/** Starts deleting the indexes of `crateId` and the crates under it, or every index */ +export const deleteIndexes = async (repository: Repository, log: FastifyBaseLogger, crateId: string | undefined): Promise => { + const crates = await covered(repository, crateId); + const under = crates && crates.length > 1 ? ' and the crates under it' : ''; + const asked = `Deleting ${crateId ? `the indexes of ${crateId}${under}` : 'every index'}`; + if (!crates) { + return refused(asked, 'it was not found'); + } + if (crates.some(({ state }) => hasState(state, State.QUEUED, State.INDEXING))) { + return refused(asked, 'indexing is in progress'); + } + const deleting = crateId === undefined ? [repository.deleteIndex()] : crates.map(({ id }) => repository.deleteIndex(id)); + Promise.all(deleting).catch((err) => log.error({ err, crateId }, 'Deleting indexes failed')); + return { accepted: true, message: `${asked} has started.` }; +}; diff --git a/src/admin/admin-users.test.ts b/src/admin/admin-users.test.ts new file mode 100644 index 0000000..c6a17df --- /dev/null +++ b/src/admin/admin-users.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ALICE, adminTestbed } from '../../test/admin.ts'; + +const COOKIE_KEYS = 'a-cookie-key-of-at-least-32-characters'; + +const testbed = adminTestbed(); +const signIn = testbed.signInAsAdmin; + +/** Starts the app afresh with `env`, as a restart with new configuration would */ +const start = (env: Record) => { + vi.resetModules(); + return testbed.start({ OIDC_COOKIE_KEYS: COOKIE_KEYS, ...env }); +}; + +describe('who is an admin', () => { + it('lets nobody in when ADMIN_USERS lists nobody', async () => { + const { app } = await start({ ADMIN_USERS: ' , ' }); + + const { page } = await signIn(app, ALICE); + + expect(page.statusCode).toBe(403); + }); + + it('stops an existing session once its user is removed from ADMIN_USERS', async () => { + const { app: before } = await start({ ADMIN_USERS: ALICE.sub }); + const { browser, page } = await signIn(before, ALICE); + expect(page.statusCode).toBe(200); + + const { app: after } = await start({}); + + expect((await browser.get('/admin/', after)).statusCode).toBe(403); + }); + + it('treats a session from an app with other cookie keys as no session', async () => { + const { app: other } = await start({ ADMIN_USERS: ALICE.sub, OIDC_COOKIE_KEYS: 'another-cookie-key-of-at-least-32-characters' }); + const { browser } = await signIn(other, ALICE); + const { app } = await start({ ADMIN_USERS: ALICE.sub }); + + const page = await browser.get('/admin/', app); + + expect(page.statusCode).toBe(303); + expect(new URL(page.headers.location as string).pathname).toBe('/oauth/authorize'); + }); +}); diff --git a/src/admin/admin.css b/src/admin/admin.css new file mode 100644 index 0000000..e86f111 --- /dev/null +++ b/src/admin/admin.css @@ -0,0 +1,2 @@ +@import "tailwindcss" source(none); +@source "./views/**/*.eta"; diff --git a/src/admin/audit.test.ts b/src/admin/audit.test.ts new file mode 100644 index 0000000..5307779 --- /dev/null +++ b/src/admin/audit.test.ts @@ -0,0 +1,35 @@ +import { Writable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import { ALICE, adminTestbed, PEOPLE, repositoryIn } from '../../test/admin.ts'; + +const logs = vi.hoisted(() => [] as Record[]); + +vi.mock('../logger.ts', async () => { + const { pino } = await import('pino'); + const stream = new Writable({ + write(chunk, _encoding, done) { + logs.push(JSON.parse(chunk.toString())); + done(); + }, + }); + return { logger: pino({ level: 'info' }, stream) }; +}); + +const testbed = adminTestbed(); + +describe('the audit log', () => { + it('records each accepted and refused action, with who asked', async () => { + const { app } = await testbed.start({ ADMIN_USERS: ALICE.sub }, { repository: repositoryIn('indexed') }); + const { browser } = await testbed.signInAsAdmin(app, ALICE); + + await browser.post('/admin/index', { crateId: PEOPLE, force: 'true' }); + await browser.post('/admin/delete', {}); + await browser.post('/admin/index', { crateId: PEOPLE }); + + expect(logs.filter(({ action }) => action)).toEqual([ + expect.objectContaining({ level: 30, sub: ALICE.sub, action: 'index', crateId: PEOPLE, force: true, accepted: true }), + expect.objectContaining({ level: 30, sub: ALICE.sub, action: 'delete', crateId: 'all', force: false, accepted: true }), + expect.objectContaining({ level: 30, sub: ALICE.sub, action: 'index', crateId: PEOPLE, force: false, accepted: false }), + ]); + }); +}); diff --git a/src/admin/client.ts b/src/admin/client.ts new file mode 100644 index 0000000..2423545 --- /dev/null +++ b/src/admin/client.ts @@ -0,0 +1,24 @@ +import { hkdfSync } from 'node:crypto'; +import type { AuthConfig } from '../configuration.ts'; + +export const ADMIN_PATH = '/admin'; + +export const ADMIN_HOME = `${ADMIN_PATH}/`; + +export const ADMIN_SIGNED_OUT = `${ADMIN_PATH}/signed-out`; + +export const derivedKey = (cookieKey: string, purpose: string) => Buffer.from(hkdfSync('sha256', cookieKey, '', `ldacapi ${purpose}`, 32)); + +export const adminRedirectUri = (adminOrigin: string) => `${adminOrigin}${ADMIN_PATH}/callback`; + +export const adminPostLogoutRedirectUri = (adminOrigin: string) => `${adminOrigin}${ADMIN_SIGNED_OUT}`; + +/** The admin pages' own client of our provider, which needs no configuration */ +export const adminClient = ({ cookieKeys }: AuthConfig, adminOrigin: string) => ({ + client_id: 'ldacapi-admin', + client_secret: derivedKey(cookieKeys[0] ?? '', 'admin client secret').toString('base64url'), + redirect_uris: [adminRedirectUri(adminOrigin)], + post_logout_redirect_uris: [adminPostLogoutRedirectUri(adminOrigin)], + grant_types: ['authorization_code'], + scope: 'openid profile', +}); diff --git a/src/admin/crates.test.ts b/src/admin/crates.test.ts new file mode 100644 index 0000000..bdf8366 --- /dev/null +++ b/src/admin/crates.test.ts @@ -0,0 +1,158 @@ +import { execFileSync } from 'node:child_process'; +import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { ALICE, adminTestbed, ocflRepository, PEOPLE, repositoryIn } from '../../test/admin.ts'; +import type { TestApp, TestRepository } from '../../test/app.ts'; +import type { Repository } from '../repository.ts'; + +const BUNDLED = 'arcp://name,bundled'; +const EXTRA = 'arcp://name,people/extra'; +const MORE = 'arcp://name,people/extra/more'; +const EXTRA_NAME = ' Extra people'; + +type Node = { id: string; row: string; children: Node[] }; + +/** The crate tree as a browser sees it: nested `
  • `, each with the text of its own row */ +const crateTree = (body: string) => { + const root: Node = { id: '', row: '', children: [] }; + const stack = [root]; + for (const match of body.matchAll(/<(\/?)li\b(?:[^>]*?\bdata-id="([^"]*)")?[^>]*>/g)) { + if (match[1]) { + stack.pop(); + continue; + } + const rest = body.slice((match.index ?? 0) + match[0].length); + const node: Node = { + id: (match[2] ?? '').replaceAll('&', '&'), + row: rest + .slice(0, rest.search(/<\/?(ul|li)\b/)) + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(), + children: [], + }; + stack.at(-1)?.children.push(node); + stack.push(node); + } + return root.children; +}; + +const shape = (nodes: Node[]): unknown[] => nodes.map(({ id, children }) => (children.length ? { id, children: shape(children) } : { id })); + +const find = (nodes: Node[], id: string): Node | undefined => { + for (const node of nodes) { + const found = node.id === id ? node : find(node.children, id); + if (found) { + return found; + } + } +}; + +const crate = (id: string, name: string) => ({ + '@context': ['https://w3id.org/ro/crate/1.1/context', { '@vocab': 'http://schema.org/' }], + '@graph': [ + { '@id': 'ro-crate-metadata.json', '@type': 'CreativeWork', about: { '@id': id } }, + { '@id': id, '@type': ['Dataset', 'RepositoryCollection'], name }, + ], +}); + +const testbed = adminTestbed(); +let storage: string; + +const start = (repository: TestRepository) => testbed.start({ OCFL_ROOT: join(storage, 'ocfl'), ADMIN_USERS: ALICE.sub }, { repository }); + +const signIn = async (app: TestApp) => { + const signedIn = await testbed.signInAsAdmin(app, ALICE); + expect(signedIn.page.statusCode).toBe(200); + return signedIn; +}; + +beforeAll(async () => { + storage = await mkdtemp(join(tmpdir(), 'ldacapi-crates-')); + const source = join(storage, 'source'); + await mkdir(source); + for (const name of ['bundled', 'people']) { + await cp(resolve('test-data', name), join(source, name), { recursive: true }); + } + for (const [directory, id, name] of [ + ['extra', EXTRA, EXTRA_NAME], + ['more', MORE, 'More people'], + ] as const) { + await mkdir(join(source, directory)); + await writeFile(join(source, directory, 'ro-crate-metadata.json'), JSON.stringify(crate(id, name))); + } + execFileSync('node', ['scripts/seed.ts', source], { env: { ...process.env, OCFL_ROOT: join(storage, 'ocfl') }, stdio: 'ignore' }); +}); + +afterAll(async () => { + await rm(storage, { recursive: true, force: true }); +}); + +describe('the crate tree', () => { + let app: TestApp; + let repository: Repository; + + beforeAll(async () => { + ({ app, repository } = await start(ocflRepository)); + await repository.deleteIndex(); + }); + + it('lists every crate, with collections containing their objects', async () => { + const { page } = await signIn(app); + + expect(shape(crateTree(page.body))).toEqual([{ id: BUNDLED }, { id: PEOPLE, children: [{ id: EXTRA, children: [{ id: MORE }] }] }]); + }); + + it('shows each crate’s real state for each index', async () => { + const { browser } = await signIn(app); + await repository.createIndex(new RegExp(`^${BUNDLED}$`)); + + await vi.waitFor(async () => { + const page = await browser.get('/admin/'); + const tree = crateTree(page.body); + expect(find(tree, BUNDLED)?.row).toMatch(/Structural: indexed .*Search: indexed/); + expect(find(tree, PEOPLE)?.row).toMatch(/Structural: not indexed .*Search: not indexed/); + expect(page.body).not.toContain('http-equiv="refresh"'); + }); + }); + + it('shows a crate name containing HTML escaped', async () => { + const { page } = await signIn(app); + + expect(find(crateTree(page.body), EXTRA)?.row).toContain('<script>alert(1)</script> Extra people'); + expect(page.body).not.toContain(''); + expect(page.headers['content-security-policy']).toContain("script-src 'self'"); + const script = await browser.get('/admin/admin.js'); + expect(script.statusCode).toBe(200); + expect(script.headers['content-type']).toMatch(/javascript/); + expect(script.body).toContain('sessionStorage'); + }); +}); + +describe('refreshing while indexes change', () => { + it.each(['queued', 'indexing', 'deleting'])('refreshes the page while an index is %s', async (state) => { + const { app } = await start(repositoryIn(state)); + + const { page } = await signIn(app); + + expect(page.body).toMatch(//); + expect(find(crateTree(page.body), PEOPLE)?.row).toContain(`Search: ${state}`); + }); + + it('does not refresh once every index is settled', async () => { + const { app } = await start(repositoryIn('indexed')); + + const { page } = await signIn(app); + + expect(page.body).not.toContain('http-equiv="refresh"'); + }); +}); diff --git a/src/admin/crates.ts b/src/admin/crates.ts new file mode 100644 index 0000000..091de16 --- /dev/null +++ b/src/admin/crates.ts @@ -0,0 +1,81 @@ +import { hasState, type RepositoryObject, State } from '../repository.ts'; + +type Crate = RepositoryObject & { label: string; children: Crate[] }; + +const INDEXES = { structural: 'Structural', search: 'Search' } as const; + +/** How often the page reloads while an index is changing */ +const REFRESH_SECONDS = 5; + +/** The ids of the crates `id` is under, nearest first, as indexing a crate also indexes the crates under it */ +function* ancestorIds(id: string) { + let path = id.replace(/\/+$/, ''); + for (let slash = path.lastIndexOf('/'); slash > 0; slash = path.lastIndexOf('/')) { + path = path.slice(0, slash); + yield path; + yield `${path}/`; + } +} + +/** Whether `id` is `crateId` or a crate under it */ +export const isWithin = (id: string, crateId: string) => id === crateId || ancestorIds(id).some((ancestor) => ancestor === crateId); + +const parentOf = (id: string, crates: Map) => { + for (const ancestor of ancestorIds(id)) { + const parent = crates.get(ancestor); + if (parent) { + return parent; + } + } +}; + +const byLabel = (a: Crate, b: Crate) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id); + +const sortTree = (crates: Crate[]) => { + crates.sort(byLabel); + for (const crate of crates) { + sortTree(crate.children); + } + return crates; +}; + +const buildTree = (objects: RepositoryObject[]) => { + const crates = new Map(objects.map((object) => [object.id, { ...object, label: object.name ?? object.id, children: [] as Crate[] }])); + const roots: Crate[] = []; + for (const crate of crates.values()) { + (parentOf(crate.id, crates)?.children ?? roots).push(crate); + } + return sortTree(roots); +}; + +/** A form posting `crateId` (none for every crate) to an action */ +type ActionForm = { action: 'index' | 'delete'; label: string; crateId?: string | undefined; force?: boolean; confirm?: string }; + +type CrateRow = { id: string; label: string; states: { label: string; value: string }[]; actions: ActionForm[]; children: CrateRow[] }; + +const crateRow = (crate: Crate): CrateRow => { + const indexed = hasState(crate.state, State.INDEXED); + const confirm = `Delete the indexes of ${crate.label}${crate.children.length > 0 ? ' and the crates under it' : ''}?`; + return { + id: crate.id, + label: crate.label, + states: Object.entries(INDEXES).map(([index, label]) => ({ label, value: crate.state[index] ?? 'not indexed' })), + actions: [ + { action: 'index', label: indexed ? 'Reindex' : 'Index', crateId: crate.id, force: indexed }, + { action: 'delete', label: 'Delete', crateId: crate.id, confirm }, + ], + children: crate.children.map(crateRow), + }; +}; + +const ALL_ACTIONS: ActionForm[] = [ + { action: 'index', label: 'Index all' }, + { action: 'index', label: 'Reindex all', force: true }, + { action: 'delete', label: 'Delete all', confirm: 'Delete every index?' }, +]; + +/** The crates as a tree, and a refresh while any index is changing */ +export const cratesPage = (objects: RepositoryObject[]) => { + const changing = objects.some(({ state }) => hasState(state, State.QUEUED, State.INDEXING, State.DELETING)); + return { refresh: changing ? REFRESH_SECONDS : undefined, actions: ALL_ACTIONS, crates: buildTree(objects).map(crateRow) }; +}; diff --git a/src/admin/off.test.ts b/src/admin/off.test.ts new file mode 100644 index 0000000..95edd29 --- /dev/null +++ b/src/admin/off.test.ts @@ -0,0 +1,65 @@ +import { Writable } from 'node:stream'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { buildTestApp, type TestApp } from '../../test/app.ts'; +import { generateCookieKey, generateSigningKey } from '../oidc/keys.ts'; + +const logs = vi.hoisted(() => [] as { level: number; msg: string }[]); + +vi.mock('../logger.ts', async () => { + const { pino } = await import('pino'); + const stream = new Writable({ + write(chunk, _encoding, done) { + logs.push(JSON.parse(chunk.toString())); + done(); + }, + }); + return { logger: pino({ level: 'warn' }, stream) }; +}); + +let app: TestApp; +let prisma: Awaited>['prisma']; + +beforeAll(async () => { + ({ app, prisma } = await buildTestApp({ ADMIN_USERS: 'alice@upstream' })); +}); + +afterAll(async () => { + await app.close(); + await prisma.$disconnect(); +}); + +describe('the admin pages without sign-in', () => { + it('are not found, and startup warns that they are off', async () => { + expect((await app.inject({ method: 'GET', url: '/admin/', headers: { host: 'admin.localhost:8080' } })).statusCode).toBe(404); + expect(logs.map(({ msg }) => msg)).toContainEqual(expect.stringContaining('OIDC_UPSTREAM_ISSUER')); + }); +}); + +describe('the admin pages in production without ADMIN_ORIGIN', () => { + it('are not found, and startup warns that they are off', async () => { + vi.resetModules(); + const started = await buildTestApp({ + NODE_ENV: 'production', + LOG_LEVEL: 'warn', + OPENSEARCH_URL: 'http://localhost:9200', + OCFL_ROOT: 'storage/ocfl', + OIDC_ISSUER: 'https://api.example.org', + OIDC_CLIENTS: JSON.stringify([{ client_id: 'oni', redirect_uris: ['https://oni.example.org/callback'] }]), + OIDC_UPSTREAM_ISSUER: 'https://idp.example.org', + OIDC_UPSTREAM_CLIENT_ID: 'ldacapi', + OIDC_UPSTREAM_CLIENT_SECRET: 'secret', + OIDC_SIGNING_KEYS: JSON.stringify({ keys: [generateSigningKey()] }), + OIDC_COOKIE_KEYS: generateCookieKey(), + ADMIN_USERS: 'alice@upstream', + }); + try { + for (const host of ['api.example.org', 'admin.localhost:8080']) { + expect((await started.app.inject({ method: 'GET', url: '/admin/', headers: { host } })).statusCode).toBe(404); + } + expect(logs.map(({ msg }) => msg)).toContainEqual(expect.stringContaining('ADMIN_ORIGIN')); + } finally { + await started.app.close(); + await started.prisma.$disconnect(); + } + }); +}); diff --git a/src/admin/plugin.ts b/src/admin/plugin.ts new file mode 100644 index 0000000..94357a4 --- /dev/null +++ b/src/admin/plugin.ts @@ -0,0 +1,162 @@ +import { fileURLToPath } from 'node:url'; +import formBody from '@fastify/formbody'; +import secureSession, { type Session, type SessionData } from '@fastify/secure-session'; +import fastifyStatic from '@fastify/static'; +import fastifyView from '@fastify/view'; +import { Eta } from 'eta'; +import type { FastifyBaseLogger, FastifyPluginAsync, FastifyReply } from 'fastify'; +import type { AuthConfig } from '../configuration.ts'; +import { logger } from '../logger.ts'; +import type { Repository } from '../repository.ts'; +import { deleteIndexes, type Flash, indexCrates } from './actions.ts'; +import { ADMIN_HOME, ADMIN_PATH, ADMIN_SIGNED_OUT, derivedKey } from './client.ts'; +import { cratesPage } from './crates.ts'; +import { createSignIn, type SignInTransaction } from './sign-in.ts'; + +const log = logger.child({ module: 'admin' }); + +type AdminSession = { sub: string; name: string; signedInAt: number; idToken: string }; + +declare module '@fastify/secure-session' { + interface SessionData { + admin: AdminSession; + transaction: SignInTransaction; + flash: Flash; + } +} + +declare module 'fastify' { + interface FastifyRequest { + signIn: Session; + } +} + +const SESSION_SECONDS = 8 * 60 * 60; +const SIGN_IN_SECONDS = 10 * 60; + +const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"; + +/** What the layout shows around every page */ +type Frame = { title: string; user?: string | undefined; flash?: Flash | undefined; refresh?: number | undefined }; + +/** Each page's template and the data it needs */ +type Pages = { + crates: Frame & ReturnType; + 'not-an-admin': Frame & { sub: string }; + 'signed-out': Frame; +}; + +const render = (reply: FastifyReply, page: Page, data: Pages[Page], status = 200) => reply.code(status).view(page, data); + +type AdminOptions = { auth: AuthConfig; origin: string; adminUsers: string[]; repository: Repository }; + +/** The admin pages, for users whose `sub` is in `adminUsers` */ +export const adminPages: FastifyPluginAsync = async (fastify, { auth, origin, adminUsers, repository }) => { + const { host, protocol } = new URL(origin); + const signInFlow = createSignIn(fastify, auth, origin); + const admins = new Set(adminUsers); + const cookie = { path: ADMIN_PATH, httpOnly: true, secure: protocol === 'https:', sameSite: 'lax' } as const; + const sessionOptions = (sessionName: string, cookieName: string, purpose: string, seconds: number) => ({ + sessionName, + cookieName, + key: auth.cookieKeys.map((key) => derivedKey(key, purpose)), + expiry: seconds, + cookie: { ...cookie, maxAge: seconds }, + }); + const actions = { + index: (log: FastifyBaseLogger, crateId: string | undefined, force: boolean) => indexCrates(repository, log, crateId, force), + delete: (log: FastifyBaseLogger, crateId: string | undefined) => deleteIndexes(repository, log, crateId), + }; + + fastify.addHook('onRoute', (route) => { + route.constraints = { ...route.constraints, host }; + }); + + fastify.addHook('onRequest', async (request, reply) => { + reply.header('content-security-policy', CSP).header('x-frame-options', 'DENY'); + if (request.method === 'POST' && request.headers.origin !== origin) { + return reply.forbidden('Cross-origin request refused'); + } + }); + + fastify.register(fastifyView, { + engine: { eta: new Eta() }, + root: fileURLToPath(new URL('views', import.meta.url)), + layout: 'layout', + defaultContext: { adminPath: ADMIN_PATH }, + }); + + fastify.register(fastifyStatic, { root: fileURLToPath(new URL('public', import.meta.url)), decorateReply: false, index: false }); + + fastify.register(async (sessions) => { + sessions.register(formBody); + sessions.register(secureSession, [ + sessionOptions('session', 'admin_session', 'admin session', SESSION_SECONDS), + sessionOptions('signIn', 'admin_sign_in', 'admin sign-in', SIGN_IN_SECONDS), + ]); + + sessions.get<{ Querystring: Record }>('/callback', async (request, reply) => { + const transaction = request.signIn.get('transaction'); + request.signIn.delete(); + if (!transaction || request.query.state !== transaction.state) { + return reply.badRequest('Sign-in has expired or was started elsewhere'); + } + let signedIn: Awaited>; + try { + signedIn = await signInFlow.finish(request.query, transaction); + } catch (err) { + log.warn({ err }, 'Sign-in to the admin pages failed'); + return reply.badRequest('Sign-in failed'); + } + const { sub, name = sub, idToken } = signedIn; + request.session.set('admin', { sub, name, idToken, signedInAt: Date.now() }); + return reply.redirect(transaction.returnTo, 303); + }); + + sessions.post('/sign-out', async (request, reply) => { + const admin = request.session.get('admin'); + request.session.delete(); + return reply.redirect(admin ? signInFlow.signOutUrl(admin.idToken).toString() : ADMIN_SIGNED_OUT, 303); + }); + + sessions.get('/signed-out', async (_request, reply) => { + return render(reply, 'signed-out', { title: 'Signed out' }); + }); + + // Every page below needs a current session belonging to an admin + sessions.register(async (pages) => { + pages.addHook('onRequest', async (request, reply) => { + const admin = request.session.get('admin'); + if (!admin || Date.now() - admin.signedInAt >= SESSION_SECONDS * 1000) { + // Only a GET can be repeated after sign-in + const { url, transaction } = await signInFlow.start(request.method === 'GET' ? request.url : ADMIN_HOME); + request.signIn.set('transaction', transaction); + return reply.redirect(url.toString(), 303); + } + if (!admins.has(admin.sub)) { + return render(reply, 'not-an-admin', { title: 'Not an admin', user: admin.name, sub: admin.sub }, 403); + } + }); + + pages.get('/', async (request, reply) => { + const flash = request.session.get('flash'); + if (flash) { + request.session.set('flash', undefined); + } + const objects = await Array.fromAsync(repository.objects()); + return render(reply, 'crates', { title: 'Crates', user: request.session.get('admin')?.name, flash, ...cratesPage(objects) }); + }); + + for (const [action, run] of Object.entries(actions)) { + pages.post<{ Body: { crateId?: string; force?: string } }>(`/${action}`, async (request, reply) => { + const crateId = request.body?.crateId || undefined; + const force = Boolean(request.body?.force); + const flash = await run(request.log, crateId, force); + request.log.info({ sub: request.session.get('admin')?.sub, action, crateId: crateId ?? 'all', force, accepted: flash.accepted }, flash.message); + request.session.set('flash', flash); + return reply.redirect(ADMIN_HOME, 303); + }); + } + }); + }); +}; diff --git a/src/admin/public/admin.js b/src/admin/public/admin.js new file mode 100644 index 0000000..a3d8b64 --- /dev/null +++ b/src/admin/public/admin.js @@ -0,0 +1,36 @@ +// Narrows the crate tree to crates whose name matches the filter, keeping their ancestors +const filter = document.getElementById('crate-filter'); +const tree = document.getElementById('crates'); +// Kept across the page's own refreshes +const FILTER_KEY = 'admin-crate-filter'; + +const narrow = (item, words) => { + const children = item.querySelector(':scope > ul'); + const childMatches = children ? [...children.children].map((child) => narrow(child, words)).includes(true) : false; + const name = item.dataset.name.toLowerCase(); + const matches = childMatches || words.every((word) => name.includes(word)); + item.hidden = !matches; + return matches; +}; + +const apply = () => { + sessionStorage.setItem(FILTER_KEY, filter.value); + const words = filter.value.toLowerCase().split(/\s+/).filter(Boolean); + for (const item of tree.querySelectorAll(':scope > ul > li')) { + narrow(item, words); + } +}; + +if (filter && tree) { + filter.value = sessionStorage.getItem(FILTER_KEY) ?? ''; + apply(); + filter.addEventListener('input', apply); +} + +for (const form of document.querySelectorAll('form[data-confirm]')) { + form.addEventListener('submit', (event) => { + if (!confirm(form.dataset.confirm)) { + event.preventDefault(); + } + }); +} diff --git a/src/admin/sign-in.test.ts b/src/admin/sign-in.test.ts new file mode 100644 index 0000000..e82e39f --- /dev/null +++ b/src/admin/sign-in.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { ADMIN, ALICE as ADMIN_USER, adminTestbed, ISSUER } from '../../test/admin.ts'; +import type { TestApp } from '../../test/app.ts'; +import type { UpstreamUser } from '../../test/fake-upstream.ts'; +import { authorizationRequest, Browser } from '../../test/oidc.ts'; + +const ALICE: UpstreamUser = { ...ADMIN_USER, name: 'Alice ' }; +const MALLORY: UpstreamUser = { sub: 'mallory@upstream', name: 'Mallory' }; + +const testbed = adminTestbed(); +let app: TestApp; + +/** Follows `url` to the upstream provider, signs `user` in there, and follows the way back */ +const signIn = async (browser: Browser, url: string, user: UpstreamUser) => { + const toUpstream = await browser.follow(url); + expect(new URL(toUpstream.headers.location as string).origin).toBe(testbed.upstream.issuer); + return browser.follow(testbed.upstream.signIn(toUpstream.headers.location as string, user)); +}; + +beforeAll(async () => { + ({ app } = await testbed.start({ ADMIN_USERS: ` other@upstream , ${ALICE.sub} ` })); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('signing in to the admin pages', () => { + it('sends a visitor with no session to sign in, then back to the page they asked for', async () => { + const browser = new Browser(app, ADMIN, ISSUER); + + const first = await browser.get('/admin/?from=test'); + expect(first.statusCode).toBe(303); + const authorize = new URL(first.headers.location as string); + expect(`${authorize.origin}${authorize.pathname}`).toBe(`${ISSUER}/oauth/authorize`); + expect(authorize.searchParams.get('redirect_uri')).toBe(`${ADMIN}/admin/callback`); + + const page = await signIn(browser, '/admin/?from=test', ALICE); + expect(browser.url).toBe('/admin/?from=test'); + expect(page.statusCode).toBe(200); + expect(page.body).toContain('Alice <Admin>'); + }); + + it('sends a POST with no session to sign in, then to the admin home page', async () => { + const browser = new Browser(app, ADMIN, ISSUER); + + const posted = await browser.post('/admin/index', {}); + expect(posted.statusCode).toBe(303); + const page = await signIn(browser, posted.headers.location as string, ALICE); + + expect(browser.url).toBe('/admin/'); + expect(page.statusCode).toBe(200); + }); + + it('lets in a user already signed in at our provider without visiting the upstream provider', async () => { + const browser = new Browser(app, ADMIN, ISSUER); + const { url } = await authorizationRequest({ client_id: 'oni', redirect_uri: 'http://localhost:5173/callback', scope: 'openid' }); + await signIn(browser, `${ISSUER}${url}`, ALICE); + + const page = await browser.follow('/admin/'); + expect(page.headers.location).toBeUndefined(); + + expect(page.statusCode).toBe(200); + expect(page.body).toContain('Alice <Admin>'); + }); + + it('shows a user who is not an admin their sub and a way to sign out', async () => { + const browser = new Browser(app, ADMIN, ISSUER); + + const page = await signIn(browser, '/admin/', MALLORY); + + expect(page.statusCode).toBe(403); + expect(page.body).toContain(MALLORY.sub); + expect(page.body).toMatch(//); + }); + + it('sends a session older than 8 hours to sign in again', async () => { + const browser = new Browser(app, ADMIN, ISSUER); + await signIn(browser, '/admin/', ALICE); + vi.useFakeTimers({ toFake: ['Date'] }); + + vi.setSystemTime(Date.now() + 8 * 60 * 60 * 1000 - 60 * 1000); + expect((await browser.get('/admin/')).statusCode).toBe(200); + vi.setSystemTime(Date.now() + 60 * 1000); + const expired = await browser.get('/admin/'); + + expect(expired.statusCode).toBe(303); + expect(new URL(expired.headers.location as string).pathname).toBe('/oauth/authorize'); + }); + + it('treats a tampered session cookie as no session', async () => { + const browser = new Browser(app, ADMIN, ISSUER); + await signIn(browser, '/admin/', ALICE); + const cookie = browser.cookie('admin_session') ?? ''; + browser.setCookie('admin_session', `${cookie[0] === 'A' ? 'B' : 'A'}${cookie.slice(1)}`); + + const page = await browser.get('/admin/'); + + expect(page.statusCode).toBe(303); + expect(new URL(page.headers.location as string).pathname).toBe('/oauth/authorize'); + }); + + it('refuses a callback whose state does not match the sign-in', async () => { + const browser = new Browser(app, ADMIN, ISSUER); + const toUpstream = await browser.follow('/admin/'); + let toAdmin = new URL(testbed.upstream.signIn(toUpstream.headers.location as string, ALICE)); + while (toAdmin.pathname !== '/admin/callback') { + toAdmin = new URL((await browser.get(toAdmin.toString())).headers.location as string, ISSUER); + } + toAdmin.searchParams.set('state', 'forged'); + + const refused = await browser.get(toAdmin.toString()); + + expect(refused.statusCode).toBe(400); + expect(refused.headers.location).toBeUndefined(); + expect((await browser.get('/admin/')).statusCode).toBe(303); + }); + + it('signs out of the admin pages and our provider, ending on a page that doesn’t sign in again', async () => { + const browser = new Browser(app, ADMIN, ISSUER); + await signIn(browser, '/admin/', ALICE); + + const signedOut = await browser.post('/admin/sign-out', {}); + + expect(signedOut.statusCode).toBe(303); + const endSession = new URL(signedOut.headers.location as string); + expect(`${endSession.origin}${endSession.pathname}`).toBe(`${ISSUER}/oauth/session/end`); + expect(endSession.searchParams.get('id_token_hint')).toEqual(expect.any(String)); + expect(endSession.searchParams.get('post_logout_redirect_uri')).toBe(`${ADMIN}/admin/signed-out`); + expect(browser.cookie('admin_session')).toBeUndefined(); + + const confirm = await browser.get(endSession.toString()); + const xsrf = confirm.body.match(/name="xsrf" value="([^"]+)"/)?.[1] ?? ''; + const toAdmin = await browser.post(`${ISSUER}/oauth/session/end/confirm`, { xsrf, logout: 'yes' }); + expect(toAdmin.headers.location).toBe(`${ADMIN}/admin/signed-out`); + const page = await browser.follow(toAdmin.headers.location as string); + expect(page.statusCode).toBe(200); + expect(page.body).toMatch(/]*href="\/admin\/"[^>]*>Sign in<\/a>/); + + const again = await browser.follow('/admin/'); + expect(new URL(again.headers.location as string).origin).toBe(testbed.upstream.issuer); + }); + + it('sends a sign-out with no session to the signed-out page', async () => { + const signedOut = await new Browser(app, ADMIN, ISSUER).post('/admin/sign-out', {}); + + expect(signedOut.statusCode).toBe(303); + expect(signedOut.headers.location).toBe('/admin/signed-out'); + }); + + it('refuses a POST with a missing or foreign Origin, including the issuer', async () => { + const browser = new Browser(app, ADMIN, ISSUER); + await signIn(browser, '/admin/', ALICE); + + for (const headers of [{}, { origin: 'https://evil.example.org' }, { origin: ISSUER }]) { + expect((await browser.post('/admin/sign-out', {}, headers)).statusCode).toBe(403); + } + expect((await browser.get('/admin/')).statusCode).toBe(200); + }); + + it('is only on the admin origin', async () => { + const api = new Browser(app, ISSUER); + for (const path of ['/admin/', '/admin/admin.css', '/admin/callback']) { + expect((await api.get(path)).statusCode).toBe(404); + } + expect((await api.get('/api/version')).statusCode).toBe(200); + expect((await new Browser(app, ADMIN).get('/api/version')).statusCode).toBe(404); + }); + + it('allows only our own scripts and styles, and no framing', async () => { + const page = await new Browser(app, ADMIN, ISSUER).get('/admin/'); + + const csp = page.headers['content-security-policy'] as string; + expect(csp).toContain("script-src 'self'"); + expect(csp).toContain("style-src 'self'"); + expect(csp).toContain("frame-ancestors 'none'"); + }); + + it('serves the stylesheet to anyone, and links it from every page', async () => { + const stylesheet = await new Browser(app, ADMIN, ISSUER).get('/admin/admin.css'); + expect(stylesheet.statusCode).toBe(200); + expect(stylesheet.headers['content-type']).toMatch(/^text\/css/); + + const link = ''; + expect((await signIn(new Browser(app, ADMIN, ISSUER), '/admin/', ALICE)).body).toContain(link); + expect((await signIn(new Browser(app, ADMIN, ISSUER), '/admin/', MALLORY)).body).toContain(link); + }); +}); diff --git a/src/admin/sign-in.ts b/src/admin/sign-in.ts new file mode 100644 index 0000000..0fa477c --- /dev/null +++ b/src/admin/sign-in.ts @@ -0,0 +1,59 @@ +import type { FastifyInstance } from 'fastify'; +import { buildEndSessionUrl, Configuration, customFetch } from 'openid-client'; +import type { AuthConfig } from '../configuration.ts'; +import { routes } from '../oidc/provider.ts'; +import { createUpstream, type UpstreamChecks } from '../oidc/upstream.ts'; +import { adminClient, adminPostLogoutRedirectUri, adminRedirectUri } from './client.ts'; + +/** What the callback checks the provider's response against, and where it sends the browser after */ +export type SignInTransaction = UpstreamChecks & { returnTo: string }; + +/** Signs admins in at our own provider, reaching its token endpoint in-process rather than through our public URL */ +export const createSignIn = (fastify: FastifyInstance, auth: AuthConfig, adminOrigin: string) => { + const { issuer } = auth; + const client = adminClient(auth, adminOrigin); + const configuration = new Configuration( + { + issuer, + authorization_endpoint: `${issuer}${routes.authorization}`, + token_endpoint: `${issuer}${routes.token}`, + end_session_endpoint: `${issuer}${routes.end_session}`, + }, + client.client_id, + client.client_secret, + ); + configuration[customFetch] = async (url, { method, headers, body }) => { + const { pathname, search } = new URL(url); + const response = await fastify.inject({ + method: method as 'POST', + url: `${pathname}${search}`, + headers, + ...(body === undefined ? {} : { payload: String(body) }), + }); + const responseHeaders = new Headers(); + for (const [name, value] of Object.entries(response.headers)) { + for (const each of [value ?? []].flat()) { + responseHeaders.append(name, String(each)); + } + } + return new Response(response.body, { status: response.statusCode, headers: responseHeaders }); + }; + const flow = createUpstream({ configuration, redirectUri: adminRedirectUri(adminOrigin), scope: client.scope }); + + return { + async start(returnTo: string) { + const checks = flow.newChecks(); + return { url: await flow.authorizationUrl(checks), transaction: { ...checks, returnTo } satisfies SignInTransaction }; + }, + + /** Who signed in, once the callback's query checks out against the transaction */ + async finish(query: Record, transaction: SignInTransaction) { + const { claims, idToken } = await flow.tokens(query, transaction); + return { sub: claims.sub, name: typeof claims.name === 'string' && claims.name ? claims.name : undefined, idToken }; + }, + + signOutUrl(idToken: string) { + return buildEndSessionUrl(configuration, { id_token_hint: idToken, post_logout_redirect_uri: adminPostLogoutRedirectUri(adminOrigin) }); + }, + }; +}; diff --git a/src/admin/views/action-form.eta b/src/admin/views/action-form.eta new file mode 100644 index 0000000..d0f53c1 --- /dev/null +++ b/src/admin/views/action-form.eta @@ -0,0 +1 @@ + data-confirm="<%= it.form.confirm %>"<% } %>><% if (it.form.crateId) { %><% } %><% if (it.form.force) { %><% } %> diff --git a/src/admin/views/crate-list.eta b/src/admin/views/crate-list.eta new file mode 100644 index 0000000..0a030af --- /dev/null +++ b/src/admin/views/crate-list.eta @@ -0,0 +1,18 @@ +
      +<% for (const crate of it.crates) { %> +
    • + <%= crate.label %> <%= crate.id %> +<% for (const state of crate.states) { %> + <%= state.label %>: <%= state.value %> +<% } %> + +<% for (const form of crate.actions) { %> + <%~ include('./action-form', { form }) %> +<% } %> + +<% if (crate.children.length > 0) { %> + <%~ include('./crate-list', { crates: crate.children }) %> +<% } %> +
    • +<% } %> +
    diff --git a/src/admin/views/crates.eta b/src/admin/views/crates.eta new file mode 100644 index 0000000..878851d --- /dev/null +++ b/src/admin/views/crates.eta @@ -0,0 +1,11 @@ +<% if (it.crates.length === 0) { %> +

    The repository has no crates.

    +<% } else { %> +

    +<% for (const form of it.actions) { %> + <%~ include('./action-form', { form }) %> +<% } %> +

    +

    +
    <%~ include('./crate-list', { crates: it.crates }) %>
    +<% } %> diff --git a/src/admin/views/layout.eta b/src/admin/views/layout.eta new file mode 100644 index 0000000..0b8dd00 --- /dev/null +++ b/src/admin/views/layout.eta @@ -0,0 +1,36 @@ + + + + + + <%= it.title %> · LDaCA admin + + +<% if (it.refresh) { %> + +<% } %> + + +
    +
    +

    + LDaCA + Admin +

    +<% if (it.user) { %> +
    +

    Signed in as <%= it.user %>

    +
    +
    +<% } %> +
    +
    +
    +

    <%= it.title %>

    +<% if (it.flash) { %> +

    <%= it.flash.message %>

    +<% } %> + <%~ it.body %> +
    + + diff --git a/src/admin/views/not-an-admin.eta b/src/admin/views/not-an-admin.eta new file mode 100644 index 0000000..3c7714a --- /dev/null +++ b/src/admin/views/not-an-admin.eta @@ -0,0 +1,5 @@ +
    +

    You are signed in, but you are not an admin.

    +

    To be given access, ask an operator to add this sub to ADMIN_USERS:

    +

    <%= it.sub %>

    +
    diff --git a/src/admin/views/signed-out.eta b/src/admin/views/signed-out.eta new file mode 100644 index 0000000..cc09a7d --- /dev/null +++ b/src/admin/views/signed-out.eta @@ -0,0 +1 @@ +

    You have signed out. Sign in

    diff --git a/src/app.test.ts b/src/app.test.ts index bacc6f5..2e11c28 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -70,4 +70,14 @@ describe('buildApp', () => { expect((await app.inject({ method: 'POST', url: `${config.prefix}/token` })).statusCode).toBe(404); await app.close(); }); + + it('has no admin API', async () => { + const app = buildApp({ repository, search }); + const headers = { authorization: 'Bearer 1234-1234-1234-1234' }; + + for (const method of ['GET', 'POST', 'DELETE'] as const) { + expect((await app.inject({ method, url: `${config.prefix}/admin/index/`, headers })).statusCode, method).toBe(404); + } + await app.close(); + }); }); diff --git a/src/app.ts b/src/app.ts index 760b1e2..6a7e2a9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,25 +4,22 @@ import type { SearchBackend } from 'arocapi'; import arocapi, { AllVisibleEntityFilter, AllVisibleSearchFilter, errorHandler, notFoundHandler } from 'arocapi'; import { EntityMetaMetadata } from 'arocapi/storage/entity-meta'; import Fastify, { type FastifyPluginAsync } from 'fastify'; -import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; import pkg from '../package.json' with { type: 'json' }; +import { ADMIN_PATH, adminClient } from './admin/client.ts'; +import { adminPages } from './admin/plugin.ts'; import { access, isAuthenticated } from './auth.ts'; import { config } from './configuration.ts'; import { logger } from './logger.ts'; import { oidcProvider } from './oidc/provider.ts'; import { prisma } from './prisma.ts'; import type { Repository } from './repository.ts'; -import { admin } from './routes/admin.ts'; type AppDeps = { repository: Repository; search: SearchBackend; }; -const ldacapi: FastifyPluginAsync<{ repository: Repository }> = async (fastify, { repository }) => { - fastify.setValidatorCompiler(validatorCompiler); - fastify.setSerializerCompiler(serializerCompiler); - fastify.register(admin, { prefix: '/admin', repository }); +const ldacapi: FastifyPluginAsync = async (fastify) => { fastify.get('/version', async () => ({ version: pkg.version })); }; @@ -62,12 +59,30 @@ export function buildApp({ repository, search }: AppDeps) { ], storage: { metadata: EntityMetaMetadata(), content: repository.storage().content }, }); - api.register(ldacapi, { repository }); + api.register(ldacapi); }, { prefix }, ); - if (config.auth) { - fastify.register(oidcProvider, { auth: config.auth }); + const { auth, adminOrigin } = config; + if (auth) { + const clients = adminOrigin ? [...auth.clients, adminClient(auth, adminOrigin)] : auth.clients; + fastify.register(oidcProvider, { auth: { ...auth, clients } }); + } + if (auth && adminOrigin) { + fastify.register(adminPages, { prefix: ADMIN_PATH, auth, origin: adminOrigin, adminUsers: config.adminUsers, repository }); + // Nothing but the admin pages answers on the admin origin, so no crate file can run script there + const adminHost = new URL(adminOrigin).host; + fastify.addHook('onRequest', async (request, reply) => { + if (request.headers.host === adminHost && request.routeOptions.url && !request.routeOptions.url.startsWith(ADMIN_PATH)) { + return reply.callNotFound(); + } + }); + } else { + fastify.log.warn( + auth + ? 'The admin pages are off because ADMIN_ORIGIN is not set' + : 'The admin pages are off because sign-in is off; set OIDC_UPSTREAM_ISSUER to turn them on', + ); } return fastify; diff --git a/src/configuration.test.ts b/src/configuration.test.ts index 4475249..e356aa9 100644 --- a/src/configuration.test.ts +++ b/src/configuration.test.ts @@ -18,7 +18,6 @@ const PRODUCTION = { NODE_ENV: 'production', DATABASE_URL: 'postgresql://prod/ldacapi', OPENSEARCH_URL: 'http://search:9200', - TOKEN_ADMIN: 'secret', OCFL_ROOT: '/data/ocfl', }; @@ -42,10 +41,9 @@ describe('loadConfig', () => { }); it('reads values from the environment', () => { - const config = loadConfig({ LDACAPI_PORT: '9000', TOKEN_ADMIN: 'secret', ENTITY_INDEX: 'other' }); + const config = loadConfig({ LDACAPI_PORT: '9000', ENTITY_INDEX: 'other' }); expect(config.port).toBe(9000); - expect(config.tokenAdmin).toBe('secret'); expect(config.search.entityIndex).toBe('other'); }); @@ -55,11 +53,12 @@ describe('loadConfig', () => { }); it('treats empty values as unset', () => { - expect(loadConfig({ TOKEN_ADMIN: '' }).tokenAdmin).toBe('1234-1234-1234-1234'); + expect(loadConfig({ OPENSEARCH_URL: '' }).opensearchUrl).toBe('http://localhost:9200'); }); - it('requires connection details, the admin token and OCFL root in production', () => { - expect(() => loadConfig({ NODE_ENV: 'production' })).toThrow(/DATABASE_URL[\s\S]*OPENSEARCH_URL[\s\S]*TOKEN_ADMIN[\s\S]*OCFL_ROOT/); + it('requires connection details and OCFL root in production', () => { + expect(() => loadConfig({ NODE_ENV: 'production' })).toThrow(/DATABASE_URL[\s\S]*OPENSEARCH_URL[\s\S]*OCFL_ROOT/); + expect(() => loadConfig({ NODE_ENV: 'production' })).not.toThrow(/TOKEN_ADMIN/); }); it('starts in production once the required values are set', () => { @@ -210,6 +209,34 @@ describe('loadConfig', () => { }); }); + it('reads admins from ADMIN_USERS as a comma-separated list, nobody when unset', () => { + expect(loadConfig({ ADMIN_USERS: ' alice@upstream , ,bob@upstream ' }).adminUsers).toEqual(['alice@upstream', 'bob@upstream']); + expect(loadConfig({}).adminUsers).toEqual([]); + }); + + describe('ADMIN_ORIGIN', () => { + const signIn = { ...UPSTREAM, ...KEYS, OIDC_ISSUER: 'https://api.example.org' }; + + it('defaults to admin.localhost outside production', () => { + expect(loadConfig({}).adminOrigin).toBe('http://admin.localhost:8080'); + expect(loadConfig({ NODE_ENV: 'test' }).adminOrigin).toBe('http://admin.localhost:8080'); + }); + + it('is unset by default in production, and read as an origin', () => { + expect(loadConfig({ ...PRODUCTION, ...signIn }).adminOrigin).toBeUndefined(); + expect(loadConfig({ ...PRODUCTION, ...signIn, ADMIN_ORIGIN: 'https://admin.example.org/' }).adminOrigin).toBe('https://admin.example.org'); + }); + + it('rejects an ADMIN_ORIGIN that is not an origin, or not https in production', () => { + expect(() => loadConfig({ ADMIN_ORIGIN: 'https://admin.example.org/admin' })).toThrow(/origin[\s\S]*ADMIN_ORIGIN/); + expect(() => loadConfig({ ...PRODUCTION, ...signIn, ADMIN_ORIGIN: 'http://admin.example.org' })).toThrow(/https[\s\S]*ADMIN_ORIGIN/); + }); + + it('rejects an ADMIN_ORIGIN that is the issuer’s origin', () => { + expect(() => loadConfig({ ...signIn, ADMIN_ORIGIN: 'https://api.example.org' })).toThrow(/OIDC_ISSUER[\s\S]*ADMIN_ORIGIN/); + }); + }); + it('rejects an unknown NODE_ENV', () => { expect(() => loadConfig({ NODE_ENV: 'staging' })).toThrow(); }); diff --git a/src/configuration.ts b/src/configuration.ts index 57f445e..b86f750 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -53,10 +53,14 @@ const privateRsaJwk = z.looseObject({ kty: z.literal('RSA'), kid: z.string().min const signingKeys = json.pipe(z.object({ keys: z.array(privateRsaJwk).min(1, 'Needs at least one key') })); -const cookieKeys = z - .string() - .transform((value) => value.split(',').map((key) => key.trim())) - .pipe(z.array(z.string().min(32, 'Each key must be at least 32 characters'))); +const commaList = z.string().transform((value) => + value + .split(',') + .map((item) => item.trim()) + .filter(Boolean), +); + +const cookieKeys = commaList.pipe(z.array(z.string().min(32, 'Each key must be at least 32 characters')).min(1, 'Needs at least one key')); const REQUIRED_WITH_UPSTREAM = 'Required when OIDC_UPSTREAM_ISSUER is set'; @@ -97,7 +101,6 @@ export function loadConfig(processEnv: Env) { .object({ DATABASE_URL: devDefault(`postgresql://ldacapi:ldacapi@localhost:5432/ldacapi${nodeEnv === 'test' ? '_test' : ''}`), OPENSEARCH_URL: devDefault('http://localhost:9200'), - TOKEN_ADMIN: devDefault('1234-1234-1234-1234'), OCFL_ROOT: devDefault('storage/ocfl'), OCFL_SCRATCH: z.string().optional(), LDACAPI_PORT: z.coerce.number().int().default(8080), @@ -118,6 +121,8 @@ export function loadConfig(processEnv: Env) { OIDC_UPSTREAM_CLIENT_SECRET: z.string().optional(), OIDC_SIGNING_KEYS: signingKeys.optional(), OIDC_COOKIE_KEYS: cookieKeys.optional(), + ADMIN_ORIGIN: nodeEnv === 'production' ? origin.optional() : origin.default('http://admin.localhost:8080'), + ADMIN_USERS: commaList.default([]), }) .transform((env, ctx) => { const { @@ -141,6 +146,10 @@ export function loadConfig(processEnv: Env) { return z.NEVER; } const { data } = required; + if (rest.ADMIN_ORIGIN === data.OIDC_ISSUER) { + ctx.addIssue({ code: 'custom', path: ['ADMIN_ORIGIN'], message: 'Must differ from OIDC_ISSUER, so the admin pages are on their own origin' }); + return z.NEVER; + } const auth = { issuer: data.OIDC_ISSUER, audience: `${data.OIDC_ISSUER}${prefix}`, @@ -166,7 +175,6 @@ export function loadConfig(processEnv: Env) { host: env.LDACAPI_HOST, logLevel: env.LOG_LEVEL, maxParamLength: env.LDACAPI_MAX_PARAM_LENGTH, - tokenAdmin: env.TOKEN_ADMIN, ocfl: { root: resolve(env.OCFL_ROOT), scratch: resolve(env.OCFL_SCRATCH ?? join(dirname(env.OCFL_ROOT), 'scratch')), @@ -182,6 +190,8 @@ export function loadConfig(processEnv: Env) { }, enrollmentUrl: env.ENROLLMENT_URL, auth: env.auth, + adminOrigin: env.ADMIN_ORIGIN, + adminUsers: env.ADMIN_USERS, indexType, search: { entityIndex: env.ENTITY_INDEX }, }; diff --git a/src/ocfl.ts b/src/ocfl.ts index aad595f..c07ec1c 100644 --- a/src/ocfl.ts +++ b/src/ocfl.ts @@ -10,13 +10,13 @@ import type { CrateObject, Indexer } from './indexer/indexer.ts'; import { createSearchBackend, SearchIndexer } from './indexer/search.ts'; import { StructuralIndexer } from './indexer/structural.ts'; import { logger } from './logger.ts'; -import { State } from './repository.ts'; +import { type IndexState, State } from './repository.ts'; const log = logger.child({ module: 'ocfl' }); const { defaultLicense, defaultMetadataLicense } = config; -const stateCache: { [key: string]: { [key: string]: (typeof State)[keyof typeof State] | undefined } } = {}; +const stateCache: { [key: string]: { [key: string]: IndexState | undefined } } = {}; let INDEXER: { [key: string]: Indexer }; let repository: ReturnType; let backend: StorageBackend; @@ -96,14 +96,14 @@ function wrap(ocflObject: OcflObject): CrateObject { }; } -function _setState(crateId: string, types: string | string[], state?: (typeof State)[keyof typeof State]) { +function _setState(crateId: string, types: string | string[], state?: IndexState) { stateCache[crateId] = stateCache[crateId] || {}; for (const type of ([] as string[]).concat(types)) { stateCache[crateId][type] = state; } } -function setState(crateId: string | undefined, types: string | string[], state?: (typeof State)[keyof typeof State]) { +function setState(crateId: string | undefined, types: string | string[], state?: IndexState) { if (crateId) { _setState(crateId, types, state); } else { @@ -127,14 +127,14 @@ export async function getState(crateId: string, type?: string) { } } } - return type ? { [type]: stateCache[crateId]?.[type] } : (stateCache[crateId] ?? {}); + // A copy, as indexing goes on changing the cache after this returns + return type ? { [type]: stateCache[crateId]?.[type] } : { ...stateCache[crateId] }; } -async function indexObject(ocflObject: OcflObject, types: string[], force?: boolean) { - if (!ocflObject) { - return; - } +async function indexObject(crateId: string, types: string[], force?: boolean) { try { + const ocflObject = repository.object(crateId); + await ocflObject.load(); log.debug({ crateId: ocflObject.id }, 'Found OCFL object'); const jsonContent = await ocflObject.getFile({ logicalPath: 'ro-crate-metadata.json' }).text(); const rawCrate = JSON.parse(jsonContent); @@ -157,24 +157,33 @@ async function indexObject(ocflObject: OcflObject, types: string[], force?: bool } } } catch (error) { - log.error({ err: error, crateId: ocflObject.id }, 'Error reading object'); + setState(crateId, types); + log.error({ err: error, crateId }, 'Error reading object'); + } +} + +async function idsMatching(pattern?: RegExp) { + const ids: string[] = []; + for await (const ocflObject of repository) { + await ocflObject.load(); + if (!pattern || ocflObject.id.match(pattern)) { + ids.push(ocflObject.id); + } } + return ids; } -export async function createIndex(crateId?: string | RegExp, type?: string | string[], force?: boolean) { +/** Indexes `crateIds`, the crates matching it, or every crate, marking them all queued before indexing any */ +export async function createIndex(crateIds?: string | string[] | RegExp, type?: string | string[], force?: boolean) { log.debug('Indexing started'); const types = type ? ([] as string[]).concat(type) : Object.keys(INDEXER); - if (typeof crateId === 'string') { - await indexObject(repository.object(crateId), types, force); - } else { - for await (const ocflObject of repository) { - await ocflObject.load(); - if (crateId && !ocflObject.id.match(crateId)) { - continue; - } - setState(ocflObject.id, types, State.QUEUED); - await indexObject(ocflObject, types, force); - } + // Given ids are queued before the first await, so callers see them in progress at once + const ids = typeof crateIds === 'string' || Array.isArray(crateIds) ? [crateIds].flat() : await idsMatching(crateIds); + for (const id of ids) { + setState(id, types, State.QUEUED); + } + for (const id of ids) { + await indexObject(id, types, force); } log.debug('Indexing finished'); } diff --git a/src/oidc/provider.ts b/src/oidc/provider.ts index ba664cf..44f8bf5 100644 --- a/src/oidc/provider.ts +++ b/src/oidc/provider.ts @@ -8,7 +8,7 @@ import { findAccount, saveUser } from './users.ts'; const log = logger.child({ module: 'oidc' }); -const routes = { +export const routes = { authorization: '/oauth/authorize', token: '/oauth/token', jwks: '/oauth/jwks', @@ -206,7 +206,7 @@ export const oidcProvider: FastifyPluginAsync<{ auth: AuthConfig }> = async (fas let result: InteractionResults; try { - const user = await saveUser(await upstream.claims(request.query, checks)); + const user = await saveUser((await upstream.tokens(request.query, checks)).claims); result = { login: { accountId: user.sub } }; } catch (err) { log.warn({ err }, 'Sign-in at the upstream provider failed'); diff --git a/src/oidc/upstream.test.ts b/src/oidc/upstream.test.ts index 0f7f7f7..e1ba708 100644 --- a/src/oidc/upstream.test.ts +++ b/src/oidc/upstream.test.ts @@ -23,7 +23,7 @@ describe('authenticating at the upstream token endpoint', () => { const checks = client.newChecks('state'); const callback = new URL(upstream?.signIn((await client.authorizationUrl(checks)).toString(), ALICE) ?? ''); - const claims = await client.claims(Object.fromEntries(callback.searchParams), checks); + const { claims } = await client.tokens(Object.fromEntries(callback.searchParams), checks); expect(claims).toMatchObject(ALICE); }); diff --git a/src/oidc/upstream.ts b/src/oidc/upstream.ts index 5ed5245..17a6e8d 100644 --- a/src/oidc/upstream.ts +++ b/src/oidc/upstream.ts @@ -10,14 +10,28 @@ import { type IDToken, randomNonce, randomPKCECodeVerifier, + randomState, type ServerMetadata, } from 'openid-client'; -type UpstreamOptions = { issuer: string; clientId: string; clientSecret: string; redirectUri: string; scope: string }; +type Discovered = { issuer: string; clientId: string; clientSecret: string }; + +/** A provider found by discovery at `issuer`, or one already configured */ +type UpstreamOptions = (Discovered | { configuration: Configuration }) & { redirectUri: string; scope: string }; /** What the callback needs to check the upstream provider's response against */ export type UpstreamChecks = { state: string; codeVerifier: string; nonce: string }; +// Configuration only allows an http issuer outside production +const insecure = (issuer: string) => (new URL(issuer).protocol === 'http:' ? [allowInsecureRequests] : []); + +const allowInsecure = (configuration: Configuration) => { + for (const apply of insecure(configuration.serverMetadata().issuer)) { + apply(configuration); + } + return configuration; +}; + // Prefers Basic, which OIDC makes the default when the provider lists no methods const clientAuth = ({ token_endpoint_auth_methods_supported: supported }: ServerMetadata, clientSecret: string) => { if (!supported || supported.includes('client_secret_basic')) { @@ -29,27 +43,31 @@ const clientAuth = ({ token_endpoint_auth_methods_supported: supported }: Server throw new Error(`The upstream provider supports none of client_secret_basic or client_secret_post at its token endpoint, only: ${supported.join(', ')}`); }; -/** Signs users in at the upstream provider with the authorisation code flow and PKCE */ -export const createUpstream = ({ issuer, clientId, clientSecret, redirectUri, scope }: UpstreamOptions) => { - let discovered: Promise | undefined; - const configuration = () => { - const server = new URL(issuer); - // Configuration only allows an http issuer outside production - const execute = server.protocol === 'http:' ? [allowInsecureRequests] : []; - discovered ??= discovery(server, clientId, undefined, undefined, { execute }) - .then((found) => { - const configured = new Configuration(found.serverMetadata(), clientId, undefined, clientAuth(found.serverMetadata(), clientSecret)); - for (const apply of execute) { - apply(configured); - } - return configured; +const discovered = ({ issuer, clientId, clientSecret }: Discovered) => { + let found: Promise | undefined; + return () => { + found ??= discovery(new URL(issuer), clientId, undefined, undefined, { execute: insecure(issuer) }) + .then((discoveredConfiguration) => { + const metadata = discoveredConfiguration.serverMetadata(); + return allowInsecure(new Configuration(metadata, clientId, undefined, clientAuth(metadata, clientSecret))); }) .catch((error: unknown) => { - discovered = undefined; + found = undefined; throw error; }); - return discovered; + return found; }; +}; + +const given = (configuration: Configuration) => { + const ready = Promise.resolve(allowInsecure(configuration)); + return () => ready; +}; + +/** Signs users in at a provider with the authorisation code flow and PKCE */ +export const createUpstream = (options: UpstreamOptions) => { + const { redirectUri, scope } = options; + const configuration = 'configuration' in options ? given(options.configuration) : discovered(options); return { /** Fetches the upstream discovery document, which sign-ins then reuse; a failure is retried at the next sign-in */ @@ -57,7 +75,7 @@ export const createUpstream = ({ issuer, clientId, clientSecret, redirectUri, sc await configuration(); }, - newChecks(state: string): UpstreamChecks { + newChecks(state = randomState()): UpstreamChecks { return { state, codeVerifier: randomPKCECodeVerifier(), nonce: randomNonce() }; }, @@ -72,8 +90,8 @@ export const createUpstream = ({ issuer, clientId, clientSecret, redirectUri, sc }); }, - /** Exchanges the code in the callback's query for the user's validated ID token claims */ - async claims(query: Record, checks: UpstreamChecks): Promise { + /** Exchanges the code in the callback's query for the user's ID token and its validated claims */ + async tokens(query: Record, checks: UpstreamChecks): Promise<{ claims: IDToken; idToken: string }> { const currentUrl = new URL(redirectUri); currentUrl.search = new URLSearchParams(query).toString(); const tokens = await authorizationCodeGrant(await configuration(), currentUrl, { @@ -82,7 +100,11 @@ export const createUpstream = ({ issuer, clientId, clientSecret, redirectUri, sc expectedNonce: checks.nonce, idTokenExpected: true, }); - return tokens.claims() as IDToken; + const claims = tokens.claims(); + if (!claims || !tokens.id_token) { + throw new Error('No ID token'); + } + return { claims, idToken: tokens.id_token }; }, }; }; diff --git a/src/repository.ts b/src/repository.ts index 7145b71..1fc72a5 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -7,15 +7,24 @@ export const State = { QUEUED: 'queued', } as const; +export type IndexState = (typeof State)[keyof typeof State]; + +/** Whether any index is in one of `values` */ +export const hasState = (state: Partial>, ...values: IndexState[]) => + Object.values(state).some((value) => value !== undefined && values.includes(value)); + +/** A crate in the repository, with the state of each index that isn't idle */ +export type RepositoryObject = { id: string; path: string; name?: string | undefined; state: Partial> }; + /** * Abstraction for repository operations, such as indexing and listing objects. This is used by the API routes to interact with the underlying data store, which can be an OCFL storage or a database. */ export interface Repository { init(): Promise; - getState(crateId: string, type?: string): Promise>; - createIndex(crateId?: string | RegExp, type?: string, force?: boolean): Promise; + getState(crateId: string, type?: string): Promise>; + createIndex(crateIds?: string | string[] | RegExp, type?: string, force?: boolean): Promise; deleteIndex(crateId?: string, type?: string | string[]): Promise; - objects(prefix?: string, refresh?: boolean): AsyncIterable<{ id: string; path: string; name: string }>; + objects(prefix?: string, refresh?: boolean): AsyncIterable; storage(): StorageBackend; } diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts deleted file mode 100644 index b2eb891..0000000 --- a/src/routes/admin.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import type { Client } from '@opensearch-project/opensearch'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; - -const BUNDLED = 'arcp://name,bundled'; -const PEOPLE = 'arcp://name,people'; - -let storage: string; -let app: ReturnType; -let opensearch: Client; -let config: typeof import('../configuration.ts').config; -let prisma: typeof import('../prisma.ts').prisma; -let repository: import('../repository.ts').Repository; - -const admin = (method: 'GET' | 'POST' | 'DELETE', path: string) => - app.inject({ method, url: `${config.prefix}/admin/index/${path}`, headers: { authorization: `Bearer ${config.tokenAdmin}` } }); - -const entityCount = (crateId: string) => prisma.entity.count({ where: { rootCollection: crateId } }); - -const searchCount = async (crateId: string) => { - await opensearch.indices.refresh({ index: config.search.entityIndex, ignore_unavailable: true }); - const { body } = await opensearch.count({ - index: config.search.entityIndex, - ignore_unavailable: true, - body: { query: { term: { rootCollection: crateId } } }, - }); - return body.count; -}; - -const waitUntilIndexed = (...crateIds: string[]) => - vi.waitFor( - async () => { - const objects: { id: string; state: unknown }[] = (await admin('GET', '')).json(); - const states = crateIds.map((id) => objects.find((object) => object.id === id)?.state); - expect(states).toEqual(crateIds.map(() => ({ structural: 'indexed', search: 'indexed' }))); - }, - { timeout: 10_000, interval: 100 }, - ); - -beforeAll(async () => { - storage = await mkdtemp(join(tmpdir(), 'ldacapi-admin-')); - process.env.OCFL_ROOT = join(storage, 'ocfl'); - execFileSync('node', ['scripts/seed.ts'], { env: process.env, stdio: 'ignore' }); - - // Imported after OCFL_ROOT is set, as configuration is read on import - ({ config } = await import('../configuration.ts')); - ({ prisma } = await import('../prisma.ts')); - const { Client } = await import('@opensearch-project/opensearch'); - const { createSearchBackend } = await import('../indexer/search.ts'); - const { initRepository } = await import('../repository.ts'); - const { buildApp } = await import('../app.ts'); - - opensearch = new Client({ node: config.opensearchUrl }); - const search = createSearchBackend(opensearch); - repository = await initRepository('ocfl', { opensearchClient: opensearch, search }); - app = buildApp({ repository, search }); -}); - -beforeEach(async () => { - await repository.deleteIndex(); -}); - -afterAll(async () => { - await app.close(); - await opensearch.close(); - await prisma.$disconnect(); - await rm(storage, { recursive: true, force: true }); -}); - -describe('admin indexing', () => { - it('rejects requests without the admin token', async () => { - const response = await app.inject({ method: 'POST', url: `${config.prefix}/admin/index/${encodeURIComponent(BUNDLED)}` }); - - expect(response.statusCode).toBe(401); - }); - - it('indexes a single object', async () => { - const response = await admin('POST', encodeURIComponent(BUNDLED)); - - expect(response.statusCode).toBe(202); - await waitUntilIndexed(BUNDLED); - expect(await entityCount(PEOPLE)).toBe(0); - expect(await searchCount(PEOPLE)).toBe(0); - }); - - it('indexes all objects', async () => { - const response = await admin('POST', ''); - - expect(response.statusCode).toBe(202); - await waitUntilIndexed(BUNDLED, PEOPLE); - }); - - it('deletes the index of a single object', async () => { - await admin('POST', ''); - await waitUntilIndexed(BUNDLED, PEOPLE); - - await admin('DELETE', encodeURIComponent(BUNDLED)); - - await vi.waitFor(async () => { - expect(await entityCount(BUNDLED)).toBe(0); - expect(await searchCount(BUNDLED)).toBe(0); - }); - expect(await entityCount(PEOPLE)).toBeGreaterThan(0); - expect(await searchCount(PEOPLE)).toBeGreaterThan(0); - }); -}); diff --git a/src/routes/admin.ts b/src/routes/admin.ts deleted file mode 100644 index b0235c3..0000000 --- a/src/routes/admin.ts +++ /dev/null @@ -1,100 +0,0 @@ -import bearerAuthPlugin from '@fastify/bearer-auth'; -import type { FastifyPluginAsync } from 'fastify'; -import type { ZodTypeProvider } from 'fastify-type-provider-zod'; -import * as z from 'zod'; -//import { getIndexerState, createIndex, deleteIndex } from '../ocfl.ts'; -import { config } from '../configuration.ts'; -import { type Repository, State } from '../repository.ts'; - -export const admin: FastifyPluginAsync<{ prefix: string; repository: Repository }> = async (fastify, opts) => { - const repo = opts.repository; - const app = fastify.withTypeProvider(); - app.register(bearerAuthPlugin, { keys: [config.tokenAdmin] }); - - app.get('/repository/', async (_request, reply) => reply.redirect('index/', 301)); - app.get('/repository', async (_request, reply) => reply.redirect('index/', 301)); - - app.get( - '/index/:crateId?', - { - schema: { - summary: 'Get the state of the indexes for a given crate or all crates if no crateId is specified.', - params: z.object({ - crateId: z.string().optional(), - }), - querystring: z.object({ - refresh: z.coerce.boolean().optional(), - }), - }, - }, - async (request, reply) => { - const { crateId } = request.params; - const { refresh } = request.query; - const objects = []; - for await (const repoObject of repo.objects(crateId, refresh)) { - objects.push(repoObject); - } - if (objects.length > 0) { - return reply.send(objects); - } else { - return reply.notFound('Index not found'); - } - }, - ); - - app.post('/index', async (_request, reply) => reply.redirect('index/', 301)); - app.post( - '/index/:crateId/:type?', - { - schema: { - summary: 'Index all creates or a specified crate from the OCFL repository.', - description: 'If the index already exists, it will not be re-indexed unless the "force" query parameter is set to true.', - params: z.object({ - crateId: z.string().optional(), - type: z.string().optional(), - }), - querystring: z.object({ force: z.string().optional() }), - }, - }, - async (request, reply) => { - const { type, crateId } = request.params; - const force = request.query.force != null; - const state = crateId ? await repo.getState(crateId, type) : {}; - if (state) { - if (type && state[type] === State.INDEXED && !force) { - throw fastify.httpErrors.conflict('Index already exists, no changes has been made.'); - } else if (type && state[type] === State.DELETING) { - throw fastify.httpErrors.conflict('Deleting is in progress.'); - } - repo - .createIndex(crateId ? new RegExp(`^${crateId}`) : undefined, type, force) - .catch((err) => request.log.error({ err, crateId, type }, 'Indexing failed')); - return reply.status(202).send(State.QUEUED); - } else { - throw fastify.httpErrors.notFound('Indexer does not exist'); - } - }, - ); - - app.delete('/index', async (_request, reply) => reply.redirect('index/*', 301)); - app.delete( - '/index/:crateId/:type?', - { - schema: { - summary: 'Delete the index of all creates or a specified crate.', - params: z.object({ - crateId: z.string().optional(), - type: z.string().optional(), - }), - }, - }, - async (request, reply) => { - let { crateId, type } = request.params; - if (crateId === 'all' || crateId === '*') { - crateId = undefined; - } - repo.deleteIndex(crateId, type).catch((err) => request.log.error({ err, crateId, type }, 'Deleting index failed')); - return reply.send({ message: 'Deleting' }); - }, - ); -}; diff --git a/test/admin.ts b/test/admin.ts new file mode 100644 index 0000000..c7951e5 --- /dev/null +++ b/test/admin.ts @@ -0,0 +1,81 @@ +import { afterAll, beforeAll, vi } from 'vitest'; +import { initRepository, type Repository } from '../src/repository.ts'; +import { buildTestApp, type TestApp } from './app.ts'; +import { startFakeUpstream, type UpstreamUser } from './fake-upstream.ts'; +import { Browser } from './oidc.ts'; + +export const ISSUER = 'http://localhost:8080'; +export const ADMIN = 'http://admin.localhost:8080'; +const UPSTREAM_CLIENT = { clientId: 'ldacapi', clientSecret: 'upstream-secret' }; +export const ALICE: UpstreamUser = { sub: 'alice@upstream', name: 'Alice' }; +export const PEOPLE = 'arcp://name,people'; + +/** What turns sign-in on, against the upstream provider at `upstreamIssuer` */ +const oidcEnv = (upstreamIssuer: string) => ({ + OIDC_ISSUER: ISSUER, + OIDC_CLIENTS: JSON.stringify([{ client_id: 'oni', redirect_uris: ['http://localhost:5173/callback'] }]), + OIDC_UPSTREAM_ISSUER: upstreamIssuer, + OIDC_UPSTREAM_CLIENT_ID: UPSTREAM_CLIENT.clientId, + OIDC_UPSTREAM_CLIENT_SECRET: UPSTREAM_CLIENT.clientSecret, +}); + +/** The OCFL repository at `OCFL_ROOT` */ +export const ocflRepository = (deps: Record) => initRepository('ocfl', deps); + +/** A repository of `crates`, each with its index state, recording what it is asked to do */ +export const repositoryOf = (crates: { id: string; name?: string; state: Record }[]) => { + const fake = { + storage: () => ({ content: {} }), + async *objects() { + for (const { id, name = id, state } of crates) { + yield { id, name, path: '', state }; + } + }, + getState: async (crateId: string) => crates.find(({ id }) => id === crateId)?.state ?? {}, + createIndex: vi.fn(async () => {}), + deleteIndex: vi.fn(async () => {}), + }; + return fake as unknown as Repository & Pick; +}; + +/** A repository with one crate, `PEOPLE`, whose search index is in `state` */ +export const repositoryIn = (state: string) => repositoryOf([{ id: PEOPLE, name: 'People', state: { structural: 'indexed', search: state } }]); + +/** Runs a fake upstream provider for a file's tests, then closes it and every app started with `start` */ +export const adminTestbed = () => { + let upstream: Awaited>; + const running: Awaited>[] = []; + + beforeAll(async () => { + upstream = await startFakeUpstream(UPSTREAM_CLIENT); + }); + + afterAll(async () => { + for (const { app, prisma } of running) { + await app.close(); + await prisma.$disconnect(); + } + await upstream.close(); + }); + + return { + get upstream() { + return upstream; + }, + + /** Builds the app with sign-in on, and `env` on top */ + async start(env: Record = {}, options?: Parameters[1]) { + const started = await buildTestApp({ ...oidcEnv(upstream.issuer), ...env }, options); + running.push(started); + return started; + }, + + /** Signs `user` in to the admin pages in a new browser, returning the page it lands on */ + async signInAsAdmin(app: TestApp, user: UpstreamUser) { + const browser = new Browser(app, ADMIN, ISSUER); + const toUpstream = await browser.follow('/admin/'); + const page = await browser.follow(upstream.signIn(toUpstream.headers.location as string, user)); + return { browser, page }; + }, + }; +}; diff --git a/test/app.ts b/test/app.ts index 742b8b7..762b168 100644 --- a/test/app.ts +++ b/test/app.ts @@ -1,9 +1,16 @@ import { Readable } from 'node:stream'; +import type { Client } from '@opensearch-project/opensearch'; import { vi } from 'vitest'; +import type { createSearchBackend } from '../src/indexer/search.ts'; import type { Repository } from '../src/repository.ts'; +type RepositoryDeps = { opensearchClient: Client; search: ReturnType }; + +/** A repository, or how to build one with the app's search */ +export type TestRepository = Repository | ((deps: RepositoryDeps) => Repository | Promise); + /** A repository with no content */ -export const repository = { storage: () => ({ content: {} }) } as unknown as Repository; +export const repository = { storage: () => ({ content: {} }), async *objects() {} } as unknown as Repository; /** A repository whose every file holds `content` */ export const repositoryWithContent = { @@ -13,7 +20,7 @@ export const repositoryWithContent = { } as unknown as Repository; /** Builds the app with `env` set while configuration is read, which happens on first import */ -export const buildTestApp = async (env: Record, options: { repository?: Repository } = {}) => { +export const buildTestApp = async (env: Record, options: { repository?: TestRepository } = {}) => { for (const [name, value] of Object.entries(env)) { vi.stubEnv(name, value); } @@ -23,10 +30,12 @@ export const buildTestApp = async (env: Record, options: { repos const { buildApp } = await import('../src/app.ts'); const { Client } = await import('@opensearch-project/opensearch'); const { createSearchBackend } = await import('../src/indexer/search.ts'); - const search = createSearchBackend(new Client({ node: config.opensearchUrl })); + const opensearchClient = new Client({ node: config.opensearchUrl }); + const search = createSearchBackend(opensearchClient); + const repo = typeof options.repository === 'function' ? await options.repository({ opensearchClient, search }) : (options.repository ?? repository); /** Builds another app, as another server process would */ - const build = () => buildApp({ repository: options.repository ?? repository, search }); - return { app: build(), build, config, prisma }; + const build = () => buildApp({ repository: repo, search }); + return { app: build(), build, config, prisma, repository: repo }; } finally { vi.unstubAllEnvs(); } diff --git a/test/global-setup.ts b/test/global-setup.ts index 71b6fdd..9128ef6 100644 --- a/test/global-setup.ts +++ b/test/global-setup.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; import { Client } from '@opensearch-project/opensearch'; import { PrismaPg } from '@prisma/adapter-pg'; import { loadConfig } from '../src/configuration.ts'; @@ -7,6 +8,11 @@ import { PrismaClient } from '../src/generated/prisma/client.ts'; // Each run gets its own database and index so runs never share or destroy each other's data export default async function setup() { + // The admin pages link the stylesheet; tests only need it to exist + if (!existsSync('src/admin/public/admin.css')) { + execFileSync('pnpm', ['build'], { stdio: 'ignore' }); + } + const config = loadConfig({ ...process.env, NODE_ENV: 'test' }); const suffix = `${process.pid}_${randomBytes(3).toString('hex')}`; diff --git a/test/oidc.ts b/test/oidc.ts index 2da9c7e..55c14c2 100644 --- a/test/oidc.ts +++ b/test/oidc.ts @@ -2,57 +2,95 @@ import type { LightMyRequestResponse } from 'fastify'; import { calculatePKCECodeChallenge, randomPKCECodeVerifier } from 'openid-client'; import type { TestApp } from './app.ts'; -/** A browser's cookies, sent with every request to the app */ +/** A browser's cookies, kept per host and sent with every request to the app at any of its origins */ export class Browser { - private readonly cookies = new Map(); + private readonly cookies = new Map>(); private readonly app: TestApp; + /** Where relative URLs go */ private readonly origin: string; + private readonly origins: Set; + /** Where the last `follow` ended */ + url = ''; - constructor(app: TestApp, origin: string) { + constructor(app: TestApp, origin: string, ...otherOrigins: string[]) { this.app = app; this.origin = origin; + this.origins = new Set([origin, ...otherOrigins]); } async get(url: string, app = this.app) { - const { pathname, search, host } = new URL(url, this.origin); - const response = await app.inject({ method: 'GET', url: `${pathname}${search}`, headers: { host, cookie: this.cookieHeader() } }); - this.store(response); + const target = new URL(url, this.origin); + const response = await app.inject({ + method: 'GET', + url: `${target.pathname}${target.search}`, + headers: { host: target.host, cookie: this.cookieHeader(target) }, + }); + this.store(target, response); return response; } - async post(url: string, form: Record) { - const { pathname, search, host } = new URL(url, this.origin); + /** Posts `form` with the `Origin` a browser sends from a page at `url`'s origin, unless `headers` say otherwise */ + async post(url: string, form: Record, headers?: Record) { + const target = new URL(url, this.origin); const response = await this.app.inject({ method: 'POST', - url: `${pathname}${search}`, - headers: { host, cookie: this.cookieHeader(), 'content-type': 'application/x-www-form-urlencoded' }, + url: `${target.pathname}${target.search}`, + headers: { + host: target.host, + cookie: this.cookieHeader(target), + 'content-type': 'application/x-www-form-urlencoded', + ...(headers ?? { origin: target.origin }), + }, payload: new URLSearchParams(form).toString(), }); - this.store(response); + this.store(target, response); return response; } - /** Follows redirects within the app, returning the first response that leaves it */ + /** Follows redirects within the app's origins, returning the first response that leaves them */ async follow(url: string) { - let response = await this.get(url); + this.url = url; + let current = new URL(url, this.origin); + let response = await this.get(current.toString()); let location = response.headers.location as string | undefined; - while (response.statusCode >= 300 && response.statusCode < 400 && location && new URL(location, this.origin).origin === this.origin) { - response = await this.get(location); + while (response.statusCode >= 300 && response.statusCode < 400 && location && this.origins.has(new URL(location, current).origin)) { + this.url = location; + current = new URL(location, current); + response = await this.get(current.toString()); location = response.headers.location as string | undefined; } return response; } - private cookieHeader() { - return [...this.cookies].map(([name, value]) => `${name}=${value}`).join('; '); + /** The cookie `name` for the host relative URLs go to */ + cookie(name: string) { + return this.jar(new URL(this.origin)).get(name); + } + + setCookie(name: string, value: string) { + this.jar(new URL(this.origin)).set(name, value); + } + + private jar({ hostname }: URL) { + let jar = this.cookies.get(hostname); + if (!jar) { + jar = new Map(); + this.cookies.set(hostname, jar); + } + return jar; + } + + private cookieHeader(url: URL) { + return [...this.jar(url)].map(([name, value]) => `${name}=${encodeURIComponent(value)}`).join('; '); } - private store(response: LightMyRequestResponse) { + private store(url: URL, response: LightMyRequestResponse) { + const jar = this.jar(url); for (const { name, value, expires, maxAge } of response.cookies as { name: string; value: string; expires?: Date; maxAge?: number }[]) { if (maxAge === 0 || (expires && expires.getTime() <= Date.now()) || !value) { - this.cookies.delete(name); + jar.delete(name); } else { - this.cookies.set(name, value); + jar.set(name, value); } } }