mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: namespace plugin permissions and keep them valid when a plugin is off
This commit is contained in:
1 parent
8431570ff7
commit
cc15559ea1
53 files changed
+31442
-351
No files matched your search
@@ -67,14 +67,44 @@ text) plus the SDK method that checks it.
|
||||
These stay in core RBAC and keep their dotted spelling (`hosts.view`), so they
|
||||
can never be confused with colon-separated capabilities.
|
||||
|
||||
A plugin declares short names in `contributes.permissionGroup`. Core registers
|
||||
them, groups them under the plugin in the role editor, and only lets a plugin
|
||||
set defaults for its own namespace: a `defaultForRole` entry naming a
|
||||
permission the plugin does not declare is a validation error, not a silent
|
||||
escalation. Permissions stored in a role survive the plugin being disabled.
|
||||
**A5 delivered this.** A plugin declares short names in
|
||||
`contributes.permissions`, and core registers each as `<pluginId>.<name>`. The
|
||||
plugin never writes the prefix, so it cannot claim a core group or another
|
||||
plugin's namespace: `parseManifest` refuses a name starting with `hosts`,
|
||||
`snippets`, `credentials`, `admin` or the plugin's own id, and the runtime
|
||||
re-checks against every namespace already in the catalog, because the validator
|
||||
cannot know which other plugins exist.
|
||||
|
||||
Role defaults are applied once and the fact is recorded, so an admin who
|
||||
revokes one does not get it handed back on the next restart.
|
||||
The ids are unchanged by the move. `ai` + `use` is `ai.use`, which is exactly
|
||||
what the core catalog held before, so no role had to be migrated when `ai.*` and
|
||||
`automations.*` left `PERMISSION_CATALOG` and `SYSTEM_ROLE_DEFAULTS`.
|
||||
|
||||
**A permission outlives its plugin.** Core records every id it has ever
|
||||
registered in `rbac_known_permissions`, loaded at boot before any request is
|
||||
served and backfilled from what roles already hold. Disabling a plugin now only
|
||||
marks its group `enabled: false`: the group stays in the catalog so the role
|
||||
editor can grey it, and `PUT /rbac/roles/:id` keeps accepting it. Previously the
|
||||
group was unregistered outright, so an admin could not save _any_ change to a
|
||||
role that still held one of these while the plugin was off. A permission nobody
|
||||
has ever registered is still rejected.
|
||||
|
||||
**Defaults apply once.** `defaultRoles` names the seeded system roles (`admin`,
|
||||
`user`) and nothing else. Each application is recorded in
|
||||
`rbac_applied_defaults`, a core table with no foreign key to `plugins`, so an
|
||||
admin who revokes a default does not get it handed back on the next boot or by
|
||||
reinstalling the plugin. That ledger used to live in `plugin_storage`, which
|
||||
cascades with the plugin row.
|
||||
|
||||
Both tables are deliberately unlinked from `plugins`: the whole point is
|
||||
surviving the plugin being gone.
|
||||
|
||||
`ctx.rbac` is the plugin-side view: `has(permission)` for the current actor,
|
||||
`hasFor(userId, permission)`, and `require(permission)` as route middleware. All
|
||||
three resolve a bare short name against this plugin's prefix and take a full id
|
||||
belonging to a core group or another plugin as given, which is what makes a
|
||||
cross-plugin check expressible. `require` additionally refuses anything the
|
||||
plugin does not declare itself, at registration time, and denies with the same
|
||||
`401`/`403` bodies `requirePermission()` sends.
|
||||
|
||||
### 5. Data
|
||||
|
||||
@@ -332,11 +362,14 @@ not do.
|
||||
},
|
||||
],
|
||||
"actionSlots": [{ "id": "example.toolbar", "accepts": ["button"] }],
|
||||
"permissionGroup": {
|
||||
"group": "example",
|
||||
"permissions": ["example.use"],
|
||||
"defaultForRole": { "admin": ["example.use"] }, // own namespace only
|
||||
},
|
||||
"permissions": [
|
||||
{
|
||||
"name": "use", // registered as "example.use"
|
||||
"titleKey": "permissions.use.title", // plugin-relative
|
||||
"descriptionKey": "permissions.use.description",
|
||||
"defaultRoles": ["user"], // system roles only, applied once
|
||||
},
|
||||
],
|
||||
"hostCapability": {
|
||||
"key": "enableExample",
|
||||
"labelKey": "k",
|
||||
@@ -361,17 +394,22 @@ Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
|
||||
- `engine.api` must match the SDK major version this build implements.
|
||||
- Every `provides[].permission`, `providesSecret[].permission` and
|
||||
`contributes.actions[].permission` must appear in the plugin's own
|
||||
`contributes.permissionGroup.permissions`. A permission the catalog never
|
||||
sees is one no admin can grant, so the surface would be invisible rather
|
||||
than denied.
|
||||
`contributes.permissions`, compared against the qualified `<pluginId>.<name>`
|
||||
form. A permission the catalog never sees is one no admin can grant, so the
|
||||
surface would be invisible rather than denied.
|
||||
- `requiresSecret[].plugin` and both dependency maps must not name the plugin
|
||||
itself, and a plugin cannot be in both `dependencies` and
|
||||
`optionalDependencies`.
|
||||
- Every `defaultForRole` entry must be a permission the plugin declares.
|
||||
- Every `contributes.permissions[].name` must be unique, must not start with a
|
||||
core group (`hosts`, `snippets`, `credentials`, `admin`) or with the plugin's
|
||||
own id, and every `defaultRoles` entry must be `admin` or `user`.
|
||||
|
||||
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
|
||||
`requiresSecret` are carried forward from v1 unchanged because the service and
|
||||
secret registries read them at activation. A5, A6 and A7 reshape them.
|
||||
secret registries read them at activation. A6 and A7 reshape them.
|
||||
|
||||
Removed in A5: `contributes.permissionGroup` and its `defaultForRole`, replaced
|
||||
by `contributes.permissions`. A plugin no longer picks its own group name.
|
||||
|
||||
Removed in v2: `permissions` (replaced by `capabilities`), `sidecars`, the
|
||||
`capabilities.{backend,frontend,electron,platforms}` object (replaced by the
|
||||
@@ -446,7 +484,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
|
||||
| `ctx.sync.registerEntity` | none | **A3** |
|
||||
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
|
||||
| `ctx.rbac.require` | own permissions only | **A4** |
|
||||
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
|
||||
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B |
|
||||
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | B |
|
||||
| `ctx.settings.*` | `settings:read-core` | A6 |
|
||||
@@ -650,8 +688,7 @@ Known specifics:
|
||||
an `optionalDependency` (added in A2; it previously declared nothing, and
|
||||
the loader had no reason to order the two).
|
||||
- Core feature servers that are not plugins yet still own ports: tunnel 30003,
|
||||
file-manager 30004, dashboard 30006, tmux 30010, serial 30011 and homepage
|
||||
30012. Each keeps its nginx block until its own Phase B step. No plugin owns
|
||||
file-manager 30004, dashboard 30006, tmux 30010, serial 30011 and homepage 30012. Each keeps its nginx block until its own Phase B step. No plugin owns
|
||||
a port any more (A4).
|
||||
- `src/ui/shell/pluginLoader.ts` still hardcodes plugin ids in
|
||||
`BUILT_IN_TABS_BY_PLUGIN` and `applyFirstPartyActions`. A7 removes both.
|
||||
@@ -244,26 +244,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissionGroup": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["group", "permissions"],
|
||||
"properties": {
|
||||
"group": { "type": "string", "minLength": 1 },
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"description": "Role permissions this plugin contributes. Core registers each as <pluginId>.<name>.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "titleKey", "descriptionKey"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+(\\.[a-z0-9_-]+)+$"
|
||||
}
|
||||
},
|
||||
"defaultForRole": {
|
||||
"type": "object",
|
||||
"description": "Role name -> permissions. Only permissions this plugin declares above are allowed.",
|
||||
"additionalProperties": {
|
||||
"description": "Plugin-relative name. May contain dots. Must not start with a core group or this plugin's id.",
|
||||
"pattern": "^[a-z0-9_-]+(\\.[a-z0-9_-]+)*$"
|
||||
},
|
||||
"titleKey": { "type": "string", "minLength": 1 },
|
||||
"descriptionKey": { "type": "string", "minLength": 1 },
|
||||
"defaultRoles": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
"description": "System roles that hold this the first time it is seen.",
|
||||
"items": { "type": "string", "enum": ["admin", "user"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,14 +213,28 @@ export interface PluginHttp {
|
||||
router: <T = unknown>(options?: PluginRouterOptions) => T;
|
||||
}
|
||||
|
||||
/** RBAC middleware for a plugin's own routes. */
|
||||
/**
|
||||
* Core RBAC, as a plugin sees it.
|
||||
*
|
||||
* Every method takes either a short name this plugin declares
|
||||
* ("services.use"), which is prefixed with the plugin id, or a full id
|
||||
* belonging to another plugin or a core group, which is used as given. That is
|
||||
* what makes a cross-plugin check expressible without letting a plugin gate its
|
||||
* own routes on someone else's authority.
|
||||
*/
|
||||
export interface PluginRbac {
|
||||
/** Whether the user this call is running as holds `permission`. */
|
||||
has: (permission: string) => Promise<boolean>;
|
||||
|
||||
/** Whether `userId` holds `permission`. */
|
||||
hasFor: (userId: string, permission: string) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Middleware that rejects a request whose user lacks `permission`.
|
||||
*
|
||||
* A plugin may only require a permission it declares in
|
||||
* contributes.permissionGroup, so a route cannot gate on admin.users.manage
|
||||
* and borrow someone else's authority.
|
||||
* contributes.permissions, so a route cannot gate on admin.users.manage and
|
||||
* borrow someone else's authority.
|
||||
*/
|
||||
require: (permission: string) => PluginMiddleware;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,31 @@ const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
|
||||
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
|
||||
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
const PERMISSION_PATTERN = /^[a-z0-9-]+(\.[a-z0-9_-]+)+$/;
|
||||
/**
|
||||
* A plugin-relative permission name. Unlike a full id one segment is fine,
|
||||
* and underscores are allowed in the first segment too ("manage_providers").
|
||||
*/
|
||||
const PERMISSION_NAME_PATTERN = /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/;
|
||||
|
||||
/** The roles core seeds. A plugin may only set defaults for these. */
|
||||
export const SYSTEM_ROLE_NAMES = ["admin", "user"] as const;
|
||||
export type SystemRoleName = (typeof SYSTEM_ROLE_NAMES)[number];
|
||||
|
||||
/**
|
||||
* Core permission groups. A plugin permission may not start with one of these,
|
||||
* or a manifest could declare admin.users.manage and gate a route on it.
|
||||
*/
|
||||
export const RESERVED_PERMISSION_PREFIXES = [
|
||||
"hosts",
|
||||
"snippets",
|
||||
"credentials",
|
||||
"admin",
|
||||
] as const;
|
||||
|
||||
/** The id a plugin-relative permission name is registered under. */
|
||||
export function qualifyPermission(pluginId: string, name: string): string {
|
||||
return `${pluginId}.${name}`;
|
||||
}
|
||||
|
||||
export interface PluginAuthor {
|
||||
name: string;
|
||||
@@ -72,11 +97,19 @@ export interface PluginTabContribution {
|
||||
openFrom: string[];
|
||||
}
|
||||
|
||||
export interface PluginPermissionGroup {
|
||||
group: string;
|
||||
permissions: string[];
|
||||
/** Role name -> permissions. Only this plugin's own permissions are allowed. */
|
||||
defaultForRole?: Record<string, string[]>;
|
||||
/**
|
||||
* One role permission a plugin contributes.
|
||||
*
|
||||
* `name` is short and plugin-relative; core registers it as
|
||||
* `<pluginId>.<name>`, so a plugin can never name a core group or another
|
||||
* plugin's namespace. The i18n keys are plugin-relative too.
|
||||
*/
|
||||
export interface PluginPermissionContribution {
|
||||
name: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
/** System roles that should hold this the first time it is seen. */
|
||||
defaultRoles?: SystemRoleName[];
|
||||
}
|
||||
|
||||
export interface PluginActionContribution {
|
||||
@@ -125,7 +158,7 @@ export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
actions?: PluginActionContribution[];
|
||||
actionSlots?: PluginActionSlot[];
|
||||
permissionGroup?: PluginPermissionGroup;
|
||||
permissions?: PluginPermissionContribution[];
|
||||
/**
|
||||
* A host-editor checkbox backed by a boolean column on the host record.
|
||||
* A5/A6/A7 reshape this; it stays in v2 because remote-desktop, docker,
|
||||
@@ -202,7 +235,7 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"tabs",
|
||||
"actions",
|
||||
"actionSlots",
|
||||
"permissionGroup",
|
||||
"permissions",
|
||||
"hostCapability",
|
||||
]);
|
||||
|
||||
@@ -288,7 +321,11 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
validateRequires(m.requires, errors);
|
||||
validateProvidesSecret(m.providesSecret, errors);
|
||||
validateRequiresSecret(m.requiresSecret, errors);
|
||||
validateContributes(m.contributes, errors);
|
||||
validateContributes(
|
||||
m.contributes,
|
||||
typeof m.id === "string" ? m.id : undefined,
|
||||
errors,
|
||||
);
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -511,7 +548,11 @@ function validateRequiresSecret(value: unknown, errors: string[]): void {
|
||||
});
|
||||
}
|
||||
|
||||
function validateContributes(contributes: unknown, errors: string[]): void {
|
||||
function validateContributes(
|
||||
contributes: unknown,
|
||||
pluginId: string | undefined,
|
||||
errors: string[],
|
||||
): void {
|
||||
if (contributes === undefined) return;
|
||||
if (!isPlainObject(contributes)) {
|
||||
errors.push('Field "contributes" must be an object');
|
||||
@@ -520,7 +561,7 @@ function validateContributes(contributes: unknown, errors: string[]): void {
|
||||
rejectUnknown(contributes, ALLOWED_CONTRIBUTES, '"contributes"', errors);
|
||||
|
||||
validateTabs(contributes.tabs, errors);
|
||||
validatePermissionGroup(contributes.permissionGroup, errors);
|
||||
validatePermissions(contributes.permissions, pluginId, errors);
|
||||
validateActions(contributes.actions, errors);
|
||||
validateActionSlots(contributes.actionSlots, errors);
|
||||
validateHostCapability(contributes.hostCapability, errors);
|
||||
@@ -562,74 +603,78 @@ function validateTabs(tabs: unknown, errors: string[]): void {
|
||||
});
|
||||
}
|
||||
|
||||
function validatePermissionGroup(group: unknown, errors: string[]): void {
|
||||
if (group === undefined) return;
|
||||
const where = "contributes.permissionGroup";
|
||||
if (!isPlainObject(group)) {
|
||||
errors.push(`${where} must be an object`);
|
||||
function validatePermissions(
|
||||
permissions: unknown,
|
||||
pluginId: string | undefined,
|
||||
errors: string[],
|
||||
): void {
|
||||
if (permissions === undefined) return;
|
||||
const where = "contributes.permissions";
|
||||
if (!Array.isArray(permissions)) {
|
||||
errors.push(`${where} must be an array`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(
|
||||
group,
|
||||
["group", "permissions", "defaultForRole"],
|
||||
where,
|
||||
errors,
|
||||
);
|
||||
|
||||
requireString(group.group, `${where}.group`, errors);
|
||||
const seen = new Set<string>();
|
||||
|
||||
if (!Array.isArray(group.permissions) || group.permissions.length === 0) {
|
||||
errors.push(`${where}.permissions must be a non-empty array`);
|
||||
} else {
|
||||
group.permissions.forEach((permission: unknown, index: number) => {
|
||||
permissions.forEach((entry: unknown, index: number) => {
|
||||
const at = `${where}[${index}]`;
|
||||
if (!isPlainObject(entry)) {
|
||||
errors.push(`${at} must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(
|
||||
entry,
|
||||
["name", "titleKey", "descriptionKey", "defaultRoles"],
|
||||
at,
|
||||
errors,
|
||||
);
|
||||
|
||||
const name = entry.name;
|
||||
if (typeof name !== "string" || !PERMISSION_NAME_PATTERN.test(name)) {
|
||||
errors.push(`${at}.name must be a lowercase, dotted permission name`);
|
||||
} else {
|
||||
if (seen.has(name)) {
|
||||
errors.push(`${at}.name "${name}" is declared more than once`);
|
||||
}
|
||||
seen.add(name);
|
||||
|
||||
// Core groups first: a plugin naming one could gate a route on core
|
||||
// authority it was never given.
|
||||
const head = name.split(".")[0];
|
||||
if ((RESERVED_PERMISSION_PREFIXES as readonly string[]).includes(head)) {
|
||||
errors.push(
|
||||
`${at}.name "${name}" starts with the reserved core group "${head}". Permissions are registered as <pluginId>.<name>, so drop the prefix.`,
|
||||
);
|
||||
}
|
||||
// "ai" declaring "ai.use" would register as ai.ai.use, which is always
|
||||
// a mistake rather than an intent.
|
||||
if (pluginId && (name === pluginId || head === pluginId)) {
|
||||
errors.push(
|
||||
`${at}.name "${name}" already starts with this plugin's id. Permissions are registered as <pluginId>.<name>, so drop the prefix.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
requireString(entry.titleKey, `${at}.titleKey`, errors);
|
||||
requireString(entry.descriptionKey, `${at}.descriptionKey`, errors);
|
||||
|
||||
if (entry.defaultRoles === undefined) return;
|
||||
if (!Array.isArray(entry.defaultRoles)) {
|
||||
errors.push(`${at}.defaultRoles must be an array`);
|
||||
return;
|
||||
}
|
||||
entry.defaultRoles.forEach((role: unknown, roleIndex: number) => {
|
||||
if (
|
||||
typeof permission !== "string" ||
|
||||
!PERMISSION_PATTERN.test(permission)
|
||||
typeof role !== "string" ||
|
||||
!(SYSTEM_ROLE_NAMES as readonly string[]).includes(role)
|
||||
) {
|
||||
errors.push(
|
||||
`${where}.permissions[${index}] must be a dotted permission name`,
|
||||
`${at}.defaultRoles[${roleIndex}] must be one of: ${SYSTEM_ROLE_NAMES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (group.defaultForRole === undefined) return;
|
||||
if (!isPlainObject(group.defaultForRole)) {
|
||||
errors.push(
|
||||
`${where}.defaultForRole must be an object of role -> permissions`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const declared = new Set(
|
||||
Array.isArray(group.permissions)
|
||||
? (group.permissions as unknown[]).filter(
|
||||
(value): value is string => typeof value === "string",
|
||||
)
|
||||
: [],
|
||||
);
|
||||
|
||||
for (const [role, wanted] of Object.entries(group.defaultForRole)) {
|
||||
if (!Array.isArray(wanted)) {
|
||||
errors.push(`${where}.defaultForRole["${role}"] must be an array`);
|
||||
continue;
|
||||
}
|
||||
wanted.forEach((permission: unknown, index: number) => {
|
||||
if (typeof permission !== "string") {
|
||||
errors.push(
|
||||
`${where}.defaultForRole["${role}"][${index}] must be a string`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A plugin may only suggest defaults for permissions it declares itself.
|
||||
// Without this a manifest could add admin.* to any role at boot.
|
||||
if (!declared.has(permission)) {
|
||||
errors.push(
|
||||
`${where}.defaultForRole["${role}"][${index}] is "${permission}", which this plugin does not declare in contributes.permissionGroup.permissions`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateActions(actions: unknown, errors: string[]): void {
|
||||
@@ -774,8 +819,12 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
||||
};
|
||||
}
|
||||
|
||||
// Full ids: provides, providesSecret and actions all name permissions the
|
||||
// way an admin sees them, so they are compared against the qualified form.
|
||||
const declared = new Set(
|
||||
manifest.contributes?.permissionGroup?.permissions ?? [],
|
||||
(manifest.contributes?.permissions ?? []).map((permission) =>
|
||||
qualifyPermission(manifest.id, permission.name),
|
||||
),
|
||||
);
|
||||
|
||||
// A permission the catalog never sees is one no admin can grant, so the
|
||||
@@ -783,21 +832,21 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
||||
for (const provide of manifest.provides ?? []) {
|
||||
if (!declared.has(provide.permission)) {
|
||||
errors.push(
|
||||
`provides["${provide.service}"].permission "${provide.permission}" is not declared in contributes.permissionGroup.permissions`,
|
||||
`provides["${provide.service}"].permission "${provide.permission}" is not declared in contributes.permissions`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const secret of manifest.providesSecret ?? []) {
|
||||
if (!declared.has(secret.permission)) {
|
||||
errors.push(
|
||||
`providesSecret["${secret.key}"].permission "${secret.permission}" is not declared in contributes.permissionGroup.permissions`,
|
||||
`providesSecret["${secret.key}"].permission "${secret.permission}" is not declared in contributes.permissions`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const action of manifest.contributes?.actions ?? []) {
|
||||
if (action.permission && !declared.has(action.permission)) {
|
||||
errors.push(
|
||||
`contributes.actions["${action.id}"].permission "${action.permission}" is not declared in contributes.permissionGroup.permissions`,
|
||||
`contributes.actions["${action.id}"].permission "${action.permission}" is not declared in contributes.permissions`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,8 @@ export function createFakeContext(
|
||||
rbac: {
|
||||
// Passes everything through: a test asserting a permission gate should
|
||||
// drive PermissionManager, not this double.
|
||||
has: async () => true,
|
||||
hasFor: async () => true,
|
||||
require: () => ((_req, _res, next) => next()) as PluginMiddleware,
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user