@learncard/network-brain-service 3.19.1 → 4.0.1

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 (315) hide show
  1. package/.env.example +44 -111
  2. package/AGENTS.md +52 -0
  3. package/CHANGELOG.md +120 -0
  4. package/README.md +9 -2
  5. package/bootstrapLambda.test.ts +72 -0
  6. package/bundle.mjs +9 -1
  7. package/config/config.dev.json +29 -0
  8. package/config/config.example.json +29 -0
  9. package/config/config.json +1 -0
  10. package/config/config.production.json +29 -0
  11. package/config/config.scouts.dev.json +24 -0
  12. package/config/config.scouts.production.json +24 -0
  13. package/contractEventsLambda.ts +18 -0
  14. package/contractEventsLambdaApp.ts +19 -0
  15. package/didWebLambda.ts +13 -18
  16. package/didWebLambdaApp.ts +22 -0
  17. package/dist/accesslayer/app-manifest-version/create.d.ts +10 -0
  18. package/dist/accesslayer/app-manifest-version/create.d.ts.map +1 -0
  19. package/dist/accesslayer/app-manifest-version/read.d.ts +11 -0
  20. package/dist/accesslayer/app-manifest-version/read.d.ts.map +1 -0
  21. package/dist/accesslayer/app-manifest-version/update.d.ts +7 -0
  22. package/dist/accesslayer/app-manifest-version/update.d.ts.map +1 -0
  23. package/dist/accesslayer/app-store-listing/read.d.ts +2 -1
  24. package/dist/accesslayer/app-store-listing/read.d.ts.map +1 -1
  25. package/dist/accesslayer/app-store-listing/relationships/create.d.ts +1 -0
  26. package/dist/accesslayer/app-store-listing/relationships/create.d.ts.map +1 -1
  27. package/dist/accesslayer/app-store-listing/relationships/read.d.ts +2 -0
  28. package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
  29. package/dist/accesslayer/app-store-listing/update.d.ts +1 -0
  30. package/dist/accesslayer/app-store-listing/update.d.ts.map +1 -1
  31. package/dist/accesslayer/boost/relationships/create.d.ts +2 -1
  32. package/dist/accesslayer/boost/relationships/create.d.ts.map +1 -1
  33. package/dist/accesslayer/consentflowcontract/create.d.ts +9 -1
  34. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  35. package/dist/accesslayer/consentflowcontract/read.d.ts +34 -3
  36. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  37. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +4 -1
  38. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  39. package/dist/accesslayer/consentflowcontract/relationships/delete.d.ts.map +1 -1
  40. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +3 -2
  41. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  42. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts +12 -0
  43. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts.map +1 -0
  44. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts +12 -0
  45. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts.map +1 -0
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +8 -5
  47. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  48. package/dist/accesslayer/credential/create.d.ts +2 -1
  49. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  50. package/dist/accesslayer/credential/relationships/create.d.ts +4 -3
  51. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  52. package/dist/accesslayer/inbox-credential/relationships/create.d.ts +2 -1
  53. package/dist/accesslayer/inbox-credential/relationships/create.d.ts.map +1 -1
  54. package/dist/accesslayer/inbox-credential/update.d.ts +2 -1
  55. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  56. package/dist/accesslayer/share-link/abandon.d.ts.map +1 -1
  57. package/dist/accesslayer/share-link/attachment.d.ts +37 -0
  58. package/dist/accesslayer/share-link/attachment.d.ts.map +1 -0
  59. package/dist/accesslayer/share-link/finalize.d.ts.map +1 -1
  60. package/dist/accesslayer/share-link/helpers.d.ts +3 -0
  61. package/dist/accesslayer/share-link/helpers.d.ts.map +1 -1
  62. package/dist/accesslayer/share-link/recovery.d.ts.map +1 -1
  63. package/dist/accesslayer/share-link/reserve.d.ts.map +1 -1
  64. package/dist/accesslayer/share-link/revoke.d.ts.map +1 -1
  65. package/dist/accesslayer/share-link/types.d.ts +8 -3
  66. package/dist/accesslayer/share-link/types.d.ts.map +1 -1
  67. package/dist/cache/app-store.caches.d.ts +49 -0
  68. package/dist/cache/app-store.caches.d.ts.map +1 -1
  69. package/dist/config/applyDockerStageConfig.d.ts +2 -0
  70. package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
  71. package/dist/config/environment.d.ts +5 -0
  72. package/dist/config/environment.d.ts.map +1 -1
  73. package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
  74. package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
  75. package/dist/config/stageConfig.d.ts +112 -0
  76. package/dist/config/stageConfig.d.ts.map +1 -0
  77. package/dist/config/stageConfig.test.d.ts +2 -0
  78. package/dist/config/stageConfig.test.d.ts.map +1 -0
  79. package/dist/didkit_wasm_bg.wasm +0 -0
  80. package/dist/docker-entry.d.ts +1 -1
  81. package/dist/docker-entry.d.ts.map +1 -1
  82. package/dist/helpers/app-manifest.helpers.d.ts +5 -0
  83. package/dist/helpers/app-manifest.helpers.d.ts.map +1 -0
  84. package/dist/helpers/claim-hooks.helpers.d.ts +2 -1
  85. package/dist/helpers/claim-hooks.helpers.d.ts.map +1 -1
  86. package/dist/helpers/consent-referral.helpers.d.ts +2 -0
  87. package/dist/helpers/consent-referral.helpers.d.ts.map +1 -0
  88. package/dist/helpers/consent-scopes.helpers.d.ts +7 -0
  89. package/dist/helpers/consent-scopes.helpers.d.ts.map +1 -0
  90. package/dist/helpers/contract-event-maintenance.helpers.d.ts +3 -0
  91. package/dist/helpers/contract-event-maintenance.helpers.d.ts.map +1 -0
  92. package/dist/helpers/contract-events.helpers.d.ts +25 -0
  93. package/dist/helpers/contract-events.helpers.d.ts.map +1 -0
  94. package/dist/helpers/contract-requests.helpers.d.ts +19 -0
  95. package/dist/helpers/contract-requests.helpers.d.ts.map +1 -0
  96. package/dist/helpers/finalize-inbox.helpers.d.ts +3 -3
  97. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  98. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  99. package/dist/helpers/inline-template.helpers.d.ts +45 -0
  100. package/dist/helpers/inline-template.helpers.d.ts.map +1 -0
  101. package/dist/helpers/notificationMessages.d.ts +1 -1
  102. package/dist/helpers/notificationMessages.d.ts.map +1 -1
  103. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  104. package/dist/helpers/notifications.helpers.d.ts +6 -0
  105. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  106. package/dist/helpers/profile.helpers.d.ts +1 -0
  107. package/dist/helpers/profile.helpers.d.ts.map +1 -1
  108. package/dist/helpers/share-link-attachment-access.d.ts +4 -0
  109. package/dist/helpers/share-link-attachment-access.d.ts.map +1 -0
  110. package/dist/helpers/share-link-attachment.d.ts +18 -0
  111. package/dist/helpers/share-link-attachment.d.ts.map +1 -0
  112. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts +5 -0
  113. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts.map +1 -0
  114. package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -1
  115. package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -1
  116. package/dist/helpers/share-link-maintenance/runner.d.ts +1 -0
  117. package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -1
  118. package/dist/helpers/share-link-maintenance/runtime.d.ts +2 -0
  119. package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -1
  120. package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -1
  121. package/dist/helpers/share-link-maintenance/types.d.ts +4 -1
  122. package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -1
  123. package/dist/helpers/share-link-owner-projection.d.ts.map +1 -1
  124. package/dist/helpers/share-link-policy/production.d.ts +3 -5
  125. package/dist/helpers/share-link-policy/production.d.ts.map +1 -1
  126. package/dist/helpers/share-link-policy/resolver.d.ts +1 -1
  127. package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -1
  128. package/dist/helpers/share-link-policy/types.d.ts +8 -2
  129. package/dist/helpers/share-link-policy/types.d.ts.map +1 -1
  130. package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
  131. package/dist/helpers/smartResume.helpers.d.ts +3 -0
  132. package/dist/helpers/smartResume.helpers.d.ts.map +1 -0
  133. package/dist/index.js +2 -2
  134. package/dist/models/AppManifestVersion.d.ts +20 -0
  135. package/dist/models/AppManifestVersion.d.ts.map +1 -0
  136. package/dist/models/AppStoreListing.d.ts +13 -0
  137. package/dist/models/AppStoreListing.d.ts.map +1 -1
  138. package/dist/models/ConsentFlowContract.d.ts +13 -2
  139. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  140. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  141. package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
  142. package/dist/models/Integration.d.ts +2 -0
  143. package/dist/models/Integration.d.ts.map +1 -1
  144. package/dist/models/ShareLink.d.ts +2 -0
  145. package/dist/models/ShareLink.d.ts.map +1 -1
  146. package/dist/models/index.d.ts +1 -0
  147. package/dist/models/index.d.ts.map +1 -1
  148. package/dist/models/share-link-constraints.d.ts.map +1 -1
  149. package/dist/openapi.d.ts +1 -1
  150. package/dist/openapi.d.ts.map +1 -1
  151. package/dist/publish-metafile.json +1 -0
  152. package/dist/routes/app-store.d.ts +619 -0
  153. package/dist/routes/app-store.d.ts.map +1 -1
  154. package/dist/routes/boosts.d.ts +2 -2
  155. package/dist/routes/boosts.d.ts.map +1 -1
  156. package/dist/routes/contracts.d.ts +146 -3
  157. package/dist/routes/contracts.d.ts.map +1 -1
  158. package/dist/routes/credentials.d.ts +34 -0
  159. package/dist/routes/credentials.d.ts.map +1 -1
  160. package/dist/routes/inbox.d.ts +52 -2
  161. package/dist/routes/inbox.d.ts.map +1 -1
  162. package/dist/routes/index.d.ts.map +1 -1
  163. package/dist/routes/profile-manager.d.ts.map +1 -1
  164. package/dist/routes/profiles.d.ts.map +1 -1
  165. package/dist/routes/public-share-links.d.ts +55 -0
  166. package/dist/routes/public-share-links.d.ts.map +1 -1
  167. package/dist/routes/share-links.d.ts +175 -14
  168. package/dist/routes/share-links.d.ts.map +1 -1
  169. package/dist/routes/test.d.ts +5 -0
  170. package/dist/routes/test.d.ts.map +1 -1
  171. package/dist/routes/workflows.d.ts.map +1 -1
  172. package/dist/types/app-manifest.d.ts +339 -0
  173. package/dist/types/app-manifest.d.ts.map +1 -0
  174. package/dist/types/app-store-listing.d.ts +34 -0
  175. package/dist/types/app-store-listing.d.ts.map +1 -1
  176. package/dist/types/consentflowcontract.d.ts +30 -3
  177. package/dist/types/consentflowcontract.d.ts.map +1 -1
  178. package/dist/types/credential-refresh.d.ts +1 -1
  179. package/esbuildPlugins.cjs +1 -0
  180. package/lambda.ts +49 -195
  181. package/lambdaApp.ts +203 -0
  182. package/package.json +20 -19
  183. package/project.json +8 -1
  184. package/scripts/export-openapi.ts +20 -0
  185. package/scripts/offline-schema-environment.ts +18 -0
  186. package/scripts/verify-python-contracts.ts +72 -0
  187. package/serverless-local.yml +3 -6
  188. package/serverless.function-env.cjs +50 -0
  189. package/serverless.local.yml +0 -1
  190. package/serverless.yml +29 -74
  191. package/shareLinkMaintenanceLambda.ts +13 -44
  192. package/shareLinkMaintenanceLambdaApp.ts +50 -0
  193. package/src/accesslayer/app-manifest-version/create.ts +57 -0
  194. package/src/accesslayer/app-manifest-version/read.ts +129 -0
  195. package/src/accesslayer/app-manifest-version/update.ts +61 -0
  196. package/src/accesslayer/app-store-listing/read.ts +6 -0
  197. package/src/accesslayer/app-store-listing/relationships/create.ts +20 -0
  198. package/src/accesslayer/app-store-listing/relationships/read.ts +24 -1
  199. package/src/accesslayer/app-store-listing/update.ts +15 -0
  200. package/src/accesslayer/boost/relationships/create.ts +8 -2
  201. package/src/accesslayer/consentflowcontract/create.ts +81 -8
  202. package/src/accesslayer/consentflowcontract/read.ts +84 -10
  203. package/src/accesslayer/consentflowcontract/relationships/create.ts +116 -93
  204. package/src/accesslayer/consentflowcontract/relationships/delete.ts +1 -0
  205. package/src/accesslayer/consentflowcontract/relationships/read.ts +184 -108
  206. package/src/accesslayer/consentflowcontract/relationships/recipients.ts +125 -0
  207. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +125 -0
  208. package/src/accesslayer/consentflowcontract/relationships/update.ts +240 -189
  209. package/src/accesslayer/credential/create.test.ts +2 -115
  210. package/src/accesslayer/credential/create.ts +11 -6
  211. package/src/accesslayer/credential/relationships/create.ts +17 -10
  212. package/src/accesslayer/inbox-credential/relationships/create.ts +70 -48
  213. package/src/accesslayer/inbox-credential/update.ts +4 -2
  214. package/src/accesslayer/share-link/abandon.ts +20 -0
  215. package/src/accesslayer/share-link/attachment.ts +360 -0
  216. package/src/accesslayer/share-link/finalize.ts +55 -7
  217. package/src/accesslayer/share-link/helpers.ts +33 -2
  218. package/src/accesslayer/share-link/recovery.ts +20 -0
  219. package/src/accesslayer/share-link/reserve.ts +85 -6
  220. package/src/accesslayer/share-link/revoke.ts +35 -0
  221. package/src/accesslayer/share-link/types.ts +9 -4
  222. package/src/cache/app-store.caches.ts +35 -0
  223. package/src/config/applyDockerStageConfig.ts +6 -0
  224. package/src/config/environment.test.ts +17 -12
  225. package/src/config/environment.ts +1 -0
  226. package/src/config/serverlessFunctionEnv.test.ts +105 -0
  227. package/src/config/stageConfig.test.ts +25 -0
  228. package/src/config/stageConfig.ts +24 -0
  229. package/src/docker-entry.ts +24 -0
  230. package/src/helpers/app-manifest.helpers.ts +185 -0
  231. package/src/helpers/claim-hooks.helpers.ts +20 -7
  232. package/src/helpers/consent-referral.helpers.ts +6 -0
  233. package/src/helpers/consent-scopes.helpers.ts +58 -0
  234. package/src/helpers/contract-event-maintenance.helpers.ts +43 -0
  235. package/src/helpers/contract-events.helpers.ts +540 -0
  236. package/src/helpers/contract-requests.helpers.ts +211 -0
  237. package/src/helpers/finalize-inbox.helpers.ts +163 -44
  238. package/src/helpers/inbox.helpers.ts +51 -66
  239. package/src/helpers/inline-template.helpers.ts +560 -0
  240. package/src/helpers/notificationMessages.ts +59 -0
  241. package/src/helpers/notificationQueue.helpers.ts +23 -5
  242. package/src/helpers/notifications.helpers.ts +25 -1
  243. package/src/helpers/profile.helpers.ts +6 -0
  244. package/src/helpers/share-link-attachment-access.ts +87 -0
  245. package/src/helpers/share-link-attachment.ts +115 -0
  246. package/src/helpers/share-link-coordinator/attachment-stat.ts +41 -0
  247. package/src/helpers/share-link-coordinator/coordinator.ts +15 -2
  248. package/src/helpers/share-link-coordinator/recovery-runner.ts +15 -0
  249. package/src/helpers/share-link-maintenance/runner.ts +28 -0
  250. package/src/helpers/share-link-maintenance/runtime.ts +6 -0
  251. package/src/helpers/share-link-maintenance/telemetry.ts +3 -0
  252. package/src/helpers/share-link-maintenance/types.ts +2 -0
  253. package/src/helpers/share-link-owner-projection.ts +2 -0
  254. package/src/helpers/share-link-policy/production.ts +37 -17
  255. package/src/helpers/share-link-policy/resolver.ts +16 -11
  256. package/src/helpers/share-link-policy/types.ts +15 -7
  257. package/src/helpers/signingAuthority.helpers.ts +23 -14
  258. package/src/helpers/smartResume.helpers.ts +123 -0
  259. package/src/models/AppManifestVersion.ts +43 -0
  260. package/src/models/AppStoreListing.ts +32 -0
  261. package/src/models/ConsentFlowContract.ts +34 -3
  262. package/src/models/ConsentFlowTerms.ts +10 -0
  263. package/src/models/ConsentFlowTransaction.ts +3 -0
  264. package/src/models/Integration.ts +2 -0
  265. package/src/models/ShareLink.ts +4 -0
  266. package/src/models/index.ts +23 -0
  267. package/src/models/share-link-constraints.ts +5 -0
  268. package/src/openapi.ts +44 -8
  269. package/src/routes/app-store.ts +1054 -27
  270. package/src/routes/boosts.ts +12 -14
  271. package/src/routes/contracts.ts +382 -262
  272. package/src/routes/inbox.ts +4 -2
  273. package/src/routes/index.ts +4 -1
  274. package/src/routes/profile-manager.ts +19 -0
  275. package/src/routes/profiles.ts +12 -0
  276. package/src/routes/public-share-links.ts +169 -0
  277. package/src/routes/share-links.ts +69 -0
  278. package/src/routes/workflows.ts +3 -2
  279. package/src/types/app-manifest.ts +28 -0
  280. package/src/types/app-store-listing.ts +13 -0
  281. package/src/types/consentflowcontract.ts +25 -4
  282. package/test/app-manifest.helpers.spec.ts +170 -0
  283. package/test/app-manifest.routes.spec.ts +299 -0
  284. package/test/app-store-listing-updates.spec.ts +236 -0
  285. package/test/app-store-listing.spec.ts +65 -3
  286. package/test/consent-data-boundaries.spec.ts +352 -0
  287. package/test/consent-recipients.spec.ts +513 -0
  288. package/test/consent-scopes.helpers.spec.ts +67 -0
  289. package/test/consentflow.spec.ts +63 -2
  290. package/test/contract-requests.spec.ts +1588 -0
  291. package/test/credential-storage.neo4j.spec.ts +122 -0
  292. package/test/guardian-gated-routes.spec.ts +36 -0
  293. package/test/inbox-security.e2e.ts +26 -10
  294. package/test/inbox.spec.ts +184 -8
  295. package/test/inline-template.helpers.spec.ts +49 -0
  296. package/test/managed-recipient-inbox.spec.ts +24 -2
  297. package/test/openapi.spec.ts +14 -73
  298. package/test/profiles.spec.ts +30 -0
  299. package/test/share-link-attachment.neo4j.spec.ts +262 -0
  300. package/test/share-link-attachment.unit.spec.ts +244 -0
  301. package/test/share-link-lifecycle.neo4j.spec.ts +145 -2
  302. package/test/share-link-maintenance.unit.spec.ts +32 -5
  303. package/test/share-link-owner-route.unit.spec.ts +70 -2
  304. package/test/share-link-policy.unit.spec.ts +18 -4
  305. package/test/share-link-production-policy.unit.spec.ts +58 -9
  306. package/test/share-link-public-route.unit.spec.ts +279 -0
  307. package/test/share-link-service-policy.neo4j.spec.ts +403 -0
  308. package/test/signing-authority-encryption.spec.ts +75 -0
  309. package/test/smart-resume-consent.spec.ts +233 -0
  310. package/test/smart-resume-upload.spec.ts +65 -0
  311. package/tsconfig.build.json +1 -1
  312. package/tsconfig.json +1 -0
  313. package/vitest.config.ts +13 -0
  314. package/vitest.inbox-e2e.config.ts +2 -0
  315. package/workerLambda.test.ts +100 -0
