Skip to main content
Wildo.ai Coming soon

Identity, organizations & access

Manage users, customer organizations, roles and service identities across application operations.

SSO · MFA · passkeys · delegated accessOAuth 2.0 · OIDC · SAML

> From sign-in to customer membership > From responsibilities to record access > From onboarding to account closure

A user is a person’s account. An organization is a customer’s workspace. Membership connects the two and defines the responsibilities that person holds there.

Wildo carries those distinctions through sign-in, data access, administration and connected tools. You choose how people join, what they may do and the experience they use.

Alex holds different responsibilities in Acme and Northwind; a service has its own access to Acme.

Identity and access that follow the work

Give each customer its own workspace

Bring people, teams and business records together within an organization. The same person can work for several customers while keeping a different role in each.

Carry permissions into the application

Connect sign-in policy, membership and declared resource operations. Access decisions reach the records being read or changed, rather than stopping at the visible screen.

Handle change as part of the system

Invitations, directory updates, temporary support access and customer closure all change who may act. Their dedicated flows keep those transitions connected to the account and its work.

Example: Work for two customers with different responsibilities

A consultant manages Acme’s projects and contributes to Northwind’s. Switching workspace changes the active customer, resets navigation and updates live subscriptions. Each request still uses the consultant’s membership and the operation’s access rules; choosing a workspace does not create permission.

For engineers

Start with the kind of account, not a login button

Backend user-type configuration defines eligible sign-in methods, registration, additional proof and session policy. A frontend separately declares which user types it admits. Scoped customer identity connections and application-authored organization-type overrides then participate in resolving the effective authentication requirements.

The following selected fields come from Wonder Todos’ administrator user type in backend-api/src/saas-config.backend.ts. Other user-type fields are omitted. These are that application’s choices, not a mandatory policy for every Wildo application.

auth: {
  authMethodsEnabled: {
    [AuthMethod.PASSWORD]: true,
    [AuthMethod.PASSKEY]: true,
    [AuthMethod.TOTP]: true,
  },
  mfaPolicy: {
    requireMFA: true,
    requireStrongMFA: true,
    acceptableMFAMethods: [AuthMethod.TOTP, AuthMethod.PASSKEY],
    enrollmentGracePeriodDays: 7,
    passkeyExemptFromMFA: true,
  },
  registration: {
    mode: RegistrationMode.ADMIN_ONLY,
    allowedMethods: [AuthMethod.PASSWORD],
    emailVerification: EmailVerificationMode.REQUIRED_BEFORE_ACCESS,
    blockForSSODomains: false,
  },
  // Other authentication settings omitted.
},

authMethodsEnabled applies to authentication; registration controls acquiring this account type. Enabling a method does not supply its external prerequisites: passkeys need relying-party/origin configuration, email and SMS methods need delivery, and enterprise sign-in needs a configured identity connection.

For native sign-in, the standard interface follows the backend’s continuation, including enrollment or additional proof. Provider callbacks complete through a separate path; provider assurance must be configured deliberately for the chosen provider rather than assumed from local MFA policy. A custom interface must preserve the contract of the authentication path it uses.

Keep application roles and customer roles distinct

An account can hold application authority, while each organization membership carries authority inside that customer. A custom role inherits from the appropriate hierarchy. The following is Wonder Todos’ role configuration from shared-lib/src/engine/roles.ts, with formatting normalized:

import { RolesConfiguration, ResourcePrimaryScope, CORE_APP_ROLES, CORE_ORG_ROLES } from '@wildo-ai/saas-models';

export const CUSTOM_ROLES_CONFIGURATION: RolesConfiguration = {
  CUSTOM_APP_MANAGER: {
    role: 'CUSTOM_APP_MANAGER',
    inheritFrom: CORE_APP_ROLES.APP_USER,
    isSystemRole: false,
    relatedPrimaryScope: ResourcePrimaryScope.APPLICATION,
  },
  CUSTOM_ORG_SUPERVISOR: {
    role: 'CUSTOM_ORG_SUPERVISOR',
    inheritFrom: CORE_ORG_ROLES.ORG_MANAGER,
    isSystemRole: false,
    relatedPrimaryScope: ResourcePrimaryScope.ORGANIZATIONS,
  },
};

Register this map as customRoles: CUSTOM_ROLES_CONFIGURATION in the existing SharedSaaSModule; Wonder Todos does this in shared-lib/src/engine/index.ts. Keep that module in the application’s shared-module assembly. The role registration example shows the contribution in context. Assign customer roles through memberships, and declare the required roles on each operation. A role name containing “manager” has no meaning until the configuration gives it one; an application role is not a membership in every customer.

Connect a business record to its owner

Resource scope is derived from its registered relationships or scope anchor. It is not a free-form organizationId that a caller may choose in a request body.

Wonder Todos connects tasks to their customer and constrains their assignee through these relationship declarations. The excerpt retains the relevant fields and omits comments and display-context options from tasks-manager.relationships.ts:

createResourcesRelationship(
  CoreResourceType.ORGANIZATIONS, TasksManager_ResourceType.TODOS,
  ResourceRelationshipCardinality.ONE, ResourceRelationshipCardinality.MANY,
  {
    nature: RelationshipNature.COMPOSITION,
    isPrimaryScope: true,
    foreignKeyField: 'organizationId',
    contextPolicy: {},
  }
),

createResourcesRelationship(
  TasksManager_ResourceType.TODOS, CoreResourceType.USERS,
  ResourceRelationshipCardinality.MANY, ResourceRelationshipCardinality.ONE,
  {
    nature: RelationshipNature.REFERENCE,
    foreignKeyField: 'assignedToUserId',
    parentResourceRequirement: ResourceParentResourceRequirement.OPTIONAL,
    accessScopeStrategy: ResourceRelationshipAccessScopeStrategy.OPTIONAL_CONTEXT,
    scopeMembership: true,
  }
),

The first relationship establishes customer ownership. The second says that a referenced person must belong to that customer; existence alone is insufficient. The schema must contain those fields, and the relationship registry must be included in the application configuration. Operation-level reference constraints can further restrict the eligible members.

An operation still needs its own role policy. Together, these declarations let the framework answer three different questions: may this principal call the action, which customer’s record may it reach, and may the resulting record refer to that person?

Give each transition and caller the right contract

SituationUseWhy it is distinct
Someone joins a customerRegistration or membership invitation flowIdentity and membership must reach a valid state together
A customer manages its directoryScoped provisioning configuration and tokenProvisioning changes membership independently of interactive sign-in
A service calls the applicationAPI key or machine clientAuthority belongs to the credential rather than a browser session
An agent acts for a personConsent and a token for the named agent endpointBoth the person and tool remain attributable
Support repairs a customer accountAn admitted operation and usable temporary grantPlatform role alone does not supply ordinary cross-customer reach
A customer pauses or leavesThe appropriate lifecycle operationState, access, recovery and notifications change together

Keep these transitions on their owning operations. Directly changing status, role arrays or tenant fields would bypass the behavior those operations coordinate. Your business-specific actions still own their domain decisions; Wildo supplies the shared identity, scope and access mechanisms they use.

Account for a login racing revocation

Account-wide revocation rejects tokens issued at or before its recorded second. A concurrent login in that same second can therefore be refused too; the person must sign in again after the boundary. This conservative comparison prevents a same-second token from retaining access for its entire lifetime. See account-wide revocation for the comparison, refresh publication check and verification cases.

Protect the boundary. Keep a way to recover.

A refused action should protect the application without leaving its administrators stranded. Wildo pairs sensitive access rules with specific recovery operations, so support can correct an exceptional situation without opening every customer account to routine intervention.

The distinction matters when authority itself needs repair: preventing the last owner from leaving is useful, but an account that has already lost its usable owner needs a different path back.

Make recovery part of the design

Keep the ordinary rule

A support intervention does not weaken everyone’s access checks.

Name the exceptional action

Recovery targets a particular operation and customer, rather than enabling general access.

Check the result

Restoring authority means leaving a usable administrator, not merely changing a role label.

Example: Restore an account's administration

An account has no usable owner. An authorized platform operator obtains temporary access for that customer and uses the ownership-repair action on an existing, active member. The action adds ownership while preserving the member’s other roles. Ordinary cross-customer operations remain unavailable.

For engineers

The organization owner floor checks active memberships and usable users whose organization-wide roles confer ownership. It serializes reducing changes with the organization row so two simultaneous removals cannot both pass an outdated count. The application administrator floor protects a separate population; neither is a substitute for the other.

Recovery has a different entry point. organizationMembers.grantOwnership is a named API operation, declared in organization-members.shared.resources-config.schemas.ts. This selected variant excerpt keeps the authority, scope declaration and input together; source comments are omitted:

variants: [
  {
    variantType: ResourceOperationVariantType.API_CALL,
    isDefault: true,
    roles: [CORE_APP_ROLES.APP_ADMIN_SUPER_ADMIN],
    riskLevel: ResourceOperationRiskLevel.CRITICAL,
    resourceOperationLike: CoreResourceOperation.UPDATE,
    admitsCrossTenantPlatformAdministration: true,
    requestDto: z.object({
      justification: z.string().min(1).max(1000),
    }),
  },
],

The declaration admits this operation variant across a tenant boundary. It does not confer blanket tenant access on that role. The authorization service also requires a usable, time-limited platform access grant for the operator and target organization. Where tenant approval is required, the grant remains pending until approval; the measured ownerless-tenant case has a separate recovery rule so approval does not depend on the missing administrator.

Make the repair’s postcondition real

The implementation in organization-member-custom-implementation.backend.service.ts runs through the resource service’s transactional path. It checks both membership and user usability, and declares roles authoritative: a submitted role array cannot turn this action into an arbitrary privilege assignment.

Once those checks pass, its selected return paths are:

if (rolesConferOrganizationOwner(currentRoles, organizationOwnerConferralResolver())) {
  return { roles: currentRoles };
}

return { roles: [...currentRoles, CORE_ORG_ROLES.ORG_OWNER] };

An existing owner remains an owner when the operation is retried. Otherwise the action adds ownership without removing existing roles. After recovery, verify the member can administer the account and appoint a second owner where appropriate. The detailed ownership repair guide describes its invocation; temporary platform access describes admission.

Keep the evidence precise

The crossing and critical operation produce audit evidence, including the role change. The ownership-repair request currently requires justification but its handler does not persist that request value. Record the operator’s reason in the operational case; do not describe the emitted role-change event as containing it. The separate platform-access request has its own justification and lifecycle.

Recovery reach is deliberately selective. Restore, reactivate and request-deletion variants can declare a reversible crossing; immediate purge does not acquire that crossing simply because the caller is a platform administrator. An erasure acknowledgement is another distinct contract, not an administrative bypass flag.

When extending the framework, name the state your refusal protects, the actor and operation allowed to repair it, and the postcondition that demonstrates recovery. Keep those decisions in the operation and service contract, where both API callers and the standard interface meet them.

Give every customer a place to work

An organisation brings together a customer’s people, settings and business records. Memberships define each person’s role in that workspace; units represent the teams and departments inside it.

Wildo connects that structure to access, navigation and everyday preferences. You decide how customers join, how responsibilities are divided and which parts of the experience they can configure.

Acme contains Sales and Support teams; Alex holds separate memberships in Acme and Northwind.

A workspace that reflects how people work

Separate customers, connect their people

Keep each customer’s workspace distinct while letting one person belong to several, with different roles in each.

Put responsibility at the right level

Use organisation roles for broad authority and unit assignments for team responsibilities. Opt relevant records into unit-based access.

Make the workspace feel familiar

Carry language, regional defaults and email destinations into the experience. Let the shell keep people in the right customer and working context.

Example: Serve a customer with several departments

A customer has Sales and Support teams. A Sales manager works with Sales-owned goals, an organisation manager sees across teams, and shared preferences give members consistent defaults. A consultant who serves another customer switches workspace and starts from the new customer’s home context.

For engineers

Give each record one responsibility

Record or configurationResponsibilityWhat it does not replace
OrganisationCustomer identity and lifecycle stateThe member’s account
Organisation membershipA user’s roles and state in that organisationGlobal application authority
Unit and unit membershipInternal structure and roles within itAutomatic filtering of every resource
Resource unit declarationThe field and access decision that confine qualifying unit grantsThe resource’s organisation scope
Profile, preferences and notification settingsCustomer presentation and operational defaultsDomain verification or authentication credentials
Shell scopeThe organisation and record a person is currently working withinServer-side authorisation

Start from membership, then narrow where the product needs it

A membership is the connection between a global user and an organisation. It can carry a role independently of that user’s memberships elsewhere. A unit assignment attaches to this membership, not directly to the user, so its authority has an organisation context from the start.

Wonder Todos then opts its team-goals resource into unit access in backend bootstrap configuration:

const APPLICATION_ORGANIZATION_UNIT_NARROWING: Readonly<Partial<Record<string, { unitFieldName: string }>>> = {
  [TasksManager_ResourceType.TEAM_GOALS]: { unitFieldName: 'organizationUnitId' },
};

const initializationConfig = buildApplicationInitializationConfig({
  additionalServiceImplementations: userSelfCustomImplementations,
  organizationUnitNarrowing: APPLICATION_ORGANIZATION_UNIT_NARROWING,
});

This source excerpt omits unrelated bootstrap options. organizationUnitId is a real field on the team-goal schema. The resource’s operations require CORE_ORG_ROLES.ORG_MANAGER. A person holding only ORG_MEMBER organisation-wide can therefore qualify through a manager grant in one unit; an organisation-wide manager remains unrestricted by units. Requiring ORG_MEMBER instead would already admit ordinary members across the organisation. The complete team-access example connects the field, operation roles, resource registration and membership request.

Make customer switching a coordinated transition

Use the shell’s organisation switcher to commit the scope, update the live connection and reset navigation. The read-cache bridge observes the committed scope, clears cached entries and releases its coverage before subsequent reads establish new claims. A custom header that only changes an organisation label or local-storage key does not perform that transition.

Working-record selectors, such as a selected list, operate within that context. Their values shape routes and scope-aware requests; they never substitute for the server’s access checks.

Configure defaults through their real consumers

Identification supplies the matched tenant’s logo and optional primary colour. The standard login layout can present them when its public brand header is enabled; an explicit shell identity takes precedence. Custom login layouts can reuse the same resolved presentation. Regional preferences sit below a member’s explicit choices. Language policy can restrict those language choices. Email category settings route or suppress mapped organisation emails, while all-member notifications remain a distinct audience.

Keep these mechanisms separate in the product configuration and test each with the active organisation. They solve different problems even though the administrator edits them in neighbouring settings screens.

Create the customer workspace

Give every customer a workspace Feature

An organisation is the customer workspace your application operates within. It brings together a business identity, its members, its settings and the records that belong to it.

People can belong to several organisations without those organisations becoming one shared account. The active organisation gives their work a context; resource scope and access rules determine what they can reach.

Example: Separate two customer accounts

A consultant works with two customers. Each has its own organisation, members and records; switching customers changes the workspace in which the consultant acts.

One application contains separate Acme and Northwind workspaces, each holding its own records.
For engineers
Start with the organisation as the scope root

Enable the organisations capability in the application configuration, then declare which business resources belong to that scope. The organisation record supplies identity and lifecycle; the relationship graph carries ownership into child resources. A tenant field alone is not a substitute for declaring the resource’s scope.

In wildo.saas.config.ts, keep the application’s other capabilities and enable this entry in engineCapabilities:

[EngineCapability.ORGANIZATIONS]: { enabled: true },

EngineCapability is exported by @wildo-ai/saas-models. This enables the organization mechanisms; it does not make every business record tenant-owned. Declare the primary ownership relationship for each relevant resource. This selected Wonder Todos declaration connects todos to their organization:

createResourcesRelationship(
  CoreResourceType.ORGANIZATIONS, TasksManager_ResourceType.TODOS,
  ResourceRelationshipCardinality.ONE, ResourceRelationshipCardinality.MANY,
  {
    nature: RelationshipNature.COMPOSITION,
    isPrimaryScope: true,
    foreignKeyField: 'organizationId',
    contextPolicy: {},
  },
)

The relationship factory and framework enums come from @wildo-ai/saas-models; TasksManager_ResourceType belongs to the example application. Its todo schema declares organizationId as a foreign key. The module contributes moduleResourcesRelationships through SharedSaaSModule.resourceRelationships, alongside its resource configuration factories and field identifiers, and the existing shared-module registry aggregates them. The resource factory derives organization scope from the primary relationship; do not maintain a second resourcePrimaryScope setting.

To verify the connection, use the authorized organization-contextual todo operations: records created for one customer should be available in that workspace, and an unrelated customer must not be able to address them. The caller’s actual membership and the operation’s roles still govern admission. The organization-scope guide follows the same declaration into query confinement.

Keep the workspace URL identity stable

The following is the actual slug declaration from organizations.shared.schemas.ts. It separates the stable URL identity from an editable display name:

slug: z.string()
  .min(1)
  .max(50)
  .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
  .isDBIndexed()
  .isUnique({ conflictMessageReference: ErrorCustomMessageReference.ORGANIZATION_SLUG_EXISTS })
  .isSummaryField()
  .excludeFromUpdate(),
Keep identity, membership and lifecycle separate

A person’s organisation roles live on their membership, not on the organisation or a global account-role list. The organisation’s type selects configured policy. Its status is controlled by lifecycle operations rather than a generic update.

A self-service create also creates the requester’s owner membership. An operator-created organisation follows a separate creation path; do not assume every create variant appoints the caller as its owner.

Use organisation types for provisioning choices and organisation membership for a person’s authority inside the workspace. Database uniqueness must be present in the deployed database: the schema’s index declaration and an applied SQL migration are distinct steps.

Shape how customer workspaces are created Mechanism

A product can serve different kinds of customer account. Organisation types give each kind its own available roles, membership defaults and authentication requirements.

Creation policy connects a type to registration when a workspace should appear as someone signs up. Other workspaces can be created through the authorised self-service or administrator paths.

Example: Create a workspace during sign-up

A business registration creates a customer workspace and makes the registrant its initial owner. The application can give another customer type a different onboarding path.

Team workspaces use self-service creation; Agency workspaces are created by an administrator.
For engineers
Declare the type and its creation policy together

The example below illustrates an organisation-type entry in organizationTypes. business-user is an application-owned user-type key: define that user type before referring to it here.

customer: {
  userTypes: ['business-user'],
  availableOrgRoles: [
    CORE_ORG_ROLES.ORG_OWNER,
    CORE_ORG_ROLES.ORG_ADMIN,
    CORE_ORG_ROLES.ORG_MEMBER,
  ],
  defaultMemberRole: CORE_ORG_ROLES.ORG_MEMBER,
  creationPolicy: {
    createOnUserTypeRegistration: ['business-user'],
    orgNameSource: OrganizationNameSource.ASK,
    defaultOwnerRole: CORE_ORG_ROLES.ORG_OWNER,
  },
},

Import the named role and name-source values from @wildo-ai/saas-models. userTypes describes the application user types membership grants; availableOrgRoles and defaultMemberRole describe authority inside this organisation. They are different dimensions.

Follow the creation path

When the configured user type registers, the registration flow creates the organisation through its internal create variant and establishes the owner membership. ASK, AUTO and ONBOARDING select how its name is obtained. A registering user type may match at most one organisation type; ambiguous matches are configuration errors.

The create prefix validates the type and rejects a policy-managed type outside the registration-managed path. Omitting creationPolicy leaves that type outside automatic registration creation; it does not itself grant permission to create it.

Make membership-dependent user types explicit

The two directions are configured separately. organizationTypes.customer.userTypes grants business-user when a person joins. To make that user type depend on continued membership, add requiresOrgMembership to the corresponding entry in the application’s userTypes map:

import type { UserTypeDefinition } from '@wildo-ai/saas-models';

// existingBusinessUserType is your complete, already configured user type.
const membershipBoundBusinessUser: UserTypeDefinition = {
  ...existingBusinessUserType,
  requiresOrgMembership: {
    orgTypes: ['customer'],
  },
};

Use membershipBoundBusinessUser as userTypes['business-user'] in the existing authored backend configuration, alongside organizationTypes.customer above. Keep the existing full auth policy, application roles and frontend usersManagement entry. This excerpt adds the membership condition; it does not replace the application’s identity or authentication setup.

ChangeEffect on the membership-dependent user type
An invitation is still pendingNo user-type grant from that invitation yet
The person accepts and joinsThe organization’s configured user types are added
One qualifying membership is lost, another remainsThe user type is preserved
The last qualifying membership is lostThe configured membership-dependent type is removed
requiresOrgMembership is absentThis leave-time reconciliation does not remove the type

A remaining membership qualifies only when it is active and its organization still confers authority. Losing a type can invalidate sessions; a membership change that removes no type does not imply that every session is terminated. Organization roles still govern access inside each workspace independently of this application-level type lifecycle.

Choose how organisation authentication may vary

A type can also carry authOverrides. Method changes follow the application’s orgAuthMethodPolicy: RESTRICT_ONLY removes methods, EXPAND_WITHIN_SET permits additions from expandableOrgMethods, and UNRESTRICTED accepts the override method set. Do not assume every application uses the restrictive default.

MFA, password and step-up settings have their own merge rules. Review the resolved policy, not just the type’s override block. Creation policy, access roles and authentication policy remain separate decisions.

Give people the right place in each workspace Feature

A membership connects a person to one organisation. It carries their roles and access state in that workspace, so the same person can be an administrator for one customer and a regular member for another.

Membership changes are real changes to authority. Invitations, role edits and removal use the organisation’s access rules and leave records of the change.

Example: One person, different responsibilities

A consultant administers their own workspace but joins a customer’s workspace as a member. Their customer access comes from that membership, not from the role they hold at home.

Alex is an administrator in Acme and a member in Northwind.
For engineers
Treat the membership as its own record

organizationMembers joins an organisation and a user. That pair is the membership’s business identity; roles and status describe what the connection currently permits. Directory information can change without changing the linked account.

Address the membership, not the account

An organization administrator updates an existing membership at the route below. MEMBERSHIP_ID is the membership record’s _id, not the global user ID; ORG_ID is the workspace that owns it. This example keeps ordinary membership and adds the manager role:

curl -X PUT "$BACKEND_URL/organizations/$ORG_ID/organization-members/$MEMBERSHIP_ID" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"roles":["ORG_MEMBER","ORG_MANAGER"]}'

Use roles available to that organization type and within the caller’s grant ceiling. The submitted array is the resulting role set, not an instruction to append one role. Read the membership back to check the persisted roles, then exercise the operation those roles are intended to permit. Preserve any existing responsibilities the person should retain; removing the last usable owner is refused.

The ordinary update cannot change userId or status. Accepting an invitation, suspending a member and reactivating a member each have their own guarded transition. Setting status: "ACTIVE" in this payload is not an acceptance flow.

Understand when a role takes effect

An invited membership does not confer the privileges of an active member. Acceptance activates the membership and is where organisation-granted application user types become effective. The organization-type configuration declares both the types granted on joining and the membership requirement used to revoke them later. Role edits check the caller’s grant ceiling, including roles that will only become active after invitation acceptance.

Remove access through the right action

A pending invitation is revoked through its invitation operation. An established member is removed with DELETE /organizations/{organizationId}/organization-members/{membershipId} using an authorized organization administrator. This removes their membership in that workspace, not their global account. The implementations distinguish these states, remove dependent unit assignments where applicable and reconcile application-level user types that came from the organisation.

The owner floor protects changes that would retire the last usable owner. A role array is therefore not merely editable metadata: its update participates in authority and continuity checks. Use unit roles when a person’s responsibility applies to one department rather than the whole organisation.

Reflect the customer’s way of working

Give each organisation its own identity Feature

Each organisation has a profile for its business information: name, contact details, location and social links. Its identity stays separate from the people who administer it.

Store a customer logo and primary colour alongside that profile. The standard sign-in page can present the identified customer when the public brand header is enabled. Custom layouts can use the same presentation contract.

Example: Recognise the customer workspace

A customer adds its logo without choosing a colour. After identification, the enabled sign-in brand header displays the customer logo and keeps its existing colour treatment when the tenant has supplied none.

Acme's logo and blue palette carry from its profile to its sign-in screen.
For engineers
Update the profile, not the tenant’s lifecycle record

The profile is a resource associated with the organisation. This illustrative profile payload shows company information alongside the branding values used by sign-in presentation:

{
  "displayName": "North Studio",
  "website": "https://north.example",
  "branding": {
    "logo": "https://north.example/logo.png",
    "primaryColor": "#234C72"
  }
}

Submit the fields through the organisation profile’s generated update operation, with authority over that organisation. The logo is an absolute URL. A profile edit does not verify ownership of a sign-in domain or change how users authenticate.

Follow branding to its consumer

authentication-orchestrator.backend.service.ts loads branding for the organisation resolved during identification. resolveTenantBrandPresentation resolves fields independently. The caller supplies the application logo as fallback; it supplies no application primary colour for this DTO, so an unset tenant colour remains absent.

The standard AuthPage_Main passes auth.identification?.organizationBranding to its public layout. usePublicShellLayout applies that input only for the login card layout, when no explicit shell identity overrides it. The header must be enabled with publicShellIdentityMode: PublicShellIdentityMode.BRAND_HEADER in the frontend configuration; an absent header is not enabled merely because a profile has a logo.

Reuse the presentation contract in a custom login layout

A custom public-layout implementation can obtain the same resolved identity through the exported hooks:

import { useAuthSession, usePublicShellLayout } from '@wildo-ai/saas-frontend-lib';

export function useIdentifiedLoginLayout() {
  const { auth } = useAuthSession();
  return usePublicShellLayout(
    true,
    auth.identification?.organizationBranding,
  );
}

This illustrative hook belongs in a custom login layout beneath the normal application, UI and authentication providers. Render the returned brandHeader only when it is present. It supplies the resolved name and optional logo; brandAccentColor supplies the optional decorative tenant colour. Keep the existing label beside an unclassified logo and use the application’s design-system colour handling rather than treating an arbitrary profile colour as a text-contrast policy.

SituationResolved login presentation
No identified organizationApplication identity
Identified organization with a logoTenant logo; identified name when supplied
Tenant logo absentApplication logo fallback
Tenant primary colour absentNo tenant accent override
Explicit shell identity presentShell identity takes precedence
Public brand header not enabledNo brand header

A verified organization domain can supply login branding even when SSO is disabled. The name comes from the organization record; the logo and colour come from its profile. This presentation lookup does not select sign-in methods, grant membership or change access rules. Pass identification branding only to the intended login layout; registration and legal pages should not inherit the last login attempt’s tenant.

That is the supported presentation contract here. The stored favicon, secondaryColor and customDomain fields do not imply corresponding sign-in DTO fields or automatic domain provisioning. Business-profile content is available through the resource pipeline; custom screens can choose how to present it.

Keep domain verification in the authentication configuration. A business website or profile domain describes the organisation; it is not proof that the organisation controls that domain.

Let each workspace set its defaults Feature

An organisation can establish shared language and formatting defaults. Members start from those conventions and can retain their own regional choices.

Language policy also decides whether members may choose another supported language. Shared defaults and enforced language are separate choices.

Example: Use a shared timezone by default

A workspace sets its timezone and date format. A member who has made no personal choice inherits them; a member’s explicit regional preference continues to take priority.

Acme's language and time-zone preferences shape how calendar information is presented.
For engineers
Distinguish language policy from regional defaults

An illustrative organisation-preferences update is:

