@learncard/network-brain-service 3.17.0 → 3.19.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 (274) hide show
  1. package/.env.example +4 -0
  2. package/AGENTS.md +177 -159
  3. package/CHANGELOG.md +148 -0
  4. package/README.md +26 -8
  5. package/compose.inbox.yml +7 -0
  6. package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
  7. package/dist/accesslayer/auth-grant/read.d.ts +1 -0
  8. package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
  9. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +3 -2
  10. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  11. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +1 -0
  12. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  13. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +5 -3
  14. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  15. package/dist/accesslayer/credential/create.d.ts +2 -1
  16. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  17. package/dist/accesslayer/credential/create.test.d.ts +2 -0
  18. package/dist/accesslayer/credential/create.test.d.ts.map +1 -0
  19. package/dist/accesslayer/credential/relationships/read.d.ts +1 -0
  20. package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
  21. package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
  22. package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
  23. package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
  24. package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
  25. package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
  26. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  27. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  28. package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
  29. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
  30. package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
  31. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  32. package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
  33. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
  34. package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
  35. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  36. package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
  37. package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
  38. package/dist/cache/atomic-counter.test.d.ts +2 -0
  39. package/dist/cache/atomic-counter.test.d.ts.map +1 -0
  40. package/dist/cache/index.d.ts +1 -1
  41. package/dist/cache/index.d.ts.map +1 -1
  42. package/dist/config/environment.d.ts +46 -0
  43. package/dist/config/environment.d.ts.map +1 -1
  44. package/dist/credential-refresh.d.ts.map +1 -1
  45. package/dist/didkit_wasm_bg.wasm +0 -0
  46. package/dist/helpers/activity.helpers.d.ts +4 -0
  47. package/dist/helpers/activity.helpers.d.ts.map +1 -1
  48. package/dist/helpers/boost-storage.helpers.test.d.ts +2 -0
  49. package/dist/helpers/boost-storage.helpers.test.d.ts.map +1 -0
  50. package/dist/helpers/boost.helpers.d.ts +3 -7
  51. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  52. package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
  53. package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
  54. package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
  55. package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
  56. package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
  57. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
  58. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
  59. package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
  60. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
  61. package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
  62. package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
  63. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
  64. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
  65. package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
  66. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  67. package/dist/helpers/credential.helpers.d.ts +4 -2
  68. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  69. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  70. package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
  71. package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
  72. package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
  73. package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
  74. package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
  75. package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
  76. package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
  77. package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
  78. package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
  79. package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
  80. package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
  81. package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
  82. package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
  83. package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
  84. package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
  85. package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
  86. package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
  87. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
  88. package/dist/helpers/inbox.helpers.d.ts +9 -1
  89. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  90. package/dist/helpers/issuedCredentialStatus.helpers.d.ts +5 -0
  91. package/dist/helpers/issuedCredentialStatus.helpers.d.ts.map +1 -0
  92. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  93. package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
  94. package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
  95. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
  96. package/dist/helpers/shim.d.ts +1 -1
  97. package/dist/helpers/shim.d.ts.map +1 -1
  98. package/dist/helpers/signingAuthority.helpers.d.ts +3 -2
  99. package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
  100. package/dist/helpers/signingAuthority.helpers.test.d.ts +2 -0
  101. package/dist/helpers/signingAuthority.helpers.test.d.ts.map +1 -0
  102. package/dist/helpers/status-list.helpers.d.ts.map +1 -1
  103. package/dist/helpers/status-list.helpers.test.d.ts +2 -0
  104. package/dist/helpers/status-list.helpers.test.d.ts.map +1 -0
  105. package/dist/inbox-queue-local.d.ts +2 -0
  106. package/dist/inbox-queue-local.d.ts.map +1 -0
  107. package/dist/index.js +2 -2
  108. package/dist/models/AuthGrant.d.ts.map +1 -1
  109. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  110. package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
  111. package/dist/models/Credential.d.ts +1 -0
  112. package/dist/models/Credential.d.ts.map +1 -1
  113. package/dist/models/CredentialActivity.d.ts +1 -0
  114. package/dist/models/CredentialActivity.d.ts.map +1 -1
  115. package/dist/models/CredentialRefresh.d.ts +5 -2
  116. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  117. package/dist/models/InboxCredential.d.ts +1 -0
  118. package/dist/models/InboxCredential.d.ts.map +1 -1
  119. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  120. package/dist/openapi.d.ts.map +1 -1
  121. package/dist/routes/act-as.test.d.ts +2 -0
  122. package/dist/routes/act-as.test.d.ts.map +1 -0
  123. package/dist/routes/activity.d.ts +3 -0
  124. package/dist/routes/activity.d.ts.map +1 -1
  125. package/dist/routes/app-store.d.ts.map +1 -1
  126. package/dist/routes/auth-grants.d.ts +10 -0
  127. package/dist/routes/auth-grants.d.ts.map +1 -1
  128. package/dist/routes/boosts.d.ts +506 -12
  129. package/dist/routes/boosts.d.ts.map +1 -1
  130. package/dist/routes/contracts.d.ts +48 -2
  131. package/dist/routes/contracts.d.ts.map +1 -1
  132. package/dist/routes/credential-refreshes.d.ts +4 -4
  133. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  134. package/dist/routes/credentials.d.ts +8 -2
  135. package/dist/routes/credentials.d.ts.map +1 -1
  136. package/dist/routes/did-metadata.d.ts +8 -8
  137. package/dist/routes/federation.d.ts +2 -2
  138. package/dist/routes/inbox.d.ts +973 -31
  139. package/dist/routes/inbox.d.ts.map +1 -1
  140. package/dist/routes/index.d.ts +47 -127
  141. package/dist/routes/index.d.ts.map +1 -1
  142. package/dist/routes/presentations.d.ts +6 -6
  143. package/dist/routes/storage.d.ts +63 -63
  144. package/dist/routes/test.d.ts +6 -0
  145. package/dist/routes/test.d.ts.map +1 -1
  146. package/dist/routes/workflows.d.ts +14 -14
  147. package/dist/routes/workflows.d.ts.map +1 -1
  148. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  149. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
  150. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
  151. package/dist/services/registry/registry.factory.d.ts.map +1 -1
  152. package/dist/types/activity.d.ts +4 -0
  153. package/dist/types/activity.d.ts.map +1 -1
  154. package/dist/types/consentflowcontract.d.ts +22 -8
  155. package/dist/types/consentflowcontract.d.ts.map +1 -1
  156. package/dist/types/credential-refresh.d.ts +10 -1
  157. package/dist/types/credential-refresh.d.ts.map +1 -1
  158. package/dist/types/credential.d.ts +7 -0
  159. package/dist/types/credential.d.ts.map +1 -1
  160. package/dist/types/did-metadata.d.ts +2 -2
  161. package/dist/types/inbox-batch.d.ts +38 -0
  162. package/dist/types/inbox-batch.d.ts.map +1 -0
  163. package/inbox-e2e-setup.ts +53 -1
  164. package/inbox-elasticmq.conf +10 -0
  165. package/lambda.ts +61 -16
  166. package/package.json +18 -16
  167. package/scripts/setup-inbox-queues.ts +60 -0
  168. package/serverless-local.yml +2 -0
  169. package/serverless.yml +75 -0
  170. package/src/accesslayer/app-store-listing/relationships/read.ts +4 -3
  171. package/src/accesslayer/auth-grant/read.ts +2 -1
  172. package/src/accesslayer/consentflowcontract/relationships/create.ts +15 -5
  173. package/src/accesslayer/consentflowcontract/relationships/read.ts +161 -63
  174. package/src/accesslayer/consentflowcontract/relationships/update.ts +43 -10
  175. package/src/accesslayer/credential/create.test.ts +141 -0
  176. package/src/accesslayer/credential/create.ts +27 -3
  177. package/src/accesslayer/credential-activity/create.ts +1 -0
  178. package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
  179. package/src/accesslayer/credential-refresh/index.ts +1 -0
  180. package/src/accesslayer/credential-refresh/read.ts +8 -2
  181. package/src/accesslayer/credential-refresh/update.ts +4 -0
  182. package/src/accesslayer/inbox-batch/store.ts +469 -0
  183. package/src/accesslayer/inbox-credential/create.ts +51 -19
  184. package/src/accesslayer/inbox-credential/refresh.ts +221 -0
  185. package/src/accesslayer/inbox-credential/update.ts +1 -1
  186. package/src/cache/atomic-counter.test-helpers.ts +27 -0
  187. package/src/cache/atomic-counter.test.ts +6 -0
  188. package/src/cache/index.ts +25 -13
  189. package/src/config/environment.ts +26 -0
  190. package/src/credential-refresh.ts +5 -1
  191. package/src/docker-entry.ts +8 -0
  192. package/src/helpers/activity.helpers.ts +4 -0
  193. package/src/helpers/app-counter.helpers.ts +1 -1
  194. package/src/helpers/boost-storage.helpers.test.ts +242 -0
  195. package/src/helpers/boost.helpers.ts +58 -437
  196. package/src/helpers/contact-method.helpers.test.ts +43 -0
  197. package/src/helpers/contact-method.helpers.ts +3 -4
  198. package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
  199. package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
  200. package/src/helpers/credential-refresh-email.helpers.ts +201 -0
  201. package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
  202. package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
  203. package/src/helpers/credential-refresh.helpers.ts +385 -107
  204. package/src/helpers/credential.helpers.ts +6 -2
  205. package/src/helpers/finalize-inbox.helpers.ts +39 -22
  206. package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
  207. package/src/helpers/inbox-batch-http.helpers.ts +150 -0
  208. package/src/helpers/inbox-batch-status.helpers.ts +11 -0
  209. package/src/helpers/inbox-batch.helpers.test.ts +668 -0
  210. package/src/helpers/inbox-batch.helpers.ts +374 -0
  211. package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
  212. package/src/helpers/inbox-queue.helpers.test.ts +253 -0
  213. package/src/helpers/inbox-queue.helpers.ts +372 -0
  214. package/src/helpers/inbox-refresh.helpers.ts +432 -0
  215. package/src/helpers/inbox.helpers.ts +301 -50
  216. package/src/helpers/issuedCredentialStatus.helpers.ts +11 -0
  217. package/src/helpers/notifications.helpers.ts +4 -2
  218. package/src/helpers/rateLimit.helpers.ts +2 -5
  219. package/src/helpers/refresh-send-intent.helpers.ts +273 -0
  220. package/src/helpers/shim.ts +32 -31
  221. package/src/helpers/signingAuthority.helpers.test.ts +198 -0
  222. package/src/helpers/signingAuthority.helpers.ts +41 -18
  223. package/src/helpers/status-list.helpers.test.ts +77 -0
  224. package/src/helpers/status-list.helpers.ts +31 -8
  225. package/src/inbox-queue-local.ts +28 -0
  226. package/src/models/AuthGrant.ts +1 -0
  227. package/src/models/ConsentFlowTerms.ts +4 -0
  228. package/src/models/ConsentFlowTransaction.ts +4 -0
  229. package/src/models/Credential.ts +1 -0
  230. package/src/models/CredentialActivity.ts +3 -5
  231. package/src/models/CredentialRefresh.ts +4 -2
  232. package/src/models/InboxCredential.ts +2 -0
  233. package/src/models/credential-refresh-constraints.test.ts +8 -3
  234. package/src/models/credential-refresh-constraints.ts +5 -0
  235. package/src/openapi.ts +8 -0
  236. package/src/routes/act-as.test.ts +239 -0
  237. package/src/routes/app-store.ts +69 -52
  238. package/src/routes/auth-grants.ts +13 -11
  239. package/src/routes/boosts.ts +943 -120
  240. package/src/routes/contracts.ts +55 -7
  241. package/src/routes/credential-refreshes.test.ts +8 -2
  242. package/src/routes/credential-refreshes.ts +4 -1
  243. package/src/routes/credentials.ts +2 -1
  244. package/src/routes/inbox.ts +95 -49
  245. package/src/routes/index.ts +164 -88
  246. package/src/routes/skills.ts +1 -1
  247. package/src/routes/workflows.ts +80 -31
  248. package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
  249. package/src/services/delivery/adapters/postmark.adapter.ts +12 -0
  250. package/src/services/registry/registry.factory.ts +2 -1
  251. package/src/types/activity.ts +2 -0
  252. package/src/types/consentflowcontract.ts +21 -3
  253. package/src/types/credential-refresh.ts +21 -2
  254. package/src/types/credential.ts +13 -1
  255. package/src/types/inbox-batch.ts +46 -0
  256. package/test/app-store-listing.spec.ts +66 -30
  257. package/test/atomic-counter.e2e.ts +12 -0
  258. package/test/auth-grant.spec.ts +21 -21
  259. package/test/bitstring-status-list.spec.ts +12 -9
  260. package/test/consentflow.spec.ts +453 -26
  261. package/test/credential-refresh-email.spec.ts +663 -0
  262. package/test/credential-refresh-publication.spec.ts +13 -5
  263. package/test/guardian-gated-routes.spec.ts +337 -180
  264. package/test/helpers/inbox-e2e-environment.ts +6 -0
  265. package/test/inbox-batch.spec.ts +1338 -0
  266. package/test/inbox-refresh.spec.ts +534 -0
  267. package/test/inbox-security.e2e.ts +48 -3
  268. package/test/openapi.spec.ts +41 -1
  269. package/test/refresh-send.spec.ts +1799 -0
  270. package/test/registry.spec.ts +56 -11
  271. package/test/revoke-suspend-per-instance.spec.ts +9 -1
  272. package/vitest.config.ts +13 -0
  273. package/vitest.inbox-e2e.config.ts +5 -1
  274. package/vitest.integration.config.ts +2 -0
