Skip to main content

@Extern

The @Extern() decorator marks a backend method as remotely callable from the frontend via the LiveContentProtocol (LCP).

Package: io-oidis-localhost

import { Extern } from "@io-oidis-localhost/.../Primitives/Decorators.js";

Syntax​

@Extern()
public async MethodName($param : Type) : Promise<ReturnType> {
// Runs only after the framework authorized the caller for this method
return await this.doWork($param);
}

How It Works​

  1. @Extern() generates a sibling method MethodName_Extern on the class prototype
  2. When the frontend calls asyncInvoke("MethodName", args), the LCP resolver invokes MethodName_Extern, which returns an IAuthPromise {auth, resolve} — auth is the fully qualified method name (<Namespace>.<Class>.MethodName)
  3. LiveContentResolver calls AuthManager.IsAuthorizedFor(auth, token) before your method runs
  4. If authorized, resolve() binds the caller's token to the instance and calls your method with the deserialized arguments; otherwise the client receives NOT_AUTHORIZED and your method is never executed
  5. The return value is serialized and sent back to the frontend

Source: io-oidis-localhost Primitives/Decorators.ts (Extern) and HttpProcessor/Resolvers/LiveContentResolver.ts (invokeMethod).

Arguments Arrive as Plain JSON​

LiveContentResolver decodes the call arguments with JSON.parse, and resolve() passes that array unchanged to your method (descriptor.value.apply(this, args)). Nothing converts the arguments according to the parameter types — TypeScript types are erased at runtime:

  • A parameter typed as a model ($data : Order) receives a plain object: $data instanceof Order is false and Save(), SyncWith(), ToInterface() or any other model method fails with TypeError: ... is not a function.
  • Nested embedded rows (InclusionConverter / ArrayInclusion properties) are plain objects too, and Date values arrive as strings.
  • Such an object must never reach a model property whose converter expects instances — ArrayInclusion calls toModel() on every item during Save() (see Converters).

Type the parameter with an interface and build the model instances on the server, either by explicit assignment:

@Extern()
public async SaveOrder($data : IOrder) : Promise<string> {
let order : Order = new Order();
if (!ObjectValidator.IsEmptyOrNull($data.Id)) {
order = await Order.FindById($data.Id);
await this.context.RequireOwnership(order, "Owner");
} else {
order.Owner = (await this.context.RequireAuthenticated()).Id;
}
order.Note = ObjectValidator.IsEmptyOrNull($data.Note) ? null : $data.Note;
order.Lines = [];
for (const line of $data.Lines ?? []) {
const row : OrderLine = new OrderLine();
row.Text = line.Text;
row.Quantity = line.Quantity;
order.Lines.push(row);
}
await order.Save();
return order.Id;
}