{
  "timezone": "Europe/Paris",
  "locale": "en",
  "dateFormat": "LOCALE_DEFAULT",
  "timeFormat": "24h",
  "languagePreferences": {
    "primaryLanguage": "en",
    "supportedLanguages": ["en"],
    "allowUserOverride": true,
    "enforceForAllMembers": false
  }
}

Use your generated schema’s actual DateFormat, TimeFormat and AvailableLanguage values when constructing this payload; the named values above describe the intended choices. The resource is seeded with defaults when the organisation is created and edited through its organisation-scoped update operation.

Follow the active organisation into the interface

UserOrgsContext publishes the active organisation’s language and regional preferences. I18nContext stores them separately from the member’s preferences. resolveRegionalFormatting resolves each regional field from member choice, then organisation default, then the framework fallback.

Language resolution additionally respects the application’s organisation-override policy and the tenant’s supported languages. enforceForAllMembers or disabling allowUserOverride can require the tenant’s language; these switches do not turn its regional settings into forced personal values.

Check both inheritance and an explicit choice

Verify a member with no regional values first, then one with an explicit timezone or format. A third useful check switches organisations: defaults should follow the new organisation while the member’s deliberate override remains theirs.

Send workspace emails to the right people Feature

A workspace can direct billing, security, administration and user-management emails to the addresses responsible for them. A category address replaces the usual member delivery for that category.

The organisation can also disable a category. Messages intended for every member remain a separate audience rather than being collapsed into one shared mailbox.

Example: Route security messages to a shared inbox

A customer sends its security-category emails to its security team’s address and billing emails to finance. Each message goes to the configured destination instead of also being copied to the ordinary member audience.

Acme routes notifications to separate Finance, Security and Administration destinations.
For engineers
Configure addresses and category switches together

This illustrative emailNotifications value routes two categories and leaves the others enabled with a default address:

{
  "emailNotifications": {
    "defaultEmailAddress": "operations@example.com",
    "billingAddress": "finance@example.com",
    "securityAddress": "security@example.com",
    "billing": true,
    "security": true,
    "administrative": true,
    "usersManagement": true
  }
}

The organisation notification resource is seeded during organisation creation. Update its settings through the generated organisation-scoped resource operation; no custom dispatcher is needed for these standard categories.

Routing still needs a registered email template and a configured delivery provider. Follow the template registration example and transactional email setup for those prerequisites. Saving a destination address does not supply either one or prove message delivery.

Understand delivery precedence

For an email with a mapped organisation target, the dispatcher checks the category switch first. false suppresses the category, including ordinary member fan-out. Otherwise it uses the category address, then defaultEmailAddress, then the existing member-target resolution when neither address is set.

ORGANIZATION_USERS intentionally remains member fan-out. These settings route email; they do not redirect every channel or every notification target.

Keep fallback behavior visible to operators

A missing settings record or a failed settings read falls back to member delivery. It does not silently drop the message. If a customer requires all mail to remain in a shared inbox, monitor failed settings reads as part of operating that requirement.

The category is derived from the notification target. Declaring another category flag on each notification would create a second answer that could disagree with the routing policy.

Organize teams and responsibilities

Organise work into teams and departments Feature

Organisation units describe departments, teams or regions inside a customer workspace. They can form a hierarchy, so the structure reflects how that customer actually works.

Units also give scoped responsibilities a place to live. Assignments and resource configuration decide when the organisational structure should affect access.

Example: Keep teams inside one customer account

A customer groups work under Sales and Support, with regional teams beneath Sales. The customer remains one organisation while each team has a recognisable place in its structure.

Acme contains North and South units; North contains Sales and Support.
For engineers
Create a node with a deliberate inheritance policy

This illustrative unit-create payload defines a department under an existing parent. The route supplies the organisation context, and parentUnitId names a unit in that organisation:

{
  "name": "European Sales",
  "code": "EU-SALES",
  "parentUnitId": "existing-sales-unit-id",
  "inheritanceType": "inherit_down"
}

Replace the example ID with a real parent. inherit_down lets a role held at this unit reach its descendants; no_inheritance limits it to the assigned unit. There is no upward permission inheritance.

Move through the operation that owns the tree

parentUnitId is excluded from generic updates. Moving a node changes its descendants’ materialised paths too, so use the unit’s move operation. The node’s path is server-owned, not something the client calculates and patches.

A unit’s optional code is unique among siblings. The parent is part of that identity: two departments may use the same code under different parents without representing the same node.

Invoke moves and lifecycle transitions on the addressed unit

For an organization administrator, moving a unit uses this request. UNIT_ID and NEW_PARENT_ID must identify units in the addressed organization:

curl -X PUT "$BACKEND_URL/organizations/$ORG_ID/organization-units/$UNIT_ID/move" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  --data "{\"newParentUnitId\":\"$NEW_PARENT_ID\",\"reason\":\"Align the regional sales structure\"}"

The move payload names newParentUnitId, not the stored parentUnitId. Send {} to promote the unit to a root; an empty ID is not the root command. The server validates the destination and updates the moved subtree’s paths. Re-read the unit and its descendants to confirm the new hierarchy.

Archiving requires a nonempty reason of at most 500 characters:

curl -X PUT "$BACKEND_URL/organizations/$ORG_ID/organization-units/$UNIT_ID/archive" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"reason":"The regional team has been reorganized"}'

To restore the unit, use PUT on the same route with /restore and send {} or an optional reason of at most 500 characters. Archiving changes that node rather than rewriting every descendant. Restoring a child requires its ancestor chain to be active, so restore archived parents first. These actions change unit availability; they do not move the customer to another tenant or replace membership assignments.

Connect structure to authority explicitly

A tree does not automatically filter all business records. Add unit assignments and opt the relevant resources into unit-based access. Review broad grants at root units carefully: downward inheritance from the root can reach the whole chart even though its source is one unit assignment.

Assign responsibility within a team Feature

A member can hold a role inside a particular unit. That lets someone manage their department without becoming a manager across the entire customer workspace.

The assignment belongs to the person’s organisation membership. It cannot be used to give an outsider a role in a team they do not belong to.

Example: Make a department manager

A member manages Sales and has a different role in Support. Their Sales responsibility can grant access to Sales-owned records without granting the same access in Support.

Alex holds the manager role within the customer's North unit, alongside a separate South unit.
For engineers
Create an assignment between a membership and a unit

This illustrative unit-member payload names an existing organisation membership and a unit in the same organisation:

{
  "organizationMemberId": "existing-membership-id",
  "organizationUnitId": "existing-sales-unit-id",
  "role": "ORG_MANAGER"
}

The generated operation supplies organisation context. Use the membership ID, not the global user ID. The assignment’s identity is the membership/unit pair; changing its role updates that assignment, while assigning another unit creates a different one.

Address the assignment and keep grant authority separate

Use an organization administrator’s authenticated token to create the assignment:

curl "$BACKEND_URL/organizations/$ORG_ID/organization-unit-members" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  --data "{\"organizationMemberId\":\"$MEMBERSHIP_ID\",\"organizationUnitId\":\"$UNIT_ID\",\"role\":\"ORG_MANAGER\"}"

The built-in CREATE, UPDATE and DELETE operations require ORG_ADMIN, with the role ceiling additionally checking what the caller may grant. Holding a manager role in one unit does not create a self-service right to grant more roles.

Keep the created assignment’s _id for later changes. Update its role through the addressed assignment’s UPDATE operation; remove it through DELETE. organizationMemberId and organizationUnitId are excluded from updates. Moving the person to another unit means creating the new assignment and deliberately retiring the old one, not changing that immutable pair.

Verify the resulting assignment and then call a business resource configured for team-based access. The assignment alone does not filter every resource or remove a broader organization-wide grant.

Follow the role into an access decision

The execution context carries the role with its organizationUnitId. On an opted-in resource, the authorisation decision checks which assignments actually satisfy the operation’s required roles. It does not include every unit the member happens to belong to.

Downward inheritance can extend reach to descendants according to the unit policy. The context distinguishes directly held and inherited units so the resulting scope remains explainable.

Do not accidentally grant organisation-wide access

The grant ceiling applies to both create and update. Unit roles do not replace an already sufficient organisation-wide role; if that broader role permits the operation, unit narrowing does not subtract its access. Choose organisation roles and unit assignments together, then test with a member whose only qualifying role is the unit assignment.

Keep team access with the team’s records Mechanism

A resource can identify the unit each record belongs to. People whose authority comes from a unit role then reach the records of the units that actually grant them that authority.

Organisation-wide roles remain organisation-wide. This adds a narrower way to grant access; it does not silently reduce the access leadership already holds.

Example: Let a team manage its own goals

A Sales manager works with Sales goals through a unit role. An organisation manager can still review goals across teams through their broader organisation role.

A role reaches records in the customer's North unit while South remains separate.
For engineers
Give the resource a real unit field

Wonder Todos’ TeamGoals_Schema contains both the customer and team identifiers. These selected fields are from shared-lib/src/modules/tasks-manager/resources/team-goals/team-goals.schemas.ts, after initZodDecorators(z) initializes the decorators:

organizationId: z.string().min(1).isDBIndexed().isForeignKey().isSummaryField().excludeFromUpdate(),
organizationUnitId: z.string().min(1).isDBIndexed().isForeignKey().isSummaryField(),

These are fields inside z.object, not a standalone schema. The organization relationship establishes tenant ownership; organizationUnitId identifies the team within it. The required field prevents unit-less goals. It remains editable so an organization-wide manager can move a goal, while a confined manager cannot move it outside their permitted units.

Require a role that the team grant can supply

The resource’s operationsConfiguration requires CORE_ORG_ROLES.ORG_MANAGER. Here is its READ entry; Wonder Todos makes the same role choice for LIST, CREATE, UPDATE and DELETE:

[CoreResourceOperation.READ]: {
  variants: [{
    variantType: ResourceOperationVariantType.API_CALL,
    isDefault: true,
    roles: [CORE_ORG_ROLES.ORG_MANAGER],
    riskLevel: ResourceOperationRiskLevel.LOW,
  }],
},

The operation enums and CORE_ORG_ROLES are exported by @wildo-ai/saas-models. This entry belongs in the resource configuration passed to createResourceConfiguration_Initialization, alongside mainSchema: TeamGoals_Schema, its resource identifiers, relationships and declared core operations.

The role choice is decisive. With ORG_MEMBER on an operation, an ordinary member already qualifies organization-wide; adding a team grant would not confine that access. With ORG_MANAGER, a person who is only an organization member can qualify through their manager role in Sales, while an organization-wide manager retains broader access.

Register the resource and its narrowing policy

Wonder Todos opts its team-goals resource into narrowing in backend-api/src/bootstrap-config.ts:

const APPLICATION_ORGANIZATION_UNIT_NARROWING: Readonly<Partial<Record<string, { unitFieldName: string }>>> = {
  [TasksManager_ResourceType.TEAM_GOALS]: { unitFieldName: 'organizationUnitId' },
};

const initializationConfig = buildApplicationInitializationConfig({
  additionalServiceImplementations: userSelfCustomImplementations,
  organizationUnitNarrowing: APPLICATION_ORGANIZATION_UNIT_NARROWING,
});

This excerpt keeps the narrowing registration and omits unrelated bootstrap options. The resource factory is separately registered in the task module’s moduleResourcesConfigurationsFactoryMap:

[TasksManager_ResourceType.TEAM_GOALS]: teamGoals_ResourceConfiguration_InitializationFactory,

That is an entry in the existing module map, not a replacement for it. Keep the task module and its relationship declarations in the application’s shared module registration, and pass the backend initialization configuration through the existing startup path. Registering the narrowing field alone does not publish a resource or its routes.

Startup rejects forbidden authorization resources and empty field names. This validator does not receive the resource schema and does not check that the named field exists: matching unitFieldName to a real field remains an authoring obligation. The declaration is backend policy, not a request parameter a caller may use to choose authority.

Assign a team role and call the resource

This request sequence follows Wonder Todos’ organization-unit-narrowing.e2e.ts. Start with two sibling root units, Sales and Legal, and one goal in each. MEMBERSHIP_ID is the person’s existing organization-membership row ID, not their user ID. ADMIN_TOKEN belongs to an organization administrator allowed to assign the role; MEMBER_TOKEN belongs to a person holding only ORG_MEMBER organization-wide.

curl "$BACKEND_URL/organizations/$ORG_ID/organization-unit-members" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  --data "{\"organizationUnitId\":\"$SALES_UNIT_ID\",\"organizationMemberId\":\"$MEMBERSHIP_ID\",\"role\":\"ORG_MANAGER\"}"

curl "$BACKEND_URL/organizations/$ORG_ID/team-goals" \
  -H "Authorization: Bearer $MEMBER_TOKEN"

The first request grants the Sales unit role; it does not promote the person’s organization membership. The second lists goals using that person’s own authority. In this setup, the list includes Sales goals and excludes Legal goals. First confirm that an organization-wide administrator can reach the same registered collection: a missing route is not evidence of confinement.

Apply the same answer at the gate and the records

resolveOrganizationUnitNarrowingDecision first checks whether an organisation-wide grant satisfies the operation. Otherwise it selects qualifying unit grants and returns the unit IDs that may be used by the row filter and write validation.

Caller’s authorityResult
Sufficient organisation-wide roleOrganisation-wide access
Only a qualifying unit roleAccess confined to qualifying units
Unit membership with an insufficient roleNo grant from that unit
Neither kind of qualifying roleRefusal
Test the boundaries, not only the list

Check a list, an addressed read and a write naming another unit. Also check a member assigned different roles in two units: only the unit whose role satisfies the operation should qualify. Context stamps can tighten when combined, not overwrite a narrower decision with a wider one later in the same request.

Keep people in the right context

Move between customer workspaces smoothly Mechanism

People who belong to several organisations can switch workspace from the user menu. The application changes context without requiring a full page reload.

The switch clears the previous organisation’s navigation and updates the live connection. The server continues to enforce the selected workspace’s access rules on requests.

Example: Finish with one customer, open another

A consultant switches from one customer to the next. The application returns to its home context, clears the old navigation and changes the live connection’s organisation subscriptions.

A person switches from Acme's records to Northwind's home context, leaving Acme's records in place.
For engineers
Use the shell’s organisation switcher

Place the organisation switcher in the application user menu. Its behavior commits the new organisation through the scope context; custom controls should use that same path rather than only changing a stored ID.

The central commit in AppScopeContext.tsx is:

const commitOrganizationScopeValue = useCallback((organizationId: string | null) => {
  setCurrentOrganizationId(previousOrganizationId => {
    if (previousOrganizationId === organizationId) {
      return previousOrganizationId;
    }

    tokenStorage.setOrganizationId(organizationId);

    if (previousOrganizationId && organizationId) {
      notifyOrganizationSwitch(organizationId, previousOrganizationId);
    }

    return organizationId;
  });
}, [notifyOrganizationSwitch, tokenStorage]);
Use the public switch operation in custom controls

A custom React control can use the public hook instead of reproducing the internal scope commit:

import { useUserOrgs } from '@wildo-ai/saas-frontend-lib';

export function useWorkspaceSelection() {
  const { setCurrentOrganization } = useUserOrgs();
  return (organizationId: string) => {
    setCurrentOrganization(organizationId);
  };
}

This illustrative hook belongs beneath the application’s normal providers, including UserOrgsProvider. Connect its returned callback to the existing organization picker and pass an ID from that user’s loaded organization list. The setter checks that list, ignores an unknown ID and does nothing when the selected organization is already active. It returns no new membership or server authorization; the backend still checks each request.

Follow the consumers of that change

AuthSessionContext re-points WebSocket organisation subscriptions. NavigationContext handles PRIMARY_SCOPE_CHANGED inside a navigation transaction and clears old stacks before going home. WebSocketContext independently releases room claims for the previous organisation.

The read cache has a separate owner. In the standard ApplicationMainContext composition, ResourceReadCacheBridge mounts inside AppScopeProvider. An organization change clears cached reads and releases its coverage claims; subsequent reads establish coverage for the new scope. Pending room replies are checked against the current request so a late reply cannot restore retired coverage or remove a newer claim.

Initial organisation establishment is treated differently from switching between established organisations. A refresh that restores the existing organisation must preserve a legitimate deep link rather than send the person home.

Verify with two real memberships

Use one account with access to two organisations containing different records. Open a record, switch, then inspect navigation, list results and live updates. A same-organisation no-op and a hard refresh of a deep link are separate checks; neither proves a genuine organisation-to-organisation transition.

Keep the current project close at hand Feature

A scope selector keeps a relevant project, list or other parent record available in the application shell. The selection supplies context to routes and scope-aware lists as people move through their work.

It follows the current surface as well as letting people change context. It is a navigation aid; the backend still decides which records the person may access.

Example: Stay within the selected list

A person selects a task list from the shell. Related task views use that list as context, and opening another list updates the selection to match.

Selecting the Launch project updates two related views to the same project context.
For engineers
Declare the scope field at the module’s shell boundary

Wonder Todos declares this selector in app-shell.module.frontend.ts:

export const moduleQuickSwitcherFields: QuickSwitcherScopeField[] = [
  {
    resourceType: TasksManager_ResourceType.TODO_LISTS,
    fieldIdentifier: 'todoListId',
    position: 0,
    persist: true,
    optionDisplayMode: ForeignKeyDisplayMode.SUMMARY,
    presentation: QuickSwitcherFieldPresentation.ICON_LED,
  },
];

The module contributes quickSwitcherFields through ShellModuleContributions. resourceType selects the records offered; fieldIdentifier is the value the scope carries. The resource and scope-aware routes must already exist.

The shell must also enable ApplicationLevelComponentType.QUICK_SWITCHER and host that component in its menubar or sidebar. Follow the complete shell hosting example for the module aggregation and both shell configuration entries. An existing host can be reused; declaring fields alone does not put a selector on screen.

Separate the option from the closed control

SUMMARY shows the resource’s summary content in each option. ICON_LED controls how the closed selector sits in the shell; it does not change the option’s record representation. position orders controls and persist retains the selection.

Let routes and selection agree

AppScopeContext turns scope values into route identifiers and request parameters. A record surface can establish its selected record; a collection surface can clear the corresponding selection. Configure one selector per resource scope rather than duplicating competing controls.

The selector does not grant membership or create a security filter. Resource scope and authorisation remain necessary even when the control only offers records the user can see.

Help people sign in. Keep access under control.

Offer the sign-in methods your users need, from passwords and passkeys to their company’s identity provider. Wildo connects them to account policy and the session that follows. Native MFA and identity-provider assurance have separate configuration paths.

The same system handles recovery, sensitive-action confirmation and withdrawal of existing access. You choose the requirements for each kind of account and customer; Wildo supplies the shared flows that apply them.

Alternative sign-in methods pass through policy into a session, with fresh proof for a sensitive action.

One account, a consistent access lifecycle

Choose the right proof

Enable familiar sign-in methods and add stronger proof for privileged accounts. Enrollment and challenges follow the accepted policy.

Honor customer requirements

Application-authored organization-type policies and enterprise identity connections shape access without a separate authentication implementation for every customer.

Control what happens next

Rotate sessions, recover credentials and withdraw access through connected operations. Sensitive actions can require fresh proof even after sign-in.

Example: Protect an administrator without slowing everyday work

An administrator signs in with the required factors, then works normally. Deleting an account asks for fresh proof. If a password is forgotten, its owner requests a recovery link. Completing the password change withdraws earlier tokens.

For engineers

Start with the account type and frontend admission

Application-authored authentication belongs in saas-config.backend.ts. A user type selects authMethodsEnabled, registration policy, password requirements, MFA and session behavior. The frontend’s usersManagement entry decides which types may authenticate or register there. These are separate gates: a configured method is not useful on a frontend that does not admit its user type.

DecisionOwning configurationResult
How an existing account signs inauthMethodsEnabledEligible first and second factors
How a new account is createdregistration plus frontend admissionSignup method and verification path
What a customer requiresApplication-authored organization-type overrides and scoped SSO configurationEffective method and proof requirements
Which actions need fresh proofResource operation and step-up policyReauthentication before the action
How sessions continueUser-type concurrency cap and deployment token settingsSession limits, token expiry and rotation

Follow the authentication state, not just the first response

In native sign-in, a successful first factor may lead to MFA, enrollment or another required step. The standard frontend follows the orchestrator’s result. Provider callbacks use their own completion path and provider-specific assurance settings. Custom clients must follow the result of the chosen path; local MFA policy does not automatically add a challenge to every external callback.

Passkeys need relying-party and origin configuration. Social sign-in needs a provider connection. Enterprise SSO additionally needs the appropriate scope connection and verified routing authority. The detailed sections below connect each method to those prerequisites.

Keep credential and account changes on their owning operations

Password changes, forced resets and sign-out-everywhere use token invalidation. Single-session logout has a narrower scope. Cross-tab coordination clears browser state, while backend guards remain responsible for refusing withdrawn credentials.

Account suspension, voluntary deactivation and deletion have different transitions. Restoring a voluntarily deactivated account is an administrative action. Avoid implementing these as direct patches to status, roles or credential storage: doing so would omit the lifecycle behavior attached to the named operations.

Continue with account policies, fresh proof for sensitive actions and session rotation for their connected contracts.

Choose how people sign in

Set sign-in rules for each kind of account Feature

Members and administrators do different work and carry different responsibility. Give each kind of account its own password requirements, failed-attempt policy and enabled sign-in methods.

Wildo applies those choices through the shared sign-in flow. Your application defines the policy; it does not need to implement a separate login system for every audience.

Example: Stronger requirements for administrators

Members can use the normal password policy, while administrators need a longer password and face a tighter failed-attempt limit. Both use the same application sign-in experience.

Member and administrator account types each have their own password rules.
For engineers
Declare the policy where the user type is defined

saas-config.backend.ts owns application-authored identity behavior. In Wonder Todos, these are selected settings from the admin.auth object:

passwordPolicy: {
  minLength: 12,
  maxLength: 128,
  requireUppercase: true,
  requireLowercase: true,
  requireNumbers: true,
  requireSpecialChars: true,
  expiryDays: 90,
},
sessionDurationMinutes: 30,
maxConcurrentSessions: 3,
lockoutPolicy: {
  maxLoginAttempts: 3,
  lockoutDurationMinutes: 60,
  progressiveLockout: true,
  maxProgressiveLockoutHours: 24,
},

Password length, composition and expiry govern the credential. lockoutPolicy governs failed attempts. sessionDurationMinutes limits the temporary authentication episode in which sign-in steps are completed; it does not set the issued access token’s expiry. Registration and invitation episodes are created with a 24-hour expiry; idle timeout and subsequent session-store updates also affect how long they remain usable. maxConcurrentSessions limits authenticated sessions. JWT signing keys and access/refresh token lifetimes belong to deployment runtime configuration.

Make the user type available on the frontend

Enabling AuthMethod.PASSWORD is necessary, but the frontend must also admit the type. Wonder Todos declares:

frontendServices: {
  'wonder-todos-app': {
    usersManagement: {
      member: { register: true, auth: true, isDefault: true },
      admin: { register: false, auth: true },
    },
  },
},

An administrator can authenticate on this frontend but cannot register there. registration.allowedMethods separately controls account creation; the enabled sign-in set is not a signup policy.

Let the authentication flow finish

The standard interface identifies the account’s applicable methods and continues authentication. Password verification alone is not necessarily the final result: required email verification, MFA and account status still matter before a usable session is issued. Custom clients should follow the returned authentication state rather than assume that a successful first factor means access has been granted.

Sign in with a device you already trust Feature

Let people use a passkey instead of remembering a password. Wildo handles the challenge, credential enrollment and sign-in verification, and keeps passkeys attached to the person’s existing account.

Your application enables the method and defines its relying-party settings. People can manage their enrolled credentials through the supplied account controls.

Example: Use a passkey on the next visit

A member enrolls a passkey while authenticated. On a later visit, their authenticator proves possession for this application and the normal account policy decides the remaining sign-in steps.

A person uses a fingerprint on their phone to access their account with a passkey.
For engineers
Configure the application identity first

Enable AuthMethod.PASSKEY for the relevant user type and configure the passkey relying party and operational settings. Expected origins come from configured runtime public URLs. These values must match the deployed frontend; enabling the flag alone does not make a credential valid for an arbitrary hostname.

Enrollment proves the application’s challenge

The standard interface requests registration options, invokes the browser authenticator and submits its response. The backend consumes the stored registration challenge and verifies it against the relying party and expected origin. This is the verification call in webauthn.backend.service.ts:

const verification = await verifyRegistrationResponse({
  response,
  expectedChallenge,
  expectedOrigin: this.expectedOrigins,
  expectedRPID: this.rpId,
  requireUserVerification: this.passkeyOps.userVerification === 'required',
});

Successful verification stores the credential’s public key, identifier and authenticator metadata. The secret stays with the authenticator. Public signup still requires email proof before passkey enrollment; an authenticator is not proof of an inbox address.

Authentication has its own ceremony

A later sign-in creates a fresh authentication challenge. The backend consumes it once, verifies the assertion and updates the credential’s counter and last-used information. Synced passkeys with a zero counter are supported; a verified assertion whose nonzero counter regresses is refused and recorded as suspicious.

Sensitive-action reauthentication uses a separate passkey challenge and requires user verification. That distinction prevents a login response from being reused for a high-impact action. Enrollment, naming and removal are separate account operations; removing a credential remains subject to account method-management policy.

Confirm access with a phone code Feature

Offer a short-lived code sent to a verified phone when that method fits the account policy. Wildo connects phone verification, delivery limits and code checking to the normal authentication flow.

You choose where SMS is enabled and configure a delivery provider. Verifying a phone number and signing in with it remain separate actions.

Example: Use an enrolled phone as a second step

A member first proves their primary credential, then receives a code on their verified phone. The code completes the enabled second-factor step.

A verified phone displays a one-time code beside a clock and confirmation mark.
For engineers
Establish the phone before using it as proof

Phone enrollment uses phoneVerifySend(phoneNumber) and phoneVerifyConfirm(phoneNumber, code). Confirmation persists the verified phone on the credential record. This enrollment action does not itself sign the person in.

Send and submit the authentication challenge

The standard SmsOtpAuthMethod.tsx receives an authentication sessionId and uses it for both steps. The send handler calls getManualCallsHttpClient().smsOTPSend(sessionId); it does not let the person substitute an arbitrary destination for their verified phone. The submit handler then continues that same authentication session:

const handleVerify = useCallback(async (data: SmsOtpChallengeFormDto) => {
  clearCodeFormError();
  try {
    const response = await handleMfaVerify(sessionId, AuthMethod.SMS_OTP, data.code.trim());
    if (response.success) {
      onSuccess(response);
    } else {
      setCodeFormError(response.error || t(MfaChallengeLabel.ERROR_INVALID_CODE));
    }
  } catch (e: any) {
    setCodeFormError(e?.message || t(MfaChallengeLabel.ERROR_INVALID_CODE));
  }
}, [sessionId, handleMfaVerify, onSuccess, t, clearCodeFormError, setCodeFormError]);

This is the existing frontend challenge handler, not a complete standalone component. handleMfaVerify comes from the authentication flow, while onSuccess hands its response back to the parent. Failed verification stays in the challenge form; a successful code follows the server’s authentication result.

Enable the method and wire the provider

Start with the SMS delivery setup: enable EngineCapability.SMS, select the backend Twilio provider, synchronize its artifacts, supply TWILIO_CREDENTIALS and configure the sender number. Keep the secret in the backend deployment environment. The provider guide contains the complete configuration fragments; choosing an authentication method does not perform those steps.

In backend-api/src/saas-config.backend.ts, merge this policy into the intended userTypes.<type>.auth. Import AuthMethod from @wildo-ai/saas-models; existingMemberAuth is that type’s existing complete policy:

