Skip to content

Atscript Models

This page covers the atscript integration — the compile-time arbacPlugin(), the @arbac.* annotations it registers, the bundled AoothArbacUserCredentials model, the AtscriptArbacUserProvider runtime that auto-derives roles + attrs from a .as annotated user type, and the bundled forms model from @aooth/auth-moost.

The two atscript surfaces

SurfaceSubpathPurpose
Compile-time plugin@aooth/arbac-moost/pluginRegisters @arbac.role / @arbac.attribute / @arbac.userId so .as files type-check.
Runtime user provider@aooth/arbac-moost/atscriptAtscriptArbacUserProvider builds getRoles / getAttrs automatically from the model's annotations.
Bundled user model@aooth/arbac-moost/atscript/models[.as]AoothArbacUserCredentials — extends @aooth/user's base credential record and pre-applies @arbac.role to roles: string[].
Bundled forms model@aooth/auth-moost/atscript/models.as18 form types consumed by AuthWorkflow. Replaceable per-workflow via opts.forms.*.

Compile-time vs runtime imports

@aooth/arbac-moost/plugin is compile-time only — atscript pulls it inside atscript.config.ts. No runtime DI surface. @aooth/arbac-moost (main) is the runtime — interceptor, composable, decorators, DB controllers, MoostArbac, hand-rolled provider base. @aooth/arbac-moost/atscript is the atscript-aware runtime: AtscriptArbacUserProvider, ArbacUserTable, and the re-exported AoothArbacUserCredentials model class.

arbacPlugin() in atscript.config.ts

ts
// atscript.config.ts
import { defineConfig } from "@atscript/typescript/config";
import arbacPlugin from "@aooth/arbac-moost/plugin";

export default defineConfig({
  plugins: [arbacPlugin()],
  // ... your other atscript config
});

The plugin is a single default function exporting a TAtscriptPlugin that registers three AnnotationSpecs under the arbac namespace:

AnnotationTargetMultiple?
@arbac.rolepropNo — exactly one
@arbac.attributepropNo — but the user model can have many @arbac.attribute props
@arbac.userIdpropNo

The @arbac.* annotations

@arbac.role

Marks the field that carries the user's roles. Two valid shapes:

as
// inline — string or string[]
interface User {
  @arbac.role
  roles: string[]
}

// rel.from — 1:N nav prop to a roles table
interface User {
  @db.rel.from { table: 'user_roles', field: 'userId' }
  @arbac.role
  roleAssignments: UserRole[]
}

@arbac.role is single-source, fail-loud

Exactly one @arbac.role field per user model. The provider's extractRoles resolution walks one field; finding zero or more than one is a fatal Error at construction time, not a runtime guess. Multi-role inheritance from base interfaces is fine — only the resolved set on the final type is counted.

@arbac.attribute

Marks each field that should appear in the UserAttrs map. Zero or more:

as
interface User {
  @arbac.role
  roles: string[]

  @arbac.attribute
  tenantId: string

  @arbac.attribute
  department?: string
}

At runtime, getAttrs(userId) returns { tenantId, department } (omitting undefined fields).

@arbac.userId

Marks the field carrying the canonical user id. Optional — if omitted, the resolution chain falls through.

User-id resolution chain

StepLookupNotes
1Field with @arbac.userIdExplicit. Highest priority.
2Field of @db.table.preferredId.uniqueIndex groupPulled from @atscript/db's table metadata.
3Field with @meta.idThe atscript primary-id annotation.

If none resolve, AtscriptArbacUserProvider's constructor throws. Fail-loud, not fail-silent.

The default chain already matches the auth subject — leave @arbac.userId off

useAuth().getUserId() returns the stable surrogate id — the token sub claim auth.issue(subject) set, which is the base model's @meta.id. So the chain falls through to @meta.id and findOne({ filter: { id: <subject> } }) matches with no annotation. Don't add @arbac.userId username — that points the lookup at username while the subject is the id, so every request 401s with "user not found". (Earlier releases keyed the subject on username and required that annotate; the id-subject re-key removed it.)