@@ -0,0 +1,222 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ buildCredentialUpdatedEmailModel,
5
+ chooseCredentialRefreshEmailTarget,
6
+ extractBoundedCredentialDisplayTitle,
7
+ MAX_CREDENTIAL_DISPLAY_TITLE_LENGTH,
8
+ } from './credential-refresh-email-content.helpers';
9
+
10
+ const email = (value: string, { verified = true, primary = false } = {}) => ({
11
+ type: 'email',
12
+ value,
13
+ isVerified: verified,
14
+ isPrimary: primary,
15
+ });
16
+
17
+ const phone = (value: string) => ({
18
+ type: 'phone',
19
+ value,
20
+ isVerified: true,
21
+ isPrimary: true,
22
+ });
23
+
24
+ describe('extractBoundedCredentialDisplayTitle', () => {
25
+ it('returns a trimmed, whitespace-collapsed title', () => {
26
+ expect(extractBoundedCredentialDisplayTitle(' Introduction \n to\tBiology ')).toBe(
27
+ 'Introduction to Biology'
28
+ );
29
+ });
30
+
31
+ it('removes control characters', () => {
32
+ expect(extractBoundedCredentialDisplayTitle('Biology\u0000\u0007 101')).toBe('Biology 101');
33
+ });
34
+
35
+ it('truncates to the bounded maximum length', () => {
36
+ const long = 'x'.repeat(MAX_CREDENTIAL_DISPLAY_TITLE_LENGTH + 50);
37
+ const result = extractBoundedCredentialDisplayTitle(long);
38
+
39
+ expect(result).toHaveLength(MAX_CREDENTIAL_DISPLAY_TITLE_LENGTH);
40
+ });
41
+
42
+ it('honors an explicit maximum length', () => {
43
+ expect(extractBoundedCredentialDisplayTitle('abcdef', 3)).toBe('abc');
44
+ });
45
+
46
+ it.each([undefined, null, '', ' ', 42, {}, []])(
47
+ 'returns undefined for a non-title value (%s)',
48
+ value => {
49
+ expect(extractBoundedCredentialDisplayTitle(value)).toBeUndefined();
50
+ }
51
+ );
52
+
53
+ it('returns undefined when only whitespace remains after sanitization', () => {
54
+ expect(extractBoundedCredentialDisplayTitle('\u0000 \n\u0007')).toBeUndefined();
55
+ });
56
+ });
57
+
58
+ describe('chooseCredentialRefreshEmailTarget', () => {
59
+ it('resolves a verified holder email for an unmanaged holder', () => {
60
+ const result = chooseCredentialRefreshEmailTarget({
61
+ holder: { locale: 'es' },
62
+ holderContactMethods: [email('holder@example.com')],
63
+ managers: [],
64
+ });
65
+
66
+ expect(result).toEqual({
67
+ status: 'resolved',
68
+ kind: 'holder',
69
+ locale: 'es',
70
+ contactMethod: { type: 'email', value: 'holder@example.com' },
71
+ });
72
+ });
73
+
74
+ it('prefers the verified primary holder email', () => {
75
+ const result = chooseCredentialRefreshEmailTarget({
76
+ holder: {},
77
+ holderContactMethods: [
78
+ email('secondary@example.com'),
79
+ email('primary@example.com', { primary: true }),
80
+ ],
81
+ managers: [],
82
+ });
83
+
84
+ expect(result).toMatchObject({
85
+ status: 'resolved',
86
+ contactMethod: { value: 'primary@example.com' },
87
+ });
88
+ });
89
+
90
+ it('never uses an unverified holder email', () => {
91
+ const result = chooseCredentialRefreshEmailTarget({
92
+ holder: {},
93
+ holderContactMethods: [
94
+ email('unverified@example.com', { verified: false }),
95
+ email('verified@example.com'),
96
+ ],
97
+ managers: [],
98
+ });
99
+
100
+ expect(result).toMatchObject({
101
+ status: 'resolved',
102
+ contactMethod: { value: 'verified@example.com' },
103
+ });
104
+ });
105
+
106
+ it('ignores non-email contact methods', () => {
107
+ const result = chooseCredentialRefreshEmailTarget({
108
+ holder: {},
109
+ holderContactMethods: [phone('+15555550100')],
110
+ managers: [],
111
+ });
112
+
113
+ expect(result).toEqual({ status: 'skipped', reason: 'no-verified-holder-email' });
114
+ });
115
+
116
+ it('skips an unmanaged holder with no verified email', () => {
117
+ const result = chooseCredentialRefreshEmailTarget({
118
+ holder: {},
119
+ holderContactMethods: [],
120
+ managers: [],
121
+ });
122
+
123
+ expect(result).toEqual({ status: 'skipped', reason: 'no-verified-holder-email' });
124
+ });
125
+
126
+ it('routes a managed child to the verified guardian email, never the child', () => {
127
+ const result = chooseCredentialRefreshEmailTarget({
128
+ holder: { locale: 'en' },
129
+ holderContactMethods: [email('child@example.com')],
130
+ managers: [
131
+ {
132
+ locale: 'fr',
133
+ contactMethods: [email('guardian@example.com')],
134
+ },
135
+ ],
136
+ });
137
+
138
+ expect(result).toEqual({
139
+ status: 'resolved',
140
+ kind: 'guardian',
141
+ locale: 'fr',
142
+ contactMethod: { type: 'email', value: 'guardian@example.com' },
143
+ });
144
+ expect(JSON.stringify(result)).not.toContain('child@example.com');
145
+ });
146
+
147
+ it('skips a managed child when no guardian has a verified email', () => {
148
+ const result = chooseCredentialRefreshEmailTarget({
149
+ holder: {},
150
+ holderContactMethods: [email('child@example.com')],
151
+ managers: [
152
+ { contactMethods: [email('guardian@example.com', { verified: false })] },
153
+ { contactMethods: [] },
154
+ ],
155
+ });
156
+
157
+ expect(result).toEqual({ status: 'skipped', reason: 'no-verified-guardian-email' });
158
+ });
159
+
160
+ it('skips a managed child even when the child has a verified email', () => {
161
+ const result = chooseCredentialRefreshEmailTarget({
162
+ holder: {},
163
+ holderContactMethods: [email('child@example.com')],
164
+ managers: [{ contactMethods: [] }],
165
+ });
166
+
167
+ expect(result).toEqual({ status: 'skipped', reason: 'no-verified-guardian-email' });
168
+ });
169
+
170
+ it('prefers a primary verified guardian email across managers', () => {
171
+ const result = chooseCredentialRefreshEmailTarget({
172
+ holder: {},
173
+ holderContactMethods: [],
174
+ managers: [
175
+ { contactMethods: [email('guardian-a@example.com')] },
176
+ {
177
+ contactMethods: [
178
+ email('guardian-b@example.com', { primary: true }),
179
+ email('guardian-b2@example.com'),
180
+ ],
181
+ },
182
+ ],
183
+ });
184
+
185
+ expect(result).toMatchObject({
186
+ status: 'resolved',
187
+ kind: 'guardian',
188
+ contactMethod: { value: 'guardian-b@example.com' },
189
+ });
190
+ });
191
+ });
192
+
193
+ describe('buildCredentialUpdatedEmailModel', () => {
194
+ it('includes the issuer display name and credential title', () => {
195
+ expect(
196
+ buildCredentialUpdatedEmailModel({
197
+ issuerDisplayName: 'Inbox Demo School',
198
+ credentialTitle: 'Introduction to Biology',
199
+ })
200
+ ).toEqual({
201
+ issuer: { name: 'Inbox Demo School' },
202
+ credential: { name: 'Introduction to Biology' },
203
+ });
204
+ });
205
+
206
+ it('omits the credential title when unavailable', () => {
207
+ expect(
208
+ buildCredentialUpdatedEmailModel({
209
+ issuerDisplayName: 'Inbox Demo School',
210
+ })
211
+ ).toEqual({ issuer: { name: 'Inbox Demo School' } });
212
+ });
213
+
214
+ it('does not include any other credential content', () => {
215
+ const model = buildCredentialUpdatedEmailModel({
216
+ issuerDisplayName: 'Inbox Demo School',
217
+ credentialTitle: 'Introduction to Biology',
218
+ });
219
+
220
+ expect(Object.keys(model).sort()).toEqual(['credential', 'issuer']);
221
+ });
222
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Pure content/recipient decisions for the managed credential-refresh update
3
+ * email (LC-2198).
4
+ *
5
+ * Kept dependency-free (no database, delivery, or model imports) so the
6
+ * privacy-sensitive decisions — what minimal context is rendered and which
7
+ * verified contact method is targeted — are exhaustively unit testable.
8
+ *
9
+ * Rendering and delivery live in `credential-refresh-email.helpers.ts`.
10
+ */
11
+
12
+ /** Upper bound on a persisted credential display title, in characters. */
13
+ export const MAX_CREDENTIAL_DISPLAY_TITLE_LENGTH = 120;
14
+
15
+ /**
16
+ * Normalizes an issuer-supplied credential title into a bounded, single-line
17
+ * string safe to persist and render. Control characters (including newlines)
18
+ * become spaces, runs of whitespace collapse, and the result is truncated to
19
+ * `maxLength`. Returns `undefined` for non-strings or values that normalize to
20
+ * nothing, so callers can fall back to generic copy.
21
+ */
22
+ export const extractBoundedCredentialDisplayTitle = (
23
+ value: unknown,
24
+ maxLength: number = MAX_CREDENTIAL_DISPLAY_TITLE_LENGTH
25
+ ): string | undefined => {
26
+ if (typeof value !== 'string') return undefined;
27
+
28
+ // Control characters (C0, DEL, C1) become spaces rather than being dropped.
29
+ const withoutControlCharacters = Array.from(value, character => {
30
+ const code = character.charCodeAt(0);
31
+
32
+ return code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? ' ' : character;
33
+ }).join('');
34
+
35
+ const sanitized = withoutControlCharacters
36
+ .replace(/\s+/g, ' ')
37
+ .trim()
38
+ .slice(0, maxLength)
39
+ .trim();
40
+
41
+ return sanitized.length > 0 ? sanitized : undefined;
42
+ };
43
+
44
+ /** Minimal shape of a contact method used by the pure selection logic. */
45
+ export type CredentialRefreshEmailContactMethod = {
46
+ type: string;
47
+ value: string;
48
+ isVerified: boolean;
49
+ isPrimary?: boolean;
50
+ };
51
+
52
+ /** A profile that may manage the holder, plus its verified contact methods. */
53
+ export type CredentialRefreshEmailManager = {
54
+ locale?: string;
55
+ contactMethods: CredentialRefreshEmailContactMethod[];
56
+ };
57
+
58
+ export type CredentialRefreshEmailTargetParams = {
59
+ holder: { locale?: string };
60
+ holderContactMethods: CredentialRefreshEmailContactMethod[];
61
+ /** Profiles that manage the holder (guardians/managers). Empty = unmanaged. */
62
+ managers: CredentialRefreshEmailManager[];
63
+ };
64
+
65
+ export type CredentialRefreshEmailTarget =
66
+ | {
67
+ status: 'resolved';
68
+ kind: 'holder' | 'guardian';
69
+ locale?: string;
70
+ contactMethod: { type: 'email'; value: string };
71
+ }
72
+ | {
73
+ status: 'skipped';
74
+ reason: 'no-verified-holder-email' | 'no-verified-guardian-email';
75
+ };
76
+
77
+ const isUsableVerifiedEmail = (contactMethod: CredentialRefreshEmailContactMethod): boolean =>
78
+ contactMethod.type === 'email' &&
79
+ contactMethod.isVerified === true &&
80
+ typeof contactMethod.value === 'string' &&
81
+ contactMethod.value.trim().length > 0;
82
+
83
+ const pickVerifiedEmail = (
84
+ contactMethods: CredentialRefreshEmailContactMethod[]
85
+ ): CredentialRefreshEmailContactMethod | undefined => {
86
+ const verified = contactMethods.filter(isUsableVerifiedEmail);
87
+
88
+ return verified.find(contactMethod => contactMethod.isPrimary) ?? verified[0];
89
+ };
90
+
91
+ /**
92
+ * Chooses the single verified email that should receive an update notice.
93
+ *
94
+ * - Unmanaged holders are emailed at their own verified email (primary preferred).
95
+ * - Managed holders are NEVER emailed directly: the notice goes to a verified
96
+ * manager's email, or is skipped when none is available. Falling back to the
97
+ * child's address is intentionally impossible.
98
+ * - Callers always resolve contact methods from the bound holder profile — the
99
+ * original inbox address and any caller-supplied address are never used.
100
+ */
101
+ export const chooseCredentialRefreshEmailTarget = (
102
+ params: CredentialRefreshEmailTargetParams
103
+ ): CredentialRefreshEmailTarget => {
104
+ const { holder, holderContactMethods, managers } = params;
105
+
106
+ if (managers.length > 0) {
107
+ const candidates = managers.flatMap(manager =>
108
+ manager.contactMethods
109
+ .filter(isUsableVerifiedEmail)
110
+ .map(contactMethod => ({ manager, contactMethod }))
111
+ );
112
+ const chosen =
113
+ candidates.find(candidate => candidate.contactMethod.isPrimary) ?? candidates[0];
114
+
115
+ if (!chosen) return { status: 'skipped', reason: 'no-verified-guardian-email' };
116
+
117
+ return {
118
+ status: 'resolved',
119
+ kind: 'guardian',
120
+ locale: chosen.manager.locale,
121
+ contactMethod: { type: 'email', value: chosen.contactMethod.value },
122
+ };
123
+ }
124
+
125
+ const contactMethod = pickVerifiedEmail(holderContactMethods);
126
+
127
+ if (!contactMethod) return { status: 'skipped', reason: 'no-verified-holder-email' };
128
+
129
+ return {
130
+ status: 'resolved',
131
+ kind: 'holder',
132
+ locale: holder.locale,
133
+ contactMethod: { type: 'email', value: contactMethod.value },
134
+ };
135
+ };
136
+
137
+ /**
138
+ * Builds the minimal template model for `credential-updated`.
139
+ *
140
+ * Exactly two optional values are allowed: the issuer display name and the
141
+ * bounded credential title. No summary, subject, recipient, grade, evidence, or
142
+ * credential body content can be expressed by this shape.
143
+ */
144
+ export const buildCredentialUpdatedEmailModel = (params: {
145
+ issuerDisplayName?: string;
146
+ credentialTitle?: string;
147
+ }): { issuer?: { name?: string }; credential?: { name?: string } } => {
148
+ const model: { issuer?: { name?: string }; credential?: { name?: string } } = {};
149
+ const title = extractBoundedCredentialDisplayTitle(params.credentialTitle);
150
+
151
+ if (params.issuerDisplayName) model.issuer = { name: params.issuerDisplayName };
152
+ if (title) model.credential = { name: title };
153
+
154
+ return model;
155
+ };
@@ -0,0 +1,201 @@
1
+ import { environment } from '@environment';
2
+ import { generateClaimUrl } from './contact-method.helpers';
3
+ import type { BoostInstance } from '@models';
4
+ import { getBoostById } from '@accesslayer/boost/read';
5
+ import { getContactMethodsForProfile } from '@accesslayer/contact-method/read';
6
+ import {
7
+ claimCredentialRefreshEmailDelivery,
8
+ finalizeCredentialRefreshEmailDelivery,
9
+ getCredentialRefresh,
10
+ } from '@accesslayer/credential-refresh';
11
+ import { getProfilesThatManageAProfile } from '@accesslayer/profile/relationships/read';
12
+ import type { TenantBranding } from '@learncard/email-templates';
13
+
14
+ import type { CredentialRefreshVersionNode } from 'types/credential-refresh';
15
+ import type { ProfileType } from 'types/profile';
16
+
17
+ import { ensureCredentialRefreshConstraints } from '../models/credential-refresh-constraints';
18
+ import { getDeliveryService } from '@services/delivery/delivery.factory';
19
+ import {
20
+ buildCredentialUpdatedEmailModel,
21
+ chooseCredentialRefreshEmailTarget,
22
+ extractBoundedCredentialDisplayTitle,
23
+ type CredentialRefreshEmailContactMethod,
24
+ type CredentialRefreshEmailManager,
25
+ type CredentialRefreshEmailTarget,
26
+ } from './credential-refresh-email-content.helpers';
27
+ import { computeCredentialRefreshDeliveryKey } from './credential-refresh-materiality.helpers';
28
+ import { resolveRecipientLocale } from './getRecipientLocale.helpers';
29
+
30
+ /**
31
+ * Managed credential-refresh update email (LC-2198).
32
+ *
33
+ * Runs independently of the durable in-app/push notification: an email failure
34
+ * never changes the publication result and never re-enqueues the push event. The
35
+ * email carries only the minimal context the user approved — issuer display name
36
+ * and a bounded credential display title — and routes to a verified email of the
37
+ * bound holder (or a verified guardian when the holder is managed).
38
+ *
39
+ * Delivery is at-most-once per configured delivery window, backed by a unique
40
+ * `CredentialRefreshEmailDelivery` node (see the access layer). A send that fails
41
+ * after the claim is recorded as `failed`; it is not retried inside the same
42
+ * window. A later material update in a new window produces a new claim and is the
43
+ * retry boundary. The window claim is intentionally attempted even when the in-app
44
+ * notification was already delivered, so the two channels do not share failure.
45
+ */
46
+
47
+ export type CredentialRefreshEmailDeliveryOutcome =
48
+ 'sent' | 'duplicate-window' | 'skipped-no-recipient' | 'failed';
49
+
50
+ /** Resolves the verified email target for the bound holder. Never caller-supplied. */
51
+ export const resolveCredentialRefreshEmailTarget = async (
52
+ holderProfile: ProfileType
53
+ ): Promise<CredentialRefreshEmailTarget> => {
54
+ const managers = await getProfilesThatManageAProfile(holderProfile.profileId);
55
+
56
+ if (managers.length > 0) {
57
+ const managerContacts: CredentialRefreshEmailManager[] = await Promise.all(
58
+ managers.map(async manager => ({
59
+ locale: manager.locale,
60
+ contactMethods: (await getContactMethodsForProfile(
61
+ manager.did
62
+ )) as CredentialRefreshEmailContactMethod[],
63
+ }))
64
+ );
65
+
66
+ // Managed holder: only verified guardian emails are eligible. The child's
67
+ // own address is deliberately not resolved, so it can never be emailed.
68
+ return chooseCredentialRefreshEmailTarget({
69
+ holder: { locale: holderProfile.locale },
70
+ holderContactMethods: [],
71
+ managers: managerContacts,
72
+ });
73
+ }
74
+
75
+ const holderContactMethods = (await getContactMethodsForProfile(
76
+ holderProfile.did
77
+ )) as CredentialRefreshEmailContactMethod[];
78
+
79
+ return chooseCredentialRefreshEmailTarget({
80
+ holder: { locale: holderProfile.locale },
81
+ holderContactMethods,
82
+ managers: [],
83
+ });
84
+ };
85
+
86
+ /**
87
+ * Resolves the bounded email-only title: the persisted publish-input title first,
88
+ * then the aggregate's coalesced title, then the original boost template name.
89
+ * The holder-encrypted payload is never decrypted to recover a title.
90
+ */
91
+ export const resolveCredentialRefreshEmailDisplayTitle = async (params: {
92
+ version: CredentialRefreshVersionNode;
93
+ refreshId: string;
94
+ }): Promise<string | undefined> => {
95
+ const { version, refreshId } = params;
96
+ const fromVersion = extractBoundedCredentialDisplayTitle(version.credentialDisplayName);
97
+
98
+ if (fromVersion) return fromVersion;
99
+
100
+ const aggregate = await getCredentialRefresh(refreshId);
101
+ const fromAggregate = extractBoundedCredentialDisplayTitle(aggregate?.credentialDisplayName);
102
+
103
+ if (fromAggregate) return fromAggregate;
104
+
105
+ if (!aggregate?.boostId) return undefined;
106
+
107
+ const boost: BoostInstance | null = await getBoostById(aggregate.boostId);
108
+
109
+ return extractBoundedCredentialDisplayTitle(boost?.name);
110
+ };
111
+
112
+ /**
113
+ * Best-effort email delivery for one published version. Never throws: the caller
114
+ * uses the outcome for observability only, so email can neither undo a
115
+ * publication nor block the in-app notification path.
116
+ */
117
+ export const deliverCredentialRefreshEmailNotification = async (params: {
118
+ version: CredentialRefreshVersionNode;
119
+ issuerProfile: ProfileType;
120
+ holderProfile: ProfileType;
121
+ branding?: Partial<TenantBranding>;
122
+ }): Promise<CredentialRefreshEmailDeliveryOutcome> => {
123
+ const { version, issuerProfile, holderProfile, branding } = params;
124
+ const refreshId = version.refreshId;
125
+
126
+ try {
127
+ const target = await resolveCredentialRefreshEmailTarget(holderProfile);
128
+
129
+ if (target.status === 'skipped') return 'skipped-no-recipient';
130
+
131
+ // Replay the publication's original window, even if the caller retries
132
+ // tomorrow. Recomputing from the current clock would send duplicate mail.
133
+ const windowKey =
134
+ version.notificationDeliveryKey ??
135
+ computeCredentialRefreshDeliveryKey(
136
+ refreshId,
137
+ new Date(version.notificationCreatedAt ?? version.publishedAt)
138
+ );
139
+
140
+ // The email-delivery unique constraint is created on demand: the accept
141
+ // path can reach this helper without passing through the publish route
142
+ // middleware that normally ensures credential-refresh constraints.
143
+ await ensureCredentialRefreshConstraints();
144
+
145
+ const claim = await claimCredentialRefreshEmailDelivery({ refreshId, windowKey });
146
+
147
+ if (claim === 'duplicate') return 'duplicate-window';
148
+
149
+ try {
150
+ const credentialTitle = await resolveCredentialRefreshEmailDisplayTitle({
151
+ version,
152
+ refreshId,
153
+ });
154
+
155
+ await getDeliveryService(target.contactMethod).send({
156
+ contactMethod: target.contactMethod,
157
+ templateId: 'credential-updated',
158
+ templateModel: buildCredentialUpdatedEmailModel({
159
+ issuerDisplayName: issuerProfile.displayName,
160
+ credentialTitle,
161
+ }),
162
+ branding: environment.IS_OFFLINE
163
+ ? { ...branding, appUrl: new URL(generateClaimUrl('')).origin }
164
+ : branding,
165
+ locale: target.locale ?? resolveRecipientLocale(holderProfile),
166
+ messageStream: 'universal-inbox',
167
+ });
168
+
169
+ await finalizeCredentialRefreshEmailDelivery({
170
+ refreshId,
171
+ windowKey,
172
+ state: 'delivered',
173
+ });
174
+
175
+ return 'sent';
176
+ } catch (error) {
177
+ console.error('Credential Refresh Email - Failed to deliver update email:', error);
178
+
179
+ try {
180
+ await finalizeCredentialRefreshEmailDelivery({
181
+ refreshId,
182
+ windowKey,
183
+ state: 'failed',
184
+ });
185
+ } catch (recordError) {
186
+ console.error(
187
+ 'Credential Refresh Email - Failed to record delivery failure:',
188
+ recordError
189
+ );
190
+ }
191
+
192
+ return 'failed';
193
+ }
194
+ } catch (error) {
195
+ // Recipient resolution / claim setup failed. Nothing was sent and no window
196
+ // was claimed, so a later attempt in the same window may still succeed.
197
+ console.error('Credential Refresh Email - Delivery setup failed:', error);
198
+
199
+ return 'failed';
200
+ }
201
+ };
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { VC } from '@learncard/types';
3
+
4
+ import { verifyManagedRefreshProof } from './credential-refresh-proof.helpers';
5
+
6
+ const issuer = 'did:web:network.example:users:issuer';
7
+ const credential = {
8
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
9
+ type: ['VerifiableCredential'],
10
+ issuer,
11
+ credentialSubject: { id: 'did:key:holder' },
12
+ } as VC;
13
+ const valid = { checks: ['proof'], warnings: [], errors: [] };
14
+ const stale = { checks: [], warnings: [], errors: ['No applicable proof'] };
15
+ const makeVerifier = () => ({
16
+ verifyCredential: vi.fn().mockResolvedValue(valid),
17
+ resolveDid: vi.fn().mockResolvedValue({ id: issuer }),
18
+ });
19
+
20
+ describe('managed refresh proof verification', () => {
21
+ it('keeps the successful path to one proof check without refreshing the resolver', async () => {
22
+ const verifier = makeVerifier();
23
+ await verifyManagedRefreshProof(verifier, credential, issuer);
24
+ expect(verifier.verifyCredential).toHaveBeenCalledExactlyOnceWith(credential, {
25
+ checks: ['proof'],
26
+ });
27
+ expect(verifier.resolveDid).not.toHaveBeenCalled();
28
+ });
29
+
30
+ it('refreshes a stale local issuer document once and re-verifies the same credential', async () => {
31
+ const verifier = makeVerifier();
32
+ verifier.verifyCredential.mockResolvedValueOnce(stale).mockResolvedValueOnce(valid);
33
+ const original = JSON.stringify(credential);
34
+ await verifyManagedRefreshProof(verifier, credential, issuer);
35
+ expect(verifier.resolveDid).toHaveBeenCalledExactlyOnceWith(issuer, { noCache: true });
36
+ expect(verifier.verifyCredential).toHaveBeenCalledTimes(2);
37
+ expect(verifier.verifyCredential).toHaveBeenNthCalledWith(2, credential, {
38
+ checks: ['proof'],
39
+ });
40
+ expect(verifier.resolveDid.mock.invocationCallOrder[0]).toBeLessThan(
41
+ verifier.verifyCredential.mock.invocationCallOrder[1]!
42
+ );
43
+ expect(JSON.stringify(credential)).toBe(original);
44
+ });
45
+
46
+ it.each([
47
+ stale,
48
+ { checks: ['proof'], warnings: ['untrusted proof'], errors: [] },
49
+ { checks: [], warnings: [], errors: [] },
50
+ { checks: ['proof'], warnings: [], errors: ['invalid signature'] },
51
+ ])('still rejects an invalid result after fresh resolution: %j', async failure => {
52
+ const verifier = makeVerifier();
53
+ verifier.verifyCredential.mockResolvedValueOnce(stale).mockResolvedValueOnce(failure);
54
+ await expect(verifyManagedRefreshProof(verifier, credential, issuer)).rejects.toMatchObject(
55
+ {
56
+ code: 'BAD_REQUEST',
57
+ }
58
+ );
59
+ expect(verifier.verifyCredential).toHaveBeenCalledTimes(2);
60
+ expect(verifier.resolveDid).toHaveBeenCalledTimes(1);
61
+ });
62
+
63
+ it.each(['did:key:issuer', 'did:web:elsewhere.example:users:issuer', `${issuer}-other`])(
64
+ 'does not force-resolve an issuer other than the exact local profile: %s',
65
+ async other => {
66
+ const verifier = makeVerifier();
67
+ verifier.verifyCredential.mockResolvedValue(stale);
68
+ await expect(
69
+ verifyManagedRefreshProof(verifier, { ...credential, issuer: other }, issuer)
70
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
71
+ expect(verifier.resolveDid).not.toHaveBeenCalled();
72
+ expect(verifier.verifyCredential).toHaveBeenCalledTimes(1);
73
+ }
74
+ );
75
+
76
+ it.each([false, true])('maps verifier exceptions consistently (retry: %s)', async retry => {
77
+ const verifier = makeVerifier();
78
+ const cause = new Error('DIDKit verification failed');
79
+ if (retry) verifier.verifyCredential.mockResolvedValueOnce(stale);
80
+ verifier.verifyCredential.mockRejectedValueOnce(cause);
81
+ await expect(verifyManagedRefreshProof(verifier, credential, issuer)).rejects.toMatchObject(
82
+ {
83
+ code: 'BAD_REQUEST',
84
+ message: 'Credential proof could not be verified',
85
+ cause,
86
+ }
87
+ );
88
+ expect(verifier.verifyCredential).toHaveBeenCalledTimes(retry ? 2 : 1);
89
+ expect(verifier.resolveDid).toHaveBeenCalledTimes(retry ? 1 : 0);
90
+ });
91
+
92
+ it('fails closed when the authoritative document cannot be fetched', async () => {
93
+ const verifier = makeVerifier();
94
+ verifier.verifyCredential.mockResolvedValue(stale);
95
+ verifier.resolveDid.mockRejectedValue(new Error('network unavailable'));
96
+ await expect(verifyManagedRefreshProof(verifier, credential, issuer)).rejects.toMatchObject(
97
+ {
98
+ code: 'BAD_REQUEST',
99
+ }
100
+ );
101
+ expect(verifier.verifyCredential).toHaveBeenCalledTimes(1);
102
+ });
103
+ });