const memberAuth = {
  ...existingMemberAuth,
  authMethodsEnabled: {
    ...existingMemberAuth.authMethodsEnabled,
    [AuthMethod.SMS_OTP]: true,
  },
};

SMS can be a first or second factor. This declaration enables the method; it does not require MFA or weaken an existing MFA requirement. For a password-then-SMS flow, password must also be enabled and the first credential must succeed before the SMS challenge completes the session. If SMS should qualify as a second factor, include AuthMethod.SMS_OTP in mfaPolicy.acceptableMFAMethods and check mfaPolicy.requireStrongMFA within the same user-type authentication policy. Keep the MFA policy consistent with the factors the application intends to accept.

Set the challenge window and sending budgets

Wonder Todos supplies the following backend operational settings under auth.otpOperational.smsOtp. These are explicit example values, not a claim that enabling SMS installs a provider or enrolls a phone. Merge this entry alongside the existing email-OTP settings:

smsOtp: {
  otpLength: 6,
  expiryMinutes: 5,
  rateLimitBurst: 1,
  rateLimitHourly: 5,
},

The burst and hourly limits apply to the resolved phone destination; the SMS-send controller also enforces its own source-IP limit. Increasing a phone budget does not remove the source-IP gate. The phone-enrollment challenge is a separate flow with its own checks; these settings describe authentication SMS codes.

StepWhat must already be trueObservable result
Enroll the phoneThe account can access the phone-verification flow and delivery is configuredConfirmation records the verified phone; it does not sign in
Request a sign-in codeThe authentication session is in an eligible state and effective policy allows SMSThe provider accepts the submission, or the request fails
Submit the codeUse the same session and the received code within its validity windowAuthentication advances according to the server response

An accepted send is not evidence that the handset received the message. Complete the challenge on a controlled account to verify the real delivery path. A missing or expired authentication session, exhausted budget or provider refusal must not be reported as successful delivery.

The SMS backend service uses the provider’s request builder and response parser, validates the standard result and refuses provider-reported send failures.

Authentication issuance resolves the verified target, checks effective policy and applies delivery budgets. An older outstanding code is revoked when a replacement is created. Verification uses the authentication session and advances the normal flow after a successful comparison.

Respect the customer’s identity authority

An actively directory-managed account has local SMS authentication disabled by the effective policy, just as local passwordless methods are disabled. The phone belongs to the person and is not a revocable IdP secret, so the method’s policy is the barrier. Choose stronger factors when the account’s requirements call for independent device proof; SMS availability is not a claim that every security policy should accept it.

Let people use an existing sign-in identity Feature

Offer an external provider as a way into the application. Wildo handles the redirect, callback and identity linking, while the application keeps its own account and membership model.

You choose the provider and account policy. A verified provider identity must still meet the application’s conditions for linking or creating an account.

Example: Join through a familiar provider

A person signs in through an enabled provider, proves their verified address and reaches their application account rather than receiving a duplicate account on every visit.

An identity provider connects to a verified local account, with customer membership shown separately.
For engineers
Configure more than the button

Enable AuthMethod.EXTERNAL_OAUTH2 for the user type, supply the provider connection and credentials, and permit social registration separately if new accounts may be created. The provider’s declared scope and supported profile mapping determine which identity is returned.

The connected provider setup joins the backend login declaration, application client ID, server secret, per-user-type method and exact callback address. Use those prerequisites together; a provider button is only the visible entry to that flow.

Choose sign-in and registration separately

Example: allow an existing member to use a social provider, while keeping new-account registration closed. In backend-api/src/saas-config.backend.ts, merge this into the member’s existing auth policy. existingMemberAuth represents that full policy; retain its password, session, lockout and MFA settings. Import AuthMethod and RegistrationMode from @wildo-ai/saas-models.

const memberAuth = {
  ...existingMemberAuth,
  authMethodsEnabled: {
    ...existingMemberAuth.authMethodsEnabled,
    [AuthMethod.EXTERNAL_OAUTH2]: true,
  },
  registration: {
    ...existingMemberAuth.registration,
    mode: RegistrationMode.DISABLED,
    allowedMethods: [],
  },
  autoLinkByEmail: false,
};

This example also disables automatic email-based linking. Existing linked identities can sign in; a matching email alone cannot attach a new identity to the account. This posture is for accounts whose external identity is already linked; it does not establish that first link. If intentional verified-email linking is required, review and enable autoLinkByEmail across the target account’s held user types instead. Registration policy governs new accounts; it does not remove existing identity links.

The initiating frontend must also allow the same member type in frontendServices.<service>.usersManagement with auth: true. To offer public social registration instead, use RegistrationMode.OPEN, include AuthMethod.EXTERNAL_OAUTH2 in registration.allowedMethods, and enable register for that frontend/user-type pair. These are additional admissions, not consequences of displaying a provider button.

Returning identityWhat Wildo checks
Already linked subjectThe linked local account and its applicable sign-in policy
New subject with an existing verified emailEmail-linking policy across all held target account types; any explicit opt-out refuses linking
New subject and new emailSocial registration policy, allowed method and frontend registration admission
Let the standard flow carry the proof

The standard authentication flow stores one-time state and, where supported, a PKCE verifier before redirecting. OIDC requests also carry a nonce. On return, Wildo consumes the state, exchanges the authorization code and binds the verified identity to the request. A returned ID token must pass validation even when the profile also comes from a user-info endpoint.

A custom frontend should start and finish this flow through the authentication client. A browser-supplied profile, matching email or successful redirect is not authentication evidence.

The service first looks for the external subject’s existing link. Verified email is required for email-based linking and new social provisioning. Unverified provider email is not treated as account ownership. Existing-account linking and new-account registration are different decisions; configure both intentionally.

Profile and pending-invitation reconciliation run through the account/provisioning services. Enterprise SSO remains a separate tenant-owned connection model. A social provider button does not by itself satisfy a customer’s requirement to control its organization’s identity provider.

Add proof where it matters

Require another proof when it matters Feature

Add a second proof to sign-in for the accounts that need it. Wildo connects the policy, enrollment, challenge and recovery-code flow, so people are offered methods the application will actually accept.

You choose which factors qualify, whether MFA is required and how enrollment is introduced. A passkey can satisfy the policy without an extra challenge when you allow that.

Example: Protect privileged accounts

An administrator signs in with a password and confirms an authenticator code. Members can have a different policy, while recovery codes provide a way back when an enrolled device is unavailable.

Sign-in is followed by additional proof on a phone before entering the application.
For engineers
Choose acceptable factors, not just an MFA switch

The factor must be enabled as well as accepted by the MFA policy. Put both declarations under userTypes.<type>.auth in backend-api/src/saas-config.backend.ts; changing only acceptableMFAMethods does not enable an authenticator.

Example: require an authenticator app after password sign-in. existingAdminAuth is the application’s existing complete policy. Import AuthMethod from @wildo-ai/saas-models and merge this result back into that user type.

const adminAuth = {
  ...existingAdminAuth,
  authMethodsEnabled: {
    ...existingAdminAuth.authMethodsEnabled,
    [AuthMethod.PASSWORD]: true,
    [AuthMethod.TOTP]: true,
  },
  mfaPolicy: {
    ...existingAdminAuth.mfaPolicy,
    requireMFA: true,
    requireStrongMFA: true,
    acceptableMFAMethods: [AuthMethod.TOTP],
    enrollmentGracePeriodDays: 0,
    passkeyExemptFromMFA: false,
  },
};

This example preserves other enabled first factors. Review those deliberately if the application must require password specifically. TOTP is a second factor, not an alternative first-factor login. The account must enroll it through the standard setup flow; enabling the method does not create a secret for anyone.

SettingEffect
requireMFARequires additional proof for the applicable sign-in flow
requireStrongMFAExcludes factors the engine does not classify as strong
acceptableMFAMethodsRestricts which enabled second factors qualify
enrollmentGracePeriodDaysControls the enrollment transition; zero provides no grace interval
passkeyExemptFromMFAControls whether a passkey avoids another challenge
Decide what an external login must demonstrate

The same user type can receive social logins, whose evidence is assessed separately from native challenges. Set externalAssuranceEnforcement: ExternalAssuranceEnforcement.STRICT inside its mfaPolicy to refuse missing or insufficient required assurance; import the enum from @wildo-ai/saas-models. Omission means warn and audit, not strict refusal.

The TOTP-specific policy above cannot be satisfied merely by a provider saying “MFA happened.” Generic upstream MFA does not establish that a particular local factor was used. A configured enterprise OIDC connection instead delegates MFA enforcement to the customer’s IdP. It still must pass identity and token validation. Organization overrides can tighten the applicable policy; they cannot weaken strict enforcement.

Enrollment and verification are different steps

The TOTP setup service creates a secret, a QR-code URI and recovery codes. The person must prove a generated code before setup is marked verified. Recovery codes are shown for the person to retain; stored recovery values are hashed. TOTP acceptance atomically claims its absolute time step in Redis. Recovery-code consumption uses a conditional credential update, so a concurrent second consumption cannot also succeed.

The engine uses resolveAcceptedSecondFactorMethods for both offered and accepted methods. This prevents a flow from asking someone to enroll a factor that the next challenge refuses. In the native sign-in flow, the orchestrator carries the first-factor state into the second-factor step and issues the session after the required proof completes.

Registration completion is also distinct from a returning user’s login challenge. It can complete with an already-enrolled factor or under configured enrollment grace; otherwise it directs the person to enrollment.

Plan the recovery route

An enrolled TOTP code and an unused recovery code are checked through the same verification service. A used recovery code is removed from the stored set. The standard flow supplies enrollment and challenge interfaces; the application still decides which account types require protection and which factors are appropriate for their users.

Ask for fresh proof before a sensitive action Guarantee

A person can stay signed in for everyday work and still prove their identity again before a high-impact action. Wildo connects the operation’s requirement to a fresh authentication challenge and resumes the action after verification.

You select the operations and accepted proof methods. The extra check complements their access rules; it does not grant a role the person lacks.

Example: Confirm who is deleting an account

An administrator can browse account settings normally. Deleting an account requires fresh proof before the operation proceeds.

A person working in the application confirms their identity before completing a sensitive action.
For engineers
Put the requirement on the operation

The engine’s user-deletion API variant declares both who may call it and the additional proof it requires. Selected fields from users.shared.resources-config.schemas.ts:

variantType: ResourceOperationVariantType.API_CALL,
isDefault: true,
roles: [CORE_APP_ROLES.APP_ADMIN_SUPER_ADMIN],
admitsCrossSubjectUserAdministration: true,
riskLevel: ResourceOperationRiskLevel.CRITICAL,
requiresStepUpAuthentication: true,

riskLevel controls how danger is presented; it is not the reauthentication gate. requiresStepUpAuthentication is the explicit requirement. The role gate remains in force.

Exchange a fresh factor for an operation proof

The reauthentication service accepts password, an enrolled TOTP or recovery code, or a passkey assertion. Passkey step-up uses its own challenge and requires user verification, so a normal login assertion cannot be replayed as step-up.

After verifying the factor, AuthMethodManagementBackendService produces this proof:

const token = await this.consumableTokenService.createToken({
  tokenType: CoreConsumableTokenTypes.REAUTH,
  consumptionMode: ConsumableToken_ConsumptionMode.SINGLE_USE,
  expiresIn: { value: 5, unit: DurationUnit.MINUTES },
  userId: userId,
  resourceIdentifier: CoreResourceType.USERS,
  relatedId: userId,
});
return { reAuthToken: token.token };

The standard frontend’s StepUpAuthContext coordinates the challenge and pending action. The backend operation gate consumes the proof for the authenticated person; a custom interface needs the same continuation rather than treating the challenge as an independent login screen.

Keep policy and enrolled methods compatible

When the effective stepUpAuth policy is enabled, its methods constrains which factors may mint the proof. The resolver includes the actual subject’s organization memberships, so organization restrictions participate rather than reading only the raw user-type policy.

An operation’s explicit requiresStepUpAuthentication can require proof even when the policy-level switch is disabled; in that case the enabled-policy method restriction does not apply. The resource-operation gate covers direct user requests, with exemptions for worker execution, nested service calls and its declared read-operation set.

A person must have a factor the minting path accepts enrolled locally; an SSO identity alone is not a password, TOTP or passkey proof. A proof already minted remains usable until its short expiry even if the accepted-method policy changes in the meantime.

Adapt sign-in to each customer’s requirements Feature

Define authentication requirements for the organization types your application serves. Wildo combines those application-authored requirements with the person’s account policy when they sign in.

For people in several organizations, the combined requirements matter. Your application defines the override on each organization type and controls whether its method set may narrow or expand the base policy.

Example: Meet a customer’s stronger password requirement

A member belongs to an organization requiring a longer password and MFA. Their effective policy reflects those requirements instead of relying only on the application’s basic member settings.

Application and customer policies combine into the effective sign-in rules.
For engineers
Start from the user-type policy

These overrides are authored in application configuration, not a per-customer settings record. Separately managed SSO connection settings govern provider routing and enforcement.

organizationTypes[type].authOverrides can describe authentication methods, MFA, password requirements and step-up policy. Registration, lockout and session length are not organization-overridable fields in this contract.

The pure resolveAuthConfig function resolves each organization’s override against the base and then combines the results. For password bounds, the actual merge uses:

function mergePasswordMostRestrictive(a: PasswordPolicyConfig, b: PasswordPolicyConfig): PasswordPolicyConfig {
  return normalizePasswordPolicyBounds({
    minLength: Math.max(a.minLength, b.minLength),
    maxLength: Math.min(a.maxLength, b.maxLength),
    requireUppercase: a.requireUppercase || b.requireUppercase,
    requireLowercase: a.requireLowercase || b.requireLowercase,
    requireNumbers: a.requireNumbers || b.requireNumbers,
    requireSpecialChars: a.requireSpecialChars || b.requireSpecialChars,
    expiryDays: mergeExpiryDays(a.expiryDays, b.expiryDays),
  });
}

Longer minimum length and shorter maximum/expiry represent different directions numerically but the same stricter intent. MFA requirement uses true-wins; a shorter enrollment grace period wins.

Decide how method changes are allowed
Method policyOrganization behavior
RESTRICT_ONLYNarrow the base enabled methods
EXPAND_WITHIN_SETAdd only methods from the application’s allowed expansion set
UNRESTRICTEDUse the organization’s configured method set

Across multiple organizations the resolved method sets are intersected. Do not assume that a method available in one membership remains available when another organization’s requirements are included.

Keep the effective policy at the point of use

The backend gathers relevant memberships and resolves policy for authentication and credential changes. Active directory management additionally disables local first-factor methods that would bypass the directory. Step-up methods can be narrowed and freshness shortened where a base step-up policy exists. Custom flows should use the effective policy rather than reading the user-type defaults alone.

Connect a customer’s identity provider

Use your customer’s identity provider for sign-in Feature

Connect an organization’s identity provider through OpenID Connect. Members use the customer’s sign-in process, while Wildo connects the verified identity to the application’s account and membership rules.

Each connection belongs to its configured scope. Domain verification and connection policy determine when it is offered and how people are provisioned.

Example: Sign in through the company directory

A member enters their company address, follows the organization’s configured identity provider and returns to the application’s workspace with the permitted membership.

Acme's identity provider connects through OIDC to the application and an Acme membership.
For engineers
Establish the connection and routing authority

The user type must enable AuthMethod.ORG_OPENID_CONNECT. The organization also needs an enabled SSO connection, client configuration and verified domain routing. Claiming a domain is not verification; DNS verification grants the routing authority. The application-scope connection has its own authorize path.

Connect the method, tenant and identity provider

Example: permit OIDC for a user type whose other authentication policy already exists. Merge into userTypes.<type>.auth in backend-api/src/saas-config.backend.ts. Import AuthMethod from @wildo-ai/saas-models.

const customerAuth = {
  ...existingCustomerAuth,
  authMethodsEnabled: {
    ...existingCustomerAuth.authMethodsEnabled,
    [AuthMethod.ORG_OPENID_CONNECT]: true,
  },
};

This permits the method; it does not create an IdP connection, verify a domain or grant membership. The initiating frontend must admit the same user type. Organization method policy can further restrict or expand available methods within the application’s declared rules.

Setup ownerWhat must agree
Application auth policyOIDC is permitted for the intended user type and frontend
Scoped SSO connectionAn enabled connection belongs to the application or customer organization being authenticated
Identity-provider applicationClient credentials and the callback registered for this deployment match
Domain routingOrganization discovery uses verified domain ownership, not an unverified domain claim
Provisioning policyThe verified subject maps to the intended local account and permitted membership

Use the standard scoped connection configuration service for manual metadata, provider templates or discovery. Those modes supply issuer, endpoints and keys; they are not three different trust policies. Keep client secrets in the backend configuration/secret path and register the callback supplied for the selected scope and deployment.

Verify the returned identity

Wildo validates the ID-token signature, expected issuer, client audience, expiry and issuance time before trusting its claims. It binds the nonce to the request and the identity to the returned profile. Missing verification metadata refuses ID-token processing; an unverified decode is not a fallback.

If the connection declares acrValues, the verified token must contain an accepted acr. Requesting a value does not prove it was returned, and a generic amr list does not substitute for the requested authentication context. Delegated enterprise MFA does not bypass this explicit demand.

Connect authentication to provisioning

The enterprise provisioning service resolves the external subject, local account, profile and membership. Just-in-time creation follows configured provisioning rules; directory provisioning is a separate lifecycle channel for joiners and leavers. Enterprise OIDC accepts delegated MFA assurance from the configured customer identity provider; it does not claim Wildo observed individual factors. The customer owns the IdP’s authentication policy, while Wildo retains token validation, identity binding and local access checks. Callback completion uses its own admission path and does not enter the native second-factor continuation.

Keep local access policy deliberate

Enforced SSO, migration grace and named emergency accounts are policy choices on the scope’s configuration. Active directory management disables local first-factor paths that would bypass the customer’s directory. These decisions belong beside connection setup, not in a custom callback that silently falls back to a local password.

This capability makes Wildo a client of the customer’s identity provider. Wildo’s own OAuth authorization-server and delegated-agent access surfaces answer a different question.

Connect enterprise sign-in through SAML Feature

Support a customer’s SAML identity provider without rebuilding the sign-in and account-linking flow. Wildo validates the returned assertion and connects it to the intended organization’s account policy.

Connection metadata, certificates and tenant binding remain explicit, including when several customers use the same identity provider.

Example: Keep two customers on their own connections

Two organizations use the same directory vendor. A returned assertion must identify the intended customer through the validated connection and signed audience, not just the provider name.

A signed SAML document bridges an identity provider and Acme's application.
For engineers
Prepare both sides of the connection

Enable AuthMethod.ORG_SAML for the relevant user type and configure the scope’s SAML connection. Exchange the application’s service-provider metadata and the identity provider’s settings, including certificates and endpoints. The configured runtime must provide the XML-validation helper used by the SAML implementation.

Create the organization-owned SSO connection with protocol: SsoProtocol.SAML, a displayName, and the provider’s saml settings. The following excerpt from sso-connection.shared.schemas.ts shows the connection fields the settings form and request contract use:

