Skip to main content

GET /settings and PATCH /settings

The 36 columns tenants carries beyond identity — everything odudu seed tenant --set can already change — read and amended through one map, @odudu/domain-tenant's SETTINGS: a name a caller writes and a column a migration owns, never restated a second time. Ranges are CHECK constraints on tenants, restated beside that map as tenantSettingProblems so a value outside one is refused before the write; an integration test holds the two in agreement, and the CHECK still stands behind it.

Requires manage-tenant on the tenant named in the path — a tenant-local admin's own capability, so a system admin reaches it only by also holding that role there, manage-tenants alone is not enough. A GET carries an ETag over the settings as they stand. A PATCH may carry If-Match: absent, the write proceeds unconditionally; present and stale, the request is refused with 412 and nothing is changed — the concurrency control every amending endpoint in this API shares. The row is locked for the rest of the amending transaction before its current ETag is computed, so two PATCHes sent at once are serialised: the second reads what the first wrote and its If-Match is stale, rather than both matching the same pre-write row and the later write replacing the earlier one unseen. Setting enabled to false ends the tenant's sessions, once the change has committed, exactly as PATCH /admin/tenants/{tenant} does.

curl -sS -D - \
-H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3082/admin/tenants/settings-demo/settings

In settings-demo, a tenant created for this section through POST /admin/tenants with display_name "Settings Demo", so every value but display_name is the migrations' own default:

HTTP/1.1 200 OK
x-request-id: 01a109b9-267d-7851-ba04-b8640c51baee
cache-control: no-store
etag: "d90ab18007f2025c9b873585dfcafcabd4428380faeca28750be33ee5514239a"
content-type: application/json; charset=utf-8
content-length: 1206
Date: Mon, 05 Oct 2026 01:41:31 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"display_name":"Settings Demo","enabled":true,"registration_allowed":false,"verify_email":false,"reset_password_allowed":false,"sso_session_idle_seconds":1800,"sso_session_max_seconds":36000,"password_min_length":15,"password_require_digit":false,"password_require_uppercase":false,"password_require_lowercase":false,"password_require_special":false,"password_not_username":true,"password_not_email":true,"password_history_depth":0,"password_max_age_days":0,"otp_required":false,"brute_force_max_failures":5,"brute_force_lockout_seconds":60,"brute_force_max_lockout_seconds":900,"brute_force_failure_reset_seconds":43200,"client_registration_policy":"disabled","max_clients":200,"max_sessions_per_browser":25,"remember_me_allowed":false,"remember_me_idle_seconds":604800,"remember_me_max_seconds":2592000,"audit_retention_days":90,"username_editable":false,"access_token_ttl_seconds":300,"id_token_ttl_seconds":300,"refresh_token_ttl_seconds":1209600,"authorization_code_ttl_seconds":60,"login_ttl_seconds":1800,"verify_email_ttl_seconds":43200,"reset_password_ttl_seconds":300,"login_with_email":false,"audit_event_types":["admin_mutation","admin_access","authentication","session","token","credential"]}

Amending sends only the settings that change, and the response is the whole object as it now reads, with a fresh ETag for the next If-Match:

curl -sS -D - -X PATCH \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"verify_email": true, "password_min_length": 14}' \
http://localhost:3082/admin/tenants/settings-demo/settings
HTTP/1.1 200 OK
x-request-id: 01a109b9-268f-7826-9bea-9f78b47886a1
cache-control: no-store
etag: "3e3df2b9af1db77a14c2f4b445b6c4077aa90bf1ed899f5b92cf7e8c68caf21b"
content-type: application/json; charset=utf-8
content-length: 1205
Date: Mon, 05 Oct 2026 01:41:31 GMT
Connection: keep-alive
Keep-Alive: timeout=72

{"display_name":"Settings Demo","enabled":true,"registration_allowed":false,"verify_email":true,"reset_password_allowed":false,"sso_session_idle_seconds":1800,"sso_session_max_seconds":36000,"password_min_length":14,"password_require_digit":false,"password_require_uppercase":false,"password_require_lowercase":false,"password_require_special":false,"password_not_username":true,"password_not_email":true,"password_history_depth":0,"password_max_age_days":0,"otp_required":false,"brute_force_max_failures":5,"brute_force_lockout_seconds":60,"brute_force_max_lockout_seconds":900,"brute_force_failure_reset_seconds":43200,"client_registration_policy":"disabled","max_clients":200,"max_sessions_per_browser":25,"remember_me_allowed":false,"remember_me_idle_seconds":604800,"remember_me_max_seconds":2592000,"audit_retention_days":90,"username_editable":false,"access_token_ttl_seconds":300,"id_token_ttl_seconds":300,"refresh_token_ttl_seconds":1209600,"authorization_code_ttl_seconds":60,"login_ttl_seconds":1800,"verify_email_ttl_seconds":43200,"reset_password_ttl_seconds":300,"login_with_email":false,"audit_event_types":["admin_mutation","admin_access","authentication","session","token","credential"]}

