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 }
}
  • branding contains only the configured fields. Branding has no defaults, so an application with no branding returns {}.
  • device_settings holds the effective settings for native applications. Unset fields take DEFAULT_DEVICE_SETTINGS (8 characters, 600 seconds, 5 seconds). Browser applications cannot use the device grant, so they return null.
  • 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, enabled and the others). Omitted fields stay unchanged. In branding, null clears a field. In device_settings, null resets 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-56 runs the full-stack API tests for defaults, partial updates, validation, roles, tenant isolation, audit metadata and replay.
  • pnpm --filter @auth4/tenancy test runs the resolver and contrast tests.
  • pnpm --filter @auth4/observability test runs the changed_fields allowlist tests.

Source: docs/api-spec/auth4d-56.md