saml: z.object({
  entityId: z.string().min(1).isAuditEvidence(),
  ssoUrl: z.url().isAuditEvidence(),
  sloUrl: z.url().optional().isAuditEvidence(),
  x509CertificatePem: z.string().min(1).optional(),
  x509Certificates: z.array(z.string()).min(1).isBackendOnly(),
  attributeMapping: z.object({
    email: z.string().default('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'),
    firstName: z.string().default('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'),
    lastName: z.string().default('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'),
    roles: z.string().optional(),
    department: z.string().optional(),
    employeeId: z.string().optional(),
    groups: z.string().optional(),
  }),
  signRequests: z.boolean().default(true).isAuditEvidence(),
  wantAssertionsSigned: z.boolean().default(true).isAuditEvidence(),

This is the relevant portion of the existing schema, not a complete connection payload. Enter the IdP’s entity identifier and SSO endpoint, supply its signing certificate through x509CertificatePem, and align attribute names with the assertions it sends. The backend derives the internal certificate array; clients do not write that backend-only field. Connection-level allowedDomains, defaultRole and autoCreateUsers govern who can enter and how new local accounts are provisioned.

On the provider side, use Wildo’s service-provider metadata for the intended organization. The metadata endpoint supplies that configuration; the assertion-consumer endpoint receives the provider’s response. Enable the connection and start sign-in through it: a valid assertion then enters the local account and membership resolution flow.

Bind the assertion before selecting the customer

SP-initiated sign-in carries request correlation. For IdP-initiated sign-in, the engine validates candidate connections against their configured certificates before selecting a scope. If several tenants share an identity provider and certificate, the signed audience must identify one intended tenant; unresolved ambiguity is refused.

Assertion conditions and replay protection are handled by the validator and replay guard. An unsigned issuer label is not tenant authority. Multiple configured certificates support certificate rollover without disabling validation.

Complete the account and logout lifecycle

After validation, the enterprise provisioning path resolves the local account and membership under the connection’s policy. SAML completion checks the local account and issues tokens on its callback path; it does not automatically enter the native MFA challenge continuation. Set the customer’s provider assurance requirements deliberately rather than assuming local mfaPolicy is translated into a SAML demand. Separate SAML logout paths manage the federated session behavior. Test the customer’s configured login direction and logout path with its provider; protocol support alone does not establish that a particular customer’s metadata and certificates are correct.

Manage ongoing access

Keep sessions usable while credentials rotate Guarantee

Keep people signed in without treating a long-lived token as permanent authority. Wildo refreshes access through rotating credentials and checks the account’s current state before continuing the session.

A short retry window handles ordinary duplicate refreshes. Reuse outside that window triggers token withdrawal rather than silently extending access.

Example: Retry a refresh without losing the session

A refresh succeeds but its response is lost. Once the successor is cached, a prompt retry with the retired credential can receive that same successor. Replaying it after the grace window is treated differently.

A retired token is replaced by the next token, with retries returning to that successor.
For engineers
Configure session policy at the right level

Wonder Todos’ administrator type declares:

sessionDurationMinutes: 30,
maxConcurrentSessions: 3,

sessionDurationMinutes is the lifetime of the temporary sign-in episode, not the duration of the access JWT it eventually produces. Registration and invitation episodes are created with a 24-hour expiry; idle timeout and subsequent session-store updates also affect how long they remain usable. maxConcurrentSessions controls the number of authenticated sessions. JWT signing material and access/refresh token lifetimes are deployment-authored runtime settings.

The standard client sends the refresh request with the HTTP-only refresh cookie. The endpoint takes an empty body; a JavaScript client should not read, store or submit the refresh token itself. Access-token updates are propagated to API clients and coordinated between same-origin tabs.

The controller reads the cookie after validating the request shape. Selected code from authentication-controller.backend.service.ts:

const ec = await this._getPreAuthExecutionContext();
RefreshTokenBodySchema.parse(req.body || {});
const refreshTokenValue = AuthControllerUtils.extractRefreshTokenFromCookie(req);
if (!refreshTokenValue) {
  throw this.errorBuilder.buildError(ErrorType.VALIDATION, undefined, {
    customMessageReference: ErrorCustomMessageReference.AUTHENTICATION_REFRESH_TOKEN_REQUIRED,
  });
}
Follow the order of the backend checks
CheckWhy it precedes rotation
Token validity and live accountA valid signature does not keep a disabled account active
Authorization versionA session cannot retain an older authority snapshot
Account and session revocationA retry cannot cross a withdrawal
Retired token and grace successorA short duplicate can receive the already-issued successor

AuthTokenIssuerBackendService.refreshAccessToken returns a cached successor when the predecessor is already marked as rotated and its grace entry exists. Concurrent refreshes select one successor in a Redis transaction that also records the retired credential and applies the winner’s session-limit effects. Losing requests receive the stored successor; they do not register extra sessions. If a response is lost after that transaction, a retry can recover the same tokens during the grace window. After the grace window expires, reuse of the retired credential writes the account-wide invalidation fence and reports a distinct reuse event. The raw token is not part of that signal.

Concurrent-session enforcement uses the shared session store and eviction path. A custom authentication integration must preserve these issuance and refresh paths; manually signing a replacement JWT would bypass the session lifecycle that makes the policy meaningful.

End access across a person’s sessions Guarantee

Withdraw a person’s existing tokens across devices when they sign out everywhere or a credential needs to be replaced. Wildo checks the revocation state during token use and refresh, rather than waiting only for normal expiry.

Individual-session logout remains a separate action, so ending one session does not have to end all the others.

Example: Respond to a compromised password

A password reset withdraws the person’s previous tokens. Another device cannot keep refreshing the old session after the reset.

Signing out everywhere from one account revokes its laptop, phone and tablet sessions.
For engineers
Use the account-wide revocation path

The authentication controller exposes logoutAll for the current user. Credential-reset services also use the issuer’s account-wide invalidation method. This is its current implementation:

public async invalidateAllTokensForUser(userId: string): Promise<void> {
  const nowSeconds = Math.floor(Date.now() / 1000);
  const refreshTokenDays = this.appConfigService.config.jwt.refreshTokenExpirationDays;
  await this.redisService.set(
    `${AUTH_REDIS_KEYS.TOKEN_INVALID_BEFORE}${userId}`,
    nowSeconds,
    { ttl: refreshTokenDays * 86400 },
  );
  this.logDebug('All tokens invalidated for user', { userId, invalidBefore: nowSeconds });
}

The fence expresses which earlier tokens are no longer admissible. The JWT guard, execution-context creation and refresh path consult it. A custom credential-changing flow must use the owning service rather than update the password hash and leave existing sessions untouched.

Choose the scope of logout deliberately

invalidateSession(sessionId) is the sibling for one login session. Account-wide invalidation affects the user’s earlier sessions; per-session invalidation withdraws only the selected one. Cross-tab logout separately clears the current browser’s visible state and client credentials.

Treat the revocation second as part of the boundary

The shared evaluateUserTokenInvalidationFence compares whole-second issuance times with the stored revocation second. It deliberately uses an inclusive comparison:

return { isRevoked: issuedAtSeconds <= invalidBefore, invalidBefore };

A token issued before the boundary is refused, and so is one issued in the same second. A login racing the revocation can therefore obtain a token that is immediately rejected: those timestamps cannot distinguish issuance just before the revocation from issuance just after it. Sign in again after that second has passed; do not relax the comparison or keep retrying the withdrawn token.

Token issuance timeThis timestamp fence’s verdict
Before the revocation secondRevoked
In the revocation secondRevoked, including a concurrent fresh login
After the revocation secondNot revoked by this fence; all other checks still apply

The atomic refresh publication path repeats the inclusive comparison inside Redis, so a revocation arriving while a successor is being signed is checked again before publication. Timestamp revocation, authorization-version changes and individual-session withdrawal are distinct checks; passing one does not bypass another. Test the equality case alongside an older token and a genuinely later issuance.

Understand the observed result

The next guarded request or refresh with a withdrawn token is refused. This does not reverse requests that already completed. Refresh checks revocation before returning a cached successor, so its short retry window cannot preserve access past a revocation. Keep the distinction between local interface cleanup, one-session logout and account-wide token withdrawal explicit in custom security controls.

Keep open tabs in step when you sign out Feature

Signing out should not leave another tab looking active. Wildo tells the application’s other same-origin tabs to clear their authentication state, API credentials and live connection.

This keeps the browser experience consistent while the backend handles token revocation.

Example: Leave the account in every open tab

A person signs out in one tab. Their other application tab clears its signed-in state too, rather than continuing to display working account controls.

Signing out in one browser tab sends a signal that signs out the other tabs.
For engineers
The standard authentication provider coordinates tabs

AuthSessionContext uses the wildo-auth-tokens BroadcastChannel. Logout calls the backend, broadcasts LOGOUT, disconnects the live connection and clears local credentials. The receiver performs this selected cleanup from AuthSessionContext.tsx:

if (type === 'LOGOUT') {
  logDebug('Received logout signal from another tab');
  disconnect();
  tokenStorage.clear();
  getManualCallsHttpClient().setConsumableToken(null);
  setResourcesConsumableToken(null);
  storeAuthTokens(null);
  getManualCallsHttpClient().setAuthToken(null);
  setResourcesAuthToken(null);
  setAuth(EMPTY_AUTH_STATE);
}

Clearing both the manual HTTP client and resource client matters: an empty React account display alone would leave requests carrying an old credential. Consumable-token state is cleared as well. The receiving tab does not rebroadcast logout.

Use the shared provider in custom screens

Call the provider’s logout operation rather than removing a storage item yourself. Token refresh is coordinated on the same channel so tabs can adopt the new access token; the HTTP-only refresh cookie remains outside JavaScript.

Know the browser boundary

BroadcastChannel coordinates same-origin tabs where the browser supports it. It does not reach another device or unrelated domain. Backend logout is still the credential authority, and sign-out-everywhere is the separate account-wide mechanism. The sender clears its local state even if the network logout call fails, so a visibly signed-out tab is not by itself proof of server-side revocation.

Give a link a purpose and a lifetime Mechanism

Use a token for a specific interaction: accepting an invitation, resetting a password, carrying a redirect or granting bounded access. Wildo records its purpose, expiry and allowed use, then checks those conditions when it is redeemed.

Choose single use or bounded reuse according to the action. A token does not automatically become a full account session.

Example: Accept an invitation once

An invitation link identifies the intended acceptance. Competing requests cannot both consume a single-use token successfully.

A time-limited ticket for one action passes a check and is marked used.
For engineers
Declare what the token is for

The password-reset service is a concrete consumer of the shared token mechanism. It mints this token before building the email link:

const resetToken = await this.consumableTokenService.createToken({
  tokenType: CoreConsumableTokenTypes.PASSWORD_RESET,
  consumptionMode: ConsumableToken_ConsumptionMode.SINGLE_USE,
  expiresIn: { value: PASSWORD_RESET_TOKEN_TTL_HOURS, unit: DurationUnit.HOURS },
  userId: user._id,
  resourceIdentifier: CoreResourceType.USERS,
  relatedId: user._id,
  metadata: { email: user.email },
});

Purpose, subject, related resource and expiration travel together. The reset handler later checks that the consumed token is a PASSWORD_RESET token before changing the credential. The token value is a secret, not a record identifier to display publicly.

Select the right lifecycle
Consumption modeIntended interaction
SINGLE_USEOne successful redemption, such as accepting an invitation
BOUNDED_REUSEA limited number of uses within an expiry
EPHEMERAL_STATECorrelation across a redirect, consumed on return

Validation checks the current record. Consumption performs a conditional atomic mutation so two callers cannot both claim the last permitted use. Revocation is also guarded and makes an outstanding token unusable.

Reach it through the owning operation

Standard flows already supply their token consumers. Application operations can use declarative token generation, which reaches the same mint service. Define the intended resource/action target and the point of consumption; do not use a generic token as an implicit permission to call unrelated operations. Session-establishing links and upload grants add their own bounds above this shared lifecycle.

Connect generation to the action that needs proof

Wonder Todos pairs ASSIGN and CHANGE_STATUS in tasks.resources-config.ts. Its application-owned TasksManager_ConsumableTokenType.TASK_APPROVAL names the purpose (task_approval). The following is the generation block inside the existing ASSIGN API variant; its request declares assignedToUserId, and the operation requires ORG_MEMBER:

tokenGeneration: {
  tokenType: TasksManager_ConsumableTokenType.TASK_APPROVAL,
  consumptionMode: ConsumableToken_ConsumptionMode.SINGLE_USE,
  expiresIn: { value: 12, unit: DurationUnit.HOURS },
  grantedRoles: [{ roles: [CORE_ORG_ROLES.ORG_MEMBER], relatedPrimaryScope: ResourcePrimaryScope.ORGANIZATIONS }],
  userIdField: 'assignedToUserId',
  revokeExistingForSameRelated: true,
  createsOneOffSession: true,
  consumeAt: 'TARGET_OPERATION',
  targetingFields: { taskId: '_id' },
},

ConsumableToken_ConsumptionMode, DurationUnit, CORE_ORG_ROLES and ResourcePrimaryScope come from @wildo-ai/saas-models; the token-purpose enum belongs to the application. The generation helper reads the updated task: _id supplies the related record, assignedToUserId supplies the recipient and organizationId supplies the tenant. Reassignment replaces outstanding tokens of this purpose for that related record.

The matching CHANGE_STATUS variant already has its update request and organization-member role requirement. Its additional declaration is:

tokenAuthentication: {
  types: [TasksManager_ConsumableTokenType.TASK_APPROVAL],
  policy: TokenAuthenticationPolicy.ADDITIVE,
},

TokenAuthenticationPolicy is also exported by @wildo-ai/saas-models. ADDITIVE requires normal authentication plus the token; the token’s purpose, state and recipient are checked, and normal operation authorization still applies. ALTERNATIVE is a separate authoring choice that authenticates from the token context. Do not substitute it just to avoid supplying a signed-in session. In this ADDITIVE path, the recipient check does not compare the token’s task target with the task addressed by the request. An application that requires approval for exactly one record must enforce that match in its operation; targetingFields alone is not that authorization check.

Register the resource and deliver the generated secret

The task factory is registered under TasksManager_ResourceType.TASKS in tasks-manager.resource-configs.ts. The shared tasks-manager module contributes that map through resourceConfigurations, alongside resourceFieldIdentifiers and resourceRelationships. These declarations participate in the normal resource pipeline; they are not a second standalone token router.

Token generation also supplies additionalContext.tokenValue to configured notification dispatch. An authored notification/template must consume that value and deliver it to the intended recipient through a configured channel. Declaring generation does not by itself author an approval email, and the ordinary task-update response is not a secret-retrieval API. Keep the recipient selection, template and delivery configuration aligned with the generation branch.

This particular example opts into a one-off session and therefore uses the engine’s twelve-hour mint ceiling. With TARGET_OPERATION, exchange retains the consumable token for the target action. The exchanged session uses the account’s normal authorization; the targeting fields do not make its access JWT record-only. See one-off session links for that separate session contract. A workflow that does not need login should not opt into session creation merely to carry an action token.

Invoke the paired operations

For an existing task, use the application API base, organization/task IDs and an authorized member session. The assignment request supplies the recipient ID:

curl --fail-with-body -X PUT "$BACKEND_URL/organizations/$ORGANIZATION_ID/tasks/$TASK_ID/assign" \
  -H "Authorization: Bearer $MEMBER_TOKEN" \
  -H 'Content-Type: application/json' \
  --data "{\"assignedToUserId\":\"$ASSIGNEE_ID\"}"

After the configured delivery reaches the assignee, APPROVAL_TOKEN is that generated secret and ASSIGNEE_ACCESS_TOKEN is their authenticated session. BACKEND_URL includes the API prefix. Change the same task’s status with both credentials:

curl --fail-with-body -X PUT "$BACKEND_URL/organizations/$ORGANIZATION_ID/tasks/$TASK_ID/change-status" \
  -H "Authorization: Bearer $ASSIGNEE_ACCESS_TOKEN" \
  -H "x-consumable-token: $APPROVAL_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"status":"completed","reason":"Approved by the assignee"}'

For ADDITIVE, put the secret in x-consumable-token; a consumable_token query parameter does not satisfy that requirement. Inspect the persisted status after success, then repeat with the spent token: it must no longer authorize a second consumption. Also check a missing token and a different recipient, alongside the successful authorized request. A failed attempt is only meaningful after the positive path has established that the task, session and delivery are valid.

Recover accounts and manage access changes

Recover access through your inbox Feature

Let people replace a forgotten password using an expiring email link. Wildo checks the replacement against the account’s policy and withdraws earlier tokens when the change completes.

The request response does not reveal whether an address belongs to an active account.

Example: Replace a forgotten password

A person requests recovery, opens the email and sets a new password. Their older sessions must authenticate again.

A person requests a reset, receives a time-limited email link and chooses a new password.
For engineers
Keep request and completion separate

The request handler returns the same response for unknown, eligible and ineligible accounts. ACTIVE and PENDING_VERIFICATION accounts can receive a single-use password-reset token by transactional email; suspended, inactive and deleted accounts do not receive it. Auth_PasswordLost supplies the standard request and completion interface.

Apply the current account policy

The completion service consumes the token, verifies its purpose and resolves the account’s effective password policy. After validation, the actual credential-changing sequence is:

const newPasswordHash = await this.credentialVerifier.hashPassword(newPassword, this.appConfigService.config.auth.passwordHashing);
await this.tokenIssuer.invalidateAllTokensForUser(userId);
await this.credentialVerifier.updatePasswordHash(ec, userId, newPasswordHash);
await this.consumableTokenService.revokeTokensByRelatedId(userId, CoreConsumableTokenTypes.PASSWORD_RESET);

The hash is derived using configured password hashing. Earlier account tokens are withdrawn before the credential is written, and sibling password-reset links are revoked. A custom recovery screen should submit to this completion operation rather than update a user record directly.

Make the retry behavior understandable

The token is consumed before password-policy validation. A rejected replacement can therefore require another recovery email; the same link is not a reusable validation session. Present the known password requirements before submission and preserve the resend path.

A pending-verification account can receive recovery mail without becoming active. Restoring a suspended or deactivated account remains a separate lifecycle action. The completion endpoint limits requests to 10 per minute per IP before parsing or hashing; the request endpoint has its own throttles.

Require a password reset without seeing the new password Feature

An administrator can initiate a password reset for another account, withdraw its existing sessions and send the replacement link to the account owner.

The action records who requested it and why. The administrator receives confirmation, not a link that lets them choose someone else’s password.

Example: Respond to a suspected credential leak

An administrator provides a reason for resetting a member’s password. Existing sessions are withdrawn and the member receives the reset email.

An administrator requests a reset; an email leads the account holder to choose a new password.
For engineers
Use the dedicated administrative operation

The FORCE_PASSWORD_RESET variant is application-super-admin gated and explicitly admits a different target user. Its request and response contracts are selected here from users.shared.resources-config.schemas.ts:

requestDto: z.object({
  reason: z.string().min(1).max(500)
}),
customResponseDto: z.object({
  expiresAt: z.date(),
  sessionsRevoked: z.boolean()
}),

The custom implementation requires one addressed account, an attributable caller and a non-empty reason. Self-reset is refused here; the account owner’s normal password-change path proves their current credential instead.

Observe what the operation actually returns

expiresAt describes the emailed link and sessionsRevoked confirms withdrawal. Neither the reset token nor URL is returned to the administrator. The shared password-reset service sends to the target user’s email using the transactional template and resolved locale.

Separate containment from completion

Existing tokens are invalidated before the forced-reset flow completes. The user then follows the mail and chooses a password that meets their effective policy. Starting the reset is not evidence that the owner has received the email or changed the password yet. Delivery configuration and operational monitoring therefore remain necessary alongside the account action.

The backend refuses a deleted target. Reset initiation does not reactivate a suspended account or assign new roles; those are separate lifecycle decisions.

Manage account access through deliberate actions Feature

Give administrators named actions for changing roles, suspending access and restoring accounts. Wildo carries those actions through their permissions, account transitions and audit behavior.

A suspension, a person’s own deactivation and account deletion mean different things. Keeping them separate makes both the interface and the resulting access easier to understand.

Example: Restore an account for the right reason

An administrator unsuspends an imposed hold. A voluntarily deactivated account uses a different reactivation action with a required reason.

An administrator oversees an account moving from active to suspended and then restored.
For engineers
Choose the lifecycle action rather than patching status

The user resource declares separate operations for role assignment/revocation, suspension, unsuspension, reactivation and forced password reset. Generic updates do not stand in for those transitions.

ActionIntended transition
SuspendAn administrator imposes a halt on access
UnsuspendLift that imposed suspension
Reactivate userRestore an account its owner deactivated
Assign or revoke rolesChange authority through the role-management path
Force password resetWithdraw sessions and send the owner a reset link
Keep the actor and subject distinct

Administrative variants combine their role requirement with admitsCrossSubjectUserAdministration: true. That flag allows the target to be another user; it does not itself grant administrative authority. The same operation still has to satisfy its declared roles and any fresh-proof requirement.

The destructive user operation makes the combination explicit:

variantType: ResourceOperationVariantType.API_CALL,
isDefault: true,
roles: [CORE_APP_ROLES.APP_ADMIN_SUPER_ADMIN],
admitsCrossSubjectUserAdministration: true,
riskLevel: ResourceOperationRiskLevel.CRITICAL,
requiresStepUpAuthentication: true,

These are selected fields from the real user-deletion variant, with comments omitted. Presentation risk and step-up enforcement are separate settings.

Preserve continuity and the record of change

The shared transition machinery owns status changes and scheduled reactivation cleanup. Administrative floors prevent removing the last usable administrator. reactivate_user requires a reason because it reverses the owner’s chosen departure, while unsuspension has a different contract. Custom administration screens should invoke these operations and display their outcomes, not write the underlying status and role fields directly.

Give people control over their own account Feature

People can read their own account and choose to deactivate it through a dedicated self-service surface. Wildo keeps these actions separate from the administrative operations that manage other users.

Deactivation stops access. Returning from that state requires administrative reactivation; it is not an automatic pause-and-return flow.

Example: Leave an account deliberately

A person deactivates their account with an optional reason. The application preserves the distinction between their decision to leave and an administrator’s suspension.

An account can be deactivated from active to inactive; an administrator provides the return to active.
For engineers
Use the self-service resource for the current person

userSelf provides own-account reads and lifecycle actions over the user records. It does not expose arbitrary user-row updates. Profile and preference changes belong to their dedicated self-service resources.

The deactivation operation’s current request and availability condition are:

requestDto: z.object({
  reason: z.string().max(500).optional(),
}),
enabledCondition: ({ currentObject }) => {
  return currentObject.status === UserStatus.ACTIVE;
}

The shared transition moves ACTIVE to INACTIVE. The administrative continuity floor still applies, so the last usable administrator cannot bypass that protection by choosing the self-service route.

Explain the return path before deactivation

Normal authentication admits active accounts. Once deactivated, a person cannot simply sign back in to invoke self-reactivation. The administrative reactivate_user operation restores INACTIVE accounts with a documented reason. An imposed SUSPENDED account uses the distinct unsuspend operation.

Keep data-rights actions explicit

Account deactivation is not data erasure. This resource does not provide a self-delete or subject-export endpoint. Applications that offer those journeys need to connect their authorized data-rights process to the relevant services rather than relabel deactivation as deletion. This distinction preserves the user’s expectation and the lifecycle behavior behind the control.

Give people authority within clear boundaries

Decide what people and services may do, and where they may do it. Wildo connects roles, account membership and resource ownership to the operations that read and change your data.

The same model also covers the awkward moments: granting a role, linking a person from another account, handing over administration or letting support investigate a customer’s problem.

A request connects the caller, permitted action and customer scope before reaching a record.

Access rules that stay connected to the work

Delegate the right responsibilities

Roles inherit responsibilities, while grant checks stop ordinary administrators from assigning authority above their own.

Keep customers’ records separate

Authorized scope carries into reads, writes and declared membership checks, including lists of the accounts themselves.

Make exceptional access explicit

Administrative continuity and temporary support grants handle recovery without turning platform access into a standing tenant privilege.

Example: Assign work without crossing account boundaries

An administrator gives a supervisor the role needed to assign work. The operation checks that role, the task stays within its organization, and the assignee relationship requires an eligible member. A support operator investigating the account needs the separately declared access path and a usable temporary grant.

For engineers

Start with ownership and operation authority

A resource’s registered ownership relationships or scope anchor establish its primary scope. An operation declares its required roles. At request time, the framework combines those declarations with the authenticated principal’s memberships and other contextual authority before reaching the resource operation.

LayerQuestion it answers
Principal and membershipWho is calling, and in which account can they act?
Role hierarchy and operation rolesMay this caller use this action?
Addressed scope and repository confinementWhich records may this operation reach?
Declared reference membershipMay this record link to that person or organization?
Grant and continuity controlsIs this exceptional crossing or administrative change admissible now?

A matching role does not replace scope confinement. A successful existence check does not replace membership eligibility. Keeping these decisions separate is what lets the same resource operations serve people, services and declared administrative work.

Add relationship eligibility where existence is insufficient

A user can exist in the application without belonging to the task’s organization. This declaration from Wonder Todos adds the membership question to its assignee relationship; context display options are omitted here.

createResourcesRelationship(
  TasksManager_ResourceType.TODOS, CoreResourceType.USERS,
  ResourceRelationshipCardinality.MANY, ResourceRelationshipCardinality.ONE,
  {
    nature: RelationshipNature.REFERENCE,
    foreignKeyField: 'assignedToUserId',
    parentResourceRequirement: ResourceParentResourceRequirement.OPTIONAL,
    accessScopeStrategy: ResourceRelationshipAccessScopeStrategy.OPTIONAL_CONTEXT,
    scopeMembership: true,
  }
),

The write-integrity path checks the membership junction before accepting the foreign key. For a different eligibility relationship, such as a rota, name the junction and its scope/target columns. Operation-level referenceConstraints can further narrow the eligible population.

Keep elevated actions inside their intended contract

Role-grant ceilings compare requested authority with the caller’s effective roles; the surrounding operation gate still determines where the write is allowed. Administrator floors examine the population left after a change, including whether the remaining accounts are usable.

A platform crossing requires both an operation that admits it and a usable grant into the target account. Customer approval is configurable; ownerless recovery and application-wide directory access have their own explicit paths. The support-access sequence connects the operation declaration, stored customer approval setting and two callers; ownership recovery shows the subsequent membership-addressed request. Do not replace these controls with direct repository or database writes when an ordinary operation refuses.

Continue with role hierarchy, tenant confinement and temporary support access for the corresponding declarations and lifecycle details.

Define who may do what

Give each role the right responsibilities Mechanism

Roles describe what a person or service may do. Wildo lets your application extend the built-in roles and inherit their responsibilities, so you can express your own team structure without rebuilding ordinary access checks.

The role travels with its scope: being an administrator of one organization does not make someone an administrator of another.

Example: A supervisor can do the work they oversee

A project supervisor inherits the manager role. Operations available to managers remain available to that supervisor, while ownership changes can require a higher role.

Member, manager and administrator roles form a hierarchy with increasing read, assign and manage permissions.
For engineers

Register custom roles in the application role configuration. inheritFrom supplies the existing responsibilities; relatedPrimaryScope selects the role table in which they are meaningful. Wonder Todos declares both an application manager and an organization supervisor:

This implementation excerpt from roles.ts shows the decision in context; explanatory source comments are omitted.

import { RolesConfiguration, ResourcePrimaryScope, CORE_APP_ROLES, CORE_ORG_ROLES } from "@wildo-ai/saas-models";

export const CUSTOM_ROLES_CONFIGURATION: RolesConfiguration = {
  'CUSTOM_APP_MANAGER': {
    role: 'CUSTOM_APP_MANAGER',
    inheritFrom: CORE_APP_ROLES.APP_USER,
    isSystemRole: false,
    relatedPrimaryScope: ResourcePrimaryScope.APPLICATION
  },
  'CUSTOM_ORG_SUPERVISOR': {
    role: 'CUSTOM_ORG_SUPERVISOR',
    inheritFrom: CORE_ORG_ROLES.ORG_MANAGER,
    isSystemRole: false,
    relatedPrimaryScope: ResourcePrimaryScope.ORGANIZATIONS
  }
};
Register the role map with the shared application module

A role map must reach startup before operations can use its hierarchy. Wonder Todos’ shared-lib/src/engine/index.ts contributes it through the existing engine module:

import type { SharedSaaSModule } from '@wildo-ai/saas-models';
import { CUSTOM_ROLES_CONFIGURATION } from './roles';

const engineSharedModule: SharedSaaSModule = {
  moduleId: 'engine',
  kind: 'engine',
  customRoles: CUSTOM_ROLES_CONFIGURATION,
  // Keep the module's existing feature, milestone and product contributions.
};

export default engineSharedModule;

This selected module excerpt is the contribution point. In Wonder Todos, shared-lib/src/modules-registry.shared.ts includes engineSharedModule in sharedModules and passes that list to buildSharedSaaSModulesRegistry. Retain that existing assembly. Declaring the constant in an unreferenced file does not register roles. Membership assignment and an operation’s required roles are separate choices: registration makes the hierarchy available, it does not grant it to every account.

Follow the declaration into authorization

Application startup registers this configuration with initializeRolesWithCustom. The authorizer expands the caller’s role through the configured inheritance chain before comparing it with an operation’s roles. This means an inherited manager responsibility remains available without copying the entire role list onto every member.

DecisionDeclaration or runtime input
What responsibilities a role includesinheritFrom in the role configuration
Where the role appliesrelatedPrimaryScope and the caller’s membership
Which role an action requiresThe operation’s roles
Which records the caller can reachScope, membership and resource authorization

A matching role is one part of admission. Tenant confinement and reference eligibility still apply. APP_PUBLIC is an operation declaration that removes a role requirement; it is not a role to grant to an account. Use role grants to control who may assign the roles you define.

Keep role grants within the caller’s authority Guarantee

People can delegate responsibilities they hold without being able to create a more powerful account or integration. Wildo checks requested roles when members and machine credentials receive their authority.

Inherited responsibilities count too: an administrator can grant the member role they already include, while a higher owner role remains outside their authority.

Example: An administrator creates an integration

An organization administrator gives a service the member role it needs. Asking for the owner role is refused before the credential is created.

A manager can grant lower roles while higher authority remains above a boundary.
For engineers

The OAuth-client mint handler checks the caller-authored roles before generating secret material. The core create still performs persistence, and the postfix returns the plaintext secret once:

This implementation excerpt from oauth-clients.custom-impl.backend.service.ts shows the decision in context; explanatory source comments are omitted.

export function buildOAuthClientMintHandlers(scope: ApiKeyScope, roleHierarchyResolver: RoleHierarchyResolver): OAuthClientImplHandlers {
  return {
    prefixCoreOperations: async (_id, input, executionContext, _operationPath, utils) => {
      assertRequestedRolesWithinCallerCeiling(executionContext, (input as { roles?: string[] }).roles, utils.errorBuilder, roleHierarchyResolver);
      const { plainSecret, secretPrefix, hashedSecret } = generateOAuthClientSecretMaterial(scope);
      PLAINTEXT_SECRET_BY_EC.set(executionContext, plainSecret);
      return { ...(input as Record<string, unknown>), secretPrefix, hashedSecret };
    },
    postfixCoreOperations: async (_id, createdClient, executionContext, _operationPath, _utils) => {
      if (!createdClient || typeof createdClient !== 'object') return createdClient;
      const plainSecret = PLAINTEXT_SECRET_BY_EC.get(executionContext);
      PLAINTEXT_SECRET_BY_EC.delete(executionContext);
      if (!plainSecret) return createdClient;
      return { ...(createdClient as Record<string, unknown>), plainSecret };
    },
  };
}
Extend the same check to application resources

The framework wires assertRequestedRolesWithinCallerCeiling into its membership, unit-assignment, application-role and credential creation paths. If an application introduces another resource whose caller-settable roles confer authority, call the assertion from its create and roles-changing update handlers too.

Use createRoleHierarchyResolver to read the live, application-configured hierarchy. Pass the roles as the caller authored them, before adding policy defaults. Otherwise a default role supplied by the framework would be mistaken for a privilege the person asked to grant.

Keep scope and privilege checks separate

The ceiling compares effective roles across the caller’s organization-wide memberships. A unit-scoped administrator role does not become organization-wide grant authority. The surrounding operation authorization answers where the caller may write; this check answers how high they may grant.

Internally initiated provisioning and an application-scoped super-administrator are deliberate exceptions. Credential roles are fixed at creation: rotation changes secret material, not the roles the credential carries.

Keep a working administrator in place Guarantee

An administrative change should not leave the application with nobody able to manage it. Wildo refuses changes that would remove the last usable super-administrator and explains the recovery step.

The check considers whether an account can actually serve as an administrator, not only whether a role name remains in the database.

Example: Hand over administration before leaving

Before removing the final administrator’s authority, appoint another usable administrator. The same request can then proceed without leaving the application stranded.

An application administrator hands responsibility to another administrator, with a pause at the handover.
For engineers

Role and account-status changes use the transition guard; removals use the removal guard. Both reach the same population check. This part of that check shows how it excludes the accounts being changed:

This implementation excerpt from super-admin-floor.backend.utils.ts shows the decision in context; explanatory source comments are omitted.

export async function assertSuperAdminFloorPreserved(params: {

  subjectUserIds: readonly string[];
  cause: SuperAdminFloorReductionCause;
  executionContext: ExecutionContext<any>;
  repositoriesRegistry: RepositoriesRegistryHandlerBackendService;
  errorBuilder: ErrorBuilderBackendService;
  conferringRoles: readonly string[];
  operationIdentifier: string;
}): Promise<void> {
  const { subjectUserIds, cause, executionContext, repositoriesRegistry, errorBuilder, conferringRoles, operationIdentifier } = params;

  const remainingUsableSuperAdmins = await countOtherUsableSuperAdmins({
    excludedSubjectIds: subjectUserIds,
    executionContext,
    repositoriesRegistry,
    errorBuilder,
    conferringRoles,
    operationIdentifier,
  });

  if (remainingUsableSuperAdmins >= SUPER_ADMIN_FLOOR_MINIMUM) return;

  throw errorBuilder.buildError(ErrorType.CONFLICT, executionContext, {
    context: {
      code: AdministrativeContinuityErrorCode.LAST_APPLICATION_SUPER_ADMIN,
      reason: SUPER_ADMIN_FLOOR_REFUSAL_REASON,
      message:
        'Refused: this would leave the application with no usable super-administrator. '
        + SUPER_ADMIN_FLOOR_REMEDY,
      cause,
      operationIdentifier,
      subjectUserIds: [...subjectUserIds],
      remainingUsableSuperAdmins,
      requiredUsableSuperAdmins: SUPER_ADMIN_FLOOR_MINIMUM,
      conferringRoles: [...conferringRoles],
      usableStatuses: [...ADMINISTRATIVELY_USABLE_USER_STATUSES],
    },
  });
}
Treat the refusal as a state conflict

The caller may have all the required permissions and still be unable to perform this change. The response is a conflict with LAST_APPLICATION_SUPER_ADMIN, because the problem is the resulting administrator population. Promoting another usable account is the remedy; acquiring another permission is not.

Custom roles that confer super-administrator authority participate through the live role hierarchy. A disabled or otherwise unusable account must not be counted as the fallback simply because its stored roles still look powerful.

Preserve continuity on the right lifecycle path

The account-role administration handlers call the guard when authority or usability decreases. Subject erasure has a separate continuity path: do not replace a privacy erasure workflow with a blanket refusal. Organization ownership has its own last-owner rule, because tenant ownership and application administration are different responsibilities.

Keep ownership and requests aligned

Put information in the scope it belongs to Mechanism

Some information belongs to the application, some to a customer organization, some to one person and some to a visitor who has not registered. Wildo makes that ownership an explicit part of the resource definition.

Those distinctions let shared settings, private preferences and tenant business records coexist without treating them as the same kind of data.

Example: Keep personal preferences beside team records

A person’s preferences belong to that person. The projects they work on belong to their organization, and the application’s configuration belongs to the deployment.

Four separate cards represent application, customer, personal and visitor scopes.
For engineers

ResourcePrimaryScope names the resolved scope. Application authors declare ownership through relationships; the resource factory derives resourcePrimaryScope from that declaration. Scope-root resources anchor themselves, and polymorphic resources receive the relationship for their selected scope variant. Operation roles then decide who can act within the resolved scope.

ScopeOwnershipTypical use
APPLICATIONThis application deploymentShared configuration
ORGANIZATIONSOne customer organizationBusiness records
USER_SELFOne authenticated personPersonal preferences
ANONYMOUSOne visitor sessionWork started before registration
Declare the ownership that determines the scope

Wonder Todos registers this relationship alongside its resource definitions. The parent is an organization, the child is a todo, and isPrimaryScope identifies the ownership relationship used to derive the child’s scope. Source: tasks-manager.relationships.ts; source comments omitted.

createResourcesRelationship(
  CoreResourceType.ORGANIZATIONS, TasksManager_ResourceType.TODOS,
  ResourceRelationshipCardinality.ONE, ResourceRelationshipCardinality.MANY,
  {
    nature: RelationshipNature.COMPOSITION,
    isPrimaryScope: true,
    foreignKeyField: 'organizationId',
    contextPolicy: {}
  }
)

With this registered relationship, the factory resolves organization ownership and its foreign-key field. Another relationship, such as the todo’s assignee, remains a reference; it does not replace the primary owner. An ordinary resource without a resolvable primary scope is rejected during configuration instead of silently receiving a broader scope.

Let a draft belong to a person or a visitor

Wonder Todos’ draft notes demonstrate USER_SELF and ANONYMOUS without changing the business object. The schema declares nullable userId and anonymousUserId foreign keys, both excluded from ordinary updates. The resource opts into anonymous ownership with isAnonymizable: true and chooses transpositionPolicy: ResourceTranspositionPolicy.ADD for the later account handoff.

Its authored relationship starts from the signed-in person. This is the relevant declaration from tasks-manager.relationships.ts, including the child lifecycle decision:

createResourcesRelationship(
  CoreResourceType.USERS, TasksManager_ResourceType.DRAFT_NOTES,
  ResourceRelationshipCardinality.ONE, ResourceRelationshipCardinality.MANY,
  {
    nature: RelationshipNature.COMPOSITION,
    isPrimaryScope: true,
    foreignKeyField: 'userId',
    parentResourceRequirement: ResourceParentResourceRequirement.OPTIONAL,
    accessScopeStrategy: ResourceRelationshipAccessScopeStrategy.REQUIRES_CONTEXT,
    childOperations: { lifecycle: { onParentDelete: { enabled: true, mode: ChildLifecycleMode.IMMEDIATE } } },
    contextPolicy: {},
  }
)

The relationship helper and enum vocabularies come from @wildo-ai/saas-models; TasksManager_ResourceType is application-owned. OPTIONAL accommodates a visitor-owned row without a userId; it does not make another person’s records public. The anonymizable resource factory supplies the anonymous ownership relationship. Application authors do not hand-author a second unrelated draft resource.

The draftNotes_ResourceConfiguration_InitializationFactory is registered in the tasks-manager resource factory map, and the shared module contributes that map together with its relationships and field identifiers. Its ordinary create/read/list/update/delete API variants accept APP_USER and APP_ANONYMOUS; ownership confinement still applies to each caller. Allowing both roles does not merge their records.

Observe the two ownership paths

With BACKEND_URL including the API prefix, an authenticated person lists their drafts using their own user ID and Bearer session:

curl --fail-with-body "$BACKEND_URL/users/$USER_ID/draft-notes" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN"

For an already established anonymous session, use its UUID in the path and its signed session token in the header. The UUID is an identifier, not a credential:

curl --fail-with-body "$BACKEND_URL/anonymous-users/$ANONYMOUS_UUID/draft-notes" \
  -H "x-anonymous-session: $ANONYMOUS_SESSION_TOKEN"

The standard client establishes and carries that session; anonymous sessions and transposition explains its lifecycle. Supply the application’s frontend-service header where its deployment requires it. Neither call turns a user-supplied owner field into authority.

Verify isolation using existing disposable drafts: each owner can read their own record; substituting another user or visitor’s path/record must not expose or mutate that record. Check the allowed read first so an unavailable route cannot masquerade as successful isolation. The existing scope-isolation-user-anon-hostile.e2e.ts exercises these separate ownership paths and re-reads storage after denied mutations.

Carry the scope through the operation

The framework resolves the scope root and contextual identifier, builds the caller’s execution context and uses the matching authorization path. A user-scoped resource is not made accessible to everyone merely because it sits in the same application database.

Organization units narrow authority inside an organization; they do not introduce a fifth primary scope. Likewise, a directory of users is an application administration surface, not the same interaction as reading one person’s own preferences.

Plan the visitor-to-account transition

Anonymous ownership can support work before registration. A resource’s transposition policy determines whether that work is reassigned, replaces existing work or is discarded when ownership moves. Treat that transition as a separate product decision from who may operate on the visitor’s data now.

See tenant confinement for organization records and anonymous sessions for the registration handoff.

Keep each customer’s records in their account Guarantee

Organization-owned records are confined through the framework’s resource and repository paths. Application code can work with the current organization’s data without rebuilding a tenant filter in every endpoint.

The organization comes from the authorized request context, while shared confinement rules serve both MongoDB and PostgreSQL.

Example: Two customers use the same project feature

Both organizations can list and edit projects through the same application code. Each request operates within its authorized organization, so one customer’s records do not become the other’s results.

A request reaches Acme's records while Northwind's records remain separate.
For engineers

Register the primary ownership relationship with the application’s resource relationships. The resource factory derives organization scope from it; resourcePrimaryScope is not a second author-maintained setting. In Wonder Todos, tasks-manager.relationships.ts contains this declaration (source comments omitted):

createResourcesRelationship(
  CoreResourceType.ORGANIZATIONS, TasksManager_ResourceType.TODOS,
  ResourceRelationshipCardinality.ONE, ResourceRelationshipCardinality.MANY,
  {
    nature: RelationshipNature.COMPOSITION,
    isPrimaryScope: true,
    foreignKeyField: 'organizationId',
    contextPolicy: {}
  }
)

The todo schema supplies the organizationId foreign-key field. The relationship connects that field to the organization parent and makes it the primary ownership axis. Register it before initializing the resource configuration, alongside the resource’s operations and other relationships.

When an authorized caller lists todos through the organization’s contextual resource operation, the framework resolves that organization in the execution context and carries confinement into the repository query. The same declaration serves both persistence adapters. Application code still supplies the operation’s roles and business filters; it does not infer tenant authority from a submitted organization ID.

The shared confinement function checks the authenticated organization and the operation’s declared crossing policy before adding the top-level ownership predicate:

This implementation excerpt from initiator-organization-confinement.backend.ts shows the decision in context; explanatory source comments are omitted.

export function assignInitiatorOrganizationConfinementToFilter<TFilter extends object>(params: {
  readonly filter: TFilter;
  readonly executionContext: InitiatorOrganizationConfinementSignal;
  readonly blacklistedFields: ReadonlySet<string>;
  readonly mainSchema: unknown;
  readonly schemaPaths: InitiatorOrganizationConfinementPathResolver;
  readonly isFieldPersisted: (fieldName: string) => boolean;
}): boolean {
  const initiatorOrganizationId = params.executionContext.initiatorIds?.organizationId;
  if (!initiatorOrganizationId) {
    return false;
  }

  if (params.executionContext.operation?.admitsCrossTenantPlatformAdministration === true) {
    return false;
  }

  if (
    params.blacklistedFields.has(INITIATOR_ORGANIZATION_CONFINEMENT_FIELD)
    || !params.isFieldPersisted(INITIATOR_ORGANIZATION_CONFINEMENT_FIELD)
  ) {
    return false;
  }

  const organizationPaths = params.schemaPaths.findNestedFieldPathsFromZodSchema(
    params.mainSchema,
    INITIATOR_ORGANIZATION_CONFINEMENT_FIELD,
  ).filter((fullPath) => !fullPath.includes('.'));

  let applied = false;
  organizationPaths.forEach((fullPath) => {
    if (!params.schemaPaths.hasNestedPath(params.filter, fullPath)) {
      params.schemaPaths.setNestedValue(params.filter, fullPath, initiatorOrganizationId);
      applied = true;
    }
  });

  return applied;
}
Follow authority, rather than trusting a payload

The execution-context creator obtains memberships and removes authority from organizations that are no longer operational. The normal create path supplies contextual ownership fields; client data is not the authority for which tenant owns the new record.

Both persistence adapters call the shared confinement authority. A nested field that happens to be named organizationId is ordinary data unless it is the declared top-level ownership axis; filtering every matching field name would hide legitimate records.

Use the declared exceptional path

A deliberately admitted cross-tenant platform operation can step outside normal confinement. That is an operation-level declaration with its own admission requirements, not a global super-administrator shortcut. Arbitrary direct database access is outside the resource path and must not be mistaken for an authorized resource operation.

Organization listings need an additional rule because the organization is the scope root itself: see account-list confinement.

Stop requests from choosing another customer’s account Guarantee

An authorized write must remain in the account it was authorized for. Wildo reconciles the caller’s identity, addressed scope and server-managed ownership fields so request data cannot quietly move the operation into another tenant.

This protects the create and update path as well as the records returned by a query.

Example: A request carries the wrong organization

An integration authenticated for one organization calls a route naming another. The mismatch is refused instead of creating a record under the organization named in the request.

An Acme write reaches Acme storage; a Northwind-labelled write is stopped as a mismatch.
For engineers

A machine token names its scope. The execution-context creator compares the addressed scope with that authenticated value before constructing the request context:

This implementation excerpt from execution-context-creator.backend.service.ts shows the decision in context; explanatory source comments are omitted.

const urlScopeId = machineScope === ResourcePrimaryScope.ORGANIZATIONS
          ? initiatorCriticalParamsValue.organizationId
          : initiatorCriticalParamsValue.applicationId;
        if (urlScopeId && urlScopeId !== machineToken.scopeId) {
          throw this.errorBuilder.buildError(ErrorType.AUTHORIZATION, undefined,
            { customMessageReference: ErrorCustomMessageReference.AUTHORIZATION_MACHINE_SCOPE_MISMATCH,
              context: { reason: 'machine_scope_url_mismatch', tokenScope: machineScope } });
        }

        const machineCredential = { authMethod: MachineAuthMethod.OAUTH_CLIENT, credentialId: machineToken.clientId };
        const initiatorIds: ExecutionContext_InitiatorIds = machineScope === ResourcePrimaryScope.ORGANIZATIONS
          ? { ...this.extractInitiatorIds(initiatorCriticalParamsValue), organizationId: machineToken.scopeId, machineCredential }
          : { ...this.extractInitiatorIds(initiatorCriticalParamsValue), applicationId: machineToken.scopeId, machineCredential };
Keep ownership fields server-authored

Generated operation input shapes exclude contextual ownership fields from normal caller-controlled data. The resource operation path then enriches the write using its resolved context. Build scoped API calls with the intended organization in the route and ordinary business fields in the payload; do not rely on a payload organizationId to establish authority.

InputResponsibility
Authenticated principalEstablishes the caller and its authority
Addressed route/contextIdentifies the scope and resource being requested
Business payloadSupplies the values the operation allows the caller to change
Contextual enrichmentSupplies server-managed ownership fields

The exact refusal depends on the stage: invalid identity, inaccessible scope and disallowed input are different cases. Do not interpret an ignored extra property as proof that it was trusted.

A reference inside the payload still has its own eligibility question. Membership-constrained relationships prevent a valid tenant write from linking an ineligible person or partner.

Link records to eligible people and partners Mechanism

A referenced person or organization can exist without being eligible for this record. Wildo can require the declared membership or partnership before accepting the link.

That keeps relationships meaningful: an assignee belongs to the account, an escalation contact belongs to its rota, or a partner belongs to an approved relationship.

Example: Assign work to an account member

A task can refer to a user only when that user has the required membership in the task’s organization. Knowing somebody’s user ID is not enough.

An Acme task can reference a member, while its attempted reference to an outsider is refused.
For engineers

This is the task-assignee declaration in Wonder Todos. scopeMembership: true asks the registry to resolve the organization-membership junction. The context settings separately control the related information returned with the task:

This implementation excerpt from tasks-manager.relationships.ts shows the decision in context; explanatory source comments are omitted.

createResourcesRelationship(
    TasksManager_ResourceType.TODOS, CoreResourceType.USERS,
    ResourceRelationshipCardinality.MANY, ResourceRelationshipCardinality.ONE,
    {
      nature: RelationshipNature.REFERENCE,
      foreignKeyField: 'assignedToUserId', // Explicit for Edge Case #1 - multiple FKs to same type
      parentResourceRequirement: ResourceParentResourceRequirement.OPTIONAL,
      accessScopeStrategy: ResourceRelationshipAccessScopeStrategy.OPTIONAL_CONTEXT,
      scopeMembership: true,
      contextPolicy: {
        objectMode: ContextPolicy_ObjectMode.SUMMARY,
        operationOverrides: {
          [CoreResourceOperation.READ]: { objectMode: ContextPolicy_ObjectMode.FULL },
          [CoreResourceOperation.LIST]: { enabled: false },
          [CoreResourceOperation.SEARCH]: { enabled: false },
        }
      }
    }
  ),
Use an explicit junction when membership means something else

For a rota or partnership, set scopeMembership to an object naming the junction. junctionScopeField and junctionTargetField identify its columns when they cannot be inferred. The target’s existence and the junction’s existence answer different questions; both matter.

Create and update integrity checks evaluate referenced foreign keys before storing the write. An operation can add referenceConstraints to tighten eligibility, such as requiring a particular membership status. Related-record resolution uses the declared membership semantics as well.

Separate who may assign from who may be assigned

Wonder Todos’ assign-lead operation uses the same assignedToUserId relationship shown above and declares an active organization administrator as its eligible target. The caller must also be an administrator. The example below uses built-in organization roles: permission to make the change does not make every selected person eligible.

This is the operation declaration from todos.resources-config.ts, with comments omitted. Todos_Operations belongs to the application; the other enums come from @wildo-ai/saas-models, and z comes from zod.

[Todos_Operations.ASSIGN_LEAD]: {
  variants: [
    {
      variantType: ResourceOperationVariantType.API_CALL,
      isDefault: true,
      roles: [CORE_ORG_ROLES.ORG_ADMIN],
      riskLevel: ResourceOperationRiskLevel.MEDIUM,
      resourceOperationLike: CoreResourceOperation.UPDATE,
      requestDto: z.object({
        assignedToUserId: z.string().min(1),
      }),
      referenceConstraints: {
        assignedToUserId: {
          qualifyingStatuses: [OrganizationMemberStatus.ACTIVE],
          requiredRoles: { scope: ResourcePrimaryScope.ORGANIZATIONS, roles: [CORE_ORG_ROLES.ORG_ADMIN] },
        },
      },
    },
  ],
},

Place this inside the resource’s operationsConfiguration. The shared tasks-manager module contributes its factory map through resourceConfigurations and its relationship list through resourceRelationships. Both are needed: the relationship identifies the membership junction; this operation adds the eligibility criteria. Merely adding an input field does not declare that relationship.

For an existing todo, call the addressed operation with the organization’s administrator session. API_BASE includes the backend API prefix; ASSIGNEE_USER_ID is the selected user’s ID, not their membership ID.

curl --fail-with-body --request PUT "$API_BASE/organizations/$ORGANIZATION_ID/todos/$TODO_ID/assign-lead" \
  --header "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
  --header 'Content-Type: application/json' \
  --data "{\"assignedToUserId\":\"$ASSIGNEE_USER_ID\"}"
Selected person using built-in rolesResult with an authorized caller
Active administrator in this organizationEligible; verify the stored assignedToUserId after the update
Active owner in this organizationEligible through role inheritance: owner includes administrator authority
Member holding only ORG_MEMBER in this organizationRefused by this operation’s role requirement on the target
Administrator belonging only to another organizationRefused: the required membership is in the todo’s organization
Membership outside the qualifying statusRefused even if the membership carries an administrator role

Run the permitted case before testing refusals, and confirm refused writes leave the previous assignee unchanged. The ordinary assign operation on this resource does not add these status/role criteria: it retains the relationship’s membership check. If several operations must enforce the stricter policy, declare it on each applicable variant.

Target roles are resolved against the application’s current role definitions in the declared scope. A custom role can qualify through its inheritance; an unknown role grants nothing. Related-record search can offer members who do not meet a stricter write policy, so handle an assignment refusal even when the person appeared in a picker.

Make exceptions deliberate

This policy is opt-in on the relationship. An organization-less target without it may receive an existence check rather than a membership check. A variant that explicitly waives membership changes the contract for that variant; do not use that escape hatch to make an ordinary assignment work around an incomplete declaration.

Show people the accounts they belong to Guarantee

Account lists need their own boundary: an organization record is the account itself, rather than a record carrying a parent account ID. Wildo confines those collections to the caller’s organization-wide memberships.

A requested filter can narrow that set, but cannot turn it into a directory of other customers.

Example: Filter the account switcher

A person belonging to accounts A and B asks for B and C. The confined result can include B; the requested ID for C does not create membership.

Alex sees Acme and Northwind under My organizations, while unrelated Contoso stays outside the list.
For engineers

The same scope-root authority is used by authorization and repository filtering. After identifying a governed organization collection operation, it derives the allowed IDs from the caller’s organization-wide role entries and intersects the requested IDs:

This implementation excerpt from scope-root-collection-confinement.backend.ts shows the decision in context; explanatory source comments are omitted.

function intersectRequestedIdsWithMembership(requested: unknown, membershipIds: readonly string[]): string[] {
  if (requested === undefined || requested === null) return [...membershipIds];

  const membership = new Set(membershipIds);

  if (typeof requested === 'string') {
    return membership.has(requested) ? [requested] : [];
  }

  if (Array.isArray(requested)) {
    return requested.map(String).filter((id) => membership.has(id));
  }

  if (typeof requested === 'object') {
    const operators = Object.keys(requested as Record<string, unknown>);
    const inValue = (requested as { $in?: unknown }).$in;
    if (operators.length === 1 && operators[0] === '$in' && Array.isArray(inValue)) {
      return inValue.map(String).filter((id) => membership.has(id));
    }
    return [];
  }

  return [];
}
Know which operations are set-shaped

The confinement covers list, search, count, update-many and delete-many operations on the organization root. Addressed reads have a separate per-record authorization path. A unit-only role does not become permission to enumerate the whole organization.

Requested filterResult within memberships A and B
No ID restrictionA and B
ID BB
IDs B and CB
An unsupported ID operatorNo matching IDs

The portable empty-set predicate produces no results on either database adapter. A caller-supplied condition is never a reason to drop the membership restriction.

Distinguish tenant lists from application directories

Other scope roots have explicit dispositions: the deployment application row, the person directory and anonymous sessions do not share the same tenant-membership filter. Application-wide directory admission has its own elevation policy. Internally initiated work, verified callbacks and deliberately admitted cross-tenant operations also have distinct handling; an empty membership list must not silently empty a framework maintenance sweep.

Refuse access without exposing private records Guarantee

A refusal should not reveal whether another customer’s record exists. Wildo separates the decision visible to the caller from the detail needed to investigate the refusal.

The authorization trail preserves a signal for repeated attempts while keeping outward responses from becoming a private-record directory.

Example: An account member guesses a record ID

A record outside the person’s accessible scope does not become discoverable through a different “you cannot access this existing record” response. The internal refusal still has a reason for investigation.

Two unknown targets lead to the same unavailable response, with an audit record kept separately.
For engineers

Role checks that do not depend on the row can refuse before resolving it. A missing or scope-invisible addressed record uses the same outward not-found treatment. Internal storage faults remain faults rather than being rewritten as evidence that a record is absent.

CaseMeaning to preserve
Missing operation roleThe caller cannot use this action
Missing or inaccessible addressed recordNo accessible record can be returned
Repository or infrastructure failureThe operation failed; absence was not established
Keep a useful refusal trail

authorization-denial-audit.backend.utils.ts classifies authorizer-origin not-found outcomes as authorization refusals while excluding unrelated application 404s. The audit policy emits the first refusal and escalating occurrence counts, rather than writing an attacker-controlled number of rows.

The resource-operation authorization path uses this classifier to distinguish a concealed subject from an unrelated application failure. Exact excerpt from authorization-denial-audit.backend.utils.ts:

export function classifyAuthorizationDenial(
  error: unknown,
  phaseDefaultReason: ResourceOperationDenialReason,
): ResourceOperationDenialReason | undefined {
  if (!isWildoBackendError(error)) return undefined;

  if (error.type === ErrorType.AUTHORIZATION) {
    return error.customMessageReference === ErrorCustomMessageReference.AUTHORIZATIONS_MISSING_INITIATOR
      ? ResourceOperationDenialReason.MISSING_INITIATOR
      : phaseDefaultReason;
  }

  if (
    error.type === ErrorType.NOT_FOUND &&
    error.customMessageReference === ErrorCustomMessageReference.AUTHORIZATIONS_VALIDATION_FAILED
  ) {
    return ResourceOperationDenialReason.SUBJECT_NOT_RESOLVABLE;
  }

  return undefined;
}

The second branch recognizes only a not-found outcome marked by the authorizer. Other failures return undefined here; they keep their own error handling rather than entering the access-refusal series.

The counter is associated with the available user identity, resource, operation, variant and reason; calls without a user identity share the fallback bucket. Walking different target IDs therefore does not create an independent audit bucket for every guessed record.

Do not confuse observation with prevention

The normal authorization decision refuses the request. The audit volume policy does not rate-limit or lock the caller, and it does not normalize response timing. Security monitoring can act on the recorded pattern; application access rules remain the preventive control.

Make exceptional access deliberate

Make support access requested and temporary Feature

Support access can be tied to one customer account, a written reason and an expiry instead of being a standing privilege. Wildo requires a usable grant for declared platform crossings and lets the customer require approval.

The customer can see the request and withdraw the grant. Expired access stops being usable when its time window ends.

Example: Investigate a customer’s support request

An operator requests access to the named account for a short investigation. If customer approval is enabled, the operator waits for approval before using a declared support operation.

A support operator requests access to Acme for a limited time, with an explicit expiry.
For engineers

The resource exposes requestAccess, approve, deny and revoke. The request supplies justification and requested duration; operator identity, status and expiry are server-authored. Default duration is one hour and the server caps requests at four hours.

Admission checks the actual expiry, not whether a background process has relabelled the row:

This implementation excerpt from platform-access-grants.shared.schemas.ts shows the decision in context; explanatory source comments are omitted.

export function isPlatformAccessGrantUsable(
  grant: Pick<PlatformAccessGrant, 'status' | 'expiresAt'> | undefined,
  now: Date,
): boolean {
  if (!grant) return false;
  if (grant.status !== PlatformAccessGrantStatus.ACTIVE) return false;
  return grant.expiresAt instanceof Date
    ? grant.expiresAt.getTime() > now.getTime()
    : new Date(grant.expiresAt).getTime() > now.getTime();
}
Declare the operation that support may use

The crossing flag belongs to an API operation variant. The built-in ownership-repair operation keeps the role, scope permission and request together:

variantType: ResourceOperationVariantType.API_CALL,
isDefault: true,
roles: [CORE_APP_ROLES.APP_ADMIN_SUPER_ADMIN],
riskLevel: ResourceOperationRiskLevel.CRITICAL,
resourceOperationLike: CoreResourceOperation.UPDATE,
admitsCrossTenantPlatformAdministration: true,
requestDto: z.object({ justification: z.string().min(1).max(1000) }),

This is a selected variant fragment from organization-members.shared.resources-config.schemas.ts, not a standalone resource. Its operation enums and roles come from @wildo-ai/saas-models, with z from zod. The existing membership resource registers this operation. A custom support action needs its own registered resource operation, API variant and implementation; copying the flag alone creates neither a route nor business behavior.

Choose whether the customer must approve

platformAccessApprovalRequired is a field on the organization record, with a default of false. It is not an organization-type authentication override or a caller-supplied grant status. Set it through the authorized organization configuration path before support requests arrive. The request handler reads the stored organization posture and checks whether a usable owner can approve.

Request, decide, then perform the action

The following sequence follows Wonder Todos’ platform-access-grant lifecycle example. ORG_ID comes from the customer support case; ordinary organization listing is not a platform-wide customer directory. OPERATOR_TOKEN belongs to an application super-administrator. CUSTOMER_ADMIN_TOKEN belongs to a tenant administrator with authority to decide the request. Use a customer with approval required and a usable owner for this pending-approval example.

curl "$BACKEND_URL/organizations/$ORG_ID/platform-access-grants/request-access" \
  -H "Authorization: Bearer $OPERATOR_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"justification":"Investigate support case SUP-42","requestedDurationMinutes":30}'

curl -X PUT "$BACKEND_URL/organizations/$ORG_ID/platform-access-grants/$GRANT_ID/approve" \
  -H "Authorization: Bearer $CUSTOMER_ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"decisionReason":"Verified support case SUP-42"}'

Take GRANT_ID from the created grant record’s _id. Check its returned status and expiry: accepting the request does not mean access is active. With the example’s posture it waits for the tenant decision, and the operator’s crossing is refused while pending. The request operation itself has a narrow bootstrap exemption so an operator can ask without already holding the grant.

Approval uses the customer’s authority, not the operator’s platform crossing. For a refused request use the grant’s deny operation; to withdraw an existing grant use revoke. These are lifecycle operations, not edits to status or expiresAt. A usable grant is looked up when the operator calls the target operation; it is not a replacement login token to send as a Bearer credential.

Continue with the ownership-repair invocation for an actual declared target operation. The grant remains limited to its operator, organization and time window; it does not admit undeclared actions.

Apply both admission requirements

The target operation must declare that it admits cross-tenant platform administration, and the operator must have a usable grant for the target account. A grant does not turn every operation into a support door. Grant lookup failures do not restore standing access.

Account postureNew request
Approval not requiredAutomatically active, with the reason recorded
Approval required and usable owner presentWaits for a tenant administrator’s decision
No usable owner can approveRecovery path auto-approves with a distinct recorded reason

The last case prevents an ownerless account from becoming impossible to repair. Approval posture and owner usability are checked on the request path, not copied from the caller.

Separate account support from global administration

Approval and denial use the tenant’s own authority; the platform crossing declaration does not let an operator approve on that basis. Application-wide directory access has a separate grant and two-person approval policy because no single tenant can authorize reading a deployment-wide directory.

Let customers configure their own connections Feature

Each customer can have its own supported identity, provisioning and audit connections. Wildo places those settings in organization-owned resources so one account’s configuration does not become the application-wide default.

The settings interface follows the enabled features, while the resource and credential paths retain the organization context.

Example: Two customers bring different identity providers

One account configures its single sign-on connection while another uses its own. Their administrators manage the relevant organization settings instead of sharing one deployment credential.

Acme and Northwind each have separate connections and settings.
For engineers

The settings hub selects an organisation; its registered resource operations keep that owner in the request path and execution context. The backend validates who may use those operations. Selecting a customer in the interface does not itself grant authority over that customer’s resources.

For SSO, configuration and connections are separate resources under the same owner. Domain verification establishes who controls the sign-in domain; connection settings describe how the identity provider participates. Neither should be copied into an application-wide credential merely because the application hosts both customers.

Exercise an organization-owned configuration operation

For example, the SSO domain-claim operation lets the organization’s administrator begin proving a domain it controls. The organization already has its seeded SSO configuration; claiming a domain does not require creating a second configuration row.

This request follows sso-domain-ownership.e2e.ts. Set BACKEND_URL, ORGANIZATION_ID and the organization’s administrator ACCESS_TOKEN; replace the illustrative domain with one the customer controls.

curl -X PUT \
  "$BACKEND_URL/organizations/$ORGANIZATION_ID/organization-sso-config/claim-domain" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"domain":"customer.example"}'

The claim response names the domain, reports pending and returns dnsName plus the one-time dnsRecordValue. Publish that exact value as a TXT record at the returned name. Do not invent the challenge value or assume that the domain’s presence in the stored list means it is verified.

After publishing the record, ask the same scoped operation family to verify it:

curl --fail-with-body -X PUT \
  "$BACKEND_URL/organizations/$ORGANIZATION_ID/organization-sso-config/verify-domain" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"domain":"customer.example"}'