or with model.SyncWith($data), which rebuilds nested model properties and array items as instances (an existing item with the same Id is reused, otherwise the class is taken from the item's ClassName and falls back to the @Property type) and turns Date property strings into Date objects. SyncWith copies every field present in the client object, so prefer explicit assignment for fields the caller must not control (see Models Overview).

Response Types​

@Extern()                                    // DEFAULT — auto response
@Extern(ExternResponseType.IMPLICIT_RETURN) // Return value sent directly
@Extern(ExternResponseType.FULL_CONTROL) // Manual IResponse management
@Extern(ExternResponseType.WITH_PROGRESS) // Supports progress updates
@Extern(ExternResponseType.RESPONSE_PROTOCOL)// Low-level protocol control
TypeWhen to Use
DEFAULTMost methods — framework handles response automatically
IMPLICIT_RETURNWhen the return value should be the direct response payload
FULL_CONTROLWhen you need to set HTTP headers, status codes, or stream data
WITH_PROGRESSLong-running operations that report progress to the client
RESPONSE_PROTOCOLAdvanced protocol-level control

For 95% of use cases, @Extern() with no arguments is sufficient.

Authorization Inside the Method​

There is no auth wrapper to call — @Extern methods are plain async methods. The framework only checks whether the caller may call the method; it knows nothing about the arguments. Every per-record rule (ownership, organization, role) must be checked inside the method.

Connectors extending the Hub BaseConnector (@io-oidis-hub/Io/Oidis/Hub/Primitives/BaseConnector.js) get this.context — a RequestContext bound to the current call (io-oidis-hub Utils/RequestContext.ts). Results are cached per token and shared by concurrent calls of the same token for at most one minute; logout, token removal or user/group mutations evict the cache immediately:

MethodPurpose
getUser() / getUserId()Current user (or null for anonymous calls)
IsAuthenticated() / RequireAuthenticated()Check, or throw when nobody is signed in; RequireAuthenticated() returns the user
HasGroup($group) / getSystemGroups()Group membership by Group, id or name
IsAdmin() / IsSysAdmin()System role checks
HasFeature("#Feature")Explicitly granted #-feature
IsAuthorizedFor($method)Method permission check
RequireOwnership($record, "Owner")Throw unless the record's owner field matches the current user
import { BaseConnector } from "@io-oidis-hub/Io/Oidis/Hub/Primitives/BaseConnector.js";
import { Extern } from "@io-oidis-localhost/Io/Oidis/Localhost/Primitives/Decorators.js";

export class InventoryManager extends BaseConnector {

@Extern()
public async SaveRecord($data : IRecordInput) : Promise<string> {
await this.context.RequireAuthenticated();
const record : InventoryRecord = await InventoryRecord.FindById($data.id);
await this.context.RequireOwnership(record, "Owner");
record.Name = $data.name; // copy fields explicitly, never save the client object as a model
await record.Save();
return record.Id;
}
}
Method permission is not data permission

IsAuthorizedFor only matches the method name against the caller's permission patterns. A method that accepts a record, user or organization id from the client must verify that the caller may touch that record — otherwise any authorized caller reads or modifies foreign data just by passing another id.

Admin and features

Admin holds the * method pattern but no explicit #-features, so HasFeature("#MyFeature") returns false for an administrator unless the feature is granted to one of his groups. Combine it with IsAdmin() when admins should pass.

Prerequisites​

The class containing @Extern methods must:

  1. Extend BaseConnector — preferably the Hub one (io-oidis-hub Primitives/BaseConnector.ts, adds this.context); the io-oidis-localhost base still works
  2. Be registered in AuthManager — Methods must be assigned to a permission category
  3. Be imported by ForceCompile.ts — The Builder adds every project source file containing @Extern( to the generated block on each build, no manual import needed (see ForceCompile)

Authorization Registration​

In your project's AuthManager constructor:

// Any signed-in user may call these (3-argument form: category, class, methods)
this.generateAuthMethodsFrom<InventoryManager>(AuthMethodCategory.ONLY_FOR_AUTHORIZED_USERS,
InventoryManager, ($proto : InventoryManager) => [
$proto.ListRecords,
$proto.SaveRecord,
$proto.GetRecord
]);

// Only members of a system group (2-argument form returns patterns for addSystemPermissions)
this.addSystemPermissions("InventoryAdmin", "#InventoryAdmin",
...this.generateAuthMethodsFrom<InventoryManager>(InventoryManager, ($proto : InventoryManager) => [
$proto.DeleteRecord,
$proto.PurgeData
]));

generateAuthMethodsFrom has two signatures (io-oidis-hub AuthProcessor/AuthMethodsManager.ts):

  • (category : AuthMethodCategory, class, methods) — registers the methods for the category
  • (class, methods) — only returns the method patterns, to be spread into addSystemPermissions("<Group>", ...)

The first argument is never a string. Categories:

  • AuthMethodCategory.FOR_ALL_USERS — No authentication needed (Login, Register, public endpoints)
  • AuthMethodCategory.ONLY_FOR_AUTHORIZED_USERS — Any logged-in user can call
  • System permissions — Group-based restrictions via addSystemPermissions("<Group>", ...patternsOrFeatures); the system group and its authorized methods are synchronized to the database on start

Combining with @Route​

A method can have both @Extern and @Route for dual LCP + REST access:

@Extern()
@Route("GET /api/products?offset=int(0)&limit=int(50)")
public async ListProducts($options : IListOptions) : Promise<IModelListResult> {
// Accessible via LCP and REST; both go through the same IsAuthorizedFor check
}

Common Patterns​

CRUD Set​

@Extern()
public async List($options : IListOptions) : Promise<IModelListResult> { ... }

@Extern()
public async Get($id : string) : Promise<IRecord> { ... }

@Extern()
public async Save($data : IRecord) : Promise<void> { ... } // plain JSON, not a model instance

@Extern()
public async Delete($id : string) : Promise<void> { ... }

Error Handling​

@Extern()
public async ProcessData($data : InputData) : Promise<IResult> {
try {
const result : IResult = await this.heavyProcessing($data);
await SystemLog.Trace({
level : LogLevel.INFO,
message : "Data processed successfully",
onBehalf : await this.context.getUserId(),
tags : ["processing"],
traceBack: `InputId: ${$data.Id}, ResultCount: ${result.count}`
});
return result;
} catch (ex) {
LogIt.Error("Processing failed", ex);
await SystemLog.Error("Processing failed: " + ex.message);
throw ex;
}
}

See also: LCP & RPC | @Route | @Resolver