@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
@@ -15,9 +15,11 @@ import {
15
15
  } from '@models';
16
16
  import {
17
17
  DbTermsType,
18
+ DbTermsValidator,
18
19
  FlatDbTermsType,
19
20
  DbContractType,
20
21
  FlatDbContractType,
22
+ DbContractValidator,
21
23
  FlatDbTransactionType,
22
24
  DbTransactionType,
23
25
  } from 'types/consentflowcontract';
@@ -32,6 +34,7 @@ import {
32
34
  HolderExportMetadata,
33
35
  LCNProfile,
34
36
  } from '@learncard/types';
37
+ import { ConsentFlowGuardianApprovalValidator } from '@learncard/types';
35
38
  import { FlatBoostType } from 'types/boost';
36
39
  import { constructUri, getIdFromUri } from '@helpers/uri.helpers';
37
40
  import { flattenObject, inflateObject } from '@helpers/objects.helpers';
@@ -40,6 +43,8 @@ import { ProfileType } from 'types/profile';
40
43
  import { CredentialType } from 'types/credential';
41
44
  import { getBoostUri } from '@helpers/boost.helpers';
42
45
  import { getCredentialUri } from '@helpers/credential.helpers';
46
+ import { getProfilesThatManageAProfile } from '@accesslayer/profile/relationships/read';
47
+ import { getDidWeb } from '@helpers/did.helpers';
43
48
 
44
49
  export const isProfileConsentFlowContractAdmin = async (
45
50
  profile: ProfileType,
@@ -80,6 +85,25 @@ export const hasProfileConsentedToContract = async (
80
85
  return Number(result.records[0]?.get('count') ?? 0) > 0;
81
86
  };
82
87
 
88
+ export const hasGuardianApprovalHistory = async (terms: DbTermsType): Promise<boolean> => {
89
+ if (terms.guardianApproval) return true;
90
+
91
+ const result = await new QueryBuilder()
92
+ .match({
93
+ related: [
94
+ { identifier: 'transaction', model: ConsentFlowTransaction },
95
+ ConsentFlowTransaction.getRelationshipByAlias('isFor'),
96
+ { model: ConsentFlowTerms, where: { id: terms.id } },
97
+ ],
98
+ })
99
+ .where('transaction.`guardianApproval.guardianProfileId` IS NOT NULL')
100
+ .return('transaction.id')
101
+ .limit(1)
102
+ .run();
103
+
104
+ return result.records.length > 0;
105
+ };
106
+
83
107
  export const getContractDetailsById = async (
84
108
  id: string
85
109
  ): Promise<{ contractOwner: LCNProfile; contract: DbContractType } | null> => {
@@ -135,7 +159,7 @@ export const getContractTermsForProfile = async (
135
159
  .run()
136
160
  );
137
161
 
138
- return result.length > 0 ? inflateObject<DbTermsType>(result[0]!.terms) : null;
162
+ return result.length > 0 ? DbTermsValidator.parse(inflateObject(result[0]!.terms)) : null;
139
163
  };
140
164
 
141
165
  export const getContractTermsById = async (
@@ -516,6 +540,13 @@ all(key IN keys($params) WHERE
516
540
  }));
517
541
  };
518
542
 