ResultWhat the administrator should understand
Claim returns pendingA challenge exists; publish its TXT record
Verification returns failedThe challenge was not established; inspect DNS and retry verification
Verification returns verifiedThe ownership check succeeded for this scope
The domain belongs to another verified scopeThe operation rejects the competing ownership claim

Read the response’s status, not only the HTTP status code. A completed verification request can return HTTP 200 with failed; --fail-with-body cannot detect that business result. The backend checks DNS and rechecks competing ownership at verification time, before granting routing authority. A claim alone does not authorize sign-on routing.

This illustrates the ownership contract: a public, named operation changes the selected organization’s configuration and returns the next action its administrator needs. Provider connection details and SSO enforcement remain separate settings; the general configuration update is an internal operation, not a public catch-all HTTP endpoint.

Configure the correct integration owner
SurfaceOrganization-owned configuration
Single sign-onSSO configuration and connections
Directory provisioningSCIM provisioning configuration and tokens
Audit streamingSIEM export configuration and delivery failures
Connected accountsProvider credentials
API accessOrganization API keys

Enable the relevant feature and configure the provider through its declared resource. A page appearing in settings is a consumer of that configuration, not proof that a provider is already connected or operating.

Preserve the distinction between personal and organization connections

A person’s connected account and an organization-owned credential represent different authority. Use the appropriate resource and runtime context rather than copying credential material into generic application settings.

