@webtypen/webframez-core 0.3.108 → 0.3.110
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/Auth/AuthResourceAccess.d.ts +30 -0
- package/dist/Auth/AuthResourceAccess.js +70 -0
- package/dist/Auth/index.d.ts +1 -0
- package/dist/Auth/index.js +1 -0
- package/dist/Hooks/WebframezHooks.d.ts +1 -1
- package/dist/Http/WebServerExtension.d.ts +14 -0
- package/dist/Http/WebServerExtension.js +2 -0
- package/dist/WebApplication.d.ts +4 -0
- package/dist/WebApplication.js +75 -20
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/docs/authentication.md +40 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,6 +23,24 @@ This README reflects the current API in this repository and focuses on:
|
|
|
23
23
|
npm i @webtypen/webframez-core
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
## Optional server extensions
|
|
27
|
+
|
|
28
|
+
`WebApplication.boot({ extensions: [...] })` accepts `WebServerExtension` instances.
|
|
29
|
+
Their synchronous `attach({ server, basename, kernel, modulesLoader })` method runs
|
|
30
|
+
after routes are registered and before the HTTP server listens. This allows optional
|
|
31
|
+
transports such as `@webtypen/webframez-sockets` to share the application's port
|
|
32
|
+
without adding their dependencies to Core.
|
|
33
|
+
|
|
34
|
+
Call `await application.close()` to release extensions in reverse order before
|
|
35
|
+
closing HTTP. Cleanup continues if an extension fails, and the returned promise
|
|
36
|
+
reports the failure. Partially attached extensions are also closed after startup
|
|
37
|
+
failure. Application instances boot once; create a new instance after shutdown.
|
|
38
|
+
`port: 0` selects an ephemeral port, and `onBoot(port)` receives the actual port.
|
|
39
|
+
|
|
40
|
+
Socket integrations can report `socket.event.start`, `socket.event.end` and
|
|
41
|
+
`socket.event.error` through `WebframezHooks` using the same operation context as
|
|
42
|
+
HTTP and queue execution.
|
|
43
|
+
|
|
26
44
|
## Authentication
|
|
27
45
|
|
|
28
46
|
New integrations can use `SessionAuth` for hashed, revocable device sessions,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Model } from "../Database/Model";
|
|
2
|
+
import { AuthMembershipCondition } from "./AuthMembership";
|
|
3
|
+
export type AuthResourceGrants = {
|
|
4
|
+
all?: boolean;
|
|
5
|
+
own?: boolean;
|
|
6
|
+
assigned?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type AuthResourceAccessOptions = {
|
|
9
|
+
model: typeof Model;
|
|
10
|
+
/** Immutable creator reference on the resource. */
|
|
11
|
+
ownerField?: string;
|
|
12
|
+
/** Array of explicitly assigned user references (also supports paths such as members.user). */
|
|
13
|
+
membersField?: string;
|
|
14
|
+
userKey?: string;
|
|
15
|
+
userConditions?: readonly AuthMembershipCondition[];
|
|
16
|
+
resourceConditions?: readonly AuthMembershipCondition[];
|
|
17
|
+
};
|
|
18
|
+
/** Document-query authorization for all, owned and explicitly assigned resources.
|
|
19
|
+
* Callers must supply authenticated, server-loaded users and server-derived grants.
|
|
20
|
+
* Use the same filter before pagination/counts/exports and for individual mutations.
|
|
21
|
+
*/
|
|
22
|
+
export declare class AuthResourceAccess {
|
|
23
|
+
private readonly options;
|
|
24
|
+
private readonly primaryKey;
|
|
25
|
+
private readonly userKey;
|
|
26
|
+
constructor(options: AuthResourceAccessOptions);
|
|
27
|
+
filter(user: Model, grants: AuthResourceGrants): Record<string, any>;
|
|
28
|
+
/** The returned model is freshly read; missing/invalid IDs never create an unbounded query. */
|
|
29
|
+
find<TResource extends Model = Model>(user: Model, id: unknown, grants: AuthResourceGrants): Promise<TResource | null>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.AuthResourceAccess = void 0;
|
|
13
|
+
const Model_1 = require("../Database/Model");
|
|
14
|
+
/** Document-query authorization for all, owned and explicitly assigned resources.
|
|
15
|
+
* Callers must supply authenticated, server-loaded users and server-derived grants.
|
|
16
|
+
* Use the same filter before pagination/counts/exports and for individual mutations.
|
|
17
|
+
*/
|
|
18
|
+
class AuthResourceAccess {
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.primaryKey = new options.model().__primaryKey;
|
|
22
|
+
this.userKey = options.userKey || "_id";
|
|
23
|
+
for (const field of [this.primaryKey, this.userKey, options.ownerField, options.membersField,
|
|
24
|
+
...(options.userConditions || []).map(condition => condition.field),
|
|
25
|
+
...(options.resourceConditions || []).map(condition => condition.field)]) {
|
|
26
|
+
if (field !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(field))
|
|
27
|
+
throw new Error("Invalid resource access field.");
|
|
28
|
+
}
|
|
29
|
+
if (options.ownerField && options.ownerField === options.membersField)
|
|
30
|
+
throw new Error("Owner and membership fields must be separate.");
|
|
31
|
+
}
|
|
32
|
+
filter(user, grants) {
|
|
33
|
+
const deny = { [this.primaryKey]: { $in: [] } };
|
|
34
|
+
if (!(user instanceof Model_1.Model))
|
|
35
|
+
return deny;
|
|
36
|
+
const userId = user[this.userKey];
|
|
37
|
+
if (userId === null || userId === undefined || userId === "")
|
|
38
|
+
return deny;
|
|
39
|
+
if (!(this.options.userConditions || []).every(condition => condition.operator === "="
|
|
40
|
+
? user[condition.field] === condition.value : user[condition.field] !== condition.value))
|
|
41
|
+
return deny;
|
|
42
|
+
const alternatives = [];
|
|
43
|
+
if (grants.own === true && this.options.ownerField)
|
|
44
|
+
alternatives.push({ [this.options.ownerField]: { $eq: userId } });
|
|
45
|
+
if (grants.assigned === true && this.options.membersField)
|
|
46
|
+
alternatives.push({ [this.options.membersField]: { $eq: userId } });
|
|
47
|
+
if (grants.all !== true && !alternatives.length)
|
|
48
|
+
return deny;
|
|
49
|
+
const conditions = (this.options.resourceConditions || []).map(condition => ({
|
|
50
|
+
[condition.field]: { [condition.operator === "=" ? "$eq" : "$ne"]: condition.value },
|
|
51
|
+
}));
|
|
52
|
+
if (grants.all !== true)
|
|
53
|
+
conditions.push({ $or: alternatives });
|
|
54
|
+
return conditions.length ? { $and: conditions } : {};
|
|
55
|
+
}
|
|
56
|
+
/** The returned model is freshly read; missing/invalid IDs never create an unbounded query. */
|
|
57
|
+
find(user, id, grants) {
|
|
58
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
59
|
+
if (id === null || id === undefined || id === "" || !(user instanceof Model_1.Model))
|
|
60
|
+
return null;
|
|
61
|
+
const resourceId = yield this.options.model.objectId(id, { noExceptions: true });
|
|
62
|
+
if (resourceId === null || resourceId === undefined)
|
|
63
|
+
return null;
|
|
64
|
+
return yield this.options.model.where("$and", "=", [
|
|
65
|
+
{ [this.primaryKey]: { $eq: resourceId } }, this.filter(user, grants),
|
|
66
|
+
]).first();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
exports.AuthResourceAccess = AuthResourceAccess;
|
package/dist/Auth/index.d.ts
CHANGED
package/dist/Auth/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type WebframezHookOperation = "http.request" | "route.handler" | "console.command" | "queue.job" | "queue.worker" | "app.boot" | "lambda.invoke";
|
|
1
|
+
export type WebframezHookOperation = "http.request" | "route.handler" | "console.command" | "queue.job" | "queue.worker" | "app.boot" | "lambda.invoke" | "socket.event";
|
|
2
2
|
export type WebframezHookPhase = "start" | "end" | "error";
|
|
3
3
|
export type WebframezHookEventName = `${WebframezHookOperation}.${WebframezHookPhase}`;
|
|
4
4
|
export type WebframezHookAttributeValue = string | number | boolean | null;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import type { Server } from "http";
|
|
3
|
+
import type { ModulesLoader } from "../Modules/ModulesLoader";
|
|
4
|
+
export type WebServerExtensionContext = {
|
|
5
|
+
server: Server;
|
|
6
|
+
basename: string | null;
|
|
7
|
+
kernel: any;
|
|
8
|
+
modulesLoader: ModulesLoader;
|
|
9
|
+
};
|
|
10
|
+
/** Optional transports attach before listen; close must release their connections. */
|
|
11
|
+
export interface WebServerExtension {
|
|
12
|
+
attach(context: WebServerExtensionContext): void;
|
|
13
|
+
close(): void | Promise<void>;
|
|
14
|
+
}
|
package/dist/WebApplication.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ import { ModulesLoader } from "./Modules/ModulesLoader";
|
|
|
4
4
|
export declare class WebApplication {
|
|
5
5
|
private server;
|
|
6
6
|
modulesLoader: ModulesLoader | null;
|
|
7
|
+
private serverExtensions;
|
|
8
|
+
private closing;
|
|
9
|
+
/** Close optional transports before waiting for the HTTP server to stop. */
|
|
10
|
+
close(): Promise<void>;
|
|
7
11
|
/**
|
|
8
12
|
* Init the routes and start the http-server
|
|
9
13
|
*/
|
package/dist/WebApplication.js
CHANGED
|
@@ -30,6 +30,35 @@ const NotificationService_1 = require("./Notifications/NotificationService");
|
|
|
30
30
|
class WebApplication {
|
|
31
31
|
constructor() {
|
|
32
32
|
this.modulesLoader = null;
|
|
33
|
+
this.serverExtensions = [];
|
|
34
|
+
this.closing = null;
|
|
35
|
+
}
|
|
36
|
+
/** Close optional transports before waiting for the HTTP server to stop. */
|
|
37
|
+
close() {
|
|
38
|
+
if (this.closing)
|
|
39
|
+
return this.closing;
|
|
40
|
+
this.closing = (() => __awaiter(this, void 0, void 0, function* () {
|
|
41
|
+
let failure;
|
|
42
|
+
for (const extension of this.serverExtensions.splice(0).reverse()) {
|
|
43
|
+
try {
|
|
44
|
+
yield extension.close();
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
failure = failure || error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (this.server) {
|
|
51
|
+
yield new Promise((resolve, reject) => this.server.close((error) => {
|
|
52
|
+
if (error && error.code !== "ERR_SERVER_NOT_RUNNING")
|
|
53
|
+
reject(error);
|
|
54
|
+
else
|
|
55
|
+
resolve();
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
if (failure)
|
|
59
|
+
throw failure;
|
|
60
|
+
}))();
|
|
61
|
+
return this.closing;
|
|
33
62
|
}
|
|
34
63
|
/**
|
|
35
64
|
* Init the routes and start the http-server
|
|
@@ -46,6 +75,12 @@ class WebApplication {
|
|
|
46
75
|
});
|
|
47
76
|
}
|
|
48
77
|
bootScoped(options) {
|
|
78
|
+
if (this.server || this.closing)
|
|
79
|
+
throw new Error("WebApplication instances can only boot once.");
|
|
80
|
+
const extensions = (options === null || options === void 0 ? void 0 : options.extensions) || [];
|
|
81
|
+
if (!Array.isArray(extensions) || extensions.some(extension => !extension || typeof extension.attach !== "function" || typeof extension.close !== "function")) {
|
|
82
|
+
throw new Error("WebApplication extensions must implement attach(context) and close().");
|
|
83
|
+
}
|
|
49
84
|
const bootOperationId = WebframezHooks_1.WebframezHooks.createOperationId("app");
|
|
50
85
|
void WebframezHooks_1.WebframezHooks.emit("app.boot.start", {
|
|
51
86
|
operationId: bootOperationId,
|
|
@@ -99,7 +134,7 @@ class WebApplication {
|
|
|
99
134
|
if (options.jobs && options.jobs.length > 0) {
|
|
100
135
|
QueueJobsRegisty_1.QueueJobsRegisty.registerJob(options.jobs);
|
|
101
136
|
}
|
|
102
|
-
const port = options
|
|
137
|
+
const port = (options === null || options === void 0 ? void 0 : options.port) !== undefined ? options.port : 3000;
|
|
103
138
|
this.server = http_1.default.createServer((req, res) => ErrorHandler_1.ErrorHandler.withContext({ applicationType: "WebApplication", scope: "controller", operationId: WebframezHooks_1.WebframezHooks.createOperationId("http") }, () => __awaiter(this, void 0, void 0, function* () {
|
|
104
139
|
try {
|
|
105
140
|
const handled = yield HttpKernelHandlerRegistry_1.HttpKernelHandlerRegistry.handle({
|
|
@@ -131,6 +166,7 @@ class WebApplication {
|
|
|
131
166
|
}
|
|
132
167
|
})));
|
|
133
168
|
this.server.on("error", (error) => {
|
|
169
|
+
void this.close().catch(closeError => ErrorHandler_1.ErrorHandler.report(closeError, { source: "http.server.close" }));
|
|
134
170
|
void ErrorHandler_1.ErrorHandler.report(error, { applicationType: "WebApplication", scope: "boot", source: "http.server" });
|
|
135
171
|
void WebframezHooks_1.WebframezHooks.emit("app.boot.error", {
|
|
136
172
|
operationId: bootOperationId,
|
|
@@ -149,26 +185,45 @@ class WebApplication {
|
|
|
149
185
|
// this.server.on("close", () => {
|
|
150
186
|
// void SigNozTelemetry.shutdown();
|
|
151
187
|
// });
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
attributes: {
|
|
158
|
-
"server.port": port,
|
|
159
|
-
"webframez.mode": options && options.mode ? options.mode : "web",
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
|
-
const runtimeConsole = typeof globalThis !== "undefined" ? globalThis.console : undefined;
|
|
163
|
-
if (runtimeConsole && typeof runtimeConsole.log === "function") {
|
|
164
|
-
runtimeConsole.log("Server started and listening on port " +
|
|
165
|
-
port +
|
|
166
|
-
(Router_1.Router.basename ? " (Basename: " + Router_1.Router.basename + ")" : ""));
|
|
167
|
-
}
|
|
168
|
-
if (options && options.onBoot) {
|
|
169
|
-
options.onBoot(port);
|
|
188
|
+
try {
|
|
189
|
+
for (const extension of extensions) {
|
|
190
|
+
this.serverExtensions.push(extension);
|
|
191
|
+
extension.attach({ server: this.server, basename: Router_1.Router.basename,
|
|
192
|
+
kernel: (options === null || options === void 0 ? void 0 : options.kernel) || null, modulesLoader: this.modulesLoader });
|
|
170
193
|
}
|
|
171
|
-
}
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
void this.close().catch(closeError => ErrorHandler_1.ErrorHandler.report(closeError, { source: "http.server.close" }));
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
this.server.listen(port, () => {
|
|
201
|
+
const address = this.server.address();
|
|
202
|
+
const listeningPort = address && typeof address === "object" ? address.port : port;
|
|
203
|
+
void WebframezHooks_1.WebframezHooks.emit("app.boot.end", {
|
|
204
|
+
operationId: bootOperationId,
|
|
205
|
+
name: "web",
|
|
206
|
+
status: "ok",
|
|
207
|
+
attributes: {
|
|
208
|
+
"server.port": listeningPort,
|
|
209
|
+
"webframez.mode": options && options.mode ? options.mode : "web",
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
const runtimeConsole = typeof globalThis !== "undefined" ? globalThis.console : undefined;
|
|
213
|
+
if (runtimeConsole && typeof runtimeConsole.log === "function") {
|
|
214
|
+
runtimeConsole.log("Server started and listening on port " +
|
|
215
|
+
listeningPort +
|
|
216
|
+
(Router_1.Router.basename ? " (Basename: " + Router_1.Router.basename + ")" : ""));
|
|
217
|
+
}
|
|
218
|
+
if (options && options.onBoot) {
|
|
219
|
+
options.onBoot(listeningPort);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
void this.close().catch(closeError => ErrorHandler_1.ErrorHandler.report(closeError, { source: "http.server.close" }));
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
172
227
|
return this.server;
|
|
173
228
|
}
|
|
174
229
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -61,6 +61,7 @@ export * from "./Api/ApiScopeRegistry";
|
|
|
61
61
|
export * from "./Ai/AiTypes";
|
|
62
62
|
export * from "./Mcp/McpTypes";
|
|
63
63
|
export * from "./Http/HttpKernelHandlerRegistry";
|
|
64
|
+
export * from "./Http/WebServerExtension";
|
|
64
65
|
export * from "./Auth";
|
|
65
66
|
export * from "./Notifications/Notification";
|
|
66
67
|
export * from "./Notifications/BaseNotificationsController";
|
package/dist/index.js
CHANGED
|
@@ -78,6 +78,7 @@ __exportStar(require("./Api/ApiScopeRegistry"), exports);
|
|
|
78
78
|
__exportStar(require("./Ai/AiTypes"), exports);
|
|
79
79
|
__exportStar(require("./Mcp/McpTypes"), exports);
|
|
80
80
|
__exportStar(require("./Http/HttpKernelHandlerRegistry"), exports);
|
|
81
|
+
__exportStar(require("./Http/WebServerExtension"), exports);
|
|
81
82
|
__exportStar(require("./Auth"), exports);
|
|
82
83
|
__exportStar(require("./Notifications/Notification"), exports);
|
|
83
84
|
__exportStar(require("./Notifications/BaseNotificationsController"), exports);
|
package/docs/authentication.md
CHANGED
|
@@ -584,3 +584,43 @@ checked by `isSessionAllowed` on every authentication/refresh. To enforce logout
|
|
|
584
584
|
request, do not cache positive introspection results. This requires one authority round trip
|
|
585
585
|
per check and fails closed when the authority is unavailable. No browser push is implied;
|
|
586
586
|
clients detect logout when making their next authenticated request.
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
## Owned and assigned resource scopes
|
|
590
|
+
|
|
591
|
+
`AuthResourceAccess` composes document filters for global, creator-owned and
|
|
592
|
+
explicitly assigned records. It complements `AuthMembership` (separate membership
|
|
593
|
+
models) with embedded administrator assignments and list/detail query parity.
|
|
594
|
+
|
|
595
|
+
```ts
|
|
596
|
+
const access = new AuthResourceAccess({
|
|
597
|
+
model: Project,
|
|
598
|
+
ownerField: "created_by",
|
|
599
|
+
membersField: "administrators.user",
|
|
600
|
+
userConditions: [{ field: "is_active", operator: "!=", value: false }],
|
|
601
|
+
resourceConditions: [{ field: "is_deleted", operator: "!=", value: true }],
|
|
602
|
+
});
|
|
603
|
+
const grants = {
|
|
604
|
+
all: user.can("projects.read"),
|
|
605
|
+
own: user.can("projects.own.read"),
|
|
606
|
+
assigned: user.can("projects.assigned.read"),
|
|
607
|
+
};
|
|
608
|
+
const rows = await Project.aggregate([{ $match: access.filter(user, grants) }]);
|
|
609
|
+
const project = await access.find(user, req.params.id, grants);
|
|
610
|
+
if (!project) return res.status(403).send({ code: "forbidden" });
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Grants are server-derived booleans; own and assigned are a union. Missing identity,
|
|
614
|
+
missing references and invalid resource IDs fail closed. `all` still respects the
|
|
615
|
+
configured status conditions. `filter` must run before pagination, counts, exports,
|
|
616
|
+
selectors and lookups that could expose other records. `find` performs a fresh
|
|
617
|
+
lookup, so revoked assignments take effect on the next request. Neither method
|
|
618
|
+
caches authorization or authenticates a supplied model. User identity fields are
|
|
619
|
+
scalar model fields; resource mappings may use document paths.
|
|
620
|
+
|
|
621
|
+
Set creator references server-side on creation and keep them immutable. Protect
|
|
622
|
+
assignment changes separately from scoped editing, and never derive grants from
|
|
623
|
+
submitted form values. Use the filter in conditional writes where concurrent
|
|
624
|
+
revocation must also constrain an already running operation. Existing records
|
|
625
|
+
without a creator or assignment gain no scoped access. Scope membership itself
|
|
626
|
+
does not grant another application's login or tenant membership.
|
package/package.json
CHANGED