@oondemand/oon-core-back 0.3.73 → 0.3.75

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.
@@ -0,0 +1,387 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const mongoose = require("mongoose");
5
+ const ctx = require("../context");
6
+ const GenericError = require("../errors/GenericError");
7
+ const { encrypt, decrypt } = require("../utils/instanceActivation");
8
+ const {
9
+ policy,
10
+ roleByCode,
11
+ rolePermissions,
12
+ legacyPermissions,
13
+ matchingRoleForLegacyUser,
14
+ } = require("./policy");
15
+
16
+ const ACCESS_STATUSES = ["invited", "active", "disabled", "revoked"];
17
+ const INVITATION_STATUSES = ["pending", "accepted", "revoked", "expired"];
18
+
19
+ function models() {
20
+ const accessSchema = new mongoose.Schema({
21
+ appCode: { type: String, required: true, index: true },
22
+ identityId: { type: String, index: true },
23
+ email: { type: String, required: true, lowercase: true, trim: true },
24
+ name: String,
25
+ roleCode: { type: String, required: true },
26
+ status: { type: String, enum: ACCESS_STATUSES, default: "invited", index: true },
27
+ origin: { type: String, default: "rbac" },
28
+ grantedBy: String,
29
+ mustChangePassword: { type: Boolean, default: false },
30
+ invitedAt: Date,
31
+ activatedAt: Date,
32
+ disabledAt: Date,
33
+ revokedAt: Date,
34
+ lastAccessAt: Date,
35
+ }, { timestamps: true, collection: "oon_core_user_access" });
36
+ accessSchema.index({ appCode: 1, email: 1 }, { unique: true });
37
+ accessSchema.index(
38
+ { appCode: 1, identityId: 1 },
39
+ { unique: true, partialFilterExpression: { identityId: { $type: "string" } } },
40
+ );
41
+
42
+ const invitationSchema = new mongoose.Schema({
43
+ appCode: { type: String, required: true, index: true },
44
+ accessId: { type: mongoose.Schema.Types.ObjectId, ref: "OonCoreUserAccess", required: true },
45
+ email: { type: String, required: true, lowercase: true, trim: true },
46
+ name: String,
47
+ roleCode: { type: String, required: true },
48
+ tokenHash: { type: String, required: true, unique: true, select: false },
49
+ tokenFingerprint: { type: String, required: true },
50
+ tokenEncrypted: { type: String, select: false },
51
+ tokenIv: { type: String, select: false },
52
+ tokenAuthTag: { type: String, select: false },
53
+ status: { type: String, enum: INVITATION_STATUSES, default: "pending", index: true },
54
+ expiresAt: { type: Date, required: true, index: true },
55
+ createdBy: String,
56
+ acceptedAt: Date,
57
+ revokedAt: Date,
58
+ }, { timestamps: true, collection: "oon_core_invitations" });
59
+ invitationSchema.index({ appCode: 1, email: 1, status: 1 });
60
+
61
+ const auditSchema = new mongoose.Schema({
62
+ appCode: { type: String, required: true, index: true },
63
+ event: { type: String, required: true, index: true },
64
+ actor: String,
65
+ target: String,
66
+ before: mongoose.Schema.Types.Mixed,
67
+ after: mongoose.Schema.Types.Mixed,
68
+ context: mongoose.Schema.Types.Mixed,
69
+ at: { type: Date, default: Date.now, index: true },
70
+ }, { timestamps: false, collection: "oon_core_access_audit" });
71
+
72
+ return {
73
+ UserAccess: mongoose.models.OonCoreUserAccess || mongoose.model("OonCoreUserAccess", accessSchema),
74
+ Invitation: mongoose.models.OonCoreInvitation || mongoose.model("OonCoreInvitation", invitationSchema),
75
+ Audit: mongoose.models.OonCoreAccessAudit || mongoose.model("OonCoreAccessAudit", auditSchema),
76
+ };
77
+ }
78
+
79
+ function appCode() {
80
+ return String(ctx.config.appCode || ctx.config.slug || "central").trim().toLowerCase();
81
+ }
82
+
83
+ function normalizeEmail(value) {
84
+ return String(value || "").trim().toLowerCase();
85
+ }
86
+
87
+ function identityKey(user) {
88
+ return String(user?.id || user?._id || user?.usuarioId || "").trim();
89
+ }
90
+
91
+ function actorKey(user) {
92
+ return identityKey(user) || normalizeEmail(user?.email) || "system";
93
+ }
94
+
95
+ function sanitizeSnapshot(value) {
96
+ if (!value) return value;
97
+ const plain = typeof value.toObject === "function" ? value.toObject() : { ...value };
98
+ delete plain.tokenHash;
99
+ delete plain.tokenEncrypted;
100
+ delete plain.tokenIv;
101
+ delete plain.tokenAuthTag;
102
+ delete plain.senha;
103
+ delete plain.password;
104
+ return plain;
105
+ }
106
+
107
+ async function audit(event, { actor, target, before, after, context } = {}) {
108
+ if (mongoose.connection.readyState !== 1) return null;
109
+ const { Audit } = models();
110
+ return Audit.create({
111
+ appCode: appCode(),
112
+ event,
113
+ actor: typeof actor === "string" ? actor : actorKey(actor),
114
+ target: typeof target === "string" ? target : target && (identityKey(target) || normalizeEmail(target.email)),
115
+ before: sanitizeSnapshot(before),
116
+ after: sanitizeSnapshot(after),
117
+ context,
118
+ at: new Date(),
119
+ });
120
+ }
121
+
122
+ function accessError(message, code = "RBAC_ACCESS_DENIED", statusCode = 403) {
123
+ return new GenericError(message, { statusCode, code });
124
+ }
125
+
126
+ async function findAccessForUser(user) {
127
+ if (mongoose.connection.readyState !== 1) return null;
128
+ const { UserAccess } = models();
129
+ const conditions = [];
130
+ const id = identityKey(user);
131
+ const email = normalizeEmail(user?.email);
132
+ if (id) conditions.push({ identityId: id });
133
+ if (email) conditions.push({ email });
134
+ if (!conditions.length) return null;
135
+ return UserAccess.findOne({ appCode: appCode(), $or: conditions });
136
+ }
137
+
138
+ async function migrateLegacyAccess(user) {
139
+ const currentPolicy = policy();
140
+ if (!currentPolicy.enabled || mongoose.connection.readyState !== 1) return null;
141
+ const role = matchingRoleForLegacyUser(user);
142
+ if (!role) return null;
143
+
144
+ const { UserAccess } = models();
145
+ const email = normalizeEmail(user?.email);
146
+ if (!email) return null;
147
+ const identityId = identityKey(user) || undefined;
148
+ const existing = await UserAccess.findOne({ appCode: appCode(), email });
149
+ if (existing) return existing;
150
+
151
+ const created = await UserAccess.create({
152
+ appCode: appCode(),
153
+ identityId,
154
+ email,
155
+ name: user?.nome || user?.name,
156
+ roleCode: role.code,
157
+ status: "active",
158
+ origin: "legacy-migration",
159
+ grantedBy: "system:migration",
160
+ mustChangePassword: false,
161
+ activatedAt: new Date(),
162
+ lastAccessAt: new Date(),
163
+ });
164
+ await audit("user.migrated", { actor: "system:migration", target: created, after: created });
165
+ return created;
166
+ }
167
+
168
+ async function resolveUserAccess(user, { touch = true } = {}) {
169
+ const currentPolicy = policy();
170
+ if (!currentPolicy.enabled) {
171
+ return {
172
+ mode: "legacy",
173
+ status: "active",
174
+ roleCode: user?.perfil || user?.tipo || "user",
175
+ permissions: legacyPermissions(user),
176
+ mustChangePassword: Boolean(user?.mustChangePassword || user?.firstAccessPending),
177
+ };
178
+ }
179
+
180
+ let access = await findAccessForUser(user);
181
+ if (!access) access = await migrateLegacyAccess(user);
182
+ if (!access) {
183
+ throw accessError("Usuário autenticado sem perfil local nesta Central.", "RBAC_LOCAL_ACCESS_REQUIRED");
184
+ }
185
+ if (["disabled", "revoked"].includes(access.status)) {
186
+ throw accessError("Acesso do usuário está desabilitado nesta Central.", "RBAC_USER_DISABLED");
187
+ }
188
+ if (access.status === "invited") {
189
+ throw accessError("Convite ainda não foi concluído.", "RBAC_INVITATION_PENDING");
190
+ }
191
+
192
+ const role = roleByCode(access.roleCode);
193
+ if (!role) {
194
+ throw accessError("O perfil associado ao usuário não existe mais na política da Central.", "RBAC_ROLE_NOT_FOUND");
195
+ }
196
+
197
+ const id = identityKey(user);
198
+ let changed = false;
199
+ if (id && access.identityId !== id) {
200
+ access.identityId = id;
201
+ changed = true;
202
+ }
203
+ if (user?.nome && access.name !== user.nome) {
204
+ access.name = user.nome;
205
+ changed = true;
206
+ }
207
+ if (touch && (!access.lastAccessAt || Date.now() - access.lastAccessAt.getTime() > 5 * 60 * 1000)) {
208
+ access.lastAccessAt = new Date();
209
+ changed = true;
210
+ }
211
+ if (changed) await access.save();
212
+
213
+ return {
214
+ mode: "rbac",
215
+ access,
216
+ status: access.status,
217
+ roleCode: access.roleCode,
218
+ role,
219
+ permissions: rolePermissions(role),
220
+ mustChangePassword: Boolean(access.mustChangePassword),
221
+ };
222
+ }
223
+
224
+ function hashToken(token) {
225
+ return crypto.createHash("sha256").update(String(token)).digest("hex");
226
+ }
227
+
228
+ function tokenFingerprint(token) {
229
+ return hashToken(token).slice(0, 12);
230
+ }
231
+
232
+ function protectInvitationToken(token) {
233
+ const protectedToken = encrypt(String(token));
234
+ return {
235
+ tokenEncrypted: protectedToken.credentialEncrypted,
236
+ tokenIv: protectedToken.credentialIv,
237
+ tokenAuthTag: protectedToken.credentialAuthTag,
238
+ };
239
+ }
240
+
241
+ function recoverInvitationToken(invitation) {
242
+ if (!invitation?.tokenEncrypted || !invitation?.tokenIv || !invitation?.tokenAuthTag) {
243
+ return null;
244
+ }
245
+ return decrypt({
246
+ credentialEncrypted: invitation.tokenEncrypted,
247
+ credentialIv: invitation.tokenIv,
248
+ credentialAuthTag: invitation.tokenAuthTag,
249
+ });
250
+ }
251
+
252
+ function invitationExpiry() {
253
+ return new Date(Date.now() + policy().invitationTtlHours * 60 * 60 * 1000);
254
+ }
255
+
256
+ async function revokePendingInvitations(email, actor) {
257
+ const { Invitation } = models();
258
+ const pending = await Invitation.find({ appCode: appCode(), email: normalizeEmail(email), status: "pending" });
259
+ for (const invitation of pending) {
260
+ invitation.status = "revoked";
261
+ invitation.revokedAt = new Date();
262
+ await invitation.save();
263
+ await audit("invitation.revoked", { actor, target: invitation.email, before: invitation });
264
+ }
265
+ }
266
+
267
+ async function createInvitation({ email, name, roleCode, actor }) {
268
+ if (mongoose.connection.readyState !== 1) {
269
+ throw new GenericError("Banco de dados indisponível para criar convites.", { statusCode: 503 });
270
+ }
271
+ const role = roleByCode(roleCode);
272
+ if (!role) throw new GenericError("Perfil inválido.", { statusCode: 422, code: "RBAC_ROLE_NOT_FOUND" });
273
+ const normalizedEmail = normalizeEmail(email);
274
+ if (!/^\S+@\S+\.\S+$/.test(normalizedEmail)) {
275
+ throw new GenericError("Informe um e-mail válido.", { statusCode: 422 });
276
+ }
277
+
278
+ const { UserAccess, Invitation } = models();
279
+ await revokePendingInvitations(normalizedEmail, actor);
280
+ let access = await UserAccess.findOne({ appCode: appCode(), email: normalizedEmail });
281
+ const before = sanitizeSnapshot(access);
282
+ if (!access) {
283
+ access = new UserAccess({ appCode: appCode(), email: normalizedEmail });
284
+ }
285
+ access.name = name || access.name;
286
+ access.roleCode = role.code;
287
+ access.status = "invited";
288
+ access.origin = "invitation";
289
+ access.grantedBy = actorKey(actor);
290
+ access.mustChangePassword = true;
291
+ access.invitedAt = new Date();
292
+ access.disabledAt = undefined;
293
+ access.revokedAt = undefined;
294
+ await access.save();
295
+
296
+ const token = crypto.randomBytes(32).toString("base64url");
297
+ const invitation = await Invitation.create({
298
+ appCode: appCode(),
299
+ accessId: access._id,
300
+ email: normalizedEmail,
301
+ name: access.name,
302
+ roleCode: role.code,
303
+ tokenHash: hashToken(token),
304
+ tokenFingerprint: tokenFingerprint(token),
305
+ ...protectInvitationToken(token),
306
+ expiresAt: invitationExpiry(),
307
+ status: "pending",
308
+ createdBy: actorKey(actor),
309
+ });
310
+ await audit("invitation.created", { actor, target: normalizedEmail, before, after: access, context: { invitationId: String(invitation._id) } });
311
+ return { access, invitation, token };
312
+ }
313
+
314
+ async function findInvitationByToken(token) {
315
+ if (mongoose.connection.readyState !== 1) return null;
316
+ const { Invitation } = models();
317
+ const invitation = await Invitation.findOne({ appCode: appCode(), tokenHash: hashToken(token) }).select("+tokenHash");
318
+ if (!invitation) return null;
319
+ if (invitation.status === "pending" && invitation.expiresAt.getTime() <= Date.now()) {
320
+ invitation.status = "expired";
321
+ await invitation.save();
322
+ await audit("invitation.expired", { actor: "system", target: invitation.email, context: { invitationId: String(invitation._id) } });
323
+ }
324
+ return invitation;
325
+ }
326
+
327
+ async function recoverPendingInvitation(id) {
328
+ if (mongoose.connection.readyState !== 1) return null;
329
+ const { Invitation } = models();
330
+ const invitation = await Invitation.findOne({ _id: id, appCode: appCode() })
331
+ .select("+tokenEncrypted +tokenIv +tokenAuthTag");
332
+ if (!invitation) return null;
333
+ if (invitation.status === "pending" && invitation.expiresAt.getTime() <= Date.now()) {
334
+ invitation.status = "expired";
335
+ await invitation.save();
336
+ return { invitation, token: null };
337
+ }
338
+ if (invitation.status !== "pending") return { invitation, token: null };
339
+ return { invitation, token: recoverInvitationToken(invitation) };
340
+ }
341
+
342
+ async function activeAdminCount() {
343
+ const adminCodes = policy().roles
344
+ .filter((role) => role.admin === true || role.permissions?.includes("*"))
345
+ .map((role) => role.code);
346
+ if (!adminCodes.length || mongoose.connection.readyState !== 1) return 0;
347
+ const { UserAccess } = models();
348
+ return UserAccess.countDocuments({ appCode: appCode(), status: "active", roleCode: { $in: adminCodes } });
349
+ }
350
+
351
+ async function assertNotLastAdmin(access, nextRoleCode) {
352
+ const currentRole = roleByCode(access.roleCode);
353
+ const nextRole = nextRoleCode ? roleByCode(nextRoleCode) : null;
354
+ const currentAdmin = Boolean(currentRole?.admin || currentRole?.permissions?.includes("*"));
355
+ const nextAdmin = Boolean(nextRole?.admin || nextRole?.permissions?.includes("*"));
356
+ if (currentAdmin && !nextAdmin && (await activeAdminCount()) <= 1) {
357
+ throw new GenericError("Não é possível remover ou desabilitar o último administrador da Central.", {
358
+ statusCode: 409,
359
+ code: "RBAC_LAST_ADMIN_PROTECTED",
360
+ });
361
+ }
362
+ }
363
+
364
+ module.exports = {
365
+ ACCESS_STATUSES,
366
+ INVITATION_STATUSES,
367
+ models,
368
+ appCode,
369
+ normalizeEmail,
370
+ identityKey,
371
+ actorKey,
372
+ sanitizeSnapshot,
373
+ audit,
374
+ findAccessForUser,
375
+ migrateLegacyAccess,
376
+ resolveUserAccess,
377
+ hashToken,
378
+ tokenFingerprint,
379
+ protectInvitationToken,
380
+ recoverInvitationToken,
381
+ createInvitation,
382
+ revokePendingInvitations,
383
+ findInvitationByToken,
384
+ recoverPendingInvitation,
385
+ activeAdminCount,
386
+ assertNotLastAdmin,
387
+ };
package/src/index.js CHANGED
@@ -7,6 +7,8 @@ const define = require("./core/define");
7
7
  const fields = require("./core/fields");