This capability describes the integrations with organization-owned configuration. It does not imply that every external provider supports every ownership mode. See the individual sign-on, provisioning and machine-access guides for their setup and operation contracts.

Turn a first visit into the right account and membership

People can register, accept an invitation or arrive through their company’s directory. Wildo connects those entry paths to the application account and the organizations it belongs to.

Keep eligible work started before signup, link verified external identities and apply the customer’s provisioning rules. You define how people join; Wildo carries their identity and membership through the transition.

Signup, invitation and directory paths connect an account to its intended workspace membership.

Make joining part of the application

Verify the person joining

Registration separates account creation from proof of the email address. External identity linking follows the provider’s verified identity.

Preserve their context

An invitation activates the intended membership. Eligible anonymous records can move to the new account without a browser-side copy.

Follow the directory lifecycle

Directory provisioning handles configured account updates and departures, as well as creation. Sign-in and provisioning remain distinct responsibilities.

Example: Join a workspace without starting over

A visitor begins a draft, creates an account and verifies their address. The draft becomes theirs. Later, an invitation adds membership in a customer’s workspace while keeping the same application account.

For engineers

Choose the entry path deliberately

Entry pathWhat establishes identityWhat the application receives
Public registrationThe selected credential and email-verification flowAn account continuing through configured onboarding
Organization invitationThe invitation or authenticated recipient pathActivation of the intended pending membership
External sign-inThe provider’s verified subject and permitted linking policyA resolved or provisioned local account
Directory provisioningA scoped directory credentialAccount and membership changes under provisioning policy

registration.allowedMethods is separate from authMethodsEnabled. The frontend must admit registration for the selected user type too. Public passwordless signup still proves email ownership before granting access; enrolling an authenticator alone is not that proof.

Keep one account and explicit memberships

The pending organization-member record is the invitation. Accepting it activates that junction. An already authenticated person can accept their own pending invitation without replacing their session. External identity links connect provider subjects to the local account rather than creating another account on every sign-in.

Anonymous conversion is a separate resource-ownership transition. The destination user type enables anonymousSessionsEnabled; an eligible resource declares isAnonymizable and its transpositionPolicy. The server recognizes the prior anonymous session and moves or discards records according to that policy; a client-supplied user identifier does not establish ownership. Recovery retries preserve work created after conversion; a failed initial REPLACE deletion can therefore leave both sets of records.

Separate directory availability from active management

An organization type enables the SCIM route. Each customer then needs its provisioning configuration and credential. The credential determines which organization a request may affect; no request-body organization selector overrides it.

Provisioning controls automatic creation, organization-membership deactivation and role assignment. Directory withdrawal does not disable the global account or its other memberships; reactivation follows current acquisition policy. Optional unit mapping can make the directory authoritative over unit assignments, including removal of manually assigned units. Configure that consequence deliberately. Directory-managed local credential policy prevents an old local first factor from becoming a way around the directory’s authority.

See registration, workspace invitations, anonymous work and directory provisioning for the specific contracts and examples.

Bring people into the product

Welcome new users with a verified address Feature

Choose who may create an account, which signup methods they can use and when email ownership must be proved. Wildo carries that policy through account creation and the next step into the application.

Registration can be open, invitation-led or administrator-controlled. Passwordless signup still proves the email address before it establishes access.

Example: Verify before entering the workspace

A new member submits registration, follows the verification email and continues into the application. An administrator account is created through an administrative process instead of public signup.

Account creation is followed by email verification before continuing.
For engineers
Separate account creation from sign-in

This real member registration policy in Wonder Todos requires email verification before access:

registration: {
  mode: RegistrationMode.OPEN,
  allowedMethods: [AuthMethod.PASSWORD],
  emailVerification: EmailVerificationMode.REQUIRED_BEFORE_ACCESS,
  blockForSSODomains: false,
},

allowedMethods is the acquisition set. authMethodsEnabled is the sign-in set. The frontend’s usersManagement entry must also allow registration for this user type. A method being available to an existing member does not automatically make it a signup method.

Follow the selected method’s next step
Signup methodHow the account proves its addressWhat follows
PasswordThe configured email-verification flowContinue authentication when its requirements are met
Magic linkThe emailed link proves control of the inboxContinue through the normal authentication policy
PasskeyEmail proof comes before passkey enrollmentEnroll the authenticator for the account
Social identityThe provider must supply a verified emailLink or provision through the social callback

The public registration service returns the appropriate next step. A passkey proves possession of an authenticator, not ownership of an email address, so passwordless public signup does not skip email proof even when deferred verification is selected for password registration.

Complete the surrounding onboarding

User-type roles and configured organization creation are separate parts of the signup result. Use the standard registration flow or follow its returned state with a custom interface; do not issue your own session immediately after creating a user row. Your application chooses the profile and onboarding information, while Wildo connects account status, verification and authentication.

Invite people into the right workspace Feature

Bring someone into an organization with a defined membership and role. Wildo connects the invitation, account and acceptance flow, whether the person is new or already uses the application.

The organization’s authentication policy follows them through acceptance. An invitation does not create a separate way around the customer’s sign-in requirements.

Example: Invite a colleague who already has an account

A colleague accepts an invitation to a second workspace. Their existing account remains the same; the new membership grants access to the inviting organization.

An invitation from Acme takes Alex from invited status to membership.
For engineers
The pending membership is the invitation

An invitation is an organization-member record in the INVITED state. Acceptance activates that membership instead of creating a disconnected invitation history and another membership by hand. The organization’s member operations own invitation creation, role assignment and revocation.

Create, resend or cancel the same invitation

Use an organization administrator’s token and the target workspace ID. The email-based CREATE path resolves the invited account and creates the pending membership; do not supply userId or force a lifecycle status:

curl "$BACKEND_URL/organizations/$ORG_ID/organization-members" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"userEmail":"colleague@example.com","roles":["ORG_MEMBER"]}'

Read the created membership and retain its _id as MEMBERSHIP_ID. Confirm its status is INVITED. The caller must be allowed to grant the selected roles, and those roles must fit the organization type. A successful create is not evidence that the recipient received an email: delivery also needs the application’s configured email provider and invitation templates.

If the invitation remains pending, resend it on that same record:

curl -X PUT "$BACKEND_URL/organizations/$ORG_ID/organization-members/$MEMBERSHIP_ID/resend" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{}'

Resending replaces the acceptance token and invalidates the previous link. To withdraw the invitation instead, use its revoke operation:

curl -X DELETE "$BACKEND_URL/organizations/$ORG_ID/organization-members/$MEMBERSHIP_ID/revoke" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"reason":"The invitation is no longer needed"}'

The optional reason is limited to 500 characters. Both resend and revoke require a still-pending membership and organization-administrator authority. Revocation removes that invitation and revokes its acceptance token; it is not the operation for removing an already active member. For an acceptance check, use the newest invitation link or the signed-in invitee’s own inbox, then verify the membership becomes ACTIVE. Do not run revoke before testing acceptance on the same invitation.

Match the acceptance path to the person
SituationAcceptance behavior
New person choosing passwordActivate the invited account and establish the permitted credential
New person choosing magic linkActivate the account and send the sign-in link; no session is issued at this step
New person choosing passkeyContinue into enrollment for the invited account
Already authenticated personAccept their own pending membership without replacing their session
Directory-managed organizationDo not establish a local credential that bypasses directory management

The registration service first validates the token and current membership, then checks the selected method against the effective invitation policy. Token consumption is deferred until the guarded acceptance transition, so validation does not itself accept an invitation.

Keep existing users on their current account

The standard invitation screen uses the authenticated client and refreshes organization context after acceptance. This is its handler in AppPage_InvitationsSettings.tsx, with comments omitted:

const handleAccept = useCallback(
  async (membershipId: string) => {
    setBusyId(membershipId);
    setError(null);
    try {
      await getManualCallsHttpClient().acceptMyInvitation(membershipId);
      await refreshOrganizations();
      setSuccessMsg(t('acceptSuccess'));
      await refresh();
    } catch (e: unknown) {
      if (isWildoBackendError(e)) throw e;
      setError(t('errorGeneric'));
    } finally {
      setBusyId(null);
    }
  },
  [refresh, refreshOrganizations, t],
);

The service checks that the addressed pending membership belongs to the current user. Public token-based acceptance and authenticated acceptance converge on membership activation, but the latter does not issue a replacement session. A custom invitation inbox should use these ownership-checked operations rather than accepting an arbitrary membership identifier through an administrative update.

Revoking or rejecting an invitation withdraws the pending membership. An unusable public invitation produces a common refusal rather than exposing whether another person’s invitation exists.

Keep work started before signup Feature

Let visitors begin useful work before they create an account. When they register, Wildo can move the eligible records to their authenticated identity instead of making them start again.

Choose which resources allow anonymous ownership and whether existing work is added, replaced or discarded during conversion.

Example: Keep a draft after creating an account

A visitor writes a draft note, then registers. The same draft becomes theirs as a signed-in member, without copying it through the browser.

A visitor carries their draft documents through sign-in and keeps their work afterward.
For engineers
Enable continuity for the destination account

Set anonymousSessionsEnabled: true in the destination user type’s authentication policy, as Wonder Todos does for its member accounts. This is checked again by transposeOwnership: a resource’s anonymous settings alone do not authorize conversion into every account type.

Opt the resource into anonymous ownership

Wonder Todos’ draft-note resource declares the policy alongside its schema and operations. Selected configuration from draft-notes.resources-config.ts:

mainSchema: DraftNotes_Schema,
resourceIdentifier: TasksManager_ResourceType.DRAFT_NOTES,
resourceFieldIdentifier: TasksManager_ResourceFieldIdentifier[TasksManager_ResourceType.DRAFT_NOTES],
resourceRelationships: resourcesRelationships,
isSystemResource: false,
isAnonymizable: true,
transpositionPolicy: ResourceTranspositionPolicy.ADD,
systemAccessPolicy: { exportSubject: ResourceSystemAccessMode.ALLOWED },

Its create/read/list/update variants admit both CORE_APP_ROLES.APP_ANONYMOUS and CORE_APP_ROLES.APP_USER. The resource must use the supported user-self ownership scope; an anonymous identity does not become organization membership.

Decide how conversion treats existing records
PolicyConversion behavior
ADDReassign the anonymous records to the authenticated owner
REPLACERemove the authenticated owner’s existing records for that resource before moving the anonymous records
DISCARDRemove the anonymous records instead of attaching them

These are data-lifecycle choices, not display preferences. Use replacement only when that is what the application means.

Hand off the server-recognized session

The authentication controller passes the recognized anonymous identity into transposeOwnership after conversion. The server updates ownership and records the result; the browser does not prove ownership by submitting an arbitrary user identifier. Incomplete resource moves are tracked and retried by reconciliation, so conversion is not modeled as an all-or-nothing copy in frontend state.

Reconciliation deliberately does not repeat REPLACE deletion: the account may have gained new records since conversion. If the initial deletion failed, retry can leave both the account’s existing records and the moved anonymous records. This preserves subsequent work rather than deleting it to recreate the original replacement outcome.

For website-to-application continuity, configure the actual cookie and deployment topology and initialize non-essential sessions according to the application’s consent policy. The anonymous-session mechanism supplies continuity; it does not replace authentication or consent.

Connect existing identities and directories

Keep accounts in step with the company directory Feature

Let a customer’s directory provision people into its organization and manage their membership there. Wildo connects those changes to local accounts and configured organization-unit assignments.

The directory credential determines the organization. You choose the provisioning rules, including whether directory deactivation withdraws access.

Example: Handle a departure through the directory

When an employee leaves, the directory sends the account update. With deactivation enabled, the application withdraws that organization membership and its dependent grants. The person’s global account and memberships in other customers are not disabled.

A directory sends additions, updates and deactivation through SCIM to Acme's members.
For engineers
Enable availability, then configure the customer

Wonder Todos declares scimProvisioning: { enabled: true } under organizationTypes.workspace in backend-api/src/saas-config.backend.ts. That makes the route available for the type; it does not mean every organization already has an active directory connection.

The organization has its own provisioning configuration and bearer token. The endpoint path is exposed as scimEndpointUrl, derived from the actual API base rather than stored as tenant data. Combine it with the deployed API host when configuring the directory.

Save the customer’s provisioning policy

Keep scimProvisioning: { enabled: true } alongside the existing organization type’s membership and creation rules. The backend mounts the directory routes when at least one type enables them, and checks the token’s organization type again on incoming requests. Also enable CoreFeature.DIRECTORY_PROVISIONING for the target organization through the application’s feature configuration; the type-level switch does not grant that customer feature.

In an application-owned, organization-admin-authorized backend operation, use the registered configuration service. This helper receives the backend container and the caller’s execution context from that operation; organizationId is its authorized target organization:

import {
  AuthOrganizationConfigurationService,
  SAAS_SERVICE_TYPES,
  type ExecutionContext,
  type InversifyContainer,
} from '@wildo-ai/saas-backend-lib';
import { AuthScopeType, CORE_ORG_ROLES } from '@wildo-ai/saas-models';

async function configureDirectory(
  container: InversifyContainer,
  executionContext: ExecutionContext<any>,
  organizationId: string,
): Promise<void> {
  const configuration = container.get<AuthOrganizationConfigurationService>(SAAS_SERVICE_TYPES.AuthOrganizationConfigurationService);
  await configuration.saveScimProvisioningConfig(
    executionContext,
    AuthScopeType.ORGANIZATION,
    organizationId,
    { autoCreateUsers: true, autoVerifyEmail: true, autoDeactivateUsers: true, defaultRole: CORE_ORG_ROLES.ORG_MEMBER },
  );
}

The service is already registered by the engine. It creates or updates the scope’s configuration through the services registry with the caller’s context, preserving authorization and audit attribution. This is a backend integration helper, not a new HTTP route: the configuration resource’s create/update operations are internal. Keep the administration action in your application’s declared operation and access policy.

Here, directory assertions may create accounts, verify their email and withdraw membership on deactivation. Choose those switches deliberately. The save service rejects the core owner/admin roles as defaultRole; custom roles still need an application-level review of the authority they grant.

Issue the directory its own credential

An organization administrator invokes ScimTokens_Operations.GENERATE_TOKEN on CoreResourceType.ORGANIZATION_SCIM_TOKENS. The registered API operation borrows CREATE, accepts displayName and an optional ISO expiresAt, and returns a persisted record with a one-time token. With BACKEND_URL set to the application’s API base (including its API prefix), invoke the organization-scoped operation with an administrator session. The directory credential itself is not the administrator’s session token:

curl --fail-with-body "$BACKEND_URL/organizations/$ORGANIZATION_ID/organization-scim-tokens/generate-token" \
  -H "Authorization: Bearer $ORG_ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"displayName":"Company directory"}'

Add expiresAt with a future ISO timestamp when the deployment requires an expiry. Transfer the returned token to the directory’s secret configuration and retain the record ID for lifecycle management. Only its hash is stored. Rotation returns newToken and replaces the hash immediately; there is no old-token grace window. Revocation makes the record inactive and keeps it for audit.

Send a directory user and inspect the result

Set SCIM_BASE_URL to the deployed API host plus the configuration’s scimEndpointUrl, and SCIM_TOKEN to the one-time secret above. Use an email accepted by the customer’s verified-domain policy and a user type eligible to join that organization. This example uses a disposable account in your own test organization:

curl --fail-with-body "$SCIM_BASE_URL/Users" \
  -H "Authorization: Bearer $SCIM_TOKEN" \
  -H 'Content-Type: application/scim+json' \
  --data-binary @directory-user.json

The directory-user.json payload supplies both the directory identifier and the profile fields used by the default mapping:

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "externalId": "directory-person-001",
  "userName": "person@your-verified-domain.example",
  "name": { "givenName": "Alex", "familyName": "Morgan" },
  "emails": [{ "value": "person@your-verified-domain.example", "primary": true }],
  "active": true
}

A successful create returns HTTP 201 with the SCIM user ID and a Location header. Read Users/<returned-id> with the same credential and inspect the organization membership in your application. The token selects the organization; adding another organization ID to this payload does not change the target. Existing accounts can be linked rather than duplicated. A directory push is not an SSO login and does not create a browser session.

Choose what incoming changes may do

Selected current fields from scim-provisioning-config.shared.schemas.ts:

autoCreateUsers: z.boolean().default(true).isSummaryField().isAuditEvidence(),
autoVerifyEmail: z.boolean().default(true),
autoDeactivateUsers: z.boolean().default(true).isAuditEvidence(),
defaultRole: z.string().optional().isAuditEvidence(),
attributeMapping: ScimAttributeMappingSchema.optional(),

defaultRole is an organization-wide role grant, so configuring it is an access decision. Unit assignments use separate organizationUnitMapping and defaultUnitRole fields. When both are configured, directory synchronization becomes authoritative over those assignments: a push replaces the mapped set, including removal of manual assignments. Leave that mapping absent when the application should own them manually.

Let the credential determine the tenant

The middleware hashes the presented bearer token, verifies active/expiry state and takes organizationId from its stored record. The caller cannot choose another organization in the body. Rate limits are per token, keeping one directory’s traffic from using another credential’s budget.

Match the supported provisioning surface

The controller exposes Users create/read/list/replace/patch/delete and bounded bulk operations. Ordinary and bulk writes share lifecycle and unit-update handlers: replacement synchronizes the full mapped unit state; PATCH changes only the supplied fields. Delete performs membership deactivation through provisioning policy.

The active response and filter describe membership state in the addressed organization. Reactivation uses current acquisition and default-role policy rather than restoring historical privileges, and cannot unlock a globally disabled account. Configuration read failures refuse mutation instead of substituting permissive defaults. Groups, sorting and entity tags are not supplied by this surface; configure the directory accordingly. Local first-factor credentials are neutralized for actively directory-managed users so directory removal cannot be bypassed by an old local sign-in method. SSO and provisioning share account creation services but remain distinct connections and responsibilities.

Manage customer change without losing control

A customer workspace can be paused, restored or closed. Wildo gives those transitions distinct actions, with the access changes and recovery paths that belong to each.

Temporary suspension preserves records. Deletion starts with a recoverable mark and a recorded deadline before scheduled removal. Ownership safeguards help keep a usable administrator in place along the way.

A workspace can pause and reactivate, or await deletion with a restore path before purge.

Different actions for different outcomes

Pause without destroying

Suspend a workspace when activity must stop but its data must remain. Reactivate it through the authorised recovery path.

Make deletion recoverable first

Close access when deletion is requested, retain the recorded recovery deadline and let the purge job complete removal afterward.

Keep responsibility visible

Protect the last usable owner, provide a narrow recovery action and notify the people affected while the message is still useful.

Example: Close a customer workspace deliberately

A customer asks to leave. Its administrator requests deletion, access stops and the recovery deadline is recorded. If the decision changes before purge, an authorised operator can restore the workspace. Otherwise the scheduled job removes it through the normal child-lifecycle rules.

For engineers

Choose the action before writing the implementation

ActionImmediate effectRecovery or boundary
SuspendStops the organisation conferring authority; keeps dataActivate returns an eligible organisation to ACTIVE
Request deletionRequires fresh authentication proof, marks DELETED and stores a purge deadlineBoth administrator variants require proof; restore remains possible while the marked row exists
RestoreClears deletion marks, sets ACTIVE and re-grants derived user typesPlatform recovery action with appropriate target reach
Scheduled purgeDeletes a due organisation through the internal deletion operationChild retention and cascade policies remain in force
Administrator deleteBypasses the retention windowFresh re-authentication and tenant access are separate requirements
Grant ownershipAdds ownership to a usable existing membershipNarrow platform recovery action; never a general role replacement

Configure the waiting period at the backend boundary

The optional tenantTeardown block in backend configuration owns the recovery period and scheduled work:

tenantTeardown: {
  retentionWindowDays: 30,
  purgeCronSchedule: '0 3 * * *',
  purgeBatchSize: 50,
},

These are the current defaults. The deletion-request transaction computes and stores the individual organisation’s deadline. Changing this block does not shorten deadlines already promised. A job run processes due marked organisations; its schedule and backlog determine when removal actually happens.

Keep state, membership and application grants coherent

The organisation type declares which application user types membership grants. Each dependent user type must also declare requiresOrgMembership.orgTypes for withdrawal to revoke it when its last qualifying membership is lost. The lifecycle implementation reconciles those configured dependencies; token invalidation follows an actual revocation. Restoration re-grants the organisation type’s configured user types. It does not delete all member accounts simply because their organisation closes: a person may still have another valid source of authority.

Generic organisation updates cannot change the lifecycle status. Use the declared transitions so state conditions, transactional work, notifications and audit hooks run through their intended paths.

Give recovery an authority that still works

A suspended or deleted organisation no longer confers its members’ organisation roles. Recovery therefore uses explicitly declared platform actions; a non-member operator also needs the appropriate target-organisation access grant. The irreversible administrator delete deliberately does not inherit those recuperative crossings.

Owner continuity is a related but separate concern. Ordinary membership reductions must preserve a usable owner. Platform account deactivation and explicit erasure decisions have their own treatment; a stranded organisation is repaired by promoting a usable existing member rather than weakening the regular membership guard.

Check the stored result and the message

For a lifecycle transition, verify the organisation’s state, the affected member’s resulting access, the deadline where relevant and the intended notification. For purge, inspect child-lifecycle outcomes and the deletion event, not just an HTTP success. The normal deletion pipeline can retain and impersonalise children according to their declared policy.

Pause, close and recover

Pause a workspace without deleting it Feature

Suspension stops a workspace from conferring authority while preserving its data. It gives the operator a way to halt activity while an account or security problem is resolved.

Reactivation returns an eligible suspended or expired-trial organisation to active operation. Recovery has its own authorised path because the suspended organisation’s members cannot lift the suspension themselves.

Example: Pause an account while a problem is investigated

An operator suspends a customer workspace with an explanation. Its records stay in place; when the matter is resolved, an authorised reactivation makes the workspace active again.

Acme moves from paused to active while keeping the same documents.
For engineers
Use a lifecycle action rather than a status patch

An illustrative suspension request body is:

{
  "suspensionReason": "Account activity is under review",
  "notifyMembers": false
}

Submit it through organizations.suspend with the required platform authority and tenant reach. The action accepts ACTIVE or TRIAL; it refuses an already suspended organisation. The reason is required, and notifyMembers: false suppresses its member notification.

Understand what the transition changes

The transactional implementation sets the organisation to SUSPENDED, reconciles application user types granted by that organisation type and tied to membership through requiresOrgMembership.orgTypes. The paired organization/user-type declarations are prerequisites for that revocation: the organization type names what it grants, and the user type names the organization types whose membership it requires. Another qualifying active membership preserves the type. Without that membership requirement, this reconciliation does not remove the application user type; the suspended workspace still stops conferring organization-scoped authority. Tokens are invalidated when a type is actually revoked, rather than logging out every member unconditionally. Organisation-scoped authority is derived from the current organisation state, so retained membership rows do not keep conferring access.

Reactivation sets ACTIVE; it does not restore a remembered previous trial state. The activate operation accepts SUSPENDED or TRIAL_EXPIRED and re-grants organisation-derived user types.

Give recovery its own reach

Activation explicitly admits the platform cross-tenant administration path. A non-member platform operator still needs the applicable time-limited access grant for the target organisation. Suspension does not acquire that crossing merely because activation has it.

Both actions enforce their state conditions on the server as well as in the interface. Follow reversible deletion when the intended outcome is offboarding rather than a temporary pause.

Leave time to undo workspace deletion Feature

Requesting deletion closes the workspace to its members without immediately destroying its records. Wildo records a purge deadline so the operator has a recovery period before permanent removal.

Restoration is a separate operator action. It reactivates the organisation and clears the deletion marks while the organisation still exists.

Example: Recover a deletion requested by mistake

An administrator requests deletion and the workspace closes. Before it is purged, an authorised operator restores it and the members can work in it again.

Workspace access closes before a recovery window and purge; a return arrow allows recovery to the active workspace.
For engineers
Mark the organisation through its deletion request

Both the tenant-administrator and platform-operator variants of Organization_Lifecycle_Operations.REQUEST_DELETION (request_deletion) require fresh authentication proof. The standard interface requests that proof before submitting the operation; permission to administer the workspace alone is not enough.

The internal operation key is request_deletion; the generated HTTP path uses request-deletion. Choose the variant that matches the caller:

RequestRequired authority
PUT /organizations/{organizationId}/request-deletionOrganization administrator within that workspace
PUT /organizations/{organizationId}/request-deletion/adminPlatform super-administrator; a cross-tenant caller also needs the applicable access grant

For the tenant-administrator path, the request is:

curl -X PUT "$BACKEND_URL/organizations/$ORG_ID/request-deletion" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "x-reauth-token: $REAUTH_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"reason":"The customer has asked to close this workspace"}'

REAUTH_TOKEN is the fresh proof returned by the application’s step-up authentication flow, for the same caller as ADMIN_TOKEN. It is not another name for the session token. The optional reason is limited to 500 characters. The operator variant uses the /admin suffix and also requires fresh proof; changing the URL does not grant platform authority or tenant reach.

