Skip to main content

Behaviors

A Behavior is a class — marked with the @WebdaBehavior <Namespace/Name> JSDoc tag — used as the type of a property on a model. It bundles together:

  • a small set of actions (methods decorated with @Action) that get exposed as model-scoped operations, and
  • optional inline state that round-trips with the parent model on save and load.

Behaviors are how you give a model a reusable, namespaced capability without putting all the methods directly on the model class. Typical examples: MFA, audit summary, soft-delete tombstone, lock metadata, signing keys.

Defining a behavior​

import { Action } from "@webda/core";
import type { User } from "../models/user.js";

/**
* Second factor of a user
* @WebdaBehavior MyApp/MFA
*/
export class MFA {
// Persisted state — round-trips with the parent model
secret?: string;
lastVerified?: number;
recoveryCodes?: string[];

/** The model holding this behavior: `parent` is generated by the compiler */
private get owner(): User {
return (this as any).parent.instance;
}

@Action({ rest: { method: "POST" } })
async verify(totp: string): Promise<boolean> {
// (this as any).parent.attribute is the property name (e.g. "mfa")
const ok = await verifyTOTP(this.secret!, totp);
if (ok) {
this.lastVerified = Date.now();
await this.owner.save();
}
return ok;
}

@Action({ rest: { method: "POST" } })
async set(secret: string, totp1: string, totp2: string): Promise<void> {
/* validate two consecutive TOTPs, then store the secret */
}
}

A Behavior is discovered from its @WebdaBehavior JSDoc tag (the tag payload is its identifier), not from a decorator, so the marker leaves no trace at runtime. The compiler generates three things on the class, none of them enumerable data:

  • a parent getter — { instance, attribute }, the parent model and the name of the property holding the behavior; read it with (this as any).parent. It is wired by the parent model during hydration.
  • a storage slot backing that reference.
  • toJSON() — strips the parent reference so persistence and HTTP responses contain only the data fields.

If you write your own toJSON, yours wins. The core behaviors (EncryptedField, Password, MFA, SealedField, UserKeys) follow exactly this convention.

Attaching a behavior to a model​

@Model()
export class User extends CoreModel {
mfa: MFA; // single Behavior attribute
audit: AuditLog; // multiple Behaviors per model are allowed
}

The compiler detects the property type and registers the attribute on the model. After the model is hydrated from a store, user.mfa is always an instance of MFA — the model constructor calls the generated __hydrateBehaviors(), which constructs it, links it to the parent (parent.instance === user, parent.attribute === "mfa") and copies any persisted state onto it. A model with Behavior attributes calls it at the end of its constructor, as Ident and User do.

REST surface​

Each @Action on a Behavior is exposed as a model-scoped operation:

  • Operation ID: User.Mfa.Verify
  • REST: PUT /users/{uuid}/mfa.verify

The path uses a dot-separator (mfa.verify) — not a nested slash — so the segment after the uuid identifies attribute and action together. Operations are registered in the same registry as model actions and binary actions, so GraphQL, gRPC, and CLI exposure happens automatically.

The HTTP method default is PUT, overridable via @Action({ rest: { method: "POST" } }).

Persisted state​

The Behavior's own data fields (non-method, non-static) are part of the model record. Serialisation produces a clean nested object:

{
"uuid": "u1",
"mfa": { "secret": "…", "lastVerified": 1714000000000 }
}

Fields participate in the parent model's JSON schema, so validation rules (required, defaults, formats, JSDoc-driven schema annotations) apply just like fields declared directly on the model.

A Behavior method that mutates state is responsible for saving its parent (await (this as any).parent.instance.save()) if it wants the change persisted. This matches the rule for @Action methods on models — the framework does not auto-save.

Authorization​

Authorization for Behavior actions is decided by the parent model's canAct, with the action string in dotted form:

class User extends CoreModel {
mfa: MFA;

async canAct(ctx, action) {
if (action === "mfa.set") return this.uuid === ctx.getCurrentUserId();
if (action === "mfa.verify") return true;
return super.canAct(ctx, action);
}
}

What the model author writes in canAct is exactly what the URL and operation ID expose (canAct(ctx, "<attribute>.<action>")). A Behavior class does not implement its own canAct; it may still add its own checks on top (fresh authentication, throttling) in the action body, as UserKeys does.

Reaching services​

Inside a Behavior method, use the standard webda hooks:

import { useService, useLog } from "@webda/core";

@Action({ rest: { method: "POST" } })
async verify(totp: string) {
const log = useLog("MFA");
const verifier = useService<TOTPVerifier>("totp");
// ...
}

@Inject is not used on Behaviors — they are short-lived per-model-instance objects, and lazy hook lookup matches their lifetime correctly.

Edge cases worth knowing​

  • user.mfa is always present after hydration. Setting user.mfa = null substitutes a fresh empty instance — user.mfa.X is always safe. To clear state, give your Behavior a clear() method and call it.
  • Same Behavior class on multiple attributes of one model is allowed (primaryMfa: MFA; backupMfa: MFA;) — they are independent instances with independent state and produce distinct operations (User.PrimaryMfa.Verify, User.BackupMfa.Verify).
  • Non-@Action methods on a Behavior are plain methods — useful as shared internals, not exposed as operations.
  • Static methods on Behaviors and @Action({ global: true }) on Behavior methods are not supported. Use a model action or a service for global operations.
  • Polymorphic Behaviors (mfa: MFA | TOTP | WebAuthn) are not supported. The attribute type must resolve to a single concrete @WebdaBehavior class.

When to use a behavior vs an action vs a service​

  • Action on the model — one or two methods, all data lives on the model already, no namespace conflict.
  • Behavior — three or more related methods, or a small chunk of own state, or a capability you want to reuse across several models. The dotted operation ID gives a natural namespace.
  • Service — system-wide capability with no per-model state, long-lived singleton, dependency-injected.