The bundled AoothArbacUserCredentials model

packages/arbac-moost/src/atscript/models/user.as:

as
import { AoothUserCredentials } from '@aooth/user/atscript-db/model'

/**
 * Base credential record for ARBAC-enabled atscript users.
 * Pre-applies `@arbac.role` to `roles: string[]` so subclasses inherit it.
 */
export interface AoothArbacUserCredentials extends AoothUserCredentials {
    @arbac.role
    roles: string[]
}

Intentionally minimal. Extend it in your app model:

as
// my-app/models/user.as
import { AoothArbacUserCredentials } from '@aooth/arbac-moost/atscript/models'

export interface MyUser extends AoothArbacUserCredentials {
  @arbac.attribute
  tenantId: string

  @arbac.attribute
  department?: string

  // ... your domain fields
}

Now roles is already typed string[] and pre-annotated with @arbac.role; you only add the @arbac.attribute fields.

AtscriptArbacUserProvider

ts
import { AtscriptArbacUserProvider, type ArbacUserTable } from "@aooth/arbac-moost/atscript";
import { Injectable } from "moost";
import { MyUser } from "./models/user.as";

@Injectable()
class MyArbacUserProvider extends AtscriptArbacUserProvider<MyUser> {
  constructor() {
    super(MyUser, myUserTable);
  }
  override getUserId(): string {
    return useAuth().getUserId();
  }
}

Constructor — (userType, table)

ParamTypePurpose
userTypeTAtscriptAnnotatedTypeThe runtime-metadata class generated by unplugin-atscript or asc -f dts.
tableArbacUserTable<T>A minimal { findOne({ filter, controls? }): Promise<T | null> } shim. Often an AtscriptDbTable cast.

What's left abstract

Only getUserId(). Everything else is auto-derived:

  • getRoles(id) — resolves through the @arbac.role field (inline array or rel.from).
  • getAttrs(id) — collects every @arbac.attribute field into a UserAttrs map.

getUserId() — the consumer's seam

Typically reads from useAuth():

ts
override getUserId(): string {
  return useAuth().getUserId();
}

For event kinds where the auth context isn't HTTP (e.g. a CLI script with a hard-coded admin id), override to return that id directly.

extractRoles / extractAttrs — protected seams

If your model encodes roles in a non-standard shape (e.g. a comma-separated string field), override one of the protected extractRoles(record) / extractAttrs(record) methods:

ts
@Injectable()
class MyArbacUserProvider extends AtscriptArbacUserProvider<MyUser> {
  constructor() {
    super(MyUser, myUserTable);
  }
  override getUserId() {
    return useAuth().getUserId();
  }

  protected override extractRoles(record: MyUser): string[] {
    return record.rolesCsv
      .split(",")
      .map((s) => s.trim())
      .filter(Boolean);
  }
}

Per-event memoization

getRoles(id) and getAttrs(id) both dispatch through a private fetchRecord(userId) that runs table.findOne({ filter: { [userIdField]: userId }, controls: { $select, $with? } }). The result is memoized per (EventContext, this, userId) via a wooks slot — so getRoles + getAttrs collapse to one round-trip per request.

Two distinct events do NOT share the cache

Cross-request caching is not provided. If a role is revoked, the next request reflects it on its next fetchRecord call. There is no TTL knob — caching is per-event only.

Injectable inheritance gotcha

@Injectable() does NOT inherit across extends in moost@0.6.x

Every concrete subclass of AtscriptArbacUserProvider MUST re-apply @Injectable(). Without it, Moost's DI fails to instantiate the provider with an opaque "could not instantiate" error.

ts
// CORRECT
@Injectable()
class MyProvider extends AtscriptArbacUserProvider<MyUser> {
  /* ... */
}

// WRONG — Moost won't instantiate
class MyProvider extends AtscriptArbacUserProvider<MyUser> {
  /* ... */
}