The operation writes status: DELETED, deletionRequestedAt and purgeScheduledAt in the lifecycle transaction. It also reconciles assigned application user types granted by that organisation type and tied to membership through requiresOrgMembership.orgTypes. A type is retained while another qualifying active membership exists. Tokens are invalidated only when a type is actually revoked; closing the workspace separately removes its organisation-scoped authority.

Keep the deadline attached to this decision

The deadline is calculated from tenantTeardown.retentionWindowDays when the organisation is marked. Changing the configuration later does not rewrite deadlines already recorded. The warning belongs on this reversible mark, while someone can still ask for recovery. After marking, inspect all three stored values: DELETED, deletionRequestedAt and purgeScheduledAt. Repeating the mark on an already deleted organization is refused, so it cannot silently restart the recovery window.

Restore an existing marked organisation

restore requires platform authority and, for a non-member crossing, the appropriate access grant. It sets ACTIVE, clears both marks and re-grants organisation-derived user types. It does not restore a previous suspended or trial state.

An authorized platform operator restores the addressed organization with:

curl -X PUT "$BACKEND_URL/organizations/$ORG_ID/restore" \
  -H "Authorization: Bearer $OPERATOR_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"reason":"The closure request was withdrawn"}'

Arrange the target-organization access grant before using the cross-tenant path. After restoration, verify ACTIVE, cleared deletion timestamps and access through a qualifying membership. The reason is optional and limited to 500 characters.

The restoration gate tests that the row is marked, not whether its deadline has passed. Until purge actually removes the row, it can still be restored. Once the row is gone, addressing cannot find it. This makes the configured period a minimum recovery opportunity subject to the purge schedule, rather than a promise that removal happens at the exact deadline.

Finish offboarding on a clear schedule Feature

After a marked organisation’s recovery deadline passes, a background job removes it through the ordinary deletion operation. The same child-lifecycle rules apply as on an explicit delete.

The operator controls the retention period, schedule and work per run. A failure on one organisation is recorded without preventing the job from attempting the others.

Example: Honor the deadline already given

An organisation is marked under a thirty-day policy. Shortening the default tomorrow does not move that organisation’s stored deadline; the purge job uses the date already recorded.

Due records move from a calendar into scheduled cleanup, while a failed item enters a retry loop.
For engineers
Configure the operator policy

In backend-api/src/saas-config.backend.ts, the optional tenantTeardown block accepts:

tenantTeardown: {
  retentionWindowDays: 30,
  purgeCronSchedule: '0 3 * * *',
  purgeBatchSize: 50,
},

These are the current defaults, including when the entire tenantTeardown block is omitted. Omitting it does not disable purge. Choose the period to match the application’s obligations. The schedule controls when due organisations are processed; it does not change their individual stored deadlines.

Follow the batch through normal deletion

Application startup registers the engine’s organisation-purge batch. It selects organisations with status: DELETED and an existing purgeScheduledAt earlier than the current instant, bounds the attempted set and invokes the route-less internal delete operation for each.

This reuses cascade and retention behavior instead of maintaining a second teardown algorithm. A child configured for retention and impersonalisation follows that policy; do not describe purge as unconditional physical deletion of every related business record.

Verify that scheduling reaches a running executor

Registration makes the batch available; it does not prove a scheduled run happened. The application’s cron registration must reach the crontabs manager, and the background execution path must have its queue connection and consumers running. Startup can continue when queue initialization or cron synchronization has failed, so a healthy HTTP endpoint alone is not proof that cleanup is operating.

Check the registered organization-purge schedule, then a completed run and its result. Compare the stored deadline with the actual execution time; an organization becomes eligible after its deadline, and removal occurs when a successful run reaches it. Do not rewrite stored deadlines merely to clear a backlog.

Operate failures and backlog explicitly

The result distinguishes attempted, purged and failed organisations. dueOrganizations is the number attempted in this bounded run, not the total backlog; purged and failed describe those attempts. A failure is logged with the organisation and deadline; the next eligible run can attempt it again. A run bounded by batch size also logs that more work may remain.

The organisation deletion audit hook runs on this internal delete path as well as the administrator path. Its evidence records the organisation from the pre-deletion snapshot, which remains meaningful after the live row has disappeared.

Require a fresh check before permanent removal Feature

Immediate deletion is a separate, destructive action. It bypasses the recovery period and requires the administrator to prove their identity again before removing the organisation.

Platform role alone is not unlimited reach: this action remains within its declared tenant boundary. The normal deletion-request path provides the reversible option.

Example: Confirm the irreversible action

An authorised administrator chooses permanent deletion. A previously signed-in session is not enough; the operation requires fresh re-authentication before the deletion can proceed.

Identity confirmation and access to the target Acme workspace meet as separate requirements before immediate purge.
For engineers
Read the operation requirement independently of risk labels

The administrator delete variant declares requiresStepUpAuthentication: true. This is the operative requirement; a risk label alone is not equivalent.

The following excerpt shows the security-relevant contract, with surrounding operation configuration omitted:

{
  roles: [CORE_APP_ROLES.APP_ADMIN_SUPER_ADMIN],
  riskLevel: ResourceOperationRiskLevel.HIGH,
  requiresStepUpAuthentication: true,
}

Use the generated administrator delete operation and its re-authentication flow. The service handler checks the per-operation requirement rather than accepting an old login merely because it is still a valid session.

Address the administrator variant explicitly

The HTTP operation is DELETE /organizations/{organizationId}/admin. The default DELETE variant is internal and route-less; it is what the scheduled purge uses. There is no ordinary tenant-facing delete route to use instead.

For a platform super-administrator who also has legitimate scope access to the target workspace:

curl -X DELETE "$BACKEND_URL/organizations/$ORG_ID/admin" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "x-reauth-token: $REAUTH_TOKEN"

Obtain REAUTH_TOKEN from the application’s step-up flow for that same authenticated caller. The fresh proof accompanies the request as x-reauth-token; a still-valid login token by itself does not satisfy the operation. This route performs the purge immediately rather than setting a future deadline.

Keep tenant reach as a separate check

This irreversible door does not declare cross-tenant platform administration. A non-member operator cannot infer permission from the role or from a recovery grant used by a different operation. A successful fresh identity check proves the caller again; it does not manufacture tenant access.

Preserve a meaningful deletion record

The deletion audit implementation captures the organisation’s identity before removal and emits after the delete. Both the administrator variant and the scheduled internal deletion variant register the hook.

Verify the refused cross-tenant request, the missing-step-up request and the allowed deletion separately. Only the last should remove the row. For ordinary customer offboarding, request deletion keeps the recovery interval available.

Keep a responsible owner and a recovery path

Keep a usable owner in the workspace Guarantee

An organisation needs an owner who can actually act. Wildo checks the remaining usable owners before allowing membership changes that would remove the last one.

The normal remedy is to appoint another owner first. This protects everyday administration while keeping account suspension and explicit data-erasure decisions distinct.

Example: Transfer ownership before leaving

The only owner tries to remove their membership. The change is refused until another active member with an active account has a role that confers ownership.

An Acme owner hands responsibility to another owner before leaving the workspace.
For engineers
Count authority, not the text of one role

A usable owner combines a usable membership, a usable linked user and a role that confers ORG_OWNER through the current role hierarchy. A role label on an invited or inactive membership does not satisfy the floor.

CandidateCounts as a usable owner?
Active membership, active user, owner-conferring roleYes
Invitation carrying an owner roleNo
Active membership whose user is inactiveNo
Custom role that inherits ownership, with usable membership and userYes
Let the existing membership operation enforce it

The create/update/removal implementations call the owner-floor authority as appropriate. Application code should use the normal membership service path rather than editing the repository to bypass it.

The guard measures the owners the proposed change would remove. This selected excerpt from organization-owner-floor.backend.utils.ts runs after the transaction has established the serialization boundary; intervening comments are omitted:

const usableOwnerMemberIds = await measureUsableOrganizationOwners(params);
const reducedMemberIdSet = new Set(reducedMemberIds.map((memberId) => String(memberId)));
const reducedUsableMemberIds = usableOwnerMemberIds.filter((memberId) => reducedMemberIdSet.has(memberId));
if (reducedUsableMemberIds.length === 0) return undefined;

const remainingUsableOwners = usableOwnerMemberIds.length - reducedUsableMemberIds.length;
if (remainingUsableOwners >= ORGANIZATION_OWNER_FLOOR_MINIMUM) return undefined;

return { organizationId, reducedMemberIds: reducedUsableMemberIds, remainingUsableOwners };

An empty result means this change does not breach the floor. A returned breach identifies the affected organisation and reductions for the caller’s refusal; it is not permission to proceed.

The guard serialises competing reductions on the organisation record inside the transaction. Two removals that each observe a second owner must not both commit and leave zero. This is why a separate preflight count in a custom UI is only guidance, not enforcement.

Give the refusal an actionable recovery path

A refused reduction returns HTTP 409, with error.code set to LAST_ORGANIZATION_OWNER in the HTTP response. Use the public enum to distinguish it from an unrelated conflict. For example, this application-owned predicate can select the ownership-transfer message in a custom administration screen:

import { AdministrativeContinuityErrorCode } from '@wildo-ai/saas-models';

export function needsAnotherOrganizationOwner(
  status: number,
  errorCode: unknown,
): boolean {
  return (
    status === 409 &&
    errorCode === AdministrativeContinuityErrorCode.LAST_ORGANIZATION_OWNER
  );
}

Pass the HTTP status and the parsed response’s error.code; keep the normal handling for other failures. Do not match the human-readable message or the internal audit reason. This conflict needs a change in ownership, so automatically retrying the same request is not a remedy.

Guide the administrator to give an active organization-wide member an owner-conferring role, confirm that member’s linked account is active, and then retry the original operation. Preserve the target member’s other required roles when updating their role set. A pending owner invitation is insufficient until accepted. An owner grant on an organization unit is also insufficient: the floor measures organization-wide memberships, not unit assignments.

The server still evaluates the final write in its transaction. A UI that shows a second owner is helpful guidance, not proof that concurrent changes cannot alter the result.

Keep distinct decisions distinct

Platform user deactivation can stop a compromised account even when that strands an organisation; the affected organisation is reported for repair. An explicit subject-erasure decision has its own acknowledged-breach path. Neither should be described as an ordinary membership administrator’s permission to remove the final owner.

If the organisation is already stranded, use the ownership recovery operation. It promotes a usable existing member; it does not weaken the regular floor.

Restore ownership when a workspace is stranded Feature

If an organisation no longer has a usable owner, a platform operator can promote an existing usable member. The recovery action adds ownership without replacing the member’s other roles.

The operation is deliberately narrow: it repairs authority in an existing membership rather than creating a new account or opening general cross-customer editing.

Example: Recover an ownerless customer workspace

An active customer member is ready to take responsibility after the former owner’s account is disabled. An authorised operator grants ownership to that membership and hands administration back to the customer.

An operator uses a narrow ownership-repair door to promote an existing Acme member.
For engineers
Establish the target before invoking recovery

The target must have both a usable organisation membership and a usable user account. An invitation or disabled account cannot become a functioning owner merely by receiving another role.

The operation is organizationMembers.grantOwnership. Its request requires a justification:

{
  "justification": "Restore customer administration after the former owner left"
}

A non-member caller needs application-level super-administrator authority and the applicable target-organisation access grant. The operation explicitly admits this crossing; ordinary membership operations do not inherit it.

Invoke the repair on the membership

First complete the temporary-access request and approval sequence. For a genuinely ownerless organization, the request handler can establish the recorded recovery grant without waiting for the missing owner. An operator still needs the required platform role and a usable grant.

MEMBERSHIP_ID below identifies the existing organization-member row, not the global user. Obtain that identifier from the authorized support context; the grant does not make an ordinary cross-tenant membership LIST available. This route is the one exercised by Wonder Todos’ ownership-repair example:

curl -X PUT "$BACKEND_URL/organizations/$ORG_ID/organization-members/$MEMBERSHIP_ID/grant-ownership" \
  -H "Authorization: Bearer $OPERATOR_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"justification":"Restore customer administration for support case SUP-42"}'

Use the operator’s own authenticated token after the grant becomes usable. The request does not accept a replacement role array. On success, the existing member gains ownership while retaining their other roles; the customer can resume administration. Retrying for an existing owner does not duplicate authority.

Understand the authoritative result

The prefix marks roles authoritative. It checks the current membership and user, retains all existing roles and adds ORG_OWNER if they do not already confer ownership. A retry is idempotent: a member who is already an owner remains an owner.

It cannot demote someone, move the membership or replace the entire role set with values supplied by the caller. The write and the linked-user usability check participate in the same transaction.

Confirm the repair and preserve the operator’s reason

Re-check that the target can administer the workspace and appoint another owner where appropriate. The current request contract requires justification, but the implementation does not persist that request value; keep the operator’s reason in the operational case record. The emitted operation evidence must not be described as containing the discarded justification.

Keep people informed

Tell people when their workspace changes Feature

Organisation lifecycle actions can notify the people affected: creation, updates, suspension, activation, deletion requests and restoration. The notification is attached to the action rather than being a separate task the application must remember.

Deletion warnings belong to the request stage, while recovery is still possible. A suspension can be deliberately quiet when its authorised caller chooses not to notify members.

Example: Warn while recovery is still possible

A workspace is marked for deletion and its members receive the lifecycle warning. The message arrives during the recovery period, rather than after the organisation has already been purged.

Paused, closing and restored workspace states each trigger an email notification.
For engineers
Connect operation, target and template

The restore declaration includes this actual notification definition:

userNotifications: [
  {
    target: CoreUserNotificationTarget.ORGANIZATION_USERS,
    channel: CoreUserNotificationChannel.EMAIL,
  }
]

It tells the notification pipeline whom the event is for and which channel to use. The application supplies the corresponding email template and delivery configuration. Wonder Todos keeps those templates under backend-api/src/engine/email/resources/organizations/, including separate templates where the administrator variant has a distinct reference.

Register the file under the reference the operation resolves

For restore, the operation identifier is Organization_Lifecycle_Operations.RESTORE (restore), the target is ORGANIZATION_USERS, and the resolved template reference is email.organizations.restore.organization-users. Wonder Todos supplies it from:

backend-api/src/engine/email/resources/organizations/
  restore.organization-users/
    template.tsx
    labels.en.ts

template.tsx default-exports an EmailTemplateDefinition; each locale file exports labels. The normal compiler must publish the corresponding JavaScript files before the startup scanner can load them. A source file alone is not a registered runtime template.

This is the resource-directory scan used by the existing email definitions module, with its shared options inlined:

import {
  scanEmailTemplateDirectory,
  type EmailTemplateDefinition,
} from '@wildo-ai/saas-backend-lib';

const resourceTemplates = await scanEmailTemplateDirectory<EmailTemplateDefinition>({
  importMetaUrl: import.meta.url,
  subdir: 'resources',
  keyFromPath: (relativePath) => `email.${relativePath.replace(/\//g, '.')}`,
});

Keep this scanner in backend-api/src/engine/email/email-template-definitions.ts, where resources is a sibling directory. Its map is merged into the existing emailTemplateDefinitions, alongside system templates. The engine backend module contributes that map through its emailTemplateDefinitions property; modules-registry.backend.ts collects those module maps as defaultEmailTemplateDefinitions for buildApplicationInitializationConfigFromModules. Extend this existing path rather than creating a second registry or importing the template directly in a sender.

Lifecycle operationTemplate reference in this application
Restoreemail.organizations.restore.organization-users
Request deletion, tenant variantemail.organizations.request_deletion.organization-users
Request deletion, administrator variantemail.organizations.request_deletion.admin.organization-users

The HTTP route uses request-deletion, but the template reference retains the internal request_deletion identifier. The variant segment also matters: a template for the tenant action does not satisfy the administrator action’s reference.

Match the message to the transition

Organization_Lifecycle_Operations.REQUEST_DELETION (request_deletion) carries the warning at the reversible mark. restore informs members when access returns. Suspension’s condition respects notifyMembers; activation targets the administrative audience. These audiences are intentional and should not all become the same generic broadcast.

Organisation category email routing can replace the member audience for mapped categories. An ORGANIZATION_USERS target remains a per-member audience, as described in workspace email settings.

Verify delivery prerequisites as well as the declaration

The startup validator reports unresolved template references and names the directory expected. That check does not itself prove an email was delivered. Exercise the operation with the configured email transport and inspect its delivery result, especially the quiet-suspension case and the deletion-request warning.

Connect tools without sharing a person’s session

A scheduled service, a user-approved agent and someone supplying a file need different kinds of access. Wildo gives each a fitting identity or permission, tied to its intended scope and destination.

Registered services receive their own roles. Delegated tools act for a consenting person at a named agent endpoint. Upload grants make a narrower handoff possible when all that is needed is one file.

Service identity, user consent and a single upload grant represent distinct kinds of tool access.

Match the credential to the relationship

Give services their own identity

Scoped keys and OAuth clients let integrations act under assigned responsibilities, with credentials that can be rotated or withdrawn.

Keep people in the authorization flow

Consent names the requesting tool and target. Remembered approvals avoid repeating the same question while broader requests ask again.

Make narrow handoffs possible

Audience-bound agent tokens and single-use upload grants provide access for a particular destination or task instead of a full session.

Example: An agent prepares a document request

A person approves a connected tool for the application’s agent endpoint. The tool uses the person’s available operations to prepare the work, then returns a temporary upload page for the missing file. The person supplies it, and the tool completes the authorized record update with the resulting file ID.

For engineers

Separate service identity from user delegation

Access mechanismAuthority belongs toIntended use
API keyRegistered organization or application credentialIntegration calls under fixed roles
OAuth client credentialsRegistered service clientScoped machine token for a named resource
Authorization-code delegationConsenting person, attributed to the clientCalls to the selected MCP or A2A endpoint
File-upload grantMinter, for one bound uploadFile handoff without a general session

Identity scopes such as profile and email govern identity information. They are not the resource operation’s role policy. Likewise, obtaining a client ID through dynamic registration or a metadata document does not itself grant business-data access.

Complete the token exchange and the resource call

The following illustrative HTTP flow uses a previously created client whose allowed grants include client_credentials. CLIENT_SECRET is the one-time credential returned at creation; RESOURCE_AUDIENCE is the audience advertised for the resource server the integration will call.

curl "$BACKEND_URL/oauth/token" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode "client_secret=$CLIENT_SECRET" \
  --data-urlencode "resource=$RESOURCE_AUDIENCE"

curl "$RESOURCE_URL" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Use access_token from the token response as ACCESS_TOKEN and call the intended resource. The provider checks the allowed grant and audience; the resource server checks the token’s destination and the principal’s authority before dispatch.

Preserve the narrower delegated contract

For user delegation, the browser flow adds consent and PKCE. The authorization code is bound to the client, redirect and verifier, and the resulting access token is bound to the named agent endpoint. It does not grant the ordinary application API or return a refresh token. The user’s current roles remain part of request-time authorization.

A remembered grant only decides whether a future consent prompt can be skipped. Its key includes the user, client and resource; extra scopes or another target need approval. Revoking that remembered decision and retiring an issued credential are distinct actions.

Plan credential and handoff lifecycles

Keep plaintext keys and client secrets from their one-time creation response. Rotation and regeneration have different overlap policies, so choose the lifecycle action that matches the integration’s cutover plan. An expired or otherwise unusable client cannot obtain fresh tokens, but already issued machine Bearers retain their signed roles and expiry; the machine Bearer paths do not re-read client status. The service-credential lifecycle explains the secret cutoff and token-expiry checks separately.

For files, use the returned upload instructions and status URL. The grant authorizes the upload, not the final record mutation. Attach the resulting file ID through the normal create or update operation using the caller’s own authority.

Give services their own credentials

Give integrations their own access keys Feature

A service can use its own scoped, revocable access key instead of a person’s password. Wildo ties the credential to its roles and organization or application, with lifecycle actions for rotation and withdrawal.

A key can be replaced while a controlled overlap gives the integration time to switch.

Example: Rotate a reporting integration’s key

Choose an overlap deadline when rotating, then update the reporting service with the replacement secret before that deadline. The integration keeps the same intended responsibilities while its credential changes.

An integration's Acme key is rotated to a replacement key.
For engineers

An organization administrator creates the credential through the organization API-key resource. Supply a recognizable name, the roles the integration needs and an optional expiry. The requested roles must fall within the caller’s grant ceiling. The organization-scoped route selects the account; application keys use their separate application resource.

The creation response includes plainKey once, alongside the key record. Save that value in the integration’s credential store before leaving the creation step. For an organization integration, it starts with sk_org_; the application variant uses sk_app_.

Set RESOURCE_URL to an allowed endpoint within that key’s scope and API_KEY to the returned plainKey. This is the request contract declared in api-keys.shared.schemas.ts and exercised by external-access-auth.e2e.ts:

curl "$RESOURCE_URL" \
  -H "Authorization: $API_KEY"

The header contains the raw key, without a Bearer prefix. The backend resolves its machine principal, scope and roles before authorizing the requested operation. A successfully authenticated key can still receive an authorization refusal when its role or tenant does not match the operation.

Keep the secret and the authority separate

The API-key create handler checks the requested roles, creates secret material and lets the normal resource path persist the hash. Its response adds the plaintext key once:

This implementation excerpt from api-keys.custom-impl.backend.service.ts shows the decision in context; explanatory source comments are omitted.

export function buildApiKeyMintHandlers(scope: ApiKeyScope, roleHierarchyResolver: RoleHierarchyResolver): ApiKeyImplHandlers {
  return {
    prefixCoreOperations: async (_id, input, executionContext, _operationPath, utils) => {
      assertRequestedRolesWithinCallerCeiling(executionContext, (input as { roles?: string[] }).roles, utils.errorBuilder, roleHierarchyResolver);
      const { plainKey, keyPrefix, hashedKey } = generateApiKeyMaterial(scope);
      PLAINTEXT_KEY_BY_EC.set(executionContext, plainKey);
      return { ...(input as Record<string, unknown>), keyPrefix, hashedKey };
    },
    postfixCoreOperations: async (_id, createdKey, executionContext, _operationPath, _utils) => {
      if (!createdKey || typeof createdKey !== 'object') return createdKey;
      const plainKey = PLAINTEXT_KEY_BY_EC.get(executionContext);
      PLAINTEXT_KEY_BY_EC.delete(executionContext);
      if (!plainKey) return createdKey;
      return { ...(createdKey as Record<string, unknown>), plainKey };
    },
  };
}
Store the one-time result in the integration

Capture plainKey from the creation response and place it in the integration’s credential store. Subsequent resource reads do not recover the plaintext value. The authenticated request resolves the key’s organization or application and the roles assigned at creation.

ActionEffect
RotateNew key material with a bounded prior-key overlap
RegenerateNew material with an open-ended prior-key overlap
Extend expiryChanges the existing credential’s expiry
Deactivate or reactivateChanges whether the credential is usable

Choose rotation when you need the old secret to stop working at a known time. Regeneration is not the same retirement policy.

Rotate with fresh proof and an explicit cutoff

For the default organization-key ROTATE operation, the caller needs ORG_ADMIN and a single-use reauthentication proof. A recently established session alone does not satisfy the operation’s explicit step-up requirement. Application-level administration uses its separately authorized variant; do not substitute that route for an organization administrator’s call.

This illustrative JavaScript runs in a trusted first-party administration client. reauthUrl is the application’s /auth/reauth API endpoint; rotateUrl is the selected key’s default ROTATE URL from its generated operation contract. sessionToken belongs to the administrator, not to the integration whose key is being replaced. This example uses a locally enrolled password accepted by the effective step-up policy; the standard interface handles other supported factors.

async function rotateIntegrationKey({ reauthUrl, rotateUrl, sessionToken, password, cutoff }) {
  const json = async (response) => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const body = await response.json();
    return body.data ?? body;
  };
  const headers = { authorization: `Bearer ${sessionToken}`, 'content-type': 'application/json' };
  const proof = await json(await fetch(reauthUrl, {
    method: 'POST', headers,
    body: JSON.stringify({ method: 'PASSWORD', password }),
  }));
  if (!proof.reAuthToken) throw new Error('Reauthentication returned no operation proof');

  const replacement = await json(await fetch(rotateUrl, {
    method: 'PUT',
    headers: { ...headers, 'x-reauth-token': proof.reAuthToken },
    body: JSON.stringify({ oldKeyInvalidationDate: cutoff.toISOString() }),
  }));
  if (!replacement.plainKey) {
    throw new Error('Rotation returned no replacement secret');
  }

  // Hand this directly to the integration's credential store, not a log.
  return {
    plainKey: replacement.plainKey,
    oldKeyId: replacement.oldKeyId,
    oldKeyInvalidationDate: replacement.oldKeyInvalidationDate,
  };
}

Choose a future cutoff that leaves time to distribute and verify the replacement. Omitting oldKeyInvalidationDate defaults to immediate retirement, not a grace period. Save the returned plainKey once, switch the integration, verify a real permitted request with the new raw key, then verify the prior value is refused after the returned cutoff. Subsequent reads cannot recover the secret.

The proof is short-lived and single-use. A retry may require a new proof; if the rotation response was lost, investigate the key state before blindly rotating again. REGENERATE deliberately has different semantics: it keeps the prior secret without a scheduled cutoff and does not carry the same explicit rotation step-up gate. Neither operation grants new roles.

Keep role changes out of secret maintenance

These resources do not expose an ordinary roles-changing update. Their lifecycle handlers accept their own inputs and do not use rotation as a second path to grant authority. If the integration needs different responsibilities, provision the appropriate credential rather than treating a secret replacement as a permissions change.

Let services act under their own identity Mechanism

Automated work can belong to a service rather than impersonating a person. Wildo gives registered clients their own scoped roles and tokens, so the application can authorize and attribute machine actions explicitly.

The client requests a token for the resource it will call, keeping the credential’s intended destination part of the contract.

Example: A scheduled service updates account records

An organization-owned client receives the roles needed by the scheduled service. Its token identifies that client and account; it does not pretend a human performed the update.

A service obtains a token to act within Acme, with people shown separately beneath the workspace.
For engineers

Create the organization or application OAuth client through its resource operation. Set its allowed grants and roles, retain the one-time plainSecret, then use the token endpoint. A client using client_credentials acts as itself.

The token issuer carries the resolved scope and principal into the signed claim:

This implementation excerpt from machine-token-issuer.backend.service.ts shows the decision in context; explanatory source comments are omitted.

public async issueClientAccessToken(request: MachineAccessTokenRequest): Promise<MachineAccessTokenResponse> {
    const maxMinutes = this.appConfigService.config.jwt.accessTokenExpirationMinutes;
    const requestedMinutes = request.expiresInMinutes ?? maxMinutes;
    const minutes = Math.max(1, Math.min(requestedMinutes, maxMinutes));

    const claim: Jwt_MachineToken_CreationParameter = {
      type: request.scopeType === ResourcePrimaryScope.ORGANIZATIONS
        ? ExecutionContext_ExecutionType.ORGANIZATION_MACHINE
        : ExecutionContext_ExecutionType.APPLICATION_MACHINE,
      clientId: request.clientId,
      scopeId: request.scopeId,
      roles: request.roles,
    };

    const accessToken = await this.jwtService.createJwtForMachineToken(claim, {
      audience: request.audience,
      expiresInMinutes: minutes,
    });

    this.logger.debug('Issued machine client-credentials access token', {
      clientId: request.clientId,
      scopeType: request.scopeType,
      scopeId: request.scopeId,
      audience: request.audience,
      expiresInMinutes: minutes,
    });

    return { accessToken, tokenType: 'Bearer', expiresIn: minutes * 60 };
  }