8
8
  const registry = require("./core/registry");
9
9
  const GenericError = require("./core/errors/GenericError");
10
+ const { requirePermission } = require("./core/middlewares/permissions");
11
+ const rbacPolicy = require("./core/rbac/policy");
10
12
  const omieModule = require("./modules/omie");
11
13
  const integrations = require("./modules/integrations");
12
14
  const appManifest = require("./manifest/appManifest");
@@ -30,6 +32,12 @@ module.exports = {
30
32
  defineValidation: define.defineValidation,
31
33
  defineTrigger: define.defineTrigger,
32
34
 
35
+ requirePermission,
36
+ CORE_PERMISSIONS: rbacPolicy.CORE_PERMISSIONS,
37
+ rbacPolicy: rbacPolicy.policy,
38
+ roleByCode: rbacPolicy.roleByCode,
39
+ permissionGranted: rbacPolicy.granted,
40
+
33
41
  APP_MANIFEST_FILENAME: appManifest.APP_MANIFEST_FILENAME,
34
42
  APP_MANIFEST_SCHEMA_VERSION: appManifest.APP_MANIFEST_SCHEMA_VERSION,
35
43
  SUPPORTED_APP_KINDS: appManifest.SUPPORTED_APP_KINDS,
@@ -4,22 +4,13 @@ const fs = require("node:fs");
4
4
  const path = require("node:path");
5
5
  const ctx = require("../core/context");
6
6
  const corePackage = require("../../package.json");
7
+ const { validateRbac } = require("./rbacManifest");
7
8
 
8
9
  const APP_MANIFEST_FILENAME = "central.app.json";
9
10
  const APP_MANIFEST_SCHEMA_VERSION = 1;
10
- const SUPPORTED_APP_KINDS = Object.freeze([
11
- "root-central",
12
- "member-central",
13
- "portal-cockpit",
14
- ]);
11
+ const SUPPORTED_APP_KINDS = Object.freeze(["root-central", "member-central", "portal-cockpit"]);
15
12
  const SUPPORTED_MODULES = Object.freeze([
16
- "collections",
17
- "documents",
18
- "pipelines",
19
- "integrations",
20
- "omie",
21
- "assistants",
22
- "currencies",
13
+ "collections", "documents", "pipelines", "integrations", "omie", "assistants", "currencies",
23
14
  ]);
24
15
  const APP_KIND_ROLE = Object.freeze({
25
16
  "root-central": "root",
@@ -41,22 +32,17 @@ class AppManifestError extends Error {
41
32
  function isPlainObject(value) {
42
33
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
43
34
  }
44
-
45
35
  function hasOwn(object, key) {
46
36
  return Object.prototype.hasOwnProperty.call(object, key);
47
37
  }
48
-
49
38
  function addIssue(issues, fieldPath, message) {
50
39
  issues.push({ path: fieldPath, message });
51
40
  }
52
-
53
41
  function parseVersion(value) {
54
42
  if (typeof value !== "string") return null;
55
43
  const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/);
56
- if (!match) return null;
57
- return match.slice(1, 4).map(Number);
44
+ return match ? match.slice(1, 4).map(Number) : null;
58
45
  }
59
-
60
46
  function compareVersions(left, right) {
61
47
  for (let index = 0; index < 3; index += 1) {
62
48
  if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1;
@@ -73,50 +59,28 @@ function validateCompatibility(compatibility, issues, coreVersion) {
73
59
  addIssue(issues, "compatibility.core", "deve ser um objeto.");
74
60
  return;
75
61
  }
76
-
77
62
  const allowedKeys = new Set(["minVersion", "maxVersionExclusive"]);
78
63
  for (const key of Object.keys(compatibility.core)) {
79
64
  if (!allowedKeys.has(key)) addIssue(issues, `compatibility.core.${key}`, "propriedade não suportada.");
80
65
  }
81
-
82
66
  const minVersion = parseVersion(compatibility.core.minVersion);
83
67
  const maxVersion = hasOwn(compatibility.core, "maxVersionExclusive")
84
68
  ? parseVersion(compatibility.core.maxVersionExclusive)
85
69
  : null;
86
-
87
- if (!minVersion) {
88
- addIssue(issues, "compatibility.core.minVersion", "deve usar versão semântica, por exemplo 0.3.45.");
89
- }
70
+ if (!minVersion) addIssue(issues, "compatibility.core.minVersion", "deve usar versão semântica, por exemplo 0.3.45.");
90
71
  if (hasOwn(compatibility.core, "maxVersionExclusive") && !maxVersion) {
91
- addIssue(
92
- issues,
93
- "compatibility.core.maxVersionExclusive",
94
- "deve usar versão semântica, por exemplo 0.4.0.",
95
- );
72
+ addIssue(issues, "compatibility.core.maxVersionExclusive", "deve usar versão semântica, por exemplo 0.4.0.");
96
73
  }
97
74
  if (minVersion && maxVersion && compareVersions(minVersion, maxVersion) >= 0) {
98
- addIssue(
99
- issues,
100
- "compatibility.core",
101
- "minVersion deve ser menor que maxVersionExclusive.",
102
- );
75
+ addIssue(issues, "compatibility.core", "minVersion deve ser menor que maxVersionExclusive.");
103
76
  }
104
-
105
77
  const installedVersion = parseVersion(coreVersion);
106
78
  if (!installedVersion || !minVersion) return;
107
79
  if (compareVersions(installedVersion, minVersion) < 0) {
108
- addIssue(
109
- issues,
110
- "compatibility.core.minVersion",
111
- `requer OonCore ${compatibility.core.minVersion} ou superior; instalado ${coreVersion}.`,
112
- );
80
+ addIssue(issues, "compatibility.core.minVersion", `requer OonCore ${compatibility.core.minVersion} ou superior; instalado ${coreVersion}.`);
113
81
  }
114
82
  if (maxVersion && compareVersions(installedVersion, maxVersion) >= 0) {
115
- addIssue(
116
- issues,
117
- "compatibility.core.maxVersionExclusive",
118
- `requer OonCore anterior a ${compatibility.core.maxVersionExclusive}; instalado ${coreVersion}.`,
119
- );
83
+ addIssue(issues, "compatibility.core.maxVersionExclusive", `requer OonCore anterior a ${compatibility.core.maxVersionExclusive}; instalado ${coreVersion}.`);
120
84
  }
121
85
  }
122
86
 
@@ -133,10 +97,7 @@ function validateActivation(activation, issues) {
133
97
  if (hasOwn(activation, "enabled") && typeof activation.enabled !== "boolean") {
134
98
  addIssue(issues, "activation.enabled", "deve ser booleano.");
135
99
  }
136
- if (
137
- hasOwn(activation, "configurationVersion")
138
- && (!Number.isInteger(activation.configurationVersion) || activation.configurationVersion < 1)
139
- ) {
100
+ if (hasOwn(activation, "configurationVersion") && (!Number.isInteger(activation.configurationVersion) || activation.configurationVersion < 1)) {
140
101
  addIssue(issues, "activation.configurationVersion", "deve ser inteiro maior ou igual a 1.");
141
102
  }
142
103
  if (hasOwn(activation, "fields") && !Array.isArray(activation.fields)) {
@@ -147,84 +108,50 @@ function validateActivation(activation, issues) {
147
108
  function validateAppManifest(input, { coreVersion = corePackage.version } = {}) {
148
109
  const issues = [];
149
110
  if (!isPlainObject(input)) {
150
- throw new AppManifestError("central.app.json inválido.", [
151
- { path: "$", message: "deve conter um objeto JSON." },
152
- ]);
111
+ throw new AppManifestError("central.app.json inválido.", [{ path: "$", message: "deve conter um objeto JSON." }]);
153
112
  }
154
-
155
113
  const allowedTopLevel = new Set([
156
- "$schema",
157
- "schemaVersion",
158
- "id",
159
- "name",
160
- "slug",
161
- "appKind",
162
- "modules",
163
- "capabilities",
164
- "compatibility",
165
- "activation",
114
+ "$schema", "schemaVersion", "id", "name", "slug", "appKind", "modules", "capabilities",
115
+ "compatibility", "activation", "rbac",
166
116
  ]);
167
117
  for (const key of Object.keys(input)) {
168
118
  if (!allowedTopLevel.has(key)) addIssue(issues, key, "propriedade não suportada.");
169
119
  }
170
-
171
120
  if (input.schemaVersion !== APP_MANIFEST_SCHEMA_VERSION) {
172
- addIssue(
173
- issues,
174
- "schemaVersion",
175
- `versão não suportada. Use ${APP_MANIFEST_SCHEMA_VERSION}.`,
176
- );
121
+ addIssue(issues, "schemaVersion", `versão não suportada. Use ${APP_MANIFEST_SCHEMA_VERSION}.`);
177
122
  }
178
123
  if (typeof input.id !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(input.id)) {
179
124
  addIssue(issues, "id", "deve usar letras minúsculas, números, ponto, hífen ou sublinhado.");
180
125
  }
181
- if (typeof input.name !== "string" || !input.name.trim()) {
182
- addIssue(issues, "name", "é obrigatório e deve ser texto não vazio.");
183
- }
126
+ if (typeof input.name !== "string" || !input.name.trim()) addIssue(issues, "name", "é obrigatório e deve ser texto não vazio.");
184
127
  if (typeof input.slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.slug)) {
185
128
  addIssue(issues, "slug", "deve usar slug em letras minúsculas separado por hífen.");
186
129
  }
187
130
  if (!SUPPORTED_APP_KINDS.includes(input.appKind)) {
188
131
  addIssue(issues, "appKind", `deve ser um de: ${SUPPORTED_APP_KINDS.join(", ")}.`);
189
132
  }
190
-
191
133
  if (!isPlainObject(input.modules)) {
192
134
  addIssue(issues, "modules", "deve ser um objeto.");
193
135
  } else {
194
136
  for (const key of Object.keys(input.modules)) {
195
- if (!SUPPORTED_MODULES.includes(key)) {
196
- addIssue(issues, `modules.${key}`, `módulo não suportado. Use: ${SUPPORTED_MODULES.join(", ")}.`);
197
- } else if (typeof input.modules[key] !== "boolean") {
198
- addIssue(issues, `modules.${key}`, "deve ser booleano.");
199
- }
137
+ if (!SUPPORTED_MODULES.includes(key)) addIssue(issues, `modules.${key}`, `módulo não suportado. Use: ${SUPPORTED_MODULES.join(", ")}.`);
138
+ else if (typeof input.modules[key] !== "boolean") addIssue(issues, `modules.${key}`, "deve ser booleano.");
200
139
  }
201
140
  }
202
-
203
141
  if (!Array.isArray(input.capabilities)) {
204
142
  addIssue(issues, "capabilities", "deve ser uma lista.");
205
143
  } else {
206
144
  const seen = new Set();
207
145
  input.capabilities.forEach((capability, index) => {
208
- if (
209
- typeof capability !== "string"
210
- || !/^[a-z][a-z0-9]*(?:[._:-][a-z0-9][a-z0-9-]*)+$/.test(capability)
211
- ) {
212
- addIssue(
213
- issues,
214
- `capabilities[${index}]`,
215
- "deve ser um identificador namespaced, por exemplo core.collections.",
216
- );
217
- } else if (seen.has(capability)) {
218
- addIssue(issues, `capabilities[${index}]`, `capability duplicada: ${capability}.`);
219
- } else {
220
- seen.add(capability);
221
- }
146
+ if (typeof capability !== "string" || !/^[a-z][a-z0-9]*(?:[._:-][a-z0-9][a-z0-9-]*)+$/.test(capability)) {
147
+ addIssue(issues, `capabilities[${index}]`, "deve ser um identificador namespaced, por exemplo core.collections.");
148
+ } else if (seen.has(capability)) addIssue(issues, `capabilities[${index}]`, `capability duplicada: ${capability}.`);
149
+ else seen.add(capability);
222
150
  });
223
151
  }
224
-
225
152
  validateCompatibility(input.compatibility, issues, coreVersion);
226
153
  validateActivation(input.activation, issues);
227
-
154
+ validateRbac(input.rbac, issues);
228
155
  if (issues.length) throw new AppManifestError("central.app.json inválido.", issues);
229
156
  return JSON.parse(JSON.stringify(input));
230
157
  }
@@ -233,9 +160,7 @@ function resolveAppManifestPath(cwd = process.cwd()) {
233
160
  const absolute = path.resolve(cwd);
234
161
  const candidates = [path.join(absolute, APP_MANIFEST_FILENAME)];
235
162
  const parent = path.dirname(absolute);
236
- if (["backend", "frontend"].includes(path.basename(absolute))) {
237
- candidates.push(path.join(parent, APP_MANIFEST_FILENAME));
238
- }
163
+ if (["backend", "frontend"].includes(path.basename(absolute))) candidates.push(path.join(parent, APP_MANIFEST_FILENAME));
239
164
  return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
240
165
  }
241
166
 
@@ -254,6 +179,7 @@ function appManifestToConfig(manifest, manifestPath) {
254
179
  },
255
180
  modules: { ...manifest.modules },
256
181
  capabilities: [...manifest.capabilities],
182
+ rbac: manifest.rbac ? JSON.parse(JSON.stringify(manifest.rbac)) : undefined,
257
183
  appManifest: manifest,
258
184
  appManifestPath: manifestPath,
259
185
  };
@@ -266,17 +192,13 @@ function registerAppManifest(input, options = {}) {
266
192
  }
267
193
 
268
194
  function loadAppManifest(cwdOrFile = process.cwd(), options = {}) {
269
- const manifestPath = cwdOrFile.endsWith?.(".json")
270
- ? path.resolve(cwdOrFile)
271
- : resolveAppManifestPath(cwdOrFile);
195
+ const manifestPath = cwdOrFile.endsWith?.(".json") ? path.resolve(cwdOrFile) : resolveAppManifestPath(cwdOrFile);
272
196
  let input;
273
197
  try {
274
198
  input = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
275
199
  } catch (error) {
276
200
  if (error.code === "ENOENT") throw error;
277
- throw new AppManifestError("Não foi possível ler central.app.json.", [
278
- { path: "$", message: error.message },
279
- ], { cause: error });
201
+ throw new AppManifestError("Não foi possível ler central.app.json.", [{ path: "$", message: error.message }], { cause: error });
280
202
  }
281
203
  return registerAppManifest(input, { ...options, manifestPath });
282
204
  }