@objectstack/lint 13.0.0 → 14.3.0

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/dist/index.js CHANGED
@@ -1298,6 +1298,88 @@ function validateCapabilityReferences(stack) {
1298
1298
  return findings;
1299
1299
  }
1300
1300
 
1301
+ // src/validate-approval-approvers.ts
1302
+ import { ApproverType, APPROVAL_NODE_TYPE } from "@objectstack/spec/automation";
1303
+ var APPROVAL_ROLE_NOT_MEMBERSHIP_TIER = "approval-role-not-membership-tier";
1304
+ var APPROVAL_APPROVER_TYPE_UNKNOWN = "approval-approver-type-unknown";
1305
+ var APPROVAL_ESCALATION_REASSIGN_NO_TARGET = "approval-escalation-reassign-no-target";
1306
+ var MEMBERSHIP_TIERS = /* @__PURE__ */ new Set(["owner", "admin", "member", "guest"]);
1307
+ var TYPE_FIX = {
1308
+ business_unit: "department",
1309
+ bu: "department"
1310
+ };
1311
+ function asArray13(v) {
1312
+ if (Array.isArray(v)) return v;
1313
+ if (v && typeof v === "object") {
1314
+ return Object.entries(v).map(([name, def]) => ({ name, ...def }));
1315
+ }
1316
+ return [];
1317
+ }
1318
+ function validateApprovalApprovers(stack) {
1319
+ const findings = [];
1320
+ if (!stack || typeof stack !== "object") return findings;
1321
+ const flows = asArray13(stack.flows);
1322
+ const validTypes = new Set(ApproverType.options);
1323
+ for (let fi = 0; fi < flows.length; fi++) {
1324
+ const flow = flows[fi];
1325
+ if (!flow || typeof flow !== "object") continue;
1326
+ const flowName = typeof flow.name === "string" ? flow.name : `(flow ${fi})`;
1327
+ const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
1328
+ for (let ni = 0; ni < nodes.length; ni++) {
1329
+ const node = nodes[ni];
1330
+ if (!node || node.type !== APPROVAL_NODE_TYPE) continue;
1331
+ const nodeId = typeof node.id === "string" ? node.id : `(node ${ni})`;
1332
+ const cfg = node.config ?? {};
1333
+ const approvers = Array.isArray(cfg.approvers) ? cfg.approvers : [];
1334
+ const where = `flow "${flowName}" \xB7 node "${nodeId}"`;
1335
+ for (let ai = 0; ai < approvers.length; ai++) {
1336
+ const a = approvers[ai];
1337
+ if (!a || typeof a !== "object") continue;
1338
+ const type = typeof a.type === "string" ? a.type : "";
1339
+ const value = typeof a.value === "string" ? a.value : "";
1340
+ const path = `flows[${fi}].nodes[${ni}].config.approvers[${ai}]`;
1341
+ if (type && !validTypes.has(type)) {
1342
+ const fix = TYPE_FIX[type];
1343
+ findings.push({
1344
+ severity: "warning",
1345
+ rule: APPROVAL_APPROVER_TYPE_UNKNOWN,
1346
+ where,
1347
+ path: `${path}.type`,
1348
+ message: `approver type '${type}' is not an ApproverType (${ApproverType.options.join(" | ")}).`,
1349
+ hint: fix ? `Use the spec value: { type: '${fix}', value: '${value}' }.` : `Pick one of the spec values; unmapped types degrade to an inert '${type}:${value}' literal at runtime.`
1350
+ });
1351
+ continue;
1352
+ }
1353
+ if (type === "role" && value && !MEMBERSHIP_TIERS.has(value.toLowerCase())) {
1354
+ findings.push({
1355
+ severity: "warning",
1356
+ rule: APPROVAL_ROLE_NOT_MEMBERSHIP_TIER,
1357
+ where,
1358
+ path: `${path}.value`,
1359
+ message: `approver { type: 'role', value: '${value}' } resolves against the better-auth org-membership tier (sys_member.role: owner/admin/member) \u2014 '${value}' is not a membership tier, so this approver matches nobody and the request stalls.`,
1360
+ hint: `If '${value}' is an org position, author { type: 'position', value: '${value}' } (resolved via sys_user_position, ADR-0090 D3). Keep type 'role' only for membership tiers (owner/admin/member).`
1361
+ });
1362
+ }
1363
+ }
1364
+ const escalation = cfg.escalation ?? null;
1365
+ if (escalation && typeof escalation === "object" && escalation.action === "reassign") {
1366
+ const target = typeof escalation.escalateTo === "string" ? escalation.escalateTo.trim() : "";
1367
+ if (!target) {
1368
+ findings.push({
1369
+ severity: "warning",
1370
+ rule: APPROVAL_ESCALATION_REASSIGN_NO_TARGET,
1371
+ where,
1372
+ path: `flows[${fi}].nodes[${ni}].config.escalation.escalateTo`,
1373
+ message: `escalation.action is 'reassign' but escalateTo is empty \u2014 at runtime the escalation degrades to a notify and the request stays with the original approvers.`,
1374
+ hint: `Set escalateTo to a position machine name (expanded via sys_user_position, ADR-0090 D3) or a specific user id, or change action to 'notify'.`
1375
+ });
1376
+ }
1377
+ }
1378
+ }
1379
+ }
1380
+ return findings;
1381
+ }
1382
+
1301
1383
  // src/validate-security-posture.ts