@@ -0,0 +1,122 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { VCValidator, type BitstringStatusListEntry } from '@learncard/types';
3
+ import { Credential, Profile, StatusList } from '@models';
4
+ import { storeCredential } from '../src/accesslayer/credential/create';
5
+ import { getCredentialById } from '../src/accesslayer/credential/read';
6
+ import { setCredentialBitstringStatus, decodeBitstring } from '../src/helpers/status-list.helpers';
7
+ import { getUser } from './helpers/getClient';
8
+ import { app as statusListsApp } from '../src/status-lists';
9
+
10
+ let issuer: Awaited<ReturnType<typeof getUser>>;
11
+ const credentialIds: string[] = [];
12
+
13
+ const isStatusBitSet = async (entry: BitstringStatusListEntry): Promise<boolean> => {
14
+ const id = entry.statusListCredential.split('/').pop();
15
+ if (!id) throw new Error('Missing status list id');
16
+ const response = await statusListsApp.inject({ method: 'GET', url: `/status-lists/${id}` });
17
+ expect(response.statusCode).toBe(200);
18
+ const list = JSON.parse(response.body);
19
+ const bytes = decodeBitstring(list.credentialSubject.encodedList, 131_072);
20
+ const index = Number(entry.statusListIndex);
21
+ return ((bytes[Math.floor(index / 8)] ?? 0) & (1 << (index % 8))) !== 0;
22
+ };
23
+
24
+ describe('credential status storage in Neo4j', () => {
25
+ beforeAll(async () => {
26
+ issuer = await getUser();
27
+ await issuer.clients.fullAuth.profile.createProfile({ profileId: 'storage-issuer' });
28
+ await statusListsApp.ready();
29
+ });
30
+ afterAll(async () => {
31
+ for (const id of credentialIds) await Credential.delete({ where: { id } });
32
+ await Profile.delete({ detach: true, where: { profileId: 'storage-issuer' } });
33
+ await StatusList.delete({ detach: true, where: { ownerProfileId: 'storage-issuer' } });
34
+ });
35
+
36
+ it('persists trusted public status descriptors for revocation consumers without decrypting the payload', async () => {
37
+ const [entry] = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
38
+ statusPurposes: ['revocation'],
39
+ });
40
+ if (!entry) throw new Error('Expected allocated revocation entry');
41
+ const signed = await issuer.learnCard.invoke.issueCredential({
42
+ ...(await issuer.learnCard.invoke.getTestVc()),
43
+ credentialStatus: entry,
44
+ });
45
+ const jwe = await issuer.learnCard.invoke.createDagJwe(signed, [issuer.learnCard.id.did()]);
46
+ const stored = await storeCredential({
47
+ kind: 'issued-credential',
48
+ credential: jwe,
49
+ statusEntries: [entry],
50
+ });
51
+ credentialIds.push(stored.id);
52
+
53
+ const row = await getCredentialById(stored.id);
54
+ expect(row?.credential).toBe(JSON.stringify(jwe));
55
+ expect(JSON.parse(row?.statusEntries ?? 'null')).toEqual([entry]);
56
+ expect(await isStatusBitSet(entry)).toBe(false);
57
+ expect(await setCredentialBitstringStatus(stored.id, 'revocation', true)).toBe(true);
58
+ expect(await isStatusBitSet(entry)).toBe(true);
59
+ });
60
+
61
+ it.each(['embedded', 'absent'] as const)(
62
+ 'preserves a valid signed wire VC and proof with %s status',
63
+ async statusCase => {
64
+ const unsigned = await issuer.learnCard.invoke.getTestVc();
65
+ if (statusCase === 'embedded') {
66
+ const [entry] = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
67
+ statusPurposes: ['revocation'],
68
+ });
69
+ unsigned.credentialStatus = entry;
70
+ }
71
+ const signed = await issuer.learnCard.invoke.issueCredential(unsigned);
72
+ const stored = await storeCredential(signed);
73
+ credentialIds.push(stored.id);
74
+ const row = await getCredentialById(stored.id);
75
+ const recovered = VCValidator.parse(JSON.parse(row?.credential ?? 'null'));
76
+ expect(recovered).toEqual(signed);
77
+ expect(
78
+ await issuer.learnCard.invoke.verifyCredential(recovered, { checks: ['proof'] })
79
+ ).toMatchObject({
80
+ checks: expect.arrayContaining(['proof']),
81
+ warnings: [],
82
+ errors: [],
83
+ });
84
+ }
85
+ );
86
+
87
+ it('does not promote wire JWE metadata to trusted status descriptors', async () => {
88
+ const [entry] = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
89
+ statusPurposes: ['revocation'],
90
+ });
91
+ if (!entry) throw new Error('Expected allocated revocation entry');
92
+ const signed = await issuer.learnCard.invoke.issueCredential({
93
+ ...(await issuer.learnCard.invoke.getTestVc()),
94
+ credentialStatus: entry,
95
+ });
96
+ const jwe = await issuer.learnCard.invoke.createDagJwe(signed, [issuer.learnCard.id.did()]);
97
+ const wire = { ...jwe, kind: 'issued-credential', statusEntries: [entry] };
98
+ const stored = await storeCredential(wire);
99
+ credentialIds.push(stored.id);
100
+ const row = await getCredentialById(stored.id);
101
+ expect(row?.credential).toBe(JSON.stringify(wire));
102
+ expect(row?.statusEntries).toBeUndefined();
103
+ expect(await setCredentialBitstringStatus(stored.id, 'revocation', true)).toBe(false);
104
+ expect(await isStatusBitSet(entry)).toBe(false);
105
+ });
106
+
107
+ it('does not persist or apply an empty trusted status descriptor set', async () => {
108
+ const signed = await issuer.learnCard.invoke.issueCredential(
109
+ await issuer.learnCard.invoke.getTestVc()
110
+ );
111
+ const jwe = await issuer.learnCard.invoke.createDagJwe(signed, [issuer.learnCard.id.did()]);
112
+ const stored = await storeCredential({
113
+ kind: 'issued-credential',
114
+ credential: jwe,
115
+ statusEntries: [],
116
+ });
117
+ credentialIds.push(stored.id);
118
+ const row = await getCredentialById(stored.id);
119
+ expect(row?.statusEntries).toBeUndefined();
120
+ expect(await setCredentialBitstringStatus(stored.id, 'revocation', true)).toBe(false);
121
+ });
122
+ });
@@ -216,6 +216,21 @@ describe('Guardian-approved consent mutations', () => {
216
216
  expect(record?.terms.guardianApproval).toBeUndefined();
217
217
  });
