Editor API

The Rust backend behind the law editor: corpus reads, traject-scoped writes, tasks, and proxies to the pipeline and harvester-admin services.

The editor API is the HTTP server behind the law editor. It serves the compiled frontend, exposes the corpus over REST, and runs every write the editor makes. Writes always go to a traject: an editing session with its own members and its own corpus repository branch.

Overview

  • Language: Rust (Axum)
  • Location: packages/editor-api/
  • Port: 8000 (fixed in serve() in main.rs)
  • Database: PostgreSQL, shared with the pipeline. Sessions, accounts, trajects, favorites, notes, tasks and feature flags live there. Migrations come from the pipeline crate (regelrecht_pipeline::ensure_schema).

Authorization model

Every route sits in one router group, and each group carries one role gate. The roles are Keycloak realm roles: editor-reader, editor-writer, editor-admin, plus harvester-reader for the harvester proxy. Keycloak composite roles make a writer also a reader. See Auth and roles for the role model.

GateWhat it checks
PublicNothing. Reads the global corpus only.
ReaderSession carries editor-reader.
WriterSession carries editor-writer.
AdminSession carries editor-admin.
+ accountThe request also resolves an accounts row (account_middleware). Without a database this answers 503.
+ membershipThe handler checks traject membership on every request. Some actions are owner-only.

A missing session answers 401, a missing role 403. When OIDC is not configured, every role gate passes: that mode exists for local development only, and the server logs a warning at startup. In that mode there is no database pool, so every route that needs an account answers 503, and the GitHub OAuth routes are not mounted at all.

The login itself has a separate gate: OIDC_REQUIRED_ROLE is the minimum role needed to complete a login at all.

Endpoints

