@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
|
@@ -7,6 +7,7 @@ import { environment } from '@environment';
|
|
|
7
7
|
import {
|
|
8
8
|
ConsentFlowTransactionValidator,
|
|
9
9
|
ConsentFlowWebhookMetadataValidator,
|
|
10
|
+
ConsentFlowContractNotificationDataValidator,
|
|
10
11
|
LCNNotificationTypeEnumValidator,
|
|
11
12
|
type ConsentFlowTransaction,
|
|
12
13
|
type ConsentFlowWebhookMetadata,
|
|
@@ -25,8 +26,10 @@ import { sanitizeProfileForTier, stripSensitiveProfileListFields } from './profi
|
|
|
25
26
|
import { getStoredContractRequest } from '@accesslayer/consentflowcontract/read';
|
|
26
27
|
import { resolveRecipientLocale } from './getRecipientLocale.helpers';
|
|
27
28
|
import { ensureContractEventMaintenance } from './contract-event-maintenance.helpers';
|
|
29
|
+
import { updateDidForProfile } from './did.helpers';
|
|
30
|
+
import { buildContractUpdatePreview } from '@learncard/helpers';
|
|
28
31
|
|
|
29
|
-
type EventKind = ConsentFlowWebhookMetadata['event'];
|
|
32
|
+
type EventKind = ConsentFlowWebhookMetadata['event'] | 'contract_updated';
|
|
30
33
|
const defaultDomain = (): string => environment.DOMAIN_NAME ?? 'network.learncard.com';
|
|
31
34
|
|
|
32
35
|
const MAX_DELIVERY_ATTEMPTS = 12;
|
|
@@ -62,7 +65,7 @@ export const appendConsentEvent = (
|
|
|
62
65
|
const kind: EventKind =
|
|
63
66
|
transaction.action === 'consent'
|
|
64
67
|
? 'consent_created'
|
|
65
|
-
: transaction.action === 'update'
|
|
68
|
+
: transaction.action === 'update' || transaction.action === 'reconsent'
|
|
66
69
|
? 'consent_updated'
|
|
67
70
|
: transaction.action === 'withdraw'
|
|
68
71
|
? 'consent_withdrawn'
|
|
@@ -210,7 +213,7 @@ export const authorizeContractNotification = async (
|
|
|
210
213
|
if (!relationship || !parsedTerms.success) return false;
|
|
211
214
|
const terms = parsedTerms.data;
|
|
212
215
|
if (
|
|
213
|
-
!(terms.status === 'live' || (terms.status === '
|
|
216
|
+
!(terms.status === 'live' || (terms.status === 'consumed' && terms.oneTime)) ||
|
|
214
217
|
!expiryActive(terms.expiresAt) ||
|
|
215
218
|
!expiryActive(relationship.contract.expiresAt)
|
|
216
219
|
)
|
|
@@ -245,6 +248,35 @@ const buildNotification = async (
|
|
|
245
248
|
getContractById(event.contractId),
|
|
246
249
|
]);
|
|
247
250
|
if (!from || !to || !owner) return null;
|
|
251
|
+
if (event.kind === 'contract_updated') {
|
|
252
|
+
if (!contract) return null;
|
|
253
|
+
const data = ConsentFlowContractNotificationDataValidator.parse(
|
|
254
|
+
JSON.parse(event.payload ?? '')
|
|
255
|
+
);
|
|
256
|
+
const preview =
|
|
257
|
+
data.preview ??
|
|
258
|
+
buildContractUpdatePreview({
|
|
259
|
+
name: contract.name,
|
|
260
|
+
kind: data.kind,
|
|
261
|
+
diff: data.diff,
|
|
262
|
+
});
|
|
263
|
+
const sender = updateDidForProfile(event.domain, from);
|
|
264
|
+
return {
|
|
265
|
+
type: LCNNotificationTypeEnumValidator.enum.CONSENT_FLOW_CONTRACT_UPDATED,
|
|
266
|
+
from: {
|
|
267
|
+
...stripSensitiveProfileListFields(
|
|
268
|
+
sanitizeProfileForTier(sender, 'unauthenticated')
|
|
269
|
+
),
|
|
270
|
+
did: sender.did,
|
|
271
|
+
},
|
|
272
|
+
to: updateDidForProfile(event.domain, to),
|
|
273
|
+
sent: event.createdAt,
|
|
274
|
+
...(data.kind === 'requires_consent'
|
|
275
|
+
? { message: { title: preview.headline, body: data.reason ?? preview.subhead } }
|
|
276
|
+
: {}),
|
|
277
|
+
data,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
248
280
|
const eventKind =
|
|
249
281
|
delivery.role === 'requester' && event.requestAccepted ? 'request_accepted' : event.kind;
|
|
250
282
|
const metadata = ConsentFlowWebhookMetadataValidator.parse({
|
|
@@ -99,7 +99,7 @@ export const sendGenericContractRequest = async (
|
|
|
99
99
|
} AND CASE WHEN contract.expiresAt IS NULL OR trim(contract.expiresAt) = '' THEN true ELSE datetime(contract.expiresAt) > datetime($now) END
|
|
100
100
|
AND NOT EXISTS {
|
|
101
101
|
MATCH (target)-[:CREATED_BY]->(terms:ConsentFlowTerms)-[:CONSENTS_TO]->(contract)
|
|
102
|
-
WHERE (terms.status = 'live' OR (terms.status = '
|
|
102
|
+
WHERE (terms.status = 'live' OR (terms.status = 'consumed' AND terms.oneTime = true)) AND
|
|
103
103
|
CASE WHEN terms.expiresAt IS NULL OR trim(terms.expiresAt) = '' THEN true ELSE datetime(terms.expiresAt) > datetime($now) END
|
|
104
104
|
}
|
|
105
105
|
OPTIONAL MATCH (contract)-[existing:REQUESTED_FOR]->(target)
|
|
@@ -5,7 +5,11 @@ import {
|
|
|
5
5
|
} from '@learncard/types';
|
|
6
6
|
import cloneDeep from 'lodash/cloneDeep';
|
|
7
7
|
|
|
8
|
-
export const areTermsValid = (
|
|
8
|
+
export const areTermsValid = (
|
|
9
|
+
terms: ConsentFlowTermsInput,
|
|
10
|
+
contractSnapshot: ConsentFlowContractInput
|
|
11
|
+
) => {
|
|
12
|
+
const contract = contractSnapshot;
|
|
9
13
|
const contractPersonalReadKeys = Object.keys(contract.read?.personal ?? {});
|
|
10
14
|
const contractPersonalWriteKeys = Object.keys(contract.write?.personal ?? {});
|
|
11
15
|
const contractCategoryReadKeys = Object.keys(contract.read?.credentials?.categories ?? {});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
CredentialDisplayValidator,
|
|
4
|
+
PublishCredentialRefreshInputValidator,
|
|
5
|
+
} from '@learncard/types';
|
|
6
|
+
import { getPublicBoostDisplayName } from './credential-display.helpers';
|
|
7
|
+
import { buildCredentialUpdatedEmailModel } from './credential-refresh-email-content.helpers';
|
|
8
|
+
|
|
9
|
+
describe('public credential display', () => {
|
|
10
|
+
it('never infers an anchor title from private claims or the generic list label', () => {
|
|
11
|
+
const name = getPublicBoostDisplayName({
|
|
12
|
+
name: 'Credential',
|
|
13
|
+
boost: JSON.stringify({ displayOnlyAnchor: true, display: {}, name: 'PRIVATE CLAIM' }),
|
|
14
|
+
});
|
|
15
|
+
expect(name).toBeUndefined();
|
|
16
|
+
expect(
|
|
17
|
+
JSON.stringify(buildCredentialUpdatedEmailModel({ credentialTitle: name }))
|
|
18
|
+
).not.toContain('PRIVATE CLAIM');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('uses only explicit public display fields', () => {
|
|
22
|
+
const display = CredentialDisplayValidator.parse({
|
|
23
|
+
name: 'Public label',
|
|
24
|
+
claims: 'PRIVATE',
|
|
25
|
+
});
|
|
26
|
+
expect(display).toEqual({ name: 'Public label' });
|
|
27
|
+
expect(
|
|
28
|
+
getPublicBoostDisplayName({
|
|
29
|
+
boost: JSON.stringify({ displayOnlyAnchor: true, display }),
|
|
30
|
+
})
|
|
31
|
+
).toBe('Public label');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('leaves reusable Boost metadata unchanged', () => {
|
|
35
|
+
expect(getPublicBoostDisplayName({ name: 'Public template', boost: '{}' })).toBe(
|
|
36
|
+
'Public template'
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does not mistake an ordinary Boost's own display object (e.g. app appearance fields) for a display-only anchor", () => {
|
|
41
|
+
// Real app-created BoostCredentials have their own top-level `display` object
|
|
42
|
+
// (backgroundImage, backgroundColor, displayType) — `'display' in boost` alone
|
|
43
|
+
// is not a safe anchor marker, unlike `displayOnlyAnchor`.
|
|
44
|
+
const name = getPublicBoostDisplayName({
|
|
45
|
+
name: 'Real Boost Name',
|
|
46
|
+
boost: JSON.stringify({
|
|
47
|
+
display: { backgroundColor: '#fff', backgroundImage: '', displayType: 'card' },
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
expect(name).toBe('Real Boost Name');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('bounds public summaries and rejects arbitrary update reasons', () => {
|
|
54
|
+
const input = {
|
|
55
|
+
refreshId: 'refresh',
|
|
56
|
+
mode: 'issuer-signed',
|
|
57
|
+
updateSummary: 'x'.repeat(281),
|
|
58
|
+
updateReason: 'PRIVATE',
|
|
59
|
+
};
|
|
60
|
+
const result = PublishCredentialRefreshInputValidator.safeParse(input);
|
|
61
|
+
expect(result.success).toBe(false);
|
|
62
|
+
if (!result.success) {
|
|
63
|
+
expect(result.error.issues.map(issue => issue.path[0])).toEqual(
|
|
64
|
+
expect.arrayContaining(['updateSummary', 'updateReason'])
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { CredentialDisplayValidator } from '@learncard/types';
|
|
2
|
+
import { isDisplayOnlyAnchorTemplate } from '@learncard/helpers';
|
|
3
|
+
|
|
4
|
+
/** Display-only anchors distinguish an omitted public name from the generic list label. */
|
|
5
|
+
export const getPublicBoostDisplayName = (
|
|
6
|
+
boost: { boost: string; name?: string } | null | undefined
|
|
7
|
+
): string | undefined => {
|
|
8
|
+
if (!boost) return undefined;
|
|
9
|
+
const template: unknown = JSON.parse(boost.boost);
|
|
10
|
+
if (isDisplayOnlyAnchorTemplate(template)) {
|
|
11
|
+
return CredentialDisplayValidator.parse((template as { display?: unknown }).display).name;
|
|
12
|
+
}
|
|
13
|
+
// Explicit reusable Boost/CMS metadata retains its existing public contract.
|
|
14
|
+
return boost.name;
|
|
15
|
+
};
|
|
@@ -2,6 +2,7 @@ import { environment } from '@environment';
|
|
|
2
2
|
import { generateClaimUrl } from './contact-method.helpers';
|
|
3
3
|
import type { BoostInstance } from '@models';
|
|
4
4
|
import { getBoostById } from '@accesslayer/boost/read';
|
|
5
|
+
import { getPublicBoostDisplayName } from './credential-display.helpers';
|
|
5
6
|
import { getContactMethodsForProfile } from '@accesslayer/contact-method/read';
|
|
6
7
|
import {
|
|
7
8
|
claimCredentialRefreshEmailDelivery,
|
|
@@ -84,29 +85,21 @@ export const resolveCredentialRefreshEmailTarget = async (
|
|
|
84
85
|
};
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* The holder-encrypted payload is never decrypted to recover a title.
|
|
88
|
+
* Only explicit public Boost display metadata can supply an email title.
|
|
89
|
+
* Historical publication titles may contain claims, so they are never used.
|
|
90
90
|
*/
|
|
91
91
|
export const resolveCredentialRefreshEmailDisplayTitle = async (params: {
|
|
92
92
|
version: CredentialRefreshVersionNode;
|
|
93
93
|
refreshId: string;
|
|
94
94
|
}): Promise<string | undefined> => {
|
|
95
|
-
const {
|
|
96
|
-
const fromVersion = extractBoundedCredentialDisplayTitle(version.credentialDisplayName);
|
|
97
|
-
|
|
98
|
-
if (fromVersion) return fromVersion;
|
|
99
|
-
|
|
95
|
+
const { refreshId } = params;
|
|
100
96
|
const aggregate = await getCredentialRefresh(refreshId);
|
|
101
|
-
const fromAggregate = extractBoundedCredentialDisplayTitle(aggregate?.credentialDisplayName);
|
|
102
|
-
|
|
103
|
-
if (fromAggregate) return fromAggregate;
|
|
104
97
|
|
|
105
98
|
if (!aggregate?.boostId) return undefined;
|
|
106
99
|
|
|
107
100
|
const boost: BoostInstance | null = await getBoostById(aggregate.boostId);
|
|
108
101
|
|
|
109
|
-
return extractBoundedCredentialDisplayTitle(boost
|
|
102
|
+
return extractBoundedCredentialDisplayTitle(getPublicBoostDisplayName(boost));
|
|
110
103
|
};
|
|
111
104
|
|
|
112
105
|
/**
|
|
@@ -1,11 +1,148 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
computeCredentialMaterialDigest,
|
|
5
|
+
matchesCredentialMaterialDigest,
|
|
6
|
+
matchesCredentialSubjectDigest,
|
|
7
|
+
getCredentialRefreshDigestCandidateSecrets,
|
|
8
|
+
decideCredentialRefreshNotification,
|
|
4
9
|
computeCredentialSubjectDigest,
|
|
10
|
+
computeCredentialStatusDigest,
|
|
11
|
+
matchesCredentialStatusDigest,
|
|
5
12
|
computeCredentialRefreshInitialDeliveryKey,
|
|
6
13
|
computeCredentialRefreshRouteKey,
|
|
7
14
|
} from './credential-refresh-materiality.helpers';
|
|
8
15
|
|
|
16
|
+
describe('versioned status fingerprints', () => {
|
|
17
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
18
|
+
|
|
19
|
+
it('matches current-version digests without parsing previous keys', () => {
|
|
20
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_KEY_VERSION', 'v2');
|
|
21
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_SECRET', 'current-test-key');
|
|
22
|
+
const status = { type: 'BitstringStatusListEntry', statusPurpose: 'revocation' };
|
|
23
|
+
const digest = computeCredentialStatusDigest(status);
|
|
24
|
+
// Startup validation is separate; current matches never consult old keys.
|
|
25
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS', 'invalid-json');
|
|
26
|
+
expect(getCredentialRefreshDigestCandidateSecrets('v2')).toEqual(['current-test-key']);
|
|
27
|
+
expect(matchesCredentialStatusDigest(status, digest)).toBe(true);
|
|
28
|
+
expect(() => getCredentialRefreshDigestCandidateSecrets('v1')).toThrow();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('retains shape, array order and fields, and accepts unversioned current-key digests', () => {
|
|
32
|
+
const status = { type: 'BitstringStatusListEntry', statusPurpose: 'revocation' };
|
|
33
|
+
const digest = computeCredentialStatusDigest(status);
|
|
34
|
+
expect(digest).toMatch(/^v1:/);
|
|
35
|
+
expect(matchesCredentialStatusDigest(status, digest)).toBe(true);
|
|
36
|
+
expect(matchesCredentialStatusDigest(status, digest.split(':')[1])).toBe(true);
|
|
37
|
+
expect(matchesCredentialStatusDigest([status], digest)).toBe(false);
|
|
38
|
+
expect(matchesCredentialStatusDigest({ ...status, extra: true }, digest)).toBe(false);
|
|
39
|
+
expect(
|
|
40
|
+
matchesCredentialStatusDigest([status, {}], computeCredentialStatusDigest([{}, status]))
|
|
41
|
+
).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('accepts only current or explicitly retained previous keys', () => {
|
|
45
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_SECRET', 'old-test-key');
|
|
46
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_KEY_VERSION', 'v1');
|
|
47
|
+
const digest = computeCredentialStatusDigest({ type: 'example' });
|
|
48
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_SECRET', 'new-test-key');
|
|
49
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_KEY_VERSION', 'v2');
|
|
50
|
+
vi.stubEnv(
|
|
51
|
+
'CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS',
|
|
52
|
+
JSON.stringify({ v1: 'old-test-key' })
|
|
53
|
+
);
|
|
54
|
+
expect(matchesCredentialStatusDigest({ type: 'example' }, digest)).toBe(true);
|
|
55
|
+
expect(matchesCredentialStatusDigest({ type: 'example' }, digest.split(':')[1])).toBe(true);
|
|
56
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS', '{}');
|
|
57
|
+
expect(matchesCredentialStatusDigest({ type: 'example' }, digest)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('subject and material digest key rotation', () => {
|
|
62
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
63
|
+
|
|
64
|
+
it.each([false, true])('matches retained old keys (version prefix: %s)', prefixed => {
|
|
65
|
+
const subject = { id: 'holder' };
|
|
66
|
+
const credential = { credentialSubject: subject, name: 'Original' };
|
|
67
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_SECRET', 'old-test-key');
|
|
68
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_KEY_VERSION', 'v1');
|
|
69
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS', '{}');
|
|
70
|
+
const prefix = prefixed ? 'v1:' : '';
|
|
71
|
+
const subjectDigest = prefix + computeCredentialSubjectDigest(subject);
|
|
72
|
+
const materialDigest = prefix + computeCredentialMaterialDigest(credential);
|
|
73
|
+
|
|
74
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_SECRET', 'new-test-key');
|
|
75
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_KEY_VERSION', 'v2');
|
|
76
|
+
vi.stubEnv(
|
|
77
|
+
'CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS',
|
|
78
|
+
JSON.stringify({ v1: 'old-test-key' })
|
|
79
|
+
);
|
|
80
|
+
expect(getCredentialRefreshDigestCandidateSecrets()).toEqual([
|
|
81
|
+
'new-test-key',
|
|
82
|
+
'old-test-key',
|
|
83
|
+
]);
|
|
84
|
+
expect(matchesCredentialSubjectDigest(subject, subjectDigest)).toBe(true);
|
|
85
|
+
expect(matchesCredentialMaterialDigest(credential, materialDigest)).toBe(true);
|
|
86
|
+
expect(matchesCredentialSubjectDigest({ id: 'other' }, subjectDigest)).toBe(false);
|
|
87
|
+
expect(
|
|
88
|
+
matchesCredentialMaterialDigest({ ...credential, name: 'Changed' }, materialDigest)
|
|
89
|
+
).toBe(false);
|
|
90
|
+
expect(matchesCredentialSubjectDigest(subject, `unknown:${subjectDigest}`)).toBe(false);
|
|
91
|
+
expect(matchesCredentialMaterialDigest(credential, `unknown:${materialDigest}`)).toBe(
|
|
92
|
+
false
|
|
93
|
+
);
|
|
94
|
+
const nextDigest = computeCredentialMaterialDigest(credential);
|
|
95
|
+
expect(nextDigest).toBe(computeCredentialMaterialDigest(credential, 'new-test-key'));
|
|
96
|
+
expect(computeCredentialSubjectDigest(subject)).toBe(
|
|
97
|
+
computeCredentialSubjectDigest(subject, 'new-test-key')
|
|
98
|
+
);
|
|
99
|
+
expect(
|
|
100
|
+
decideCredentialRefreshNotification({
|
|
101
|
+
state: 'active',
|
|
102
|
+
previousDigest: matchesCredentialMaterialDigest(credential, materialDigest)
|
|
103
|
+
? nextDigest
|
|
104
|
+
: materialDigest,
|
|
105
|
+
nextDigest,
|
|
106
|
+
})
|
|
107
|
+
).toBe('suppressed');
|
|
108
|
+
|
|
109
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS', '{}');
|
|
110
|
+
expect(matchesCredentialSubjectDigest(subject, subjectDigest)).toBe(false);
|
|
111
|
+
expect(matchesCredentialMaterialDigest(credential, materialDigest)).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects missing digests and mismatched version prefixes', () => {
|
|
115
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_SECRET', 'current-test-key');
|
|
116
|
+
vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_KEY_VERSION', 'v2');
|
|
117
|
+
vi.stubEnv(
|
|
118
|
+
'CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS',
|
|
119
|
+
JSON.stringify({ v1: 'old-test-key' })
|
|
120
|
+
);
|
|
121
|
+
const subject = { id: 'holder' };
|
|
122
|
+
const credential = { credentialSubject: subject };
|
|
123
|
+
expect(matchesCredentialSubjectDigest(subject)).toBe(false);
|
|
124
|
+
expect(matchesCredentialMaterialDigest(credential, '')).toBe(false);
|
|
125
|
+
expect(
|
|
126
|
+
matchesCredentialSubjectDigest(subject, `v1:${computeCredentialSubjectDigest(subject)}`)
|
|
127
|
+
).toBe(false);
|
|
128
|
+
expect(
|
|
129
|
+
matchesCredentialMaterialDigest(
|
|
130
|
+
credential,
|
|
131
|
+
`v1:${computeCredentialMaterialDigest(credential)}`
|
|
132
|
+
)
|
|
133
|
+
).toBe(false);
|
|
134
|
+
expect(
|
|
135
|
+
matchesCredentialSubjectDigest(subject, `v2:${computeCredentialSubjectDigest(subject)}`)
|
|
136
|
+
).toBe(true);
|
|
137
|
+
expect(
|
|
138
|
+
matchesCredentialMaterialDigest(
|
|
139
|
+
credential,
|
|
140
|
+
`v2:${computeCredentialMaterialDigest(credential)}`
|
|
141
|
+
)
|
|
142
|
+
).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
9
146
|
describe('managed initial credential notification keys', () => {
|
|
10
147
|
it('derives a stable opaque initial-delivery key from the refresh route key', () => {
|
|
11
148
|
const secret = 'test-secret';
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createHmac } from 'crypto';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getCredentialRefreshRuntimeEnvironment,
|
|
5
|
+
getCredentialRefreshPreviousDigestKeys,
|
|
6
|
+
} from '@environment';
|
|
4
7
|
import { canonicalizeCredentialContent, canonicalizeCredentialJson } from '@learncard/helpers';
|
|
5
8
|
import type { PublishCredentialRefreshNotification } from '@learncard/types';
|
|
6
9
|
|
|
@@ -37,6 +40,55 @@ export const getCredentialRefreshDigestSecret = (): string => {
|
|
|
37
40
|
);
|
|
38
41
|
};
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Returns current then explicitly retained previous HMAC secrets for legacy digests.
|
|
45
|
+
* A version prefix restricts matching to that version's key, just as status digests do.
|
|
46
|
+
* Candidates are read-only compatibility keys; new writes use the current secret.
|
|
47
|
+
* Rotate SECRET and KEY_VERSION together, retaining the old secret under its old
|
|
48
|
+
* version in CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS until all aggregates republish.
|
|
49
|
+
* Original subject/status/root fingerprints survive republishing, so retain their
|
|
50
|
+
* keys while those aggregates still need publication, repair, or initial-send retries.
|
|
51
|
+
* Notification route/delivery keys also rotate, potentially producing one duplicate
|
|
52
|
+
* notification across the rotation boundary.
|
|
53
|
+
*/
|
|
54
|
+
export const getCredentialRefreshDigestCandidateSecrets = (version?: string): string[] => {
|
|
55
|
+
const runtime = getCredentialRefreshRuntimeEnvironment();
|
|
56
|
+
const current = getCredentialRefreshDigestSecret();
|
|
57
|
+
if (version === runtime.CREDENTIAL_REFRESH_DIGEST_KEY_VERSION) return [current];
|
|
58
|
+
const previous = getCredentialRefreshPreviousDigestKeys();
|
|
59
|
+
if (version === undefined) return [current, ...Object.values(previous)];
|
|
60
|
+
return Object.prototype.hasOwnProperty.call(previous, version) ? [previous[version]!] : [];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const matchesStoredDigest = (
|
|
64
|
+
stored: string | undefined,
|
|
65
|
+
compute: (secret: string) => string
|
|
66
|
+
): boolean => {
|
|
67
|
+
if (!stored) return false;
|
|
68
|
+
const separator = stored.indexOf(':');
|
|
69
|
+
const version = separator < 0 ? undefined : stored.slice(0, separator);
|
|
70
|
+
const digest = separator < 0 ? stored : stored.slice(separator + 1);
|
|
71
|
+
return getCredentialRefreshDigestCandidateSecrets(version).some(
|
|
72
|
+
secret => compute(secret) === digest
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Matches persisted material fingerprints across explicitly retained key rotations. */
|
|
77
|
+
export const matchesCredentialMaterialDigest = (
|
|
78
|
+
credential: CredentialLike,
|
|
79
|
+
stored?: string
|
|
80
|
+
): boolean =>
|
|
81
|
+
matchesStoredDigest(stored, secret => computeCredentialMaterialDigest(credential, secret));
|
|
82
|
+
|
|
83
|
+
/** Matches persisted subject identities across explicitly retained key rotations. */
|
|
84
|
+
export const matchesCredentialSubjectDigest = (
|
|
85
|
+
credentialSubject: unknown,
|
|
86
|
+
stored?: string
|
|
87
|
+
): boolean =>
|
|
88
|
+
matchesStoredDigest(stored, secret =>
|
|
89
|
+
computeCredentialSubjectDigest(credentialSubject, secret)
|
|
90
|
+
);
|
|
91
|
+
|
|
40
92
|
/**
|
|
41
93
|
* Keys excluded from the user-visible projection:
|
|
42
94
|
*
|
|
@@ -96,11 +148,20 @@ export const computeCredentialStatusDigest = (
|
|
|
96
148
|
credentialStatus: unknown,
|
|
97
149
|
secret: string = getCredentialRefreshDigestSecret()
|
|
98
150
|
): string =>
|
|
151
|
+
`${getCredentialRefreshRuntimeEnvironment().CREDENTIAL_REFRESH_DIGEST_KEY_VERSION}:${statusDigest(credentialStatus, secret)}`;
|
|
152
|
+
|
|
153
|
+
const statusDigest = (credentialStatus: unknown, secret: string): string =>
|
|
99
154
|
createHmac('sha256', secret)
|
|
100
155
|
.update('credential-status:v1\0')
|
|
101
156
|
.update(canonicalizeCredentialJson(credentialStatus ?? null))
|
|
102
157
|
.digest('base64url');
|
|
103
158
|
|
|
159
|
+
/** Legacy fingerprints accept the current key or explicitly retained previous keys. */
|
|
160
|
+
export const matchesCredentialStatusDigest = (
|
|
161
|
+
credentialStatus: unknown,
|
|
162
|
+
stored?: string
|
|
163
|
+
): boolean => matchesStoredDigest(stored, secret => statusDigest(credentialStatus, secret));
|
|
164
|
+
|
|
104
165
|
/** Keyed fingerprint matching the holder SDK's sorted subject identity comparison. */
|
|
105
166
|
export const computeCredentialSubjectDigest = (
|
|
106
167
|
credentialSubject: unknown,
|
|
@@ -82,7 +82,7 @@ describe('managed refresh proof verification', () => {
|
|
|
82
82
|
{
|
|
83
83
|
code: 'BAD_REQUEST',
|
|
84
84
|
message: 'Credential proof could not be verified',
|
|
85
|
-
cause,
|
|
85
|
+
cause: expect.objectContaining({ message: 'CREDENTIAL_PROOF_INVALID' }),
|
|
86
86
|
}
|
|
87
87
|
);
|
|
88
88
|
expect(verifier.verifyCredential).toHaveBeenCalledTimes(retry ? 2 : 1);
|
|
@@ -28,11 +28,11 @@ export const verifyManagedRefreshProof = async (
|
|
|
28
28
|
result = await verifier.verifyCredential(credential, { checks: ['proof'] });
|
|
29
29
|
if (proofVerified(result)) return;
|
|
30
30
|
}
|
|
31
|
-
} catch
|
|
31
|
+
} catch {
|
|
32
32
|
throw new TRPCError({
|
|
33
33
|
code: 'BAD_REQUEST',
|
|
34
34
|
message: 'Credential proof could not be verified',
|
|
35
|
-
cause,
|
|
35
|
+
cause: 'CREDENTIAL_PROOF_INVALID',
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
|