@antelopejs/dms 0.15.1 → 0.16.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 (295) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +36 -26
  3. package/bin/control/base.mjs +9 -4
  4. package/bin/control/browser-client.mjs +24 -5
  5. package/bin/control/browser-wire.mjs +41 -0
  6. package/bin/control/browser-wire.test.mjs +96 -0
  7. package/bin/control/browser.mjs +78 -27
  8. package/bin/control/commands-stack.mjs +58 -8
  9. package/bin/control/commands-verify.mjs +47 -1
  10. package/bin/control/doctor.mjs +49 -4
  11. package/bin/control/failure.mjs +9 -0
  12. package/bin/control/journey.mjs +158 -30
  13. package/bin/control/journey.test.mjs +61 -0
  14. package/bin/control/json.mjs +229 -0
  15. package/bin/control/json.test.mjs +130 -0
  16. package/bin/control/mail.mjs +57 -7
  17. package/bin/control/mail.test.mjs +55 -0
  18. package/bin/control/mongo.mjs +205 -27
  19. package/bin/control/mongo.test.mjs +298 -0
  20. package/bin/control/options.mjs +33 -0
  21. package/bin/control/processes.mjs +24 -2
  22. package/bin/control/renderer.mjs +39 -0
  23. package/bin/control/renderer.test.mjs +324 -0
  24. package/bin/control/run-state.mjs +119 -6
  25. package/bin/control/run-state.test.mjs +171 -0
  26. package/bin/control/session.mjs +30 -14
  27. package/bin/control/stack.mjs +95 -24
  28. package/bin/control/tab.mjs +117 -18
  29. package/bin/control/verify/behaviour.mjs +44 -6
  30. package/bin/control/verify/behaviour.test.mjs +130 -22
  31. package/bin/control/verify/decode.mjs +220 -0
  32. package/bin/control/verify/decode.test.mjs +245 -0
  33. package/bin/control/verify/doors.mjs +1 -1
  34. package/bin/control/verify/doors.test.mjs +40 -0
  35. package/bin/control/verify/emit.mjs +135 -9
  36. package/bin/control/verify/formal.mjs +61 -10
  37. package/bin/control/verify/formal.test.mjs +19 -3
  38. package/bin/control/verify/judgement.mjs +22 -3
  39. package/bin/control/verify/map.mjs +25 -5
  40. package/bin/control/verify/publish.mjs +20 -14
  41. package/bin/control/verify/seeded.mjs +65 -16
  42. package/bin/control/verify/skill.mjs +27 -6
  43. package/bin/control/verify/types.mjs +29 -0
  44. package/bin/control/verify/verdict.mjs +72 -7
  45. package/bin/dms-control.mjs +8 -28
  46. package/checks/api-error-presentation.d.mts +61 -0
  47. package/dist/attachments/access.js +1 -1
  48. package/dist/attachments/access.js.map +1 -1
  49. package/dist/attachments/fields.d.ts +2 -2
  50. package/dist/attachments/fields.js +26 -13
  51. package/dist/attachments/fields.js.map +1 -1
  52. package/dist/attachments/index.d.ts +1 -0
  53. package/dist/attachments/index.js +1 -0
  54. package/dist/attachments/index.js.map +1 -1
  55. package/dist/attachments/read.js.map +1 -1
  56. package/dist/attachments/save.d.ts +1 -1
  57. package/dist/attachments/save.js +49 -17
  58. package/dist/attachments/save.js.map +1 -1
  59. package/dist/attachments/table-view.d.ts +1 -2
  60. package/dist/attachments/table-view.js +23 -13
  61. package/dist/attachments/table-view.js.map +1 -1
  62. package/dist/automation/events.d.ts +10 -10
  63. package/dist/automation/index.js +3 -4
  64. package/dist/automation/index.js.map +1 -1
  65. package/dist/automation/send-notification.d.ts +2 -2
  66. package/dist/automation/send-notification.js +4 -6
  67. package/dist/automation/send-notification.js.map +1 -1
  68. package/dist/crons/cleanup-user-invites.js.map +1 -1
  69. package/dist/crons/sweep-expired-sessions.js.map +1 -1
  70. package/dist/crons/sweep-stale-exports.js.map +1 -1
  71. package/dist/db/guard-row.d.ts +2 -3
  72. package/dist/db/guard-row.js +1 -2
  73. package/dist/db/guard-row.js.map +1 -1
  74. package/dist/db/models/attachments.model.d.ts +7 -5
  75. package/dist/db/models/setupToken.model.d.ts +7 -5
  76. package/dist/db/models/signInAttempts.model.d.ts +7 -5
  77. package/dist/db/models/systemState.model.d.ts +7 -5
  78. package/dist/db/models/userEmailChanges.model.d.ts +7 -5
  79. package/dist/db/models/userKnownDevices.model.d.ts +7 -5
  80. package/dist/db/models/userNotificationPreferences.model.d.ts +7 -5
  81. package/dist/db/models/userNotifications.model.d.ts +9 -6
  82. package/dist/db/models/userNotifications.model.js.map +1 -1
  83. package/dist/dev/module-update-notifications.js.map +1 -1
  84. package/dist/hooks/internal/sign-in-monitor.js +6 -6
  85. package/dist/hooks/internal/sign-in-monitor.js.map +1 -1
  86. package/dist/implementations/dms/page/controller.d.ts +2 -2
  87. package/dist/implementations/dms/page/controller.js +3 -1
  88. package/dist/implementations/dms/page/controller.js.map +1 -1
  89. package/dist/implementations/dms/page/dynamic-menu.js +3 -2
  90. package/dist/implementations/dms/page/dynamic-menu.js.map +1 -1
  91. package/dist/implementations/dms/page/notifications.d.ts +6 -2
  92. package/dist/implementations/dms/page/notifications.js +1 -0
  93. package/dist/implementations/dms/page/notifications.js.map +1 -1
  94. package/dist/implementations/dms/page/preview-pages.js +6 -3
  95. package/dist/implementations/dms/page/preview-pages.js.map +1 -1
  96. package/dist/implementations/dms/page/registry.js +10 -4
  97. package/dist/implementations/dms/page/registry.js.map +1 -1
  98. package/dist/implementations/dms/permissions.js +12 -12
  99. package/dist/implementations/dms/permissions.js.map +1 -1
  100. package/dist/implementations/dms-base/bulk-selection.d.ts +3 -4
  101. package/dist/implementations/dms-base/bulk-selection.js +18 -6
  102. package/dist/implementations/dms-base/bulk-selection.js.map +1 -1
  103. package/dist/implementations/dms-base/filter-tokens.js.map +1 -1
  104. package/dist/implementations/dms-base/footer-summary.js +44 -27
  105. package/dist/implementations/dms-base/footer-summary.js.map +1 -1
  106. package/dist/implementations/dms-base/row-rule-operand.d.ts +6 -0
  107. package/dist/implementations/dms-base/row-rule-operand.js +41 -0
  108. package/dist/implementations/dms-base/row-rule-operand.js.map +1 -0
  109. package/dist/implementations/dms-base/search-route.d.ts +9 -13
  110. package/dist/implementations/dms-base/search-route.js +7 -6
  111. package/dist/implementations/dms-base/search-route.js.map +1 -1
  112. package/dist/implementations/dms-base/services.d.ts +1 -0
  113. package/dist/implementations/dms-base/services.js +1 -0
  114. package/dist/implementations/dms-base/services.js.map +1 -1
  115. package/dist/implementations/dms-base/table-view.d.ts +5 -6
  116. package/dist/implementations/dms-base/table-view.js +17 -6
  117. package/dist/implementations/dms-base/table-view.js.map +1 -1
  118. package/dist/implementations/dms-html-render/index.js +16 -6
  119. package/dist/implementations/dms-html-render/index.js.map +1 -1
  120. package/dist/implementations/dms-notifications/delivery-guard.d.ts +3 -2
  121. package/dist/implementations/dms-notifications/delivery-guard.js.map +1 -1
  122. package/dist/implementations/dms-notifications/index.js.map +1 -1
  123. package/dist/index.js.map +1 -1
  124. package/dist/pages/public/preview/block-types.d.ts +1 -2
  125. package/dist/pages/public/preview/block-types.js.map +1 -1
  126. package/dist/pages/settings/overview/account-summary.js +4 -1
  127. package/dist/pages/settings/overview/account-summary.js.map +1 -1
  128. package/dist/pages/settings/users/account-activity-feed.js +3 -2
  129. package/dist/pages/settings/users/account-activity-feed.js.map +1 -1
  130. package/dist/pages/settings/users/account-credentials.d.ts +1 -1
  131. package/dist/pages/settings/users/account-credentials.js +1 -2
  132. package/dist/pages/settings/users/account-credentials.js.map +1 -1
  133. package/dist/pages/settings/users/account-data-store.d.ts +1 -1
  134. package/dist/pages/settings/users/account-data-store.js +1 -3
  135. package/dist/pages/settings/users/account-data-store.js.map +1 -1
  136. package/dist/pages/settings/users/email-change.d.ts +2 -2
  137. package/dist/pages/settings/users/email-change.js +2 -3
  138. package/dist/pages/settings/users/email-change.js.map +1 -1
  139. package/dist/pages/settings/users/invite-extension-routes.d.ts +2 -2
  140. package/dist/pages/settings/users/invite-extension-routes.js +11 -4
  141. package/dist/pages/settings/users/invite-extension-routes.js.map +1 -1
  142. package/dist/pages/settings/users/invites.d.ts +2 -2
  143. package/dist/pages/settings/users/member-invite-batch.d.ts +2 -2
  144. package/dist/pages/settings/users/member-invite-batch.js +7 -3
  145. package/dist/pages/settings/users/member-invite-batch.js.map +1 -1
  146. package/dist/pages/settings/users/member-roles-field.js +1 -4
  147. package/dist/pages/settings/users/member-roles-field.js.map +1 -1
  148. package/dist/pages/settings/users/members.d.ts +2 -3
  149. package/dist/pages/settings/users/members.js.map +1 -1
  150. package/dist/pages/settings/users/notifications.d.ts +2 -3
  151. package/dist/pages/settings/users/notifications.js +2 -1
  152. package/dist/pages/settings/users/notifications.js.map +1 -1
  153. package/dist/pages/settings/users/profile-helpers.d.ts +0 -1
  154. package/dist/pages/settings/users/profile-helpers.js +0 -17
  155. package/dist/pages/settings/users/profile-helpers.js.map +1 -1
  156. package/dist/pages/settings/users/profile.d.ts +3 -4
  157. package/dist/pages/settings/users/profile.js +12 -12
  158. package/dist/pages/settings/users/profile.js.map +1 -1
  159. package/dist/pages/settings/users/region.d.ts +3 -4
  160. package/dist/pages/settings/users/region.js.map +1 -1
  161. package/dist/pages/settings/users/regional-preferences.d.ts +8 -1
  162. package/dist/pages/settings/users/regional-preferences.js +10 -6
  163. package/dist/pages/settings/users/regional-preferences.js.map +1 -1
  164. package/dist/pages/settings/users/role-preview.js +3 -2
  165. package/dist/pages/settings/users/role-preview.js.map +1 -1
  166. package/dist/pages/settings/users/roles.d.ts +4 -5
  167. package/dist/pages/settings/users/roles.js.map +1 -1
  168. package/dist/pages/settings/users/security.d.ts +9 -12
  169. package/dist/pages/settings/users/security.js +11 -9
  170. package/dist/pages/settings/users/security.js.map +1 -1
  171. package/dist/pages/settings/users/two-factor-operations.d.ts +7 -7
  172. package/dist/pages/settings/users/two-factor-operations.js +5 -7
  173. package/dist/pages/settings/users/two-factor-operations.js.map +1 -1
  174. package/dist/realtime/broker.js.map +1 -1
  175. package/dist/realtime/heartbeat.js.map +1 -1
  176. package/dist/realtime/redis-broker.js.map +1 -1
  177. package/dist/realtime/sessions.js.map +1 -1
  178. package/dist/realtime/sse.js +1 -2
  179. package/dist/realtime/sse.js.map +1 -1
  180. package/dist/routes/auth/forgot-password.d.ts +1 -1
  181. package/dist/routes/auth/forgot-password.js +1 -3
  182. package/dist/routes/auth/forgot-password.js.map +1 -1
  183. package/dist/routes/auth/login.d.ts +1 -1
  184. package/dist/routes/auth/login.js +1 -2
  185. package/dist/routes/auth/login.js.map +1 -1
  186. package/dist/routes/auth/logout.d.ts +1 -1
  187. package/dist/routes/auth/logout.js +1 -2
  188. package/dist/routes/auth/logout.js.map +1 -1
  189. package/dist/routes/auth/oauth/providers.js +30 -6
  190. package/dist/routes/auth/oauth/providers.js.map +1 -1
  191. package/dist/routes/auth/oauth/state.js +8 -5
  192. package/dist/routes/auth/oauth/state.js.map +1 -1
  193. package/dist/routes/auth/oauth/token-exchange.js +8 -1
  194. package/dist/routes/auth/oauth/token-exchange.js.map +1 -1
  195. package/dist/routes/auth/refresh.d.ts +1 -1
  196. package/dist/routes/auth/refresh.js +1 -2
  197. package/dist/routes/auth/refresh.js.map +1 -1
  198. package/dist/routes/auth/request-2fa-email.d.ts +1 -1
  199. package/dist/routes/auth/request-2fa-email.js +1 -2
  200. package/dist/routes/auth/request-2fa-email.js.map +1 -1
  201. package/dist/routes/auth/reset-password.d.ts +1 -1
  202. package/dist/routes/auth/reset-password.js +1 -3
  203. package/dist/routes/auth/reset-password.js.map +1 -1
  204. package/dist/routes/auth/session-handoff.d.ts +1 -1
  205. package/dist/routes/auth/session-handoff.js +1 -2
  206. package/dist/routes/auth/session-handoff.js.map +1 -1
  207. package/dist/routes/auth/signup.d.ts +1 -1
  208. package/dist/routes/auth/signup.js +1 -2
  209. package/dist/routes/auth/signup.js.map +1 -1
  210. package/dist/routes/auth/switch-tenant.d.ts +2 -3
  211. package/dist/routes/auth/switch-tenant.js +3 -2
  212. package/dist/routes/auth/switch-tenant.js.map +1 -1
  213. package/dist/routes/auth/validate-forgot-password-token.d.ts +1 -1
  214. package/dist/routes/auth/validate-forgot-password-token.js +1 -3
  215. package/dist/routes/auth/validate-forgot-password-token.js.map +1 -1
  216. package/dist/routes/auth/validate-invite-token.d.ts +1 -1
  217. package/dist/routes/auth/validate-invite-token.js +1 -3
  218. package/dist/routes/auth/validate-invite-token.js.map +1 -1
  219. package/dist/routes/auth/verify-2fa.d.ts +1 -1
  220. package/dist/routes/auth/verify-2fa.js +1 -2
  221. package/dist/routes/auth/verify-2fa.js.map +1 -1
  222. package/dist/routes/auth/verify-email.d.ts +1 -1
  223. package/dist/routes/auth/verify-email.js +1 -2
  224. package/dist/routes/auth/verify-email.js.map +1 -1
  225. package/dist/routes/auth.d.ts +14 -15
  226. package/dist/routes/auth.js +15 -13
  227. package/dist/routes/auth.js.map +1 -1
  228. package/dist/routes/files.d.ts +1 -2
  229. package/dist/routes/files.js.map +1 -1
  230. package/dist/routes/onboarding.d.ts +1 -2
  231. package/dist/routes/onboarding.js.map +1 -1
  232. package/dist/routes/realtime.js +5 -6
  233. package/dist/routes/realtime.js.map +1 -1
  234. package/dist/routes/store.d.ts +6 -7
  235. package/dist/routes/store.js +19 -13
  236. package/dist/routes/store.js.map +1 -1
  237. package/dist/store/catalog.d.ts +13 -5
  238. package/dist/store/catalog.js +19 -9
  239. package/dist/store/catalog.js.map +1 -1
  240. package/dist/store/compatibility.d.ts +1 -0
  241. package/dist/store/compatibility.js +24 -8
  242. package/dist/store/compatibility.js.map +1 -1
  243. package/dist/store/frontend-compatibility.js +15 -3
  244. package/dist/store/frontend-compatibility.js.map +1 -1
  245. package/dist/store/interfaces.js +35 -13
  246. package/dist/store/interfaces.js.map +1 -1
  247. package/dist/store/internal/frontend/frontend-modules.js +7 -3
  248. package/dist/store/internal/frontend/frontend-modules.js.map +1 -1
  249. package/dist/store/internal/frontend/frontend-ready.d.ts +2 -1
  250. package/dist/store/internal/frontend/frontend-ready.js +6 -4
  251. package/dist/store/internal/frontend/frontend-ready.js.map +1 -1
  252. package/dist/store/internal/frontend/manifest.js +6 -7
  253. package/dist/store/internal/frontend/manifest.js.map +1 -1
  254. package/dist/store/operations.js.map +1 -1
  255. package/dist/store/registry.js.map +1 -1
  256. package/dist/store/runtime.d.ts +13 -0
  257. package/dist/store/runtime.js +9 -1
  258. package/dist/store/runtime.js.map +1 -1
  259. package/dist/utils/admin-invite-email.js +3 -1
  260. package/dist/utils/admin-invite-email.js.map +1 -1
  261. package/dist/utils/email-subjects.js +3 -1
  262. package/dist/utils/email-subjects.js.map +1 -1
  263. package/dist/utils/export-jobs-run.js.map +1 -1
  264. package/dist/utils/export-jobs.js +3 -9
  265. package/dist/utils/export-jobs.js.map +1 -1
  266. package/dist/utils/index.d.ts +1 -0
  267. package/dist/utils/index.js +1 -0
  268. package/dist/utils/index.js.map +1 -1
  269. package/dist/utils/own-value.d.ts +1 -0
  270. package/dist/utils/own-value.js +7 -0
  271. package/dist/utils/own-value.js.map +1 -0
  272. package/dist/utils/row-action-rule-evaluator.js +31 -35
  273. package/dist/utils/row-action-rule-evaluator.js.map +1 -1
  274. package/dist/utils/sign-in-country.js +4 -2
  275. package/dist/utils/sign-in-country.js.map +1 -1
  276. package/dist/utils/tenant-export-archive.js +9 -14
  277. package/dist/utils/tenant-export-archive.js.map +1 -1
  278. package/dist/utils/user-agent.js +2 -1
  279. package/dist/utils/user-agent.js.map +1 -1
  280. package/dist/validation/auth.schema.d.ts +10 -10
  281. package/dist/validation/regional-preferences.schema.d.ts +2 -2
  282. package/dist/validation/role-editor.schema.d.ts +1 -1
  283. package/frontend-vue/build/backend-boundary.ts +49 -18
  284. package/frontend-vue/dms.frontend.ts +8 -12
  285. package/frontend-vue/layers/dms-auth/server/accounts.ts +44 -3
  286. package/frontend-vue/layers/dms-auth/server/actions.ts +17 -4
  287. package/frontend-vue/layers/dms-auth/server/oauth.ts +42 -4
  288. package/frontend-vue/layers/dms-core/app/types/watch.ts +1 -2
  289. package/frontend-vue/layers/dms-ui/app/build/composables/form/fieldValueRule.ts +57 -6
  290. package/frontend-vue/layers/dms-ui/app/build/composables/form/registerFormFunctions.ts +7 -9
  291. package/frontend-vue/layers/dms-ui/app/entries/composables/form/registerFormFunctions.ts +1 -0
  292. package/frontend-vue/lazy-plugin.ts +18 -0
  293. package/frontend-vue/package.json +2 -2
  294. package/package.json +19 -13
  295. package/skills/dms-project/SKILL.md +4 -3
