@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,2323 @@
1
+ var __assign = (this && this.__assign) || function () {
2
+ __assign = Object.assign || function(t) {
3
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
4
+ s = arguments[i];
5
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
6
+ t[p] = s[p];
7
+ }
8
+ return t;
9
+ };
10
+ return __assign.apply(this, arguments);
11
+ };
12
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
13
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
14
+ return new (P || (P = Promise))(function (resolve, reject) {
15
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
16
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
17
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
18
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
19
+ });
20
+ };
21
+ var __generator = (this && this.__generator) || function (thisArg, body) {
22
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
23
+ return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
24
+ function verb(n) { return function (v) { return step([n, v]); }; }
25
+ function step(op) {
26
+ if (f) throw new TypeError("Generator is already executing.");
27
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
28
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
29
+ if (y = 0, t) op = [op[0] & 2, t.value];
30
+ switch (op[0]) {
31
+ case 0: case 1: t = op; break;
32
+ case 4: _.label++; return { value: op[1], done: false };
33
+ case 5: _.label++; y = op[1]; op = [0]; continue;
34
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
35
+ default:
36
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
37
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
38
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
39
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
40
+ if (t[2]) _.ops.pop();
41
+ _.trys.pop(); continue;
42
+ }
43
+ op = body.call(thisArg, _);
44
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
45
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
46
+ }
47
+ };
48
+ require('source-map-support').install();
49
+ import axios from "axios";
50
+ import { Session, EnduserSession } from "../../../sdk";
51
+ import { assert, log_header, } from "@tellescope/testing";
52
+ import { setup_tests, authenticate_enduser_via_token } from "../../setup";
53
+ var host = process.env.API_URL || 'http://localhost:8080';
54
+ /**
55
+ * Regression test for: email / phone are locked against writes from public session-start endpoints.
56
+ *
57
+ * Two public, unauthenticated endpoints match an existing patient on a loose $or
58
+ * ({ email } OR { fname, lname, dateOfBirth }) and then update the matched record:
59
+ *
60
+ * A POST /v1/session-for-public-form
61
+ * B POST /v1/session-for-public-appointment-booking
62
+ *
63
+ * Both used to overwrite `email`, `phone` and `state` on the matched patient. `email` and `phone`
64
+ * are authentication channels, so that overwrite chains to account takeover:
65
+ *
66
+ * - email-less patients are common and cheap to create (sms_messages.leave_message is an
67
+ * unauthenticated enduser-creation primitive that writes only phone/fname/lname)
68
+ * - attacker submits a public form matching on fname + lname + dateOfBirth with their own email
69
+ * - POST /v1/request-enduser-password-reset resolves purely by findOne({ email }) -> mints a
70
+ * passwordReset token and mails the reset link to the attacker
71
+ * - reset_password sets hashedPassword; the attacker logs into the portal as the patient
72
+ *
73
+ * phone is equally load-bearing: begin_login_flow accepts email OR phone, and send_otp delivers
74
+ * login codes to enduser.phone. A "fill only if blank" fix does NOT close this — the email-less
75
+ * patient is precisely the common case, which is why the UNSET shapes below are the load-bearing
76
+ * rows of G1/G2.
77
+ *
78
+ * The rule under test: `email` and `phone` may be written only on the INSERT path. On a matched
79
+ * existing patient they are never written — not even when the stored value is blank.
80
+ *
81
+ * `state` deliberately still overwrites on match (G6 pins the status quo): it is not an auth
82
+ * channel, and moving it behind "if unset" would break a relocated patient booking with a
83
+ * provider licensed in their new state.
84
+ *
85
+ * Scenario groups (pre-fix expectation in parens):
86
+ * G1 (RED) A — overwrite blocked on match, 4 victim shapes (both set / email unset / phone unset / both unset)
87
+ * G2 (RED) B — same 4 shapes
88
+ * G3 (RED) A — overwrite blocked when the victim is targeted directly via the enduserId param
89
+ * G4 (RED) takeover chain: email-less victim -> overwrite via A -> password-reset resolvability
90
+ * G5 (GREEN) blank fname/lname/dateOfBirth/gender still filled; blank phone still NOT filled
91
+ * G6 (GREEN) state still overwrites on match (status-quo pin)
92
+ * G7 (GREEN) brand-new visitor still created WITH submitted email + phone
93
+ * G8 (GREEN) form submission completes end-to-end under the returned token
94
+ * G9 (GREEN) appointment availability / booking works under the returned session
95
+ * G10 (GREEN) skipMatch still inserts, and skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge
96
+ *
97
+ * SECOND WRITE PATH (G11-G18). Locking the endpoints is not sufficient on its own. `email` and
98
+ * `phone` are also UI-selectable form intakeField mappings, and save_enduser_updates_for_form_response
99
+ * (api/modules/forms.ts) copies those answers onto the enduser named by the form response — with no
100
+ * session argument and no only-if-blank guard. Two of its callers sit in the 'public-form' session scope
101
+ * (submit_form_response, and save_field_response for fields with options.saveIntakeOnPartial), so the
102
+ * whole chain above is reachable with the token the hardened endpoint itself mints: match the victim,
103
+ * get a session scoped to their id, then answer the form's Email question with the attacker address.
104
+ *
105
+ * The lock is scoped to "a public session that matched a PRE-EXISTING patient", not to every public
106
+ * session, because intakeEmailHidden / intakePhone: 'hidden' are supported configurations in which
107
+ * the intake question is the only source of a brand-new patient's contact info (G16).
108
+ *
109
+ * G11 (RED) intake answers do not overwrite a matched patient (submit path), 4 victim shapes
110
+ * G12 (RED) same via save_field_response + saveIntakeOnPartial, without ever submitting
111
+ * G13 (RED) full takeover chain driven through an intake answer
112
+ * G14 (RED) pivot: public session -> prepare_form_response for a NON-public form -> submit
113
+ * G15 (RED) save_field_response rejects another patient's formResponseId (accessCode path stays open)
114
+ * G16 (GREEN) brand-new visitor still gets email/phone from intake answers
115
+ * G17 (GREEN) authenticated (non-public) portal session can still self-update via intake
116
+ * G18 (GREEN) staff submitting on a patient's behalf can still set email/phone
117
+ *
118
+ * NULL-MATCH / COLD START (G19-G22). In both handlers' $or, `email` and `dateOfBirth` get `|| "never"`
119
+ * sentinels but fname/lname are interpolated RAW. `ignoreUndefined` is not set on the Mongo client and
120
+ * findOne does not strip undefined keys, so an absent fname serializes to `{fname: null}` — which
121
+ * matches every document where the field is null OR MISSING. A request supplying ONLY dateOfBirth can
122
+ * therefore match another patient and receive a session scoped to THEIR record. The cold-start pattern
123
+ * (a public form started with no intake info, which just creates an Enduser with an ID) is what
124
+ * manufactures those nameless endusers.
125
+ *
126
+ * G19 (RED) DOB-only request does not hijack a nameless patient — form endpoint
127
+ * G20 (RED) same — booking endpoint (no skipMatch, no phone guard, so reached unconditionally)
128
+ * G21 (GREEN) the match sentinel never leaks into a stored fname/lname
129
+ * G22 (GREEN) cold start still lets intake answers populate email/phone on the fresh record
130
+ *
131
+ * TENANT SCOPING (G23). addPublicEndpoint overwrites the schema's `required: true` for businessId with
132
+ * mongoIdStringOptional, and a falsy businessId leaves restrictAccess as the IDENTITY function in the
133
+ * query builders — the tenant clause is never appended, so the handler queries every tenant. It fails
134
+ * OPEN. session_for_public_appointment_booking already guards this; session_for_public_form did not.
135
+ *
136
+ * G23 (RED) session_for_public_form rejects a missing or malformed businessId
137
+ *
138
+ * LOCK BYPASSES (G24-G26). The lock is defeated two ways, each needing more than one hop — which is
139
+ * why the single-hop groups above pass while the chain is still open:
140
+ * - prepare_form_response mints a token that preserves allowedPaths but DROPS fromPublicSession, so
141
+ * a second hop through the same endpoint produces an unlocked response. That flag also gates PHI
142
+ * on refresh_session, so the chained token additionally reads the victim's whole record.
143
+ * - the lock is a snapshot taken at session start; a later repoint of the response at a pre-existing
144
+ * patient leaves a stale `false` behind unless it also writes the flag. Two callers repoint:
145
+ * autoMergeOnSubmission (G26) and the staff endusers.merge handler (G27).
146
+ *
147
+ * G24 (RED) chained prepare_form_response session stays locked
148
+ * G25 (RED) chained session does not expose the enduser record on refresh
149
+ * G26 (RED) auto-merge does not let an intake answer repoint the merged patient
150
+ * G27 (RED) the staff endusers.merge repoint applies the lock too
151
+ *
152
+ * SCOPE BOUNDARY (G28). The formsort / form-ingestion webhook re-opens a blank email/phone for one
153
+ * hour after the Stripe integration creates a contact, because that endpoint is API-key
154
+ * authenticated. This anonymous path gets no such exemption.
155
+ *
156
+ * G28 a fresh Stripe-created patient gets no grace here
157
+ *
158
+ * STRUCTURE: record -> report -> cleanup -> assert.
159
+ * EXIT_ON_FIRST_ERROR is true in testing.ts, so assert() calls process.exit(1) synchronously on the
160
+ * first failure. Two consequences shape this file:
161
+ * 1. No async_test here. Every scenario records into `results`, a summary table prints, and a
162
+ * single assert() loop runs LAST — so a pre-fix run prints the complete failure picture
163
+ * instead of dying on the first probe.
164
+ * 2. process.exit() does not unwind pending finally blocks, so cleanup runs BEFORE the assert
165
+ * loop; otherwise a by-design-failing run would leak every fixture into the next run.
166
+ */
167
+ var post = function (path, body) { return __awaiter(void 0, void 0, void 0, function () {
168
+ var res, err_1;
169
+ var _a, _b;
170
+ return __generator(this, function (_c) {
171
+ switch (_c.label) {
172
+ case 0:
173
+ _c.trys.push([0, 2, , 3]);
174
+ return [4 /*yield*/, axios.post("".concat(host).concat(path), body, { validateStatus: function () { return true; } })];
175
+ case 1:
176
+ res = _c.sent();
177
+ return [2 /*return*/, { status: res.status, data: res.data }];
178
+ case 2:
179
+ err_1 = _c.sent();
180
+ return [2 /*return*/, { status: (_a = err_1 === null || err_1 === void 0 ? void 0 : err_1.response) === null || _a === void 0 ? void 0 : _a.status, data: (_b = err_1 === null || err_1 === void 0 ? void 0 : err_1.response) === null || _b === void 0 ? void 0 : _b.data }];
181
+ case 3: return [2 /*return*/];
182
+ }
183
+ });
184
+ }); };
185
+ // Endpoint A's update is fire-and-forget (.catch(console.error), not awaited); B's is awaited.
186
+ // Reading the victim immediately after calling A would frequently show the un-overwritten value on
187
+ // a VULNERABLE server -> false pass. So poll for the attack landing: fails fast against vulnerable
188
+ // code, burns the full window against fixed code.
189
+ var OVERWRITE_WINDOW_MS = 3000;
190
+ var AWAITED_WINDOW_MS = 1200;
191
+ var POLL_INTERVAL_MS = 150;
192
+ var sleep = function (ms) { return new Promise(function (resolve) { return setTimeout(resolve, ms); }); };
193
+ export var public_endpoint_auth_field_lock_tests = function (_a) {
194
+ var sdk = _a.sdk, sdkNonAdmin = _a.sdkNonAdmin;
195
+ return __awaiter(void 0, void 0, void 0, function () {
196
+ var businessId, RUN, results, record, skip, runScenario, phoneOffset, phoneSeed, nextPhone, emailFor, createdEnduserIds, createdFormIds, createdFormFieldIds, createdUserIds, createdEventIds, createdTemplateIds, organization, MATCH_EMAIL_AND_NAMES, form, plainField, autoMergeForm, autoMergeField, makeIntakeForm, intake, partialIntake, pivotIntake, MERGE_CUSTOM_KEY, autoMergeIntake, template, hostUser, publicSDK, startForm, startBooking, startFormOn, asEnduser, postAuthed, tokenFromURL, patchAuthed, intakeAnswers, pollEnduser, describe, makeVictim, VICTIM_SHAPES, _loop_1, _i, VICTIM_SHAPES_1, shape, _loop_2, _b, VICTIM_SHAPES_2, shape, formSessions, bookingTokens, _loop_3, _c, VICTIM_SHAPES_3, shape, G12_NAME, G13_NAME, G14_NAME, G15_NAME, namelessDob, makeNamelessVictim, G19_NAME, G20_NAME, coldStartCase, G23_NAME, chainForVictim, G24_NAME, G25_NAME, G26_NAME, G27_NAME, G28_NAME, failures, skipped, _d, results_1, r, status_1, _e, createdEventIds_1, id, _f, createdFormIds_1, formId, responses, _g, responses_1, response, _h, createdFormFieldIds_1, id, _j, createdFormIds_2, id, uniqueEnduserIds, _k, uniqueEnduserIds_1, id, _l, createdTemplateIds_1, id, _m, createdUserIds_1, id, _o, results_2, r;
197
+ var _p, _q;
198
+ return __generator(this, function (_r) {
199
+ switch (_r.label) {
200
+ case 0:
201
+ log_header("Public Endpoint Auth Field Lock (email / phone)");
202
+ businessId = sdk.userInfo.businessId;
203
+ RUN = Date.now().toString(36);
204
+ results = [];
205
+ record = function (group, name, ok, detail) {
206
+ if (detail === void 0) { detail = ''; }
207
+ results.push({ group: group, name: name, ok: ok, detail: detail });
208
+ console.log(" ".concat(ok ? '✓' : '✗', " [").concat(group, "] ").concat(name).concat(detail ? " \u2014 ".concat(detail) : ''));
209
+ };
210
+ skip = function (group, name, detail) {
211
+ results.push({ group: group, name: name, ok: true, skipped: true, detail: detail });
212
+ console.log(" \u26A0 SKIP [".concat(group, "] ").concat(name, " \u2014 ").concat(detail));
213
+ };
214
+ runScenario = function (group, name, fn) { return __awaiter(void 0, void 0, void 0, function () {
215
+ var err_2, message;
216
+ var _a, _b;
217
+ return __generator(this, function (_c) {
218
+ switch (_c.label) {
219
+ case 0:
220
+ _c.trys.push([0, 2, , 3]);
221
+ return [4 /*yield*/, fn()];
222
+ case 1:
223
+ _c.sent();
224
+ return [3 /*break*/, 3];
225
+ case 2:
226
+ err_2 = _c.sent();
227
+ message = (_b = (_a = err_2 === null || err_2 === void 0 ? void 0 : err_2.message) !== null && _a !== void 0 ? _a : err_2 === null || err_2 === void 0 ? void 0 : err_2.error) !== null && _b !== void 0 ? _b : JSON.stringify(err_2 !== null && err_2 !== void 0 ? err_2 : {}).slice(0, 300);
228
+ record(group, name, false, "threw: ".concat(message));
229
+ return [3 /*break*/, 3];
230
+ case 3: return [2 /*return*/];
231
+ }
232
+ });
233
+ }); };
234
+ phoneOffset = 0;
235
+ phoneSeed = Number(RUN.replace(/[^0-9]/g, '') || '0');
236
+ nextPhone = function () { return "+1555555".concat(String((phoneSeed + (phoneOffset += 1)) % 10000).padStart(4, '0')); };
237
+ emailFor = function (label) { return "".concat(label, "-").concat(RUN, "@tellescope.com").toLowerCase(); };
238
+ createdEnduserIds = [];
239
+ createdFormIds = [];
240
+ createdFormFieldIds = [];
241
+ createdUserIds = [];
242
+ createdEventIds = [];
243
+ createdTemplateIds = [];
244
+ return [4 /*yield*/, sdk.api.organizations.getOne(businessId).catch(function () { return null; })];
245
+ case 1:
246
+ organization = _r.sent();
247
+ MATCH_EMAIL_AND_NAMES = !!((_q = (_p = organization === null || organization === void 0 ? void 0 : organization.settings) === null || _p === void 0 ? void 0 : _p.endusers) === null || _q === void 0 ? void 0 : _q.matchEmailAndNames);
248
+ return [4 /*yield*/, sdk.api.forms.createOne({ title: "auth field lock ".concat(RUN), allowPublicURL: true })];
249
+ case 2:
250
+ form = _r.sent();
251
+ createdFormIds.push(form.id);
252
+ return [4 /*yield*/, sdk.api.form_fields.createOne({
253
+ formId: form.id,
254
+ title: 'Anything',
255
+ type: 'string',
256
+ previousFields: [{ type: 'root', info: {} }],
257
+ })];
258
+ case 3:
259
+ plainField = _r.sent();
260
+ createdFormFieldIds.push(plainField.id);
261
+ return [4 /*yield*/, sdk.api.forms.createOne({
262
+ title: "auth field lock automerge ".concat(RUN),
263
+ allowPublicURL: true,
264
+ autoMergeOnSubmission: true,
265
+ })];
266
+ case 4:
267
+ autoMergeForm = _r.sent();
268
+ createdFormIds.push(autoMergeForm.id);
269
+ return [4 /*yield*/, sdk.api.form_fields.createOne({
270
+ formId: autoMergeForm.id,
271
+ title: 'Anything',
272
+ type: 'string',
273
+ previousFields: [{ type: 'root', info: {} }],
274
+ })];
275
+ case 5:
276
+ autoMergeField = _r.sent();
277
+ createdFormFieldIds.push(autoMergeField.id);
278
+ makeIntakeForm = function (label, opts) {
279
+ if (opts === void 0) { opts = {}; }
280
+ return __awaiter(void 0, void 0, void 0, function () {
281
+ var intakeForm, fieldOptions, emailField, phoneField;
282
+ return __generator(this, function (_a) {
283
+ switch (_a.label) {
284
+ case 0: return [4 /*yield*/, sdk.api.forms.createOne(__assign({ title: "auth field lock ".concat(label, " ").concat(RUN), intakePhone: 'hidden' }, (opts.public === false ? {} : { allowPublicURL: true })))];
285
+ case 1:
286
+ intakeForm = _a.sent();
287
+ createdFormIds.push(intakeForm.id);
288
+ fieldOptions = opts.saveIntakeOnPartial ? { options: { saveIntakeOnPartial: true } } : {};
289
+ return [4 /*yield*/, sdk.api.form_fields.createOne(__assign({ formId: intakeForm.id, title: 'Email', type: 'email', intakeField: 'email', previousFields: [{ type: 'root', info: {} }] }, fieldOptions))];
290
+ case 2:
291
+ emailField = _a.sent();
292
+ createdFormFieldIds.push(emailField.id);
293
+ return [4 /*yield*/, sdk.api.form_fields.createOne(__assign({ formId: intakeForm.id, title: 'Phone Number', type: 'phone', intakeField: 'phone', previousFields: [{ type: 'after', info: { fieldId: emailField.id } }] }, fieldOptions))];
294
+ case 3:
295
+ phoneField = _a.sent();
296
+ createdFormFieldIds.push(phoneField.id);
297
+ return [2 /*return*/, { form: intakeForm, emailField: emailField, phoneField: phoneField }];
298
+ }
299
+ });
300
+ });
301
+ };
302
+ return [4 /*yield*/, makeIntakeForm('intake')];
303
+ case 6:
304
+ intake = _r.sent();
305
+ return [4 /*yield*/, makeIntakeForm('partial', { saveIntakeOnPartial: true })
306
+ // Deliberately NOT allowPublicURL. prepare_form_response does not require it, so a public session
307
+ // can pivot from any public form to any form in the org carrying an email/phone intake mapping.
308
+ ];
309
+ case 7:
310
+ partialIntake = _r.sent();
311
+ return [4 /*yield*/, makeIntakeForm('pivot', { public: false })
312
+ // Auto-merge fixture for G26. Needs real intakeField mappings, because resolveAutoMergeBeforeSubmission
313
+ // derives its match values from the submitted answers via those mappings — and the custom field is the
314
+ // anti-vacuity control (it proves the intake write ran at all).
315
+ ];
316
+ case 8:
317
+ pivotIntake = _r.sent();
318
+ MERGE_CUSTOM_KEY = "aflMerge".concat(RUN);
319
+ return [4 /*yield*/, (function () { return __awaiter(void 0, void 0, void 0, function () {
320
+ var form, spec, made, _i, spec_1, s, field, fname, lname, dateOfBirth, email, custom;
321
+ return __generator(this, function (_a) {
322
+ switch (_a.label) {
323
+ case 0: return [4 /*yield*/, sdk.api.forms.createOne({
324
+ title: "auth field lock automerge intake ".concat(RUN),
325
+ allowPublicURL: true,
326
+ autoMergeOnSubmission: true,
327
+ intakePhone: 'hidden',
328
+ })];
329
+ case 1:
330
+ form = _a.sent();
331
+ createdFormIds.push(form.id);
332
+ spec = [
333
+ { title: 'First Name', type: 'string', intakeField: 'fname' },
334
+ { title: 'Last Name', type: 'string', intakeField: 'lname' },
335
+ { title: 'Date of Birth', type: 'dateString', intakeField: 'dateOfBirth' },
336
+ { title: 'Email', type: 'email', intakeField: 'email' },
337
+ { title: 'Custom', type: 'string', intakeField: MERGE_CUSTOM_KEY },
338
+ ];
339
+ made = [];
340
+ _i = 0, spec_1 = spec;
341
+ _a.label = 2;
342
+ case 2:
343
+ if (!(_i < spec_1.length)) return [3 /*break*/, 5];
344
+ s = spec_1[_i];
345
+ return [4 /*yield*/, sdk.api.form_fields.createOne({
346
+ formId: form.id,
347
+ title: s.title,
348
+ type: s.type,
349
+ intakeField: s.intakeField,
350
+ previousFields: made.length
351
+ ? [{ type: 'after', info: { fieldId: made[made.length - 1].id } }]
352
+ : [{ type: 'root', info: {} }],
353
+ })];
354
+ case 3:
355
+ field = _a.sent();
356
+ createdFormFieldIds.push(field.id);
357
+ made.push(field);
358
+ _a.label = 4;
359
+ case 4:
360
+ _i++;
361
+ return [3 /*break*/, 2];
362
+ case 5:
363
+ fname = made[0], lname = made[1], dateOfBirth = made[2], email = made[3], custom = made[4];
364
+ return [2 /*return*/, { form: form, fname: fname, lname: lname, dateOfBirth: dateOfBirth, email: email, custom: custom }];
365
+ }
366
+ });
367
+ }); })()];
368
+ case 9:
369
+ autoMergeIntake = _r.sent();
370
+ return [4 /*yield*/, sdk.api.calendar_event_templates.createOne({
371
+ title: "auth field lock ".concat(RUN),
372
+ durationInMinutes: 60,
373
+ confirmationEmailDisabled: true,
374
+ confirmationSMSDisabled: true,
375
+ reminders: [],
376
+ })];
377
+ case 10:
378
+ template = _r.sent();
379
+ createdTemplateIds.push(template.id);
380
+ return [4 /*yield*/, sdk.api.users.createOne({
381
+ email: emailFor('lock-host'),
382
+ verifiedEmail: true,
383
+ fname: 'Lock',
384
+ lname: "Host".concat(RUN),
385
+ })];
386
+ case 11:
387
+ hostUser = _r.sent();
388
+ createdUserIds.push(hostUser.id);
389
+ return [4 /*yield*/, sdk.api.users.updateOne(hostUser.id, {
390
+ weeklyAvailabilities: [0, 1, 2, 3, 4, 5, 6].map(function (dayOfWeekStartingSundayIndexedByZero) { return ({
391
+ dayOfWeekStartingSundayIndexedByZero: dayOfWeekStartingSundayIndexedByZero,
392
+ startTimeInMinutes: 9 * 60,
393
+ endTimeInMinutes: 17 * 60,
394
+ intervalInMinutes: 60,
395
+ }); }),
396
+ calendarEventLimits: [],
397
+ }, { replaceObjectFields: true })];
398
+ case 12:
399
+ _r.sent();
400
+ return [4 /*yield*/, sleep(250)
401
+ // ---------------------------------------------------------------------------------------------
402
+ // Helpers
403
+ // ---------------------------------------------------------------------------------------------
404
+ ]; // let form.numFields settle before the public endpoints validate it
405
+ case 13:
406
+ _r.sent(); // let form.numFields settle before the public endpoints validate it
407
+ publicSDK = function () { return new EnduserSession({ host: host, businessId: businessId }); };
408
+ startForm = function (args) { return publicSDK().api.form_responses.session_for_public_form(__assign({ formId: form.id, businessId: businessId }, args)); };
409
+ startBooking = function (args) { return publicSDK().api.calendar_events.session_for_public_appointment_booking(__assign({ calendarEventTemplateId: template.id, businessId: businessId }, args)); };
410
+ startFormOn = function (formId, args) { return publicSDK().api.form_responses.session_for_public_form(__assign({ formId: formId, businessId: businessId }, args)); };
411
+ asEnduser = function (authToken) { return new EnduserSession({ host: host, businessId: businessId, authToken: authToken }); };
412
+ postAuthed = function (path, body, authToken) { return __awaiter(void 0, void 0, void 0, function () {
413
+ var res, err_3;
414
+ var _a, _b;
415
+ return __generator(this, function (_c) {
416
+ switch (_c.label) {
417
+ case 0:
418
+ _c.trys.push([0, 2, , 3]);
419
+ return [4 /*yield*/, axios.post("".concat(host).concat(path), body, {
420
+ headers: { Authorization: "Bearer ".concat(authToken) },
421
+ validateStatus: function () { return true; },
422
+ })];
423
+ case 1:
424
+ res = _c.sent();
425
+ return [2 /*return*/, { status: res.status, data: res.data }];
426
+ case 2:
427
+ err_3 = _c.sent();
428
+ return [2 /*return*/, { status: (_a = err_3 === null || err_3 === void 0 ? void 0 : err_3.response) === null || _a === void 0 ? void 0 : _a.status, data: (_b = err_3 === null || err_3 === void 0 ? void 0 : err_3.response) === null || _b === void 0 ? void 0 : _b.data }];
429
+ case 3: return [2 /*return*/];
430
+ }
431
+ });
432
+ }); };
433
+ tokenFromURL = function (fullURL) { var _a; return (_a = /[?&]token=([^&]+)/.exec(fullURL !== null && fullURL !== void 0 ? fullURL : '')) === null || _a === void 0 ? void 0 : _a[1]; };
434
+ patchAuthed = function (path, body, authToken) { return __awaiter(void 0, void 0, void 0, function () {
435
+ var res, err_4;
436
+ var _a, _b;
437
+ return __generator(this, function (_c) {
438
+ switch (_c.label) {
439
+ case 0:
440
+ _c.trys.push([0, 2, , 3]);
441
+ return [4 /*yield*/, axios.patch("".concat(host).concat(path), body, {
442
+ headers: { Authorization: "Bearer ".concat(authToken) },
443
+ validateStatus: function () { return true; },
444
+ })];
445
+ case 1:
446
+ res = _c.sent();
447
+ return [2 /*return*/, { status: res.status, data: res.data }];
448
+ case 2:
449
+ err_4 = _c.sent();
450
+ return [2 /*return*/, { status: (_a = err_4 === null || err_4 === void 0 ? void 0 : err_4.response) === null || _a === void 0 ? void 0 : _a.status, data: (_b = err_4 === null || err_4 === void 0 ? void 0 : err_4.response) === null || _b === void 0 ? void 0 : _b.data }];
451
+ case 3: return [2 /*return*/];
452
+ }
453
+ });
454
+ }); };
455
+ intakeAnswers = function (f, email, phone) { return ([
456
+ { fieldId: f.emailField.id, fieldTitle: f.emailField.title, answer: { type: 'email', value: email } },
457
+ { fieldId: f.phoneField.id, fieldTitle: f.phoneField.title, answer: { type: 'phone', value: phone } },
458
+ ]); };
459
+ pollEnduser = function (id, landed, windowMs) { return __awaiter(void 0, void 0, void 0, function () {
460
+ var deadline, latest;
461
+ return __generator(this, function (_a) {
462
+ switch (_a.label) {
463
+ case 0:
464
+ deadline = Date.now() + windowMs;
465
+ latest = null;
466
+ _a.label = 1;
467
+ case 1:
468
+ if (!true) return [3 /*break*/, 4];
469
+ return [4 /*yield*/, sdk.api.endusers.getOne(id).catch(function () { return null; })];
470
+ case 2:
471
+ latest = _a.sent();
472
+ if (latest && landed(latest))
473
+ return [2 /*return*/, { landed: true, enduser: latest }];
474
+ if (Date.now() >= deadline)
475
+ return [2 /*return*/, { landed: false, enduser: latest }];
476
+ return [4 /*yield*/, sleep(POLL_INTERVAL_MS)];
477
+ case 3:
478
+ _a.sent();
479
+ return [3 /*break*/, 1];
480
+ case 4: return [2 /*return*/];
481
+ }
482
+ });
483
+ }); };
484
+ describe = function (e) { return "email=".concat((e === null || e === void 0 ? void 0 : e.email) || '(unset)', " phone=").concat((e === null || e === void 0 ? void 0 : e.phone) || '(unset)'); };
485
+ makeVictim = function (tag, fields) {
486
+ if (fields === void 0) { fields = {}; }
487
+ return __awaiter(void 0, void 0, void 0, function () {
488
+ var victim;
489
+ return __generator(this, function (_a) {
490
+ switch (_a.label) {
491
+ case 0: return [4 /*yield*/, sdk.api.endusers.createOne(__assign({ fname: "Lock".concat(RUN), lname: "Victim".concat(RUN).concat(tag), dateOfBirth: '01-02-1990' }, fields))];
492
+ case 1:
493
+ victim = _a.sent();
494
+ createdEnduserIds.push(victim.id);
495
+ return [2 /*return*/, victim];
496
+ }
497
+ });
498
+ });
499
+ };
500
+ VICTIM_SHAPES = [
501
+ { tag: 'A1', label: 'victim has email + phone', withEmail: true, withPhone: true },
502
+ { tag: 'A2', label: 'victim email UNSET (load-bearing)', withEmail: false, withPhone: true },
503
+ { tag: 'A3', label: 'victim phone UNSET', withEmail: true, withPhone: false },
504
+ { tag: 'A4', label: 'victim email + phone UNSET (load-bearing)', withEmail: false, withPhone: false },
505
+ ];
506
+ _loop_1 = function (shape) {
507
+ var name_1;
508
+ return __generator(this, function (_s) {
509
+ switch (_s.label) {
510
+ case 0:
511
+ name_1 = "A blocks email/phone overwrite \u2014 ".concat(shape.label);
512
+ return [4 /*yield*/, runScenario('G1', name_1, function () { return __awaiter(void 0, void 0, void 0, function () {
513
+ var victim, attackerEmail, attackerPhone, session, _a, landed, enduser;
514
+ return __generator(this, function (_b) {
515
+ switch (_b.label) {
516
+ case 0: return [4 /*yield*/, makeVictim(shape.tag, __assign(__assign({}, (shape.withEmail ? { email: emailFor("lock-v-".concat(shape.tag)) } : {})), (shape.withPhone ? { phone: nextPhone() } : {})))];
517
+ case 1:
518
+ victim = _b.sent();
519
+ attackerEmail = emailFor("atk-".concat(shape.tag));
520
+ attackerPhone = nextPhone();
521
+ return [4 /*yield*/, startForm({
522
+ fname: victim.fname,
523
+ lname: victim.lname,
524
+ dateOfBirth: victim.dateOfBirth,
525
+ email: attackerEmail,
526
+ phone: attackerPhone,
527
+ })
528
+ // Probe-validity guard: if the handler INSERTED instead of matching, the victim is trivially
529
+ // unchanged and the test would pass for the wrong reason.
530
+ ];
531
+ case 2:
532
+ session = _b.sent();
533
+ // Probe-validity guard: if the handler INSERTED instead of matching, the victim is trivially
534
+ // unchanged and the test would pass for the wrong reason.
535
+ if (session.enduserId !== victim.id) {
536
+ if (session.enduserId)
537
+ createdEnduserIds.push(session.enduserId);
538
+ record('G1', name_1, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
539
+ return [2 /*return*/];
540
+ }
541
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)];
542
+ case 3:
543
+ _a = _b.sent(), landed = _a.landed, enduser = _a.enduser;
544
+ record('G1', name_1, !landed, landed
545
+ ? "EXPLOIT: attacker values landed on victim (".concat(describe(enduser), ")")
546
+ : "unchanged (".concat(describe(enduser), ")"));
547
+ return [2 /*return*/];
548
+ }
549
+ });
550
+ }); })];
551
+ case 1:
552
+ _s.sent();
553
+ return [2 /*return*/];
554
+ }
555
+ });
556
+ };
557
+ _i = 0, VICTIM_SHAPES_1 = VICTIM_SHAPES;
558
+ _r.label = 14;
559
+ case 14:
560
+ if (!(_i < VICTIM_SHAPES_1.length)) return [3 /*break*/, 17];
561
+ shape = VICTIM_SHAPES_1[_i];
562
+ return [5 /*yield**/, _loop_1(shape)];
563
+ case 15:
564
+ _r.sent();
565
+ _r.label = 16;
566
+ case 16:
567
+ _i++;
568
+ return [3 /*break*/, 14];
569
+ case 17:
570
+ _loop_2 = function (shape) {
571
+ var name_2;
572
+ return __generator(this, function (_t) {
573
+ switch (_t.label) {
574
+ case 0:
575
+ name_2 = "B blocks email/phone overwrite \u2014 ".concat(shape.label);
576
+ return [4 /*yield*/, runScenario('G2', name_2, function () { return __awaiter(void 0, void 0, void 0, function () {
577
+ var victim, attackerEmail, attackerPhone, matching, _i, matching_1, m, _a, landed, enduser;
578
+ return __generator(this, function (_b) {
579
+ switch (_b.label) {
580
+ case 0: return [4 /*yield*/, makeVictim("B".concat(shape.tag), __assign(__assign({}, (shape.withEmail ? { email: emailFor("lock-vb-".concat(shape.tag)) } : {})), (shape.withPhone ? { phone: nextPhone() } : {})))];
581
+ case 1:
582
+ victim = _b.sent();
583
+ attackerEmail = emailFor("atkb-".concat(shape.tag));
584
+ attackerPhone = nextPhone();
585
+ return [4 /*yield*/, startBooking({
586
+ fname: victim.fname,
587
+ lname: victim.lname,
588
+ dateOfBirth: victim.dateOfBirth,
589
+ email: attackerEmail,
590
+ phone: attackerPhone,
591
+ })];
592
+ case 2:
593
+ _b.sent();
594
+ return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { lname: victim.lname } })];
595
+ case 3:
596
+ matching = _b.sent();
597
+ if (matching.length !== 1 || matching[0].id !== victim.id) {
598
+ for (_i = 0, matching_1 = matching; _i < matching_1.length; _i++) {
599
+ m = matching_1[_i];
600
+ if (m.id !== victim.id)
601
+ createdEnduserIds.push(m.id);
602
+ }
603
+ record('G2', name_2, false, "probe invalid \u2014 ".concat(matching.length, " records match lname ").concat(victim.lname, "; endpoint inserted instead of matching"));
604
+ return [2 /*return*/];
605
+ }
606
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, AWAITED_WINDOW_MS)];
607
+ case 4:
608
+ _a = _b.sent(), landed = _a.landed, enduser = _a.enduser;
609
+ record('G2', name_2, !landed, landed
610
+ ? "EXPLOIT: attacker values landed on victim (".concat(describe(enduser), ")")
611
+ : "unchanged (".concat(describe(enduser), ")"));
612
+ return [2 /*return*/];
613
+ }
614
+ });
615
+ }); })];
616
+ case 1:
617
+ _t.sent();
618
+ return [2 /*return*/];
619
+ }
620
+ });
621
+ };
622
+ _b = 0, VICTIM_SHAPES_2 = VICTIM_SHAPES;
623
+ _r.label = 18;
624
+ case 18:
625
+ if (!(_b < VICTIM_SHAPES_2.length)) return [3 /*break*/, 21];
626
+ shape = VICTIM_SHAPES_2[_b];
627
+ return [5 /*yield**/, _loop_2(shape)];
628
+ case 19:
629
+ _r.sent();
630
+ _r.label = 20;
631
+ case 20:
632
+ _b++;
633
+ return [3 /*break*/, 18];
634
+ case 21:
635
+ // ---------------------------------------------------------------------------------------------
636
+ // G3 — Endpoint A with the raw enduserId parameter (findById, no ownership check). Not reachable
637
+ // as an attack through the first-party frontend, but trivially reachable by POSTing directly.
638
+ // The field lock must hold on this path too.
639
+ // ---------------------------------------------------------------------------------------------
640
+ return [4 /*yield*/, runScenario('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', function () { return __awaiter(void 0, void 0, void 0, function () {
641
+ var victim, attackerEmail, attackerPhone, session, _a, landed, enduser;
642
+ return __generator(this, function (_b) {
643
+ switch (_b.label) {
644
+ case 0: return [4 /*yield*/, makeVictim('C1')]; // no email, no phone — the takeover-ready shape
645
+ case 1:
646
+ victim = _b.sent() // no email, no phone — the takeover-ready shape
647
+ ;
648
+ attackerEmail = emailFor('atk-c1');
649
+ attackerPhone = nextPhone();
650
+ return [4 /*yield*/, startForm({ enduserId: victim.id, email: attackerEmail, phone: attackerPhone })];
651
+ case 2:
652
+ session = _b.sent();
653
+ if (session.enduserId !== victim.id) {
654
+ if (session.enduserId)
655
+ createdEnduserIds.push(session.enduserId);
656
+ record('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', false, "probe invalid \u2014 endpoint returned ".concat(session.enduserId, ", expected victim ").concat(victim.id));
657
+ return [2 /*return*/];
658
+ }
659
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)];
660
+ case 3:
661
+ _a = _b.sent(), landed = _a.landed, enduser = _a.enduser;
662
+ record('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', !landed, landed
663
+ ? "EXPLOIT: attacker values landed on victim (".concat(describe(enduser), ")")
664
+ : "unchanged (".concat(describe(enduser), ")"));
665
+ return [2 /*return*/];
666
+ }
667
+ });
668
+ }); })
669
+ // ---------------------------------------------------------------------------------------------
670
+ // G4 — The takeover chain itself.
671
+ //
672
+ // access_tokens is not readable from a test (written via getInternalQueries(), no schema entry,
673
+ // no SDK surface) and request-enduser-password-reset returns 204 unconditionally — success,
674
+ // no-match and throttled alike — so it carries zero signal. Two sound substitutes:
675
+ // 1. endusers.getSome({ filter: { email: attackerEmail } }) is empty. That is the exact
676
+ // predicate the handler gates on (findOne({ email }); if (!enduser) return 204) before
677
+ // minting anything, so an empty set proves non-issuance.
678
+ // 2. begin-enduser-login-flow with the attacker email still returns continue-with-password.
679
+ // On a vulnerable server the now-email-bearing, password-less victim flips this to
680
+ // sent-email/sent-sms and an OTP is genuinely dispatched to the attacker.
681
+ // Rate-limit budget: begin_login_flow is 5 / 10 min per identifier and 10 / 60s per IP. Exactly
682
+ // two calls are made, on the same run-unique identifier. A 429 is recorded as SKIP, not a
683
+ // failure (reset_db is deliberately NOT called here — it would wipe other suites' fixtures when
684
+ // this file runs as part of tests.ts).
685
+ // ---------------------------------------------------------------------------------------------
686
+ ];
687
+ case 22:
688
+ // ---------------------------------------------------------------------------------------------
689
+ // G3 — Endpoint A with the raw enduserId parameter (findById, no ownership check). Not reachable
690
+ // as an attack through the first-party frontend, but trivially reachable by POSTing directly.
691
+ // The field lock must hold on this path too.
692
+ // ---------------------------------------------------------------------------------------------
693
+ _r.sent();
694
+ // ---------------------------------------------------------------------------------------------
695
+ // G4 — The takeover chain itself.
696
+ //
697
+ // access_tokens is not readable from a test (written via getInternalQueries(), no schema entry,
698
+ // no SDK surface) and request-enduser-password-reset returns 204 unconditionally — success,
699
+ // no-match and throttled alike — so it carries zero signal. Two sound substitutes:
700
+ // 1. endusers.getSome({ filter: { email: attackerEmail } }) is empty. That is the exact
701
+ // predicate the handler gates on (findOne({ email }); if (!enduser) return 204) before
702
+ // minting anything, so an empty set proves non-issuance.
703
+ // 2. begin-enduser-login-flow with the attacker email still returns continue-with-password.
704
+ // On a vulnerable server the now-email-bearing, password-less victim flips this to
705
+ // sent-email/sent-sms and an OTP is genuinely dispatched to the attacker.
706
+ // Rate-limit budget: begin_login_flow is 5 / 10 min per identifier and 10 / 60s per IP. Exactly
707
+ // two calls are made, on the same run-unique identifier. A 429 is recorded as SKIP, not a
708
+ // failure (reset_db is deliberately NOT called here — it would wipe other suites' fixtures when
709
+ // this file runs as part of tests.ts).
710
+ // ---------------------------------------------------------------------------------------------
711
+ return [4 /*yield*/, runScenario('G4', 'takeover chain: email-less victim is not made resolvable by A', function () { return __awaiter(void 0, void 0, void 0, function () {
712
+ var victim, attackerEmail, baseline, baselineResult, session, enduser, resetRes, resolvable, after, afterResult;
713
+ var _a, _b;
714
+ return __generator(this, function (_c) {
715
+ switch (_c.label) {
716
+ case 0: return [4 /*yield*/, makeVictim('D1', { phone: nextPhone() })]; // email-less: the common, cheap case
717
+ case 1:
718
+ victim = _c.sent() // email-less: the common, cheap case
719
+ ;
720
+ attackerEmail = emailFor('atk-d1');
721
+ return [4 /*yield*/, post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId: businessId })];
722
+ case 2:
723
+ baseline = _c.sent();
724
+ baselineResult = (_a = baseline.data) === null || _a === void 0 ? void 0 : _a.result;
725
+ if (baseline.status === 429) {
726
+ skip('G4', 'begin_login_flow oracle baseline', 'rate limited (429) — oracle unavailable this run');
727
+ }
728
+ else {
729
+ record('G4', 'begin_login_flow oracle baseline is continue-with-password', baselineResult === 'continue-with-password', "result=".concat(baselineResult, " status=").concat(baseline.status));
730
+ }
731
+ return [4 /*yield*/, startForm({
732
+ fname: victim.fname,
733
+ lname: victim.lname,
734
+ dateOfBirth: victim.dateOfBirth,
735
+ email: attackerEmail,
736
+ phone: nextPhone(),
737
+ })];
738
+ case 3:
739
+ session = _c.sent();
740
+ if (session.enduserId !== victim.id) {
741
+ if (session.enduserId)
742
+ createdEnduserIds.push(session.enduserId);
743
+ record('G4', 'takeover chain: email-less victim is not made resolvable by A', false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
744
+ return [2 /*return*/];
745
+ }
746
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)
747
+ // Informational only: 204 regardless of outcome.
748
+ ];
749
+ case 4:
750
+ enduser = (_c.sent()).enduser;
751
+ return [4 /*yield*/, post('/v1/request-enduser-password-reset', { email: attackerEmail, businessId: businessId })];
752
+ case 5:
753
+ resetRes = _c.sent();
754
+ console.log(" (info) request-enduser-password-reset -> HTTP ".concat(resetRes.status, " \u2014 unconditional, carries no signal"));
755
+ return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { email: attackerEmail } })];
756
+ case 6:
757
+ resolvable = _c.sent();
758
+ record('G4', 'attacker email resolves to no enduser (password reset cannot be issued)', resolvable.length === 0, resolvable.length === 0
759
+ ? "no enduser carries ".concat(attackerEmail, " (victim still ").concat(describe(enduser), ")")
760
+ : "EXPLOIT: ".concat(resolvable.length, " enduser(s) now resolve for ").concat(attackerEmail, " \u2014 reset token would be minted and mailed to the attacker"));
761
+ return [4 /*yield*/, post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId: businessId })];
762
+ case 7:
763
+ after = _c.sent();
764
+ afterResult = (_b = after.data) === null || _b === void 0 ? void 0 : _b.result;
765
+ if (after.status === 429) {
766
+ skip('G4', 'begin_login_flow oracle after attack', 'rate limited (429) — oracle unavailable this run');
767
+ }
768
+ else {
769
+ record('G4', 'begin_login_flow still returns continue-with-password after attack', afterResult === 'continue-with-password', afterResult === 'continue-with-password'
770
+ ? "result=".concat(afterResult)
771
+ : "EXPLOIT: result=".concat(afterResult, " \u2014 an OTP / magic link was dispatched for the attacker's address"));
772
+ }
773
+ return [2 /*return*/];
774
+ }
775
+ });
776
+ }); })
777
+ // ---------------------------------------------------------------------------------------------
778
+ // G5 — Intended intake behavior preserved: blank match keys still get filled, blank phone does not.
779
+ // ---------------------------------------------------------------------------------------------
780
+ ];
781
+ case 23:
782
+ // ---------------------------------------------------------------------------------------------
783
+ // G4 — The takeover chain itself.
784
+ //
785
+ // access_tokens is not readable from a test (written via getInternalQueries(), no schema entry,
786
+ // no SDK surface) and request-enduser-password-reset returns 204 unconditionally — success,
787
+ // no-match and throttled alike — so it carries zero signal. Two sound substitutes:
788
+ // 1. endusers.getSome({ filter: { email: attackerEmail } }) is empty. That is the exact
789
+ // predicate the handler gates on (findOne({ email }); if (!enduser) return 204) before
790
+ // minting anything, so an empty set proves non-issuance.
791
+ // 2. begin-enduser-login-flow with the attacker email still returns continue-with-password.
792
+ // On a vulnerable server the now-email-bearing, password-less victim flips this to
793
+ // sent-email/sent-sms and an OTP is genuinely dispatched to the attacker.
794
+ // Rate-limit budget: begin_login_flow is 5 / 10 min per identifier and 10 / 60s per IP. Exactly
795
+ // two calls are made, on the same run-unique identifier. A 429 is recorded as SKIP, not a
796
+ // failure (reset_db is deliberately NOT called here — it would wipe other suites' fixtures when
797
+ // this file runs as part of tests.ts).
798
+ // ---------------------------------------------------------------------------------------------
799
+ _r.sent();
800
+ // ---------------------------------------------------------------------------------------------
801
+ // G5 — Intended intake behavior preserved: blank match keys still get filled, blank phone does not.
802
+ // ---------------------------------------------------------------------------------------------
803
+ return [4 /*yield*/, runScenario('G5', 'blank match keys filled on match, blank phone NOT filled', function () { return __awaiter(void 0, void 0, void 0, function () {
804
+ var victimEmail, victim, submittedPhone, session, filled, phoneLanded;
805
+ var _a, _b, _c, _d, _e;
806
+ return __generator(this, function (_f) {
807
+ switch (_f.label) {
808
+ case 0:
809
+ victimEmail = emailFor('lock-v-e1');
810
+ return [4 /*yield*/, sdk.api.endusers.createOne(__assign({ email: victimEmail }, (MATCH_EMAIL_AND_NAMES ? { fname: "Lock".concat(RUN), lname: "Victim".concat(RUN, "E1") } : {})))];
811
+ case 1:
812
+ victim = _f.sent();
813
+ createdEnduserIds.push(victim.id);
814
+ if (MATCH_EMAIL_AND_NAMES) {
815
+ skip('G5', 'blank fname/lname fill', 'organization has settings.endusers.matchEmailAndNames — a name-less record is unmatchable');
816
+ }
817
+ submittedPhone = nextPhone();
818
+ return [4 /*yield*/, startForm({
819
+ email: victimEmail,
820
+ fname: "Lock".concat(RUN),
821
+ lname: "Victim".concat(RUN, "E1"),
822
+ dateOfBirth: '03-04-1991',
823
+ gender: 'Female',
824
+ phone: submittedPhone,
825
+ })];
826
+ case 2:
827
+ session = _f.sent();
828
+ if (session.enduserId !== victim.id) {
829
+ if (session.enduserId)
830
+ createdEnduserIds.push(session.enduserId);
831
+ record('G5', 'blank match keys filled on match, blank phone NOT filled', false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
832
+ return [2 /*return*/];
833
+ }
834
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return !!e.dateOfBirth && !!e.gender && (MATCH_EMAIL_AND_NAMES || (!!e.fname && !!e.lname)); }, OVERWRITE_WINDOW_MS)];
835
+ case 3:
836
+ filled = _f.sent();
837
+ record('G5', 'blank fname/lname/dateOfBirth/gender still filled on match', filled.landed, "fname=".concat(((_a = filled.enduser) === null || _a === void 0 ? void 0 : _a.fname) || '(unset)', " lname=").concat(((_b = filled.enduser) === null || _b === void 0 ? void 0 : _b.lname) || '(unset)', " ")
838
+ + "dateOfBirth=".concat(((_c = filled.enduser) === null || _c === void 0 ? void 0 : _c.dateOfBirth) || '(unset)', " gender=").concat(((_d = filled.enduser) === null || _d === void 0 ? void 0 : _d.gender) || '(unset)'));
839
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return !!e.phone; }, AWAITED_WINDOW_MS)];
840
+ case 4:
841
+ phoneLanded = _f.sent();
842
+ record('G5', 'blank phone still NOT filled on match', !phoneLanded.landed, phoneLanded.landed
843
+ ? "EXPLOIT: phone=".concat((_e = phoneLanded.enduser) === null || _e === void 0 ? void 0 : _e.phone, " written into a blank field (submitted ").concat(submittedPhone, ")")
844
+ : 'phone remains unset');
845
+ return [2 /*return*/];
846
+ }
847
+ });
848
+ }); })
849
+ // ---------------------------------------------------------------------------------------------
850
+ // G6 — Status-quo pin: state still overwrites on match. This is deliberate, not incidental —
851
+ // state is not an auth channel, and locking it would break a relocated patient booking with a
852
+ // provider licensed in their new state.
853
+ // ---------------------------------------------------------------------------------------------
854
+ ];
855
+ case 24:
856
+ // ---------------------------------------------------------------------------------------------
857
+ // G5 — Intended intake behavior preserved: blank match keys still get filled, blank phone does not.
858
+ // ---------------------------------------------------------------------------------------------
859
+ _r.sent();
860
+ // ---------------------------------------------------------------------------------------------
861
+ // G6 — Status-quo pin: state still overwrites on match. This is deliberate, not incidental —
862
+ // state is not an auth channel, and locking it would break a relocated patient booking with a
863
+ // provider licensed in their new state.
864
+ // ---------------------------------------------------------------------------------------------
865
+ return [4 /*yield*/, runScenario('G6', 'state still overwrites on match (status-quo pin)', function () { return __awaiter(void 0, void 0, void 0, function () {
866
+ var victim, session, _a, landed, enduser;
867
+ return __generator(this, function (_b) {
868
+ switch (_b.label) {
869
+ case 0: return [4 /*yield*/, makeVictim('F1', { email: emailFor('lock-v-f1'), state: 'NY' })];
870
+ case 1:
871
+ victim = _b.sent();
872
+ return [4 /*yield*/, startForm({
873
+ fname: victim.fname,
874
+ lname: victim.lname,
875
+ dateOfBirth: victim.dateOfBirth,
876
+ phone: nextPhone(),
877
+ state: 'CA',
878
+ })];
879
+ case 2:
880
+ session = _b.sent();
881
+ if (session.enduserId !== victim.id) {
882
+ if (session.enduserId)
883
+ createdEnduserIds.push(session.enduserId);
884
+ record('G6', 'state still overwrites on match (status-quo pin)', false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
885
+ return [2 /*return*/];
886
+ }
887
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.state === 'CA'; }, OVERWRITE_WINDOW_MS)];
888
+ case 3:
889
+ _a = _b.sent(), landed = _a.landed, enduser = _a.enduser;
890
+ record('G6', 'state still overwrites on match (status-quo pin)', landed, "state=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.state) || '(unset)'));
891
+ return [2 /*return*/];
892
+ }
893
+ });
894
+ }); })
895
+ // ---------------------------------------------------------------------------------------------
896
+ // G7 — Insert path unchanged: a brand-new visitor is still created with the submitted email/phone.
897
+ // ---------------------------------------------------------------------------------------------
898
+ // Accumulator arrays rather than `let ... | null`: values are assigned inside runScenario's
899
+ // callback, and TS's control-flow narrowing does not account for closure assignments.
900
+ ];
901
+ case 25:
902
+ // ---------------------------------------------------------------------------------------------
903
+ // G6 — Status-quo pin: state still overwrites on match. This is deliberate, not incidental —
904
+ // state is not an auth channel, and locking it would break a relocated patient booking with a
905
+ // provider licensed in their new state.
906
+ // ---------------------------------------------------------------------------------------------
907
+ _r.sent();
908
+ formSessions = [];
909
+ return [4 /*yield*/, runScenario('G7', 'A creates a new visitor with submitted email + phone', function () { return __awaiter(void 0, void 0, void 0, function () {
910
+ var email, phone, session, created, ok;
911
+ return __generator(this, function (_a) {
912
+ switch (_a.label) {
913
+ case 0:
914
+ email = emailFor('lock-new-a');
915
+ phone = nextPhone();
916
+ return [4 /*yield*/, startForm({
917
+ fname: "Lock".concat(RUN), lname: "NewA".concat(RUN), dateOfBirth: '05-06-1992',
918
+ email: email,
919
+ phone: phone,
920
+ })];
921
+ case 1:
922
+ session = _a.sent();
923
+ if (session.enduserId)
924
+ createdEnduserIds.push(session.enduserId);
925
+ return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
926
+ case 2:
927
+ created = _a.sent();
928
+ ok = created.email === email && created.phone === phone
929
+ && !!session.accessCode && !!session.authToken && !!session.enduserId;
930
+ record('G7', 'A creates a new visitor with submitted email + phone', ok, "".concat(describe(created), "; accessCode=").concat(!!session.accessCode, " authToken=").concat(!!session.authToken, " enduserId=").concat(!!session.enduserId));
931
+ if (ok)
932
+ formSessions.push(session);
933
+ return [2 /*return*/];
934
+ }
935
+ });
936
+ }); })];
937
+ case 26:
938
+ _r.sent();
939
+ bookingTokens = [];
940
+ return [4 /*yield*/, runScenario('G7', 'B creates a new visitor with submitted email + phone', function () { return __awaiter(void 0, void 0, void 0, function () {
941
+ var email, phone, lname, session, matching, _i, matching_2, m, created, ok;
942
+ return __generator(this, function (_a) {
943
+ switch (_a.label) {
944
+ case 0:
945
+ email = emailFor('lock-new-b');
946
+ phone = nextPhone();
947
+ lname = "NewB".concat(RUN);
948
+ return [4 /*yield*/, startBooking({
949
+ fname: "Lock".concat(RUN),
950
+ lname: lname,
951
+ dateOfBirth: '07-08-1993',
952
+ email: email,
953
+ phone: phone,
954
+ })];
955
+ case 1:
956
+ session = _a.sent();
957
+ return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { lname: lname } })];
958
+ case 2:
959
+ matching = _a.sent();
960
+ for (_i = 0, matching_2 = matching; _i < matching_2.length; _i++) {
961
+ m = matching_2[_i];
962
+ createdEnduserIds.push(m.id);
963
+ }
964
+ created = matching.length === 1 ? matching[0] : null;
965
+ ok = !!session.authToken && !!created && created.email === email && created.phone === phone;
966
+ record('G7', 'B creates a new visitor with submitted email + phone', ok, "authToken=".concat(!!session.authToken, "; ").concat(matching.length, " record(s); ").concat(describe(created)));
967
+ if (session.authToken)
968
+ bookingTokens.push(session.authToken);
969
+ return [2 /*return*/];
970
+ }
971
+ });
972
+ }); })
973
+ // ---------------------------------------------------------------------------------------------
974
+ // G8 — The returned public-form session still works end-to-end.
975
+ // ---------------------------------------------------------------------------------------------
976
+ ];
977
+ case 27:
978
+ _r.sent();
979
+ // ---------------------------------------------------------------------------------------------
980
+ // G8 — The returned public-form session still works end-to-end.
981
+ // ---------------------------------------------------------------------------------------------
982
+ return [4 /*yield*/, runScenario('G8', 'form submission completes under the returned token', function () { return __awaiter(void 0, void 0, void 0, function () {
983
+ var session, authed, formResponse;
984
+ return __generator(this, function (_a) {
985
+ switch (_a.label) {
986
+ case 0:
987
+ session = formSessions[0];
988
+ if (!session) {
989
+ record('G8', 'form submission completes under the returned token', false, 'no session available from G7');
990
+ return [2 /*return*/];
991
+ }
992
+ authed = new EnduserSession({ host: host, businessId: businessId, authToken: session.authToken });
993
+ return [4 /*yield*/, authed.api.form_responses.submit_form_response({
994
+ accessCode: session.accessCode,
995
+ responses: [{
996
+ fieldId: plainField.id,
997
+ fieldTitle: plainField.title,
998
+ answer: { type: 'string', value: "lock test ".concat(RUN) },
999
+ }],
1000
+ })];
1001
+ case 1:
1002
+ formResponse = (_a.sent()).formResponse;
1003
+ record('G8', 'form submission completes under the returned token', !!(formResponse === null || formResponse === void 0 ? void 0 : formResponse.submittedAt), "formResponse=".concat(formResponse === null || formResponse === void 0 ? void 0 : formResponse.id, " submittedAt=").concat((formResponse === null || formResponse === void 0 ? void 0 : formResponse.submittedAt) ? 'set' : '(unset)'));
1004
+ return [2 /*return*/];
1005
+ }
1006
+ });
1007
+ }); })
1008
+ // ---------------------------------------------------------------------------------------------
1009
+ // G9 — The returned booking session still works. Tiered: availability is always asserted; an
1010
+ // actual booking is attempted only when a slot exists, so an empty window never reads as a
1011
+ // regression.
1012
+ // ---------------------------------------------------------------------------------------------
1013
+ ];
1014
+ case 28:
1015
+ // ---------------------------------------------------------------------------------------------
1016
+ // G8 — The returned public-form session still works end-to-end.
1017
+ // ---------------------------------------------------------------------------------------------
1018
+ _r.sent();
1019
+ // ---------------------------------------------------------------------------------------------
1020
+ // G9 — The returned booking session still works. Tiered: availability is always asserted; an
1021
+ // actual booking is attempted only when a slot exists, so an empty window never reads as a
1022
+ // regression.
1023
+ // ---------------------------------------------------------------------------------------------
1024
+ return [4 /*yield*/, runScenario('G9', 'availability + booking work under the returned session', function () { return __awaiter(void 0, void 0, void 0, function () {
1025
+ var bookingAuthToken, booking, DAY_IN_MS, from, to, availabilityBlocks, slots, createdEvent;
1026
+ return __generator(this, function (_a) {
1027
+ switch (_a.label) {
1028
+ case 0:
1029
+ bookingAuthToken = bookingTokens[0];
1030
+ if (!bookingAuthToken) {
1031
+ record('G9', 'availability + booking work under the returned session', false, 'no booking session available from G7');
1032
+ return [2 /*return*/];
1033
+ }
1034
+ booking = new EnduserSession({ host: host, businessId: businessId, authToken: bookingAuthToken });
1035
+ DAY_IN_MS = 24 * 60 * 60 * 1000;
1036
+ from = new Date(Date.now() + DAY_IN_MS);
1037
+ to = new Date(from.getTime() + (7 * DAY_IN_MS));
1038
+ return [4 /*yield*/, booking.api.calendar_events.get_appointment_availability({
1039
+ calendarEventTemplateId: template.id,
1040
+ userIds: [hostUser.id],
1041
+ restrictedByState: false,
1042
+ from: from,
1043
+ to: to,
1044
+ })];
1045
+ case 1:
1046
+ availabilityBlocks = (_a.sent()).availabilityBlocks;
1047
+ slots = availabilityBlocks.filter(function (b) { return b.userId === hostUser.id && b.startTimeInMS > Date.now(); });
1048
+ record('G9', 'get_appointment_availability succeeds under the confined booking token', true, "".concat(availabilityBlocks.length, " block(s), ").concat(slots.length, " future slot(s) for the throwaway host"));
1049
+ if (slots.length === 0) {
1050
+ skip('G9', 'book_appointment under the confined booking token', 'no future availability slot in the 7-day window');
1051
+ return [2 /*return*/];
1052
+ }
1053
+ return [4 /*yield*/, booking.api.calendar_events.book_appointment({
1054
+ userId: hostUser.id,
1055
+ calendarEventTemplateId: template.id,
1056
+ startTime: new Date(slots[0].startTimeInMS),
1057
+ timezone: 'America/New_York',
1058
+ })];
1059
+ case 2:
1060
+ createdEvent = (_a.sent()).createdEvent;
1061
+ if (createdEvent === null || createdEvent === void 0 ? void 0 : createdEvent.id)
1062
+ createdEventIds.push(createdEvent.id);
1063
+ record('G9', 'book_appointment succeeds under the confined booking token', !!(createdEvent === null || createdEvent === void 0 ? void 0 : createdEvent.id), "createdEvent=".concat(createdEvent === null || createdEvent === void 0 ? void 0 : createdEvent.id));
1064
+ return [2 /*return*/];
1065
+ }
1066
+ });
1067
+ }); })
1068
+ // ---------------------------------------------------------------------------------------------
1069
+ // G10 — skipMatch behavior unchanged.
1070
+ // ---------------------------------------------------------------------------------------------
1071
+ ];
1072
+ case 29:
1073
+ // ---------------------------------------------------------------------------------------------
1074
+ // G9 — The returned booking session still works. Tiered: availability is always asserted; an
1075
+ // actual booking is attempted only when a slot exists, so an empty window never reads as a
1076
+ // regression.
1077
+ // ---------------------------------------------------------------------------------------------
1078
+ _r.sent();
1079
+ // ---------------------------------------------------------------------------------------------
1080
+ // G10 — skipMatch behavior unchanged.
1081
+ // ---------------------------------------------------------------------------------------------
1082
+ return [4 /*yield*/, runScenario('G10', 'skipMatch=true still inserts a new record', function () { return __awaiter(void 0, void 0, void 0, function () {
1083
+ var victim, session;
1084
+ return __generator(this, function (_a) {
1085
+ switch (_a.label) {
1086
+ case 0: return [4 /*yield*/, makeVictim('H1', { email: emailFor('lock-v-h1'), phone: nextPhone() })];
1087
+ case 1:
1088
+ victim = _a.sent();
1089
+ return [4 /*yield*/, startForm({
1090
+ fname: victim.fname,
1091
+ lname: victim.lname,
1092
+ dateOfBirth: victim.dateOfBirth,
1093
+ email: emailFor('lock-skip'),
1094
+ phone: nextPhone(),
1095
+ skipMatch: true,
1096
+ })];
1097
+ case 2:
1098
+ session = _a.sent();
1099
+ if (session.enduserId)
1100
+ createdEnduserIds.push(session.enduserId);
1101
+ record('G10', 'skipMatch=true still inserts a new record', session.enduserId !== victim.id, "inserted=".concat(session.enduserId, " victim=").concat(victim.id));
1102
+ return [2 /*return*/];
1103
+ }
1104
+ });
1105
+ }); })];
1106
+ case 30:
1107
+ // ---------------------------------------------------------------------------------------------
1108
+ // G10 — skipMatch behavior unchanged.
1109
+ // ---------------------------------------------------------------------------------------------
1110
+ _r.sent();
1111
+ return [4 /*yield*/, runScenario('G10', 'skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge', function () { return __awaiter(void 0, void 0, void 0, function () {
1112
+ var session, created;
1113
+ return __generator(this, function (_a) {
1114
+ switch (_a.label) {
1115
+ case 0: return [4 /*yield*/, publicSDK().api.form_responses.session_for_public_form({
1116
+ formId: autoMergeForm.id,
1117
+ businessId: businessId,
1118
+ skipMatch: true,
1119
+ fname: "Lock".concat(RUN),
1120
+ lname: "Merge".concat(RUN),
1121
+ dateOfBirth: '09-10-1994',
1122
+ email: emailFor('lock-merge'),
1123
+ phone: nextPhone(),
1124
+ })];
1125
+ case 1:
1126
+ session = _a.sent();
1127
+ if (session.enduserId)
1128
+ createdEnduserIds.push(session.enduserId);
1129
+ return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
1130
+ case 2:
1131
+ created = _a.sent();
1132
+ record('G10', 'skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge', created.eligibleForAutoMerge === true, "eligibleForAutoMerge=".concat(created.eligibleForAutoMerge));
1133
+ return [2 /*return*/];
1134
+ }
1135
+ });
1136
+ }); })
1137
+ // ---------------------------------------------------------------------------------------------
1138
+ // G11 — Intake answers must not overwrite email/phone on a matched patient (submit path).
1139
+ // ---------------------------------------------------------------------------------------------
1140
+ ];
1141
+ case 31:
1142
+ _r.sent();
1143
+ _loop_3 = function (shape) {
1144
+ var name_3;
1145
+ return __generator(this, function (_u) {
1146
+ switch (_u.label) {
1147
+ case 0:
1148
+ name_3 = "intake answers do not overwrite a matched patient \u2014 ".concat(shape.label);
1149
+ return [4 /*yield*/, runScenario('G11', name_3, function () { return __awaiter(void 0, void 0, void 0, function () {
1150
+ var victimEmail, victimPhone, victim, attackerEmail, attackerPhone, session, enduser, emailHeld, phoneHeld;
1151
+ return __generator(this, function (_a) {
1152
+ switch (_a.label) {
1153
+ case 0:
1154
+ victimEmail = shape.withEmail ? emailFor("lock-i-".concat(shape.tag)) : undefined;
1155
+ victimPhone = shape.withPhone ? nextPhone() : undefined;
1156
+ return [4 /*yield*/, makeVictim("I".concat(shape.tag), __assign(__assign({}, (victimEmail ? { email: victimEmail } : {})), (victimPhone ? { phone: victimPhone } : {})))];
1157
+ case 1:
1158
+ victim = _a.sent();
1159
+ attackerEmail = emailFor("atk-i-".concat(shape.tag));
1160
+ attackerPhone = nextPhone();
1161
+ return [4 /*yield*/, startFormOn(intake.form.id, {
1162
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
1163
+ })];
1164
+ case 2:
1165
+ session = _a.sent();
1166
+ if (session.enduserId)
1167
+ createdEnduserIds.push(session.enduserId);
1168
+ if (session.enduserId !== victim.id) {
1169
+ record('G11', name_3, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
1170
+ return [2 /*return*/];
1171
+ }
1172
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
1173
+ accessCode: session.accessCode,
1174
+ responses: intakeAnswers(intake, attackerEmail, attackerPhone),
1175
+ })];
1176
+ case 3:
1177
+ _a.sent();
1178
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)];
1179
+ case 4:
1180
+ enduser = (_a.sent()).enduser;
1181
+ emailHeld = ((enduser === null || enduser === void 0 ? void 0 : enduser.email) || undefined) === victimEmail;
1182
+ phoneHeld = ((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || undefined) === victimPhone;
1183
+ record('G11', "".concat(name_3, " \u2014 email"), emailHeld, emailHeld
1184
+ ? "email still ".concat(victimEmail || '(unset)')
1185
+ : "EXPLOIT: intake answer wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email, " (was ").concat(victimEmail || '(unset)', ")"));
1186
+ record('G11', "".concat(name_3, " \u2014 phone"), phoneHeld, phoneHeld
1187
+ ? "phone still ".concat(victimPhone || '(unset)')
1188
+ : "EXPLOIT: intake answer wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.phone, " (was ").concat(victimPhone || '(unset)', ")"));
1189
+ return [2 /*return*/];
1190
+ }
1191
+ });
1192
+ }); })];
1193
+ case 1:
1194
+ _u.sent();
1195
+ return [2 /*return*/];
1196
+ }
1197
+ });
1198
+ };
1199
+ _c = 0, VICTIM_SHAPES_3 = VICTIM_SHAPES;
1200
+ _r.label = 32;
1201
+ case 32:
1202
+ if (!(_c < VICTIM_SHAPES_3.length)) return [3 /*break*/, 35];
1203
+ shape = VICTIM_SHAPES_3[_c];
1204
+ return [5 /*yield**/, _loop_3(shape)];
1205
+ case 33:
1206
+ _r.sent();
1207
+ _r.label = 34;
1208
+ case 34:
1209
+ _c++;
1210
+ return [3 /*break*/, 32];
1211
+ case 35:
1212
+ G12_NAME = 'saveIntakeOnPartial answers do not overwrite a matched patient';
1213
+ return [4 /*yield*/, runScenario('G12', G12_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1214
+ var victim, attackerEmail, session, enduser;
1215
+ return __generator(this, function (_a) {
1216
+ switch (_a.label) {
1217
+ case 0: return [4 /*yield*/, makeVictim('J1', { phone: nextPhone() })]; // email-less: the load-bearing shape
1218
+ case 1:
1219
+ victim = _a.sent() // email-less: the load-bearing shape
1220
+ ;
1221
+ attackerEmail = emailFor('atk-j1');
1222
+ return [4 /*yield*/, startFormOn(partialIntake.form.id, {
1223
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
1224
+ })];
1225
+ case 2:
1226
+ session = _a.sent();
1227
+ if (session.enduserId)
1228
+ createdEnduserIds.push(session.enduserId);
1229
+ if (session.enduserId !== victim.id) {
1230
+ record('G12', G12_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
1231
+ return [2 /*return*/];
1232
+ }
1233
+ // No submit at all: for fields carrying options.saveIntakeOnPartial, save_field_response alone
1234
+ // triggers the intake write.
1235
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.save_field_response({
1236
+ accessCode: session.accessCode,
1237
+ response: {
1238
+ fieldId: partialIntake.emailField.id,
1239
+ fieldTitle: partialIntake.emailField.title,
1240
+ answer: { type: 'email', value: attackerEmail },
1241
+ },
1242
+ })];
1243
+ case 3:
1244
+ // No submit at all: for fields carrying options.saveIntakeOnPartial, save_field_response alone
1245
+ // triggers the intake write.
1246
+ _a.sent();
1247
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)];
1248
+ case 4:
1249
+ enduser = (_a.sent()).enduser;
1250
+ record('G12', G12_NAME, !(enduser === null || enduser === void 0 ? void 0 : enduser.email), !(enduser === null || enduser === void 0 ? void 0 : enduser.email)
1251
+ ? 'email still (unset) after partial save'
1252
+ : "EXPLOIT: partial save wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email, " without ever submitting the form"));
1253
+ return [2 /*return*/];
1254
+ }
1255
+ });
1256
+ }); })
1257
+ // ---------------------------------------------------------------------------------------------
1258
+ // G13 — The full takeover chain, driven through an intake answer instead of the endpoint.
1259
+ // ---------------------------------------------------------------------------------------------
1260
+ ];
1261
+ case 36:
1262
+ _r.sent();
1263
+ G13_NAME = 'takeover chain via intake answer: victim not made resolvable';
1264
+ return [4 /*yield*/, runScenario('G13', G13_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1265
+ var victim, attackerEmail, session, enduser, resetRes, resolvable, after, afterResult;
1266
+ var _a;
1267
+ return __generator(this, function (_b) {
1268
+ switch (_b.label) {
1269
+ case 0: return [4 /*yield*/, makeVictim('K1', { phone: nextPhone() })];
1270
+ case 1:
1271
+ victim = _b.sent();
1272
+ attackerEmail = emailFor('atk-k1');
1273
+ return [4 /*yield*/, startFormOn(intake.form.id, {
1274
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
1275
+ })];
1276
+ case 2:
1277
+ session = _b.sent();
1278
+ if (session.enduserId)
1279
+ createdEnduserIds.push(session.enduserId);
1280
+ if (session.enduserId !== victim.id) {
1281
+ record('G13', G13_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
1282
+ return [2 /*return*/];
1283
+ }
1284
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
1285
+ accessCode: session.accessCode,
1286
+ responses: intakeAnswers(intake, attackerEmail, nextPhone()),
1287
+ })];
1288
+ case 3:
1289
+ _b.sent();
1290
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)
1291
+ // Informational only: 204 regardless of outcome.
1292
+ ];
1293
+ case 4:
1294
+ enduser = (_b.sent()).enduser;
1295
+ return [4 /*yield*/, post('/v1/request-enduser-password-reset', { email: attackerEmail, businessId: businessId })];
1296
+ case 5:
1297
+ resetRes = _b.sent();
1298
+ console.log(" (info) request-enduser-password-reset -> HTTP ".concat(resetRes.status, " \u2014 unconditional, carries no signal"));
1299
+ return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { email: attackerEmail } })];
1300
+ case 6:
1301
+ resolvable = _b.sent();
1302
+ record('G13', 'attacker email resolves to no enduser after intake submit', resolvable.length === 0, resolvable.length === 0
1303
+ ? "no enduser carries ".concat(attackerEmail, " (victim still ").concat(describe(enduser), ")")
1304
+ : "EXPLOIT: ".concat(resolvable.length, " enduser(s) now resolve for ").concat(attackerEmail, " \u2014 reset token would be mailed to the attacker"));
1305
+ return [4 /*yield*/, post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId: businessId })];
1306
+ case 7:
1307
+ after = _b.sent();
1308
+ afterResult = (_a = after.data) === null || _a === void 0 ? void 0 : _a.result;
1309
+ if (after.status === 429) {
1310
+ skip('G13', 'begin_login_flow oracle after intake attack', 'rate limited (429) — oracle unavailable this run');
1311
+ }
1312
+ else {
1313
+ record('G13', 'begin_login_flow still returns continue-with-password after intake attack', afterResult === 'continue-with-password', afterResult === 'continue-with-password'
1314
+ ? "result=".concat(afterResult)
1315
+ : "EXPLOIT: result=".concat(afterResult, " \u2014 an OTP / magic link was dispatched for the attacker's address"));
1316
+ }
1317
+ return [2 /*return*/];
1318
+ }
1319
+ });
1320
+ }); })
1321
+ // ---------------------------------------------------------------------------------------------
1322
+ // G14 — Pivot: a public session can prepare a response for a NON-public form and submit that.
1323
+ // ---------------------------------------------------------------------------------------------
1324
+ ];
1325
+ case 37:
1326
+ _r.sent();
1327
+ G14_NAME = 'pivot via prepare_form_response to a non-public form is also blocked';
1328
+ return [4 /*yield*/, runScenario('G14', G14_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1329
+ var victimPhone, victim, attackerEmail, attackerPhone, session, authed, prepared, enduser;
1330
+ return __generator(this, function (_a) {
1331
+ switch (_a.label) {
1332
+ case 0:
1333
+ victimPhone = nextPhone();
1334
+ return [4 /*yield*/, makeVictim('L1', { phone: victimPhone })];
1335
+ case 1:
1336
+ victim = _a.sent();
1337
+ attackerEmail = emailFor('atk-l1');
1338
+ attackerPhone = nextPhone();
1339
+ return [4 /*yield*/, startFormOn(intake.form.id, {
1340
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
1341
+ })];
1342
+ case 2:
1343
+ session = _a.sent();
1344
+ if (session.enduserId)
1345
+ createdEnduserIds.push(session.enduserId);
1346
+ if (session.enduserId !== victim.id) {
1347
+ record('G14', G14_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
1348
+ return [2 /*return*/];
1349
+ }
1350
+ authed = asEnduser(session.authToken);
1351
+ return [4 /*yield*/, authed.api.form_responses.prepare_form_response({
1352
+ formId: pivotIntake.form.id,
1353
+ enduserId: victim.id,
1354
+ })];
1355
+ case 3:
1356
+ prepared = _a.sent();
1357
+ return [4 /*yield*/, authed.api.form_responses.submit_form_response({
1358
+ accessCode: prepared.accessCode,
1359
+ responses: intakeAnswers(pivotIntake, attackerEmail, attackerPhone),
1360
+ })];
1361
+ case 4:
1362
+ _a.sent();
1363
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)];
1364
+ case 5:
1365
+ enduser = (_a.sent()).enduser;
1366
+ record('G14', "".concat(G14_NAME, " \u2014 email"), !(enduser === null || enduser === void 0 ? void 0 : enduser.email), !(enduser === null || enduser === void 0 ? void 0 : enduser.email)
1367
+ ? 'email still (unset) after pivot submit'
1368
+ : "EXPLOIT: pivot to a non-public form wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email));
1369
+ record('G14', "".concat(G14_NAME, " \u2014 phone"), (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === victimPhone, (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === victimPhone
1370
+ ? "phone still ".concat(victimPhone)
1371
+ : "EXPLOIT: pivot to a non-public form wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.phone, " (was ").concat(victimPhone, ")"));
1372
+ return [2 /*return*/];
1373
+ }
1374
+ });
1375
+ }); })
1376
+ // ---------------------------------------------------------------------------------------------
1377
+ // G15 — save_field_response ownership: a guessable formResponseId must not reach another patient.
1378
+ // ---------------------------------------------------------------------------------------------
1379
+ ];
1380
+ case 38:
1381
+ _r.sent();
1382
+ G15_NAME = "save_field_response rejects another patient's formResponseId";
1383
+ return [4 /*yield*/, runScenario('G15', G15_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1384
+ var patientA, patientB, sessionA, bPrepared, bResponseId, answer, byId, byCode, byIdWrongCode, aResponse, ownBoth;
1385
+ var _a, _b, _c;
1386
+ return __generator(this, function (_d) {
1387
+ switch (_d.label) {
1388
+ case 0: return [4 /*yield*/, makeVictim('M1', { phone: nextPhone() })];
1389
+ case 1:
1390
+ patientA = _d.sent();
1391
+ return [4 /*yield*/, makeVictim('M2', { phone: nextPhone() })];
1392
+ case 2:
1393
+ patientB = _d.sent();
1394
+ return [4 /*yield*/, startFormOn(partialIntake.form.id, {
1395
+ fname: patientA.fname, lname: patientA.lname, dateOfBirth: patientA.dateOfBirth,
1396
+ })];
1397
+ case 3:
1398
+ sessionA = _d.sent();
1399
+ if (sessionA.enduserId)
1400
+ createdEnduserIds.push(sessionA.enduserId);
1401
+ if (sessionA.enduserId !== patientA.id) {
1402
+ record('G15', G15_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(sessionA.enduserId, " instead of matching patient A ").concat(patientA.id));
1403
+ return [2 /*return*/];
1404
+ }
1405
+ return [4 /*yield*/, sdk.api.form_responses.prepare_form_response({
1406
+ formId: partialIntake.form.id, enduserId: patientB.id,
1407
+ })];
1408
+ case 4:
1409
+ bPrepared = _d.sent();
1410
+ bResponseId = (_b = (_a = bPrepared.response) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : (_c = bPrepared.response) === null || _c === void 0 ? void 0 : _c._id;
1411
+ answer = {
1412
+ fieldId: partialIntake.emailField.id,
1413
+ fieldTitle: partialIntake.emailField.title,
1414
+ answer: { type: 'email', value: emailFor('atk-m1') },
1415
+ };
1416
+ return [4 /*yield*/, patchAuthed('/v1/save-field-response', { formResponseId: bResponseId, response: answer }, sessionA.authToken)];
1417
+ case 5:
1418
+ byId = _d.sent();
1419
+ record('G15', G15_NAME, byId.status === 403, byId.status === 403
1420
+ ? 'HTTP 403 as expected'
1421
+ : "EXPLOIT: HTTP ".concat(byId.status, " \u2014 patient A wrote into patient B's in-progress response"));
1422
+ return [4 /*yield*/, patchAuthed('/v1/save-field-response', { accessCode: bPrepared.accessCode, response: answer }, sessionA.authToken)];
1423
+ case 6:
1424
+ byCode = _d.sent();
1425
+ record('G15', 'save_field_response still accepts a known accessCode', byCode.status === 200, "HTTP ".concat(byCode.status));
1426
+ return [4 /*yield*/, patchAuthed('/v1/save-field-response', { formResponseId: bResponseId, accessCode: 'not-a-real-access-code', response: answer }, sessionA.authToken)];
1427
+ case 7:
1428
+ byIdWrongCode = _d.sent();
1429
+ record('G15', "another patient's formResponseId + wrong accessCode is rejected", byIdWrongCode.status === 403, "HTTP ".concat(byIdWrongCode.status));
1430
+ return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: sessionA.accessCode } })];
1431
+ case 8:
1432
+ aResponse = (_d.sent())[0];
1433
+ return [4 /*yield*/, patchAuthed('/v1/save-field-response', {
1434
+ formResponseId: aResponse === null || aResponse === void 0 ? void 0 : aResponse.id,
1435
+ accessCode: sessionA.accessCode,
1436
+ response: {
1437
+ fieldId: partialIntake.emailField.id,
1438
+ fieldTitle: partialIntake.emailField.title,
1439
+ answer: { type: 'email', value: emailFor('own-both') },
1440
+ },
1441
+ }, sessionA.authToken)];
1442
+ case 9:
1443
+ ownBoth = _d.sent();
1444
+ record('G15', 'own response with accessCode + formResponseId still succeeds (real UI shape)', ownBoth.status === 200, ownBoth.status === 200 ? 'HTTP 200' : "REGRESSION: HTTP ".concat(ownBoth.status, " \u2014 this is how the form UI calls it"));
1445
+ return [2 /*return*/];
1446
+ }
1447
+ });
1448
+ }); })
1449
+ // ---------------------------------------------------------------------------------------------
1450
+ // G16 — No-regression: the intake path is the ONLY source of contact info for a new visitor.
1451
+ // This is the intakeEmailHidden / intakePhone:'hidden' configuration, and the reason the lock is
1452
+ // scoped to "matched a pre-existing patient" rather than "any public session".
1453
+ // ---------------------------------------------------------------------------------------------
1454
+ ];
1455
+ case 39:
1456
+ _r.sent();
1457
+ // ---------------------------------------------------------------------------------------------
1458
+ // G16 — No-regression: the intake path is the ONLY source of contact info for a new visitor.
1459
+ // This is the intakeEmailHidden / intakePhone:'hidden' configuration, and the reason the lock is
1460
+ // scoped to "matched a pre-existing patient" rather than "any public session".
1461
+ // ---------------------------------------------------------------------------------------------
1462
+ return [4 /*yield*/, runScenario('G16', 'brand-new visitor still gets email/phone from intake answers', function () { return __awaiter(void 0, void 0, void 0, function () {
1463
+ var submittedEmail, submittedPhone, session, created, enduser;
1464
+ return __generator(this, function (_a) {
1465
+ switch (_a.label) {
1466
+ case 0:
1467
+ submittedEmail = emailFor('lock-new-intake');
1468
+ submittedPhone = nextPhone();
1469
+ return [4 /*yield*/, startFormOn(intake.form.id, {
1470
+ fname: "Lock".concat(RUN), lname: "NewIntake".concat(RUN), dateOfBirth: '05-06-1992',
1471
+ })];
1472
+ case 1:
1473
+ session = _a.sent();
1474
+ if (session.enduserId)
1475
+ createdEnduserIds.push(session.enduserId);
1476
+ return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
1477
+ case 2:
1478
+ created = _a.sent();
1479
+ record('G16', 'new visitor starts with no contact info from the endpoint', !created.email && !created.phone, describe(created));
1480
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
1481
+ accessCode: session.accessCode,
1482
+ responses: intakeAnswers(intake, submittedEmail, submittedPhone),
1483
+ })];
1484
+ case 3:
1485
+ _a.sent();
1486
+ return [4 /*yield*/, pollEnduser(session.enduserId, function (e) { return !!e.email && !!e.phone; }, AWAITED_WINDOW_MS)];
1487
+ case 4:
1488
+ enduser = (_a.sent()).enduser;
1489
+ record('G16', 'brand-new visitor still gets email from intake answers', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === submittedEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(submittedEmail));
1490
+ record('G16', 'brand-new visitor still gets phone from intake answers', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === submittedPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(submittedPhone));
1491
+ return [2 /*return*/];
1492
+ }
1493
+ });
1494
+ }); })
1495
+ // ---------------------------------------------------------------------------------------------
1496
+ // G17 — No-regression: a fully authenticated (non-public) portal session may still self-update.
1497
+ // ---------------------------------------------------------------------------------------------
1498
+ ];
1499
+ case 40:
1500
+ // ---------------------------------------------------------------------------------------------
1501
+ // G16 — No-regression: the intake path is the ONLY source of contact info for a new visitor.
1502
+ // This is the intakeEmailHidden / intakePhone:'hidden' configuration, and the reason the lock is
1503
+ // scoped to "matched a pre-existing patient" rather than "any public session".
1504
+ // ---------------------------------------------------------------------------------------------
1505
+ _r.sent();
1506
+ // ---------------------------------------------------------------------------------------------
1507
+ // G17 — No-regression: a fully authenticated (non-public) portal session may still self-update.
1508
+ // ---------------------------------------------------------------------------------------------
1509
+ return [4 /*yield*/, runScenario('G17', 'authenticated portal enduser can still self-update via intake', function () { return __awaiter(void 0, void 0, void 0, function () {
1510
+ var patient, newEmail, newPhone, portalSDK, prepared, enduser;
1511
+ return __generator(this, function (_a) {
1512
+ switch (_a.label) {
1513
+ case 0: return [4 /*yield*/, makeVictim('N1', { email: emailFor('lock-n1'), phone: nextPhone() })];
1514
+ case 1:
1515
+ patient = _a.sent();
1516
+ newEmail = emailFor('lock-n1-new');
1517
+ newPhone = nextPhone();
1518
+ portalSDK = new EnduserSession({ host: host, businessId: businessId });
1519
+ return [4 /*yield*/, authenticate_enduser_via_token(sdk, portalSDK, { id: patient.id })];
1520
+ case 2:
1521
+ _a.sent();
1522
+ return [4 /*yield*/, sdk.api.form_responses.prepare_form_response({
1523
+ formId: intake.form.id, enduserId: patient.id,
1524
+ })];
1525
+ case 3:
1526
+ prepared = _a.sent();
1527
+ return [4 /*yield*/, portalSDK.api.form_responses.submit_form_response({
1528
+ accessCode: prepared.accessCode,
1529
+ responses: intakeAnswers(intake, newEmail, newPhone),
1530
+ })];
1531
+ case 4:
1532
+ _a.sent();
1533
+ return [4 /*yield*/, pollEnduser(patient.id, function (e) { return e.email === newEmail; }, AWAITED_WINDOW_MS)];
1534
+ case 5:
1535
+ enduser = (_a.sent()).enduser;
1536
+ record('G17', 'authenticated portal intake still updates email', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === newEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(newEmail));
1537
+ record('G17', 'authenticated portal intake still updates phone', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === newPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(newPhone));
1538
+ return [2 /*return*/];
1539
+ }
1540
+ });
1541
+ }); })
1542
+ // ---------------------------------------------------------------------------------------------
1543
+ // G18 — No-regression: staff submitting on a patient's behalf may still set email/phone.
1544
+ // ---------------------------------------------------------------------------------------------
1545
+ ];
1546
+ case 41:
1547
+ // ---------------------------------------------------------------------------------------------
1548
+ // G17 — No-regression: a fully authenticated (non-public) portal session may still self-update.
1549
+ // ---------------------------------------------------------------------------------------------
1550
+ _r.sent();
1551
+ // ---------------------------------------------------------------------------------------------
1552
+ // G18 — No-regression: staff submitting on a patient's behalf may still set email/phone.
1553
+ // ---------------------------------------------------------------------------------------------
1554
+ return [4 /*yield*/, runScenario('G18', 'staff intake submission still updates email/phone', function () { return __awaiter(void 0, void 0, void 0, function () {
1555
+ var patient, newEmail, newPhone, prepared, enduser;
1556
+ return __generator(this, function (_a) {
1557
+ switch (_a.label) {
1558
+ case 0: return [4 /*yield*/, makeVictim('O1', { email: emailFor('lock-o1'), phone: nextPhone() })];
1559
+ case 1:
1560
+ patient = _a.sent();
1561
+ newEmail = emailFor('lock-o1-new');
1562
+ newPhone = nextPhone();
1563
+ return [4 /*yield*/, sdk.api.form_responses.prepare_form_response({
1564
+ formId: intake.form.id, enduserId: patient.id,
1565
+ })];
1566
+ case 2:
1567
+ prepared = _a.sent();
1568
+ return [4 /*yield*/, sdk.api.form_responses.submit_form_response({
1569
+ accessCode: prepared.accessCode,
1570
+ responses: intakeAnswers(intake, newEmail, newPhone),
1571
+ })];
1572
+ case 3:
1573
+ _a.sent();
1574
+ return [4 /*yield*/, pollEnduser(patient.id, function (e) { return e.email === newEmail; }, AWAITED_WINDOW_MS)];
1575
+ case 4:
1576
+ enduser = (_a.sent()).enduser;
1577
+ record('G18', 'staff intake submission still updates email', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === newEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(newEmail));
1578
+ record('G18', 'staff intake submission still updates phone', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === newPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(newPhone));
1579
+ return [2 /*return*/];
1580
+ }
1581
+ });
1582
+ }); })
1583
+ // ---------------------------------------------------------------------------------------------
1584
+ // G19 / G20 — Cross-patient session via a null fname/lname match.
1585
+ //
1586
+ // In both handlers' $or, `email` and `dateOfBirth` get `|| "never"` sentinels but fname/lname are
1587
+ // interpolated RAW. `ignoreUndefined` is not set on the Mongo client (database.ts connect options;
1588
+ // driver 5.7.0 defaults it to false), and findOne does not strip undefined keys — so an absent
1589
+ // fname serializes to `{fname: null}`, which matches every document where the field is null OR
1590
+ // MISSING. fname/lname have no schema initializer, so a cold-started enduser has them absent.
1591
+ //
1592
+ // Net: a request supplying ONLY dateOfBirth queries {fname: null, lname: null, dateOfBirth: X} and
1593
+ // can match another patient, returning a session scoped to THEIR record. The cold-start pattern is
1594
+ // what manufactures nameless endusers, and a later intakeField: 'dateOfBirth' question fills the DOB.
1595
+ // ---------------------------------------------------------------------------------------------
1596
+ // dateOfBirth is only string-validated and the handlers merely trim it, so a run-tokenized value is
1597
+ // accepted. Deliberately opaque rather than date-shaped, so match-vs-insert is unambiguous even when
1598
+ // this file runs inside the full suite (no reset_db between files).
1599
+ ];
1600
+ case 42:
1601
+ // ---------------------------------------------------------------------------------------------
1602
+ // G18 — No-regression: staff submitting on a patient's behalf may still set email/phone.
1603
+ // ---------------------------------------------------------------------------------------------
1604
+ _r.sent();
1605
+ namelessDob = "01-02-1990-".concat(RUN);
1606
+ makeNamelessVictim = function (tag) { return __awaiter(void 0, void 0, void 0, function () {
1607
+ var victim, stored;
1608
+ return __generator(this, function (_a) {
1609
+ switch (_a.label) {
1610
+ case 0: return [4 /*yield*/, sdk.api.endusers.createOne({ dateOfBirth: "".concat(namelessDob, "-").concat(tag) })];
1611
+ case 1:
1612
+ victim = _a.sent();
1613
+ createdEnduserIds.push(victim.id);
1614
+ return [4 /*yield*/, sdk.api.endusers.getOne(victim.id)];
1615
+ case 2:
1616
+ stored = _a.sent();
1617
+ return [2 /*return*/, { victim: victim, stored: stored }];
1618
+ }
1619
+ });
1620
+ }); };
1621
+ G19_NAME = 'DOB-only request does not hijack a nameless patient (form endpoint)';
1622
+ return [4 /*yield*/, runScenario('G19', G19_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1623
+ var _a, victim, stored, session;
1624
+ return __generator(this, function (_b) {
1625
+ switch (_b.label) {
1626
+ case 0: return [4 /*yield*/, makeNamelessVictim('f')
1627
+ // Probe validity: a stored '' would not be null-matched and would make this test vacuous.
1628
+ ];
1629
+ case 1:
1630
+ _a = _b.sent(), victim = _a.victim, stored = _a.stored;
1631
+ // Probe validity: a stored '' would not be null-matched and would make this test vacuous.
1632
+ record('G19', 'nameless victim really has absent fname/lname (probe validity)', stored.fname === undefined && stored.lname === undefined, "fname=".concat(JSON.stringify(stored.fname), " lname=").concat(JSON.stringify(stored.lname)));
1633
+ return [4 /*yield*/, startFormOn(intake.form.id, { dateOfBirth: stored.dateOfBirth })];
1634
+ case 2:
1635
+ session = _b.sent();
1636
+ if (session.enduserId)
1637
+ createdEnduserIds.push(session.enduserId);
1638
+ record('G19', G19_NAME, session.enduserId !== victim.id, session.enduserId !== victim.id
1639
+ ? "inserted a new record (".concat(session.enduserId, ") instead of matching the victim")
1640
+ : "EXPLOIT: returned a session for the victim's record ".concat(victim.id, " from a DOB-only request"));
1641
+ return [2 /*return*/];
1642
+ }
1643
+ });
1644
+ }); })];
1645
+ case 43:
1646
+ _r.sent();
1647
+ G20_NAME = 'DOB-only request does not hijack a nameless patient (booking endpoint)';
1648
+ return [4 /*yield*/, runScenario('G20', G20_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1649
+ var _a, victim, stored, dob, withDob;
1650
+ return __generator(this, function (_b) {
1651
+ switch (_b.label) {
1652
+ case 0: return [4 /*yield*/, makeNamelessVictim('b')];
1653
+ case 1:
1654
+ _a = _b.sent(), victim = _a.victim, stored = _a.stored;
1655
+ dob = stored.dateOfBirth;
1656
+ // The booking endpoint has no skipMatch option and no phone guard at all, so this filter is
1657
+ // reached unconditionally. It returns only { authToken }, so detect match-vs-insert by counting
1658
+ // records carrying this run-unique DOB.
1659
+ return [4 /*yield*/, startBooking({ dateOfBirth: dob })];
1660
+ case 2:
1661
+ // The booking endpoint has no skipMatch option and no phone guard at all, so this filter is
1662
+ // reached unconditionally. It returns only { authToken }, so detect match-vs-insert by counting
1663
+ // records carrying this run-unique DOB.
1664
+ _b.sent();
1665
+ return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { dateOfBirth: dob } })];
1666
+ case 3:
1667
+ withDob = _b.sent();
1668
+ withDob.forEach(function (e) { if (e.id !== victim.id)
1669
+ createdEnduserIds.push(e.id); });
1670
+ record('G20', G20_NAME, withDob.length > 1, withDob.length > 1
1671
+ ? "inserted a new record (".concat(withDob.length, " total with this DOB) instead of matching the victim")
1672
+ : "EXPLOIT: matched the victim's record ".concat(victim.id, " from a DOB-only request \u2014 a booking session was issued for it"));
1673
+ return [2 /*return*/];
1674
+ }
1675
+ });
1676
+ }); })
1677
+ // ---------------------------------------------------------------------------------------------
1678
+ // G21 — The match sentinel must never be stored as a patient's name.
1679
+ // Guard rail on the fix: the fallback belongs in the filter literal only, never written into
1680
+ // enduserInfo (which is spread into both the enduser insert and the form_responses record).
1681
+ // ---------------------------------------------------------------------------------------------
1682
+ ];
1683
+ case 44:
1684
+ _r.sent();
1685
+ // ---------------------------------------------------------------------------------------------
1686
+ // G21 — The match sentinel must never be stored as a patient's name.
1687
+ // Guard rail on the fix: the fallback belongs in the filter literal only, never written into
1688
+ // enduserInfo (which is spread into both the enduser insert and the form_responses record).
1689
+ // ---------------------------------------------------------------------------------------------
1690
+ return [4 /*yield*/, runScenario('G21', 'match sentinel never leaks into stored fname/lname', function () { return __awaiter(void 0, void 0, void 0, function () {
1691
+ var session, created, leaked, response;
1692
+ return __generator(this, function (_a) {
1693
+ switch (_a.label) {
1694
+ case 0: return [4 /*yield*/, startFormOn(intake.form.id, { dateOfBirth: "".concat(namelessDob, "-sentinel") })];
1695
+ case 1:
1696
+ session = _a.sent();
1697
+ if (session.enduserId)
1698
+ createdEnduserIds.push(session.enduserId);
1699
+ return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
1700
+ case 2:
1701
+ created = _a.sent();
1702
+ leaked = function (v) { return v === '__never__' || v === 'never'; };
1703
+ record('G21', 'created enduser fname/lname are not the sentinel', !leaked(created.fname) && !leaked(created.lname), "fname=".concat(JSON.stringify(created.fname), " lname=").concat(JSON.stringify(created.lname)));
1704
+ return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })];
1705
+ case 3:
1706
+ response = (_a.sent())[0];
1707
+ record('G21', 'form_responses fname/lname are not the sentinel', !leaked(response === null || response === void 0 ? void 0 : response.fname) && !leaked(response === null || response === void 0 ? void 0 : response.lname), "fname=".concat(JSON.stringify(response === null || response === void 0 ? void 0 : response.fname), " lname=").concat(JSON.stringify(response === null || response === void 0 ? void 0 : response.lname)));
1708
+ return [2 /*return*/];
1709
+ }
1710
+ });
1711
+ }); })
1712
+ // ---------------------------------------------------------------------------------------------
1713
+ // G22 — The cold-start pattern: a public form started with NO intake info, which just creates an
1714
+ // Enduser with an ID. Intake answers (including the authentication fields email/phone) must still
1715
+ // populate that fresh record — the lock is scoped to "matched a pre-existing patient", not to
1716
+ // "any public session", precisely so this keeps working.
1717
+ // ---------------------------------------------------------------------------------------------
1718
+ ];
1719
+ case 45:
1720
+ // ---------------------------------------------------------------------------------------------
1721
+ // G21 — The match sentinel must never be stored as a patient's name.
1722
+ // Guard rail on the fix: the fallback belongs in the filter literal only, never written into
1723
+ // enduserInfo (which is spread into both the enduser insert and the form_responses record).
1724
+ // ---------------------------------------------------------------------------------------------
1725
+ _r.sent();
1726
+ coldStartCase = function (label, args) { return __awaiter(void 0, void 0, void 0, function () {
1727
+ var group, submittedEmail, submittedPhone, session, created, enduser;
1728
+ return __generator(this, function (_a) {
1729
+ switch (_a.label) {
1730
+ case 0:
1731
+ group = 'G22';
1732
+ submittedEmail = emailFor("cold-".concat(label));
1733
+ submittedPhone = nextPhone();
1734
+ return [4 /*yield*/, startFormOn(intake.form.id, args)];
1735
+ case 1:
1736
+ session = _a.sent();
1737
+ if (session.enduserId)
1738
+ createdEnduserIds.push(session.enduserId);
1739
+ return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
1740
+ case 2:
1741
+ created = _a.sent();
1742
+ record(group, "".concat(label, ": cold start creates a bare enduser"), !created.email && !created.phone && !!session.enduserId, describe(created));
1743
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
1744
+ accessCode: session.accessCode,
1745
+ responses: intakeAnswers(intake, submittedEmail, submittedPhone),
1746
+ })];
1747
+ case 3:
1748
+ _a.sent();
1749
+ return [4 /*yield*/, pollEnduser(session.enduserId, function (e) { return !!e.email && !!e.phone; }, AWAITED_WINDOW_MS)];
1750
+ case 4:
1751
+ enduser = (_a.sent()).enduser;
1752
+ record(group, "".concat(label, ": intake email (an auth field) still populates the cold-started record"), (enduser === null || enduser === void 0 ? void 0 : enduser.email) === submittedEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(submittedEmail));
1753
+ record(group, "".concat(label, ": intake phone (an auth field) still populates the cold-started record"), (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === submittedPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(submittedPhone));
1754
+ return [2 /*return*/];
1755
+ }
1756
+ });
1757
+ }); };
1758
+ // The shape the real webapp sends (public_form.tsx handleNoIntakeRedirect): nothing but ids.
1759
+ return [4 /*yield*/, runScenario('G22', 'cold start with skipMatch (real webapp shape)', function () { return coldStartCase('skipMatch', { skipMatch: true }); })
1760
+ // And the same with no skipMatch, which still cannot match: the email branch needs email === "never"
1761
+ // (unstorable) and the name branch needs dateOfBirth === "never" literally.
1762
+ ];
1763
+ case 46:
1764
+ // The shape the real webapp sends (public_form.tsx handleNoIntakeRedirect): nothing but ids.
1765
+ _r.sent();
1766
+ // And the same with no skipMatch, which still cannot match: the email branch needs email === "never"
1767
+ // (unstorable) and the name branch needs dateOfBirth === "never" literally.
1768
+ return [4 /*yield*/, runScenario('G22', 'cold start without skipMatch', function () { return coldStartCase('no-skipMatch', {}); })
1769
+ // ---------------------------------------------------------------------------------------------
1770
+ // G23 — businessId must be required on session_for_public_form.
1771
+ //
1772
+ // addPublicEndpoint overwrites the schema's `required: true` for businessId with
1773
+ // mongoIdStringOptional (routing.ts, and its own "todo: make businessId required"), and TypeScript
1774
+ // cannot catch it because CustomAction declares businessId as a non-optional string.
1775
+ //
1776
+ // A falsy businessId then leaves restrictAccess as the IDENTITY function in
1777
+ // buildAllQueries/buildOrganizationWideQueries (database.ts): the tenant clause is not appended at
1778
+ // all, so every query in the handler runs across ALL tenants. It fails OPEN, which is worse than
1779
+ // matching nothing. login_enduser signs the JWT from enduser.businessId, not from the request, so
1780
+ // the returned token is a fully valid session on whatever record matched.
1781
+ //
1782
+ // session_for_public_appointment_booking already guards this (its own comment says the schema
1783
+ // cannot enforce it); this endpoint did not. Note the sentinel fix in G19/G20 does NOT cover this:
1784
+ // sentinels constrain which record shapes the filter can match, not which tenants it searches.
1785
+ // ---------------------------------------------------------------------------------------------
1786
+ ];
1787
+ case 47:
1788
+ // And the same with no skipMatch, which still cannot match: the email branch needs email === "never"
1789
+ // (unstorable) and the name branch needs dateOfBirth === "never" literally.
1790
+ _r.sent();
1791
+ G23_NAME = 'session_for_public_form requires businessId';
1792
+ return [4 /*yield*/, runScenario('G23', G23_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1793
+ var victim, omitted, blocked, wrongBiz, badBiz;
1794
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
1795
+ return __generator(this, function (_m) {
1796
+ switch (_m.label) {
1797
+ case 0: return [4 /*yield*/, makeVictim('P1', { email: emailFor('lock-bizid'), phone: nextPhone() })
1798
+ // (a) THE ISSUE. Omit businessId entirely and name the patient directly via enduserId, which
1799
+ // reaches an untenanted findById — no name/DOB guessing required. Pre-fix this hands back a
1800
+ // working session for that patient; post-fix it must 400 before touching the database.
1801
+ ];
1802
+ case 1:
1803
+ victim = _m.sent();
1804
+ return [4 /*yield*/, post('/v1/session-for-public-form', {
1805
+ formId: intake.form.id,
1806
+ enduserId: victim.id,
1807
+ })];
1808
+ case 2:
1809
+ omitted = _m.sent();
1810
+ if ((_a = omitted.data) === null || _a === void 0 ? void 0 : _a.enduserId)
1811
+ createdEnduserIds.push(omitted.data.enduserId);
1812
+ blocked = omitted.status === 400 && ((_b = omitted.data) === null || _b === void 0 ? void 0 : _b.message) === 'businessId is required';
1813
+ record('G23', "".concat(G23_NAME, " \u2014 omitted businessId is rejected"), blocked, blocked
1814
+ ? 'HTTP 400 businessId is required'
1815
+ : "EXPLOIT: HTTP ".concat(omitted.status, " \u2014 got enduserId=").concat((_c = omitted.data) === null || _c === void 0 ? void 0 : _c.enduserId, " authToken=").concat(!!((_d = omitted.data) === null || _d === void 0 ? void 0 : _d.authToken), " for victim ").concat(victim.id, " with NO businessId, i.e. tenant filtering was disabled entirely"));
1816
+ return [4 /*yield*/, post('/v1/session-for-public-form', {
1817
+ formId: intake.form.id,
1818
+ enduserId: victim.id,
1819
+ businessId: '000000000000000000000001',
1820
+ })];
1821
+ case 3:
1822
+ wrongBiz = _m.sent();
1823
+ record('G23', "".concat(G23_NAME, " \u2014 a wrong businessId cannot reach the patient"), wrongBiz.status !== 200 && ((_e = wrongBiz.data) === null || _e === void 0 ? void 0 : _e.enduserId) !== victim.id, "HTTP ".concat(wrongBiz.status, " ").concat(JSON.stringify((_j = (_g = (_f = wrongBiz.data) === null || _f === void 0 ? void 0 : _f.message) !== null && _g !== void 0 ? _g : (_h = wrongBiz.data) === null || _h === void 0 ? void 0 : _h.enduserId) !== null && _j !== void 0 ? _j : '')));
1824
+ return [4 /*yield*/, post('/v1/session-for-public-form', {
1825
+ formId: intake.form.id,
1826
+ enduserId: victim.id,
1827
+ businessId: 'not-a-mongo-id',
1828
+ })];
1829
+ case 4:
1830
+ badBiz = _m.sent();
1831
+ record('G23', "".concat(G23_NAME, " \u2014 a malformed businessId is rejected"), badBiz.status === 400, "HTTP ".concat(badBiz.status, " ").concat(JSON.stringify((_l = (_k = badBiz.data) === null || _k === void 0 ? void 0 : _k.message) !== null && _l !== void 0 ? _l : '')));
1832
+ return [2 /*return*/];
1833
+ }
1834
+ });
1835
+ }); })
1836
+ // ---------------------------------------------------------------------------------------------
1837
+ // G24 / G25 — The token prepare_form_response mints drops `fromPublicSession`.
1838
+ //
1839
+ // The lock in prepare_form_response keys off session.fromPublicSession, but the token it mints
1840
+ // preserved the session's confinement WITHOUT that flag and handed it back in fullURL (?token=...).
1841
+ // Since prepare_form_response is itself in the 'public-form' session scope, a caller can hop twice:
1842
+ // the first hop's response is locked, the second hop's is not — because by then the session no longer
1843
+ // looks like a public one. Submitting an intakeField: 'email' answer against that second response
1844
+ // then repoints the victim's login channel.
1845
+ //
1846
+ // The same flag also gates PHI on refresh_session (G25): when it is falsy the handler returns
1847
+ // redact_field(enduser, 'hashedPassword') — the whole record — instead of just assignedTo.
1848
+ // ---------------------------------------------------------------------------------------------
1849
+ ];
1850
+ case 48:
1851
+ _r.sent();
1852
+ chainForVictim = function (tag, victim) { return __awaiter(void 0, void 0, void 0, function () {
1853
+ var t1, hop1;
1854
+ return __generator(this, function (_a) {
1855
+ switch (_a.label) {
1856
+ case 0: return [4 /*yield*/, startFormOn(intake.form.id, {
1857
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
1858
+ })];
1859
+ case 1:
1860
+ t1 = _a.sent();
1861
+ if (t1.enduserId)
1862
+ createdEnduserIds.push(t1.enduserId);
1863
+ if (t1.enduserId !== victim.id)
1864
+ return [2 /*return*/, { matched: false, t1: t1, t2: undefined, hop1: undefined }];
1865
+ return [4 /*yield*/, asEnduser(t1.authToken).api.form_responses.prepare_form_response({
1866
+ formId: pivotIntake.form.id, enduserId: victim.id,
1867
+ })];
1868
+ case 2:
1869
+ hop1 = _a.sent();
1870
+ return [2 /*return*/, { matched: true, t1: t1, hop1: hop1, t2: tokenFromURL(hop1 === null || hop1 === void 0 ? void 0 : hop1.fullURL) }];
1871
+ }
1872
+ });
1873
+ }); };
1874
+ G24_NAME = 'chained prepare_form_response session stays locked';
1875
+ return [4 /*yield*/, runScenario('G24', G24_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1876
+ var victimEmail, victim, attackerEmail, _a, matched, t2, hop2, hop2Response, enduser;
1877
+ return __generator(this, function (_b) {
1878
+ switch (_b.label) {
1879
+ case 0:
1880
+ victimEmail = emailFor('lock-q1');
1881
+ return [4 /*yield*/, makeVictim('Q1', { email: victimEmail, phone: nextPhone() })];
1882
+ case 1:
1883
+ victim = _b.sent();
1884
+ attackerEmail = emailFor('atk-q1');
1885
+ return [4 /*yield*/, chainForVictim('Q1', victim)];
1886
+ case 2:
1887
+ _a = _b.sent(), matched = _a.matched, t2 = _a.t2;
1888
+ if (!matched) {
1889
+ record('G24', G24_NAME, false, "probe invalid \u2014 first hop did not match victim ".concat(victim.id));
1890
+ return [2 /*return*/];
1891
+ }
1892
+ record('G24', 'chained token is returned in fullURL (probe validity)', !!t2, "token extracted=".concat(!!t2));
1893
+ if (!t2)
1894
+ return [2 /*return*/];
1895
+ return [4 /*yield*/, asEnduser(t2).api.form_responses.prepare_form_response({
1896
+ formId: pivotIntake.form.id, enduserId: victim.id,
1897
+ })];
1898
+ case 3:
1899
+ hop2 = _b.sent();
1900
+ record('G24', 'second-hop prepare succeeds (probe validity)', !!(hop2 === null || hop2 === void 0 ? void 0 : hop2.accessCode), "accessCode=".concat(!!(hop2 === null || hop2 === void 0 ? void 0 : hop2.accessCode)));
1901
+ if (!(hop2 === null || hop2 === void 0 ? void 0 : hop2.accessCode))
1902
+ return [2 /*return*/];
1903
+ return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: hop2.accessCode } })];
1904
+ case 4:
1905
+ hop2Response = (_b.sent())[0];
1906
+ record('G24', "".concat(G24_NAME, " \u2014 second-hop response carries the lock"), (hop2Response === null || hop2Response === void 0 ? void 0 : hop2Response.authFieldIntakeDisabled) === true, "authFieldIntakeDisabled=".concat(JSON.stringify(hop2Response === null || hop2Response === void 0 ? void 0 : hop2Response.authFieldIntakeDisabled)));
1907
+ // The assertion that actually matters: does the victim's login channel move?
1908
+ return [4 /*yield*/, asEnduser(t2).api.form_responses.submit_form_response({
1909
+ accessCode: hop2.accessCode,
1910
+ responses: intakeAnswers(pivotIntake, attackerEmail, nextPhone()),
1911
+ })];
1912
+ case 5:
1913
+ // The assertion that actually matters: does the victim's login channel move?
1914
+ _b.sent();
1915
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)];
1916
+ case 6:
1917
+ enduser = (_b.sent()).enduser;
1918
+ record('G24', "".concat(G24_NAME, " \u2014 victim email unchanged after chained submit"), (enduser === null || enduser === void 0 ? void 0 : enduser.email) === victimEmail, (enduser === null || enduser === void 0 ? void 0 : enduser.email) === victimEmail
1919
+ ? "email still ".concat(victimEmail)
1920
+ : "EXPLOIT: chained session wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email, " via an intake answer"));
1921
+ return [2 /*return*/];
1922
+ }
1923
+ });
1924
+ }); })];
1925
+ case 49:
1926
+ _r.sent();
1927
+ G25_NAME = 'chained session does not expose the enduser record on refresh';
1928
+ return [4 /*yield*/, runScenario('G25', G25_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1929
+ var victim, _a, matched, t1, t2, PHI_KEYS, leakedIn, base, chained, leaked;
1930
+ return __generator(this, function (_b) {
1931
+ switch (_b.label) {
1932
+ case 0: return [4 /*yield*/, makeVictim('R1', { email: emailFor('lock-r1'), phone: nextPhone() })];
1933
+ case 1:
1934
+ victim = _b.sent();
1935
+ return [4 /*yield*/, chainForVictim('R1', victim)];
1936
+ case 2:
1937
+ _a = _b.sent(), matched = _a.matched, t1 = _a.t1, t2 = _a.t2;
1938
+ if (!matched) {
1939
+ record('G25', G25_NAME, false, "probe invalid \u2014 first hop did not match victim ".concat(victim.id));
1940
+ return [2 /*return*/];
1941
+ }
1942
+ PHI_KEYS = ['email', 'phone', 'dateOfBirth', 'fname', 'lname'];
1943
+ leakedIn = function (body) { return PHI_KEYS.filter(function (k) { var _a; return ((_a = body === null || body === void 0 ? void 0 : body.enduser) !== null && _a !== void 0 ? _a : {})[k] !== undefined; }); };
1944
+ return [4 /*yield*/, postAuthed('/v1/refresh-enduser-session', {}, t1.authToken)];
1945
+ case 3:
1946
+ base = _b.sent();
1947
+ record('G25', 'baseline: original public token withholds the record (control)', base.status === 200 && leakedIn(base.data).length === 0, "HTTP ".concat(base.status, " leaked=[").concat(leakedIn(base.data).join(', '), "]"));
1948
+ if (!t2) {
1949
+ record('G25', G25_NAME, false, 'probe invalid — no chained token');
1950
+ return [2 /*return*/];
1951
+ }
1952
+ return [4 /*yield*/, postAuthed('/v1/refresh-enduser-session', {}, t2)];
1953
+ case 4:
1954
+ chained = _b.sent();
1955
+ leaked = leakedIn(chained.data);
1956
+ record('G25', G25_NAME, chained.status === 200 && leaked.length === 0, leaked.length === 0
1957
+ ? "HTTP ".concat(chained.status, ", no PHI fields returned")
1958
+ : "EXPLOIT: refresh under the chained token returned [".concat(leaked.join(', '), "] for victim ").concat(victim.id));
1959
+ return [2 /*return*/];
1960
+ }
1961
+ });
1962
+ }); })
1963
+ // ---------------------------------------------------------------------------------------------
1964
+ // G26 — Auto-merge re-points the response after the lock was computed.
1965
+ //
1966
+ // authFieldIntakeDisabled is a snapshot taken at session start (`!!existing`). With skipMatch it is
1967
+ // correctly false, because a fresh record really was inserted. But on submission
1968
+ // resolveAutoMergeBeforeSubmission repoints the response at a PRE-EXISTING patient and persists only
1969
+ // enduserId — so the intake write later reads a stale `false` and lands email/phone on the victim.
1970
+ // The merge matches on { fname, lname, dateOfBirth }, so the attacker never needs to know the
1971
+ // victim's email or phone.
1972
+ //
1973
+ // performAutoMerge copies no demographic fields itself, so the intake write is the only channel —
1974
+ // which is why the custom-field assertion below is essential: it proves the write RAN, so "email
1975
+ // unchanged" cannot pass just because nothing happened.
1976
+ // ---------------------------------------------------------------------------------------------
1977
+ ];
1978
+ case 50:
1979
+ _r.sent();
1980
+ G26_NAME = 'auto-merge does not let an intake answer repoint the merged patient';
1981
+ return [4 /*yield*/, runScenario('G26', G26_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1982
+ var victimEmail, victimPhone, victimDob, victim, attackerEmail, session, sourceId, f, after, merged, customLanded;
1983
+ var _a, _b, _c;
1984
+ return __generator(this, function (_d) {
1985
+ switch (_d.label) {
1986
+ case 0:
1987
+ victimEmail = emailFor('lock-s1');
1988
+ victimPhone = nextPhone();
1989
+ victimDob = '07-08-1988';
1990
+ return [4 /*yield*/, sdk.api.endusers.createOne({
1991
+ fname: "Lock".concat(RUN),
1992
+ lname: "Merge".concat(RUN, "S1"),
1993
+ dateOfBirth: victimDob,
1994
+ email: victimEmail,
1995
+ phone: victimPhone,
1996
+ })];
1997
+ case 1:
1998
+ victim = _d.sent();
1999
+ createdEnduserIds.push(victim.id);
2000
+ attackerEmail = emailFor('atk-s1');
2001
+ return [4 /*yield*/, startFormOn(autoMergeIntake.form.id, { skipMatch: true })];
2002
+ case 2:
2003
+ session = _d.sent();
2004
+ if (session.enduserId)
2005
+ createdEnduserIds.push(session.enduserId);
2006
+ sourceId = session.enduserId;
2007
+ f = autoMergeIntake;
2008
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
2009
+ accessCode: session.accessCode,
2010
+ responses: [
2011
+ { fieldId: f.fname.id, fieldTitle: f.fname.title, answer: { type: 'string', value: victim.fname } },
2012
+ { fieldId: f.lname.id, fieldTitle: f.lname.title, answer: { type: 'string', value: victim.lname } },
2013
+ { fieldId: f.dateOfBirth.id, fieldTitle: f.dateOfBirth.title, answer: { type: 'dateString', value: victimDob } },
2014
+ { fieldId: f.email.id, fieldTitle: f.email.title, answer: { type: 'email', value: attackerEmail } },
2015
+ { fieldId: f.custom.id, fieldTitle: f.custom.title, answer: { type: 'string', value: 'merged-ok' } },
2016
+ ],
2017
+ })];
2018
+ case 3:
2019
+ _d.sent();
2020
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)];
2021
+ case 4:
2022
+ after = (_d.sent()).enduser;
2023
+ merged = ((_a = after === null || after === void 0 ? void 0 : after.mergedIds) !== null && _a !== void 0 ? _a : []).includes(sourceId);
2024
+ record('G26', 'auto-merge actually resolved to the victim (probe validity)', merged, "mergedIds=".concat(JSON.stringify(after === null || after === void 0 ? void 0 : after.mergedIds), " source=").concat(sourceId));
2025
+ customLanded = ((_b = after === null || after === void 0 ? void 0 : after.fields) === null || _b === void 0 ? void 0 : _b[MERGE_CUSTOM_KEY]) === 'merged-ok';
2026
+ record('G26', 'non-auth intake fields still reconcile onto the merged patient (anti-vacuity)', customLanded, "fields.".concat(MERGE_CUSTOM_KEY, "=").concat(JSON.stringify((_c = after === null || after === void 0 ? void 0 : after.fields) === null || _c === void 0 ? void 0 : _c[MERGE_CUSTOM_KEY])));
2027
+ record('G26', "".concat(G26_NAME, " \u2014 email"), (after === null || after === void 0 ? void 0 : after.email) === victimEmail, (after === null || after === void 0 ? void 0 : after.email) === victimEmail
2028
+ ? "email still ".concat(victimEmail)
2029
+ : "EXPLOIT: auto-merge intake write set ".concat(after === null || after === void 0 ? void 0 : after.email, " on the merged-into victim"));
2030
+ record('G26', "".concat(G26_NAME, " \u2014 phone"), (after === null || after === void 0 ? void 0 : after.phone) === victimPhone, (after === null || after === void 0 ? void 0 : after.phone) === victimPhone ? "phone still ".concat(victimPhone) : "EXPLOIT: phone became ".concat(after === null || after === void 0 ? void 0 : after.phone));
2031
+ return [2 /*return*/];
2032
+ }
2033
+ });
2034
+ }); })
2035
+ // ---------------------------------------------------------------------------------------------
2036
+ // G27 — The STAFF merge re-points responses too, and must also apply the lock.
2037
+ //
2038
+ // Same shape as G26 but through endusers.merge rather than autoMergeOnSubmission: the response is
2039
+ // created by a skipMatch public session (so the flag is correctly false — a fresh record really was
2040
+ // inserted), then staff resolve that ephemeral duplicate into a real patient. The merge loop copies
2041
+ // enduserId onto every model that has one, so without the fix the response ends up attached to a
2042
+ // patient who pre-existed it while still carrying a stale `false`.
2043
+ //
2044
+ // Note the source enduser is DELETED by the merge, which kills the original public token — so the
2045
+ // stored flag is the load-bearing assertion here, not a follow-up submit.
2046
+ // ---------------------------------------------------------------------------------------------
2047
+ ];
2048
+ case 51:
2049
+ _r.sent();
2050
+ G27_NAME = 'staff merge applies the lock when it repoints a response';
2051
+ return [4 /*yield*/, runScenario('G27', G27_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
2052
+ var victimEmail, victim, session, beforeMerge, afterMerge, repointed, stillVictim;
2053
+ return __generator(this, function (_a) {
2054
+ switch (_a.label) {
2055
+ case 0:
2056
+ victimEmail = emailFor('lock-t1');
2057
+ return [4 /*yield*/, makeVictim('T1', { email: victimEmail, phone: nextPhone() })];
2058
+ case 1:
2059
+ victim = _a.sent();
2060
+ return [4 /*yield*/, startFormOn(intake.form.id, { skipMatch: true })];
2061
+ case 2:
2062
+ session = _a.sent();
2063
+ if (session.enduserId)
2064
+ createdEnduserIds.push(session.enduserId);
2065
+ return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })
2066
+ // Probe validity: if this were already true the test could not distinguish the fix from the
2067
+ // pre-existing state.
2068
+ ];
2069
+ case 3:
2070
+ beforeMerge = (_a.sent())[0];
2071
+ // Probe validity: if this were already true the test could not distinguish the fix from the
2072
+ // pre-existing state.
2073
+ record('G27', 'skipMatch response starts unlocked (probe validity)', (beforeMerge === null || beforeMerge === void 0 ? void 0 : beforeMerge.authFieldIntakeDisabled) === false, "authFieldIntakeDisabled=".concat(JSON.stringify(beforeMerge === null || beforeMerge === void 0 ? void 0 : beforeMerge.authFieldIntakeDisabled)));
2074
+ return [4 /*yield*/, sdk.api.endusers.merge({
2075
+ sourceEnduserId: session.enduserId,
2076
+ destinationEnduserId: victim.id,
2077
+ })];
2078
+ case 4:
2079
+ _a.sent();
2080
+ return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })];
2081
+ case 5:
2082
+ afterMerge = (_a.sent())[0];
2083
+ repointed = (afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.enduserId) === victim.id;
2084
+ record('G27', 'merge repointed the response at the pre-existing patient (probe validity)', repointed, "enduserId=".concat(afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.enduserId, " expected=").concat(victim.id));
2085
+ record('G27', G27_NAME, (afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.authFieldIntakeDisabled) === true, (afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.authFieldIntakeDisabled) === true
2086
+ ? 'authFieldIntakeDisabled=true'
2087
+ : "EXPLOIT: response now points at pre-existing patient ".concat(victim.id, " with ")
2088
+ + "authFieldIntakeDisabled=".concat(JSON.stringify(afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.authFieldIntakeDisabled), " \u2014 ")
2089
+ + "an intake email/phone answer would repoint their login channel");
2090
+ return [4 /*yield*/, sdk.api.endusers.getOne(victim.id).catch(function () { return null; })];
2091
+ case 6:
2092
+ stillVictim = _a.sent();
2093
+ record('G27', 'merge did not itself move the victim email', (stillVictim === null || stillVictim === void 0 ? void 0 : stillVictim.email) === victimEmail, "email=".concat((stillVictim === null || stillVictim === void 0 ? void 0 : stillVictim.email) || '(unset)', " expected=").concat(victimEmail));
2094
+ return [2 /*return*/];
2095
+ }
2096
+ });
2097
+ }); })
2098
+ // ---------------------------------------------------------------------------------------------
2099
+ // G28 — SCOPE BOUNDARY for the Stripe intake grace.
2100
+ //
2101
+ // The formsort / form-ingestion webhook re-opens a BLANK email/phone for one hour after the Stripe
2102
+ // integration creates a contact (enduser_is_provisional_stripe_record, exercised by G12/R12-R15 in
2103
+ // formsort_webhook_auth_field_lock.test.ts). That endpoint is API-key authenticated. THIS path is
2104
+ // anonymous, and its match only needs a name + DOB or an email, so the same exemption here would
2105
+ // hand an attacker an OTP login channel on a patient who just paid. It must not apply.
2106
+ //
2107
+ // This gate exists because the tempting refactor — moving the grace down into
2108
+ // save_enduser_updates_for_form_response, the single chokepoint — would silently extend it to every
2109
+ // caller including this one, with no other test noticing.
2110
+ // ---------------------------------------------------------------------------------------------
2111
+ ];
2112
+ case 52:
2113
+ _r.sent();
2114
+ G28_NAME = 'a fresh Stripe-created patient gets no grace on the public form path';
2115
+ return [4 /*yield*/, runScenario('G28', G28_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
2116
+ var victimEmail, victim, attackerEmail, attackerPhone, session, enduser;
2117
+ return __generator(this, function (_a) {
2118
+ switch (_a.label) {
2119
+ case 0:
2120
+ victimEmail = emailFor('lock-stripe');
2121
+ return [4 /*yield*/, makeVictim('Stripe', {
2122
+ email: victimEmail,
2123
+ source: 'Stripe',
2124
+ stripeCustomerId: "cus_".concat(RUN, "g28"),
2125
+ })];
2126
+ case 1:
2127
+ victim = _a.sent();
2128
+ attackerEmail = emailFor('atk-stripe');
2129
+ attackerPhone = nextPhone();
2130
+ return [4 /*yield*/, startFormOn(intake.form.id, {
2131
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
2132
+ })];
2133
+ case 2:
2134
+ session = _a.sent();
2135
+ if (session.enduserId)
2136
+ createdEnduserIds.push(session.enduserId);
2137
+ if (session.enduserId !== victim.id) {
2138
+ record('G28', G28_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
2139
+ return [2 /*return*/];
2140
+ }
2141
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
2142
+ accessCode: session.accessCode,
2143
+ responses: intakeAnswers(intake, attackerEmail, attackerPhone),
2144
+ })];
2145
+ case 3:
2146
+ _a.sent();
2147
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)
2148
+ // phone is UNSET on the victim, so this is exactly the shape the webhook grace would have filled
2149
+ ];
2150
+ case 4:
2151
+ enduser = (_a.sent()).enduser;
2152
+ // phone is UNSET on the victim, so this is exactly the shape the webhook grace would have filled
2153
+ record('G28', "".concat(G28_NAME, " \u2014 phone"), !(enduser === null || enduser === void 0 ? void 0 : enduser.phone), (enduser === null || enduser === void 0 ? void 0 : enduser.phone)
2154
+ ? "EXPLOIT: intake answer wrote phone ".concat(enduser.phone, " on a Stripe-created patient (was unset)")
2155
+ : 'phone still (unset)');
2156
+ record('G28', "".concat(G28_NAME, " \u2014 email"), (enduser === null || enduser === void 0 ? void 0 : enduser.email) === victimEmail, (enduser === null || enduser === void 0 ? void 0 : enduser.email) === victimEmail
2157
+ ? "email still ".concat(victimEmail)
2158
+ : "EXPLOIT: intake answer wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email, " (was ").concat(victimEmail, ")"));
2159
+ return [2 /*return*/];
2160
+ }
2161
+ });
2162
+ }); })
2163
+ // ---------------------------------------------------------------------------------------------
2164
+ // Report
2165
+ // ---------------------------------------------------------------------------------------------
2166
+ ];
2167
+ case 53:
2168
+ _r.sent();
2169
+ failures = results.filter(function (r) { return !r.ok; });
2170
+ skipped = results.filter(function (r) { return !!r.skipped; });
2171
+ console.log('');
2172
+ console.log(' ┌─ Public endpoint auth-field lock — summary ────────────────────────────');
2173
+ for (_d = 0, results_1 = results; _d < results_1.length; _d++) {
2174
+ r = results_1[_d];
2175
+ status_1 = r.skipped ? 'SKIP' : r.ok ? 'PASS' : 'FAIL';
2176
+ console.log(" \u2502 ".concat(status_1, " ").concat(r.group.padEnd(3), " ").concat(r.name).concat(r.detail ? " \u2014 ".concat(r.detail) : ''));
2177
+ }
2178
+ console.log(" \u2514\u2500 ".concat(results.length - failures.length, "/").concat(results.length, " passed, ").concat(skipped.length, " skipped, ").concat(failures.length, " failed"));
2179
+ console.log('');
2180
+ _e = 0, createdEventIds_1 = createdEventIds;
2181
+ _r.label = 54;
2182
+ case 54:
2183
+ if (!(_e < createdEventIds_1.length)) return [3 /*break*/, 57];
2184
+ id = createdEventIds_1[_e];
2185
+ return [4 /*yield*/, sdk.api.calendar_events.deleteOne(id).catch(function () { return null; })];
2186
+ case 55:
2187
+ _r.sent();
2188
+ _r.label = 56;
2189
+ case 56:
2190
+ _e++;
2191
+ return [3 /*break*/, 54];
2192
+ case 57:
2193
+ _f = 0, createdFormIds_1 = createdFormIds;
2194
+ _r.label = 58;
2195
+ case 58:
2196
+ if (!(_f < createdFormIds_1.length)) return [3 /*break*/, 64];
2197
+ formId = createdFormIds_1[_f];
2198
+ return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { formId: formId } }).catch(function () { return []; })];
2199
+ case 59:
2200
+ responses = _r.sent();
2201
+ _g = 0, responses_1 = responses;
2202
+ _r.label = 60;
2203
+ case 60:
2204
+ if (!(_g < responses_1.length)) return [3 /*break*/, 63];
2205
+ response = responses_1[_g];
2206
+ return [4 /*yield*/, sdk.api.form_responses.deleteOne(response.id).catch(function () { return null; })];
2207
+ case 61:
2208
+ _r.sent();
2209
+ _r.label = 62;
2210
+ case 62:
2211
+ _g++;
2212
+ return [3 /*break*/, 60];
2213
+ case 63:
2214
+ _f++;
2215
+ return [3 /*break*/, 58];
2216
+ case 64:
2217
+ _h = 0, createdFormFieldIds_1 = createdFormFieldIds;
2218
+ _r.label = 65;
2219
+ case 65:
2220
+ if (!(_h < createdFormFieldIds_1.length)) return [3 /*break*/, 68];
2221
+ id = createdFormFieldIds_1[_h];
2222
+ return [4 /*yield*/, sdk.api.form_fields.deleteOne(id).catch(function () { return null; })];
2223
+ case 66:
2224
+ _r.sent();
2225
+ _r.label = 67;
2226
+ case 67:
2227
+ _h++;
2228
+ return [3 /*break*/, 65];
2229
+ case 68:
2230
+ _j = 0, createdFormIds_2 = createdFormIds;
2231
+ _r.label = 69;
2232
+ case 69:
2233
+ if (!(_j < createdFormIds_2.length)) return [3 /*break*/, 72];
2234
+ id = createdFormIds_2[_j];
2235
+ return [4 /*yield*/, sdk.api.forms.deleteOne(id).catch(function () { return null; })];
2236
+ case 70:
2237
+ _r.sent();
2238
+ _r.label = 71;
2239
+ case 71:
2240
+ _j++;
2241
+ return [3 /*break*/, 69];
2242
+ case 72:
2243
+ uniqueEnduserIds = createdEnduserIds.filter(function (id, i) { return !!id && createdEnduserIds.indexOf(id) === i; });
2244
+ _k = 0, uniqueEnduserIds_1 = uniqueEnduserIds;
2245
+ _r.label = 73;
2246
+ case 73:
2247
+ if (!(_k < uniqueEnduserIds_1.length)) return [3 /*break*/, 76];
2248
+ id = uniqueEnduserIds_1[_k];
2249
+ return [4 /*yield*/, sdk.api.endusers.deleteOne(id).catch(function () { return null; })];
2250
+ case 74:
2251
+ _r.sent();
2252
+ _r.label = 75;
2253
+ case 75:
2254
+ _k++;
2255
+ return [3 /*break*/, 73];
2256
+ case 76:
2257
+ _l = 0, createdTemplateIds_1 = createdTemplateIds;
2258
+ _r.label = 77;
2259
+ case 77:
2260
+ if (!(_l < createdTemplateIds_1.length)) return [3 /*break*/, 80];
2261
+ id = createdTemplateIds_1[_l];
2262
+ return [4 /*yield*/, sdk.api.calendar_event_templates.deleteOne(id).catch(function () { return null; })];
2263
+ case 78:
2264
+ _r.sent();
2265
+ _r.label = 79;
2266
+ case 79:
2267
+ _l++;
2268
+ return [3 /*break*/, 77];
2269
+ case 80:
2270
+ _m = 0, createdUserIds_1 = createdUserIds;
2271
+ _r.label = 81;
2272
+ case 81:
2273
+ if (!(_m < createdUserIds_1.length)) return [3 /*break*/, 84];
2274
+ id = createdUserIds_1[_m];
2275
+ return [4 /*yield*/, sdk.api.users.deleteOne(id).catch(function () { return null; })];
2276
+ case 82:
2277
+ _r.sent();
2278
+ _r.label = 83;
2279
+ case 83:
2280
+ _m++;
2281
+ return [3 /*break*/, 81];
2282
+ case 84:
2283
+ // ---------------------------------------------------------------------------------------------
2284
+ // Assert — single pass, last, so the full table above is always printed first.
2285
+ // ---------------------------------------------------------------------------------------------
2286
+ for (_o = 0, results_2 = results; _o < results_2.length; _o++) {
2287
+ r = results_2[_o];
2288
+ assert(r.ok, "\u274C [".concat(r.group, "] ").concat(r.name, " \u2014 ").concat(r.detail), "[".concat(r.group, "] ").concat(r.name).concat(r.skipped ? ' (SKIPPED)' : ''));
2289
+ }
2290
+ return [2 /*return*/];
2291
+ }
2292
+ });
2293
+ });
2294
+ };
2295
+ // Allow running this test file independently
2296
+ if (require.main === module) {
2297
+ console.log("\uD83C\uDF10 Using API URL: ".concat(host));
2298
+ var sdk_1 = new Session({ host: host });
2299
+ var sdkNonAdmin_1 = new Session({ host: host });
2300
+ var runTests = function () { return __awaiter(void 0, void 0, void 0, function () {
2301
+ return __generator(this, function (_a) {
2302
+ switch (_a.label) {
2303
+ case 0: return [4 /*yield*/, setup_tests(sdk_1, sdkNonAdmin_1)];
2304
+ case 1:
2305
+ _a.sent();
2306
+ return [4 /*yield*/, public_endpoint_auth_field_lock_tests({ sdk: sdk_1, sdkNonAdmin: sdkNonAdmin_1 })];
2307
+ case 2:
2308
+ _a.sent();
2309
+ return [2 /*return*/];
2310
+ }
2311
+ });
2312
+ }); };
2313
+ runTests()
2314
+ .then(function () {
2315
+ console.log("✅ Public endpoint auth field lock test suite completed successfully");
2316
+ process.exit(0);
2317
+ })
2318
+ .catch(function (error) {
2319
+ console.error("❌ Public endpoint auth field lock test suite failed:", error);
2320
+ process.exit(1);
2321
+ });
2322
+ }
2323
+ //# sourceMappingURL=public_endpoint_auth_field_lock.test.js.map