@tellescope/sdk 1.255.18 → 1.256.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.
Files changed (165) hide show
  1. package/lib/cjs/sdk.d.ts +7 -2
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +1 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
  6. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js +18 -8
  7. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
  8. package/lib/cjs/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  9. package/lib/cjs/tests/api_tests/custom_dashboards.test.js +99 -8
  10. package/lib/cjs/tests/api_tests/custom_dashboards.test.js.map +1 -1
  11. package/lib/cjs/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  12. package/lib/cjs/tests/api_tests/default_summary_journey.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  14. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  15. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js +278 -0
  16. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  17. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
  18. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +203 -72
  19. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
  20. package/lib/cjs/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  21. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js +212 -33
  22. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  23. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  24. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  25. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  26. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
  27. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
  28. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js +102 -0
  29. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
  30. package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
  31. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +84 -81
  32. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js.map +1 -1
  33. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  34. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  35. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +242 -0
  36. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  37. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  38. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  39. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +443 -0
  40. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  41. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  42. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  43. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +231 -0
  44. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  45. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
  46. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
  47. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
  48. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
  49. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
  50. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +1003 -0
  51. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
  52. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
  53. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
  54. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +215 -0
  55. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
  56. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
  57. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
  58. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1200 -0
  59. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
  60. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
  61. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
  62. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2330 -0
  63. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
  64. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
  65. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
  66. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js +204 -0
  67. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
  68. package/lib/cjs/tests/tests.d.ts.map +1 -1
  69. package/lib/cjs/tests/tests.js +489 -255
  70. package/lib/cjs/tests/tests.js.map +1 -1
  71. package/lib/esm/enduser.d.ts +0 -1
  72. package/lib/esm/enduser.d.ts.map +1 -1
  73. package/lib/esm/sdk.d.ts +9 -5
  74. package/lib/esm/sdk.d.ts.map +1 -1
  75. package/lib/esm/sdk.js +1 -0
  76. package/lib/esm/sdk.js.map +1 -1
  77. package/lib/esm/session.d.ts +0 -1
  78. package/lib/esm/session.d.ts.map +1 -1
  79. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
  80. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js +18 -8
  81. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
  82. package/lib/esm/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  83. package/lib/esm/tests/api_tests/custom_dashboards.test.js +99 -8
  84. package/lib/esm/tests/api_tests/custom_dashboards.test.js.map +1 -1
  85. package/lib/esm/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  86. package/lib/esm/tests/api_tests/default_summary_journey.test.js.map +1 -0
  87. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  88. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  89. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js +248 -0
  90. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  91. package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
  92. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +205 -74
  93. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
  94. package/lib/esm/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  95. package/lib/esm/tests/api_tests/openloop_webhooks.test.js +213 -34
  96. package/lib/esm/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  97. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  98. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  99. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  100. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
  101. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
  102. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js +98 -0
  103. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
  104. package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
  105. package/lib/esm/tests/api_tests/scoped_api_keys.test.js +84 -81
  106. package/lib/esm/tests/api_tests/scoped_api_keys.test.js.map +1 -1
  107. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  108. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  109. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +238 -0
  110. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  111. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  112. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  113. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +436 -0
  114. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  115. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  116. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  117. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +224 -0
  118. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  119. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
  120. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
  121. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
  122. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
  123. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
  124. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +996 -0
  125. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
  126. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
  127. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
  128. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +211 -0
  129. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
  130. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
  131. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
  132. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1193 -0
  133. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
  134. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
  135. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
  136. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2323 -0
  137. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
  138. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
  139. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
  140. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js +200 -0
  141. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
  142. package/lib/esm/tests/tests.d.ts.map +1 -1
  143. package/lib/esm/tests/tests.js +489 -255
  144. package/lib/esm/tests/tests.js.map +1 -1
  145. package/lib/tsconfig.tsbuildinfo +1 -1
  146. package/package.json +10 -10
  147. package/src/sdk.ts +4 -0
  148. package/src/tests/api_tests/auto_merge_form_submission.test.ts +13 -4
  149. package/src/tests/api_tests/custom_dashboards.test.ts +92 -0
  150. package/src/tests/api_tests/file_download_unicode_names.test.ts +223 -0
  151. package/src/tests/api_tests/healthie_multi_integration.test.ts +103 -5
  152. package/src/tests/api_tests/openloop_webhooks.test.ts +138 -0
  153. package/src/tests/api_tests/phone_calls_cold_transfer.test.ts +54 -0
  154. package/src/tests/api_tests/phone_calls_conference_hold.test.ts +62 -0
  155. package/src/tests/api_tests/scoped_api_keys.test.ts +5 -0
  156. package/src/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.ts +215 -0
  157. package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +38 -4
  158. package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +721 -0
  159. package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +125 -0
  160. package/src/tests/api_tests/security/formsort_webhook_auth_field_lock.test.ts +753 -0
  161. package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +1707 -0
  162. package/src/tests/api_tests/tickets_bulk_assign_care_team.test.ts +132 -0
  163. package/src/tests/tests.ts +232 -2
  164. package/test_generated.pdf +0 -0
  165. package/.env +0 -13
