@forumone/throughline-approvals 0.8.2 → 0.8.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # @forumone/throughline-approvals
2
2
 
3
+ ## 0.8.4
4
+
5
+ ### Patch Changes
6
+
7
+ - d02772f: `get_approval_status` now refuses an unauthenticated caller, and answers only
8
+ the requester or a member of an approver group.
9
+
10
+ It had no authorization check of any kind. The handler was `async (input) =>` —
11
+ the signature did not accept `ctx`, so there was nothing to check against — and
12
+ it `findByID`d an arbitrary `approvalId` at the Local API default of
13
+ `overrideAccess: true`, returning the target, the requester, the approver
14
+ groups, the decision and the decision notes. The other four tools on this
15
+ server all check `ctx.user`, which made this the one approvals tool that still
16
+ answered when the rest correctly denied. Audit 04 F-20.
17
+
18
+ The rule is the union of what the two list tools already show a caller —
19
+ `list_my_requests` returns the approvals they requested, `list_pending_approvals`
20
+ the ones routed to their groups — so the by-id read agrees with the by-list
21
+ reads instead of being a way around them. No admin bypass, because
22
+ `respond_to_approval` has none either and a second policy on the same collection
23
+ is how the two drift.
24
+
25
+ A refused read returns `Approval not found`, the same sentence as a genuine
26
+ miss: ids are sequential, and a distinguishable refusal enumerates who is asking
27
+ whom to approve what.
28
+
29
+ **Behavioural change for consumers.** A `get_approval_status` call arriving with
30
+ no `ctx.user` now returns `{ error: 'Must be authenticated…' }` instead of the
31
+ approval. In practice that is every `Bearer`-authenticated MCP call, because
32
+ `@payloadcms/plugin-mcp` does not assign `req.user` — the same fact that made
33
+ the four gated tools deny and this one answer.
34
+
35
+ - Updated dependencies [d02772f]
36
+ - @forumone/throughline-core@0.9.1
37
+ - @forumone/throughline-publishing@0.9.4
38
+
39
+ ## 0.8.3
40
+
41
+ ### Patch Changes
42
+
43
+ - 1f3346f: `get_approval_status` now refuses an unauthenticated caller, and answers only
44
+ the requester or a member of an approver group.
45
+
46
+ It had no authorization check of any kind. The handler was `async (input) =>` —
47
+ the signature did not accept `ctx`, so there was nothing to check against — and
48
+ it `findByID`d an arbitrary `approvalId` at the Local API default of
49
+ `overrideAccess: true`, returning the target, the requester, the approver
50
+ groups, the decision and the decision notes. The other four tools on this
51
+ server all check `ctx.user`, which made this the one approvals tool that still
52
+ answered when the rest correctly denied. Audit 04 F-20.
53
+
54
+ The rule is the union of what the two list tools already show a caller —
55
+ `list_my_requests` returns the approvals they requested, `list_pending_approvals`
56
+ the ones routed to their groups — so the by-id read agrees with the by-list
57
+ reads instead of being a way around them. No admin bypass, because
58
+ `respond_to_approval` has none either and a second policy on the same collection
59
+ is how the two drift.
60
+
61
+ A refused read returns `Approval not found`, the same sentence as a genuine
62
+ miss: ids are sequential, and a distinguishable refusal enumerates who is asking
63
+ whom to approve what.
64
+
65
+ **Behavioural change for consumers.** A `get_approval_status` call arriving with
66
+ no `ctx.user` now returns `{ error: 'Must be authenticated…' }` instead of the
67
+ approval. In practice that is every `Bearer`-authenticated MCP call, because
68
+ `@payloadcms/plugin-mcp` does not assign `req.user` — the same fact that made
69
+ the four gated tools deny and this one answer.
70
+
3
71
  ## 0.8.2
4
72
 
5
73
  ### Patch Changes