543
+ // Empty expiry values historically mean no expiry. Invalid nonempty values never authorize access.
544
+ const isConsentExpiryActive = (expiresAt: string | undefined, now: number): boolean => {
545
+ if (!expiresAt?.trim()) return true;
546
+ const expiry = Date.parse(expiresAt);
547
+ return Number.isFinite(expiry) && expiry > now;
548
+ };
549
+
519
550
  export const getConsentedDataBetweenProfiles = async (
520
551
  ownerProfileId: string,
521
552
  consenterProfileId: string,
@@ -530,29 +561,40 @@ export const getConsentedDataBetweenProfiles = async (
530
561
  const convertedContractQuery = id ? convertObjectRegExpToNeo4j({ id }) : {};
531
562
  const { whereClause: contractWhereClause, params: contractQueryParams } =
532
563
  buildWhereForQueryBuilder('contract', convertedContractQuery as any);
533
-
534
- const _dbQuery = new QueryBuilder(
535
- new BindParam({
536
- params: flattenObject({ terms: flattenObject(convertDataQueryToNeo4jQuery(params)) }),
537
- cursor,
538
- ...contractQueryParams,
539
- })
540
- ).match({
541
- related: [
542
- { model: Profile, where: { profileId: consenterProfileId } },
543
- ConsentFlowTerms.getRelationshipByAlias('createdBy'),
544
- { identifier: 'terms', model: ConsentFlowTerms },
545
- ConsentFlowTerms.getRelationshipByAlias('consentsTo'),
546
- { model: ConsentFlowContract, identifier: 'contract' },
547
- ConsentFlowContract.getRelationshipByAlias('createdBy'),
548
- { model: Profile, where: { profileId: ownerProfileId } },
549
- ],
550
- // The following is custom Cypher logic that has the following behavior:
551
- // If query key is true, ensure all resulting terms have that key
552
- // If query key is false, ensure no resulting terms have that key
553
- // If query key is not present, return all terms whether they have it or not
554
- }).where(`
555
- all(key IN keys($params) WHERE
564
+ const now = Date.now();
565
+ const managers = await getProfilesThatManageAProfile(consenterProfileId);
566
+ const records: ConsentFlowContractDataForDid[] = [];
567
+ const batchSize = Math.max(limit, 50);
568
+ let offset = 0;
569
+
570
+ // Filter dates after parsing, but fetch bounded batches so invalid or expired grants
571
+ // cannot truncate an otherwise full page or require loading every grant at once.
572
+ while (records.length < limit) {
573
+ const _dbQuery = new QueryBuilder(
574
+ new BindParam({
575
+ params: flattenObject({
576
+ terms: flattenObject(convertDataQueryToNeo4jQuery(params)),
577
+ }),
578
+ cursor,
579
+ ...contractQueryParams,
580
+ })
581
+ ).match({
582
+ related: [
583
+ { model: Profile, where: { profileId: consenterProfileId } },
584
+ ConsentFlowTerms.getRelationshipByAlias('createdBy'),
585
+ { identifier: 'terms', model: ConsentFlowTerms },
586
+ ConsentFlowTerms.getRelationshipByAlias('consentsTo'),
587
+ { model: ConsentFlowContract, identifier: 'contract' },
588
+ ConsentFlowContract.getRelationshipByAlias('createdBy'),
589
+ { model: Profile, where: { profileId: ownerProfileId } },
590
+ ],
591
+ // The following is custom Cypher logic that has the following behavior:
592
+ // If query key is true, ensure all resulting terms have that key
593
+ // If query key is false, ensure no resulting terms have that key
594
+ // If query key is not present, return all terms whether they have it or not
595
+ }).where(`
596
+ (terms.status = 'live' OR (terms.status = 'stale' AND terms.oneTime = true))
597
+ AND all(key IN keys($params) WHERE
556
598
  CASE $params[key]
557
599
  WHEN true THEN terms[key] IS NOT NULL AND terms[key] <> []
558
600
  WHEN false THEN terms[key] IS NULL OR terms[key] = []
@@ -561,47 +603,103 @@ all(key IN keys($params) WHERE
561
603
  AND ${contractWhereClause}
562
604
  `);
563
605
 
564
- const dbQuery = cursor ? _dbQuery.raw(' AND terms.updatedAt < $cursor') : _dbQuery;
565
-
566
- const results = convertQueryResultToPropertiesObjectArray<{
567
- terms: FlatDbTermsType;
568
- contract: FlatDbContractType;
569
- }>(
570
- await dbQuery
571
- .return('DISTINCT terms, contract')
572
- .orderBy('terms.updatedAt DESC')
573
- .limit(limit)
574
- .run()
575
- );
606
+ const dbQuery = _dbQuery.match({
607
+ optional: true,
608
+ related: [
609
+ { identifier: 'transaction', model: ConsentFlowTransaction },
610
+ ConsentFlowTransaction.getRelationshipByAlias('isFor'),
611
+ { identifier: 'terms' },
612
+ ],
613
+ }).with(`terms, contract,
614
+ coalesce(terms.createdAt, min(CASE WHEN transaction.action = 'consent' THEN transaction.date END)) AS createdAt,
615
+ coalesce(terms.updatedAt, max(transaction.date), terms.createdAt) AS date,
616
+ count(transaction.\`guardianApproval.guardianProfileId\`) > 0 AS hadGuardianApproval`);
617
+
618
+ if (cursor) dbQuery.where('date < $cursor');
619
+
620
+ const results = convertQueryResultToPropertiesObjectArray<{
621
+ terms: FlatDbTermsType;
622
+ contract: FlatDbContractType;
623
+ createdAt: string | null;
624
+ date: string;
625
+ hadGuardianApproval: boolean;
626
+ }>(
627
+ await dbQuery
628
+ .return('terms, contract, createdAt, date, hadGuardianApproval')
629
+ .orderBy('date DESC, terms.id ASC')
630
+ .skip(offset)
631
+ .limit(batchSize)
632
+ .run()
633
+ );
634
+
635
+ for (const result of results) {
636
+ const parsedTerm = DbTermsValidator.safeParse(inflateObject(result.terms));
637
+ const parsedContract = DbContractValidator.safeParse(inflateObject(result.contract));
638
+ if (!parsedTerm.success || !parsedContract.success) {
639
+ // Never log stored consent data, identifiers, or raw validation errors.
640
+ console.warn('consented_data: invalid_stored_grant');
641
+ continue;
642
+ }
643
+ const term = parsedTerm.data;
644
+ const contract = parsedContract.data;
645
+ if (
646
+ !isConsentExpiryActive(term.expiresAt, now) ||
647
+ !isConsentExpiryActive(contract.expiresAt, now)
648
+ )
649
+ continue;
650
+
651
+ const parsedApproval = ConsentFlowGuardianApprovalValidator.safeParse(
652
+ term.guardianApproval
653
+ );
654
+ const approval = parsedApproval.success ? parsedApproval.data : undefined;
655
+ const required =
656
+ managers.length > 0 || !!term.guardianApproval || result.hadGuardianApproval;
657
+ const approved =
658
+ !!approval &&
659
+ approval.contractUpdatedAt === contract.updatedAt &&
660
+ managers.some(
661
+ manager =>
662
+ manager.profileId === approval.guardianProfileId &&
663
+ (manager.did === approval.guardianDid ||
664
+ getDidWeb(domain, manager.profileId) === approval.guardianDid)
665
+ );
576
666
 
577
- const inflatedResults = results.map(result => ({
578
- term: inflateObject<any>(result.terms) as DbTermsType,
579
- contract: inflateObject<any>(result.contract) as DbContractType,
580
- }));
667
+ records.push({
668
+ date: result.date,
669
+ ...(result.createdAt ? { createdAt: result.createdAt } : {}),
670
+ contractUpdatedAt: contract.updatedAt,
671
+ ...(contract.expiresAt?.trim() ? { contractExpiresAt: contract.expiresAt } : {}),
672
+ ...(contract.reasonForAccessing !== undefined
673
+ ? { reasonForAccessing: contract.reasonForAccessing }
674
+ : {}),
675
+ guardian: { required, approved, ...(approval ? { approval } : {}) },
676
+ contractUri: constructUri('contract', contract.id, domain),
677
+ termsUri: constructUri('terms', term.id, domain),
678
+ status: term.status,
679
+ ...(term.expiresAt?.trim() ? { expiresAt: term.expiresAt } : {}),
680
+ terms: term.terms,
681
+ credentials: Object.entries(term.terms.read.credentials.categories).flatMap(
682
+ ([category, { shared, sharing, shareUntil }]) => {
683
+ if (
684
+ term.terms.read.credentials.sharing === false ||
685
+ !sharing ||
686
+ !isConsentExpiryActive(shareUntil, now) ||
687
+ !shouldIncludeCategory(params.credentials?.categories, category)
688
+ )
689
+ return [];
690
+
691
+ return shared?.map(uri => ({ category, uri })) ?? [];
692
+ }
693
+ ),
694
+ personal: term.terms.read.personal,
695
+ });
696
+ if (records.length >= limit) return records;
697
+ }
698
+ if (results.length < batchSize) break;
699
+ offset += batchSize;
700
+ }
581
701
 
582
- return inflatedResults.map(({ term, contract }) => ({
583
- date: term.updatedAt!,
584
- contractUri: constructUri('contract', contract.id, domain),
585
- credentials: Object.entries(term.terms.read.credentials.categories)
586
- .flatMap(([category, { shared, sharing, shareUntil }]) => {
587
- if (
588
- !sharing ||
589
- (shareUntil && new Date(shareUntil) < new Date()) ||
590
- !shouldIncludeCategory(params.credentials?.categories, category)
591
- ) {
592
- return false as any as { category: string; uri: string };
593
- }
594
-
595
- return (
596
- shared?.map(uri => ({
597
- category: category,
598
- uri,
599
- })) ?? []
600
- );
601
- })
602
- .filter(Boolean),
603
- personal: term.terms.read.personal,
604
- }));
702
+ return records;
605
703
  };
606
704
 
607
705
  export const getTransactionsForTerms = async (
@@ -3,6 +3,7 @@ import { v4 as uuid } from 'uuid';
3
3
  import {
4
4
  ConsentFlowTerms as ConsentFlowTermsType,
5
5
  ConsentFlowTransaction as ConsentFlowTransactionType,
6
+ ConsentFlowGuardianApproval,
6
7
  LCNNotificationTypeEnumValidator,
7
8
  LCNProfile,
8
9
  VC,
@@ -34,10 +35,12 @@ export const reconsentTerms = async (
34
35
  terms,
35
36
  expiresAt,
36
37
  oneTime,
38
+ guardianApproval,
37
39
  }: {
38
40
  terms: ConsentFlowTermsType;
39
41
  expiresAt?: string;
40
42
  oneTime?: boolean;
43
+ guardianApproval?: ConsentFlowGuardianApproval;
41
44
  },
42
45
  domain: string
43
46
  ): Promise<boolean> => {
@@ -49,15 +52,27 @@ export const reconsentTerms = async (
49
52
  ...(typeof oneTime === 'boolean' ? { oneTime } : {}),
50
53
  } as const satisfies ConsentFlowTransactionType;
51
54
 
55
+ const existingFlat = flattenObject({
56
+ terms: relationship.terms.terms,
57
+ guardianApproval: relationship.terms.guardianApproval,
58
+ });
59
+ const newFlat = flattenObject({ terms, ...(guardianApproval ? { guardianApproval } : {}) });
60
+ const removedProperties = Object.fromEntries(
61
+ Object.keys(existingFlat)
62
+ .filter(key => !(key in newFlat))
63
+ .map(key => [key, null])
64
+ );
65
+
52
66
  const result = await new QueryBuilder(
53
67
  new BindParam({
54
- params: flattenObject({
55
- terms,
56
- updatedAt: new Date().toISOString(),
68
+ params: {
69
+ ...newFlat,
70
+ ...removedProperties,
71
+ updatedAt: transaction.date,
57
72
  status: oneTime ? 'stale' : 'live',
58
73
  ...(typeof expiresAt === 'string' ? { expiresAt } : {}),
59
74
  ...(typeof oneTime === 'boolean' ? { oneTime } : {}),
60
- }),
75
+ },
61
76
  })
62
77
  )
63
78
  .match({
@@ -167,7 +182,10 @@ export const reconsentTerms = async (
167
182
  boostCredential,
168
183
  contractOwnerSigningAuthority,
169
184
  domain,
170
- false
185
+ true,
186
+ undefined,
187
+ true,
188
+ [getDidWeb(domain, relationship.contractOwner.profileId)]
171
189
  );
172
190
 
173
191
  // Create transaction to record the boost issuance
@@ -244,7 +262,13 @@ export const updateTerms = async (
244
262
  terms,
245
263
  expiresAt,
246
264
  oneTime,
247
- }: { terms: ConsentFlowTermsType; expiresAt?: string; oneTime?: boolean },
265
+ guardianApproval,
266
+ }: {
267
+ terms: ConsentFlowTermsType;
268
+ expiresAt?: string;
269
+ oneTime?: boolean;
270
+ guardianApproval?: ConsentFlowGuardianApproval;
271
+ },
248
272
  domain: string
249
273
  ): Promise<boolean> => {
250
274
  const transaction = {
@@ -260,8 +284,14 @@ export const updateTerms = async (
260
284
  /* -------------------------------------------------------------------------- */
261
285
 
262
286
  // 1. Flatten both the existing stored terms and the new terms we are saving
263
- const existingFlat = flattenObject({ terms: relationship.terms.terms });
264
- const newFlatInner = flattenObject({ terms });
287
+ const existingFlat = flattenObject({
288
+ terms: relationship.terms.terms,
289
+ guardianApproval: relationship.terms.guardianApproval,
290
+ });
291
+ const newFlatInner = flattenObject({
292
+ terms,
293
+ ...(guardianApproval ? { guardianApproval } : {}),
294
+ });
265
295
 
266
296
  // 2. Determine keys that are present in the existing node but NOT in the new update
267
297
  const keysToRemove = Object.keys(existingFlat).filter(key => !(key in newFlatInner));
@@ -269,7 +299,7 @@ export const updateTerms = async (
269
299
  // 3. Build a params object: keys for new/updated properties + keys to delete (set to null)
270
300
  const paramsForSet = {
271
301
  ...newFlatInner,
272
- updatedAt: new Date().toISOString(),
302
+ updatedAt: transaction.date,
273
303
  status: oneTime ? 'stale' : 'live',
274
304
  ...(typeof expiresAt === 'string' ? { expiresAt } : {}),
275
305
  ...(typeof oneTime === 'boolean' ? { oneTime } : {}),
@@ -382,7 +412,10 @@ export const updateTerms = async (
382
412
  boostCredential,
383
413
  contractOwnerSigningAuthority,
384
414
  domain,
385
- false
415
+ true,
416
+ undefined,
417
+ true,
418
+ [getDidWeb(domain, relationship.contractOwner.profileId)]
386
419
  );
387
420
 
388
421
  // Create transaction to record the boost issuance
@@ -0,0 +1,141 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { JWE, VC } from '@learncard/types';
3
+ import type { IssuedCredential } from '../../types/credential';
4
+
5
+ const mocks = vi.hoisted(() => ({ createOne: vi.fn(async input => input) }));
6
+ vi.mock('@models', () => ({ Credential: { createOne: mocks.createOne } }));
7
+
8
+ import { storeCredential } from './create';
9
+
10
+ const statusEntry = {
11
+ id: 'https://network.example/status/1#42',
12
+ type: 'BitstringStatusListEntry' as const,
13
+ statusPurpose: 'revocation' as const,
14
+ statusListIndex: '42',
15
+ statusListCredential: 'https://network.example/status/1',
16
+ };
17
+ const createJwe = (): JWE => ({
18
+ protected: 'header',
19
+ iv: 'iv',
20
+ ciphertext: 'ciphertext',
21
+ tag: 'tag',
22
+ recipients: [],
23
+ });
24
+ const createVc = (): VC => ({
25
+ '@context': ['https://www.w3.org/2018/credentials/v1'],
26
+ type: ['VerifiableCredential'],
27
+ issuer: 'did:example:issuer',
28
+ issuanceDate: '2026-01-01T00:00:00Z',
29
+ credentialSubject: { id: 'did:example:subject' },
30
+ proof: {
31
+ type: 'Ed25519Signature2020',
32
+ created: '2026-01-01T00:00:00Z',
33
+ proofPurpose: 'assertionMethod',
34
+ verificationMethod: 'did:example:issuer#key-1',
35
+ proofValue: 'existing-signature',
36
+ },
37
+ });
38
+
39
+ describe('credential status storage', () => {
40
+ beforeEach(() => {
41
+ vi.restoreAllMocks();
42
+ vi.clearAllMocks();
43
+ });
44
+
45
+ it('persists public status coordinates after serializing an issuance result', async () => {
46
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
47
+ const jwe = createJwe();
48
+ const issued: IssuedCredential = {
49
+ kind: 'issued-credential',
50
+ credential: jwe,
51
+ statusEntries: [statusEntry],
52
+ };
53
+ await storeCredential(JSON.parse(JSON.stringify(issued)));
54
+ expect(mocks.createOne).toHaveBeenCalledWith(
55
+ expect.objectContaining({
56
+ credential: JSON.stringify(jwe),
57
+ statusEntries: JSON.stringify([statusEntry]),
58
+ })
59
+ );
60
+ expect(warn).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('warns when a client-encrypted JWE has no metadata, without logging its payload', async () => {
64
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
65
+ const original = createJwe();
66
+ const jwe = { ...original };
67
+ await storeCredential(jwe);
68
+ expect(warn).toHaveBeenCalledExactlyOnceWith(
69
+ expect.stringContaining('Encrypted credential has no status metadata'),
70
+ { credentialId: expect.any(String) }
71
+ );
72
+ expect(mocks.createOne.mock.calls[0]![0].statusEntries).toBeUndefined();
73
+ expect(mocks.createOne.mock.calls[0]![0].credential).toBe(JSON.stringify(jwe));
74
+ });
75
+
76
+ it('rejects internal issuance results that lost their status metadata before writing', async () => {
77
+ const issued = { kind: 'issued-credential', credential: createJwe() } as IssuedCredential;
78
+ await expect(storeCredential(issued)).rejects.toThrow();
79
+ expect(mocks.createOne).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it.each([undefined, []])(
83
+ 'warns for a pre-signed plaintext VC with missing or empty status (%j) and preserves its signature',
84
+ async credentialStatus => {
85
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
86
+ const credential = { ...createVc(), credentialStatus };
87
+ const stored = await storeCredential(credential);
88
+ expect(warn).toHaveBeenCalledExactlyOnceWith(
89
+ expect.stringContaining('Plaintext credential has no status metadata'),
90
+ { credentialId: stored.id }
91
+ );
92
+ expect(stored.credential).toBe(JSON.stringify(credential));
93
+ expect(stored.statusEntries).toBeUndefined();
94
+ }
95
+ );
96
+
97
+ it.each([statusEntry, [statusEntry]])(
98
+ 'does not warn for a plaintext VC with embedded status entries (%j)',
99
+ async credentialStatus => {
100
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
101
+ const credential = { ...createVc(), credentialStatus };
102
+ const stored = await storeCredential(credential);
103
+ expect(warn).not.toHaveBeenCalled();
104
+ expect(stored.credential).toBe(JSON.stringify(credential));
105
+ }
106
+ );
107
+
108
+ it.each([
109
+ ['Encrypted', createJwe()],
110
+ ['Plaintext', createVc()],
111
+ ] as const)(
112
+ 'warns for %s VC v1 issuance with empty status metadata',
113
+ async (kind, credential) => {
114
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
115
+ const stored = await storeCredential({
116
+ kind: 'issued-credential',
117
+ credential,
118
+ statusEntries: [],
119
+ });
120
+ expect(warn).toHaveBeenCalledExactlyOnceWith(
121
+ expect.stringContaining(`${kind} credential has no status metadata`),
122
+ { credentialId: stored.id }
123
+ );
124
+ expect(stored.credential).toBe(JSON.stringify(credential));
125
+ expect(stored.statusEntries).toBeUndefined();
126
+ }
127
+ );
128
+
129
+ it('does not treat a wire JWE with extra envelope-like fields as trusted issuance metadata', async () => {
130
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
131
+ const jwe = {
132
+ ...createJwe(),
133
+ kind: 'issued-credential',
134
+ credential: createJwe(),
135
+ statusEntries: [statusEntry],
136
+ };
137
+ await storeCredential(jwe);
138
+ expect(mocks.createOne.mock.calls[0]![0].statusEntries).toBeUndefined();
139
+ expect(mocks.createOne.mock.calls[0]![0].credential).toBe(JSON.stringify(jwe));
140
+ });
141
+ });
@@ -1,12 +1,36 @@
1
- import { UnsignedVC, VC, JWE } from '@learncard/types';
1
+ import { BitstringStatusListEntryValidator, UnsignedVC, VC, JWE } from '@learncard/types';
2
2
  import { v4 as uuid } from 'uuid';
3
+ import { getBitstringStatusListEntries, isEncrypted } from '@learncard/helpers';
4
+ import { isIssuedCredential } from '@helpers/issuedCredentialStatus.helpers';
5
+ import type { IssuedCredential } from 'types/credential';
3
6
 
4
7
  import { Credential, CredentialInstance } from '@models';
5
8
 
6
9
  export const storeCredential = async (
7
- credential: UnsignedVC | VC | JWE
10
+ input: UnsignedVC | VC | JWE | IssuedCredential
8
11
  ): Promise<CredentialInstance> => {
9
12
  const id = uuid();
10
13
 
11
- return Credential.createOne({ id, credential: JSON.stringify(credential) });
14
+ const issued = isIssuedCredential(input);
15
+ const credential = issued ? input.credential : input;
16
+ // Fail if an internal issuance result lost its metadata. Pre-signed wire
17
+ // payloads remain supported, with an explicit revocation warning.
18
+ const statusEntries = issued
19
+ ? BitstringStatusListEntryValidator.array().parse(input.statusEntries)
20
+ : undefined;
21
+ const encrypted = isEncrypted(credential);
22
+ if (
23
+ !statusEntries?.length &&
24
+ (encrypted || !getBitstringStatusListEntries(credential).length)
25
+ ) {
26
+ console.warn(
27
+ `[storeCredential] ${encrypted ? 'Encrypted' : 'Plaintext'} credential has no status metadata; network revocation and suspension cannot update its signed status list.`,
28
+ { credentialId: id }
29
+ );
30
+ }
31
+ return Credential.createOne({
32
+ id,
33
+ credential: JSON.stringify(credential),
34
+ ...(statusEntries?.length ? { statusEntries: JSON.stringify(statusEntries) } : {}),
35
+ });
12
36
  };
@@ -23,6 +23,7 @@ export const createCredentialActivity = async (
23
23
  eventType: params.eventType,
24
24
  timestamp,
25
25
  actorProfileId: params.actorProfileId,
26
+ onBehalfOf: params.onBehalfOf,
26
27
  recipientType: params.recipientType,
27
28
  recipientIdentifier: params.recipientIdentifier,
28
29
  boostUri: params.boostUri,