@@ -0,0 +1,721 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+
5
+ import { Session, EnduserSession } from "../../../sdk"
6
+ import {
7
+ assert,
8
+ log_header,
9
+ } from "@tellescope/testing"
10
+ import { setup_tests } from "../../setup"
11
+
12
+ const host = process.env.API_URL || 'http://localhost:8080' as const
13
+
14
+ /**
15
+ * Regression test for F-0166 — allowedPaths wildcard disables path confinement entirely.
16
+ *
17
+ * Several public, unauthenticated endpoints mint a JWT confined to a small set of API paths via an
18
+ * `allowedPaths` claim. The enforcement predicate (api/modules/authentication.ts checkAccess)
19
+ * contained:
20
+ *
21
+ * || (p.endsWith('*') && req.path.split('/')[0] === p.split('/')[0])
22
+ *
23
+ * Every request path begins with '/', so req.path.split('/')[0] is ''. Every wildcard entry also
24
+ * begins with '/', so p.split('/')[0] is ''. The clause reduces to '' === '' — always true. Any
25
+ * allowedPaths array containing a single '*' entry therefore matched EVERY request path and the
26
+ * 403 never fired.
27
+ *
28
+ * Two mint sites contain `/meeting/*`, and both are public and unauthenticated:
29
+ * - session_for_join_link — enduser session, MFA bypassed
30
+ * - session_for_start_link — full STAFF session with mfa: false
31
+ *
32
+ * So an unauthenticated caller holding a link token had an effectively unrestricted session.
33
+ *
34
+ * Fixing that exposed a second defect in the same primitive: entries matched by bare string prefix
35
+ * with no notion of HTTP method, so '/users' and '/endusers' were collection-NAMESPACE grants (22
36
+ * and 21 routes each). The worst consequence was a full escape — PATCH /users/consent called
37
+ * login_user on a freshly-read DB record with no options, minting a token with no allowedPaths at
38
+ * all, so two requests turned a start link into an unconfined staff session.
39
+ *
40
+ * Fix, in two parts:
41
+ * - entries are now method-scoped and exact ('get:/endusers'), with ':param' for segments not
42
+ * knowable at mint time ('get:/meeting/:id'). Un-prefixed and '*' entries remain honoured only
43
+ * so tokens minted before this grammar keep working until they expire.
44
+ * - consent / configure_inbox / switch_account forward req.session.allowedPaths into login_user.
45
+ *
46
+ * Scenarios:
47
+ * S1 public-form session — no wildcard; correctly confined throughout (regression anchor)
48
+ * S2 join-link session — RED before the wildcard fix
49
+ * S3 start-link session — RED before the wildcard fix (staff session; the severe case)
50
+ * S4 side-effects of the /calendar-events entry
51
+ * S6 namespace grants and the privilege-escalation chain — RED before method-scoped exact matching
52
+ * S5 ordinary sessions with no allowedPaths claim, incl. headless API-key callers, are unaffected
53
+ */
54
+
55
+ const RAND = () => Math.random().toString(36).slice(2, 10)
56
+ const OBJECT_ID = '68a1b2c3d4e5f60718293a4b'
57
+
58
+ type ProbeMethod = 'get' | 'post' | 'patch' | 'put' | 'delete'
59
+
60
+ type Probe = {
61
+ scenario: string,
62
+ label: string,
63
+ path: string,
64
+ expected: 'allowed' | 'denied',
65
+ status: number,
66
+ baselineStatus?: number,
67
+ pass: boolean,
68
+ note?: string,
69
+ }
70
+
71
+ export const allowed_paths_confinement_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
72
+ log_header("F-0166: allowedPaths wildcard bypasses session path confinement")
73
+
74
+ const probes: Probe[] = []
75
+
76
+ // Probe with raw axios rather than the SDK: parseError (sdk/src/session.ts) discards the status
77
+ // code, and a path-confinement denial is byte-identical to a permissions denial.
78
+ const request = async (token: string, method: ProbeMethod, path: string, data?: any) => {
79
+ const r = await axios.request({
80
+ method,
81
+ url: `${host}/v1${path}`,
82
+ ...method === 'get' ? { params: data } : { data },
83
+ headers: { Authorization: `Bearer ${token}` },
84
+ validateStatus: () => true,
85
+ })
86
+ return { status: r.status, body: r.data }
87
+ }
88
+
89
+ const isPathDenial = (r: { status: number, body: any }) => (
90
+ r.status === 403 && r.body?.message === 'Inaccessible'
91
+ )
92
+
93
+ /**
94
+ * A confined session must be denied `path`.
95
+ *
96
+ * First confirms an UNCONFINED session of the same type gets a non-403 on the same path in the
97
+ * same run. Without that, a 403 could just as easily be an ordinary permissions denial and the
98
+ * test would pass for the wrong reason.
99
+ */
100
+ const expectDenied = async (
101
+ scenario: string, label: string, confinedToken: string, baselineToken: string,
102
+ method: ProbeMethod, path: string, data?: any,
103
+ ) => {
104
+ const baseline = await request(baselineToken, method, path, data)
105
+ const confined = await request(confinedToken, method, path, data)
106
+
107
+ probes.push({
108
+ scenario,
109
+ label,
110
+ path,
111
+ expected: 'denied',
112
+ status: confined.status,
113
+ baselineStatus: baseline.status,
114
+ pass: baseline.status !== 403 && isPathDenial(confined),
115
+ note: baseline.status === 403 ? 'BAD PROBE: baseline session also got 403' : undefined,
116
+ })
117
+ }
118
+
119
+ /**
120
+ * The path must not be blocked by confinement.
121
+ *
122
+ * Asserts the status is neither 403 (path denied) nor 401 (unauthenticated) rather than === 200,
123
+ * so a 404 from a nonexistent id or a 400 from a stub body is not a false failure. Rejecting 401
124
+ * matters: without it a probe that never reached the handler would pass as "allowed".
125
+ * `allow401` opts out for paths the session legitimately cannot authenticate against.
126
+ */
127
+ const expectAllowed = async (
128
+ scenario: string, label: string, token: string,
129
+ method: ProbeMethod, path: string, data?: any,
130
+ { allow401 = false }: { allow401?: boolean } = {},
131
+ ) => {
132
+ const r = await request(token, method, path, data)
133
+ probes.push({
134
+ scenario,
135
+ label,
136
+ path,
137
+ expected: 'allowed',
138
+ status: r.status,
139
+ pass: r.status !== 403 && (allow401 || r.status !== 401),
140
+ note: allow401 && r.status === 401 ? '401 expected: enduser cannot authenticate this model' : undefined,
141
+ })
142
+ }
143
+
144
+ let formId: string | undefined
145
+ let productId: string | undefined
146
+ let joinEnduserId: string | undefined
147
+ let formEnduserEmail: string | undefined
148
+ let hostUserId: string | undefined
149
+ let templateId: string | undefined
150
+ let bookingEnduserId: string | undefined
151
+ const eventIds: string[] = []
152
+
153
+ try {
154
+ /* ================= fixtures ================= */
155
+
156
+ const businessId = sdk.userInfo.businessId
157
+
158
+ // -- public form (no wildcard in its allowedPaths)
159
+ const form = await sdk.api.forms.createOne({
160
+ title: `F-0166 path confinement ${RAND()}`,
161
+ allowPublicURL: true,
162
+ })
163
+ formId = form.id
164
+ await sdk.api.form_fields.createOne({
165
+ formId: form.id,
166
+ title: 'q',
167
+ type: 'string',
168
+ previousFields: [{ type: 'root', info: {} }],
169
+ })
170
+ formEnduserEmail = `f0166-form-${RAND()}@tellescope.example`
171
+ const publicFormSession = await new EnduserSession({ host, businessId })
172
+ .api.form_responses.session_for_public_form({
173
+ businessId,
174
+ formId: form.id,
175
+ email: formEnduserEmail,
176
+ fname: 'Path',
177
+ lname: 'Confinement',
178
+ skipMatch: true, // also skips the intake-phone requirement
179
+ })
180
+
181
+ const product = await sdk.api.products.createOne({
182
+ title: `F-0166 product ${RAND()}`,
183
+ cost: { amount: 500, currency: 'USD' },
184
+ processor: 'Stripe',
185
+ } as any)
186
+ productId = product.id
187
+
188
+ // -- join link (enduser session, allowedPaths contains /meeting/*)
189
+ const joinEnduser = await sdk.api.endusers.createOne({
190
+ email: `f0166-join-${RAND()}@tellescope.example`,
191
+ })
192
+ joinEnduserId = joinEnduser.id
193
+
194
+ const joinLinkToken = `f0166-join-${RAND()}${RAND()}`
195
+ const joinEvent = await sdk.api.calendar_events.createOne({
196
+ title: 'F-0166 join', durationInMinutes: 30, startTimeInMS: Date.now() + 3600_000,
197
+ attendees: [{ type: 'enduser', id: joinEnduser.id, joinLinkToken }],
198
+ } as any)
199
+ eventIds.push(joinEvent.id)
200
+
201
+ const unrelatedEvent = await sdk.api.calendar_events.createOne({
202
+ title: 'F-0166 unrelated', durationInMinutes: 30, startTimeInMS: Date.now() + 3600_000,
203
+ attendees: [{ type: 'enduser', id: joinEnduser.id }],
204
+ } as any)
205
+ eventIds.push(unrelatedEvent.id)
206
+
207
+ const joinSession = await new EnduserSession({ host, businessId })
208
+ .api.calendar_events.session_for_join_link({ token: joinLinkToken })
209
+
210
+ // -- start link (STAFF session, allowedPaths contains /meeting/*)
211
+ // throwaway user; verifiedEmail must be set at create time (it is rejected on update)
212
+ const hostUser = await sdk.api.users.createOne({
213
+ email: `f0166-host-${RAND()}@tellescope.example`,
214
+ fname: 'Path', lname: 'Host',
215
+ verifiedEmail: true,
216
+ notificationEmailsDisabled: true,
217
+ } as any)
218
+ hostUserId = hostUser.id
219
+
220
+ const startLinkToken = `f0166-start-${RAND()}${RAND()}`
221
+ const startEvent = await sdk.api.calendar_events.createOne({
222
+ title: 'F-0166 start', durationInMinutes: 30, startTimeInMS: Date.now() + 3600_000,
223
+ attendees: [{ type: 'user', id: hostUser.id }, { type: 'enduser', id: joinEnduser.id }],
224
+ startLinkToken,
225
+ } as any)
226
+ eventIds.push(startEvent.id)
227
+
228
+ const startSession = await sdk.api.calendar_events.session_for_start_link({ token: startLinkToken })
229
+
230
+ // -- unconfined baselines of each session type (generate_auth_token, never .authenticate)
231
+ const { authToken: baselineEnduserToken } = await sdk.api.endusers.generate_auth_token({ id: joinEnduser.id })
232
+ const { authToken: baselineUserToken } = await sdk.api.users.generate_auth_token({ id: hostUser.id })
233
+
234
+ /* ============ S1: public-form session — no wildcard, correctly confined already ============ */
235
+
236
+ const S1 = 'S1 public-form'
237
+ await expectAllowed(S1, 'always-allowed path', publicFormSession.authToken, 'get', '/test-authenticated')
238
+ await expectAllowed(S1, 'listed: form info for access code', publicFormSession.authToken, 'get', '/form-info-for-access-code', { accessCode: publicFormSession.accessCode })
239
+ await expectAllowed(S1, 'listed: refresh enduser session', publicFormSession.authToken, 'post', '/refresh-enduser-session')
240
+ await expectAllowed(S1, 'listed: product by id', publicFormSession.authToken, 'get', `/product/${productId}`)
241
+
242
+ await expectDenied(S1, 'unlisted: calendar events', publicFormSession.authToken, baselineEnduserToken, 'get', '/calendar-events')
243
+ await expectDenied(S1, 'unlisted: form responses', publicFormSession.authToken, baselineEnduserToken, 'get', '/form-responses')
244
+ await expectDenied(S1, 'unlisted: files', publicFormSession.authToken, baselineEnduserToken, 'get', '/files')
245
+
246
+ // 'get:/product' names one route, so it no longer leaks the whole /products namespace the way a
247
+ // bare '/product' prefix entry did — that used to reach /products/prepare-stripe-checkout too
248
+ await expectDenied(S1, 'exact matching: /products not granted by get:/product', publicFormSession.authToken, baselineEnduserToken, 'get', '/products')
249
+
250
+ /* ---- field confinement on the granted generic update route ----
251
+ * 'patch:/enduser/:id' is granted for Portal V2's terms gate, so the route check alone no longer
252
+ * protects this session — SESSION_SCOPE_WRITABLE_FIELDS does. These probes are the live control:
253
+ * the consent write lands, and every contactable/identity field is refused. Without them the
254
+ * module test is the only thing standing between a scope-table edit and account takeover.
255
+ */
256
+ const formEnduserId = publicFormSession.enduserId
257
+
258
+ // The refresh payload for a fromPublicSession token must carry the session's OWN id — portal V2
259
+ // seeds its store from it and then addresses itself by id, so without it every write goes to
260
+ // /enduser/undefined — while still withholding everything else about the patient. Both halves
261
+ // matter: adding the id must not become a foothold for widening this payload.
262
+ const publicRefresh = await request(publicFormSession.authToken, 'post', '/refresh-enduser-session', {})
263
+ const refreshedEnduser = publicRefresh.body?.enduser ?? {}
264
+ probes.push({
265
+ scenario: S1,
266
+ label: 'public refresh exposes the session\'s own id',
267
+ path: '/refresh-enduser-session',
268
+ expected: 'allowed',
269
+ status: refreshedEnduser.id === formEnduserId ? 200 : 500,
270
+ pass: refreshedEnduser.id === formEnduserId,
271
+ note: `id=${refreshedEnduser.id}`,
272
+ })
273
+ const withheld = ['email', 'phone', 'dateOfBirth', 'fname', 'lname', 'note', 'insurance', 'tags']
274
+ .filter(f => refreshedEnduser[f] !== undefined)
275
+ probes.push({
276
+ scenario: S1,
277
+ label: 'public refresh still withholds the patient record',
278
+ path: '/refresh-enduser-session',
279
+ expected: 'denied',
280
+ status: withheld.length ? 200 : 403,
281
+ pass: withheld.length === 0,
282
+ note: withheld.length ? `LEAKED [${withheld}]` : `keys=[${Object.keys(refreshedEnduser)}]`,
283
+ })
284
+
285
+ await expectAllowed(
286
+ S1, 'field-restricted: may record terms consent', publicFormSession.authToken,
287
+ 'patch', `/enduser/${formEnduserId}`,
288
+ { updates: { termsSigned: new Date().toISOString(), termsVersion: '1' } },
289
+ )
290
+
291
+ // The write being confined is not enough: the update RESPONSE is not redacted for enduser sessions
292
+ // (applyRedactions runs only for user sessions on that path), so an unprojected echo would hand
293
+ // this session the record it can't otherwise read — GET /enduser/:id is not even in this scope.
294
+ // Assert the response carries ONLY what the session was allowed to write.
295
+ const consentEcho = await request(
296
+ publicFormSession.authToken, 'patch', `/enduser/${formEnduserId}`,
297
+ { updates: { termsVersion: '2' } },
298
+ )
299
+ const echoedKeys = Object.keys(consentEcho.body ?? {})
300
+ const leakedKeys = echoedKeys.filter(k => !['id', 'termsSigned', 'termsVersion'].includes(k))
301
+ probes.push({
302
+ scenario: S1,
303
+ label: 'field-restricted: update response echoes only the writable fields',
304
+ path: `/enduser/${formEnduserId}`,
305
+ expected: 'allowed',
306
+ status: leakedKeys.length === 0 ? consentEcho.status : 500,
307
+ pass: consentEcho.status === 200 && leakedKeys.length === 0,
308
+ note: leakedKeys.length ? `LEAKED [${leakedKeys}]` : `echoed [${echoedKeys}]`,
309
+ })
310
+
311
+ // email/phone are the takeover vector: password reset resolves by findOne({ email }) and OTP codes
312
+ // deliver to enduser.phone. dateOfBirth/fname are the public-form MATCH keys, so writing them would
313
+ // let a session redirect which patient a later submission attaches to.
314
+ const fieldsThatMustBeRefused: [string, any][] = [
315
+ ['email', `f0166-takeover-${RAND()}@tellescope.example`],
316
+ ['phone', '+15555550123'],
317
+ ['landline', '+15555550124'],
318
+ ['dateOfBirth', '1990-01-01'],
319
+ ['fname', 'F0166Overwritten'],
320
+ ]
321
+ for (const [field, value] of fieldsThatMustBeRefused) {
322
+ const r = await request(
323
+ publicFormSession.authToken, 'patch', `/enduser/${formEnduserId}`, { updates: { [field]: value } },
324
+ )
325
+ probes.push({
326
+ scenario: S1,
327
+ label: `field-restricted: cannot write enduser '${field}'`,
328
+ path: `/enduser/${formEnduserId}`,
329
+ expected: 'denied',
330
+ status: r.status,
331
+ pass: r.status === 403,
332
+ note: 'route is granted; the field allowlist is what refuses this',
333
+ })
334
+ }
335
+
336
+ // the field guard must reject the whole request rather than filtering, or the allowed key would
337
+ // carry a disallowed one through alongside it
338
+ const mixed = await request(
339
+ publicFormSession.authToken, 'patch', `/enduser/${formEnduserId}`,
340
+ { updates: { termsVersion: '2', email: `f0166-mixed-${RAND()}@tellescope.example` } },
341
+ )
342
+ probes.push({
343
+ scenario: S1,
344
+ label: 'field-restricted: a mixed allowed+denied update is refused whole',
345
+ path: `/enduser/${formEnduserId}`,
346
+ expected: 'denied',
347
+ status: mixed.status,
348
+ pass: mixed.status === 403,
349
+ note: 'no partial application — email must not ride along with termsVersion',
350
+ })
351
+
352
+ // and the email on record must be untouched by all of the above
353
+ const afterProbes = await sdk.api.endusers.getOne(formEnduserId)
354
+ probes.push({
355
+ scenario: S1,
356
+ label: 'field-restricted: stored email survived every attempt',
357
+ path: `/enduser/${formEnduserId}`,
358
+ expected: 'denied',
359
+ status: afterProbes.email === formEnduserEmail ? 403 : 200,
360
+ pass: afterProbes.email === formEnduserEmail,
361
+ note: `stored=${afterProbes.email}`,
362
+ })
363
+
364
+ /* ============ S2: join-link session — RED before the fix ============ */
365
+
366
+ const S2 = 'S2 join-link (enduser)'
367
+ await expectDenied(S2, 'unlisted: form responses', joinSession.authToken, baselineEnduserToken, 'get', '/form-responses')
368
+ await expectDenied(S2, 'unlisted: files', joinSession.authToken, baselineEnduserToken, 'get', '/files')
369
+ await expectDenied(S2, 'unlisted: portal customizations', joinSession.authToken, baselineEnduserToken, 'get', '/portal-customizations')
370
+ await expectDenied(S2, 'unlisted: UNRELATED calendar event', joinSession.authToken, baselineEnduserToken, 'get', `/calendar-event/${unrelatedEvent.id}`)
371
+
372
+ await expectAllowed(S2, 'always-allowed path', joinSession.authToken, 'get', '/test-authenticated')
373
+ await expectAllowed(S2, 'listed: my meetings', joinSession.authToken, 'get', '/my-meetings')
374
+ // enduserActions for organizations is {}, so an enduser 401s here regardless of confinement
375
+ await expectAllowed(S2, 'listed: organizations', joinSession.authToken, 'get', '/organizations', undefined, { allow401: true })
376
+ await expectAllowed(S2, 'listed: users', joinSession.authToken, 'get', '/users')
377
+ await expectAllowed(S2, 'listed: pinned calendar event', joinSession.authToken, 'get', `/calendar-event/${joinEvent.id}`)
378
+ await expectAllowed(S2, 'wildcard: meeting by id', joinSession.authToken, 'get', `/meeting/${OBJECT_ID}`)
379
+ // the two paths widened in this change so the portal video flow keeps working
380
+ await expectAllowed(S2, 'widened: calendar events list', joinSession.authToken, 'get', '/calendar-events')
381
+ await expectAllowed(S2, 'widened: join meeting for event', joinSession.authToken, 'post', '/join-meeting-for-event', { calendarEventId: joinEvent.id })
382
+
383
+ // The WaitingRoom marks attendance here. This write is deliberately NOT field-restricted: the
384
+ // portal reads the event back through useCalendarEvents, whose updateElement REPLACES the local
385
+ // record with the update response (react state.tsx), so a truncated response would drop event
386
+ // fields out of local state mid-visit and break a live Twilio call. Assert both halves of that
387
+ // contract — the write lands, AND the response is still a whole event.
388
+ const attendanceWrite = await request(
389
+ joinSession.authToken, 'patch', `/calendar-event/${joinEvent.id}`,
390
+ { updates: { joinedVideoCall: [{ id: joinEnduserId, at: new Date().toISOString() }] } },
391
+ )
392
+ probes.push({
393
+ scenario: S2,
394
+ label: 'marks joinedVideoCall on its pinned event',
395
+ path: `/calendar-event/${joinEvent.id}`,
396
+ expected: 'allowed',
397
+ status: attendanceWrite.status,
398
+ pass: attendanceWrite.status === 200,
399
+ })
400
+ // title/durationInMinutes were set at fixture creation, so their presence proves the response was
401
+ // not projected down to the written field
402
+ const echoedEvent = attendanceWrite.body ?? {}
403
+ probes.push({
404
+ scenario: S2,
405
+ label: 'attendance write returns the WHOLE event (client replaces local state from it)',
406
+ path: `/calendar-event/${joinEvent.id}`,
407
+ expected: 'allowed',
408
+ status: (echoedEvent.title && echoedEvent.durationInMinutes) ? 200 : 500,
409
+ pass: !!echoedEvent.title && !!echoedEvent.durationInMinutes && !!echoedEvent.joinedVideoCall,
410
+ note: `echoed ${Object.keys(echoedEvent).length} keys`,
411
+ })
412
+
413
+ // confinement must survive a refresh rather than widening
414
+ const refreshedJoin = await request(joinSession.authToken, 'post', '/refresh-enduser-session', {})
415
+ const refreshedJoinToken = refreshedJoin.body?.authToken
416
+ probes.push({
417
+ scenario: S2, label: 'refresh returned a new token', path: '/refresh-enduser-session',
418
+ expected: 'allowed', status: refreshedJoin.status, pass: !!refreshedJoinToken,
419
+ })
420
+ if (refreshedJoinToken) {
421
+ await expectDenied(S2, 'after refresh: still denied form responses', refreshedJoinToken, baselineEnduserToken, 'get', '/form-responses')
422
+ await expectAllowed(S2, 'after refresh: still allowed my meetings', refreshedJoinToken, 'get', '/my-meetings')
423
+ }
424
+
425
+ /* ============ S3: start-link session — STAFF, RED before the fix ============ */
426
+
427
+ const S3 = 'S3 start-link (STAFF)'
428
+ await expectDenied(S3, 'unlisted: form responses', startSession.authToken, baselineUserToken, 'get', '/form-responses')
429
+ await expectDenied(S3, 'unlisted: journeys', startSession.authToken, baselineUserToken, 'get', '/journeys')
430
+ await expectDenied(S3, 'unlisted: tickets', startSession.authToken, baselineUserToken, 'get', '/tickets')
431
+ await expectDenied(S3, 'unlisted: data sync (org-wide change firehose)', startSession.authToken, baselineUserToken, 'get', '/data-sync', { from: new Date(Date.now() - 60_000).toISOString() })
432
+ await expectDenied(S3, 'unlisted: file download URL', startSession.authToken, baselineUserToken, 'get', '/file-download-URL', { secureName: 'f0166-does-not-exist' })
433
+ await expectDenied(S3, 'unlisted: UNRELATED calendar event', startSession.authToken, baselineUserToken, 'get', `/calendar-event/${unrelatedEvent.id}`)
434
+
435
+ await expectAllowed(S3, 'always-allowed path', startSession.authToken, 'get', '/test-authenticated')
436
+ await expectAllowed(S3, 'listed: my meetings', startSession.authToken, 'get', '/my-meetings')
437
+ await expectAllowed(S3, 'listed: calendar events', startSession.authToken, 'get', '/calendar-events')
438
+ await expectAllowed(S3, 'listed: organizations', startSession.authToken, 'get', '/organizations')
439
+ await expectAllowed(S3, 'listed: users', startSession.authToken, 'get', '/users')
440
+ await expectAllowed(S3, 'listed: endusers', startSession.authToken, 'get', '/endusers')
441
+ await expectAllowed(S3, 'listed: pinned calendar event', startSession.authToken, 'get', `/calendar-event/${startEvent.id}`)
442
+ await expectAllowed(S3, 'wildcard: meeting by id', startSession.authToken, 'get', `/meeting/${OBJECT_ID}`)
443
+ await expectAllowed(S3, 'prefix descendant: users bulk-read', startSession.authToken, 'post', '/users/bulk-read', { ids: [hostUser.id] })
444
+ await expectAllowed(S3, 'prefix descendant: endusers bulk-read', startSession.authToken, 'post', '/endusers/bulk-read', { ids: [joinEnduser.id] })
445
+ await expectAllowed(S3, 'widened: join meeting for event', startSession.authToken, 'post', '/join-meeting-for-event', { calendarEventId: startEvent.id })
446
+ await expectAllowed(S3, 'widened: add attendees to meeting', startSession.authToken, 'post', '/add-attendees-to-meeting', { id: OBJECT_ID })
447
+
448
+ // login_user propagates allowedPaths implicitly through its ...toSign rest spread — the
449
+ // "(ASSUMPTION)" comments in the mint sites depend on this, so assert it explicitly
450
+ const refreshedStart = await request(startSession.authToken, 'post', '/refresh-session', {})
451
+ const refreshedStartToken = refreshedStart.body?.authToken
452
+ probes.push({
453
+ scenario: S3, label: 'refresh returned a new token', path: '/refresh-session',
454
+ expected: 'allowed', status: refreshedStart.status, pass: !!refreshedStartToken,
455
+ })
456
+ if (refreshedStartToken) {
457
+ await expectDenied(S3, 'after refresh: still denied journeys', refreshedStartToken, baselineUserToken, 'get', '/journeys')
458
+ await expectAllowed(S3, 'after refresh: still allowed my meetings', refreshedStartToken, 'get', '/my-meetings')
459
+ }
460
+
461
+ /* ============ S4: side-effects of the widened /calendar-events entry ============ */
462
+
463
+ // Non-wildcard entries still match by bare prefix, so adding '/calendar-events' to the join-link
464
+ // list also prefix-grants '/calendar-events/bulk-update' and friends. Those are staff-only and
465
+ // gated by enduserActions, so an enduser session is still refused — assert that rather than
466
+ // assume it. This is the concrete argument for the segment-boundary follow-up.
467
+ const S4 = 'S4 widening side-effects'
468
+ const prefixGranted: [('patch' | 'post'), string][] = [
469
+ ['patch', '/calendar-events/bulk-update'],
470
+ ['post', '/calendar-events/report'],
471
+ ]
472
+ for (const [method, path] of prefixGranted) {
473
+ const r = await axios.request({
474
+ method,
475
+ url: `${host}/v1${path}`,
476
+ data: {},
477
+ headers: { Authorization: `Bearer ${joinSession.authToken}` },
478
+ validateStatus: () => true,
479
+ })
480
+ probes.push({
481
+ scenario: S4,
482
+ label: `enduser action gating blocks ${method.toUpperCase()} ${path}`,
483
+ path,
484
+ expected: 'denied',
485
+ status: r.status,
486
+ pass: r.status === 401 || r.status === 403,
487
+ note: 'prefix-granted by /calendar-events; blocked by enduserActions instead',
488
+ })
489
+ }
490
+
491
+ /* ============ S6: the namespace grants and the privilege-escalation chain ============ */
492
+
493
+ // Before method-scoped exact matching, '/users' and '/endusers' were collection-namespace
494
+ // grants: 22 and 21 routes each. The worst of them, PATCH /users/consent, called
495
+ // login_user on a fresh DB record and minted a token with NO allowedPaths at all — so an
496
+ // unauthenticated start link could be traded for a fully unconfined staff session.
497
+ const S6 = 'S6 namespace escapes'
498
+
499
+ // expectDenied issues the BASELINE request for real, so mutating probes must be given inert
500
+ // payloads — nonexistent ids or bodies that fail validation. A 400/404 baseline still proves the
501
+ // route is reachable (the check is only `!== 403`). Passing real ids here once made the
502
+ // unconfined baseline actually perform an /endusers/merge, deleting the fixture enduser.
503
+ const ABSENT_ID_A = '68a1b2c3d4e5f60718293a4b'
504
+ const ABSENT_ID_B = '68a1b2c3d4e5f60718293a4c'
505
+ const INVALID_EMAIL = 'f0166-not-an-email'
506
+
507
+ await expectDenied(S6, 'ESCAPE: /users/consent (minted an unconfined token)', startSession.authToken, baselineUserToken, 'patch', '/users/consent', { termsVersion: '1' })
508
+ await expectDenied(S6, 'ESCAPE: /users/switch-account', startSession.authToken, baselineUserToken, 'post', '/users/switch-account', { targetUserId: ABSENT_ID_A })
509
+ await expectDenied(S6, 'ESCAPE: /users/configure-inbox', startSession.authToken, baselineUserToken, 'post', '/users/configure-inbox', {})
510
+ await expectDenied(S6, 'PHI: /endusers/load-inbox-data', startSession.authToken, baselineUserToken, 'get', '/endusers/load-inbox-data')
511
+ await expectDenied(S6, 'PHI: /endusers/report', startSession.authToken, baselineUserToken, 'get', '/endusers/report')
512
+ await expectDenied(S6, 'PHI: /endusers/engagement', startSession.authToken, baselineUserToken, 'get', '/endusers/engagement')
513
+ await expectDenied(S6, 'DESTRUCTIVE: /endusers/merge', startSession.authToken, baselineUserToken, 'post', '/endusers/merge', { sourceEnduserId: ABSENT_ID_A, destinationEnduserId: ABSENT_ID_B })
514
+ await expectDenied(S6, 'DESTRUCTIVE: /endusers/bulk-update', startSession.authToken, baselineUserToken, 'patch', '/endusers/bulk-update', { ids: [ABSENT_ID_A], fields: {} })
515
+
516
+ // method scoping, end to end: the same path is readable but not writable.
517
+ // Baselines use the ADMIN token — the throwaway host user cannot create users or endusers, so it
518
+ // would 403 on permissions and the probe could not distinguish that from a confinement denial.
519
+ await expectAllowed(S6, 'method scoping: GET /endusers allowed', startSession.authToken, 'get', '/endusers')
520
+ await expectDenied(S6, 'method scoping: POST /endusers (create) denied', startSession.authToken, sdk.authToken, 'post', '/endusers', { email: INVALID_EMAIL })
521
+ await expectAllowed(S6, 'method scoping: GET /users allowed', startSession.authToken, 'get', '/users')
522
+ await expectDenied(S6, 'method scoping: POST /users (create) denied', startSession.authToken, sdk.authToken, 'post', '/users', { email: INVALID_EMAIL })
523
+
524
+ // even if consent were reachable, the token it mints must stay confined (defense in depth)
525
+ const consentAttempt = await request(startSession.authToken, 'patch', '/users/consent', { termsVersion: '1' })
526
+ if (consentAttempt.status === 200 && consentAttempt.body?.authToken) {
527
+ await expectDenied(S6, 'consent-minted token is still confined', consentAttempt.body.authToken, baselineUserToken, 'get', '/journeys')
528
+ }
529
+
530
+ // Confinement now lives in a `sessionScopes` claim resolved server-side. Refresh must carry that
531
+ // claim, not silently drop it — login_user's toSign is a deny-list so it propagates, but that is
532
+ // exactly the kind of implicit behavior worth asserting.
533
+ const refreshedForScopes = await request(startSession.authToken, 'post', '/refresh-session', {})
534
+ const rescopedToken = refreshedForScopes.body?.authToken
535
+ probes.push({
536
+ scenario: S6, label: 'refresh returned a new token', path: '/refresh-session',
537
+ expected: 'allowed', status: refreshedForScopes.status, pass: !!rescopedToken,
538
+ })
539
+ if (rescopedToken) {
540
+ await expectDenied(S6, 'after refresh: still cannot reach /users/consent', rescopedToken, baselineUserToken, 'patch', '/users/consent', { termsVersion: '1' })
541
+ await expectDenied(S6, 'after refresh: still cannot reach /endusers/load-inbox-data', rescopedToken, baselineUserToken, 'get', '/endusers/load-inbox-data')
542
+ await expectDenied(S6, 'after refresh: POST /endusers still denied', rescopedToken, baselineUserToken, 'post', '/endusers', { email: `f0166-should-not-exist-${RAND()}@tellescope.example` })
543
+ await expectAllowed(S6, 'after refresh: GET /endusers still allowed', rescopedToken, 'get', '/endusers')
544
+ await expectAllowed(S6, 'after refresh: my-meetings still allowed', rescopedToken, 'get', '/my-meetings')
545
+ }
546
+
547
+ /* ============ S7: appointment-booking session — the SAME terms gate as S1 ============
548
+ * Portal V2's terms gate wraps the booking route too, so this scope needs the same enduser write
549
+ * (and only that write). Without the grant the gate is unpassable and the patient never reaches the
550
+ * booking page at all. Field restriction and response projection must behave exactly as in S1 —
551
+ * mirrored here rather than assumed, since the two scopes are separate table entries.
552
+ */
553
+ const S7 = 'S7 appointment-booking'
554
+
555
+ const bookingTemplate = await sdk.api.calendar_event_templates.createOne({
556
+ title: `F-0166 booking ${RAND()}`, durationInMinutes: 30,
557
+ })
558
+ templateId = bookingTemplate.id
559
+
560
+ const bookingEnduserEmail = `f0166-booking-${RAND()}@tellescope.example`
561
+ const bookingEnduser = await sdk.api.endusers.createOne({
562
+ email: bookingEnduserEmail, fname: 'Booking', lname: 'Confinement',
563
+ })
564
+ bookingEnduserId = bookingEnduser.id
565
+
566
+ const bookingSession = await new EnduserSession({ host, businessId })
567
+ .api.calendar_events.session_for_public_appointment_booking({
568
+ businessId,
569
+ calendarEventTemplateId: bookingTemplate.id,
570
+ email: bookingEnduserEmail,
571
+ fname: 'Booking',
572
+ lname: 'Confinement',
573
+ })
574
+
575
+ await expectAllowed(
576
+ S7, 'field-restricted: may record terms consent', bookingSession.authToken,
577
+ 'patch', `/enduser/${bookingEnduser.id}`,
578
+ { updates: { termsSigned: new Date().toISOString(), termsVersion: '1' } },
579
+ )
580
+
581
+ const bookingEcho = await request(
582
+ bookingSession.authToken, 'patch', `/enduser/${bookingEnduser.id}`, { updates: { termsVersion: '2' } },
583
+ )
584
+ const bookingLeaked = Object.keys(bookingEcho.body ?? {})
585
+ .filter(k => !['id', 'termsSigned', 'termsVersion'].includes(k))
586
+ probes.push({
587
+ scenario: S7,
588
+ label: 'field-restricted: update response echoes only the writable fields',
589
+ path: `/enduser/${bookingEnduser.id}`,
590
+ expected: 'allowed',
591
+ status: bookingLeaked.length === 0 ? bookingEcho.status : 500,
592
+ pass: bookingEcho.status === 200 && bookingLeaked.length === 0,
593
+ note: bookingLeaked.length ? `LEAKED [${bookingLeaked}]` : undefined,
594
+ })
595
+
596
+ for (const [field, value] of [['email', `f0166-bt-${RAND()}@tellescope.example`], ['phone', '+15555550188']] as [string, any][]) {
597
+ const r = await request(bookingSession.authToken, 'patch', `/enduser/${bookingEnduser.id}`, { updates: { [field]: value } })
598
+ probes.push({
599
+ scenario: S7,
600
+ label: `field-restricted: cannot write enduser '${field}'`,
601
+ path: `/enduser/${bookingEnduser.id}`,
602
+ expected: 'denied',
603
+ status: r.status,
604
+ pass: r.status === 403,
605
+ note: 'route is granted; the field allowlist is what refuses this',
606
+ })
607
+ }
608
+
609
+ // the booking scope must not have gained anything else along with the enduser write
610
+ await expectDenied(S7, 'unlisted: form responses', bookingSession.authToken, baselineEnduserToken, 'get', '/form-responses')
611
+ await expectDenied(S7, 'unlisted: calendar events', bookingSession.authToken, baselineEnduserToken, 'get', '/calendar-events')
612
+ await expectAllowed(S7, 'listed: refresh enduser session', bookingSession.authToken, 'post', '/refresh-enduser-session')
613
+
614
+ /* ============ S5: ordinary sessions with no allowedPaths claim ============ */
615
+
616
+ const S5 = 'S5 unconfined sessions'
617
+ for (const path of ['/journeys', '/form-responses', '/endusers', '/calendar-events', '/tickets']) {
618
+ await expectAllowed(S5, `staff session reaches ${path}`, baselineUserToken, 'get', path)
619
+ }
620
+ await expectAllowed(S5, 'staff session reaches /data-sync', baselineUserToken, 'get', '/data-sync', { from: new Date(Date.now() - 60_000).toISOString() })
621
+ for (const path of ['/form-responses', '/calendar-events', '/files']) {
622
+ await expectAllowed(S5, `enduser session reaches ${path}`, baselineEnduserToken, 'get', path)
623
+ }
624
+ const adminAuthenticated = await sdk.test_authenticated()
625
+ probes.push({
626
+ scenario: S5, label: 'admin sdk still authenticated', path: '/test-authenticated',
627
+ expected: 'allowed', status: 200, pass: adminAuthenticated === 'Authenticated!',
628
+ })
629
+
630
+ // Headless / server-to-server callers authenticate with an API key, which resolves to a DB user
631
+ // record. `allowedPaths` is a JWT claim only (WithAllowedPaths in types-models is used solely on
632
+ // the session type; there is no schema validator for it), so an API-key session can never carry
633
+ // one and is never path-confined. Assert that rather than reason about it.
634
+ const apiKeySDK = new Session({ host, apiKey: '3n5q0SCBT_iUvZz-b9BJtX7o7HQUVJ9v132PgHJNJsg.' })
635
+ for (const path of ['/journeys', '/form-responses', '/endusers', '/calendar-events', '/files']) {
636
+ const r = await axios.request({
637
+ method: 'get',
638
+ url: `${host}/v1${path}`,
639
+ headers: { Authorization: `API_KEY ${(apiKeySDK as any).apiKey}` },
640
+ validateStatus: () => true,
641
+ })
642
+ probes.push({
643
+ scenario: S5,
644
+ label: `API key (headless) reaches ${path}`,
645
+ path,
646
+ expected: 'allowed',
647
+ status: r.status,
648
+ pass: r.status !== 403,
649
+ })
650
+ }
651
+
652
+ /* ============ report, then assert ============ */
653
+
654
+ // Collect everything before asserting. @tellescope/testing sets EXIT_ON_FIRST_ERROR, so
655
+ // asserting inline would abort at the first failure and hide the rest of the bypass.
656
+ const failures = probes.filter(p => !p.pass)
657
+
658
+ console.log('')
659
+ console.log(' F-0166 — full probe table')
660
+ console.log(' ' + '-'.repeat(112))
661
+ const scenarios = probes.map(p => p.scenario).filter((s, i, all) => all.indexOf(s) === i)
662
+ for (const scenario of scenarios) {
663
+ const inScenario = probes.filter(p => p.scenario === scenario)
664
+ const failed = inScenario.filter(p => !p.pass).length
665
+ console.log(` ${scenario}: ${inScenario.length - failed}/${inScenario.length} passing${failed ? ` <-- ${failed} FAILING` : ''}`)
666
+ for (const p of inScenario) {
667
+ const baseline = p.baselineStatus !== undefined ? ` baseline=${p.baselineStatus}` : ''
668
+ console.log(` ${p.pass ? ' ok ' : 'FAIL'} want=${p.expected.padEnd(7)} got=${p.status}${baseline} ${p.label}${p.note ? ` (${p.note})` : ''}`)
669
+ }
670
+ }
671
+ console.log(' ' + '-'.repeat(112))
672
+ console.log(` TOTAL: ${probes.length - failures.length}/${probes.length} passing, ${failures.length} failing`)
673
+ console.log('')
674
+
675
+ for (const p of probes) {
676
+ assert(
677
+ p.pass,
678
+ `${p.scenario} — ${p.label} (${p.path}): wanted ${p.expected}, got status ${p.status}`
679
+ + (p.baselineStatus !== undefined ? ` (unconfined baseline got ${p.baselineStatus})` : ''),
680
+ `${p.scenario} — ${p.label}`,
681
+ )
682
+ }
683
+ } finally {
684
+ for (const id of eventIds) {
685
+ try { await sdk.api.calendar_events.deleteOne(id) } catch {}
686
+ }
687
+ if (joinEnduserId) { try { await sdk.api.endusers.deleteOne(joinEnduserId) } catch {} }
688
+ if (formEnduserEmail) {
689
+ try {
690
+ const e = await sdk.api.endusers.getOne({ email: formEnduserEmail })
691
+ if (e?.id) await sdk.api.endusers.deleteOne(e.id)
692
+ } catch {}
693
+ }
694
+ if (hostUserId) { try { await sdk.api.users.deleteOne(hostUserId) } catch {} }
695
+ if (bookingEnduserId) { try { await sdk.api.endusers.deleteOne(bookingEnduserId) } catch {} }
696
+ if (templateId) { try { await sdk.api.calendar_event_templates.deleteOne(templateId) } catch {} }
697
+ if (productId) { try { await sdk.api.products.deleteOne(productId) } catch {} }
698
+ if (formId) { try { await sdk.api.forms.deleteOne(formId) } catch {} }
699
+ }
700
+ }
701
+
702
+ if (require.main === module) {
703
+ console.log(`🌐 Using API URL: ${host}`)
704
+ const sdk = new Session({ host })
705
+ const sdkNonAdmin = new Session({ host })
706
+
707
+ const runTests = async () => {
708
+ await setup_tests(sdk, sdkNonAdmin)
709
+ await allowed_paths_confinement_tests({ sdk, sdkNonAdmin })
710
+ }
711
+
712
+ runTests()
713
+ .then(() => {
714
+ console.log("✅ F-0166 allowedPaths confinement test suite completed successfully")
715
+ process.exit(0)
716
+ })
717
+ .catch((error) => {
718
+ console.error("❌ F-0166 allowedPaths confinement test suite failed:", error)
719
+ process.exit(1)
720
+ })
721
+ }