@@ -1 +1 @@
1
- {"version":3,"file":"get-approval-status.d.ts","sourceRoot":"","sources":["../../src/tools/get-approval-status.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;AAEtC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uCAAuC,CAAA;AAE9E,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAA;AAG3D,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,OAAO,CAAA;IAChB,OAAO,EAAE,sBAAsB,GAAG;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,CAAA;CAC1D;AAED,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,qBAAqB,GAAG,iBAAiB,CAgC1F"}
1
+ {"version":3,"file":"get-approval-status.d.ts","sourceRoot":"","sources":["../../src/tools/get-approval-status.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;AAEtC,OAAO,KAAK,EAAkB,iBAAiB,EAAE,MAAM,uCAAuC,CAAA;AAE9F,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAA;AAG3D,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,OAAO,CAAA;IAChB,OAAO,EAAE,sBAAsB,GAAG;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,CAAA;CAC1D;AAqDD,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,qBAAqB,GAAG,iBAAiB,CA2C1F"}
@@ -2,6 +2,52 @@ import { z } from 'zod';
2
2
  import { unwrapRelationshipId, withMeta } from '@forumone/throughline-core';
3
3
  import { DEFAULT_APPROVALS_SLUG } from '../collection.js';
4
4
  import { APPROVALS_TOOLS } from './descriptors.js';
5
+ /*
6
+ Who may read one approval by id.
7
+
8
+ Audit 04 F-20. This handler was `async (input) =>` — the signature did not
9
+ accept `ctx` at all, so there was nothing to check against and nothing was
10
+ checked. It `findByID`s an arbitrary `approvalId` at the Local API default of
11
+ `overrideAccess: true` and returns the target, the requester, the approver
12
+ groups, the decision and the decision notes.
13
+
14
+ The other four tools on this server all refuse an unauthenticated caller. This
15
+ was the one that still answered when they denied, and that asymmetry is not
16
+ theoretical: `@payloadcms/plugin-mcp` never assigns `req.user`, so for a
17
+ `Bearer`-authenticated MCP call `ctx.user` is null and the refusal below is the
18
+ branch that actually fires today.
19
+
20
+ ## The rule is the union of the two list tools, and deliberately nothing more
21
+
22
+ `list_my_requests` shows a caller the approvals they requested.
23
+ `list_pending_approvals` shows a caller the approvals routed to one of their
24
+ groups. Between them they already disclose every field this tool returns. So
25
+ the rule here is *requester or approver-group member*, which makes the by-id
26
+ read agree with the by-list reads rather than being a way around them.
27
+
28
+ No admin bypass, because `respond_to_approval` has none either and a second
29
+ policy on the same collection is how the two drift. An admin who needs an
30
+ arbitrary approval has the admin UI and the REST collection, both of which
31
+ apply `approval-requests`' own access rules — which is the thing this server
32
+ should not be quietly wider than.
33
+
34
+ The order of the three checks is load-bearing. Identity first, because the
35
+ unauthenticated refusal must not depend on the id existing; then the document;
36
+ then membership.
37
+ */
38
+ /**
39
+ * Whether this caller is a party to this approval.
40
+ *
41
+ * Group membership is compared exactly as `respond_to_approval` compares it —
42
+ * `approverGroups` against `ctx.user.groups`, slug by slug — so a change in how
43
+ * groups are named breaks both together rather than leaving this one behind.
44
+ */
45
+ function isPartyTo(approval, user) {
46
+ if (unwrapRelationshipId(approval['requestedBy']) === user.id)
47
+ return true;
48
+ const approverGroups = approval['approverGroups'] ?? [];
49
+ return approverGroups.some((group) => user.groups.includes(group));
50
+ }
5
51
  export function createGetApprovalStatusTool(deps) {
6
52
  const inputSchema = withMeta({
7
53
  approvalId: z.string(),
@@ -9,13 +55,23 @@ export function createGetApprovalStatusTool(deps) {
9
55
  return {
10
56
  ...APPROVALS_TOOLS.getApprovalStatus,
11
57
  inputSchema,
12
- handler: async (input) => {
58
+ handler: async (input, ctx) => {
59
+ if (!ctx.user) {
60
+ return { error: 'Must be authenticated to read an approval request' };
61
+ }
13
62
  const approval = (await deps.payload.findByID({
14
63
  collection: deps.options.collectionSlug ?? DEFAULT_APPROVALS_SLUG,
15
64
  id: input.approvalId,
16
65
  }));
17
66
  if (!approval)
18
67
  return { error: 'Approval not found' };
68
+ /*
69
+ The same sentence as "not found", and that is the point: a caller who is
70
+ not a party to this request learns nothing about whether the id names one.
71
+ Ids are sequential, so a distinguishable refusal enumerates the collection.
72
+ */
73
+ if (!isPartyTo(approval, ctx.user))
74
+ return { error: 'Approval not found' };
19
75
  return {
20
76
  approvalId: String(approval['id']),
21
77
  status: approval['status'],
@@ -1 +1 @@
1
- {"version":3,"file":"get-approval-status.js","sourceRoot":"","sources":["../../src/tools/get-approval-status.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,OAAO,EAAE,oBAAoB,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAA;AAE3E,OAAO,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAA;AAEzD,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAOlD,MAAM,UAAU,2BAA2B,CAAC,IAA2B;IACrE,MAAM,WAAW,GAAG,QAAQ,CAAC;QAC3B,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;KACvB,CAAC,CAAA;IAEF,OAAO;QACL,GAAG,eAAe,CAAC,iBAAiB;QACpC,WAAW;QACX,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE;YACvB,MAAM,QAAQ,GAAG,CAAC,MAAM,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC;gBAC5C,UAAU,EAAE,IAAI,CAAC,OAAO,CAAC,cAAc,IAAI,sBAAsB;gBACjE,EAAE,EAAE,KAAK,CAAC,UAAU;aACrB,CAAC,CAAmC,CAAA;YACrC,IAAI,CAAC,QAAQ;gBAAE,OAAO,EAAE,KAAK,EAAE,oBAAoB,EAAE,CAAA;YAErD,OAAO;gBACL,UAAU,EAAE,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;gBAClC,MAAM,EAAE,QAAQ,CAAC,QAAQ,CAAC;gBAC1B,gBAAgB,EAAE,QAAQ,CAAC,kBAAkB,CAAC;gBAC9C,QAAQ,EAAE,QAAQ,CAAC,UAAU,CAAC;gBAC9B,WAAW,EAAE,QAAQ,CAAC,aAAa,CAAC;gBACpC,aAAa,EAAE,QAAQ,CAAC,eAAe,CAAC;gBACxC,WAAW,EAAE,oBAAoB,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC;gBAC1D,WAAW,EAAE,QAAQ,CAAC,aAAa,CAAC;gBACpC,cAAc,EAAE,QAAQ,CAAC,gBAAgB,CAAC;gBAC1C,SAAS,EAAE,oBAAoB,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;gBACtD,SAAS,EAAE,QAAQ,CAAC,WAAW,CAAC;gBAChC,aAAa,EAAE,QAAQ,CAAC,eAAe,CAAC;gBACxC,SAAS,EAAE,QAAQ,CAAC,WAAW,CAAC;aACjC,CAAA;QACH,CAAC;KACF,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"get-approval-status.js","sourceRoot":"","sources":["../../src/tools/get-approval-status.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,OAAO,EAAE,oBAAoB,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAA;AAE3E,OAAO,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAA;AAEzD,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAOlD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAgCE;AAEF;;;;;;GAMG;AACH,SAAS,SAAS,CAChB,QAAiC,EACjC,IAAyC;IAEzC,IAAI,oBAAoB,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC,KAAK,IAAI,CAAC,EAAE;QAAE,OAAO,IAAI,CAAA;IAE1E,MAAM,cAAc,GAAI,QAAQ,CAAC,gBAAgB,CAA0B,IAAI,EAAE,CAAA;IACjF,OAAO,cAAc,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAA;AACpE,CAAC;AAED,MAAM,UAAU,2BAA2B,CAAC,IAA2B;IACrE,MAAM,WAAW,GAAG,QAAQ,CAAC;QAC3B,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;KACvB,CAAC,CAAA;IAEF,OAAO;QACL,GAAG,eAAe,CAAC,iBAAiB;QACpC,WAAW;QACX,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE;YAC5B,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;gBACd,OAAO,EAAE,KAAK,EAAE,mDAAmD,EAAE,CAAA;YACvE,CAAC;YAED,MAAM,QAAQ,GAAG,CAAC,MAAM,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC;gBAC5C,UAAU,EAAE,IAAI,CAAC,OAAO,CAAC,cAAc,IAAI,sBAAsB;gBACjE,EAAE,EAAE,KAAK,CAAC,UAAU;aACrB,CAAC,CAAmC,CAAA;YACrC,IAAI,CAAC,QAAQ;gBAAE,OAAO,EAAE,KAAK,EAAE,oBAAoB,EAAE,CAAA;YAErD;;;;cAIE;YACF,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,GAAG,CAAC,IAAI,CAAC;gBAAE,OAAO,EAAE,KAAK,EAAE,oBAAoB,EAAE,CAAA;YAE1E,OAAO;gBACL,UAAU,EAAE,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;gBAClC,MAAM,EAAE,QAAQ,CAAC,QAAQ,CAAC;gBAC1B,gBAAgB,EAAE,QAAQ,CAAC,kBAAkB,CAAC;gBAC9C,QAAQ,EAAE,QAAQ,CAAC,UAAU,CAAC;gBAC9B,WAAW,EAAE,QAAQ,CAAC,aAAa,CAAC;gBACpC,aAAa,EAAE,QAAQ,CAAC,eAAe,CAAC;gBACxC,WAAW,EAAE,oBAAoB,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC;gBAC1D,WAAW,EAAE,QAAQ,CAAC,aAAa,CAAC;gBACpC,cAAc,EAAE,QAAQ,CAAC,gBAAgB,CAAC;gBAC1C,SAAS,EAAE,oBAAoB,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;gBACtD,SAAS,EAAE,QAAQ,CAAC,WAAW,CAAC;gBAChC,aAAa,EAAE,QAAQ,CAAC,eAAe,CAAC;gBACxC,SAAS,EAAE,QAAQ,CAAC,WAAW,CAAC;aACjC,CAAA;QACH,CAAC;KACF,CAAA;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forumone/throughline-approvals",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Conversational approval workflow server for Throughline. Provides the approval resolver the publishing server consumes plus MCP tools for requesting and responding to approvals.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -42,9 +42,9 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "zod": "^3.23.0",
45
- "@forumone/throughline-core": "0.9.0",
45
+ "@forumone/throughline-core": "0.9.1",
46
46
  "@forumone/throughline-plugin-contract": "0.4.1",
47
- "@forumone/throughline-publishing": "0.9.3"
47
+ "@forumone/throughline-publishing": "0.9.4"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/node": "^24.13.2",