Config, policy, branding, OAuth setup, password and setup wizard writes
used to answer 500 when the shared store couldn't be reached, and the
wizard's token check failed outside any handler. They answer 503 with
Retry-After now, through the helper that already turned a read-only
refusal into 409. The step-session-secret test's wizard mock gains
wizardSessionRefusal, which the setup routes now call.
Fixes from the review of the admin part:
- GET /api/admin/policy reads the policy and its version together; an
identity check waiting on the store let another replica's change pair
the old policy with the new ETag, and a save with it then won.
- A policy save answers with the stored policy and its ETag, and the
policy, organisation, plugins and themes pages continue from that
instead of reading it back from an instance that may not have it yet.
- A stored policy that doesn't parse keeps its version, so a save can
replace it instead of answering 409 forever.
- A reload of another replica's change throws when this replica's writes
kept landing while it read, so the change poller tries again; a write
whose reload raced it reads the document again instead of applying its
older result.
- Dropping a deleted plugin from the policy rereads policy.json first in
local mode, as every other write does.
- New updateAdminConfig(key, change) builds a value on the stored one.
The policy route's test mocks gain getPolicyWithVersion.
The three admin login limiters (5 per address and 50 overall in one
reservation, 5 per known device) keep their numbers and still don't count
refused attempts, but live in the store: memory in this process as before,
or the shared store with several replicas. checkRateLimit and
checkDeviceRateLimit are async now; the login route awaits them and
answers 503 "Sign-in is temporarily unavailable" when the store can't be
reached. The two limiter tests gain await and reset the store's counters,
which vi.resetModules no longer clears.
The admin code tells a store outage by the error's name, since the store
may come from another bundle's copy of the module.
The admin pages save the whole policy from the draft they loaded. A
save from a page opened before another tab's or another admin's save,
or before a deleted plugin was dropped from the policy, used to put the
older policy back. GET /api/admin/policy now sends the policy version as
an ETag; the policy, organisation, plugins and themes pages send it back
as If-Match, and a save based on an older version answers 409 and keeps
the draft on the page. A PUT without If-Match replaces the policy as
before. Deleting a plugin or theme changes only its id in the stored
policy, and a policy import compares against what it replaces.
Test mocks of the config manager gain getPolicyVersion and updatePolicy,
which the route and the extension cleanup now call.
The marketplace now talks to the extension directory's v2 API only. The
detail page lists every version, previews the one picked, installs it,
and refuses a version the publisher withdrew unless the admin confirms,
or one outside the Bulwark range it supports. The listing gains sort, an
Installed view and an updates strip fed by the directory's updates check,
with notices for advisories, deprecations and suspensions.
A self-hosted directory older than 2026-10-14 has no v2 API and no
longer works with the marketplace.
Plugins that talk to Nextcloud, Paperless or an AI provider used to need
the password or key in their own settings, readable by the plugin and
stored in the browser. A plugin can now declare credentials in its
manifest and send requests "with credential X" through the server:
- `credentials` in the manifest: admin scope (secret and server from the
admin config) or user scope (the user connects an account; the server
address is fixed or chosen by the user)
- `/api/plugin-secrets`: the user's connected accounts, encrypted per
mail account under `PLUGIN_SECRETS_DIR`; listing never returns secrets
- `/api/plugin-proxy`: adds the secret and forwards only to the
credential's own origin, through the SSRF guard unless the admin set
the server up or listed it in `PLUGIN_PROXY_PRIVATE_ORIGINS`; no
redirects or WebDAV moves to other servers, streamed bodies capped at
512 MB up and 2 GB down, 300 requests a minute per plugin, 8 at once
per user, and one audit line per request without paths or bodies
- a capture mode that stores a secret from a sign-in response (Nextcloud
Login Flow v2) and removes it from what the plugin gets back