@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
@Extern()generates a sibling methodMethodName_Externon the class prototype- When the frontend calls
asyncInvoke("MethodName", args), the LCP resolver invokesMethodName_Extern, which returns anIAuthPromise{auth, resolve}—authis the fully qualified method name (<Namespace>.<Class>.MethodName) LiveContentResolvercallsAuthManager.IsAuthorizedFor(auth, token)before your method runs- If authorized,
resolve()binds the caller's token to the instance and calls your method with the deserialized arguments; otherwise the client receivesNOT_AUTHORIZEDand your method is never executed - 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 OrderisfalseandSave(),SyncWith(),ToInterface()or any other model method fails withTypeError: ... is not a function. - Nested embedded rows (
InclusionConverter/ArrayInclusionproperties) are plain objects too, andDatevalues arrive as strings. - Such an object must never reach a model property whose converter expects instances —
ArrayInclusioncallstoModel()on every item duringSave()(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
| Type | When to Use |
|---|---|
DEFAULT | Most methods — framework handles response automatically |
IMPLICIT_RETURN | When the return value should be the direct response payload |
FULL_CONTROL | When you need to set HTTP headers, status codes, or stream data |
WITH_PROGRESS | Long-running operations that report progress to the client |
RESPONSE_PROTOCOL | Advanced 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:
| Method | Purpose |
|---|---|
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;
}
}
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 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:
- Extend
BaseConnector— preferably the Hub one (io-oidis-hubPrimitives/BaseConnector.ts, addsthis.context); theio-oidis-localhostbase still works - Be registered in
AuthManager— Methods must be assigned to a permission category - 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 intoaddSystemPermissions("<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;
}
}