1302
1384
  import { describeAnchorForbiddenBits } from "@objectstack/spec/security";
1303
1385
  var SECURITY_OWD_UNSET = "security-owd-unset";
@@ -1306,7 +1388,9 @@ var SECURITY_EXTERNAL_WIDER = "security-external-wider-than-internal";
1306
1388
  var SECURITY_WILDCARD_VAMA = "security-wildcard-vama";
1307
1389
  var SECURITY_ANCHOR_HIGH_PRIVILEGE = "security-anchor-high-privilege";
1308
1390
  var SECURITY_ROLE_WORD = "security-role-word";
1391
+ var SECURITY_BOOK_AUDIENCE_UNKNOWN_SET = "security-book-audience-unknown-set";
1309
1392
  var SECURITY_PRIVATE_NO_READSCOPE = "security-private-no-readscope";
1393
+ var SECURITY_MASTER_DETAIL_UNGRANTED = "security-master-detail-ungranted";
1310
1394
  var CANONICAL_OWD = ["private", "public_read", "public_read_write", "controlled_by_parent"];
1311
1395
  var OWD_ALIAS_FIX = {
1312
1396
  read: "public_read",
@@ -1319,7 +1403,7 @@ var OWD_WIDTH = {
1319
1403
  public_read: 1,
1320
1404
  public_read_write: 2
1321
1405
  };
1322
- function asArray13(v) {
1406
+ function asArray14(v) {
1323
1407
  if (Array.isArray(v)) return v;
1324
1408
  if (v && typeof v === "object") {
1325
1409
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -1340,11 +1424,26 @@ function labelHasRoleWord(label) {
1340
1424
  if (typeof label !== "string") return false;
1341
1425
  return /\brole(s)?\b/i.test(label);
1342
1426
  }
1427
+ function refOf(def) {
1428
+ const r = def.reference ?? def.reference_to;
1429
+ return typeof r === "string" && r ? r : void 0;
1430
+ }
1431
+ function firstMasterDetailField(obj) {
1432
+ for (const f of asArray14(obj.fields)) {
1433
+ if (f.type === "master_detail") {
1434
+ return { name: String(f.name ?? "?"), parent: refOf(f) };
1435
+ }
1436
+ }
1437
+ return void 0;
1438
+ }
1439
+ function grantsObjectAccess(p) {
1440
+ return p.allowRead === true || p.allowCreate === true || p.allowEdit === true || p.allowDelete === true || p.viewAllRecords === true || p.modifyAllRecords === true;
1441
+ }
1343
1442
  function validateSecurityPosture(stack) {
1344
1443
  const findings = [];
1345
1444
  if (!stack || typeof stack !== "object") return findings;
1346
- const objects = asArray13(stack.objects);
1347
- const permissionSets = asArray13(stack.permissions);
1445
+ const objects = asArray14(stack.objects);
1446
+ const permissionSets = asArray14(stack.permissions);
1348
1447
  for (let i = 0; i < objects.length; i++) {
1349
1448
  const obj = objects[i];
1350
1449
  if (!obj || typeof obj !== "object") continue;
@@ -1461,10 +1560,10 @@ function validateSecurityPosture(stack) {
1461
1560
  if (!obj || typeof obj !== "object" || isSystemObject(obj)) continue;
1462
1561
  const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
1463
1562
  flagRole("object", obj.name, obj.label, `object "${objName}"`, `objects[${i}].name`);
1464
- for (const f of asArray13(obj.fields)) {
1563
+ for (const f of asArray14(obj.fields)) {
1465
1564
  flagRole("field", f.name, f.label, `field "${objName}.${String(f.name ?? "?")}"`, `objects[${i}].fields.${String(f.name ?? "?")}.name`);
1466
1565
  }
1467
- for (const [ai, action] of asArray13(obj.actions).entries()) {
1566
+ for (const [ai, action] of asArray14(obj.actions).entries()) {
1468
1567
  flagRole("action", action.name, action.label, `action "${objName}.${String(action.name ?? "?")}"`, `objects[${i}].actions[${ai}].name`);
1469
1568
  }
1470
1569
  }
@@ -1473,12 +1572,34 @@ function validateSecurityPosture(stack) {
1473
1572
  if (!ps || typeof ps !== "object") continue;
1474
1573
  flagRole("permission set", ps.name, ps.label, `permission set "${String(ps.name ?? i)}"`, `permissions[${i}].name`);
1475
1574
  }
1476
- for (const [i, pos] of asArray13(stack.positions).entries()) {
1575
+ for (const [i, pos] of asArray14(stack.positions).entries()) {
1477
1576
  flagRole("position", pos.name, pos.label, `position "${String(pos.name ?? i)}"`, `positions[${i}].name`);
1478
1577
  }
1479
- for (const [i, app] of asArray13(stack.apps).entries()) {
1578
+ for (const [i, app] of asArray14(stack.apps).entries()) {
1480
1579
  flagRole("app", app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`);
1481
1580
  }
1581
+ for (const [i, book] of asArray14(stack.books).entries()) {
1582
+ flagRole("book", book.name, book.label, `book "${String(book.name ?? i)}"`, `books[${i}].name`);
1583
+ }
1584
+ const stackSetNames = new Set(
1585
+ permissionSets.map((ps) => typeof ps.name === "string" ? ps.name : void 0).filter((n) => !!n)
1586
+ );
1587
+ for (const [i, book] of asArray14(stack.books).entries()) {
1588
+ const audience = book.audience;
1589
+ if (!audience || typeof audience !== "object") continue;
1590
+ const setName = audience.permissionSet;
1591
+ if (typeof setName !== "string" || setName.length === 0) continue;
1592
+ if (!stackSetNames.has(setName)) {
1593
+ findings.push({
1594
+ severity: "warning",
1595
+ rule: SECURITY_BOOK_AUDIENCE_UNKNOWN_SET,
1596
+ where: `book "${String(book.name ?? i)}"`,
1597
+ path: `books[${i}].audience.permissionSet`,
1598
+ message: `book audience references permission set "${setName}", which this stack does not declare. The runtime fails closed \u2014 no holder means NO reader can open the book.`,
1599
+ hint: `Gate the book on one of this package's own permission sets (ADR-0090 D9, e.g. its admin set), or fix the typo. Ignore if the set is intentionally provided by another installed package.`
1600
+ });
1601
+ }
1602
+ }
1482
1603
  const privateObjects = new Set(
1483
1604
  objects.filter((o) => o && typeof o === "object" && !isSystemObject(o)).filter((o) => {
1484
1605
  const owd = owdOf(o);
@@ -1507,11 +1628,43 @@ function validateSecurityPosture(stack) {
1507
1628
  }
1508
1629
  }
1509
1630
  }
1631
+ if (permissionSets.length > 0) {
1632
+ const wildcardGrantsAll = permissionSets.some(
1633
+ (ps) => grantsObjectAccess(ps.objects?.["*"] ?? {})
1634
+ );
1635
+ if (!wildcardGrantsAll) {
1636
+ const grantedObjects = /* @__PURE__ */ new Set();
1637
+ for (const ps of permissionSets) {
1638
+ const objectsMap = ps.objects && typeof ps.objects === "object" ? ps.objects : {};
1639
+ for (const [objName, rawPerm] of Object.entries(objectsMap)) {
1640
+ if (objName === "*") continue;
1641
+ if (grantsObjectAccess(rawPerm ?? {})) grantedObjects.add(objName);
1642
+ }
1643
+ }
1644
+ for (let i = 0; i < objects.length; i++) {
1645
+ const obj = objects[i];
1646
+ if (!obj || typeof obj !== "object" || isSystemObject(obj)) continue;
1647
+ const objName = typeof obj.name === "string" ? obj.name : "";
1648
+ if (!objName || grantedObjects.has(objName)) continue;
1649
+ const md = firstMasterDetailField(obj);
1650
+ if (!md) continue;
1651
+ const parentText = md.parent ? ` \u2192 "${md.parent}"` : "";
1652
+ findings.push({
1653
+ severity: "warning",
1654
+ rule: SECURITY_MASTER_DETAIL_UNGRANTED,
1655
+ where: `object "${objName}"`,
1656
+ path: `objects[${i}].fields.${md.name}`,
1657
+ message: `detail object "${objName}" (master_detail "${md.name}"${parentText}) has no object-level CRUD grant in any permission set. A master-detail child derives its RECORD-level access from the master (ADR-0055 controlled_by_parent), but object-level CRUD is a SEPARATE gate that is never derived \u2014 role-bound non-admin users are denied (403) before the parent-derived access is ever consulted (the silent "can't submit the subtable" trap).`,
1658
+ hint: `Grant "${objName}" in at least one permission set that already grants its master${md.parent ? ` "${md.parent}"` : ""} \u2014 e.g. permissions[i].objects.${objName} = { allowRead: true, allowCreate: true, allowEdit: true }. If no role should ever touch it (a pure system/internal table), name it sys_* or set isSystem: true.`
1659
+ });
1660
+ }
1661
+ }
1662
+ }
1510
1663
  return findings;
1511
1664
  }
1512
1665
 
1513
1666
  // src/build-access-matrix.ts
1514
- function asArray14(v) {
1667
+ function asArray15(v) {
1515
1668
  if (Array.isArray(v)) return v;
1516
1669
  if (v && typeof v === "object") {
1517
1670
  return Object.entries(v).map(([name, def]) => ({ name, ...def }));
@@ -1522,13 +1675,13 @@ function buildAccessMatrix(stack) {
1522
1675
  const entries = [];
1523
1676
  if (!stack || typeof stack !== "object") return { version: 1, entries };
1524
1677
  const owdByObject = /* @__PURE__ */ new Map();
1525
- for (const obj of asArray14(stack.objects)) {
1678
+ for (const obj of asArray15(stack.objects)) {
1526
1679
  const name = typeof obj.name === "string" ? obj.name : "";
1527
1680
  if (!name) continue;
1528
1681
  const owd = obj.sharingModel ?? obj.security?.sharingModel;
1529
1682
  if (typeof owd === "string") owdByObject.set(name, owd);
1530
1683
  }
1531
- for (const ps of asArray14(stack.permissions)) {
1684
+ for (const ps of asArray15(stack.permissions)) {
1532
1685
  const psName = typeof ps.name === "string" ? ps.name : "";
1533
1686
  if (!psName) continue;
1534
1687
  const objects = ps.objects && typeof ps.objects === "object" ? ps.objects : {};
@@ -1599,6 +1752,9 @@ function diffAccessMatrix(before, after) {
1599
1752
  return lines;
1600
1753
  }
1601
1754
  export {
1755
+ APPROVAL_APPROVER_TYPE_UNKNOWN,
1756
+ APPROVAL_ESCALATION_REASSIGN_NO_TARGET,
1757
+ APPROVAL_ROLE_NOT_MEMBERSHIP_TIER,
1602
1758
  CAPABILITY_REFERENCE_UNKNOWN,
1603
1759
  CHART_CONFIG_MISSING,
1604
1760
  CHART_FIELD_UNKNOWN,
@@ -1610,6 +1766,7 @@ export {
1610
1766
  MEASURE_AGGREGATE_INCOHERENT,
1611
1767
  PAGE_SOURCE_CLASSNAME,
1612
1768
  SECURITY_ANCHOR_HIGH_PRIVILEGE,
1769
+ SECURITY_BOOK_AUDIENCE_UNKNOWN_SET,
1613
1770
  SECURITY_EXTERNAL_WIDER,
1614
1771
  SECURITY_OWD_ALIAS,
1615
1772
  SECURITY_OWD_UNSET,
@@ -1630,6 +1787,7 @@ export {
1630
1787
  WIDGET_MEASURE_UNKNOWN,
1631
1788
  buildAccessMatrix,
1632
1789
  diffAccessMatrix,
1790
+ validateApprovalApprovers,
1633
1791
  validateCapabilityReferences,
1634
1792
  validateFormLayout,
1635
1793
  validateJsxPages,