Derived from the router in packages/editor-api/src/main.rs (and github_oauth.rs for /auth/github/*). That file is the authority; this list is a map of it.

Service and session

MethodPathGate
GET/healthPublic
GET/auth/login, /auth/callback, /auth/logout, /auth/statusPublic (OIDC flow from regelrecht-auth)
GET/api/feature-flagsPublic
PUT/api/feature-flags/{key}Admin
GET/api/document-upload-formatsPublic

Any other path falls through to the single-page app in STATIC_DIR.

Global corpus (read-only)

All public. These serve the corpus as loaded at startup, without any traject overlay.

MethodPath
GET/api/sources
GET/api/corpus/laws
GET/api/corpus/laws/{law_id}
GET/api/corpus/laws/{law_id}/versions
GET/api/corpus/laws/{law_id}/outputs
GET/api/corpus/laws/{law_id}/implementors
GET/api/corpus/laws/{law_id}/scenarios
GET/api/corpus/laws/{law_id}/scenarios/{filename}
GET/api/corpus/laws/{law_id}/annotations
POST/api/corpus/reload (Admin)

Trajects

Reader + account for reads, Writer + account for changes. Membership is checked in the handler.

MethodPathNotes
GET/api/trajectsTrajects you are a member of
POST/api/trajectsCreator becomes owner
GET/api/trajects/{id}
PATCH, DELETE/api/trajects/{id}Owner only
POST/api/trajects/{id}/leave
POST/api/trajects/{id}/membersOwner only; adds a member or an invite
PATCH, DELETE/api/trajects/{id}/members/{account_id}Owner only
DELETE/api/trajects/{id}/invites/{email}Owner only
GET/api/trajects/{traject_ref}/integrityIntegrity report over the traject’s corpus repo
GET/api/trajects/{traject_ref}/sources
GET/api/trajects/{traject_ref}/favorites
PUT, DELETE/api/trajects/{traject_ref}/favorites/{law_id}

Member roles are owner and contributor.

Traject corpus

The traject-scoped view of the corpus: the global corpus with the traject’s own changes on top. Reads are Reader + account, writes Writer + account, and all of them check membership.

MethodPathGate
GET/api/trajects/{traject_ref}/corpus/lawsReader
POST/api/trajects/{traject_ref}/corpus/lawsWriter; create a new law (body cap 5 MiB)
POST/api/trajects/{traject_ref}/corpus/laws/uploadWriter; upload a law document for conversion (25 MiB)
GET/api/trajects/{traject_ref}/corpus/changed-lawsReader
GET, PUT/api/trajects/{traject_ref}/corpus/laws/{law_id}Reader / Writer (5 MiB)
GET.../corpus/laws/{law_id}/versionsReader
GET.../corpus/laws/{law_id}/outputsReader
GET.../corpus/laws/{law_id}/implementorsReader
GET.../corpus/laws/{law_id}/scenariosReader
GET, PUT, DELETE.../corpus/laws/{law_id}/scenarios/{filename}Reader / Writer (1 MiB)
GET, PUT.../corpus/laws/{law_id}/annotationsReader / Writer (1 MiB)
POST.../corpus/laws/{law_id}/promoteWriter; copy a law from the central corpus into the traject repo
POST.../corpus/laws/{law_id}/enrichWriter; request an enrichment task
POST/api/trajects/{traject_ref}/corpus/harvestWriter; harvest a law into the traject via a task
GET/api/trajects/{traject_ref}/corpus/documentsReader
POST.../corpus/documents/uploadWriter; PDF or Word, converted to markdown asynchronously (25 MiB)
GET.../corpus/documents/jobsReader; running conversions
DELETE.../corpus/documents/jobs/{job_id}Reader; any member may cancel a conversion
GET, PUT, DELETE.../corpus/documents/{*doc_path}Reader / Writer (1 MiB)

... stands for /api/trajects/{traject_ref}.

Tasks

Review tasks produced by asynchronous jobs, scoped to the account that requested them. Reader + account for reads, Writer + account for resolving.

MethodPath
GET/api/tasks
GET/api/tasks/{task_id}
POST/api/tasks/{task_id}/resolve
GET/api/tasks/jobs/{job_id}
POST/api/tasks/jobs/{job_id}/apply

Personal data

MethodPathGate
GET/api/favoritesReader
PUT, DELETE/api/favorites/{law_id}Writer
GET/api/user/settingsReader
PUT/api/user/settings/{key}Writer (4 KiB body)
GET/api/user/notes/{law_id}Reader + account
POST/api/user/notes/{law_id}Writer + account (128 KiB)
PUT, DELETE/api/user/notes/{law_id}/{note_id}Writer + account

Personal notes stay in Postgres and never reach a git repository.

Harvesting and the harvester-admin proxy

MethodPathGateForwards to
GET/api/harvest/statusPublicpipeline-api /harvest/status
GET/api/harvest/searchReaderpipeline-api /harvest/search
POST/api/harvestWriterpipeline-api /harvest
POST/api/harvest/batchWriterpipeline-api /harvest/batch
any/api/harvest-admin/{*rest}harvester-readerharvester-admin /api/{rest}

The pipeline proxy strips /api and forwards only content-type. The search endpoint is behind a login because it makes outbound requests to zoekservice.overheid.nl.

The harvester-admin proxy is how the editor’s Corpusinwinning section reaches the harvester-admin API; the browser never calls that API directly. It also forwards the session cookie. The two services share the Postgres session store, so harvester-admin resolves the same session and enforces its own harvester-writer and harvester-admin gates. The harvester-reader gate here is a first filter only.

Both proxies answer 503 when their upstream URL cannot be resolved.

Only mounted when OIDC is enabled. Writer + account, except the relay.

MethodPathNotes
GET/auth/github/loginStart the OAuth flow
GET/auth/github/callback
GET/auth/github/statusWhether a token is linked, and whether one is required
POST/auth/github/disconnect
GET/auth/github/relayPublic. Forwards the OAuth callback from the fixed callback host to the originating deployment, limited to GITHUB_OAUTH_ALLOWED_ORIGIN_SUFFIXES

When GITHUB_OAUTH_* is not configured the handlers answer 501. The github.user_oauth feature flag decides whether traject writes require the user’s own token.

Configuration

Service

From main.rs, config.rs and packages/shared/src/telemetry.rs.

VariableDefaultPurpose
STATIC_DIRstaticCompiled frontend to serve
DATABASE_URLnonePostgres connection. Required when OIDC is on; DATABASE_SERVER_FULL is accepted as fallback. Without OIDC no pool is opened.
HOSTNAMEset by the platformPod hostname. When it names a ZAD deployment, the proxy targets are derived from it and win over the two variables below.
PIPELINE_API_URLnonePipeline-api base URL when HOSTNAME does not yield one (http://<deployment>-pipelineapi:8000)
HARVEST_ADMIN_URLnoneHarvester-admin base URL when HOSTNAME does not yield one (http://<deployment>-harvester-admin:8000)
TASK_ENRICH_PROVIDERclaudeLLM provider for enrichment tasks requested from a traject. Must be one of the pipeline’s providers (opencode, claude); any other value stops startup.
RUST_LOGinfoLog filter
LOG_FORMATtextjson for one JSON object per log line
LOG_SPAN_EVENTSon for this serviceSpan-close timing lines; none turns them off

Login (OIDC)

From packages/auth/src/config.rs, shared with harvester-admin. Leaving OIDC_CLIENT_ID unset disables login entirely.

VariableDefaultPurpose
OIDC_CLIENT_IDnoneEnables OIDC when set
OIDC_CLIENT_SECRETnoneRequired once OIDC_CLIENT_ID is set
OIDC_DISCOVERY_URLnoneIssuer discovery URL; takes priority over the Keycloak pair
KEYCLOAK_BASE_URL, KEYCLOAK_REALMnoneBuild the issuer as <base>/realms/<realm> when there is no discovery URL
OIDC_REQUIRED_ROLEallowed-userMinimum realm role to log in. Set it to editor-reader; the default only exists for old deployments and logs a warning.
BASE_URLderived from request headersPublic origin used for redirect URLs. An http://localhost value also drops the Secure flag from the session cookie.

Corpus

From init_corpus in main.rs, packages/corpus/src/auth.rs and, for GITHUB_API_BASE, packages/github/src/client.rs.

VariableDefaultPurpose
CORPUS_REGISTRY_PATHcorpus-registry.yamlRegistry manifest with the corpus sources
CORPUS_REGISTRY_LOCAL_PATHcorpus-registry.local.yamlLocal overrides on the manifest
CORPUS_AUTH_FILEcorpus-auth.yamlPer-source tokens, file-backed
CORPUS_AUTH_<KEY>_TOKENnonePer-source token; checked before the auth file
CORPUS_GIT_TOKENnoneShared fallback token for manifest sources. Never used for a repository a user chose.
GITHUB_API_BASEhttps://api.github.comGitHub API base; a test seam, and usable for GitHub Enterprise

CORPUS_REPO_URL and CORPUS_BRANCH are not read by this service. They configure the corpus checkout of the pipeline workers; see Pipeline.

From github_oauth.rs. The first three are all-or-nothing: set all of them or none, or startup fails.

VariableDefaultPurpose
GITHUB_OAUTH_CLIENT_IDnoneOAuth app client id
GITHUB_OAUTH_CLIENT_SECRETnoneOAuth app secret
GITHUB_TOKEN_ENC_KEYnoneBase64 key that encrypts the stored user token
GITHUB_OAUTH_SCOPESrepo read:orgRequested scopes
GITHUB_USER_TOKEN_REQUIREDfalse1 or true forces a personal token for traject writes, regardless of the feature flag
GITHUB_OAUTH_CALLBACK_BASEnoneFixed callback host for relay mode (preview deployments)
GITHUB_OAUTH_ALLOWED_ORIGIN_SUFFIXESnoneComma-separated host suffixes the relay may forward to. Required when GITHUB_OAUTH_CALLBACK_BASE is set.

Running locally

just editor-api # cargo run -p regelrecht-editor-api, no login just editor-sso # same, with a real Keycloak login from .env.sso-local

STATIC_DIR defaults to static, relative to packages/. For frontend work, just dev editor starts this API with a Vite dev server in front of it, logged in against the central Keycloak (it needs .env.sso-local). just dev all adds a local harvester-admin on port 8001 and points HARVEST_ADMIN_URL at it; without it the Corpusinwinning screens answer 503. See Editor.

Further reading

RegelRecht

An exploration into transparent, executable legislation, and one of the three projects in the starting selection of the Nederlandse Digitale Dienst.

Links

GitHub repository
How it works
Stay informed
Roadmap (Dutch)
Documentation
Research

Contact

regelrecht@minbzk.nl

Part of

Nederlandse Digitale Dienst
Ministry of Economic Affairs and Climate Policy