Appearance
Public Self-Registration
Public self-registration lets anonymous visitors create their own user accounts without an admin invite. The feature is disabled by default and gated by system settings. It ships with an anti-enumeration design, optional email verification, and an allow-list filter on email addresses.
Source:
src/routes/users.ts,src/services/users.ts(registerUser,verifyRegistration,getRegistrationOptions),src/services/registration-fields.ts,src/utils/match-filter.ts, migrations016-create-settings.ts/024-seed-defaults.ts/029-seed-notify-templates.ts/065-registration-fields.ts.
Overview
| Concern | Behaviour |
|---|---|
| Enabled by | public_registration setting (default false) |
| New user role | public_registration_role setting (UUID, nullable) |
| Email verification | public_registration_verify_email setting (default true) |
| Email allow-list | public_registration_email_filter setting (ODP filter JSON, nullable) |
| Form fields | public_registration_fields setting (JSON list of user-collection fields, nullable → first_name + last_name) |
| New user status | unverified if verification required, else active |
| Anti-enumeration | All outcomes return 204 after a fixed stall time |
Three public endpoints (no auth):
GET /users/register/options— registration form schema (enabled flag + fields to render)POST /users/register— submit a registrationPOST /users/register/verify-email— activate the account from the emailed token
GET /users/register/options
Public, no auth. Returns the registration form schema so a client can render the form without hardcoding fields. Safe to expose: it only reveals display metadata for the whitelisted fields, never protected columns.
Response 200
json
{
"data": {
"enabled": true,
"verify_email": true,
"fields": [
{
"field": "first_name",
"type": "string",
"interface": "input",
"options": null,
"required": false,
"note": null,
"translations": null,
"validation": null
}
]
}
}| Field | Description |
|---|---|
enabled | Mirrors public_registration. When false, fields is [] |
verify_email | Mirrors public_registration_verify_email |
fields | Resolved, render-ready descriptors for each configured field (in order) |
The fields array is produced by resolveRegistrationFields() (src/services/registration-fields.ts): each configured field is matched against the live users-collection schema and dropped if it is unknown, a base field (email/password), or protected.
POST /users/register
Public. Rate limited (rateLimiterEmail: 5 requests / 60s per IP, shared with invite and password-reset).
Request Body
json
{
"email": "jane@company.com",
"password": "S3cure-passw0rd",
"first_name": "Jane",
"last_name": "Doe",
"phone": "+84 90 000 0000",
"verification_url": "https://app.example.com/verify"
}| Field | Required | Description |
|---|---|---|
email | Yes | Valid email. Normalized to lowercase before lookup/storage |
password | Yes | Must pass the configured password policy |
| configured fields | depends | Any field listed in public_registration_fields (e.g. first_name, last_name, phone). See Configurable fields |
verification_url | No | Base URL the verification link points to. Must be allow-listed via USER_REGISTER_URL_ALLOW_LIST, else 400. Falls back to PUBLIC_URL |
Server-side whitelist. The body may carry arbitrary keys, but
registerUserkeeps only the fields resolved frompublic_registration_fields. Everything else — includingrole,status,token,tfa_secretand other protected columns — is dropped.roleandstatusare always forced from settings, never from the request.
Response 204 No Content — returned for every non-validation outcome (see Anti-enumeration):
- account created and verification email sent
- email already exists but is
unverified→ verification email re-sent - email already exists and is active → no-op
- email rejected by the filter
public_registrationdisabled
Error responses
| Status | When |
|---|---|
400 | Missing email/password, password fails policy, or verification_url not allow-listed |
429 | Rate limit exceeded (Retry-After header set, emits RATE_LIMIT_TRIGGERED security event) |
Note: "registration disabled" and "email filtered out" do not surface as errors — they return
204after the stall, by design.
Processing flow (UsersService.registerUser)
- Start the anti-enumeration timer.
- Validate
verification_urlagainstUSER_REGISTER_URL_ALLOW_LIST(if provided). - Validate and normalize the email.
- Load settings:
public_registration,public_registration_verify_email,public_registration_role,public_registration_email_filter. If disabled → stall + return. - Validate the password against policy (before any DB write, so the error is not enumeration-sensitive).
- Resolve
public_registration_fieldsagainst the schema and whitelist the body to those fields. Enforcerequiredfields and run per-field metadata validation (validatePayload) — these throw before any DB write and are not enumeration-sensitive. - Apply the email filter via
matchesFilter(). No match → stall +ForbiddenError. - Case-insensitive lookup of existing user:
- status
active/other → stall + silent return (no mutation) - status
unverified→ reuse the user, re-send verification
- status
- Create the user (system context, no permission gating): the whitelisted fields plus hashed password (argon2), role =
public_registration_role, status =unverified(if verification on) oractive.role/statusalways override any same-named submitted value. - If verification is on, mint an
email-verificationJWT and send theemail_verificationnotification template. - Stall to the minimum time before responding.
POST /users/register/verify-email
Public. Not rate limited. Activates an unverified account.
Request Body
json
{ "token": "eyJhbGciOiJIUzI1NiJ9..." }| Field | Required | Description |
|---|---|---|
token | Yes | JWT with scope: "email-verification" from the verification email |
Response 200
json
{ "data": { "id": "8f2c…user-uuid" } }Error responses
| Status | When |
|---|---|
400 | Token invalid/expired, already used, or the user is no longer unverified |
403 | Token scope is not email-verification |
Verification token
- Minted with
createAccessToken(),scope: "email-verification". - TTL from
EMAIL_VERIFICATION_TOKEN_TTL(default7d). - Payload:
{ sub: <user-uuid>, scope: "email-verification", iat, exp }.
On success the user's status moves unverified → active.
Settings
Stored on the odp_settings singleton (migration 016-create-settings.ts). Read/write via PATCH /system-settings (admin only — see Public Registration (Admin Guide) in the App docs for the UI).
| Setting | Type | Default | Description |
|---|---|---|---|
public_registration | boolean | false | Master on/off switch |
public_registration_verify_email | boolean | true | Require email verification before login |
public_registration_role | uuid → odp_roles.id | null | Role granted to new accounts |
public_registration_email_filter | text (ODP filter JSON) | null | Allow-list rule applied to the email; null = allow any |
public_registration_fields | text (JSON list) | null | Curated user-collection fields exposed on the form; null = first_name + last_name (both optional) |
Configurable registration fields
public_registration_fields stores a JSON list of user-collection fields to expose on the public form, beyond the always-present email + password:
jsonc
[
{ "field": "first_name", "required": true },
{ "field": "last_name" },
{ "field": "phone", "required": true }
]field— a column on the users collection (odp_users). Add the field to the collection first (Settings → Data Model) so it has a real column and field metadata.required— overrides the field's ownrequiredmetadata for registration. Omitted → falls back to the field metadata.
Resolution + enforcement is centralised in resolveRegistrationFields() (src/services/registration-fields.ts) and shared by GET /users/register/options and registerUser:
- Unknown fields (not on the users schema) are dropped.
- Base fields (
email,password) are handled separately and ignored here. - Protected fields are dropped unconditionally — even if an admin lists them — because they are security-sensitive or system-managed:
id,role,status,password,password_changed_at,token,tfa_secret,last_access,last_page,provider,external_identifier,auth_data,email_notifications,appearance,theme_light,theme_dark,theme_light_overrides,theme_dark_overrides.
Because the public create path bypasses permission-based field/payload validation, registerUser re-runs required and per-field metadata validation against this resolved list itself.
Email filter format
public_registration_email_filter stores a standard ODP filter evaluated against { email } by matchesFilter() (src/utils/match-filter.ts). Supported operators: _eq, _neq, _in, _nin, _contains, _icontains, _starts_with, _ends_with, _empty, _nempty, _and, _or.
jsonc
// Single domain
{ "email": { "_ends_with": "@company.com" } }
// Multiple domains
{ "_or": [
{ "email": { "_ends_with": "@company.com" } },
{ "email": { "_ends_with": "@partner.org" } }
] }The admin UI exposes this as a friendly comma-separated "Allowed Email Domains" field and converts to/from the filter JSON. Filters set directly via API that are not simple domain rules are preserved (the UI leaves them untouched unless the domains field is edited).
Login gating
unverified (and invited) users cannot log in. LocalAuthDriver throws UserNotVerifiedError (401, code USER_NOT_VERIFIED) until the account is activated. The unverified status is defined in USER_STATUSES (src/utils/constants.ts).
Anti-enumeration
POST /users/register is designed so that an attacker cannot tell whether an email is already registered, filtered, or whether registration is even enabled:
- Every outcome (success, existing account, filtered, disabled) returns the same
204. - Each code path is padded to a minimum duration (
REGISTER_STALL_TIME, default750ms) so response timing does not leak state. - Password-policy validation runs before any DB mutation, so a weak-password
400is returned uniformly regardless of whether the email exists.
Environment variables
| Variable | Default | Description |
|---|---|---|
REGISTER_STALL_TIME | 750 (ms) | Minimum duration every registration code path is padded to |
USER_REGISTER_URL_ALLOW_LIST | '' | Comma-separated base URLs allowed for verification_url. Empty = only PUBLIC_URL |
EMAIL_VERIFICATION_TOKEN_TTL | 7d | Verification JWT lifetime |
PUBLIC_URL | http://localhost:6688 | Fallback base URL for the verification link |
Email verification delivery
When public_registration_verify_email is on, registerUser (src/services/users.ts) sends the verification email through the Notify module — it does not call MailService directly.
1. URL building (users.ts):
ts
const base = input.verification_url || env.PUBLIC_URL + '/verify-email';
const url = base + (base.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token);The default link is PUBLIC_URL + /verify-email?token=<jwt>. A custom verification_url is only used if it passed the USER_REGISTER_URL_ALLOW_LIST check. The front-end page at that URL reads ?token= and calls POST /users/register/verify-email.
2. Dispatch — sendNotification({ template_id: 'email_verification', recipients: [email], variables: { url, token } }) (src/modules/notify/send.ts) runs NotifyEngine.trigger: it looks up the email_verification template, renders / , dispatches over the template's channels, and writes a delivery log.
3. Email channel — src/modules/notify/providers/email.ts uses nodemailer, configured from Notify settings (odp_notify_settings: email_transport = smtp | ses | sendmail, plus smtp_host/port/credentials or ses_region). These are managed in the admin console (Notifications → Settings), not via env vars.
Email failure is swallowed — registration still returns `204`
The send call is wrapped in try/catch (users.ts): if the provider is unconfigured or fails, it only logs a warning and the user is still created (as unverified). So POST /users/register returns 204 even when no email is delivered. If users never receive the link, check: (1) the email provider is configured and verified in Notify settings, (2) the email_verification template exists and is published with an email-channel content containing (seeded by migration 029-seed-notify-templates.ts), and (3) PUBLIC_URL points at the front-end that hosts the /verify-email page.
Testing
End-to-end coverage in test/e2e/registration.test.ts: disabled registration, email validation, unverified → active flow, filter enforcement, anti-enumeration (existing accounts return 204), weak-password rejection, verification_url allow-listing, and token-scope validation.