A name this map does not know is refused with 400, naming the settings it does — which is also the one place the whole vocabulary is listed by the server itself. In the same tenant:

curl -sS -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" -d '{"nonesuch": true}' \
http://localhost:3082/admin/tenants/settings-demo/settings
{"type":"about:blank","title":"Bad Request","status":400,"detail":"unknown tenant setting \"nonesuch\"; expected one of display_name, enabled, registration_allowed, verify_email, reset_password_allowed, sso_session_idle_seconds, sso_session_max_seconds, password_min_length, password_require_digit, password_require_uppercase, password_require_lowercase, password_require_special, password_not_username, password_not_email, password_history_depth, password_max_age_days, otp_required, brute_force_max_failures, brute_force_lockout_seconds, brute_force_max_lockout_seconds, brute_force_failure_reset_seconds, client_registration_policy, max_clients, max_sessions_per_browser, remember_me_allowed, remember_me_idle_seconds, remember_me_max_seconds, audit_retention_days, username_editable, access_token_ttl_seconds, id_token_ttl_seconds, refresh_token_ttl_seconds, authorization_code_ttl_seconds, login_ttl_seconds, verify_email_ttl_seconds, reset_password_ttl_seconds, login_with_email, audit_event_types","errors":[{"path":"nonesuch","message":"is not a tenant setting"}],"instance":"01a109b9-26a8-7436-8e4c-ae8456134de0"}

A value the map coerces but outside its range — password_min_length outside 8..256, for instance — is refused with 400 before anything is written, every such setting listed together under errors by its name. An idle lifetime is judged against the maximum it would sit under once the patch is applied, the stored one when the patch leaves it alone. The refusals come in a fixed order: a malformed or unknown setting answers 400 first, then a stale If-Match answers 412, and only then is an out-of-range value refused with 400 — so a stale header on an out-of-range patch is told 412. As ada-import, in a tenant settings-range-demo created for it — its stored values first:

curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3000/admin/tenants/settings-range-demo/settings \
| jq -c '{password_min_length, max_sessions_per_browser, sso_session_idle_seconds, sso_session_max_seconds}'
for body in '{"password_min_length": 4}' \
'{"password_min_length": 4, "max_sessions_per_browser": 99}' \
'{"sso_session_idle_seconds": 40000}'; do
curl -sS -X PATCH \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "$body" \
http://localhost:3000/admin/tenants/settings-range-demo/settings
echo
done
{"password_min_length":15,"max_sessions_per_browser":25,"sso_session_idle_seconds":1800,"sso_session_max_seconds":36000}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"1 tenant setting(s) outside the permitted range, listed under errors","errors":[{"path":"password_min_length","message":"must be between 8 and 256"}],"instance":"01a0e50e-af82-7d71-9707-90c95f5bc100"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"2 tenant setting(s) outside the permitted range, listed under errors","errors":[{"path":"password_min_length","message":"must be between 8 and 256"},{"path":"max_sessions_per_browser","message":"must be between 1 and 32"}],"instance":"01a0e50e-af99-7032-8cb9-ad764a4a3a15"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"1 tenant setting(s) outside the permitted range, listed under errors","errors":[{"path":"sso_session_idle_seconds","message":"must not exceed sso_session_max_seconds"}],"instance":"01a0e50e-afb0-72d3-a667-8e7576e63166"}

A setting's value may be sent as its JSON type (true, 14) or as the equivalent string ("true", "14") — both reach the same coerceTenantSetting the CLI uses, which reads a string either way.

A display_name is trimmed wherever one is written, and a blank one is stored as none (null), which the tenant's pages replace with its name: PATCH /settings, PATCH /admin/tenants/{tenant}, POST /admin/tenants and, in the request or the document, POST /admin/tenant-imports all go through one function in @odudu/domain-tenant. (The setting is the one nullable text setting, and a request cannot carry null.) With tenants name-demo3 (created with a blank name) and name-demo4 (created with "Name Demo"):