218
218
 
219
+ it('preserves the manager-based gate for explicitly typed profiles without managers', async () => {
220
+ await Profile.update(
221
+ { type: 'child', isServiceProfile: true },
222
+ { where: { profileId: 'child-user' } }
223
+ );
224
+
225
+ const { termsUri } = await childCaller().contracts.consentToContract({
226
+ contractUri,
227
+ terms: minimalTerms,
228
+ });
229
+ const record = await getContractTermsByUri(termsUri);
230
+ expect(record?.terms.status).toBe('live');
231
+ expect(record?.terms.guardianApproval).toBeUndefined();
232
+ });
233
+
219
234
  it('does not create consent or an audit transaction without guardian approval', async () => {
220
235
  await manageChild();
221
236
  await expect(
@@ -238,6 +253,27 @@ describe('Guardian-approved consent mutations', () => {
238
253
  expect(record?.terms.guardianApproval).toBeUndefined();
239
254
  });
240
255
 
256
+ it('requires guardian approval for legacy service-flagged children', async () => {
257
+ await Profile.update(
258
+ { type: 'child', isServiceProfile: true },
259
+ { where: { profileId: 'child-user' } }
260
+ );
261
+ await manageChild();
262
+
263
+ await expect(
264
+ childCaller().contracts.consentToContract({ contractUri, terms: minimalTerms })
265
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
266
+
267
+ const { termsUri } = await childCaller(await approve()).contracts.consentToContract({
268
+ contractUri,
269
+ terms: minimalTerms,
270
+ });
271
+ expect((await getContractTermsByUri(termsUri))?.terms.guardianApproval).toMatchObject({
272
+ guardianProfileId: 'guardian-user',
273
+ guardianDid: guardian.id.did(),
274
+ });
275
+ });
276
+
241
277
  it('records the verified guardian and contract version on consent and permission updates', async () => {
242
278
  await manageChild();
243
279
  const beforeApproval = Date.now();
@@ -3,7 +3,12 @@ import Fastify, { type FastifyInstance } from 'fastify';
3
3
  import { fastifyTRPCOpenApiPlugin } from 'trpc-to-openapi';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { randomUUID } from 'node:crypto';
6
- import type { IssueInboxCredentialType, UnsignedVC, VC } from '@learncard/types';
6
+ import {
7
+ JWEValidator,
8
+ type IssueInboxCredentialType,
9
+ type UnsignedVC,
10
+ type VC,
11
+ } from '@learncard/types';
7
12
  import { getBitstringStatusListEntries } from '@learncard/helpers';
8
13
 
9
14
  import { appRouter, createContext } from '../src/app';
@@ -380,7 +385,8 @@ describe('Universal Inbox escrow (HTTP + isolated Neo4j/Redis)', () => {
380
385
  expect(result.deliveries).toEqual([
381
386
  { id: issued.issuanceId, credential: result.verifiableCredentials[0] },
382
387
  ]);
383
- expect(result.verifiableCredentials[0]).toMatchObject({
388
+ const encrypted = JWEValidator.parse(result.verifiableCredentials[0]);
389
+ expect(await recipient.learnCard.invoke.decryptDagJwe(encrypted)).toMatchObject({
384
390
  type: ['VerifiableCredential', 'ClrCredential'],
385
391
  proof: expect.anything(),
386
392
  credentialSubject: {
@@ -397,7 +403,7 @@ describe('Universal Inbox escrow (HTTP + isolated Neo4j/Redis)', () => {
397
403
  });
398
404
 
399
405
  it.each(['finalize', 'claim link', 'claim without profile'] as const)(
400
- 'recovers exactly one holder-only delivery after discarding the %s response',
406
+ 'recovers exactly one authorized delivery after discarding the %s response',
401
407
  async path => {
402
408
  const credential = await signedCredential();
403
409
  const issued = await issue({
@@ -439,11 +445,11 @@ describe('Universal Inbox escrow (HTTP + isolated Neo4j/Redis)', () => {
439
445
  expect(
440
446
  (await service.invoke.decryptDagJwe(recovery.credential).catch(() => null)) || null
441
447
  ).toBeNull();
442
- expect(
448
+ const issuerRead =
443
449
  (await issuer.learnCard.invoke
444
450
  .decryptDagJwe(recovery.credential)
445
- .catch(() => null)) || null
446
- ).toBeNull();
451
+ .catch(() => null)) || null;
452
+ expect(issuerRead).toEqual(path === 'finalize' ? credential : null);
447
453
  expect((await post('/api/inbox/deliveries', {})).status).toBe(401);
448
454
  expect(
449
455
  (await (await post('/api/inbox/deliveries', {}, issuer)).json()).records
@@ -510,9 +516,14 @@ describe('Universal Inbox escrow (HTTP + isolated Neo4j/Redis)', () => {
510
516
  await verifyRecipientContact('idless@example.test');
511
517
  const response = await post('/api/inbox/finalize', {}, recipient);
512
518
  expect(response.status).toBe(200);
513
- expect((await response.json()).deliveries).toEqual([
514
- { id: issued.issuanceId, credential },
515
- ]);
519
+ const { deliveries } = await response.json();
520
+ expect(deliveries).toHaveLength(1);
521
+ expect(deliveries[0].id).toBe(issued.issuanceId);
522
+ expect(
523
+ await recipient.learnCard.invoke.decryptDagJwe(
524
+ JWEValidator.parse(deliveries[0].credential)
525
+ )
526
+ ).toEqual(credential);
516
527
  } else {
517
528
  expect(await claim(issued.claimUrl!)).toEqual([credential]);
518
529
  }
@@ -893,7 +904,12 @@ describe('Universal Inbox escrow (HTTP + isolated Neo4j/Redis)', () => {
893
904
  expect(config).toMatch(
894
905
  /inboxMaintenance:\s+handler: lambda\.inboxMaintenanceHandler[\s\S]*?rate: rate\(1 day\)\s+enabled: true/
895
906
  );
896
- const handler = await readFile(new URL('../lambda.ts', import.meta.url), 'utf8');
907
+ // lambda.ts is a thin bootstrap entry that delegates to the lazily imported lambdaApp.ts.
908
+ const entry = await readFile(new URL('../lambda.ts', import.meta.url), 'utf8');
909
+ expect(entry).toMatch(
910
+ /export const inboxMaintenanceHandler[\s\S]*?\(await getApplication\(\)\)\.inboxMaintenanceHandler\(/
911
+ );
912
+ const handler = await readFile(new URL('../lambdaApp.ts', import.meta.url), 'utf8');
897
913
  expect(handler).toMatch(/inboxMaintenanceHandler[\s\S]*?await runInboxMaintenance\(\)/);
898
914
  });
899
915
  });
@@ -8,6 +8,7 @@ import {
8
8
  VC,
9
9
  VP,
10
10
  JWE,
11
+ JWEValidator,
11
12
  } from '@learncard/types';
12
13
  import { sendSpy, addNotificationToQueueSpy } from './helpers/spies';
13
14
  import * as notifications from '@helpers/notifications.helpers';
@@ -16,6 +17,9 @@ import { getProfileByProfileId } from '@accesslayer/profile/read';
16
17
  import { areProfilesConnected } from '@helpers/connection.helpers';
17
18
  import { updateInboxCredential } from '@accesslayer/inbox-credential/update';
18
19
  import { neogma } from '@instance';
20
+ import { Credential } from '@models';
21
+ import { getInboxCredentialById } from '@accesslayer/inbox-credential/read';
22
+ import * as credentialRelationships from '@accesslayer/credential/relationships/create';
19
23
 
20
24
  const noAuthClient = getClient();
21
25
  let userA: Awaited<ReturnType<typeof getUser>>;
@@ -1832,6 +1836,174 @@ describe('Universal Inbox', () => {
1832
1836
  await SigningAuthority.delete({ detach: true, where: {} });
1833
1837
  });
1834
1838
 
1839
+ const preparePendingBoost = async (email: string) => {
1840
+ await Profile.relateTo({
1841
+ alias: 'usesSigningAuthority',
1842
+ where: {
1843
+ source: { profileId: 'usera' },
1844
+ target: { endpoint: 'https://example.com' },
1845
+ },
1846
+ properties: { name: 'example', did: userA.learnCard.id.did() },
1847
+ });
1848
+ const boostUri = await userA.clients.fullAuth.boost.createBoost({
1849
+ credential: testUnsignedBoost,
1850
+ claimPermissions: { canRevoke: true },
1851
+ });
1852
+ const issued = await userA.clients.fullAuth.inbox.issue({
1853
+ templateUri: boostUri,
1854
+ recipient: { type: 'email', value: email },
1855
+ configuration: {
1856
+ signingAuthority: { endpoint: 'https://example.com', name: 'example' },
1857
+ webhookUrl: 'https://example.com/finalized',
1858
+ },
1859
+ });
1860
+ await updateInboxCredential(issued.issuanceId, { isAccepted: true });
1861
+ await userB.clients.fullAuth.contactMethods.addContactMethod({
1862
+ type: 'email',
1863
+ value: email,
1864
+ });
1865
+ await userB.clients.fullAuth.contactMethods.verifyContactMethod({
1866
+ token: sendSpy.mock.calls.at(-1)?.[0].templateModel.verificationToken,
1867
+ });
1868
+ addNotificationToQueueSpy.mockClear();
1869
+ return { boostUri, inboxId: issued.issuanceId };
1870
+ };
1871
+
1872
+ it('rolls back a failed received write and completes all delivery tracking on retry', async () => {
1873
+ const { boostUri, inboxId } = await preparePendingBoost('atomic-receipt@test.com');
1874
+ const escrow = (await getInboxCredentialById(inboxId))!.credential;
1875
+ const originalCredentialIds = (await Credential.findMany()).map(record => record.id);
1876
+ const failedWrite = vi
1877
+ .spyOn(credentialRelationships, 'createReceivedCredentialRelationship')
1878
+ .mockRejectedValueOnce(new Error('injected received write failure'));
1879
+ try {
1880
+ await expect(userB.clients.fullAuth.inbox.finalize({})).resolves.toMatchObject({
1881
+ claimed: 0,
1882
+ errors: 1,
1883
+ deliveries: [],
1884
+ });
1885
+ } finally {
1886
+ failedWrite.mockRestore();
1887
+ }
1888
+ expect(await getInboxCredentialById(inboxId)).toMatchObject({
1889
+ currentStatus: 'PENDING',
1890
+ credential: escrow,
1891
+ });
1892
+ expect((await Credential.findMany()).map(record => record.id).sort()).toEqual(
1893
+ originalCredentialIds.sort()
1894
+ );
1895
+ expect(await userB.clients.fullAuth.credential.receivedCredentials({})).toEqual([]);
1896
+ const auditBeforeRetry = await neogma.queryRunner.run(
1897
+ 'MATCH ()-[r:CLAIMED_INBOX_CREDENTIAL]->(ic:InboxCredential {id: $id}) RETURN count(r) AS count',
1898
+ { id: inboxId }
1899
+ );
1900
+ expect(auditBeforeRetry.records[0].get('count').toNumber()).toBe(0);
1901
+ expect(
1902
+ addNotificationToQueueSpy.mock.calls.some(
1903
+ ([notification]) =>
1904
+ notification.type ===
1905
+ LCNNotificationTypeEnumValidator.enum.ISSUANCE_ERROR &&
1906
+ notification.data?.inbox?.status === 'PENDING'
1907
+ )
1908
+ ).toBe(true);
1909
+ addNotificationToQueueSpy.mockClear();
1910
+
1911
+ const retry = await userB.clients.fullAuth.inbox.finalize({});
1912
+ expect(retry).toMatchObject({ claimed: 1, errors: 0, deliveries: [{ id: inboxId }] });
1913
+ const finalized = await getInboxCredentialById(inboxId);
1914
+ expect(finalized?.currentStatus).toBe('ISSUED');
1915
+ expect(finalized?.credential).toBeUndefined();
1916
+ expect(
1917
+ await userA.clients.fullAuth.boost.getBoostRecipients({
1918
+ uri: boostUri,
1919
+ includeUnacceptedBoosts: false,
1920
+ })
1921
+ ).toHaveLength(1);
1922
+ expect(await userB.clients.fullAuth.credential.receivedCredentials({})).toHaveLength(1);
1923
+ const auditAfterRetry = await neogma.queryRunner.run(
1924
+ 'MATCH ()-[r:CLAIMED_INBOX_CREDENTIAL]->(ic:InboxCredential {id: $id}) RETURN count(r) AS count',
1925
+ { id: inboxId }
1926
+ );
1927
+ expect(auditAfterRetry.records[0].get('count').toNumber()).toBe(1);
1928
+ expect(
1929
+ addNotificationToQueueSpy.mock.calls.some(
1930
+ ([notification]) =>
1931
+ notification.type === LCNNotificationTypeEnumValidator.enum.ISSUANCE_ERROR
1932
+ )
1933
+ ).toBe(false);
1934
+ });
1935
+
1936
+ it('rolls back partial claim permissions and grants hooks and the default role on retry', async () => {
1937
+ const { boostUri, inboxId } = await preparePendingBoost('atomic-permissions@test.com');
1938
+ const targetUri = await userA.clients.fullAuth.boost.createBoost({
1939
+ credential: testUnsignedBoost,
1940
+ });
1941
+ await userA.clients.fullAuth.claimHook.createClaimHook({
1942
+ hook: {
1943
+ type: 'GRANT_PERMISSIONS',
1944
+ data: { claimUri: boostUri, targetUri, permissions: { canIssue: true } },
1945
+ },
1946
+ });
1947
+ const failedRole = vi
1948
+ .spyOn(credentialRelationships, 'setDefaultClaimedRole')
1949
+ .mockRejectedValueOnce(new Error('injected claimed-role failure'));
1950
+ try {
1951
+ await expect(userB.clients.fullAuth.inbox.finalize({})).resolves.toMatchObject({
1952
+ claimed: 0,
1953
+ errors: 1,
1954
+ });
1955
+ } finally {
1956
+ failedRole.mockRestore();
1957
+ }
1958
+ expect(await getInboxCredentialById(inboxId)).toMatchObject({
1959
+ currentStatus: 'PENDING',
1960
+ });
1961
+ expect(
1962
+ (await userB.clients.fullAuth.boost.getBoostPermissions({ uri: targetUri }))
1963
+ .canIssue
1964
+ ).toBe(false);
1965
+ expect(await userB.clients.fullAuth.credential.receivedCredentials({})).toEqual([]);
1966
+
1967
+ await expect(userB.clients.fullAuth.inbox.finalize({})).resolves.toMatchObject({
1968
+ claimed: 1,
1969
+ errors: 0,
1970
+ });
1971
+ expect(
1972
+ (await userB.clients.fullAuth.boost.getBoostPermissions({ uri: targetUri }))
1973
+ .canIssue
1974
+ ).toBe(true);
1975
+ expect(
1976
+ (await userB.clients.fullAuth.boost.getBoostPermissions({ uri: boostUri }))
1977
+ .canRevoke
1978
+ ).toBe(true);
1979
+ const [received] = await userB.clients.fullAuth.credential.receivedCredentials({});
1980
+ expect(
1981
+ await userB.clients.fullAuth.credential.acceptCredential({ uri: received.uri })
1982
+ ).toBe(true);
1983
+ const roleCounts = await neogma.queryRunner.run(
1984
+ 'MATCH (:Profile {profileId: "userb"})-[r:HAS_ROLE]->(b:Boost) WHERE b.id IN $ids RETURN b.id AS id, count(r) AS count',
1985
+ { ids: [boostUri, targetUri].map(uri => uri.split(':').at(-1)) }
1986
+ );
1987
+ expect(roleCounts.records.map(record => record.get('count').toNumber())).toEqual([
1988
+ 1, 1,
1989
+ ]);
1990
+ });
1991
+
1992
+ it('commits only one accepted delivery and claim audit under concurrent finalization', async () => {
1993
+ const { inboxId } = await preparePendingBoost('atomic-concurrent@test.com');
1994
+ const results = await Promise.all(
1995
+ Array.from({ length: 3 }, () => userB.clients.fullAuth.inbox.finalize({}))
1996
+ );
1997
+ expect(results.reduce((count, result) => count + result.claimed, 0)).toBe(1);
1998
+ expect(results.reduce((count, result) => count + result.errors, 0)).toBe(0);
1999
+ expect(await userB.clients.fullAuth.credential.receivedCredentials({})).toHaveLength(1);
2000
+ const audit = await neogma.queryRunner.run(
2001
+ 'MATCH ()-[r:CLAIMED_INBOX_CREDENTIAL]->(ic:InboxCredential {id: $id}) RETURN count(r) AS count',
2002
+ { id: inboxId }
2003
+ );
2004
+ expect(audit.records[0].get('count').toNumber()).toBe(1);
2005
+ });
2006
+
1835
2007
  it('should not allow finalize without full auth', async () => {
1836
2008
  await expect(noAuthClient.inbox.finalize({})).rejects.toMatchObject({
1837
2009
  code: 'UNAUTHORIZED',
@@ -1912,8 +2084,11 @@ describe('Universal Inbox', () => {
1912
2084
  expect(res.claimed).toBe(2);
1913
2085
  expect(res.errors).toBe(0);
1914
2086
  expect(res.verifiableCredentials).toHaveLength(2);
1915
- // Ensure returned VCs are signed
1916
- for (const vc of res.verifiableCredentials) {
2087
+ // The HTTP contract is encrypted; only the holder decrypts the signed VCs.
2088
+ for (const encrypted of res.verifiableCredentials) {
2089
+ const vc = await userB.learnCard.invoke.decryptDagJwe<VC>(
2090
+ JWEValidator.parse(encrypted)
2091
+ );
1917
2092
  expect(vc.proof).toBeDefined();
1918
2093
  }
1919
2094
 
@@ -2089,12 +2264,13 @@ describe('Universal Inbox', () => {
2089
2264
  const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
2090
2265
 
2091
2266
  try {
2092
- await expect(userB.clients.fullAuth.inbox.finalize({})).resolves.toMatchObject({
2093
- processed: 1,
2094
- claimed: 1,
2095
- errors: 0,
2096
- verifiableCredentials: [expect.objectContaining({ proof: expect.anything() })],
2097
- });
2267
+ const result = await userB.clients.fullAuth.inbox.finalize({});
2268
+ expect(result).toMatchObject({ processed: 1, claimed: 1, errors: 0 });
2269
+ expect(
2270
+ await userB.learnCard.invoke.decryptDagJwe<VC>(
2271
+ JWEValidator.parse(result.verifiableCredentials[0])
2272
+ )
2273
+ ).toMatchObject({ proof: expect.anything() });
2098
2274
  } finally {
2099
2275
  queryRunnerSpy.mockRestore();
2100
2276
  consoleErrorSpy.mockRestore();
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ computeInlineTemplateHash,
5
+ validateInlineTemplateDataOrThrow,
6
+ validateInlineTemplateOrThrow,
7
+ } from '../src/helpers/inline-template.helpers';
8
+
9
+ describe('inline-template.helpers', () => {
10
+ it('throws developer-readable template validation errors', () => {
11
+ expect(() =>
12
+ validateInlineTemplateOrThrow({
13
+ name: '',
14
+ image: 'http://not-https.example.com/badge.png',
15
+ })
16
+ ).toThrow(/TEMPLATE_INVALID:/);
17
+ });
18
+
19
+ it('throws strict templateData validation errors with suggestions', () => {
20
+ const compiled = computeInlineTemplateHash({
21
+ name: 'Course Completion: {{courseName}}',
22
+ description: 'Awarded to {{learnerName}}',
23
+ });
24
+
25
+ expect(() =>
26
+ validateInlineTemplateDataOrThrow(compiled.variableManifest, {
27
+ courseNmae: 'Broken key',
28
+ learnerName: 'Ada',
29
+ })
30
+ ).toThrow(
31
+ /TEMPLATE_DATA_INVALID: .*templateData.courseName is required.*did you mean "courseName"/
32
+ );
33
+ });
34
+
35
+ it('hashes template content deterministically', () => {
36
+ const first = computeInlineTemplateHash({
37
+ name: 'Course Completion: {{courseName}}',
38
+ description: 'Awarded to {{learnerName}}',
39
+ walletSkills: [{ frameworkId: 'framework-1', id: 'skill-1' }],
40
+ });
41
+ const second = computeInlineTemplateHash({
42
+ name: 'Course Completion: {{courseName}}',
43
+ description: 'Awarded to {{learnerName}}',
44
+ walletSkills: [{ frameworkId: 'framework-1', id: 'skill-1' }],
45
+ });
46
+
47
+ expect(first.contentHash).toBe(second.contentHash);
48
+ });
49
+ });
@@ -37,10 +37,13 @@ const clearDb = async (): Promise<void> => {
37
37
  const createManagedRecipient = async (
38
38
  profileId: string,
39
39
  email: string,
40
- isServiceProfile: boolean
40
+ isServiceProfile: boolean,
41
+ parentCategory = isServiceProfile ? 'ID' : 'Family',
42
+ profileType?: string
41
43
  ): Promise<void> => {
42
44
  const boostUri = await userA.clients.fullAuth.boost.createBoost({
43
45
  credential: testUnsignedBoost,
46
+ category: parentCategory,
44
47
  });
45
48
  const managerDid = await userA.clients.fullAuth.profileManager.createChildProfileManager({
46
49
  parentUri: boostUri,
@@ -51,6 +54,7 @@ const createManagedRecipient = async (
51
54
  profileId,
52
55
  displayName: profileId,
53
56
  isServiceProfile,
57
+ ...(profileType ? { type: profileType } : {}),
54
58
  });
55
59
 
56
60
  const contactMethod = await createContactMethod({
@@ -95,6 +99,25 @@ describe('Inbox send to managed recipients', () => {
95
99
  await clearDb();
96
100
  });
97
101
 
102
+ it.each([
103
+ { parentCategory: 'Family', profileType: undefined },
104
+ { parentCategory: 'ID', profileType: 'child' },
105
+ ])(
106
+ 'rejects a service flag for child creation through $parentCategory / $profileType',
107
+ async ({ parentCategory, profileType }) => {
108
+ await expect(
109
+ createManagedRecipient(
110
+ 'service-child',
111
+ 'child@example.com',
112
+ true,
113
+ parentCategory,
114
+ profileType
115
+ )
116
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
117
+ expect(await Profile.findMany({ where: { profileId: 'service-child' } })).toEqual([]);
118
+ }
119
+ );
120
+
98
121
  it('delivers directly to a managed service profile without a guardian gate', async () => {
99
122
  await createManagedRecipient('managed-org', 'org@example.com', true);
100
123
 
@@ -114,7 +137,6 @@ describe('Inbox send to managed recipients', () => {
114
137
  const result = await sendTo('child@example.com');
115
138
 
116
139
  expect(result.inbox?.status).toBe('PENDING');
117
- expect(result.inbox?.guardianStatus).toBe('AWAITING_GUARDIAN');
118
140
 
119
141
  const credentials = await InboxCredential.findMany({ where: {} });
120
142
  expect(credentials).toHaveLength(1);