The same gotcha applies to workflow subclasses, where the recipe is @Inherit() @Injectable("FOR_EVENT") @Controller().

Codegen — unplugin-atscript or asc -f dts

The runtime provider reads @atscript/typescript's generated metadata at construction time. unplugin-atscript (or asc -f dts) must run before app build.

bash
# one-shot codegen
pnpm exec asc -f dts

# or via Vite plugin
# vite.config.ts
import unpluginAtscript from "unplugin-atscript/vite";
export default defineConfig({
  plugins: [unpluginAtscript({ /* ... */ })],
});

Without a built .as.d.ts / .as.js pair, importing MyUser from ./models/user.as will fail at runtime with Cannot read properties of undefined (reading 'metadata').

The forms model from @aooth/auth-moost

AuthWorkflow consumes 18 .as form types from packages/auth-moost/src/atscript/models/forms.as:

FormUsed by
WithInlineConsentFormBase — carries the inline consents field (extended by the four below)
LoginCredentialsFormLogin — username + password
Select2faFormLogin / invite — MFA method picker
MfaCodeFormLogin / invite — TOTP code entry
PincodeFormLogin / recovery — SMS/email OTP entry
EmailIdentifierFormRecovery — identifier (email/username)
SetPasswordFormLogin forced change / invite set / recovery reset
EnrollPickMethodFormMFA enrollment — pick method
EnrollAddressFormMFA enrollment — email/phone address
EnrollConfirmFormMFA enrollment — confirm (TOTP shows a QR)
AskEmailForm / AskPhoneFormLogin — channel enrollment carrier forms
TermsBumpFormLogin — terms re-acceptance
ConcurrencyLimitFormLogin — session-limit kick prompt
InviteFormInvite admin form — email / name / roles
MagicLinkRequestFormLogin — magicLink alt-action
RecoveryModeSelectFormRecovery — magic-link vs OTP
RecoveryFactorFormRecovery — known-factor verification

⁺ extends WithInlineConsentForm (renders the inline pending-consents field).

Three fields carry a @ui.form.component '<Name>' annotation pointing at SPA components from @atscript/vue-aooth — register the matching component name in <AsWfForm :components> (see SPA Components):

Field@ui.form.componentRenders
WithInlineConsentForm.consentsAsConsentArrayone checkbox per pending consent
SetPasswordForm.passwordRulesAsPasswordRuleslive password-policy fulfillment dots
EnrollConfirmForm.qrCodeAsQrCodescannable TOTP otpauth:// QR + secret

Every form is replaceable per-workflow through opts.forms.<slot>:

ts
import { LoginCredentialsForm as MyLoginForm } from "./my-forms.as";

new AuthWorkflow({ forms: { loginCredentials: MyLoginForm } }, users, auth, consentStore);

Annotations used in forms.as: @meta.label / @meta.required / @meta.sensitive / @meta.default / @meta.description, @ui.form.type / @ui.form.autocomplete / @ui.form.component / @ui.form.options / @ui.form.order / @ui.form.grid.colSpan / @ui.form.action / @ui.form.attr / @ui.form.pushDown / @ui.form.submit.text / @ui.form.validate, the @ui.form.fn.* reactive family (value / hidden / options / attr / title / description), @expect.minLength / @expect.maxLength / @expect.pattern, and @wf.context.pass / @wf.action.withData.

The cross-flow navigation actions (login's signup + magicLink, signup's backToLogin, recovery's backToLogin) carry @ui.form.pushDown + @ui.form.attr 'text'/'align' so they render as pushed-down, centered "text + link" affordances below the submit button — see SPA Components — cross-flow alt-action links.

Clients consume the forms via @atscript/vue-wf (<AsWfForm> mounts the form at the paused step automatically). See SPA Components.

See also

  • Setup — wiring the provider via setReplaceRegistry([ArbacUserProviderToken, ...]).
  • ARBAC Authorize — how the provider feeds into the interceptor.
  • Workflows — how the forms are consumed at each paused step.

Released under the MIT License.