curl -sS -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"name-demo3","display_name":" "}' \
http://localhost:3082/admin/tenants | jq -c '{name,display_name}'
for body in '{"display_name": " Name Demo Inc "}' '{"display_name": " "}'; do
curl -sS -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" -d "$body" \
http://localhost:3082/admin/tenants/name-demo4 | jq -c '{display_name}'
done
for body in '{"display_name": "Name Demo Inc"}' '{"display_name": " "}'; do
curl -sS -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" -d "$body" \
http://localhost:3082/admin/tenants/name-demo4/settings | jq -c '{display_name}'
done
{"name":"name-demo3","display_name":null}
{"display_name":"Name Demo Inc"}
{"display_name":null}
{"display_name":"Name Demo Inc"}
{"display_name":null}

seed tenant --set runs the same check before it writes anything, over the tenant's stored settings with the new values laid on them — or, for a tenant it would create, the column defaults — and names every problem at once. So a refused --set leaves no tenant behind: the second command below creates range-demo, which the first did not.

docker compose exec -T odudu node dist/main.js seed tenant --name range-demo \
--set password_max_age_days=4000 --set sso_session_max_seconds=600 2>&1 \
| grep -E '^OduduError|code:'
docker compose exec -T odudu node dist/main.js seed tenant --name range-demo 2>/dev/null
OduduError: tenant setting password_max_age_days must be between 0 and 3650; tenant setting sso_session_idle_seconds must not exceed sso_session_max_seconds
code: 'seed_invalid_options'
{"command":"tenant","created":true,"tenant":"range-demo","tenantId":"01a0e5a1-a6ff-7c12-9a71-8b9c81ba450b"}

Lifetimes, and signing in with an email address​

Seven of the settings are lifetimes the code used to fix, each defaulting to the constant it replaced (0082_tenant_lifetimes.sql): access_token_ttl_seconds and id_token_ttl_seconds (300, 1 to 3600), refresh_token_ttl_seconds (1209600, at least 1), authorization_code_ttl_seconds (60, 1 to 600 — RFC 6749 §4.1.2's ten minutes), login_ttl_seconds (1800, 60 to 86400: how long a login page may stay open), verify_email_ttl_seconds (43200, 60 to 604800) and reset_password_ttl_seconds (300, 60 to 86400). The three token lifetimes are defaults: a client's own access_token_ttl_seconds, id_token_ttl_seconds or refresh_token_ttl_seconds overrides each, and a client whose value is null takes the tenant's. Each is read where the token, code, login or link is minted, so a change applies from the next one. login_with_email (default false) lets the login form take a verified email address where it takes a username — Signing in with an email address walks through it. In the same tenant, the response narrowed to the settings this subsection is about:

curl -sS -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"access_token_ttl_seconds": 600, "id_token_ttl_seconds": 900, "authorization_code_ttl_seconds": 30, "login_ttl_seconds": 600, "reset_password_ttl_seconds": 900, "login_with_email": true}' \
http://localhost:3082/admin/tenants/settings-demo/settings \
| jq -c '{access_token_ttl_seconds, id_token_ttl_seconds, refresh_token_ttl_seconds, authorization_code_ttl_seconds, login_ttl_seconds, verify_email_ttl_seconds, reset_password_ttl_seconds, login_with_email}'
curl -sS -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"access_token_ttl_seconds": 7200, "authorization_code_ttl_seconds": 900, "reset_password_ttl_seconds": 30}' \
http://localhost:3082/admin/tenants/settings-demo/settings
{"access_token_ttl_seconds":600,"id_token_ttl_seconds":900,"refresh_token_ttl_seconds":1209600,"authorization_code_ttl_seconds":30,"login_ttl_seconds":600,"verify_email_ttl_seconds":43200,"reset_password_ttl_seconds":900,"login_with_email":true}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"3 tenant setting(s) outside the permitted range, listed under errors","errors":[{"path":"access_token_ttl_seconds","message":"must be between 1 and 3600"},{"path":"authorization_code_ttl_seconds","message":"must be between 1 and 600"},{"path":"reset_password_ttl_seconds","message":"must be between 60 and 86400"}],"instance":"01a10930-cffb-71d9-bb78-4c1cacfd308d"}