@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.js CHANGED
@@ -1411,10 +1411,45 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1411
1411
  },
1412
1412
  fields: {
1413
1413
  id: import_data.Field.text({ label: "Request ID", required: true, readonly: true, group: "System" }),
1414
+ // ⚠️ MEASURED DEFECT, cloud#1395 — read this before trusting the column.
1415
+ //
1416
+ // An approval request DOES belong to an organization: it is read through the
1417
+ // organization wall by the approvals inbox, and on a shared-database
1418
+ // deployment a row carrying no organization is not filtered BY that wall —
1419
+ // it is either invisible to everyone or visible to everyone, decided by
1420
+ // whatever filter each surface happens to apply rather than by the data.
1421
+ //
1422
+ // The value is resolved from the ACTING CONTEXT only (`openNodeRequest`'s
1423
+ // `ctxOrg`), so it is NULL whenever the flow that opened the request ran
1424
+ // without one — every schedule / time-relative / api triggered run, none of
1425
+ // which sets a tenant. On a walled single-database HotCRM SaaS boot this
1426
+ // measured 27 of 27 rows org-less, each naming an `object_name` /
1427
+ // `record_id` owned by a specific customer.
1428
+ //
1429
+ // ⛔ Do NOT read that as "platform tables do not carry an organization".
1430
+ // `sys_audit_log` (1669 rows) was correctly attributed on the SAME boot,
1431
+ // because its writer takes the organization from the RECORD the row is
1432
+ // about, with the session only as fallback (plugin-audit
1433
+ // `resolveRecordOrganizationField`, #8707 honouring #8287's ruling). Two
1434
+ // writers read the actor; a third reads the subject. That disagreement is
1435
+ // the defect.
1436
+ //
1437
+ // Which of the two a side-table row should follow is an open contract
1438
+ // question on cloud#1395 — the audit resolver is scope-pinned to audit
1439
+ // stamping by the #8778 ruling, so this writer needs its own. The same
1440
+ // `ctxOrg` also stamps `sys_approval_action` and `sys_approval_approver`,
1441
+ // so all three move together.
1414
1442
  organization_id: import_data.Field.lookup("sys_organization", {
1415
1443
  label: "Organization",
1416
1444
  required: false,
1417
1445
  group: "System",
1446
+ // ⛔ String unchanged on purpose: it is extracted into the generated i18n
1447
+ // bundles (`translations/*.objects.generated.ts`, as `help`), so rewording
1448
+ // it is a translation-regeneration change and not a comment. The
1449
+ // correction it needs — it claims a propagation that measurably does not
1450
+ // happen, and says "Tenant" where ADR-0120 §Terminology requires
1451
+ // "organization" — rides the cloud#1395 write-side fix, which rewrites the
1452
+ // sentence and regenerates the four locales in one pass.
1418
1453
  description: "Tenant that owns this approval request (propagated from submitter context)"
1419
1454
  }),
1420
1455
  process_name: import_data.Field.text({
@@ -1558,6 +1593,28 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1558
1593
  // approving, rejecting, or reassigning it to a real approver. `viewer` is
1559
1594
  // attached by getRequest/listRequests; where it is absent the predicate fails
1560
1595
  // closed.
1596
+ //
1597
+ // Every predicate below is guarded for the SPARSE action face (#8990). This
1598
+ // binding is a list row or a record read carrying only what the caller
1599
+ // projected, and CEL aborts the whole expression at key resolution — so the
1600
+ // unguarded `record.viewer.can_act` faulted (`No such key: viewer`) on any
1601
+ // row without the block, and the button silently vanished, indistinguishable
1602
+ // from "the gate said no". `materializeDeclaredFields`'s doc comment in
1603
+ // `@objectstack/objectql` is the canonical statement of the guard rule; this
1604
+ // file follows it and does not restate it.
1605
+ //
1606
+ // `viewer` is a NESTED block, which needs one measurement the canonical rule
1607
+ // does not spell out. Measured against the `@objectstack/formula` CEL engine:
1608
+ // `has(record.viewer) && record.viewer != null && record.viewer.can_act`
1609
+ // still FAULTS on `{viewer: {}}` (`No such key: can_act`) and on
1610
+ // `{viewer: {can_act: null}}` (`Logical operator requires bool operands`).
1611
+ // Guarding the LEAF instead — `has(record.viewer) &&
1612
+ // has(record.viewer.can_act) && record.viewer.can_act == true` — is total
1613
+ // over every binding AND subsumes the parent `!= null` half, because `has()`
1614
+ // on a path whose parent is null answers `false` rather than faulting. So the
1615
+ // leaf `has()` plus the `== true` comparison is the MINIMAL safe form here,
1616
+ // not a longer one: `== true` is load-bearing (a bare truthy read of a null
1617
+ // leaf faults the logical operator), the parent `!= null` is not.
1561
1618
  actions: [
1562
1619
  {
1563
1620
  name: "approval_approve",
@@ -1577,7 +1634,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1577
1634
  // string[]`; the decision route persists them on `sys_approval_action`.
1578
1635
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1579
1636
  ],
1580
- visible: "record.viewer.can_act || record.viewer.can_override",
1637
+ 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",
1581
1638
  locations: ["record_section", "list_item"],
1582
1639
  successMessage: "Approved.",
1583
1640
  refreshAfter: true
@@ -1605,7 +1662,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1605
1662
  { name: "comment", label: "Comment", type: "textarea", required: false },
1606
1663
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1607
1664
  ],
1608
- visible: "record.viewer.can_act || record.viewer.can_override",
1665
+ 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",
1609
1666
  locations: ["record_section", "list_item"],
1610
1667
  successMessage: "Rejected.",
1611
1668
  refreshAfter: true
@@ -1626,7 +1683,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1626
1683
  { field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
1627
1684
  { name: "comment", label: "Comment", type: "textarea", required: false }
1628
1685
  ],
1629
- visible: "record.viewer.can_act || record.viewer.can_override",
1686
+ 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",
1630
1687
  locations: ["record_section"],
1631
1688
  successMessage: "Reassigned.",
1632
1689
  refreshAfter: true
@@ -1645,7 +1702,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1645
1702
  params: [
1646
1703
  { name: "comment", label: "Reason", type: "textarea", required: false }
1647
1704
  ],
1648
- visible: "record.viewer.can_act",
1705
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
1649
1706
  locations: ["record_section"],
1650
1707
  successMessage: "Sent back for revision.",
1651
1708
  refreshAfter: true
@@ -1660,7 +1717,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1660
1717
  params: [
1661
1718
  { name: "comment", label: "What do you need?", type: "textarea", required: true }
1662
1719
  ],
1663
- visible: "record.viewer.can_act",
1720
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
1664
1721
  locations: ["record_section"],
1665
1722
  successMessage: "Information requested.",
1666
1723
  refreshAfter: true
@@ -1681,7 +1738,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1681
1738
  params: [
1682
1739
  { name: "comment", label: "Note", type: "textarea", required: false }
1683
1740
  ],
1684
- visible: 'record.status == "pending" && record.viewer.is_submitter',
1741
+ visible: 'has(record.status) && record.status == "pending" && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1685
1742
  locations: ["record_section"],
1686
1743
  successMessage: "Reminder sent.",
1687
1744
  refreshAfter: true
@@ -1701,7 +1758,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1701
1758
  ],
1702
1759
  // Recall applies while the request is live for the submitter — pending
1703
1760
  // (withdraw) or returned (abandon the revision instead of resubmitting).
1704
- visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
1761
+ visible: 'has(record.status) && (record.status == "pending" || record.status == "returned") && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1705
1762
  locations: ["record_section"],
1706
1763
  successMessage: "Recalled.",
1707
1764
  refreshAfter: true
@@ -1716,7 +1773,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1716
1773
  params: [
1717
1774
  { name: "comment", label: "What changed?", type: "textarea", required: false }
1718
1775
  ],
1719
- visible: 'record.status == "returned" && record.viewer.is_submitter',
1776
+ visible: 'has(record.status) && record.status == "returned" && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1720
1777
  locations: ["record_section"],
1721
1778
  successMessage: "Resubmitted.",
1722
1779
  refreshAfter: true
@@ -2350,6 +2407,9 @@ var _ApprovalService = class _ApprovalService {
2350
2407
  this.messaging = opts.messaging;
2351
2408
  this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
2352
2409
  this.tenancyPosture = opts.tenancyPosture;
2410
+ this.recordReaderVisibleObjects = new Set(
2411
+ (Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
2412
+ );
2353
2413
  }
2354
2414
  /** Attach (or replace) the ADR-0105 D9 posture provider. */
2355
2415
  attachTenancyPosture(provider) {
@@ -2851,9 +2911,48 @@ var _ApprovalService = class _ApprovalService {
2851
2911
  * Position holders (ADR-0090 D3): `sys_user_position` is the platform-owned
2852
2912
  * assignment table, keyed by the position's machine name (ADR-0057 D4),
2853
2913
  * unioned with the better-auth membership string (`sys_member.role`) as a
2854
- * transition source — the same semantics as `PositionGraphService` in
2855
- * `plugin-sharing`, so an approval routes to exactly the users the sharing
2856
- * engine would expand for the same position.
2914
+ * transition source.
2915
+ *
2916
+ * ⚠️ This is a ROUTING read (approver slates and escalation targets), and it
2917
+ * is deliberately NOT the same read as `PositionGraphService` in
2918
+ * `plugin-sharing`, whatever the shared method name suggests. Both answer
2919
+ * "who holds position P"; this one reads the directory RAW — neither the
2920
+ * ADR-0091 D2 validity window nor the `sys_position.active` catalogue flag is
2921
+ * applied. Maintainer ruling, 2026-08-15 (#8710, inheriting #8613), verbatim:
2922
+ *
2923
+ * > Access-conferring paths filter deactivated positions; addressing paths
2924
+ * > do not.
2925
+ *
2926
+ * Routing is an addressing path, so dropping a holder here is fail-OPEN, not
2927
+ * fail-closed: an expansion that comes back empty does not narrow the slate,
2928
+ * it falls through to the literal `position:` slot no user can ever act on —
2929
+ * the permanently stuck request of #3807 / #3424. A step routing to nobody is
2930
+ * worse than one routing to a lapsed holder, so the lapsed holder stays.
2931
+ *
2932
+ * Where the two implementations actually stand, per source. Both limbs are
2933
+ * listed because a statement about one of them is not a statement about this
2934
+ * method:
2935
+ *
2936
+ * 1. `sys_user_position` — sharing projects `valid_from` / `valid_until` and
2937
+ * drops rows on `isGrantActive` inside its own helper; we project
2938
+ * `user_id` alone, so an assignment that expired last month still routes.
2939
+ * This is the one real divergence, and it is the intended one.
2940
+ * 2. `sys_member.role` — raw on BOTH sides (`TeamGraphService.expandRoleUsers`
2941
+ * projects `user_id` too). The table carries no window columns at all and
2942
+ * `isGrantActive` reads an absent bound as unbounded, so there is nothing
2943
+ * a filter could do here; membership tier names have no `sys_position`
2944
+ * row either (#8710's "a name with no row is untouched" fallback), so no
2945
+ * catalogue flag either. This limb cannot be brought into parity by
2946
+ * adding a filter — see {@link expandMembershipTierUsers}.
2947
+ * 3. `sys_position.active` — the sharing engine's gate for it lives at the
2948
+ * RULE EVALUATOR's call site (`positionConfersAccess` in
2949
+ * `sharing-rule-service.ts`), not inside `PositionGraphService`; the same
2950
+ * ruling gives it no counterpart on this path.
2951
+ *
2952
+ * The omission is per-READ, not a missing dependency: `isGrantActive` is
2953
+ * imported in this file and IS applied to `sys_approval_delegation` in
2954
+ * {@link lookupActiveDelegation}. ⛔ So do not "fix" this by adding the window
2955
+ * filter here — that is the option #8710 rejected, on the reasoning above.
2857
2956
  */
2858
2957
  async expandPositionUsers(positionName, organizationId) {
2859
2958
  if (!positionName) return [];
@@ -2881,6 +2980,13 @@ var _ApprovalService = class _ApprovalService {
2881
2980
  * NOT positions. Named for the projection (`org_membership_level`, ADR-0057
2882
2981
  * D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
2883
2982
  * and stays, the platform-facing word does not.
2983
+ *
2984
+ * Read RAW, like every routing read here, and with nothing available to
2985
+ * filter even if it were not: `sys_member` carries no ADR-0091 D2 window
2986
+ * columns, and a tier name has no `sys_position` row to read `active` off.
2987
+ * {@link expandPositionUsers} carries the ruling both reads inherit
2988
+ * (#8613 / #8710) — this method is also the second limb of that union, so a
2989
+ * change here changes position routing too.
2884
2990
  */
2885
2991
  async expandMembershipTierUsers(tier, organizationId) {
2886
2992
  if (!tier) return [];
@@ -5110,7 +5216,7 @@ var _ApprovalService = class _ApprovalService {
5110
5216
  * is a plain membership test over the resolved ids). So this cannot hide a
5111
5217
  * request from someone who could actually act on it.
5112
5218
  */
5113
- async visibleRequestIds(context, tenantOrg) {
5219
+ async visibleRequestIds(context, tenantOrg, target) {
5114
5220
  if (this.isOverrideActor(context, tenantOrg)) return null;
5115
5221
  const uid2 = context?.userId != null ? String(context.userId) : "";
5116
5222
  if (!uid2) return /* @__PURE__ */ new Set();
@@ -5152,8 +5258,90 @@ var _ApprovalService = class _ApprovalService {
5152
5258
  error: err instanceof Error ? err.message : String(err)
5153
5259
  });
5154
5260
  }
5261
+ await this.addRecordReaderVisibleIds(ids, context, tenantOrg, target);
5155
5262
  return ids;
5156
5263
  }
5264
+ /**
5265
+ * [#8652] Read-only approval visibility derived from READ ACCESS TO THE
5266
+ * TARGET BUSINESS RECORD.
5267
+ *
5268
+ * Maintainer ruling 2026-08-15: a user who can read the target record may
5269
+ * view that record's approval requests and full action history, read-only,
5270
+ * behind a switch that is default OFF, anchored on the EXISTING record-read
5271
+ * permission. The rejected alternative was a host-injected visibility hook —
5272
+ * a security predicate the platform could neither constrain nor audit.
5273
+ *
5274
+ * ## How the anchor is evaluated
5275
+ *
5276
+ * By asking the engine to read the record AS THE CALLER. That is the whole
5277
+ * check: `engine.find(object, { where: { id }, context })` runs the ordinary
5278
+ * ObjectQL middleware — object CRUD read, then RLS — so a denial throws and a
5279
+ * row the caller may not see comes back empty. Both mean "no". No new
5280
+ * permission, role or grant type is invented, and no second copy of the
5281
+ * access rule exists to drift from the first.
5282
+ *
5283
+ * ⚠️ The caller's context is load-bearing. Probing with {@link SYSTEM_CTX} —
5284
+ * the context every other read in this service uses — would read exactly like
5285
+ * a permission check while admitting every authenticated user in the tenant.
5286
+ *
5287
+ * ## Why it needs a NAMED TARGET, and what that deliberately excludes
5288
+ *
5289
+ * The rule is anchored on one record, so it can only be evaluated where a
5290
+ * record is named: a list filtered by `object` + `recordId` (what a record
5291
+ * page's approval tab sends), or a request loaded by id (whose own row names
5292
+ * its target). An UNTARGETED list — the inbox — is left exactly as it was:
5293
+ * answering it under this tier would mean probing every request in the tenant
5294
+ * for read access, which is unbounded, and would turn a work queue into a
5295
+ * browse surface. The confirmed consumer is the record page; the inbox is not
5296
+ * part of the ruling and is not widened here.
5297
+ *
5298
+ * ## What becomes visible (stated plainly, because the switch is an opt-in)
5299
+ *
5300
+ * The request row — including its `payload` snapshot of the record at
5301
+ * submission time — plus the full action history: actor, decision, timestamp,
5302
+ * the action's COMMENT text, and (through the same gate, via
5303
+ * {@link ApprovalService.authorizeFileRead}) any decision attachments. The
5304
+ * comment text is the ruling's "full action history" read literally; it is
5305
+ * flagged on the card as the one granularity edge worth a second look.
5306
+ *
5307
+ * Read-only is not enforced here and must not be: the decision paths
5308
+ * (`decideNode` / `reassign` / `recall` / `comment`) authorize on the pending
5309
+ * approver slate, the submitter, or {@link ApprovalService.isOverrideActor},
5310
+ * none of which this tier touches. Seeing a request confers nothing.
5311
+ */
5312
+ async addRecordReaderVisibleIds(ids, context, tenantOrg, target) {
5313
+ if (this.recordReaderVisibleObjects.size === 0) return;
5314
+ const object = String(target?.object ?? "").trim();
5315
+ const recordId = String(target?.recordId ?? "").trim();
5316
+ if (!object || !recordId) return;
5317
+ if (!this.recordReaderVisibleObjects.has(object)) return;
5318
+ const uid2 = context?.userId != null ? String(context.userId) : "";
5319
+ if (!uid2) return;
5320
+ try {
5321
+ const readable = await this.engine.find(object, {
5322
+ where: { id: recordId },
5323
+ fields: ["id"],
5324
+ limit: 1,
5325
+ context
5326
+ });
5327
+ if (!Array.isArray(readable) || readable.length === 0) return;
5328
+ const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
5329
+ const rows = await this.engine.find("sys_approval_request", {
5330
+ where: { object_name: object, record_id: recordId, ...orgWhere },
5331
+ fields: ["id"],
5332
+ limit: _ApprovalService.APPROVER_INDEX_CAP,
5333
+ context: SYSTEM_CTX2
5334
+ });
5335
+ for (const r of Array.isArray(rows) ? rows : []) {
5336
+ if (r?.id != null) ids.add(String(r.id));
5337
+ }
5338
+ } catch (err) {
5339
+ this.logger?.debug?.("[approvals] record-reader visibility probe declined", {
5340
+ object,
5341
+ error: err instanceof Error ? err.message : String(err)
5342
+ });
5343
+ }
5344
+ }
5157
5345
  /** Intersect an existing `where.id` constraint with the participant set. */
5158
5346
  applyVisibility(where, visible) {
5159
5347
  if (!visible) return true;
@@ -5177,7 +5365,10 @@ var _ApprovalService = class _ApprovalService {
5177
5365
  if (ids.length === 0) return [];
5178
5366
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
5179
5367
  }
5180
- if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
5368
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
5369
+ object: filter?.object,
5370
+ recordId: filter?.recordId
5371
+ }))) return [];
5181
5372
  const findOpts = {
5182
5373
  where,
5183
5374
  orderBy: [{ field: "created_at", order: "desc" }],
@@ -5203,7 +5394,10 @@ var _ApprovalService = class _ApprovalService {
5203
5394
  if (ids.length === 0) return 0;
5204
5395
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
5205
5396
  }
5206
- if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
5397
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
5398
+ object: filter?.object,
5399
+ recordId: filter?.recordId
5400
+ }))) return 0;
5207
5401
  const countFn = this.engine.count;
5208
5402
  if (typeof countFn === "function") {
5209
5403
  try {
@@ -5249,7 +5443,10 @@ var _ApprovalService = class _ApprovalService {
5249
5443
  });
5250
5444
  if (!Array.isArray(rows) || !rows[0]) return null;
5251
5445
  if (enforceVisibility) {
5252
- const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
5446
+ const visible = await this.visibleRequestIds(context, tenantOrg ?? null, {
5447
+ object: rows[0].object_name,
5448
+ recordId: rows[0].record_id
5449
+ });
5253
5450
  if (visible && !visible.has(String(rows[0].id))) return null;
5254
5451
  }
5255
5452
  const row = rowFromRequest(rows[0]);
@@ -6001,6 +6198,9 @@ var ApprovalsServicePlugin = class {
6001
6198
  engine,
6002
6199
  logger: ctx.logger,
6003
6200
  publicBaseUrl: this.options.publicBaseUrl,
6201
+ // [#8652] Read-only record-reader visibility. Default OFF — an absent
6202
+ // declaration reaches the service as an empty set and changes nothing.
6203
+ recordReaderVisibleObjects: this.options.recordReaderVisibleObjects,
6004
6204
  // [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
6005
6205
  // capability. Read LAZILY (not captured at start) because the tenancy
6006
6206
  // service resolves its posture during its own start, which may not have