Request the intended resource

The token request names grant_type=client_credentials, the client credential and the resource audience. Present the returned access_token as a Bearer credential to that resource. The token endpoint checks that this client allows the grant and that the requested resource is known.

The public token endpoint uses the application’s configured access-token lifetime. It does not accept a requested lifetime. The expiresInMinutes option shown above belongs to the internal issuer API; its callers may shorten the policy maximum.

The following request follows external-access-auth.e2e.ts. Set BACKEND_URL to the application backend, CLIENT_SECRET to the one-time secret and RESOURCE_AUDIENCE to a resource identifier advertised by that deployment.

curl "$BACKEND_URL/oauth/token" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode "client_secret=$CLIENT_SECRET" \
  --data-urlencode "resource=$RESOURCE_AUDIENCE"

The response supplies access_token, token_type and expires_in. Save access_token as ACCESS_TOKEN; set RESOURCE_URL to an endpoint in that audience for which the client has permission:

curl "$RESOURCE_URL" -H "Authorization: Bearer $ACCESS_TOKEN"

An audience match is necessary but does not grant an operation role. A valid organization client still cannot use its token to operate on another organization’s records.

Separate the client secret from tokens already issued

The client secret is used to obtain tokens. A Bearer token is a signed snapshot of the client’s roles and scope with its own expiry. Changing the first does not rewrite the second.

ChangeNew token requestsMachine Bearer tokens already issued
Rotate the secretNew secret works; previous secret works until oldSecretInvalidationDate, defaulting to immediate cutoverRetain their signed claims and expiry
Regenerate the secretNew secret is returned; the previous secret is retained with open-ended overlapRetain their signed claims and expiry
Client is no longer active or has expiredRefused when the client is read during exchangeClient status is not re-read by the machine Bearer authentication paths
Change the client’s rolesSubsequent tokens receive the current rolesExisting tokens retain the roles signed into them

There is no dedicated OAuth-client deactivate/reactivate operation pair in this resource contract. The status row above describes an admission condition, not an extra management endpoint.

Rotation and regeneration update the same client record; the returned plainSecret is the one-time value to install in the integration. Regeneration is therefore not an immediate retirement of the previous secret. Choose rotation with a deliberate cutoff when the outgoing secret must stop obtaining tokens.

The ordinary API and supported external machine-token paths verify the signature, issuer, audience and token expiry, then construct machine authority from the claims. They do not look up the client again in those Bearer branches. Operation authorization and scope checks still apply; this is not a promise that every request succeeds until expiry.

Direct authentication with the OAuth client secret is different: that path reads the client and checks its status and expiry for the request. User-delegated agent tokens also follow a different validation path. Do not extend this machine-Bearer behavior to every kind of credential.

Make the cutover observable

Use the application’s configured access-token lifetime and the token response’s expires_in when planning the overlap. The public token endpoint does not accept a shorter lifetime requested by the integrating service.

For a controlled rotation, keep a pre-rotation token and test these separate outcomes in a disposable integration:

  1. Obtain a new token with the replacement secret and call an operation the client is allowed to use.
  2. After the chosen cutoff, confirm the old secret can no longer obtain a token.
  3. Check the pre-rotation Bearer separately: the secret cutoff does not itself revoke that token. Token expiry and the resource’s other authorization checks remain its boundaries.
  4. Confirm the same token is refused after expiry. Keep an authorized fresh-token call as a control so a missing route or stopped service is not mistaken for revocation.

These checks distinguish a successful secret replacement from withdrawal of already issued access. If the product requires immediate client-wide invalidation of machine Bearers, the current authentication paths do not supply that guarantee.

Design business code for a machine caller

Machine identity is recorded independently from user identity. A machine-created record may have no creator user ID, so custom business logic should use the execution context’s principal rather than assuming every authorized action has a human userId.

User delegation is a separate flow: agent tokens represent a consenting person, while this client represents the service itself.

Let tools request access

Give connected tools a standard authorization flow Feature

Connected tools need a way to discover the application, request authorization and exchange credentials for tokens. Wildo provides those related endpoints as one authorization-server surface.

Registered services act under their own roles. User-approved tools receive access for a named agent endpoint, with identity information separated from operation permissions.

Example: A tool connects to an agent endpoint

The tool discovers the authorization endpoint, sends the person through approval and exchanges the returned code for a token bound to the requested agent resource.

Discovery, authorization and token issuance lead an outside tool to agent access.
For engineers

Discovery publishes the browser-facing consent URL, token endpoint, supported grants and signing-key information. The browser-facing authorization flow uses a registered redirect URI, state, a PKCE S256 challenge and the intended resource.

At exchange, the provider verifies the code against the original client, redirect and verifier before issuing anything:

This implementation excerpt from oauth-provider-token.backend.service.ts shows the decision in context; explanatory source comments are omitted.

const grant = await authCodeService.exchangeAuthorizationCode({
      code: request.code,
      clientId: request.clientId,
      redirectUri: request.redirectUri,
      codeVerifier: request.codeVerifier,
      presentedClientSecret: request.clientSecret,
    });
Build a public client’s authorization request

Use a registered public client with authorization-code access, an exact registered callback URI and an allowed MCP or A2A resource audience. Public registration uses PKCE rather than a client secret. A confidential client additionally authenticates at token exchange; do not place that secret in browser JavaScript.

This illustrative browser client uses metadata from a trusted, configured discovery URL. The browser must return to the same client origin/tab so its pending state remains available. A production client can use an OAuth library for this protocol bookkeeping; these functions expose the values that must stay connected.

async function beginDelegation({ discoveryUrl, clientId, redirectUri, resource }) {
  const response = await fetch(discoveryUrl);
  if (!response.ok) throw new Error(`Discovery HTTP ${response.status}`);
  const metadata = await response.json();
  const base64url = (bytes) => btoa(String.fromCharCode(...bytes))
    .replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
  const challenge = base64url(new Uint8Array(await crypto.subtle.digest(
    'SHA-256', new TextEncoder().encode(verifier),
  )));
  const state = base64url(crypto.getRandomValues(new Uint8Array(32)));
  sessionStorage.setItem('wildo-delegation', JSON.stringify({
    state, verifier, clientId, redirectUri, resource,
    tokenEndpoint: metadata.token_endpoint,
  }));

  const authorize = new URL(metadata.authorization_endpoint);
  authorize.search = new URLSearchParams({
    response_type: 'code', client_id: clientId,
    redirect_uri: redirectUri, scope: 'openid', state,
    code_challenge: challenge, code_challenge_method: 'S256',
    resource,
  }).toString();
  window.location.assign(authorize.href);
}

The discovered authorization endpoint is the frontend consent page. Wildo handles login and the person’s decision there. Its authenticated backend authorization/decision calls return JSON containing redirect_to; the frontend navigates to it. Your external client receives the callback, not the first-party session token or the internal consent token. Denial returns an error rather than a usable code. Consent may be bypassed only where the provider’s client/user policy permits it.

Validate the callback before exchanging its code

Run this on the registered callback page. This compact example allows one outstanding authorization attempt per tab; starting another replaces the pending attempt. It removes the pending entry before exchange, so a failed exchange starts a new authorization rather than replaying the code indefinitely.

async function finishDelegation() {
  const saved = sessionStorage.getItem('wildo-delegation');
  if (!saved) throw new Error('No pending authorization');
  const pending = JSON.parse(saved);
  const callback = new URL(window.location.href);
  const expected = new URL(pending.redirectUri);
  if (callback.origin !== expected.origin || callback.pathname !== expected.pathname
      || callback.searchParams.get('state') !== pending.state) {
    throw new Error('Authorization callback does not match the pending request');
  }
  sessionStorage.removeItem('wildo-delegation');
  if (callback.searchParams.has('error')) throw new Error('Authorization was not granted');
  const code = callback.searchParams.get('code');
  if (!code) throw new Error('Authorization returned no code');

  const response = await fetch(pending.tokenEndpoint, {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code', client_id: pending.clientId,
      redirect_uri: pending.redirectUri, code, code_verifier: pending.verifier,
      resource: pending.resource,
    }),
  });
  if (!response.ok) throw new Error(`Token exchange HTTP ${response.status}`);
  const token = await response.json();
  if (!token.access_token) throw new Error('Token exchange returned no access token');
  return { accessToken: token.access_token, expiresIn: token.expires_in, resource: pending.resource };
}

The token request reuses the original redirect URI and verifier. Its resource echo is optional in the server contract, but must match when supplied; the authorization code already binds the audience. Keep the returned access token in the client’s appropriate credential/session handling, out of URLs and logs. Call only the returned intended resource using the delegated agent-request example.

Keep the grant families distinct
FlowPrincipal and purpose
Client credentialsA registered service acting with its own roles
Authorization codeA consenting user delegating to a named MCP or A2A endpoint
Refresh tokenThe provider’s separate eligible session-refresh path

Authorization-code delegation does not issue an unrestricted first-party API session or a refresh token. It requires a valid agent resource audience. Identity scopes such as openid, email and profile control identity claims; they do not grant business operations.

Complete the browser handoff correctly

The frontend consent route is the browser authorization endpoint. The authenticated backend authorize call returns JSON containing redirect_to; the frontend navigates after receiving it. This avoids trying to follow a cross-origin client redirect inside an authenticated XHR.

Use interactive consent for the person’s decision, and machine clients when no person is delegating.

Let compatible tools register themselves Feature

A tool without a pre-created client ID can register before starting a user-approved connection. Wildo offers this as an application choice, with limits on what an unauthenticated registration can obtain.

Registration supplies an identity for the tool; it does not give the tool independent access to business data.

Example: Connect a tool that needs a registration endpoint

The application enables registration. A tool supplies its name and safe redirect URI, receives a client ID and continues through the person’s consent and PKCE flow.

An outside tool submits client details, receives a client ID through registration, then proceeds to consent.
For engineers

Configure auth.dynamicClientRegistration. It is disabled by default; when disabled the endpoint returns 404 and discovery does not advertise it. The default limit is ten registrations per source IP per hour.

In the backend-authored application’s auth section, enable the public door and choose a rate ceiling. These are the fields declared by DynamicClientRegistrationConfigSchema:

dynamicClientRegistration: {
  enabled: true,
  maxRegistrationsPerHourPerIp: 10,
},

The public endpoint checks that an incoming registration is not asking to grant itself business roles:

This implementation excerpt from oauth-provider-controller.backend.service.ts shows the decision in context; explanatory source comments are omitted.

if (body.roles !== undefined) {
      this.sendRegistrationError(res, 'invalid_client_metadata', 'roles cannot be requested at dynamic registration');
      return;
    }
    const requestedScopes = typeof body.scope === 'string' ? body.scope.split(/\s+/).filter(Boolean) : [];
    const beyondIdentity = requestedScopes.filter((scope) => !OIDC_SUPPORTED_IDENTITY_SCOPES.includes(scope));
    if (beyondIdentity.length > 0) {
      this.sendRegistrationError(res, 'invalid_client_metadata', `scope(s) not available to a dynamically registered client: ${beyondIdentity.join(', ')}`);
      return;
    }
Register a public authorization-code client

Send the tool’s client_name and non-empty redirect_uris to /oauth/register. Redirects must pass the safe-URI policy: HTTPS, or permitted loopback HTTP, without embedded credentials or fragments. The returned client metadata describes what was actually registered.

Requested authorityRegistration behavior
Business rolesRefused
Non-identity scopesRefused
Client-credentials grantRefused
Authorization-code flowPublic client using PKCE and consent
Requested refresh grantNot granted; returned metadata reflects the narrower grant set

For example, a tool can register the following illustrative HTTPS callback. Replace the example URL with the tool’s actual callback and BACKEND_URL with the application’s backend:

curl "$BACKEND_URL/oauth/register" \
  -H 'Content-Type: application/json' \
  --data '{
    "client_name": "Customer workspace tool",
    "redirect_uris": ["https://tool.example/callback"],
    "grant_types": ["authorization_code"],
    "token_endpoint_auth_method": "none",
    "scope": "openid profile email"
  }'

A successful response is HTTP 201 with client_id and the accepted metadata. It also returns a one-time registration_access_token and registration_client_uri for managing that registration. Store them securely: the management token is distinct from an OAuth client secret and does not authorize business API calls.

No OAuth client secret is returned. The tool uses client_id to start authorization with PKCE and user consent; it acts for that person rather than as an independently privileged machine.

Choose the onboarding policy for your audience

Enable this for clients that need the registration mechanism. Client metadata documents provide another identity-onboarding path. Neither mechanism bypasses consent, resource-audience checks or the person’s operation permissions.

Recognize tools through their published identity Feature

A tool can identify itself through an HTTPS metadata document instead of requiring a manually created client record. Wildo checks that published identity and applies the application’s trust policy before using it.

You can allow specific client URLs or domains, or choose an open policy for a public agent surface.

Example: Accept a known tool’s published client identity

The application allows a tool’s metadata URL. The tool uses that full URL as its client ID, and Wildo checks the document before continuing to user authorization.

An agent presents HTTPS metadata for inspection as a client identity, while the access gate remains locked.
For engineers

auth.cimd selects DISABLED, ALLOWLIST or OPEN through CimdTrustPolicy. Disabled is the default. An allowlist entry can be the exact client URL or a bare host; the full URL is the narrower choice.

Import CimdTrustPolicy from @wildo-ai/saas-backend-lib. In the backend-authored application’s auth section, an exact-URL allowlist looks like this. The example hostname is illustrative; use the tool’s real HTTPS document URL.

cimd: {
  policy: CimdTrustPolicy.ALLOWLIST,
  allowedClients: ['https://tool.example/client.json'],
},

This uses the same configuration surface as Wonder Todos’ saas-config.backend.ts, with a narrowly selected client URL.

Publish a matching client document

The document supplies client_id, client_name and redirect_uris. Its client_id must equal the URL being resolved. The redirect used by authorization must exactly match a registered redirect, rather than merely share an origin or path prefix.

For the URL allowed above, serve JSON with a matching identity:

{
  "client_id": "https://tool.example/client.json",
  "client_name": "Customer workspace tool",
  "redirect_uris": ["https://tool.example/callback"],
  "token_endpoint_auth_method": "none"
}

The tool sends that full metadata URL as client_id on its authorization request, together with its redirect URI, state, PKCE challenge, identity scopes and target resource. Wildo resolves the document, checks the chosen redirect and continues to the user’s authorization step. Publishing this file does not itself create a token or a machine role.

The resolver applies URL and outbound-target checks even for allowlisted clients. It bounds fetch duration and size, validates the response and caches documents within a bounded lifetime. A metadata change is therefore not an instantaneous withdrawal of all previously fetched metadata.

Use the authentication mode the provider accepts

This implementation accepts the public-client none token-endpoint authentication method for metadata-document clients. It rejects unsupported authentication methods rather than silently treating a client claiming private-key authentication as public.

Published identity does not grant business authority. The tool still uses PKCE and user consent, then receives an audience-bound delegated token. Choose registered machine clients when the service should act under its own roles.

Keep a person in control of delegation

Let agents act with a person’s permission Guarantee

A person can authorize a connected tool to act through a particular agent endpoint without sharing a full application session. Wildo binds the token to that destination and retains both the person’s identity and the requesting client’s attribution.

The person’s current roles still determine which operations are available.

Example: Approve a tool for one assistant

A tool receives permission to call the selected MCP endpoint. That token does not become permission to call the ordinary application API or a different agent instance.

Alex delegates to an agent, which uses a time-limited token to reach MCP or A2A endpoints.
For engineers

The authorization request names the intended resource. The code exchange reads that stored audience and rejects a different resource echoed by the token request. It then verifies the audience against the registered delegatable endpoints:

This implementation excerpt from oauth-provider-token.backend.service.ts shows the decision in context; explanatory source comments are omitted.

const requestedAudience = grant.resource;
    if (!requestedAudience) {
      throw this._oauthError(ErrorType.AUTHORIZATION, ErrorCustomMessageReference.AUTHORIZATIONS_ACCESS_DENIED, 'invalid_target',
        'the `resource` parameter (RFC 8707) is required for the authorization_code grant');
    }
    if (request.resource && request.resource !== requestedAudience) {
      throw this._oauthError(ErrorType.AUTHORIZATION, ErrorCustomMessageReference.AUTHORIZATIONS_ACCESS_DENIED, 'invalid_target',
        'the token-request `resource` does not match the resource authorized at the authorization endpoint');
    }
    const allowedDelegatedAudiences = this.container
      .get<ResourceServerInstancesRegistryBackendService>(SAAS_SERVICE_TYPES.ResourceServerInstancesRegistry)
      .listResourceServerAudiences(DELEGATED_TOKEN_RESOURCE_SERVERS);
    if (!allowedDelegatedAudiences.includes(requestedAudience)) {
      throw this._oauthError(ErrorType.AUTHORIZATION, ErrorCustomMessageReference.AUTHORIZATIONS_ACCESS_DENIED, 'invalid_target',
        `resource '${requestedAudience}' is not a delegatable agent endpoint (A2A / MCP)`);
    }
Keep identity and business permissions separate

The delegated access claim includes the user, their authorization version and azp, which identifies the authorizing client. Business roles are resolved at request time; granting an identity scope does not grant the ability to edit a record.

Before issuance the provider checks that the user remains active and has a usable authorization version. The delegated token lifetime is capped and no refresh token is returned. The connected tool must return through authorization when it needs a new delegation.

Call through the agent contract

Present the returned Bearer token to the audience it names and use that endpoint’s MCP or A2A contract. The resource server verifies the audience before dispatching the operation. This preserves a different boundary from a machine principal, whose roles belong to the registered service rather than a consenting user.

Carry the delegation into an MCP request

Use the complete discovery and PKCE recipe to obtain accessToken, expiresIn and the original resource. For an MCP delegation, that resource is the exact chosen MCP endpoint, including a named instance when applicable. It is not the application’s general API origin.

After the MCP handshake has negotiated the locally supported 2025-06-18 revision and sent the initialized notification, this illustrative request lists the tools available to the consenting person:

const response = await fetch(resource, {
  method: 'POST',
  headers: {
    authorization: `Bearer ${accessToken}`,
    'content-type': 'application/json',
    accept: 'application/json',
    'MCP-Protocol-Version': '2025-06-18',
  },
  body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }),
});
if (!response.ok) throw new Error(`MCP HTTP ${response.status}`);
const envelope = await response.json();
if (envelope.error) throw new Error('MCP discovery was refused');
const tools = envelope.result.tools;

Select a tool and its argument schema from that authenticated result before calling it. Tool exposure and the person’s current business permissions still determine what can execute. An empty catalogue is not permission to invent a tool name. Other negotiated revisions require their own metadata, so let the client’s transport handle version changes.

OutcomeClient response
Callback state or redirect mismatchReject the callback before attempting exchange
Different resource echoed at exchangeCorrect the client request; the server cannot retarget the consented code
Token sent to another endpointUse the originally authorized resource; do not treat an audience refusal as a role problem
Expired delegationBegin a new authorization; this grant does not return a refresh token
Authenticated operation refusalRespect the user’s current scope/roles and the exposed operation contract

For A2A, apply the same audience-bound Bearer to the selected A2A endpoint using its request and response contract. An agent token does not become a first-party API session merely because both endpoints belong to the same application.

Grant a specific upload

Let someone supply a file without sharing your session Feature

Sometimes an agent can prepare a record but needs a person to supply the file. Wildo can issue a temporary upload grant for that handoff without giving away the caller’s application session.

The grant names one field and one create or update operation. The file still passes through that field’s constraints and storage flow, and attaching it to the record remains a separate authorized write.

Example: An agent asks for the signed document

An agent prepares a record update and returns an upload page for the document field. The person drops the signed file there. The agent reads the grant status to obtain the file ID, then submits the normal record update using its own authority.

An expiring one-use ticket permits an integration to attempt one attachment upload.
For engineers
Bind delegation to the intended write

The grant service stores the field, operation, minter and concrete upload URL in the token. An update grant also names the target row. This excerpt from file-upload-grant.backend.service.ts follows field validation; comments are omitted.

const constraint = await this.constraintResolver.resolve({ resourceType: target.resourceType, fieldName: target.fieldName });
this.assertDeclarationFitsConstraint(params.declared, constraint, executionContext, target);
const expiresInMinutes = params.expiresInMinutes ?? FILE_UPLOAD_GRANT_DEFAULT_MINUTES;

const metadataWithoutUrl: Omit<FileUploadGrantMetadata, 'uploadUrl' | 'producedFileId'> = {
  fieldName: target.fieldName,
  operation: target.operation,
  minter: { entityType: minter.uploadedByEntityType, id: minter.uploadedBy },
};

const token = await this.consumableTokenService.createToken({
  tokenType: CoreConsumableTokenTypes.FILE_UPLOAD_GRANT,
  consumptionMode: ConsumableToken_ConsumptionMode.SINGLE_USE,
  expiresIn: { value: expiresInMinutes, unit: DurationUnit.MINUTES },
  organizationId: executionContext.initiatorIds?.organizationId,
  userId: executionContext.initiatorIds?.userId,
  resourceIdentifier: String(target.resourceType),
  relatedId: target.resourceId,
  roles: [...(executionContext.initiatorRoles ?? [])],
  metadata: { ...metadataWithoutUrl, uploadUrl: params.uploadUrl },
});
Configure the addresses the recipient must reach

The HTTP mint route reads runtime.endPoints.main_backend_api.publicUrl from the application’s resolved configuration. It refuses minting when that address is absent; it does not construct a trusted upload destination from the incoming Host header. The returned upload and status URLs must be reachable by the party receiving the grant, not only from inside the application’s container network.

For a human drop page, the application must also declare a frontend service. The default frontend selected by configuration needs its own runtime.endPoints[frontendServiceName].publicUrl; the service must be present in frontendServices. The grant service builds uploadPageUrl from that address and the public upload-grant route. Configure deployment addresses through the normal application environment setup, then inspect the resolved values instead of adding a second URL authority inside a custom caller.

DeploymentHandoff available
Reachable backend and configured frontendDirect upload instructions and a browser drop-page URL
Reachable backend, no resolvable frontendDirect upload instructions; uploadPageUrl is null
Missing backend public URLThe HTTP mint request is refused

Check the returned uploadUrl and statusUrl, and inspect whether uploadPageUrl is present before offering a browser link. A headless deployment can accept the delegated upload through the backend contract; it does not acquire a hosted upload page merely by minting a grant.

Use the returned upload instructions

Both mint doors return the upload URL, a credential header, shell upload commands and a status URL. When a frontend is configured, the response also supplies the browser drop-page URL. Use those returned values so the client follows the exact bound route.

The default lifetime is 15 minutes with a framework ceiling of 60 minutes. Anonymous sessions and consumable-token callers cannot mint another grant. Ordinary operation authorization still applies; the grant does not create permission to read or modify other records.

Carry one file through the complete handoff

This illustrative browser JavaScript follows the Wonder Todos attachment test. recordUrl is the existing todo-list’s API URL, bearer belongs to a caller allowed to update it, and file is a selected File. Start with an empty attachments field: this example replaces its value with one file. An add-to-existing workflow must preserve the current IDs and handle concurrent edits.

async function attachFile({ recordUrl, bearer, file }) {
  const readJson = async (response) => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  };
  const authority = { authorization: `Bearer ${bearer}` };
  const jsonHeaders = { ...authority, 'content-type': 'application/json' };

  const grant = await readJson(await fetch(`${recordUrl}/files/attachments/grant`, {
    method: 'POST',
    headers: jsonHeaders,
    body: JSON.stringify({ expiresInMinutes: 10 }),
  }));

  const form = new FormData();
  form.append('file', file, file.name);
  await readJson(await fetch(grant.uploadUrl, {
    method: 'POST',
    headers: { [grant.uploadHeader.name]: grant.uploadHeader.value },
    body: form,
  }));

  const status = await readJson(await fetch(grant.statusUrl));
  if (status.state !== 'redeemed' || !status.file?.fileId) {
    throw new Error('The grant has not produced a file ID');
  }
  const attachments = {
    fileIds: [status.file.fileId],
    updatedAt: new Date().toISOString(),
  };

  await readJson(await fetch(recordUrl, {
    method: 'PUT',
    headers: jsonHeaders,
    body: JSON.stringify({ attachments }),
  }));
  return readJson(await fetch(recordUrl, { headers: authority }));
}

The upload request carries only the returned grant header; the final write and read use the original Bearer. Let FormData set the multipart content type. For a human handoff, show uploadPageUrl and resume at the status read after upload instead of transferring bytes in this function. That browser URL requires a configured frontend.

Inspect the record in the final response (response.data for an enveloped response, otherwise the response itself): it should contain the same ID in attachments.fileIds. A single-file field instead uses { fileId, updatedAt }. A redeemed grant proves that bytes produced a file, not that scanning has finished or the parent accepted it: pending scanning can still delay attachment. Check file readiness and retain the produced ID while resolving a pending result; do not redeem the same grant again to retry the parent write.

Handle retries according to the claim point

Route matching and actionable file validation happen before the single-use token is consumed. Consumption occurs before the file row and byte write, so concurrent redemptions cannot both start an accepted upload. A refusal before that point can leave the grant usable; a failure after consumption requires a new grant.

The upload is attributed to the minter recorded in the grant. Possession of the link does not establish the identity of the person holding it. Status exposes the produced file ID when write-back succeeds, while the final resource create or update uses the caller’s normal operation permissions.

Give an MCP caller the same upload path

Opt the parent CREATE or UPDATE operation into MCP with mcp: { exposed: true, description: '…' } on its supported default URL-bearing variant. When that request contains a user-uploadable file field, Wildo derives the upload-grant tool and adds instructions to the parent’s description. There is no second upload tool implementation to author.

For a resource named todos with an exposed CREATE operation and an attachments field, the derived name is todos__create.upload_grant.attachments. Discover the actual name and input schema in the server’s tool list: UPDATE also requires the parent’s instance identifier, while CREATE does not.

This illustrative tools/call request declares the file before minting, so an impossible MIME type or size can be refused before upload:

{
  "method": "tools/call",
  "params": {
    "name": "todos__create.upload_grant.attachments",
    "arguments": {
      "name": "inspection.jpg",
      "mimeType": "image/jpeg",
      "size": 184320,
      "expiresInMinutes": 10
    }
  }
}

Read the grant object from the tool result, then use the returned instructions rather than constructing an upload URL yourself.

StepValue to use
Upload from an agent host that can execute commandsThe returned uploadCommand, with the local file the command expects; bytes bypass the model channel
Upload through a client HTTP implementationuploadUrl plus the exact uploadHeader.name and uploadHeader.value, as in the REST example above
Ask a person to uploaduploadPageUrl, when non-null; continue from the returned statusUrl
Recover the produced IDUpload response or redeemed status file.fileId
Create or update the parentSubmit that ID in { fileId, updatedAt } or { fileIds: [...], updatedAt } under the parent field, using normal operation authorization

A grant inherits the exposed parent operation’s gating; it does not grant the caller broader record access. Upload success, processing readiness and successful parent attachment remain separate steps. Treat the header and token-bearing page/status URLs as bearer credentials and keep them out of public logs.

People change. Responsibilities change. Keep access coherent.

A customer workspace is more than a login and a tenant identifier. It connects people, responsibilities, records and the services acting around them.

Wildo gives those connections a common model, from the first invitation to account recovery and closure. Your application decides how its customers work; the shared mechanisms carry those decisions into everyday access.

Building a B2B product or an internal tool?

Wildo is not self-service yet. Tell us what you have in mind and we will say plainly whether it fits, and what happens next.