failures into `{ ok, error, statusCode, message }` JSON.
- **Schema changes**: edit `db/schema.ts`, then `npm run db-generate` and commit the generated
migration. Never hand-edit an existing migration.
- **A module prop marked `sensitive` is write-only.** A route answering with a module's stored config
runs it through `maskSensitiveProps` (`helpers/common.ts`) first, which replaces every non-empty
sensitive value with `SENSITIVE_MASK`; the client posts the whole configuration back, and
`isSensitiveMask` is what makes the mask mean "unchanged" rather than a new value. Masking belongs
at the API boundary and nowhere earlier — the config the models hand out is what the modules read
their credentials from. An empty value is never masked, so dots always mean something is stored,
and clearing the field is how a stored secret is removed — except on a create, where there is
nothing to keep and the mask leaves the prop unset. `manage:system` on the route is not a reason to
skip this: the secret still ends up in a browser, a cache and a screen share. Both module-prop
surfaces do it — storage targets (`api/storage.ts`) and authentication strategies
(`withoutSecrets` in `api/authentication.ts`) — so a new one is expected to as well.
- **Dates use the native `Temporal` API**, not luxon (no longer a backend dependency). `Temporal` is a
global in Node 26 and is typed by the TS 7 lib, so it needs no import. Four things to know:
- `Temporal.Instant` accepts **exact time units only** — `add({ days: 1 })` throws. Since these are
@ -431,8 +442,15 @@ import a tree that was put there from outside.
`ObjectStoreClient` in `helpers/storageObjects.ts` — and `objectStorageModule` builds the whole
`StorageModule` from them. An object key *is* a path, the same one `disk` would write, so a bucket and
a folder hold a site's content laid out identically and `pathPrefixFor` decides the shape of both.
Object stores have no rename, so `moveObject` copies and then deletes, in that order, and never
deletes on a copy that failed. Credentials are optional on all three: left empty, each SDK falls back
Each of the three also takes a **`pathPrefix`**, which is the segments that key starts with — empty by
default, so the tree sits at the root of the bucket, and set when the bucket has to be shared with
something else, since an object store has no folders to keep two tenants apart. It is per target,
unlike everything `pathPrefixFor` answers, for the same reason the bucket name is: *which* store the
tree goes in and *how* the tree is laid out are different questions. It is normalized rather than
validated — surrounding and doubled slashes go, and so do `.` and `..` segments, which name a literal
object in a bucket rather than a relative path. A custom `baseUrl` still stands in for the bucket and
not for the prefix, because the key is signed prefix and all. Object stores have no rename, so
`moveObject` copies and then deletes, in that order, and never deletes on a copy that failed. Credentials are optional on all three: left empty, each SDK falls back
to the machine's own identity (an IAM role, a managed identity, a workload identity), which is how a
deployment keeps a long-lived secret out of the database. Only `exportAll` is offered — there is no
`importAll`, because nothing but the wiki writes into these buckets, which is exactly what makes git
@ -886,7 +905,7 @@ async function routes(app: FastifyInstance) {
schema:{
summary:'List the configured authentication strategies',
description:
'Instance-wide, i.e. every strategy regardless of which sites offer it. Which of them a given site shows on its login screen, and in what order, is part of that site’s configuration. Configuration values include any secrets a module stores, hence the `manage:system` requirement.',
'Instance-wide, i.e. every strategy regardless of which sites offer it. Which of them a given site shows on its login screen, and in what order, is part of that site’s configuration. A configuration value belonging to a prop marked `sensitive` is write-only and comes back masked, never as the stored secret.',
tags:['Authentication'],
response:{
200:{
@ -898,7 +917,7 @@ async function routes(app: FastifyInstance) {
'Values for the module props, completed with the module defaults for any prop that has none stored yet.'
'Values for the module props, completed with the module defaults for any prop that has none stored yet. A prop declared `sensitive` is write-only: where one holds a value it reads as a fixed mask instead, and sending that mask back leaves the stored secret alone.'
'Values for the module props. Validated against what the module declares: an unknown key is dropped, a wrong type is refused, and a read-only prop keeps its stored value.'
'Values for the module props. Validated against what the module declares: an unknown key is dropped, a wrong type is refused, and a read-only prop keeps its stored value. A sensitive prop sent back as the mask it was read as keeps its stored value too; send a new value to replace the secret, or an empty string to remove it. On create there is nothing to keep, so the mask leaves the prop unset.'
'Values for the module props, completed with the module defaults for any prop that has none stored yet.'
'Values for the module props, completed with the module defaults for any prop that has none stored yet. A prop declared `sensitive` is write-only: where one holds a value it reads as a fixed mask instead, and sending that mask back leaves the stored secret alone.'
'Values for the module props. Validated against what the module declares: an unknown key is dropped, a wrong type is refused, and a read-only prop keeps its stored value.'
'Values for the module props. Validated against what the module declares: an unknown key is dropped, a wrong type is refused, and a read-only prop keeps its stored value. A sensitive prop sent back as the mask it was read as keeps its stored value too; send a new value to replace the secret, or an empty string to remove it.'
@ -18,7 +19,7 @@ async function routes(app: FastifyInstance) {
schema:{
summary:'Get the storage configuration of a site',
description:
'The site-wide settings, plus one target per storage module installed in `modules/storage`, whether or not it has ever been enabled. Configuration values include any credentials a module stores, hence the `manage:system` requirement. Where a given file is written and where it is read from are both derived from this configuration rather than recorded per file, so changing it changes where content is looked for, not where it already sits.',
'The site-wide settings, plus one target per storage module installed in `modules/storage`, whether or not it has ever been enabled. A configuration value belonging to a prop marked `sensitive` is write-only and comes back masked, never as the stored secret. Where a given file is written and where it is read from are both derived from this configuration rather than recorded per file, so changing it changes where content is looked for, not where it already sits.',
tags:['Storage'],
params:{
type:'object',
@ -82,7 +83,18 @@ async function routes(app: FastifyInstance) {
hint:Where the authorization code is exchanged for tokens.
icon:exit
order:6
if:
- {key: 'useDiscovery', eq:false}
userInfoURL:
type:String
title:User Info Endpoint URL
hint:Ignored while discovery is on. Optional even without it — the ID token alone can carry everything needed.
hint:Optional - the ID token alone can carry everything needed.
icon:contact
order:7
if:
- {key: 'useDiscovery', eq:false}
jwksURL:
type:String
title:JSON Web Key Set URL
hint:Ignored while discovery is on. Where the keys that signed the ID token are published; without it the ID token cannot be verified and logins are refused.
hint:Where the keys that signed the ID token are published. Without it the ID token cannot be verified and logins are refused.
hint:The container to store content in. It is created on first use if it does not exist yet.
icon:shipping-container
order:3
pathPrefix:
type:String
title:Path Prefix
default:''
hint:Store content under this folder inside the container rather than at its root, e.g. wiki or apps/docs. Leave empty for the root.
icon:symlink-directory
order:4
accessTier:
type:String
title:Access Tier
default:Cool
hint:What new blobs are stored as. Cool costs less to keep and more to read, which suits content that is served from another target and kept here as a copy.
description:Store the wiki's content as ordinary files on a remote server over SSH. The same tree the local disk target writes, on a machine that is not this one. Meant as a copy rather than a source, so it cannot be chosen under Content Delivery.
vendor:'Wiki.js'
website:'https://js.wiki'
assetDelivery:
isDirectAccessSupported:false
# -> A place to keep a copy of the site's content, not one to serve it from: every image on every