@objectstack/plugin-approvals 17.0.0 → 17.1.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.mjs CHANGED
@@ -1384,10 +1384,45 @@ var SysApprovalRequest = ObjectSchema.create({
1384
1384
  },
1385
1385
  fields: {
1386
1386
  id: Field.text({ label: "Request ID", required: true, readonly: true, group: "System" }),
1387
+ // ⚠️ MEASURED DEFECT, cloud#1395 — read this before trusting the column.
1388
+ //
1389
+ // An approval request DOES belong to an organization: it is read through the
1390
+ // organization wall by the approvals inbox, and on a shared-database
1391
+ // deployment a row carrying no organization is not filtered BY that wall —
1392
+ // it is either invisible to everyone or visible to everyone, decided by
1393
+ // whatever filter each surface happens to apply rather than by the data.
1394
+ //
1395
+ // The value is resolved from the ACTING CONTEXT only (`openNodeRequest`'s
1396
+ // `ctxOrg`), so it is NULL whenever the flow that opened the request ran
1397
+ // without one — every schedule / time-relative / api triggered run, none of
1398
+ // which sets a tenant. On a walled single-database HotCRM SaaS boot this
1399
+ // measured 27 of 27 rows org-less, each naming an `object_name` /
1400
+ // `record_id` owned by a specific customer.
1401
+ //
1402
+ // ⛔ Do NOT read that as "platform tables do not carry an organization".
1403
+ // `sys_audit_log` (1669 rows) was correctly attributed on the SAME boot,
1404
+ // because its writer takes the organization from the RECORD the row is
1405
+ // about, with the session only as fallback (plugin-audit
1406
+ // `resolveRecordOrganizationField`, #8707 honouring #8287's ruling). Two
1407
+ // writers read the actor; a third reads the subject. That disagreement is
1408
+ // the defect.
1409
+ //
1410
+ // Which of the two a side-table row should follow is an open contract
1411
+ // question on cloud#1395 — the audit resolver is scope-pinned to audit
1412
+ // stamping by the #8778 ruling, so this writer needs its own. The same
1413
+ // `ctxOrg` also stamps `sys_approval_action` and `sys_approval_approver`,
1414
+ // so all three move together.
1387
1415
  organization_id: Field.lookup("sys_organization", {
1388
1416
  label: "Organization",
1389
1417
  required: false,
1390
1418
  group: "System",
1419
+ // ⛔ String unchanged on purpose: it is extracted into the generated i18n
1420
+ // bundles (`translations/*.objects.generated.ts`, as `help`), so rewording
1421
+ // it is a translation-regeneration change and not a comment. The
1422
+ // correction it needs — it claims a propagation that measurably does not
1423
+ // happen, and says "Tenant" where ADR-0120 §Terminology requires
1424
+ // "organization" — rides the cloud#1395 write-side fix, which rewrites the
1425
+ // sentence and regenerates the four locales in one pass.
1391
1426
  description: "Tenant that owns this approval request (propagated from submitter context)"
1392
1427
  }),
1393
1428
  process_name: Field.text({
@@ -1531,6 +1566,28 @@ var SysApprovalRequest = ObjectSchema.create({
1531
1566
  // approving, rejecting, or reassigning it to a real approver. `viewer` is
1532
1567
  // attached by getRequest/listRequests; where it is absent the predicate fails
1533
1568
  // closed.
1569
+ //
1570
+ // Every predicate below is guarded for the SPARSE action face (#8990). This
1571
+ // binding is a list row or a record read carrying only what the caller
1572
+ // projected, and CEL aborts the whole expression at key resolution — so the
1573
+ // unguarded `record.viewer.can_act` faulted (`No such key: viewer`) on any
1574
+ // row without the block, and the button silently vanished, indistinguishable
1575
+ // from "the gate said no". `materializeDeclaredFields`'s doc comment in
1576
+ // `@objectstack/objectql` is the canonical statement of the guard rule; this
1577
+ // file follows it and does not restate it.
1578
+ //
1579
+ // `viewer` is a NESTED block, which needs one measurement the canonical rule
1580
+ // does not spell out. Measured against the `@objectstack/formula` CEL engine:
1581
+ // `has(record.viewer) && record.viewer != null && record.viewer.can_act`
1582
+ // still FAULTS on `{viewer: {}}` (`No such key: can_act`) and on
1583
+ // `{viewer: {can_act: null}}` (`Logical operator requires bool operands`).
1584
+ // Guarding the LEAF instead — `has(record.viewer) &&
1585
+ // has(record.viewer.can_act) && record.viewer.can_act == true` — is total
1586
+ // over every binding AND subsumes the parent `!= null` half, because `has()`
1587
+ // on a path whose parent is null answers `false` rather than faulting. So the
1588
+ // leaf `has()` plus the `== true` comparison is the MINIMAL safe form here,
1589
+ // not a longer one: `== true` is load-bearing (a bare truthy read of a null
1590
+ // leaf faults the logical operator), the parent `!= null` is not.
1534
1591
  actions: [
1535
1592
  {
1536
1593
  name: "approval_approve",
@@ -1550,7 +1607,7 @@ var SysApprovalRequest = ObjectSchema.create({
1550
1607
  // string[]`; the decision route persists them on `sys_approval_action`.
1551
1608
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1552
1609
  ],
1553
- visible: "record.viewer.can_act || record.viewer.can_override",
1610
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true",
1554
1611
  locations: ["record_section", "list_item"],
1555
1612
  successMessage: "Approved.",
1556
1613
  refreshAfter: true
@@ -1578,7 +1635,7 @@ var SysApprovalRequest = ObjectSchema.create({
1578
1635
  { name: "comment", label: "Comment", type: "textarea", required: false },
1579
1636
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1580
1637
  ],
1581
- visible: "record.viewer.can_act || record.viewer.can_override",
1638
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true",
1582
1639
  locations: ["record_section", "list_item"],
1583
1640
  successMessage: "Rejected.",
1584
1641
  refreshAfter: true
@@ -1599,7 +1656,7 @@ var SysApprovalRequest = ObjectSchema.create({
1599
1656
  { field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
1600
1657
  { name: "comment", label: "Comment", type: "textarea", required: false }
1601
1658
  ],
1602
- visible: "record.viewer.can_act || record.viewer.can_override",
1659
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true",
1603
1660
  locations: ["record_section"],
1604
1661
  successMessage: "Reassigned.",
1605
1662
  refreshAfter: true
@@ -1618,7 +1675,7 @@ var SysApprovalRequest = ObjectSchema.create({
1618
1675
  params: [
1619
1676
  { name: "comment", label: "Reason", type: "textarea", required: false }
1620
1677
  ],
1621
- visible: "record.viewer.can_act",
1678
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
1622
1679
  locations: ["record_section"],
1623
1680
  successMessage: "Sent back for revision.",
1624
1681
  refreshAfter: true
@@ -1633,7 +1690,7 @@ var SysApprovalRequest = ObjectSchema.create({
1633
1690
  params: [
1634
1691
  { name: "comment", label: "What do you need?", type: "textarea", required: true }
1635
1692
  ],
1636
- visible: "record.viewer.can_act",
1693
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
1637
1694
  locations: ["record_section"],
1638
1695
  successMessage: "Information requested.",
1639
1696
  refreshAfter: true
@@ -1654,7 +1711,7 @@ var SysApprovalRequest = ObjectSchema.create({
1654
1711
  params: [
1655
1712
  { name: "comment", label: "Note", type: "textarea", required: false }
1656
1713
  ],
1657
- visible: 'record.status == "pending" && record.viewer.is_submitter',
1714
+ visible: 'has(record.status) && record.status == "pending" && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1658
1715
  locations: ["record_section"],
1659
1716
  successMessage: "Reminder sent.",
1660
1717
  refreshAfter: true
@@ -1674,7 +1731,7 @@ var SysApprovalRequest = ObjectSchema.create({
1674
1731
  ],
1675
1732
  // Recall applies while the request is live for the submitter — pending
1676
1733
  // (withdraw) or returned (abandon the revision instead of resubmitting).
1677
- visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
1734
+ visible: 'has(record.status) && (record.status == "pending" || record.status == "returned") && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1678
1735
  locations: ["record_section"],
1679
1736
  successMessage: "Recalled.",
1680
1737
  refreshAfter: true
@@ -1689,7 +1746,7 @@ var SysApprovalRequest = ObjectSchema.create({
1689
1746
  params: [
1690
1747
  { name: "comment", label: "What changed?", type: "textarea", required: false }
1691
1748
  ],
1692
- visible: 'record.status == "returned" && record.viewer.is_submitter',
1749
+ visible: 'has(record.status) && record.status == "returned" && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1693
1750
  locations: ["record_section"],
1694
1751
  successMessage: "Resubmitted.",
1695
1752
  refreshAfter: true
@@ -2335,6 +2392,9 @@ var _ApprovalService = class _ApprovalService {
2335
2392
  this.messaging = opts.messaging;
2336
2393
  this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
2337
2394
  this.tenancyPosture = opts.tenancyPosture;
2395
+ this.recordReaderVisibleObjects = new Set(
2396
+ (Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
2397
+ );
2338
2398
  }
2339
2399
  /** Attach (or replace) the ADR-0105 D9 posture provider. */
2340
2400
  attachTenancyPosture(provider) {
@@ -2836,9 +2896,48 @@ var _ApprovalService = class _ApprovalService {
2836
2896
  * Position holders (ADR-0090 D3): `sys_user_position` is the platform-owned
2837
2897
  * assignment table, keyed by the position's machine name (ADR-0057 D4),
2838
2898
  * unioned with the better-auth membership string (`sys_member.role`) as a
2839
- * transition source — the same semantics as `PositionGraphService` in
2840
- * `plugin-sharing`, so an approval routes to exactly the users the sharing
2841
- * engine would expand for the same position.
2899
+ * transition source.
2900
+ *
2901
+ * ⚠️ This is a ROUTING read (approver slates and escalation targets), and it
2902
+ * is deliberately NOT the same read as `PositionGraphService` in
2903
+ * `plugin-sharing`, whatever the shared method name suggests. Both answer
2904
+ * "who holds position P"; this one reads the directory RAW — neither the
2905
+ * ADR-0091 D2 validity window nor the `sys_position.active` catalogue flag is
2906
+ * applied. Maintainer ruling, 2026-08-15 (#8710, inheriting #8613), verbatim:
2907
+ *
2908
+ * > Access-conferring paths filter deactivated positions; addressing paths
2909
+ * > do not.
2910
+ *
2911
+ * Routing is an addressing path, so dropping a holder here is fail-OPEN, not
2912
+ * fail-closed: an expansion that comes back empty does not narrow the slate,
2913
+ * it falls through to the literal `position:` slot no user can ever act on —
2914
+ * the permanently stuck request of #3807 / #3424. A step routing to nobody is
2915
+ * worse than one routing to a lapsed holder, so the lapsed holder stays.
2916
+ *
2917
+ * Where the two implementations actually stand, per source. Both limbs are
2918
+ * listed because a statement about one of them is not a statement about this
2919
+ * method:
2920
+ *
2921
+ * 1. `sys_user_position` — sharing projects `valid_from` / `valid_until` and
2922
+ * drops rows on `isGrantActive` inside its own helper; we project
2923
+ * `user_id` alone, so an assignment that expired last month still routes.
2924
+ * This is the one real divergence, and it is the intended one.
2925
+ * 2. `sys_member.role` — raw on BOTH sides (`TeamGraphService.expandRoleUsers`
2926
+ * projects `user_id` too). The table carries no window columns at all and
2927
+ * `isGrantActive` reads an absent bound as unbounded, so there is nothing
2928
+ * a filter could do here; membership tier names have no `sys_position`
2929
+ * row either (#8710's "a name with no row is untouched" fallback), so no
2930
+ * catalogue flag either. This limb cannot be brought into parity by
2931
+ * adding a filter — see {@link expandMembershipTierUsers}.
2932
+ * 3. `sys_position.active` — the sharing engine's gate for it lives at the
2933
+ * RULE EVALUATOR's call site (`positionConfersAccess` in
2934
+ * `sharing-rule-service.ts`), not inside `PositionGraphService`; the same
2935
+ * ruling gives it no counterpart on this path.
2936
+ *
2937
+ * The omission is per-READ, not a missing dependency: `isGrantActive` is
2938
+ * imported in this file and IS applied to `sys_approval_delegation` in
2939
+ * {@link lookupActiveDelegation}. ⛔ So do not "fix" this by adding the window
2940
+ * filter here — that is the option #8710 rejected, on the reasoning above.
2842
2941
  */
2843
2942
  async expandPositionUsers(positionName, organizationId) {
2844
2943
  if (!positionName) return [];
@@ -2866,6 +2965,13 @@ var _ApprovalService = class _ApprovalService {
2866
2965
  * NOT positions. Named for the projection (`org_membership_level`, ADR-0057
2867
2966
  * D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
2868
2967
  * and stays, the platform-facing word does not.
2968
+ *
2969
+ * Read RAW, like every routing read here, and with nothing available to
2970
+ * filter even if it were not: `sys_member` carries no ADR-0091 D2 window
2971
+ * columns, and a tier name has no `sys_position` row to read `active` off.
2972
+ * {@link expandPositionUsers} carries the ruling both reads inherit
2973
+ * (#8613 / #8710) — this method is also the second limb of that union, so a
2974
+ * change here changes position routing too.
2869
2975
  */
2870
2976
  async expandMembershipTierUsers(tier, organizationId) {
2871
2977
  if (!tier) return [];
@@ -5095,7 +5201,7 @@ var _ApprovalService = class _ApprovalService {
5095
5201
  * is a plain membership test over the resolved ids). So this cannot hide a
5096
5202
  * request from someone who could actually act on it.
5097
5203
  */
5098
- async visibleRequestIds(context, tenantOrg) {
5204
+ async visibleRequestIds(context, tenantOrg, target) {
5099
5205
  if (this.isOverrideActor(context, tenantOrg)) return null;
5100
5206
  const uid2 = context?.userId != null ? String(context.userId) : "";
5101
5207
  if (!uid2) return /* @__PURE__ */ new Set();
@@ -5137,8 +5243,90 @@ var _ApprovalService = class _ApprovalService {
5137
5243
  error: err instanceof Error ? err.message : String(err)
5138
5244
  });
5139
5245
  }
5246
+ await this.addRecordReaderVisibleIds(ids, context, tenantOrg, target);
5140
5247
  return ids;
5141
5248
  }
5249
+ /**
5250
+ * [#8652] Read-only approval visibility derived from READ ACCESS TO THE
5251
+ * TARGET BUSINESS RECORD.
5252
+ *
5253
+ * Maintainer ruling 2026-08-15: a user who can read the target record may
5254
+ * view that record's approval requests and full action history, read-only,
5255
+ * behind a switch that is default OFF, anchored on the EXISTING record-read
5256
+ * permission. The rejected alternative was a host-injected visibility hook —
5257
+ * a security predicate the platform could neither constrain nor audit.
5258
+ *
5259
+ * ## How the anchor is evaluated
5260
+ *
5261
+ * By asking the engine to read the record AS THE CALLER. That is the whole
5262
+ * check: `engine.find(object, { where: { id }, context })` runs the ordinary
5263
+ * ObjectQL middleware — object CRUD read, then RLS — so a denial throws and a
5264
+ * row the caller may not see comes back empty. Both mean "no". No new
5265
+ * permission, role or grant type is invented, and no second copy of the
5266
+ * access rule exists to drift from the first.
5267
+ *
5268
+ * ⚠️ The caller's context is load-bearing. Probing with {@link SYSTEM_CTX} —
5269
+ * the context every other read in this service uses — would read exactly like
5270
+ * a permission check while admitting every authenticated user in the tenant.
5271
+ *
5272
+ * ## Why it needs a NAMED TARGET, and what that deliberately excludes
5273
+ *
5274
+ * The rule is anchored on one record, so it can only be evaluated where a
5275
+ * record is named: a list filtered by `object` + `recordId` (what a record
5276
+ * page's approval tab sends), or a request loaded by id (whose own row names
5277
+ * its target). An UNTARGETED list — the inbox — is left exactly as it was:
5278
+ * answering it under this tier would mean probing every request in the tenant
5279
+ * for read access, which is unbounded, and would turn a work queue into a
5280
+ * browse surface. The confirmed consumer is the record page; the inbox is not
5281
+ * part of the ruling and is not widened here.
5282
+ *
5283
+ * ## What becomes visible (stated plainly, because the switch is an opt-in)
5284
+ *
5285
+ * The request row — including its `payload` snapshot of the record at
5286
+ * submission time — plus the full action history: actor, decision, timestamp,
5287
+ * the action's COMMENT text, and (through the same gate, via
5288
+ * {@link ApprovalService.authorizeFileRead}) any decision attachments. The
5289
+ * comment text is the ruling's "full action history" read literally; it is
5290
+ * flagged on the card as the one granularity edge worth a second look.
5291
+ *
5292
+ * Read-only is not enforced here and must not be: the decision paths
5293
+ * (`decideNode` / `reassign` / `recall` / `comment`) authorize on the pending
5294
+ * approver slate, the submitter, or {@link ApprovalService.isOverrideActor},
5295
+ * none of which this tier touches. Seeing a request confers nothing.
5296
+ */
5297
+ async addRecordReaderVisibleIds(ids, context, tenantOrg, target) {
5298
+ if (this.recordReaderVisibleObjects.size === 0) return;
5299
+ const object = String(target?.object ?? "").trim();
5300
+ const recordId = String(target?.recordId ?? "").trim();
5301
+ if (!object || !recordId) return;
5302
+ if (!this.recordReaderVisibleObjects.has(object)) return;
5303
+ const uid2 = context?.userId != null ? String(context.userId) : "";
5304
+ if (!uid2) return;
5305
+ try {
5306
+ const readable = await this.engine.find(object, {
5307
+ where: { id: recordId },
5308
+ fields: ["id"],
5309
+ limit: 1,
5310
+ context
5311
+ });
5312
+ if (!Array.isArray(readable) || readable.length === 0) return;
5313
+ const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
5314
+ const rows = await this.engine.find("sys_approval_request", {
5315
+ where: { object_name: object, record_id: recordId, ...orgWhere },
5316
+ fields: ["id"],
5317
+ limit: _ApprovalService.APPROVER_INDEX_CAP,
5318
+ context: SYSTEM_CTX2
5319
+ });
5320
+ for (const r of Array.isArray(rows) ? rows : []) {
5321
+ if (r?.id != null) ids.add(String(r.id));
5322
+ }
5323
+ } catch (err) {
5324
+ this.logger?.debug?.("[approvals] record-reader visibility probe declined", {
5325
+ object,
5326
+ error: err instanceof Error ? err.message : String(err)
5327
+ });
5328
+ }
5329
+ }
5142
5330
  /** Intersect an existing `where.id` constraint with the participant set. */
5143
5331
  applyVisibility(where, visible) {
5144
5332
  if (!visible) return true;
@@ -5162,7 +5350,10 @@ var _ApprovalService = class _ApprovalService {
5162
5350
  if (ids.length === 0) return [];
5163
5351
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
5164
5352
  }
5165
- if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
5353
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
5354
+ object: filter?.object,
5355
+ recordId: filter?.recordId
5356
+ }))) return [];
5166
5357
  const findOpts = {
5167
5358
  where,
5168
5359
  orderBy: [{ field: "created_at", order: "desc" }],
@@ -5188,7 +5379,10 @@ var _ApprovalService = class _ApprovalService {
5188
5379
  if (ids.length === 0) return 0;
5189
5380
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
5190
5381
  }
5191
- if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
5382
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
5383
+ object: filter?.object,
5384
+ recordId: filter?.recordId
5385
+ }))) return 0;
5192
5386
  const countFn = this.engine.count;
5193
5387
  if (typeof countFn === "function") {
5194
5388
  try {
@@ -5234,7 +5428,10 @@ var _ApprovalService = class _ApprovalService {
5234
5428
  });
5235
5429
  if (!Array.isArray(rows) || !rows[0]) return null;
5236
5430
  if (enforceVisibility) {
5237
- const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
5431
+ const visible = await this.visibleRequestIds(context, tenantOrg ?? null, {
5432
+ object: rows[0].object_name,
5433
+ recordId: rows[0].record_id
5434
+ });
5238
5435
  if (visible && !visible.has(String(rows[0].id))) return null;
5239
5436
  }
5240
5437
  const row = rowFromRequest(rows[0]);
@@ -5994,6 +6191,9 @@ var ApprovalsServicePlugin = class {
5994
6191
  engine,
5995
6192
  logger: ctx.logger,
5996
6193
  publicBaseUrl: this.options.publicBaseUrl,
6194
+ // [#8652] Read-only record-reader visibility. Default OFF — an absent
6195
+ // declaration reaches the service as an empty set and changes nothing.
6196
+ recordReaderVisibleObjects: this.options.recordReaderVisibleObjects,
5997
6197
  // [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
5998
6198
  // capability. Read LAZILY (not captured at start) because the tenancy
5999
6199
  // service resolves its posture during its own start, which may not have