Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_ORIGIN>/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=
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
82 changes: 55 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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/<crateId>

# 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/<crateId>/<index>?force'

# Delete the indexes of one crate
curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/admin/index/<crateId>
```

URL-encode crate ids, which are usually URIs.

## Configuration

Configuration comes only from environment variables, validated at startup
Expand All @@ -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` |
Expand All @@ -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

Expand Down Expand Up @@ -175,6 +157,52 @@ Discovery is at `<OIDC_ISSUER>/.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_ORIGIN>/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
Expand Down
1 change: 1 addition & 0 deletions biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
}
}
},
"css": { "parser": { "tailwindDirectives": true } },
"javascript": {
"formatter": {
"quoteStyle": "single"
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0001-own-oidc-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
28 changes: 28 additions & 0 deletions docs/adr/0002-server-rendered-admin-pages.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions knip.jsonc
Original file line number Diff line number Diff line change
@@ -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"]
}
14 changes: 11 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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"
}
Expand Down
Loading
Loading