@learncard/network-brain-service 4.1.2 → 5.0.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.
- package/.env.example +14 -0
- package/CHANGELOG.md +164 -0
- package/README.md +27 -1
- package/bootstrapLambda.test.ts +70 -32
- package/config/config.dev.json +1 -0
- package/config/config.example.json +2 -1
- package/config/config.production.json +1 -0
- package/config/config.scouts.dev.json +1 -0
- package/config/config.scouts.production.json +1 -0
- package/consentFlowDeadlineLambda.test.ts +108 -0
- package/consentFlowDeadlineLambda.ts +18 -0
- package/consentFlowDeadlineLambdaApp.ts +14 -0
- package/dist/accesslayer/boost/create.d.ts +8 -2
- package/dist/accesslayer/boost/create.d.ts.map +1 -1
- package/dist/accesslayer/campaign/audience.d.ts +4 -4
- package/dist/accesslayer/campaign/audience.d.ts.map +1 -1
- package/dist/accesslayer/campaign/create.d.ts +1 -1
- package/dist/accesslayer/campaign/create.d.ts.map +1 -1
- package/dist/accesslayer/campaign/delivery.d.ts +5 -0
- package/dist/accesslayer/campaign/delivery.d.ts.map +1 -0
- package/dist/accesslayer/campaign/dispatch.d.ts +5 -0
- package/dist/accesslayer/campaign/dispatch.d.ts.map +1 -0
- package/dist/accesslayer/campaign/dispatch.test.d.ts +2 -0
- package/dist/accesslayer/campaign/dispatch.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/email.d.ts +10 -0
- package/dist/accesslayer/campaign/email.d.ts.map +1 -0
- package/dist/accesslayer/campaign/email.test.d.ts +2 -0
- package/dist/accesslayer/campaign/email.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/preferences.d.ts +5 -0
- package/dist/accesslayer/campaign/preferences.d.ts.map +1 -0
- package/dist/accesslayer/campaign/read.d.ts.map +1 -1
- package/dist/accesslayer/campaign/test.d.ts +5 -0
- package/dist/accesslayer/campaign/test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/test.test.d.ts +2 -0
- package/dist/accesslayer/campaign/test.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/update.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/delete.d.ts +1 -1
- package/dist/accesslayer/consentflowcontract/delete.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +2 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +19 -4
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +6 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/versions.d.ts +68 -0
- package/dist/accesslayer/consentflowcontract/versions.d.ts.map +1 -0
- package/dist/accesslayer/credential/create.d.ts +3 -1
- package/dist/accesslayer/credential/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/delete.d.ts +1 -0
- package/dist/accesslayer/credential/delete.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/create.d.ts +15 -3
- package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/read.d.ts +2 -0
- package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/read.d.ts +1 -0
- package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/create.d.ts +1 -0
- package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/refresh.d.ts +7 -0
- package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -1
- package/dist/app.d.ts +11 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +61 -1
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/campaign-unsubscribe.d.ts +7 -0
- package/dist/campaign-unsubscribe.d.ts.map +1 -0
- package/dist/campaign-unsubscribe.test.d.ts +2 -0
- package/dist/campaign-unsubscribe.test.d.ts.map +1 -0
- package/dist/config/environment.d.ts +42 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/stageConfig.d.ts +4 -0
- package/dist/config/stageConfig.d.ts.map +1 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/helpers/boost.helpers.d.ts +4 -1
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/campaign-app-origin.helpers.d.ts +2 -0
- package/dist/helpers/campaign-app-origin.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-email.helpers.d.ts +13 -0
- package/dist/helpers/campaign-email.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-notifications.helpers.d.ts +7 -0
- package/dist/helpers/campaign-notifications.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-queue.helpers.d.ts +7 -0
- package/dist/helpers/campaign-queue.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-queue.helpers.test.d.ts +2 -0
- package/dist/helpers/campaign-queue.helpers.test.d.ts.map +1 -0
- package/dist/helpers/campaign-unsubscribe-page.d.ts +2 -0
- package/dist/helpers/campaign-unsubscribe-page.d.ts.map +1 -0
- package/dist/helpers/campaign-unsubscribe.helpers.d.ts +7 -0
- package/dist/helpers/campaign-unsubscribe.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-webhook.helpers.test.d.ts +2 -0
- package/dist/helpers/campaign-webhook.helpers.test.d.ts.map +1 -0
- package/dist/helpers/consentflow-deadline.helpers.d.ts +5 -0
- package/dist/helpers/consentflow-deadline.helpers.d.ts.map +1 -0
- package/dist/helpers/consentflow-legacy-migration.helpers.d.ts +2 -0
- package/dist/helpers/consentflow-legacy-migration.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-events.helpers.d.ts.map +1 -1
- package/dist/helpers/contract.helpers.d.ts +1 -1
- package/dist/helpers/contract.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-display.helpers.d.ts +5 -0
- package/dist/helpers/credential-display.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-display.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-display.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-refresh-materiality.helpers.d.ts +4 -0
- package/dist/helpers/credential-refresh-materiality.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-refresh.helpers.d.ts +30 -1
- package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-status-mutation.helpers.d.ts +5 -0
- package/dist/helpers/credential-status-mutation.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-status-mutation.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-status-mutation.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential.helpers.d.ts +1 -1
- package/dist/helpers/credential.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox-refresh.helpers.d.ts +4 -2
- package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox.helpers.d.ts +1 -0
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/learnCard.helpers.d.ts +1 -0
- package/dist/helpers/learnCard.helpers.d.ts.map +1 -1
- package/dist/helpers/managed-status.helpers.d.ts +7 -0
- package/dist/helpers/managed-status.helpers.d.ts.map +1 -0
- package/dist/helpers/managed-status.helpers.test.d.ts +2 -0
- package/dist/helpers/managed-status.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts +2 -0
- package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notification-webhook-fetch.helpers.d.ts +13 -0
- package/dist/helpers/notification-webhook-fetch.helpers.d.ts.map +1 -0
- package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts +2 -0
- package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
- package/dist/helpers/notifications.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.d.ts +13 -5
- package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.test.d.ts +2 -0
- package/dist/helpers/refresh-send-intent.helpers.test.d.ts.map +1 -0
- package/dist/helpers/send-contract.helpers.d.ts +11 -0
- package/dist/helpers/send-contract.helpers.d.ts.map +1 -0
- package/dist/helpers/status-list.helpers.d.ts +12 -1
- package/dist/helpers/status-list.helpers.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/models/ConsentFlowContract.d.ts.map +1 -1
- package/dist/models/ConsentFlowContractVersion.d.ts +12 -0
- package/dist/models/ConsentFlowContractVersion.d.ts.map +1 -0
- package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
- package/dist/models/Credential.d.ts +2 -0
- package/dist/models/Credential.d.ts.map +1 -1
- package/dist/models/CredentialRefresh.d.ts +5 -0
- package/dist/models/CredentialRefresh.d.ts.map +1 -1
- package/dist/models/Integration.d.ts +1 -1
- package/dist/models/Profile.d.ts.map +1 -1
- package/dist/models/campaign-constraints.d.ts.map +1 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/activity.d.ts +15 -5
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/app-store.d.ts +15 -5
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/auth-grants.d.ts +12 -2
- package/dist/routes/auth-grants.d.ts.map +1 -1
- package/dist/routes/bench.d.ts +11 -1
- package/dist/routes/bench.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +70 -15
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/campaigns.d.ts +183 -51
- package/dist/routes/campaigns.d.ts.map +1 -1
- package/dist/routes/claim-hooks.d.ts +11 -1
- package/dist/routes/claim-hooks.d.ts.map +1 -1
- package/dist/routes/contact-methods.d.ts +11 -1
- package/dist/routes/contact-methods.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +374 -8
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-input-privacy.test.d.ts +2 -0
- package/dist/routes/credential-input-privacy.test.d.ts.map +1 -0
- package/dist/routes/credential-refreshes.d.ts +210 -1
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credential-share-previews.d.ts +11 -1
- package/dist/routes/credential-share-previews.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +132 -3
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/did-metadata.d.ts +11 -1
- package/dist/routes/did-metadata.d.ts.map +1 -1
- package/dist/routes/federation.d.ts +11 -1
- package/dist/routes/federation.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +54 -19
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +100 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/integrations.d.ts +14 -4
- package/dist/routes/integrations.d.ts.map +1 -1
- package/dist/routes/presentations.d.ts +11 -1
- package/dist/routes/presentations.d.ts.map +1 -1
- package/dist/routes/profile-manager.d.ts +11 -1
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts +11 -1
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/public-share-links.d.ts +22 -2
- package/dist/routes/public-share-links.d.ts.map +1 -1
- package/dist/routes/share-links.d.ts +36 -16
- package/dist/routes/share-links.d.ts.map +1 -1
- package/dist/routes/skill-frameworks.d.ts +11 -1
- package/dist/routes/skill-frameworks.d.ts.map +1 -1
- package/dist/routes/skills.d.ts +11 -1
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/storage.d.ts +37 -23
- package/dist/routes/storage.d.ts.map +1 -1
- package/dist/routes/test.d.ts +79 -3
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/utilities.d.ts +11 -1
- package/dist/routes/utilities.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts +11 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.d.ts +3 -0
- package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
- package/dist/services/delivery/delivery.service.d.ts +10 -0
- package/dist/services/delivery/delivery.service.d.ts.map +1 -1
- package/dist/types/activity.d.ts +3 -3
- package/dist/types/app-manifest.d.ts +10 -10
- package/dist/types/campaign.d.ts +13 -1
- package/dist/types/campaign.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +175 -7
- package/dist/types/consentflowcontract.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +27 -0
- package/dist/types/credential-refresh.d.ts.map +1 -1
- package/dist/types/credential.d.ts +5 -0
- package/dist/types/credential.d.ts.map +1 -1
- package/dist/types/integration.d.ts +3 -3
- package/lambda.ts +44 -14
- package/lambdaApp.ts +20 -0
- package/package.json +20 -19
- package/project.json +5 -0
- package/scripts/migrate-consent-flow-versions.ts +9 -0
- package/scripts/report-send-template-exposure.ts +47 -0
- package/scripts/verify-python-contracts.ts +16 -1
- package/serverless-local.yml +15 -2
- package/serverless.function-env.cjs +4 -0
- package/serverless.local.yml +12 -0
- package/serverless.yml +48 -3
- package/src/accesslayer/boost/create.ts +62 -7
- package/src/accesslayer/campaign/README.md +275 -6
- package/src/accesslayer/campaign/audience.ts +39 -7
- package/src/accesslayer/campaign/create.ts +4 -2
- package/src/accesslayer/campaign/delivery.ts +66 -0
- package/src/accesslayer/campaign/dispatch.test.ts +59 -0
- package/src/accesslayer/campaign/dispatch.ts +172 -0
- package/src/accesslayer/campaign/email.test.ts +73 -0
- package/src/accesslayer/campaign/email.ts +91 -0
- package/src/accesslayer/campaign/preferences.ts +53 -0
- package/src/accesslayer/campaign/read.ts +3 -1
- package/src/accesslayer/campaign/state.ts +1 -1
- package/src/accesslayer/campaign/test.test.ts +31 -0
- package/src/accesslayer/campaign/test.ts +75 -0
- package/src/accesslayer/campaign/update.ts +15 -4
- package/src/accesslayer/consentflowcontract/create.ts +20 -1
- package/src/accesslayer/consentflowcontract/delete.ts +121 -3
- package/src/accesslayer/consentflowcontract/read.ts +15 -9
- package/src/accesslayer/consentflowcontract/relationships/create.ts +70 -36
- package/src/accesslayer/consentflowcontract/relationships/read.ts +305 -73
- package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +1 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +269 -98
- package/src/accesslayer/consentflowcontract/versions.ts +790 -0
- package/src/accesslayer/credential/create.ts +33 -9
- package/src/accesslayer/credential/delete.ts +45 -0
- package/src/accesslayer/credential/relationships/create.ts +102 -47
- package/src/accesslayer/credential/relationships/update.ts +8 -116
- package/src/accesslayer/credential-refresh/read.ts +26 -2
- package/src/accesslayer/credential-refresh/update.ts +6 -2
- package/src/accesslayer/inbox-credential/create.ts +7 -1
- package/src/accesslayer/inbox-credential/refresh.ts +14 -4
- package/src/campaign-unsubscribe.test.ts +158 -0
- package/src/campaign-unsubscribe.ts +86 -0
- package/src/config/environment.test.ts +52 -0
- package/src/config/environment.ts +58 -0
- package/src/config/serverlessFunctionEnv.test.ts +59 -0
- package/src/credential-refresh.ts +1 -0
- package/src/docker-entry.ts +26 -1
- package/src/helpers/boost-storage.helpers.test.ts +107 -41
- package/src/helpers/boost.helpers.ts +88 -40
- package/src/helpers/campaign-app-origin.helpers.ts +25 -0
- package/src/helpers/campaign-email.helpers.ts +113 -0
- package/src/helpers/campaign-notifications.helpers.ts +127 -0
- package/src/helpers/campaign-queue.helpers.test.ts +442 -0
- package/src/helpers/campaign-queue.helpers.ts +122 -0
- package/src/helpers/campaign-unsubscribe-page.ts +107 -0
- package/src/helpers/campaign-unsubscribe.helpers.ts +56 -0
- package/src/helpers/campaign-webhook.helpers.test.ts +137 -0
- package/src/helpers/consentflow-deadline.helpers.ts +22 -0
- package/src/helpers/consentflow-legacy-migration.helpers.ts +179 -0
- package/src/helpers/contract-events.helpers.ts +35 -3
- package/src/helpers/contract-requests.helpers.ts +1 -1
- package/src/helpers/contract.helpers.ts +5 -1
- package/src/helpers/credential-display.helpers.test.ts +68 -0
- package/src/helpers/credential-display.helpers.ts +15 -0
- package/src/helpers/credential-refresh-email.helpers.ts +5 -12
- package/src/helpers/credential-refresh-materiality.helpers.test.ts +138 -1
- package/src/helpers/credential-refresh-materiality.helpers.ts +62 -1
- package/src/helpers/credential-refresh-proof.helpers.test.ts +1 -1
- package/src/helpers/credential-refresh-proof.helpers.ts +2 -2
- package/src/helpers/credential-refresh.helpers.ts +1166 -376
- package/src/helpers/credential-status-mutation.helpers.test.ts +155 -0
- package/src/helpers/credential-status-mutation.helpers.ts +166 -0
- package/src/helpers/credential.helpers.ts +19 -11
- package/src/helpers/finalize-inbox.helpers.ts +1 -1
- package/src/helpers/inbox-refresh.helpers.ts +34 -8
- package/src/helpers/inbox.helpers.ts +2 -0
- package/src/helpers/learnCard.helpers.ts +23 -0
- package/src/helpers/managed-status.helpers.test.ts +101 -0
- package/src/helpers/managed-status.helpers.ts +147 -0
- package/src/helpers/notification-delivery-boundary.helpers.test.ts +114 -0
- package/src/helpers/notification-webhook-fetch.helpers.test.ts +228 -0
- package/src/helpers/notification-webhook-fetch.helpers.ts +166 -0
- package/src/helpers/notificationQueue.helpers.ts +5 -0
- package/src/helpers/notifications.helpers.ts +19 -12
- package/src/helpers/refresh-send-intent.helpers.test.ts +60 -0
- package/src/helpers/refresh-send-intent.helpers.ts +81 -18
- package/src/helpers/send-contract.helpers.ts +36 -0
- package/src/helpers/status-list.helpers.test.ts +29 -12
- package/src/helpers/status-list.helpers.ts +329 -20
- package/src/models/ConsentFlowContract.ts +3 -0
- package/src/models/ConsentFlowContractVersion.ts +50 -0
- package/src/models/ConsentFlowTerms.ts +4 -0
- package/src/models/Credential.ts +2 -0
- package/src/models/CredentialRefresh.ts +2 -0
- package/src/models/Profile.ts +2 -0
- package/src/models/campaign-constraints.ts +13 -8
- package/src/models/index.ts +2 -0
- package/src/openapi.ts +21 -0
- package/src/routes/app-store.ts +2 -1
- package/src/routes/boosts.ts +219 -137
- package/src/routes/campaigns.ts +98 -19
- package/src/routes/contracts.ts +433 -115
- package/src/routes/credential-input-privacy.test.ts +92 -0
- package/src/routes/credential-refreshes.ts +273 -2
- package/src/routes/federation.ts +7 -2
- package/src/routes/inbox.ts +6 -1
- package/src/routes/index.ts +31 -2
- package/src/routes/workflows.ts +61 -46
- package/src/services/delivery/adapters/postmark.adapter.test.ts +70 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +84 -11
- package/src/services/delivery/delivery.service.ts +4 -0
- package/src/types/campaign.ts +14 -1
- package/src/types/consentflowcontract.ts +43 -2
- package/src/types/credential-refresh.ts +11 -1
- package/src/types/credential.ts +2 -0
- package/test/boost-status-cleanup.spec.ts +123 -0
- package/test/campaigns-email.unit.spec.ts +268 -0
- package/test/campaigns.spec.ts +804 -30
- package/test/consent-autoboost-idempotency.spec.ts +432 -0
- package/test/consent-data-boundaries.spec.ts +322 -26
- package/test/consent-legacy-migration.spec.ts +315 -0
- package/test/consent-recipients.spec.ts +7 -7
- package/test/consent-version-updates.spec.ts +761 -0
- package/test/consentflow.spec.ts +698 -226
- package/test/contract-requests.spec.ts +55 -9
- package/test/credential-refresh-allocation.spec.ts +212 -14
- package/test/credential-refresh-email.spec.ts +6 -10
- package/test/credential-refresh-endpoint.spec.ts +3 -0
- package/test/credential-refresh-publication.spec.ts +52 -15
- package/test/credential-storage.neo4j.spec.ts +33 -6
- package/test/helpers/legacy-autoboost.ts +48 -0
- package/test/holder-mode-send.spec.ts +710 -0
- package/test/inbox-refresh.spec.ts +105 -0
- package/test/inbox-security.e2e.ts +18 -22
- package/test/managed-status-revocation.spec.ts +970 -0
- package/test/notifications-worker.spec.ts +31 -25
- package/test/refresh-send.spec.ts +334 -5
- package/test/smart-resume-consent.spec.ts +1 -1
- package/test/verification/campaigns.ts +40 -0
- package/vitest.campaigns.config.ts +2 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
import type { HolderEncryptedHandoff, JWE, UnsignedVC, VC } from '@learncard/types';
|
|
3
|
+
import { JWEValidator, VCValidator } from '@learncard/types';
|
|
4
|
+
import { assertManagedCredentialBinding } from '@learncard/helpers';
|
|
5
|
+
import { getRefreshSendIntent } from '@helpers/refresh-send-intent.helpers';
|
|
6
|
+
|
|
7
|
+
import { neogma } from '@instance';
|
|
8
|
+
|
|
9
|
+
import { getUser } from './helpers/getClient';
|
|
10
|
+
import { addNotificationToQueueSpy } from './helpers/spies';
|
|
11
|
+
import * as Notifications from '@helpers/notifications.helpers';
|
|
12
|
+
import { getLearnCard, SeedLearnCard } from '@helpers/learnCard.helpers';
|
|
13
|
+
import { getCredentialByUri } from '@accesslayer/credential/read';
|
|
14
|
+
import {
|
|
15
|
+
getCredentialRefresh,
|
|
16
|
+
getCredentialRefreshHead,
|
|
17
|
+
setCredentialRefreshState,
|
|
18
|
+
} from '@accesslayer/credential-refresh';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Server-side holder-mode (student-only encrypted) managed sends (LC-2345, A3 steps
|
|
22
|
+
* 6, 9, 10, 11). The server never decrypts: every check here is structural (public
|
|
23
|
+
* handoff fields vs. the allocation) or a JWE recipient-set comparison. Proof,
|
|
24
|
+
* `refreshService` equality and claim content are the receiving device's
|
|
25
|
+
* responsibility (`ingestManagedCredential`), not this server's.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const ISSUER_PROFILE_ID = 'holder-mode-issuer';
|
|
29
|
+
const HOLDER_PROFILE_ID = 'holder-mode-holder';
|
|
30
|
+
const MANAGER_PROFILE_ID = 'holder-mode-manager';
|
|
31
|
+
const MANAGER2_PROFILE_ID = 'holder-mode-manager-2';
|
|
32
|
+
const OUTSIDER_PROFILE_ID = 'holder-mode-outsider';
|
|
33
|
+
const SENTINEL_CLAIM = 'HOLDER-MODE-SENTINEL-CLAIM-f3c9a7';
|
|
34
|
+
|
|
35
|
+
let brain: SeedLearnCard;
|
|
36
|
+
let issuer: Awaited<ReturnType<typeof getUser>>;
|
|
37
|
+
let holder: Awaited<ReturnType<typeof getUser>>;
|
|
38
|
+
let manager: Awaited<ReturnType<typeof getUser>>;
|
|
39
|
+
let manager2: Awaited<ReturnType<typeof getUser>>;
|
|
40
|
+
let outsider: Awaited<ReturnType<typeof getUser>>;
|
|
41
|
+
|
|
42
|
+
type PreparedHolderSend = {
|
|
43
|
+
refreshId: string;
|
|
44
|
+
credentialId: string;
|
|
45
|
+
holderDid: string;
|
|
46
|
+
boostUri: string;
|
|
47
|
+
refreshService: { id: string; type: string; authorization?: { type: string } };
|
|
48
|
+
statusEntries?: Record<string, unknown>[];
|
|
49
|
+
recipientKeyIds?: string[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const runQuery = async (cypher: string, params: Record<string, unknown> = {}) =>
|
|
53
|
+
neogma.queryRunner.run(cypher, params);
|
|
54
|
+
|
|
55
|
+
const toNum = (value: unknown): number =>
|
|
56
|
+
value && typeof (value as { toNumber?: () => number }).toNumber === 'function'
|
|
57
|
+
? (value as { toNumber: () => number }).toNumber()
|
|
58
|
+
: Number(value ?? 0);
|
|
59
|
+
|
|
60
|
+
const linkManager = async (managerProfileId: string) =>
|
|
61
|
+
runQuery(
|
|
62
|
+
`MATCH (child:Profile {profileId: $childId})
|
|
63
|
+
MATCH (mgr:Profile {profileId: $mgrId})
|
|
64
|
+
MERGE (child)-[:MANAGED_BY]->(mgr)`,
|
|
65
|
+
{ childId: HOLDER_PROFILE_ID, mgrId: managerProfileId }
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/** Every property value of every node carrying the given label, flattened to text. */
|
|
69
|
+
const dumpNodeText = async (label: string): Promise<string> => {
|
|
70
|
+
const result = await runQuery(`MATCH (n:${label}) RETURN properties(n) AS props`);
|
|
71
|
+
|
|
72
|
+
return JSON.stringify(result.records.map(record => record.get('props')));
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const assertNoSentinelLeak = async (): Promise<void> => {
|
|
76
|
+
expect(await dumpNodeText('Boost')).not.toContain(SENTINEL_CLAIM);
|
|
77
|
+
expect(await dumpNodeText('CredentialRefresh')).not.toContain(SENTINEL_CLAIM);
|
|
78
|
+
expect(JSON.stringify(addNotificationToQueueSpy.mock.calls)).not.toContain(SENTINEL_CLAIM);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const prepareHolderSend = async (
|
|
82
|
+
overrides: Record<string, unknown> = {}
|
|
83
|
+
): Promise<PreparedHolderSend> =>
|
|
84
|
+
issuer.clients.fullAuth.boost.prepareRefreshableSend({
|
|
85
|
+
recipient: HOLDER_PROFILE_ID,
|
|
86
|
+
display: { name: 'Official Transcript', category: 'Learning History' },
|
|
87
|
+
encryption: 'holder',
|
|
88
|
+
statusBinding: 'server',
|
|
89
|
+
...overrides,
|
|
90
|
+
} as Parameters<
|
|
91
|
+
typeof issuer.clients.fullAuth.boost.prepareRefreshableSend
|
|
92
|
+
>[0]) as Promise<PreparedHolderSend>;
|
|
93
|
+
|
|
94
|
+
const MANAGED_REFRESH_CONTEXT_FRAGMENT = {
|
|
95
|
+
LearnCardCredentialRefresh2026: 'https://learncard.com/refresh#LearnCardCredentialRefresh2026',
|
|
96
|
+
authorization: {
|
|
97
|
+
'@id': 'https://purl.imsglobal.org/spec/ob/v3p0#authorization',
|
|
98
|
+
'@context': {
|
|
99
|
+
LearnCardDIDAuth: 'https://docs.learncard.com/definitions#LearnCardDIDAuth',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Signs a holder-mode-shaped credential; `overrides` replace fields directly. */
|
|
105
|
+
const signHolderCredential = async (
|
|
106
|
+
prepared: PreparedHolderSend,
|
|
107
|
+
overrides: Record<string, unknown> = {}
|
|
108
|
+
): Promise<VC> => {
|
|
109
|
+
const unsigned = {
|
|
110
|
+
'@context': ['https://www.w3.org/ns/credentials/v2', MANAGED_REFRESH_CONTEXT_FRAGMENT],
|
|
111
|
+
id: prepared.credentialId,
|
|
112
|
+
type: ['VerifiableCredential'],
|
|
113
|
+
issuer: issuer.learnCard.id.did(),
|
|
114
|
+
validFrom: '2026-01-01T00:00:00Z',
|
|
115
|
+
// `description` is a standard VCDM v2 term (unlike a custom subject claim) so
|
|
116
|
+
// it carries the sentinel without needing its own JSON-LD context fragment.
|
|
117
|
+
description: SENTINEL_CLAIM,
|
|
118
|
+
credentialSubject: { id: prepared.holderDid },
|
|
119
|
+
refreshService: prepared.refreshService,
|
|
120
|
+
...(prepared.statusEntries?.[0] ? { credentialStatus: prepared.statusEntries[0] } : {}),
|
|
121
|
+
...overrides,
|
|
122
|
+
} as UnsignedVC;
|
|
123
|
+
|
|
124
|
+
return issuer.learnCard.invoke.issueCredential(unsigned);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const DEFAULT_RECIPIENTS = (): string[] => [holder.learnCard.id.did(), manager.learnCard.id.did()];
|
|
128
|
+
|
|
129
|
+
const buildHandoff = async (
|
|
130
|
+
prepared: PreparedHolderSend,
|
|
131
|
+
params: {
|
|
132
|
+
credential?: VC;
|
|
133
|
+
jwe?: JWE;
|
|
134
|
+
recipients?: string[];
|
|
135
|
+
credentialId?: string;
|
|
136
|
+
holderDid?: string;
|
|
137
|
+
issuerDid?: string;
|
|
138
|
+
credentialStatus?: unknown;
|
|
139
|
+
} = {}
|
|
140
|
+
): Promise<{ handoff: HolderEncryptedHandoff; credential: VC }> => {
|
|
141
|
+
const credential = params.credential ?? (await signHolderCredential(prepared));
|
|
142
|
+
const jwe =
|
|
143
|
+
params.jwe ??
|
|
144
|
+
(await issuer.learnCard.invoke.createDagJweExact(
|
|
145
|
+
credential,
|
|
146
|
+
params.recipients ?? DEFAULT_RECIPIENTS()
|
|
147
|
+
));
|
|
148
|
+
const issuerId: string =
|
|
149
|
+
typeof credential.issuer === 'string'
|
|
150
|
+
? credential.issuer
|
|
151
|
+
: (credential.issuer.id ?? issuer.learnCard.id.did());
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
credential,
|
|
155
|
+
handoff: {
|
|
156
|
+
refreshId: prepared.refreshId,
|
|
157
|
+
credentialId: params.credentialId ?? credential.id!,
|
|
158
|
+
holderDid: params.holderDid ?? prepared.holderDid,
|
|
159
|
+
issuerDid: params.issuerDid ?? issuerId,
|
|
160
|
+
credentialStatus: (params.credentialStatus ??
|
|
161
|
+
credential.credentialStatus) as HolderEncryptedHandoff['credentialStatus'],
|
|
162
|
+
refreshService: credential.refreshService as HolderEncryptedHandoff['refreshService'],
|
|
163
|
+
jwe,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const sendHandoff = async (handoff: HolderEncryptedHandoff): Promise<string> => {
|
|
169
|
+
const { uri, activityId } =
|
|
170
|
+
await issuer.clients.fullAuth.credentialRefresh.sendHolderEncryptedRefreshable({
|
|
171
|
+
handoff,
|
|
172
|
+
});
|
|
173
|
+
expect(activityId).toEqual(expect.any(String));
|
|
174
|
+
expect(activityId.length).toBeGreaterThan(0);
|
|
175
|
+
return uri;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
describe('Holder-mode managed sends (LC-2345)', () => {
|
|
179
|
+
beforeAll(async () => {
|
|
180
|
+
brain = await getLearnCard();
|
|
181
|
+
issuer = await getUser('12'.repeat(32));
|
|
182
|
+
holder = await getUser('34'.repeat(32));
|
|
183
|
+
manager = await getUser('56'.repeat(32));
|
|
184
|
+
manager2 = await getUser('78'.repeat(32));
|
|
185
|
+
outsider = await getUser('9a'.repeat(32));
|
|
186
|
+
|
|
187
|
+
vi.spyOn(Notifications, 'addNotificationToQueue').mockImplementation(
|
|
188
|
+
addNotificationToQueueSpy
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
await runQuery(
|
|
192
|
+
'CREATE CONSTRAINT credential_refresh_id_unique IF NOT EXISTS FOR (r:CredentialRefresh) REQUIRE (r.refreshId) IS UNIQUE'
|
|
193
|
+
);
|
|
194
|
+
await runQuery(
|
|
195
|
+
'CREATE CONSTRAINT credential_refresh_version_key_unique IF NOT EXISTS FOR (c:Credential) REQUIRE (c.refreshVersionKey) IS UNIQUE'
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
beforeEach(async () => {
|
|
200
|
+
await runQuery('MATCH (r:CredentialRefresh) DETACH DELETE r');
|
|
201
|
+
await runQuery('MATCH (c:Credential) DETACH DELETE c');
|
|
202
|
+
await runQuery('MATCH (b:Boost) DETACH DELETE b');
|
|
203
|
+
await runQuery('MATCH (i:RefreshSendIntent) DETACH DELETE i');
|
|
204
|
+
await runQuery('MATCH (a:CredentialActivity) DETACH DELETE a');
|
|
205
|
+
await runQuery('MATCH (t:ConsentFlowTransaction) DETACH DELETE t');
|
|
206
|
+
await runQuery('MATCH (t:ConsentFlowTerms) DETACH DELETE t');
|
|
207
|
+
await runQuery('MATCH (c:ConsentFlowContract) DETACH DELETE c');
|
|
208
|
+
await runQuery('MATCH (l:BitstringStatusList) DETACH DELETE l');
|
|
209
|
+
await runQuery('MATCH (bind:CredentialStatusBinding) DETACH DELETE bind');
|
|
210
|
+
await runQuery('MATCH (p:Profile) DETACH DELETE p');
|
|
211
|
+
|
|
212
|
+
await issuer.clients.fullAuth.profile.createProfile({ profileId: ISSUER_PROFILE_ID });
|
|
213
|
+
await holder.clients.fullAuth.profile.createProfile({ profileId: HOLDER_PROFILE_ID });
|
|
214
|
+
await manager.clients.fullAuth.profile.createProfile({ profileId: MANAGER_PROFILE_ID });
|
|
215
|
+
await manager2.clients.fullAuth.profile.createProfile({ profileId: MANAGER2_PROFILE_ID });
|
|
216
|
+
await outsider.clients.fullAuth.profile.createProfile({ profileId: OUTSIDER_PROFILE_ID });
|
|
217
|
+
|
|
218
|
+
await linkManager(MANAGER_PROFILE_ID);
|
|
219
|
+
|
|
220
|
+
addNotificationToQueueSpy.mockReset();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('sendHolderEncryptedRefreshable: success', () => {
|
|
224
|
+
it('binds external template status without server allocation', async () => {
|
|
225
|
+
const credentialStatus = {
|
|
226
|
+
id: 'https://external.example/status#7',
|
|
227
|
+
type: 'BitstringStatusListEntry',
|
|
228
|
+
statusPurpose: 'revocation',
|
|
229
|
+
statusListIndex: '7',
|
|
230
|
+
statusListCredential: 'https://external.example/status',
|
|
231
|
+
};
|
|
232
|
+
const prepared = await prepareHolderSend({ statusBinding: undefined });
|
|
233
|
+
expect(prepared.statusEntries).toBeUndefined();
|
|
234
|
+
const credential = await signHolderCredential(prepared, { credentialStatus });
|
|
235
|
+
const { handoff } = await buildHandoff(prepared, { credential });
|
|
236
|
+
const result =
|
|
237
|
+
await issuer.clients.fullAuth.credentialRefresh.sendHolderEncryptedRefreshable({
|
|
238
|
+
handoff,
|
|
239
|
+
});
|
|
240
|
+
expect(result.statusManagement).toBe('external');
|
|
241
|
+
const receipt = await holder.clients.fullAuth.credentialRefresh.getReceipt({
|
|
242
|
+
uri: result.uri,
|
|
243
|
+
});
|
|
244
|
+
expect(receipt?.credentialStatus).toEqual(credentialStatus);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('completes the intent and replays fresh ciphertext without another activity or delivery', async () => {
|
|
248
|
+
const idempotencyKey = 'holder-send-replay';
|
|
249
|
+
const prepared = await prepareHolderSend({ idempotencyKey });
|
|
250
|
+
const { credential, handoff } = await buildHandoff(prepared);
|
|
251
|
+
const send = issuer.clients.fullAuth.credentialRefresh.sendHolderEncryptedRefreshable;
|
|
252
|
+
const first = await send({ handoff, idempotencyKey });
|
|
253
|
+
const retry = await buildHandoff(prepared, { credential });
|
|
254
|
+
expect(retry.handoff.jwe).not.toEqual(handoff.jwe);
|
|
255
|
+
expect(await send({ handoff: retry.handoff, idempotencyKey })).toEqual(first);
|
|
256
|
+
expect(await getRefreshSendIntent(ISSUER_PROFILE_ID, idempotencyKey)).toMatchObject({
|
|
257
|
+
state: 'delivered',
|
|
258
|
+
result: { credentialUri: first.uri, activityId: first.activityId },
|
|
259
|
+
});
|
|
260
|
+
const counts = await runQuery('MATCH (a:CredentialActivity) RETURN count(a) AS count');
|
|
261
|
+
expect(toNum(counts.records[0]?.get('count'))).toBe(1);
|
|
262
|
+
const sent = await runQuery(
|
|
263
|
+
'MATCH ()-[r:CREDENTIAL_SENT]->() RETURN count(r) AS count'
|
|
264
|
+
);
|
|
265
|
+
expect(toNum(sent.records[0]?.get('count'))).toBe(1);
|
|
266
|
+
const other = await prepareHolderSend({ recipient: OUTSIDER_PROFILE_ID });
|
|
267
|
+
const otherHandoff = await buildHandoff(other, {
|
|
268
|
+
recipients: [outsider.learnCard.id.did()],
|
|
269
|
+
});
|
|
270
|
+
await expect(
|
|
271
|
+
send({ handoff: otherHandoff.handoff, idempotencyKey })
|
|
272
|
+
).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
273
|
+
await expect(
|
|
274
|
+
send({ handoff: { ...handoff, holderDid: other.holderDid }, idempotencyKey })
|
|
275
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
276
|
+
await runQuery(
|
|
277
|
+
"MATCH (i:RefreshSendIntent {refreshId: $refreshId}) SET i.state = 'prepared' REMOVE i.result",
|
|
278
|
+
{ refreshId: prepared.refreshId }
|
|
279
|
+
);
|
|
280
|
+
expect(await send({ handoff: retry.handoff, idempotencyKey })).toEqual(first);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it.each([false, true])(
|
|
284
|
+
'returns the exact service descriptor before claim (array=%s)',
|
|
285
|
+
async arrayShape => {
|
|
286
|
+
const prepared = await prepareHolderSend();
|
|
287
|
+
const refreshService = arrayShape
|
|
288
|
+
? [
|
|
289
|
+
prepared.refreshService,
|
|
290
|
+
{
|
|
291
|
+
id: 'https://example.org/refresh/other',
|
|
292
|
+
type: 'https://example.org/OtherRefreshService',
|
|
293
|
+
},
|
|
294
|
+
]
|
|
295
|
+
: prepared.refreshService;
|
|
296
|
+
const credential = await signHolderCredential(prepared, { refreshService });
|
|
297
|
+
const { handoff } = await buildHandoff(prepared, { credential });
|
|
298
|
+
const uri = await sendHandoff(handoff);
|
|
299
|
+
const receipt = await holder.clients.fullAuth.credentialRefresh.getReceipt({ uri });
|
|
300
|
+
expect(receipt?.refreshService).toEqual(refreshService);
|
|
301
|
+
expect(receipt).not.toBeNull();
|
|
302
|
+
assertManagedCredentialBinding(credential, receipt!);
|
|
303
|
+
await holder.clients.fullAuth.credential.acceptCredential({ uri });
|
|
304
|
+
expect((await getCredentialRefresh(prepared.refreshId))?.state).toBe('active');
|
|
305
|
+
if (arrayShape) {
|
|
306
|
+
expect(() =>
|
|
307
|
+
assertManagedCredentialBinding(
|
|
308
|
+
{ ...credential, refreshService: prepared.refreshService },
|
|
309
|
+
receipt!
|
|
310
|
+
)
|
|
311
|
+
).toThrow('MANAGED_CREDENTIAL_BINDING_MISMATCH');
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
);
|
|
315
|
+
it('stores only the JWE, matches the published recipient set, and leaks no claim', async () => {
|
|
316
|
+
const prepared = await prepareHolderSend();
|
|
317
|
+
|
|
318
|
+
expect(prepared.recipientKeyIds).toBeDefined();
|
|
319
|
+
expect(prepared.recipientKeyIds).toHaveLength(2);
|
|
320
|
+
expect((await getCredentialRefresh(prepared.refreshId))?.encryptionMode).toBe('holder');
|
|
321
|
+
|
|
322
|
+
const { credential, handoff } = await buildHandoff(prepared);
|
|
323
|
+
const kids = handoff.jwe.recipients!.map(recipient => recipient.header.kid);
|
|
324
|
+
expect(new Set(kids)).toEqual(new Set(prepared.recipientKeyIds));
|
|
325
|
+
|
|
326
|
+
const uri = await sendHandoff(handoff);
|
|
327
|
+
expect(uri).toContain(':credential:');
|
|
328
|
+
|
|
329
|
+
const stored = await getCredentialByUri(uri);
|
|
330
|
+
expect(stored).toBeTruthy();
|
|
331
|
+
const parsed = JSON.parse(stored!.credential);
|
|
332
|
+
expect(JWEValidator.safeParse(parsed).success).toBe(true);
|
|
333
|
+
expect(VCValidator.safeParse(parsed).success).toBe(false);
|
|
334
|
+
expect(stored!.credential).not.toContain(SENTINEL_CLAIM);
|
|
335
|
+
|
|
336
|
+
const decrypted = await holder.learnCard.invoke.decryptDagJwe(parsed);
|
|
337
|
+
expect(decrypted).toEqual(credential);
|
|
338
|
+
const decryptedByManager = await manager.learnCard.invoke.decryptDagJwe(parsed);
|
|
339
|
+
expect(decryptedByManager).toEqual(credential);
|
|
340
|
+
const issuerAttempt = await issuer.learnCard.invoke
|
|
341
|
+
.decryptDagJwe(parsed)
|
|
342
|
+
.catch(() => null);
|
|
343
|
+
expect(issuerAttempt).not.toEqual(credential);
|
|
344
|
+
const brainAttempt = await brain.invoke.decryptDagJwe(parsed).catch(() => null);
|
|
345
|
+
expect(brainAttempt).not.toEqual(credential);
|
|
346
|
+
|
|
347
|
+
const sentCount = toNum(
|
|
348
|
+
(
|
|
349
|
+
await runQuery(
|
|
350
|
+
`MATCH (:Profile {profileId: $issuerProfileId})-[:CREDENTIAL_SENT]->(c:Credential {id: $id})
|
|
351
|
+
RETURN count(c) AS count`,
|
|
352
|
+
{ issuerProfileId: ISSUER_PROFILE_ID, id: stored!.id }
|
|
353
|
+
)
|
|
354
|
+
).records[0]?.get('count')
|
|
355
|
+
);
|
|
356
|
+
expect(sentCount).toEqual(1);
|
|
357
|
+
|
|
358
|
+
const head = await getCredentialRefreshHead(prepared.refreshId);
|
|
359
|
+
expect(head?.version).toEqual(1);
|
|
360
|
+
|
|
361
|
+
await assertNoSentinelLeak();
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe('sendHolderEncryptedRefreshable: structural rejections', () => {
|
|
366
|
+
it('rejects consent withdrawn after preparation and records writes only while consent is live', async () => {
|
|
367
|
+
await runQuery('MATCH (:Profile {profileId: $id})-[r:MANAGED_BY]->() DELETE r', {
|
|
368
|
+
id: HOLDER_PROFILE_ID,
|
|
369
|
+
});
|
|
370
|
+
const contractUri = await issuer.clients.fullAuth.contracts.createConsentFlowContract({
|
|
371
|
+
contract: {
|
|
372
|
+
read: { anonymize: false },
|
|
373
|
+
write: { credentials: { categories: { TestBoosts: { required: false } } } },
|
|
374
|
+
},
|
|
375
|
+
name: 'Holder delivery contract',
|
|
376
|
+
writers: [ISSUER_PROFILE_ID],
|
|
377
|
+
});
|
|
378
|
+
const { termsUri } = await holder.clients.fullAuth.contracts.consentToContract({
|
|
379
|
+
contractUri,
|
|
380
|
+
terms: {
|
|
381
|
+
read: { anonymize: false },
|
|
382
|
+
write: { credentials: { categories: { TestBoosts: true } } },
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
await linkManager(MANAGER_PROFILE_ID);
|
|
386
|
+
const prepared = await prepareHolderSend({
|
|
387
|
+
contractUri,
|
|
388
|
+
idempotencyKey: 'consented-holder-send',
|
|
389
|
+
display: { name: 'Prepared credential', category: 'TestBoosts' },
|
|
390
|
+
});
|
|
391
|
+
expect(
|
|
392
|
+
await getRefreshSendIntent(ISSUER_PROFILE_ID, 'consented-holder-send')
|
|
393
|
+
).toMatchObject({ contractUri });
|
|
394
|
+
const first = await buildHandoff(prepared);
|
|
395
|
+
await sendHandoff(first.handoff);
|
|
396
|
+
const transactions = await runQuery(
|
|
397
|
+
"MATCH (t:ConsentFlowTransaction {action: 'write'}) RETURN count(t) AS count"
|
|
398
|
+
);
|
|
399
|
+
expect(toNum(transactions.records[0]?.get('count'))).toBe(1);
|
|
400
|
+
|
|
401
|
+
const pending = await prepareHolderSend({
|
|
402
|
+
contractUri,
|
|
403
|
+
display: { name: 'Prepared credential', category: 'TestBoosts' },
|
|
404
|
+
});
|
|
405
|
+
await holder.clients.fullAuth.contracts.withdrawConsent({ uri: termsUri });
|
|
406
|
+
const { handoff } = await buildHandoff(pending);
|
|
407
|
+
await expect(sendHandoff(handoff)).rejects.toMatchObject({
|
|
408
|
+
code: 'FORBIDDEN',
|
|
409
|
+
message: 'User has not yet approved the latest version of this contract',
|
|
410
|
+
});
|
|
411
|
+
expect(await getCredentialRefreshHead(pending.refreshId)).toBeNull();
|
|
412
|
+
const sent = await runQuery(
|
|
413
|
+
'MATCH ()-[r:CREDENTIAL_SENT]->() RETURN count(r) AS count'
|
|
414
|
+
);
|
|
415
|
+
expect(toNum(sent.records[0]?.get('count'))).toBe(1);
|
|
416
|
+
});
|
|
417
|
+
it('rejects a JWE encrypted to the wrong recipient entirely', async () => {
|
|
418
|
+
const prepared = await prepareHolderSend();
|
|
419
|
+
const { handoff } = await buildHandoff(prepared, {
|
|
420
|
+
recipients: [outsider.learnCard.id.did()],
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await expect(sendHandoff(handoff)).rejects.toMatchObject({
|
|
424
|
+
code: 'CONFLICT',
|
|
425
|
+
message: 'RECIPIENTS_CHANGED',
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('rejects an extra recipient beyond holder + managers', async () => {
|
|
430
|
+
const prepared = await prepareHolderSend();
|
|
431
|
+
const { handoff } = await buildHandoff(prepared, {
|
|
432
|
+
recipients: [...DEFAULT_RECIPIENTS(), outsider.learnCard.id.did()],
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
await expect(sendHandoff(handoff)).rejects.toMatchObject({
|
|
436
|
+
code: 'CONFLICT',
|
|
437
|
+
message: 'RECIPIENTS_CHANGED',
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('rejects a missing manager recipient', async () => {
|
|
442
|
+
const prepared = await prepareHolderSend();
|
|
443
|
+
const { handoff } = await buildHandoff(prepared, {
|
|
444
|
+
recipients: [holder.learnCard.id.did()],
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
await expect(sendHandoff(handoff)).rejects.toMatchObject({
|
|
448
|
+
code: 'CONFLICT',
|
|
449
|
+
message: 'RECIPIENTS_CHANGED',
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('rejects a credentialId that does not match the allocated refresh', async () => {
|
|
454
|
+
const prepared = await prepareHolderSend();
|
|
455
|
+
const { handoff } = await buildHandoff(prepared, {
|
|
456
|
+
credentialId: 'urn:uuid:not-the-allocated-credential',
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
await expect(sendHandoff(handoff)).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('rejects a holderDid that does not match the intended holder', async () => {
|
|
463
|
+
const prepared = await prepareHolderSend();
|
|
464
|
+
const { handoff } = await buildHandoff(prepared, {
|
|
465
|
+
holderDid: outsider.learnCard.id.did(),
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
await expect(sendHandoff(handoff)).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('rejects a credentialStatus that does not match the bound entries', async () => {
|
|
472
|
+
// Both entries are validly owned/purposed/in-bounds on their own, so
|
|
473
|
+
// supplying only one is a pure coordinate-set mismatch against what was
|
|
474
|
+
// bound at prepare time, not an owner/purpose/bounds failure.
|
|
475
|
+
const prepared = await prepareHolderSend({ statusPurposes: ['suspension'] });
|
|
476
|
+
expect(prepared.statusEntries).toHaveLength(2);
|
|
477
|
+
|
|
478
|
+
const { handoff } = await buildHandoff(prepared, {
|
|
479
|
+
credentialStatus: [prepared.statusEntries![0]!],
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
await expect(sendHandoff(handoff)).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('rejects an issuerDid the sending profile does not control', async () => {
|
|
486
|
+
const prepared = await prepareHolderSend();
|
|
487
|
+
const { handoff } = await buildHandoff(prepared, {
|
|
488
|
+
issuerDid: outsider.learnCard.id.did(),
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
await expect(sendHandoff(handoff)).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
describe('holder mode is incompatible with inbox recipients and plaintext sends', () => {
|
|
496
|
+
it('rejects an email recipient at prepare time', async () => {
|
|
497
|
+
await expect(
|
|
498
|
+
prepareHolderSend({ recipient: 'student@example.com' })
|
|
499
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('rejects a phone recipient at prepare time', async () => {
|
|
503
|
+
await expect(prepareHolderSend({ recipient: '+15555550123' })).rejects.toMatchObject({
|
|
504
|
+
code: 'BAD_REQUEST',
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('rejects mixing modes: a plaintext send against a holder-mode aggregate', async () => {
|
|
509
|
+
const prepared = await prepareHolderSend();
|
|
510
|
+
const plaintext = await signHolderCredential(prepared);
|
|
511
|
+
|
|
512
|
+
await expect(
|
|
513
|
+
issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
|
|
514
|
+
refreshId: prepared.refreshId,
|
|
515
|
+
credential: plaintext,
|
|
516
|
+
})
|
|
517
|
+
).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('rejects mixing modes: a holder-mode handoff against a network-mode aggregate', async () => {
|
|
521
|
+
const networkAllocation =
|
|
522
|
+
await issuer.clients.fullAuth.credentialRefresh.allocateCredentialRefresh({
|
|
523
|
+
holder: { profileId: HOLDER_PROFILE_ID, did: holder.learnCard.id.did() },
|
|
524
|
+
credentialId: 'urn:uuid:network-mode-aggregate',
|
|
525
|
+
});
|
|
526
|
+
const credential = await issuer.learnCard.invoke.issueCredential({
|
|
527
|
+
'@context': [
|
|
528
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
529
|
+
MANAGED_REFRESH_CONTEXT_FRAGMENT,
|
|
530
|
+
],
|
|
531
|
+
id: 'urn:uuid:network-mode-aggregate',
|
|
532
|
+
type: ['VerifiableCredential'],
|
|
533
|
+
issuer: issuer.learnCard.id.did(),
|
|
534
|
+
validFrom: '2026-01-01T00:00:00Z',
|
|
535
|
+
credentialSubject: { id: holder.learnCard.id.did() },
|
|
536
|
+
refreshService: networkAllocation.refreshService,
|
|
537
|
+
} as UnsignedVC);
|
|
538
|
+
const jwe = await issuer.learnCard.invoke.createDagJweExact(
|
|
539
|
+
credential,
|
|
540
|
+
DEFAULT_RECIPIENTS()
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
await expect(
|
|
544
|
+
sendHandoff({
|
|
545
|
+
refreshId: networkAllocation.refreshId,
|
|
546
|
+
credentialId: credential.id!,
|
|
547
|
+
holderDid: holder.learnCard.id.did(),
|
|
548
|
+
issuerDid: issuer.learnCard.id.did(),
|
|
549
|
+
// The handoff schema requires a credentialStatus; its value is
|
|
550
|
+
// irrelevant here since the mode check rejects before any
|
|
551
|
+
// status comparison runs.
|
|
552
|
+
credentialStatus: {
|
|
553
|
+
id: 'https://localhost%3A3000/status/dummy#0',
|
|
554
|
+
type: 'BitstringStatusListEntry',
|
|
555
|
+
statusPurpose: 'revocation',
|
|
556
|
+
statusListIndex: '0',
|
|
557
|
+
statusListCredential: 'https://localhost%3A3000/status/dummy',
|
|
558
|
+
},
|
|
559
|
+
jwe,
|
|
560
|
+
})
|
|
561
|
+
).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe('manager change after prepare', () => {
|
|
566
|
+
it('rejects a stale recipient set with RECIPIENTS_CHANGED, then succeeds after getEncryptionRecipients + re-encrypt', async () => {
|
|
567
|
+
const prepared = await prepareHolderSend();
|
|
568
|
+
expect(prepared.recipientKeyIds).toHaveLength(2);
|
|
569
|
+
|
|
570
|
+
// A manager is added after prepare: the previously-prepared set is now stale.
|
|
571
|
+
await linkManager(MANAGER2_PROFILE_ID);
|
|
572
|
+
|
|
573
|
+
const credential = await signHolderCredential(prepared);
|
|
574
|
+
const staleJwe = await issuer.learnCard.invoke.createDagJweExact(
|
|
575
|
+
credential,
|
|
576
|
+
DEFAULT_RECIPIENTS()
|
|
577
|
+
);
|
|
578
|
+
const { handoff: staleHandoff } = await buildHandoff(prepared, {
|
|
579
|
+
credential,
|
|
580
|
+
jwe: staleJwe,
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
await expect(sendHandoff(staleHandoff)).rejects.toMatchObject({
|
|
584
|
+
code: 'CONFLICT',
|
|
585
|
+
message: 'RECIPIENTS_CHANGED',
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const current = await issuer.clients.fullAuth.credentialRefresh.getEncryptionRecipients(
|
|
589
|
+
{
|
|
590
|
+
refreshId: prepared.refreshId,
|
|
591
|
+
}
|
|
592
|
+
);
|
|
593
|
+
expect(current.recipientKeyIds).toHaveLength(3);
|
|
594
|
+
|
|
595
|
+
// Only the issuer who allocated this refresh may see its recipients.
|
|
596
|
+
await expect(
|
|
597
|
+
outsider.clients.fullAuth.credentialRefresh.getEncryptionRecipients({
|
|
598
|
+
refreshId: prepared.refreshId,
|
|
599
|
+
})
|
|
600
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
601
|
+
|
|
602
|
+
const retryJwe = await issuer.learnCard.invoke.createDagJweExact(credential, [
|
|
603
|
+
holder.learnCard.id.did(),
|
|
604
|
+
manager.learnCard.id.did(),
|
|
605
|
+
manager2.learnCard.id.did(),
|
|
606
|
+
]);
|
|
607
|
+
const { handoff: retryHandoff } = await buildHandoff(prepared, {
|
|
608
|
+
credential,
|
|
609
|
+
jwe: retryJwe,
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const uri = await sendHandoff(retryHandoff);
|
|
613
|
+
expect(uri).toContain(':credential:');
|
|
614
|
+
await assertNoSentinelLeak();
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
describe('publishHolderEncryptedRefreshable', () => {
|
|
619
|
+
const sendAndActivate = async (): Promise<PreparedHolderSend> => {
|
|
620
|
+
const prepared = await prepareHolderSend();
|
|
621
|
+
const { handoff } = await buildHandoff(prepared);
|
|
622
|
+
await sendHandoff(handoff);
|
|
623
|
+
await setCredentialRefreshState(prepared.refreshId, 'active');
|
|
624
|
+
// Isolate publish-triggered notifications from the initial-send one.
|
|
625
|
+
addNotificationToQueueSpy.mockClear();
|
|
626
|
+
return prepared;
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
it('notify: true queues a notification; notify: false suppresses it', async () => {
|
|
630
|
+
const prepared = await sendAndActivate();
|
|
631
|
+
|
|
632
|
+
const { handoff: v2 } = await buildHandoff(prepared, {
|
|
633
|
+
credential: await signHolderCredential(prepared, {
|
|
634
|
+
validFrom: '2026-02-01T00:00:00Z',
|
|
635
|
+
}),
|
|
636
|
+
});
|
|
637
|
+
const published =
|
|
638
|
+
await issuer.clients.fullAuth.credentialRefresh.publishHolderEncryptedRefreshable({
|
|
639
|
+
handoff: v2,
|
|
640
|
+
notify: true,
|
|
641
|
+
});
|
|
642
|
+
expect(published.version).toEqual(2);
|
|
643
|
+
expect(published.notification).toEqual('queued');
|
|
644
|
+
expect(addNotificationToQueueSpy).toHaveBeenCalledTimes(1);
|
|
645
|
+
|
|
646
|
+
addNotificationToQueueSpy.mockClear();
|
|
647
|
+
|
|
648
|
+
const { handoff: v3 } = await buildHandoff(prepared, {
|
|
649
|
+
credential: await signHolderCredential(prepared, {
|
|
650
|
+
validFrom: '2026-03-01T00:00:00Z',
|
|
651
|
+
}),
|
|
652
|
+
});
|
|
653
|
+
const publishedSuppressed =
|
|
654
|
+
await issuer.clients.fullAuth.credentialRefresh.publishHolderEncryptedRefreshable({
|
|
655
|
+
handoff: v3,
|
|
656
|
+
notify: false,
|
|
657
|
+
});
|
|
658
|
+
expect(publishedSuppressed.version).toEqual(3);
|
|
659
|
+
expect(publishedSuppressed.notification).toEqual('suppressed');
|
|
660
|
+
expect(addNotificationToQueueSpy).not.toHaveBeenCalled();
|
|
661
|
+
|
|
662
|
+
const head = await getCredentialRefreshHead(prepared.refreshId);
|
|
663
|
+
expect(head?.version).toEqual(3);
|
|
664
|
+
await assertNoSentinelLeak();
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('rejects signing-authority publication for a holder-mode aggregate', async () => {
|
|
668
|
+
const prepared = await sendAndActivate();
|
|
669
|
+
|
|
670
|
+
await expect(
|
|
671
|
+
issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
|
|
672
|
+
mode: 'signing-authority',
|
|
673
|
+
refreshId: prepared.refreshId,
|
|
674
|
+
credential: {
|
|
675
|
+
'@context': [
|
|
676
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
677
|
+
MANAGED_REFRESH_CONTEXT_FRAGMENT,
|
|
678
|
+
],
|
|
679
|
+
type: ['VerifiableCredential'],
|
|
680
|
+
issuer: issuer.learnCard.id.did(),
|
|
681
|
+
validFrom: '2026-02-01T00:00:00Z',
|
|
682
|
+
credentialSubject: { id: prepared.holderDid },
|
|
683
|
+
refreshService: prepared.refreshService,
|
|
684
|
+
} as UnsignedVC,
|
|
685
|
+
signingAuthority: {
|
|
686
|
+
type: 'SigningAuthority',
|
|
687
|
+
name: 'unregistered',
|
|
688
|
+
endpoint: 'https://sa.example.com',
|
|
689
|
+
},
|
|
690
|
+
} as Parameters<
|
|
691
|
+
typeof issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh
|
|
692
|
+
>[0])
|
|
693
|
+
).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it('rejects a handoff whose issuer differs from version 1', async () => {
|
|
697
|
+
const prepared = await sendAndActivate();
|
|
698
|
+
const { handoff } = await buildHandoff(prepared, {
|
|
699
|
+
issuerDid: outsider.learnCard.id.did(),
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
await expect(
|
|
703
|
+
issuer.clients.fullAuth.credentialRefresh.publishHolderEncryptedRefreshable({
|
|
704
|
+
handoff,
|
|
705
|
+
notify: false,
|
|
706
|
+
})
|
|
707
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
});
|