@@ -0,0 +1,130 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import {
7
+ arrayOf,
8
+ decode,
9
+ firstString,
10
+ instanceOf,
11
+ isNumber,
12
+ isRecord,
13
+ isString,
14
+ nullable,
15
+ objectOf,
16
+ oneOf,
17
+ readRecordList,
18
+ recordOf,
19
+ stringField,
20
+ } from "./json.mjs";
21
+
22
+ await test("an object is a record, an array, null and a string are not", () => {
23
+ assert.equal(isRecord({}), true);
24
+ assert.equal(isRecord([]), false);
25
+ assert.equal(isRecord(null), false);
26
+ assert.equal(isRecord("text"), false);
27
+ });
28
+
29
+ await test("a string field answers only an own string property", () => {
30
+ assert.equal(stringField({ access_token: "abc" }, "access_token"), "abc");
31
+ assert.equal(stringField({ access_token: 4 }, "access_token"), undefined);
32
+ assert.equal(stringField({}, "access_token"), undefined);
33
+ assert.equal(stringField(["abc"], "0"), undefined);
34
+ assert.equal(stringField(null, "access_token"), undefined);
35
+ });
36
+
37
+ await test("a string field never answers an inherited property", () => {
38
+ assert.equal(stringField({}, "constructor"), undefined);
39
+ assert.equal(stringField({}, "toString"), undefined);
40
+ });
41
+
42
+ await test("the first string of a list is its first element only", () => {
43
+ assert.equal(firstString(["user-1", "user-2"]), "user-1");
44
+ assert.equal(firstString([4, "user-2"]), undefined);
45
+ assert.equal(firstString([]), undefined);
46
+ assert.equal(firstString({ 0: "user-1" }), undefined);
47
+ });
48
+
49
+ /**
50
+ * @param {import("node:test").TestContext} t The test
51
+ * @param {string} text What the file holds
52
+ * @returns {string} The file
53
+ */
54
+ function jsonFile(t, text) {
55
+ const dir = mkdtempSync(join(tmpdir(), "dms-control-json-test-"));
56
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
57
+ const file = join(dir, "list.json");
58
+ writeFileSync(file, text);
59
+ return file;
60
+ }
61
+
62
+ await test("a list of objects is read as written", (t) => {
63
+ const file = jsonFile(t, '[{"journey":"smoke","extra":1},{}]');
64
+ assert.deepEqual(readRecordList(file), [{ journey: "smoke", extra: 1 }, {}]);
65
+ });
66
+
67
+ await test("a file that is not a list of objects is refused", (t) => {
68
+ for (const text of ["{}", "null", "[1]", "[null]", "[[]]"]) {
69
+ assert.throws(
70
+ () => readRecordList(jsonFile(t, text)),
71
+ /is not a list of objects/,
72
+ text,
73
+ );
74
+ }
75
+ });
76
+
77
+ await test("a file that is not JSON is refused", (t) => {
78
+ assert.throws(() => readRecordList(jsonFile(t, "[")), SyntaxError);
79
+ });
80
+
81
+ await test("an object guard checks required and optional properties and keeps the rest", () => {
82
+ const guard = objectOf({ name: isString }, { count: isNumber });
83
+ assert.equal(guard({ name: "a" }), true);
84
+ assert.equal(guard({ name: "a", count: 2, extra: true }), true);
85
+ assert.equal(guard({ name: "a", count: "2" }), false);
86
+ assert.equal(guard({ count: 2 }), false);
87
+ assert.equal(guard([]), false);
88
+ assert.equal(guard(null), false);
89
+ });
90
+
91
+ await test("an object guard never counts an inherited property", () => {
92
+ assert.equal(objectOf({ constructor: isString })({}), false);
93
+ assert.equal(objectOf({}, { toString: isString })({}), true);
94
+ });
95
+
96
+ await test("list, record, nullable and name guards check what they hold", () => {
97
+ assert.equal(arrayOf(isString)(["a", 1]), false);
98
+ assert.equal(arrayOf(isString)([]), true);
99
+ assert.equal(recordOf(isNumber)({ a: 1, b: "x" }), false);
100
+ assert.equal(nullable(isString)(null), true);
101
+ assert.equal(nullable(isString)(3), false);
102
+ assert.equal(oneOf(["PASS", "FAIL"])("PASSED"), false);
103
+ assert.equal(instanceOf(RegExp)(/x/), true);
104
+ assert.equal(instanceOf(RegExp)("x"), false);
105
+ });
106
+
107
+ await test("decode returns a value that passes and names the source of one that does not", () => {
108
+ const value = { name: "a" };
109
+ assert.equal(
110
+ decode(objectOf({ name: isString }), value, "x.json", "a thing"),
111
+ value,
112
+ );
113
+ assert.throws(
114
+ () => decode(objectOf({ name: isString }), {}, "x.json", "a thing"),
115
+ /x\.json is malformed: expected a thing/,
116
+ );
117
+ });
118
+
119
+ await test("a record guard refuses what is no object, a list included", () => {
120
+ assert.equal(recordOf(isString)("abc"), false);
121
+ assert.equal(recordOf(isNumber)([1, 2]), false);
122
+ assert.equal(recordOf(isNumber)(null), false);
123
+ assert.equal(recordOf(isNumber)({}), true);
124
+ });
125
+
126
+ await test("a nullable guard accepts null and refuses a missing value", () => {
127
+ assert.equal(nullable(isString)(undefined), false);
128
+ assert.equal(nullable(isString)(null), true);
129
+ assert.equal(objectOf({ owner: nullable(isString) })({}), false);
130
+ });
@@ -7,24 +7,52 @@ import {
7
7
  } from "node:fs";
