AUTH4D-56 application branding and device settings
Developers read and update per-application branding and device settings through the existing
application routes from AUTH4D-18 (repository file docs/api-spec/auth4d-18.md). The wire shapes are the AUTH4D-55 contracts
(BrandingSchema, BrandingUpdateSchema, DeviceSettingsSchema, DeviceSettingsUpdateSchema,
see AUTH4D-55 (repository file docs/api-spec/auth4d-55.md)). Values are stored in the clients columns added by migration 0008.
No plan gates these settings, because the billing catalog has no branding or device entitlement.
Read
GET /management/tenants/{tenantId}/clients/{clientId} (scope client:read, any project role)
{
"client": { "clientId": "cli_…", "clientType": "native", "…": "…" },
"branding": { "accent_color": "#1d4ed8" },
"device_settings": { "user_code_length": 8, "code_ttl_seconds": 900, "poll_interval_seconds": 5 }
}
brandingcontains only the configured fields. Branding has no defaults, so an application with no branding returns{}.device_settingsholds the effective settings for native applications. Unset fields takeDEFAULT_DEVICE_SETTINGS(8 characters, 600 seconds, 5 seconds). Browser applications cannot use the device grant, so they returnnull.- The list route (
GET …/clients) is unchanged and does not include these fields.
Update
PATCH (or PUT) /management/tenants/{tenantId}/clients/{clientId} (scope client:write,
project role owner or admin; a viewer gets 403 insufficient_role)
{
"branding": { "accent_color": "#1d4ed8", "support_url": null },
"device_settings": { "code_ttl_seconds": 900, "user_code_length": null }
}
- Both objects are optional and partial, and they can be combined with the AUTH4D-18 fields
(
displayName,enabledand the others). Omitted fields stay unchanged. Inbranding,nullclears a field. Indevice_settings,nullresets a field to its default. An object that is present must contain at least one field. - The response has the same shape as the read response, and it is sent with
no-store. - The policy change, the presentation change and their audit events are committed in a single D1 batch. Only the columns that change are written, so concurrent updates to different fields cannot overwrite each other.
Each of these failures returns 400 and writes nothing. details[].field names the field.
| Code | Cause |
|---|---|
validation_failed |
The request fails a schema: a color that is not #rrggbb, a URL that is not https: (including http:), a relative URL or one with credentials, a value out of range or not an integer, an unknown field at any level, or an empty object |
insufficient_contrast |
accent_color is below 4.5:1 contrast against white (WCAG AA for normal text) |
device_settings_unsupported |
device_settings was sent for a browser application |
The device settings bounds are user_code_length 8–12, code_ttl_seconds 120–1800 and
poll_interval_seconds 5–60. Accent colors are stored lowercase. The hosted login page also
checks contrast client-side, and every color that the API accepts passes that check.
Audit
Each PATCH that changes at least one branding or device field writes one
management.client.presentation.updated event. The event has target type client and the
following metadata:
{
"client_type": "native",
"changed_fields": "branding.accent_color,device_settings.code_ttl_seconds"
}
changed_fields is a sorted, comma-separated list of field names drawn from an allowlist in
packages/observability. Values such as colors, URLs and numbers are never recorded. A change to a
policy field also writes management.client.updated, as before.
Idempotency
PATCH is idempotent by value. If a request sets fields to the values they already have, it changes
nothing, writes no audit event and returns the same document, so a client can safely retry it.
PATCH also accepts an optional idempotencyKey, using the same format as the create routes. If
that key is already recorded for a create operation, the PATCH is refused with
409 idempotency_conflict. Update keys are not recorded, because management_idempotency only
allows create operations.
Isolation
Every lookup and write is keyed on (tenant_id, client_id), and the caller’s live membership
in the path’s tenant is checked first. A developer from another project gets 403 forbidden for
that project’s path. Using their own project’s path with another project’s clientId gets
404 not_found.
Tenancy resolver
resolveApplicationPresentation(db, tenantId, clientId) in packages/tenancy returns
{ branding, deviceSettings } with defaults applied, or null when the tenant has no such browser
or native application. It never serves a stored accent color that fails the contrast check. Hosted
login and device pages, and device authorization, should read per-application values through it.
The contrast helpers contrastRatio, isReadableAccentColor and MIN_ACCENT_CONTRAST_ON_WHITE
are exported from the same package.
Tests
pnpm --filter @auth4/integration exec vitest run --config vitest.config.ts auth4d-56runs the full-stack API tests for defaults, partial updates, validation, roles, tenant isolation, audit metadata and replay.pnpm --filter @auth4/tenancy testruns the resolver and contrast tests.pnpm --filter @auth4/observability testruns thechanged_fieldsallowlist tests.
Source: docs/api-spec/auth4d-56.md