8
8
  import { createServer } from "node:net";
9
9
  import { join } from "node:path";
10
+ import { arrayOf, decode, isString, objectOf } from "./json.mjs";
11
+
12
+ /**
13
+ * @typedef {{ from: string, to: string[] }} Envelope
14
+ * @typedef {{ buffer: string, inData: boolean, envelope: Envelope }} SmtpSession
15
+ */
16
+
17
+ const isSummary = objectOf({
18
+ from: isString,
19
+ to: arrayOf(isString),
20
+ subject: isString,
21
+ links: arrayOf(isString),
22
+ text: isString,
23
+ receivedAt: isString,
24
+ });
10
25
 
11
26
  const END_OF_DATA = "\r\n.\r\n";
12
27
  const QUOTED_SOFT_BREAK = /=\r?\n/g;
13
28
  const QUOTED_BYTE = /=([0-9A-F]{2})/g;
14
29
  const HEX = 16;
15
30
 
31
+ /**
32
+ * @param {import("node:net").Socket} socket
33
+ * @param {string} line
34
+ */
16
35
  function reply(socket, line) {
17
36
  socket.write(`${line}\r\n`);
18
37
  }
19
38
 
39
+ /**
40
+ * @param {string} text
41
+ * @returns {string}
42
+ */
20
43
  function decodeQuotedPrintable(text) {
21
44
  return text
22
45
  .replace(QUOTED_SOFT_BREAK, "")
23
- .replace(QUOTED_BYTE, (_match, byte) =>
46
+ .replace(QUOTED_BYTE, (_match, /** @type {string} */ byte) =>
24
47
  String.fromCharCode(Number.parseInt(byte, HEX)),
25
48
  );
26
49
  }
27
50
 
51
+ /**
52
+ * @param {string} message
53
+ * @param {string} name
54
+ * @returns {string}
55
+ */
28
56
  function headerOf(message, name) {
29
57
  const match = new RegExp(`^${name}:\\s*(.+(?:\\r?\\n[ \\t].+)*)`, "im").exec(
30
58
  message,
@@ -32,6 +60,7 @@ function headerOf(message, name) {
32
60
  return match ? match[1].replace(/\r?\n[ \t]+/g, " ").trim() : "";
33
61
  }
34
62
 
63
+ /** @type {Record<string, string>} */
35
64
  const ENTITIES = {
36
65
  amp: "&",
37
66
  lt: "<",
@@ -41,6 +70,10 @@ const ENTITIES = {
41
70
  nbsp: " ",
42
71
  };
43
72
 
73
+ /**
74
+ * @param {string} html
75
+ * @returns {string}
76
+ */
44
77
  function readableText(html) {
45
78
  const body = html.slice(html.search(/\r?\n\r?\n/));
46
79
  return body
@@ -51,6 +84,11 @@ function readableText(html) {
51
84
  .trim();
52
85
  }
53
86
 
87
+ /**
88
+ * @param {string} folder
89
+ * @param {Envelope} envelope
90
+ * @param {string} raw
91
+ */
54
92
  function saveMessage(folder, envelope, raw) {
55
93
  mkdirSync(folder, { recursive: true });
56
94
  const index =
@@ -76,6 +114,11 @@ function saveMessage(folder, envelope, raw) {
76
114
  );
77
115
  }
78
116
 
117
+ /**
118
+ * @param {SmtpSession} state
119
+ * @param {import("node:net").Socket} socket
120
+ * @param {string} line
121
+ */
79
122
  function command(state, socket, line) {
80
123
  const verb = line.slice(0, 4).toUpperCase();
81
124
  if (verb === "EHLO" || verb === "HELO")
@@ -99,11 +142,13 @@ function command(state, socket, line) {
99
142
  return reply(socket, "250 OK");
100
143
  }
101
144
 
145
+ /** @param {string} folder */
102
146
  function session(folder) {
103
- return (socket) => {
147
+ return (/** @type {import("node:net").Socket} */ socket) => {
148
+ /** @type {SmtpSession} */
104
149
  const state = { buffer: "", inData: false, envelope: { from: "", to: [] } };
105
150
  reply(socket, "220 dms-control mail sink");
106
- socket.on("data", (chunk) => {
151
+ socket.on("data", (/** @type {Buffer} */ chunk) => {
107
152
  state.buffer += chunk.toString("latin1");
108
153
  while (state.buffer.length > 0) {
109
154
  if (state.inData) {
@@ -156,8 +201,13 @@ export function capturedMail(folder) {
156
201
  return readdirSync(folder)
157
202
  .filter((name) => name.endsWith(".json"))
158
203
  .sort()
159
- .map((name) => ({
160
- file: join(folder, name.replace(/\.json$/, ".eml")),
161
- ...JSON.parse(readFileSync(join(folder, name), "utf8")),
162
- }));
204
+ .map((name) => {
205
+ const summary = join(folder, name);
206
+ /** @type {unknown} */
207
+ const stored = JSON.parse(readFileSync(summary, "utf8"));
208
+ return {
209
+ file: join(folder, name.replace(/\.json$/, ".eml")),
210
+ ...decode(isSummary, stored, summary, "a captured message"),
211
+ };
212
+ });
163
213
  }
@@ -0,0 +1,55 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { capturedMail } from "./mail.mjs";
7
+
8
+ const SUMMARY = {
9
+ from: "no-reply@example.com",
10
+ to: ["a@example.com"],
11
+ subject: "Welcome",
12
+ links: ["https://example.com/verify"],
13
+ text: "Hello",
14
+ receivedAt: "2026-11-02T00:00:00.000Z",
15
+ };
16
+
17
+ /**
18
+ * @param {import("node:test").TestContext} t The test
19
+ * @param {unknown} summary What the sink stored for its one message
20
+ * @returns {string} The sink's folder
21
+ */
22
+ function sink(t, summary) {
23
+ const folder = mkdtempSync(join(tmpdir(), "dms-control-mail-test-"));
24
+ t.after(() => rmSync(folder, { recursive: true, force: true }));
25
+ writeFileSync(join(folder, "001.json"), JSON.stringify(summary));
26
+ return folder;
27
+ }
28
+
29
+ await test("a captured message reads back with the path of its raw file", (t) => {
30
+ const folder = sink(t, SUMMARY);
31
+ assert.deepEqual(capturedMail(folder), [
32
+ { file: join(folder, "001.eml"), ...SUMMARY },
33
+ ]);
34
+ });
35
+
36
+ await test("a stored summary whose recipients are no list is refused", (t) => {
37
+ const folder = sink(t, { ...SUMMARY, to: "a@example.com" });
38
+ assert.throws(
39
+ () => capturedMail(folder),
40
+ /001\.json is malformed: expected a captured message/,
41
+ );
42
+ });
43
+
44
+ await test("a folder that does not exist holds no mail", () => {
45
+ assert.deepEqual(
46
+ capturedMail(join(tmpdir(), "dms-control-no-such-sink")),
47
+ [],
48
+ );
49
+ });
50
+
51
+ await test("a stored summary without its links is refused", (t) => {
52
+ const { links: _links, ...withoutLinks } = SUMMARY;
53
+ const folder = sink(t, withoutLinks);
54
+ assert.throws(() => capturedMail(folder), /001\.json is malformed/);
55
+ });
@@ -3,14 +3,117 @@ import { mkdirSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import { join } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
+ import { arrayOf, decode, isRecord, isString, objectOf } from "./json.mjs";
6
7
  import { freePort, startDetached, waitForPort } from "./processes.mjs";
7
8
 
9
+ /**
10
+ * @typedef {(...args: unknown[]) => unknown} Callable A function whose result is read as `unknown`
11
+ * @typedef {new (...args: unknown[]) => unknown} Constructor A class whose instances are read as `unknown`
12
+ * @typedef {{ connect: Callable, close: Callable, db: Callable }} Client The calls made on a driver client
13
+ * @typedef {{ admin: Callable, dropDatabase: Callable }} Database The calls made on a driver database
14
+ * @typedef {{ listDatabases: Callable }} Admin The call made on a driver's admin handle
15
+ */
16
+
8
17
  /** The server version the backend test harness pins, already cached by it. */
9
18
  const MONGO_VERSION = "8.0.8";
10
19
  const READY_TIMEOUT_MS = 30_000;
11
20
 
21
+ /**
22
+ * @param {unknown} value
23
+ * @returns {value is object} True for an object or a function, which can hold members
24
+ */
25
+ function isObjectLike(value) {
26
+ return (
27
+ (typeof value === "object" && value !== null) || typeof value === "function"
28
+ );
29
+ }
30
+
31
+ /**
32
+ * @param {unknown} value
33
+ * @returns {value is Callable} True for a function
34
+ */
35
+ function isCallable(value) {
36
+ return typeof value === "function";
37
+ }
38
+
39
+ /**
40
+ * @param {unknown} value
41
+ * @returns {value is Constructor} True for a function that `new` accepts, such as a class or a bound class, and false for an arrow function or a method
42
+ */
43
+ function isConstructor(value) {
44
+ if (!isCallable(value)) return false;
45
+ try {
46
+ Reflect.construct(Object, [], value);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * @param {unknown} value
55
+ * @returns {value is { getPath: Callable }} True for an object with a `getPath` function
56
+ */
57
+ function isBinaryResolver(value) {
58
+ return isObjectLike(value) && "getPath" in value && isCallable(value.getPath);
59
+ }
60
+
61
+ /**
62
+ * @param {unknown} value
63
+ * @returns {value is Client} True for an object with `connect`, `close` and `db` functions
64
+ */
65
+ function isClient(value) {
66
+ return (
67
+ isObjectLike(value) &&
68
+ "connect" in value &&
69
+ isCallable(value.connect) &&
70
+ "close" in value &&
71
+ isCallable(value.close) &&
72
+ "db" in value &&
73
+ isCallable(value.db)
74
+ );
75
+ }
76
+
77
+ /**
78
+ * @param {unknown} value
79
+ * @returns {value is Database} True for an object with `admin` and `dropDatabase` functions
80
+ */
81
+ function isDatabase(value) {
82
+ return (
83
+ isObjectLike(value) &&
84
+ "admin" in value &&
85
+ isCallable(value.admin) &&
86
+ "dropDatabase" in value &&
87
+ isCallable(value.dropDatabase)
88
+ );
89
+ }
90
+
91
+ /**
92
+ * @param {unknown} value
93
+ * @returns {value is Admin} True for an object with a `listDatabases` function
94
+ */
95
+ function isAdmin(value) {
96
+ return (
97
+ isObjectLike(value) &&
98
+ "listDatabases" in value &&
99
+ isCallable(value.listDatabases)
100
+ );
101
+ }
102
+
103
+ const isDatabaseList = objectOf({
104
+ databases: arrayOf(objectOf({ name: isString })),
105
+ });
106
+
107
+ /**
108
+ * @param {unknown} namespace A module namespace
109
+ * @returns {Record<string, unknown>} Its exports, with those of a CommonJS `default` added
110
+ */
12
111
  function withDefaultExports(namespace) {
13
- return { ...namespace.default, ...namespace };
112
+ if (!isRecord(namespace))
113
+ throw new TypeError("A module namespace is not an object");
114
+ const fallback = namespace.default;
115
+ const exports = isObjectLike(fallback) ? fallback : {};
116
+ return { ...exports, ...namespace };
14
117
  }
15
118
 
16
119
  /**
@@ -19,16 +122,18 @@ function withDefaultExports(namespace) {
19
122
  *
20
123
  * @param {string} name The package name
21
124
  * @param {string[]} from Folders to resolve from, in order
22
- * @returns {Promise<any>} The module namespace
125
+ * @returns {Promise<Record<string, unknown>>} The module namespace
23
126
  * @throws When no folder resolves it, saying which devDependency to add
24
127
  */
25
- export async function importFrom(name, from) {
128
+ async function importNamespace(name, from) {
26
129
  for (const folder of from) {
27
130
  try {
28
131
  const resolved = createRequire(join(folder, "package.json")).resolve(
29
132
  name,
30
133
  );
31
- return withDefaultExports(await import(pathToFileURL(resolved).href));
134
+ /** @type {unknown} */
135
+ const namespace = await import(pathToFileURL(resolved).href);
136
+ return withDefaultExports(namespace);
32
137
  } catch {
33
138
  continue;
34
139
  }
@@ -38,6 +143,20 @@ export async function importFrom(name, from) {
38
143
  );
39
144
  }
40
145
 
146
+ /**
147
+ * A package's namespace, for the one caller whose use of it cannot be checked
148
+ * at the import: it launches Playwright's browser and drives it through
149
+ * Playwright's own declarations.
150
+ *
151
+ * @param {string} name The package name
152
+ * @param {string[]} from Folders to resolve from, in order
153
+ * @returns {Promise<any>} The module namespace
154
+ * @throws When no folder resolves it, saying which devDependency to add
155
+ */
156
+ export async function importFrom(name, from) {
157
+ return importNamespace(name, from);
158
+ }
159
+
41
160
  function mongodOnPath() {
42
161
  return spawnSync("mongod", ["--version"], { stdio: "ignore" }).status === 0
43
162
  ? "mongod"
@@ -51,11 +170,21 @@ function mongodOnPath() {
51
170
  * @param {string[]} from Folders to resolve mongodb-memory-server-core from
52
171
  * @returns {Promise<string>} The binary path
53
172
  */
54
- async function mongodBinary(from) {
173
+ export async function mongodBinary(from) {
55
174
  const configured = process.env.DMS_CONTROL_MONGOD ?? mongodOnPath();
56
175
  if (configured) return configured;
57
- const { MongoBinary } = await importFrom("mongodb-memory-server-core", from);
58
- return MongoBinary.getPath({ version: MONGO_VERSION });
176
+ const namespace = await importNamespace("mongodb-memory-server-core", from);
177
+ const binary = namespace.MongoBinary;
178
+ if (!isBinaryResolver(binary))
179
+ throw new TypeError("mongodb-memory-server-core exports no MongoBinary");
180
+ /** @type {unknown} */
181
+ const path = await binary.getPath({ version: MONGO_VERSION });
182
+ return decode(
183
+ isString,
184
+ path,
185
+ "MongoBinary.getPath",
186
+ "the path of a mongod binary",
187
+ );
59
188
  }
60
189
 
61
190
  /**
@@ -89,6 +218,10 @@ export async function startMongod(paths, from) {
89
218
  return { url: `mongodb://127.0.0.1:${port}`, record: { ...record, port } };
90
219
  }
91
220
 
221
+ /**
222
+ * @param {string[]} from
223
+ * @returns {string | undefined}
224
+ */
92
225
  function driverBesideMemoryServer(from) {
93
226
  for (const folder of from) {
94
227
  try {
@@ -103,11 +236,56 @@ function driverBesideMemoryServer(from) {
103
236
  return undefined;
104
237
  }
105
238
 
239
+ /**
240
+ * @param {string[]} from
241
+ * @returns {Promise<Record<string, unknown>>}
242
+ */
106
243
  async function mongoDriver(from) {
107
244
  const beside = driverBesideMemoryServer(from);
108
245
  if (beside)
109
246
  return withDefaultExports(await import(pathToFileURL(beside).href));
110
- return importFrom("mongodb", from);
247
+ return importNamespace("mongodb", from);
248
+ }
249
+
250
+ /**
251
+ * @param {Client} client A driver client
252
+ * @param {string} name The database
253
+ * @returns {Database} Its handle
254
+ * @throws When the driver answers anything but a database
255
+ */
256
+ function databaseOf(client, name) {
257
+ /** @type {unknown} */
258
+ const database = client.db(name);
259
+ if (!isDatabase(database))
260
+ throw new TypeError(`MongoClient.db(${name}) returned no database`);
261
+ return database;
262
+ }
263
+
264
+ /**
265
+ * Runs `use` on a client connected to the server, then closes it.
266
+ *
267
+ * @template T
268
+ * @param {string} url The server URL
269
+ * @param {string[]} from Folders to resolve the driver from
270
+ * @param {(client: Client) => Promise<T>} use What to do with the connected client
271
+ * @returns {Promise<T>} What `use` answered
272
+ * @throws When the driver exports no `MongoClient` that builds a client
273
+ */
274
+ async function withClient(url, from, use) {
275
+ const namespace = await mongoDriver(from);
276
+ const MongoClient = namespace.MongoClient;
277
+ if (!isConstructor(MongoClient))
278
+ throw new TypeError("The MongoDB driver exports no MongoClient");
279
+ /** @type {unknown} */
280
+ const client = new MongoClient(url, { serverSelectionTimeoutMS: 5_000 });
281
+ if (!isClient(client))
282
+ throw new TypeError("MongoClient built something that is no client");
283
+ try {
284
+ await client.connect();
285
+ return await use(client);
286
+ } finally {
287
+ await client.close();
288
+ }
111
289
  }
112
290
 
113
291
  /**
@@ -120,20 +298,25 @@ async function mongoDriver(from) {
120
298
  * @param {string[]} from Folders to resolve the driver from
121
299
  * @returns {Promise<string[]>} One line per dropped database
122
300
  */
123
- export async function dropRunDatabases(url, prefix, from) {
124
- const { MongoClient } = await mongoDriver(from);
125
- const client = new MongoClient(url, { serverSelectionTimeoutMS: 5_000 });
126
- try {
127
- await client.connect();
128
- const { databases } = await client.db("admin").admin().listDatabases();
301
+ export function dropRunDatabases(url, prefix, from) {
302
+ return withClient(url, from, async (client) => {
303
+ const admin = databaseOf(client, "admin").admin();
304
+ if (!isAdmin(admin))
305
+ throw new TypeError(
306
+ "The admin handle of the driver has no listDatabases",
307
+ );
308
+ const { databases } = decode(
309
+ isDatabaseList,
310
+ await admin.listDatabases(),
311
+ "listDatabases",
312
+ "a list of databases",
313
+ );
129
314
  const owned = databases
130
315
  .map((database) => database.name)
131
316
  .filter((name) => name.startsWith(prefix));
132
- for (const name of owned) await client.db(name).dropDatabase();
317
+ for (const name of owned) await databaseOf(client, name).dropDatabase();
133
318
  return owned.map((name) => `dropped database ${name}`);
134
- } finally {
135
- await client.close();
136
- }
319
+ });
137
320
  }
138
321
 
139
322
  /**
@@ -144,13 +327,8 @@ export async function dropRunDatabases(url, prefix, from) {
144
327
  * @param {string[]} from Folders to resolve the driver from
145
328
  * @returns {Promise<void>} Resolves once dropped
146
329
  */
147
- export async function dropDatabase(url, name, from) {
148
- const { MongoClient } = await mongoDriver(from);
149
- const client = new MongoClient(url, { serverSelectionTimeoutMS: 5_000 });
150
- try {
151
- await client.connect();
152
- await client.db(name).dropDatabase();
153
- } finally {
154
- await client.close();
155
- }
330
+ export function dropDatabase(url, name, from) {
331
+ return withClient(url, from, async (client) => {
332
+ await databaseOf(client, name).dropDatabase();
333
+ });
156
334
  }