@learncard/network-brain-service 4.1.3 → 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 +143 -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,155 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Transaction } from 'neo4j-driver';
|
|
3
|
+
import type { BitstringStatusListEntry } from '@learncard/types';
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
run: vi.fn(),
|
|
7
|
+
transaction: vi.fn(),
|
|
8
|
+
entries: vi.fn(),
|
|
9
|
+
setBit: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock('@instance', () => ({ neogma: { getTransaction: mocks.transaction } }));
|
|
12
|
+
vi.mock('./status-list.helpers', () => ({
|
|
13
|
+
CREDENTIAL_STATUS_ROOT_QUERY: 'MATCH (credential:Credential {id: $credentialId})',
|
|
14
|
+
getStatusEntriesForCredential: mocks.entries,
|
|
15
|
+
setStatusListEntryBit: mocks.setBit,
|
|
16
|
+
isNetworkStatusEntry: (entry: BitstringStatusListEntry) =>
|
|
17
|
+
new URL(entry.statusListCredential).hostname === 'network.example',
|
|
18
|
+
}));
|
|
19
|
+
import {
|
|
20
|
+
mutateCredentialStatus,
|
|
21
|
+
reconcileCredentialStatus,
|
|
22
|
+
} from './credential-status-mutation.helpers';
|
|
23
|
+
|
|
24
|
+
const entry: BitstringStatusListEntry = {
|
|
25
|
+
type: 'BitstringStatusListEntry',
|
|
26
|
+
statusPurpose: 'revocation',
|
|
27
|
+
statusListIndex: '7',
|
|
28
|
+
statusListCredential: 'https://network.example/status-lists/list',
|
|
29
|
+
};
|
|
30
|
+
const tx = { run: mocks.run } as unknown as Transaction;
|
|
31
|
+
const record = (values: Record<string, unknown>) => ({
|
|
32
|
+
get: (key: string): unknown => values[key] ?? null,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('credential lifecycle status mutations', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.resetAllMocks();
|
|
38
|
+
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
39
|
+
mocks.transaction.mockImplementation(
|
|
40
|
+
async (_existing: unknown, callback: (transaction: Transaction) => Promise<unknown>) =>
|
|
41
|
+
callback(tx)
|
|
42
|
+
);
|
|
43
|
+
mocks.run.mockResolvedValue({
|
|
44
|
+
records: [record({ rootId: 'root', owner: 'status-owner' })],
|
|
45
|
+
});
|
|
46
|
+
mocks.entries.mockResolvedValue([entry]);
|
|
47
|
+
mocks.setBit.mockResolvedValue(true);
|
|
48
|
+
});
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
vi.restoreAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it.each([
|
|
54
|
+
{ value: true, previous: null, next: 'suspended' },
|
|
55
|
+
{ value: false, previous: 'suspended', next: null },
|
|
56
|
+
])(
|
|
57
|
+
'updates revocation-only relationship suspension value=$value',
|
|
58
|
+
async ({ value, previous, next }) => {
|
|
59
|
+
mocks.run.mockResolvedValue({
|
|
60
|
+
records: [record({ rootId: 'root', owner: 'status-owner', status: previous })],
|
|
61
|
+
});
|
|
62
|
+
await expect(
|
|
63
|
+
mutateCredentialStatus('version', 'recipient', 'suspension', value)
|
|
64
|
+
).resolves.toBe(true);
|
|
65
|
+
expect(mocks.setBit).not.toHaveBeenCalled();
|
|
66
|
+
expect(console.warn).toHaveBeenCalledWith(expect.any(String), {
|
|
67
|
+
rootId: 'root',
|
|
68
|
+
purpose: 'suspension',
|
|
69
|
+
});
|
|
70
|
+
expect(mocks.run).toHaveBeenLastCalledWith(
|
|
71
|
+
expect.stringContaining('SET sent.status = $status'),
|
|
72
|
+
expect.objectContaining({ status: next })
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
it('revokes using the stored status owner before the sender', async () => {
|
|
77
|
+
await expect(
|
|
78
|
+
mutateCredentialStatus('version', 'recipient', 'revocation', true)
|
|
79
|
+
).resolves.toBe(true);
|
|
80
|
+
expect(mocks.run.mock.calls[0]?.[0]).toContain(
|
|
81
|
+
'coalesce(refresh.issuerProfileId, root.statusOwnerProfileId, issuer.profileId) AS owner'
|
|
82
|
+
);
|
|
83
|
+
expect(mocks.setBit).toHaveBeenCalledWith(entry, true, 'status-owner', tx);
|
|
84
|
+
expect(mocks.run).toHaveBeenLastCalledWith(
|
|
85
|
+
expect.stringContaining('SET sent.status = $status'),
|
|
86
|
+
expect.objectContaining({ status: 'revoked' })
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
it.each(['revocation', 'suspension'] as const)(
|
|
90
|
+
'fails before relationship writes when advertised %s bit fails',
|
|
91
|
+
async purpose => {
|
|
92
|
+
mocks.entries.mockResolvedValue([{ ...entry, statusPurpose: purpose }]);
|
|
93
|
+
mocks.run
|
|
94
|
+
.mockResolvedValueOnce({
|
|
95
|
+
records: [
|
|
96
|
+
record({
|
|
97
|
+
rootId: 'root',
|
|
98
|
+
owner: 'status-owner',
|
|
99
|
+
storedOwner: null,
|
|
100
|
+
managed: false,
|
|
101
|
+
}),
|
|
102
|
+
],
|
|
103
|
+
})
|
|
104
|
+
.mockResolvedValueOnce({ records: [record({ status: null })] })
|
|
105
|
+
.mockResolvedValueOnce({ records: [record({ owner: 'status-owner' })] });
|
|
106
|
+
mocks.setBit.mockResolvedValue(false);
|
|
107
|
+
await expect(
|
|
108
|
+
mutateCredentialStatus('version', 'recipient', purpose, true)
|
|
109
|
+
).rejects.toThrow('Public credential status could not be updated');
|
|
110
|
+
expect(mocks.run).toHaveBeenCalledTimes(3);
|
|
111
|
+
expect(mocks.run.mock.calls[2]).toEqual([
|
|
112
|
+
'MATCH (list:BitstringStatusList {statusListCredential: $url}) RETURN list.ownerProfileId AS owner',
|
|
113
|
+
{ url: entry.statusListCredential },
|
|
114
|
+
]);
|
|
115
|
+
expect(mocks.setBit).toHaveBeenCalledWith(
|
|
116
|
+
{ ...entry, statusPurpose: purpose },
|
|
117
|
+
true,
|
|
118
|
+
'status-owner',
|
|
119
|
+
tx
|
|
120
|
+
);
|
|
121
|
+
for (const [query] of mocks.run.mock.calls) {
|
|
122
|
+
expect(query).not.toMatch(/SET sent\.|SET refresh\.state/);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
it('reconciles using the stored status owner', async () => {
|
|
127
|
+
mocks.run.mockResolvedValueOnce({
|
|
128
|
+
records: [record({ owner: 'status-owner', status: 'revoked' })],
|
|
129
|
+
});
|
|
130
|
+
await reconcileCredentialStatus('root', tx);
|
|
131
|
+
expect(mocks.run.mock.calls[0]?.[0]).toContain(
|
|
132
|
+
'coalesce(refresh.issuerProfileId, root.statusOwnerProfileId, issuer.profileId) AS owner'
|
|
133
|
+
);
|
|
134
|
+
expect(mocks.setBit).toHaveBeenCalledWith(entry, true, 'status-owner', tx);
|
|
135
|
+
});
|
|
136
|
+
it.each(['revocation', 'suspension'] as const)(
|
|
137
|
+
'rejects managed external %s before any writes',
|
|
138
|
+
async purpose => {
|
|
139
|
+
mocks.run.mockResolvedValue({ records: [record({ rootId: 'root', managed: true })] });
|
|
140
|
+
mocks.entries.mockResolvedValue([
|
|
141
|
+
{
|
|
142
|
+
...entry,
|
|
143
|
+
statusPurpose: purpose,
|
|
144
|
+
statusListCredential: 'https://provider.example/list',
|
|
145
|
+
},
|
|
146
|
+
]);
|
|
147
|
+
await expect(
|
|
148
|
+
mutateCredentialStatus('version', 'recipient', purpose, true)
|
|
149
|
+
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
150
|
+
expect(mocks.run).toHaveBeenCalledTimes(1);
|
|
151
|
+
expect(mocks.run.mock.calls[0]?.[0]).not.toContain('SET ');
|
|
152
|
+
expect(mocks.setBit).not.toHaveBeenCalled();
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import type { BitstringStatusListEntry, BitstringStatusPurpose } from '@learncard/types';
|
|
3
|
+
import type { Transaction } from 'neo4j-driver';
|
|
4
|
+
|
|
5
|
+
import { neogma } from '@instance';
|
|
6
|
+
import {
|
|
7
|
+
CREDENTIAL_STATUS_ROOT_QUERY,
|
|
8
|
+
getStatusEntriesForCredential,
|
|
9
|
+
isNetworkStatusEntry,
|
|
10
|
+
setStatusListEntryBit,
|
|
11
|
+
} from './status-list.helpers';
|
|
12
|
+
|
|
13
|
+
const assertNetworkManagedPurpose = (
|
|
14
|
+
entries: BitstringStatusListEntry[],
|
|
15
|
+
purpose: BitstringStatusPurpose
|
|
16
|
+
): void => {
|
|
17
|
+
if (entries.some(entry => entry.statusPurpose === purpose && !isNetworkStatusEntry(entry))) {
|
|
18
|
+
throw new TRPCError({
|
|
19
|
+
code: 'PRECONDITION_FAILED',
|
|
20
|
+
message:
|
|
21
|
+
purpose === 'revocation'
|
|
22
|
+
? "This credential's public revocation is managed outside LearnCard. Revoke it with your status-list provider."
|
|
23
|
+
: "This credential's public suspension is managed outside LearnCard. Update it with your status-list provider.",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Commit relationship state and signed public bits together, including all managed versions. */
|
|
29
|
+
export const mutateCredentialStatus = async (
|
|
30
|
+
credentialId: string,
|
|
31
|
+
profileId: string,
|
|
32
|
+
purpose: BitstringStatusPurpose,
|
|
33
|
+
value: boolean
|
|
34
|
+
): Promise<boolean> =>
|
|
35
|
+
neogma.getTransaction(null, async tx => {
|
|
36
|
+
const result = await tx.run(
|
|
37
|
+
`${CREDENTIAL_STATUS_ROOT_QUERY}
|
|
38
|
+
MATCH (sender)-[sent:CREDENTIAL_SENT {to: $profileId}]->(root)
|
|
39
|
+
WHERE sender:Profile OR sender:AppStoreListing
|
|
40
|
+
WITH root, refresh, sent
|
|
41
|
+
OPTIONAL MATCH (issuer:Profile)-[:CREDENTIAL_SENT]->(root)
|
|
42
|
+
RETURN root.id AS rootId, sent.status AS status,
|
|
43
|
+
coalesce(refresh.issuerProfileId, root.statusOwnerProfileId) AS storedOwner,
|
|
44
|
+
refresh IS NOT NULL AS managed,
|
|
45
|
+
root.statusReservationMode AS reservationMode,
|
|
46
|
+
coalesce(refresh.issuerProfileId, root.statusOwnerProfileId, issuer.profileId) AS owner`,
|
|
47
|
+
{ credentialId, profileId, now: new Date().toISOString() }
|
|
48
|
+
);
|
|
49
|
+
if (result.records.length === 0) return false;
|
|
50
|
+
const row = result.records[0]!;
|
|
51
|
+
const rootId = String(row.get('rootId'));
|
|
52
|
+
if (row.get('managed')) {
|
|
53
|
+
assertNetworkManagedPurpose(await getStatusEntriesForCredential(rootId, tx), purpose);
|
|
54
|
+
}
|
|
55
|
+
const locked = await tx.run(
|
|
56
|
+
`MATCH (root:Credential {id: $rootId})
|
|
57
|
+
SET root._statusMutationLock = true REMOVE root._statusMutationLock
|
|
58
|
+
WITH root
|
|
59
|
+
OPTIONAL MATCH (refresh:CredentialRefresh)-[:ROOT]->(root)
|
|
60
|
+
FOREACH (_ IN CASE WHEN refresh IS NULL THEN [] ELSE [1] END |
|
|
61
|
+
SET refresh._statusBindingLock = true REMOVE refresh._statusBindingLock)
|
|
62
|
+
WITH root
|
|
63
|
+
MATCH ()-[sent:CREDENTIAL_SENT {to: $profileId}]->(root)
|
|
64
|
+
RETURN sent.status AS status`,
|
|
65
|
+
{ rootId, profileId }
|
|
66
|
+
);
|
|
67
|
+
const allEntries = await getStatusEntriesForCredential(rootId, tx);
|
|
68
|
+
if (row.get('managed')) assertNetworkManagedPurpose(allEntries, purpose);
|
|
69
|
+
const status = locked.records[0]?.get('status');
|
|
70
|
+
if (purpose === 'suspension' && status === 'revoked') return false;
|
|
71
|
+
if (!value && status !== 'suspended') return false;
|
|
72
|
+
const entries = allEntries.filter(entry => entry.statusPurpose === purpose);
|
|
73
|
+
if (allEntries.length > 0 && entries.length === 0) {
|
|
74
|
+
console.warn(
|
|
75
|
+
'[mutateCredentialStatus] no public status entry for purpose; relationship-only update',
|
|
76
|
+
{ rootId, purpose }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
for (const entry of [...entries].sort((a, b) =>
|
|
80
|
+
a.statusListCredential.localeCompare(b.statusListCredential)
|
|
81
|
+
)) {
|
|
82
|
+
let owner = String(row.get('owner'));
|
|
83
|
+
const lenient = !row.get('managed') && row.get('reservationMode') === 'lenient';
|
|
84
|
+
const legacy = !row.get('managed') && !row.get('storedOwner');
|
|
85
|
+
if (lenient || legacy) {
|
|
86
|
+
const list = await tx.run(
|
|
87
|
+
'MATCH (list:BitstringStatusList {statusListCredential: $url}) RETURN list.ownerProfileId AS owner',
|
|
88
|
+
{ url: entry.statusListCredential }
|
|
89
|
+
);
|
|
90
|
+
const listOwner: unknown = list.records[0]?.get('owner');
|
|
91
|
+
if (typeof listOwner !== 'string' || (lenient && listOwner !== owner)) {
|
|
92
|
+
console.warn('[credential-status] RELATIONSHIP_ONLY_UNCONTROLLED_STATUS_LIST');
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// Before stored owners existed, lifecycle updates used the matched list,
|
|
96
|
+
// including contract autoboosts delivered by a different sender.
|
|
97
|
+
if (legacy) owner = listOwner;
|
|
98
|
+
}
|
|
99
|
+
if (!(await setStatusListEntryBit(entry, value, owner, tx))) {
|
|
100
|
+
throw new TRPCError({
|
|
101
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
102
|
+
message: 'Public credential status could not be updated; retry the operation',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await tx.run(
|
|
107
|
+
`MATCH (root:Credential {id: $rootId})<-[sent:CREDENTIAL_SENT {to: $profileId}]-(sender)
|
|
108
|
+
WHERE sender:Profile OR sender:AppStoreListing
|
|
109
|
+
SET sent.status = $status
|
|
110
|
+
FOREACH (_ IN CASE WHEN $purpose = 'revocation' THEN [1] ELSE [] END |
|
|
111
|
+
SET sent.revokedAt = $now)
|
|
112
|
+
FOREACH (_ IN CASE WHEN $purpose = 'suspension' AND $value THEN [1] ELSE [] END |
|
|
113
|
+
SET sent.suspendedAt = $now)
|
|
114
|
+
FOREACH (_ IN CASE WHEN NOT $value THEN [1] ELSE [] END |
|
|
115
|
+
SET sent.unsuspendedAt = $now)
|
|
116
|
+
WITH root
|
|
117
|
+
OPTIONAL MATCH (refresh:CredentialRefresh)-[:ROOT]->(root)
|
|
118
|
+
FOREACH (_ IN CASE WHEN refresh IS NOT NULL AND $purpose = 'revocation' THEN [1] ELSE [] END |
|
|
119
|
+
SET refresh.state = 'revoked', refresh.updatedAt = $now)`,
|
|
120
|
+
{
|
|
121
|
+
rootId,
|
|
122
|
+
profileId,
|
|
123
|
+
purpose,
|
|
124
|
+
value,
|
|
125
|
+
status: purpose === 'revocation' ? 'revoked' : value ? 'suspended' : null,
|
|
126
|
+
now: new Date().toISOString(),
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
return true;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/** Reconcile historical lifecycle state while holding the same root lock as mutations. */
|
|
133
|
+
export const reconcileCredentialStatus = async (
|
|
134
|
+
rootId: string,
|
|
135
|
+
transaction: Transaction
|
|
136
|
+
): Promise<void> => {
|
|
137
|
+
const result = await transaction.run(
|
|
138
|
+
`MATCH (root:Credential {id: $rootId})
|
|
139
|
+
SET root._statusMutationLock = true REMOVE root._statusMutationLock
|
|
140
|
+
WITH root
|
|
141
|
+
OPTIONAL MATCH (issuer:Profile)-[sent:CREDENTIAL_SENT]->(root)
|
|
142
|
+
OPTIONAL MATCH (refresh:CredentialRefresh)-[:ROOT]->(root)
|
|
143
|
+
RETURN coalesce(refresh.issuerProfileId, root.statusOwnerProfileId, issuer.profileId) AS owner,
|
|
144
|
+
sent.status AS status, refresh.state AS state`,
|
|
145
|
+
{ rootId }
|
|
146
|
+
);
|
|
147
|
+
const row = result.records[0];
|
|
148
|
+
if (!row)
|
|
149
|
+
throw new TRPCError({ code: 'CONFLICT', message: 'Credential has no original delivery' });
|
|
150
|
+
const entries = await getStatusEntriesForCredential(rootId, transaction);
|
|
151
|
+
for (const entry of [...entries].sort((a, b) =>
|
|
152
|
+
a.statusListCredential.localeCompare(b.statusListCredential)
|
|
153
|
+
)) {
|
|
154
|
+
if (!isNetworkStatusEntry(entry)) continue;
|
|
155
|
+
const value =
|
|
156
|
+
entry.statusPurpose === 'revocation'
|
|
157
|
+
? row.get('status') === 'revoked' || row.get('state') === 'revoked'
|
|
158
|
+
: row.get('status') === 'suspended';
|
|
159
|
+
if (!(await setStatusListEntryBit(entry, value, String(row.get('owner')), transaction))) {
|
|
160
|
+
throw new TRPCError({
|
|
161
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
162
|
+
message: 'Public credential status could not be updated; retry the operation',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
@@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server';
|
|
|
3
3
|
import { UnsignedVC, VC, JWE, LCNNotificationTypeEnumValidator } from '@learncard/types';
|
|
4
4
|
|
|
5
5
|
import { storeCredential } from '@accesslayer/credential/create';
|
|
6
|
+
import { neogma } from '@instance';
|
|
6
7
|
import type { IssuedCredential } from 'types/credential';
|
|
7
8
|
import {
|
|
8
9
|
createReceivedCredentialRelationship,
|
|
@@ -42,18 +43,25 @@ export const sendCredential = async (
|
|
|
42
43
|
domain: string,
|
|
43
44
|
metadata?: Record<string, unknown> | undefined,
|
|
44
45
|
activityId?: string,
|
|
45
|
-
integrationId?: string
|
|
46
|
+
integrationId?: string,
|
|
47
|
+
/** Local profile that owns any local status lists; defaults to the sender. */
|
|
48
|
+
statusOwnerProfileId: string = from.profileId
|
|
46
49
|
): Promise<string> => {
|
|
47
|
-
const credentialInstance = await
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
const credentialInstance = await neogma.getTransaction(null, async tx => {
|
|
51
|
+
const instance = await storeCredential(credential, tx, statusOwnerProfileId, {
|
|
52
|
+
lenient: true,
|
|
53
|
+
});
|
|
54
|
+
await createSentCredentialRelationship(
|
|
55
|
+
{ type: 'profile', profile: from },
|
|
56
|
+
to,
|
|
57
|
+
instance,
|
|
58
|
+
metadata,
|
|
59
|
+
activityId,
|
|
60
|
+
integrationId,
|
|
61
|
+
tx
|
|
62
|
+
);
|
|
63
|
+
return instance;
|
|
64
|
+
});
|
|
57
65
|
|
|
58
66
|
const uri = getCredentialUri(credentialInstance.id, domain);
|
|
59
67
|
|
|
@@ -67,7 +67,7 @@ const finalizeAcceptedDelivery = async (
|
|
|
67
67
|
await transaction.rollback();
|
|
68
68
|
return false;
|
|
69
69
|
}
|
|
70
|
-
const instance = await storeCredential(issued, transaction);
|
|
70
|
+
const instance = await storeCredential(issued, transaction, sender.profileId);
|
|
71
71
|
if (boost) await createBoostInstanceOfRelationship(instance, boost, transaction);
|
|
72
72
|
await createSentCredentialRelationship(
|
|
73
73
|
{ type: 'profile', profile: sender },
|
|
@@ -20,6 +20,9 @@ import { AUTH_GRANT_NO_ACCESS_SCOPE } from '../constants/auth-grant';
|
|
|
20
20
|
import { ensureCredentialRefreshConstraints } from '../models/credential-refresh-constraints';
|
|
21
21
|
import { getCredentialRefresh, generateRefreshId } from '@accesslayer/credential-refresh';
|
|
22
22
|
import { getInboxCredentialById } from '@accesslayer/inbox-credential/read';
|
|
23
|
+
import { getBoostById } from '@accesslayer/boost/read';
|
|
24
|
+
import { getPublicBoostDisplayName } from './credential-display.helpers';
|
|
25
|
+
import { extractBoundedCredentialDisplayTitle } from './credential-refresh-email-content.helpers';
|
|
23
26
|
import {
|
|
24
27
|
advancePendingInboxRefresh,
|
|
25
28
|
bindPendingInboxRefresh,
|
|
@@ -46,8 +49,10 @@ import {
|
|
|
46
49
|
} from './credential-refresh.helpers';
|
|
47
50
|
import {
|
|
48
51
|
getCredentialRefreshDigestSecret,
|
|
52
|
+
getCredentialRefreshDigestCandidateSecrets,
|
|
49
53
|
computeCredentialMaterialDigest,
|
|
50
54
|
computeCredentialStatusDigest,
|
|
55
|
+
matchesCredentialStatusDigest,
|
|
51
56
|
computeCredentialSubjectDigest,
|
|
52
57
|
} from './credential-refresh-materiality.helpers';
|
|
53
58
|
import { appendBitstringStatusListEntries } from './status-list.helpers';
|
|
@@ -67,8 +72,11 @@ export const assertInboxRefreshEnabled = async (scope?: string): Promise<void> =
|
|
|
67
72
|
await ensureCredentialRefreshConstraints();
|
|
68
73
|
};
|
|
69
74
|
|
|
70
|
-
export const inboxRefreshRequestDigest = (
|
|
71
|
-
|
|
75
|
+
export const inboxRefreshRequestDigest = (
|
|
76
|
+
request: unknown,
|
|
77
|
+
secret: string = getCredentialRefreshDigestSecret()
|
|
78
|
+
): string =>
|
|
79
|
+
createHmac('sha256', secret)
|
|
72
80
|
.update('inbox-refresh-issue:v1\0')
|
|
73
81
|
.update(canonicalizeCredentialJson(request))
|
|
74
82
|
.digest('base64url');
|
|
@@ -76,14 +84,23 @@ export const inboxRefreshRequestDigest = (request: unknown): string =>
|
|
|
76
84
|
export const getInboxRefreshIssueKey = (issuerProfileId: string, key: string): string =>
|
|
77
85
|
`${issuerProfileId}:${key}`;
|
|
78
86
|
|
|
87
|
+
export const getInboxRefreshRequestDigestCandidates = (request: unknown): string[] =>
|
|
88
|
+
getCredentialRefreshDigestCandidateSecrets().map(secret =>
|
|
89
|
+
inboxRefreshRequestDigest(request, secret)
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
export const matchesInboxRefreshRequestDigest = (request: unknown, stored: string): boolean =>
|
|
93
|
+
getInboxRefreshRequestDigestCandidates(request).includes(stored);
|
|
94
|
+
|
|
79
95
|
export const getInboxRefreshReplay = async (
|
|
80
96
|
issuerProfileId: string,
|
|
81
97
|
key: string | undefined,
|
|
82
|
-
requestDigest: string
|
|
98
|
+
requestDigest: string,
|
|
99
|
+
requestDigestCandidates: string[] = [requestDigest]
|
|
83
100
|
) => {
|
|
84
101
|
if (!key) return null;
|
|
85
102
|
const existing = await getInboxRefreshIssue(getInboxRefreshIssueKey(issuerProfileId, key));
|
|
86
|
-
if (existing && existing.requestDigest
|
|
103
|
+
if (existing && !requestDigestCandidates.includes(existing.requestDigest))
|
|
87
104
|
throw new TRPCError({
|
|
88
105
|
code: 'CONFLICT',
|
|
89
106
|
message: 'This idempotencyKey was used for a different inbox issuance.',
|
|
@@ -220,7 +237,7 @@ export const publishPendingInboxRefresh = async (
|
|
|
220
237
|
computeCredentialSubjectDigest(body.credentialSubject) !==
|
|
221
238
|
computeCredentialSubjectDigest(previous.credentialSubject) ||
|
|
222
239
|
body.boostId !== previous.boostId ||
|
|
223
|
-
|
|
240
|
+
!matchesCredentialStatusDigest(body.credentialStatus, aggregate.credentialStatusDigest) ||
|
|
224
241
|
canonicalizeCredentialJson(body.refreshService) !==
|
|
225
242
|
canonicalizeCredentialJson(previous.refreshService)
|
|
226
243
|
)
|
|
@@ -237,14 +254,20 @@ export const publishPendingInboxRefresh = async (
|
|
|
237
254
|
message: 'Credential effective time is older than the current version',
|
|
238
255
|
});
|
|
239
256
|
const prepared = injectManagedRefreshService(body, serviceFor(aggregate.refreshId, domain));
|
|
257
|
+
// Public label only: the anchor's explicit display name, never the VC's own name.
|
|
258
|
+
const displayBoost = aggregate.boostId ? await getBoostById(aggregate.boostId) : null;
|
|
259
|
+
const credentialName = extractBoundedCredentialDisplayTitle(
|
|
260
|
+
getPublicBoostDisplayName(displayBoost)
|
|
261
|
+
);
|
|
240
262
|
const result = await advancePendingInboxRefresh({
|
|
241
263
|
refreshId: aggregate.refreshId,
|
|
242
264
|
expectedVersion: aggregate.currentVersion,
|
|
243
265
|
credential: await encryptInboxCredential(JSON.stringify(prepared)),
|
|
244
|
-
credentialName
|
|
266
|
+
...(credentialName ? { credentialName } : {}),
|
|
245
267
|
materialDigest: computeCredentialMaterialDigest(prepared),
|
|
246
268
|
idempotencyKey: input.idempotencyKey,
|
|
247
269
|
updateSummary: input.updateSummary,
|
|
270
|
+
updateReason: input.updateReason,
|
|
248
271
|
});
|
|
249
272
|
if (!result)
|
|
250
273
|
throw new TRPCError({
|
|
@@ -328,8 +351,10 @@ export const finalizeInboxRefresh = async (params: {
|
|
|
328
351
|
getCredentialIssuerId(credential) !== aggregate.issuerDid ||
|
|
329
352
|
computeCredentialSubjectDigest(credential.credentialSubject) !==
|
|
330
353
|
computeCredentialSubjectDigest(unsigned.credentialSubject) ||
|
|
331
|
-
|
|
332
|
-
|
|
354
|
+
!matchesCredentialStatusDigest(
|
|
355
|
+
credential.credentialStatus,
|
|
356
|
+
aggregate.credentialStatusDigest
|
|
357
|
+
) ||
|
|
333
358
|
canonicalizeCredentialJson(credential.refreshService) !==
|
|
334
359
|
canonicalizeCredentialJson(unsigned.refreshService) ||
|
|
335
360
|
computeCredentialMaterialDigest(credential) !==
|
|
@@ -386,6 +411,7 @@ export const finalizeInboxRefresh = async (params: {
|
|
|
386
411
|
? {}
|
|
387
412
|
: { effectiveAt: new Date(effectiveTime).toISOString() }),
|
|
388
413
|
...(aggregate.updateSummary ? { updateSummary: aggregate.updateSummary } : {}),
|
|
414
|
+
...(aggregate.updateReason ? { updateReason: aggregate.updateReason } : {}),
|
|
389
415
|
etag,
|
|
390
416
|
},
|
|
391
417
|
aggregate: {
|
|
@@ -207,6 +207,7 @@ export const issueToInbox = async (
|
|
|
207
207
|
refresh?: boolean;
|
|
208
208
|
idempotencyKey?: string;
|
|
209
209
|
refreshRequestDigest?: string;
|
|
210
|
+
refreshRequestDigestCandidates?: string[];
|
|
210
211
|
} = {},
|
|
211
212
|
ctx: Context,
|
|
212
213
|
/** Queue ownership checkpoint immediately before delivery or inbox persistence. */
|
|
@@ -484,6 +485,7 @@ export const issueToInbox = async (
|
|
|
484
485
|
configuration.idempotencyKey ?? randomUUID()
|
|
485
486
|
),
|
|
486
487
|
requestDigest: configuration.refreshRequestDigest!,
|
|
488
|
+
requestDigestCandidates: configuration.refreshRequestDigestCandidates,
|
|
487
489
|
},
|
|
488
490
|
}
|
|
489
491
|
: {}),
|
|
@@ -231,6 +231,29 @@ export const createDagJweForRecipients = async <T>(
|
|
|
231
231
|
return wallet.invoke.createDagJwe(cleartext, recipients);
|
|
232
232
|
};
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Resolves `did`'s key-agreement verification-method id — the exact `kid` DIDKit
|
|
236
|
+
* assigns that recipient in a DAG-JWE (see `createDagJweForRecipients`). Used to
|
|
237
|
+
* publish the expected holder-mode encryption recipient set (LC-2345) without ever
|
|
238
|
+
* performing the encryption itself.
|
|
239
|
+
*/
|
|
240
|
+
export const getKeyAgreementKeyId = async (
|
|
241
|
+
did: string,
|
|
242
|
+
seed = environment.SEED
|
|
243
|
+
): Promise<string> => {
|
|
244
|
+
const learnCard = await getLearnCard(seed);
|
|
245
|
+
const doc = await learnCard.invoke.resolveDid(did);
|
|
246
|
+
const [agreement] = doc.keyAgreement ?? [];
|
|
247
|
+
|
|
248
|
+
if (!agreement) {
|
|
249
|
+
throw new Error(`No key-agreement verification method found for DID: ${did}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const id = typeof agreement === 'string' ? agreement : agreement.id;
|
|
253
|
+
|
|
254
|
+
return id.startsWith('#') ? `${doc.id}${id}` : id;
|
|
255
|
+
};
|
|
256
|
+
|
|
234
257
|
export const getServerDidWebDID = (): string => {
|
|
235
258
|
const domainName = environment.DOMAIN_NAME;
|
|
236
259
|
const isOffline = !!environment.IS_OFFLINE;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Transaction } from 'neo4j-driver';
|
|
3
|
+
import type { BitstringStatusListEntry } from '@learncard/types';
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
run: vi.fn(),
|
|
7
|
+
transaction: vi.fn(),
|
|
8
|
+
allocate: vi.fn(),
|
|
9
|
+
validate: vi.fn(),
|
|
10
|
+
getRefresh: vi.fn(),
|
|
11
|
+
reconcile: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('@instance', () => ({ neogma: { getTransaction: mocks.transaction } }));
|
|
14
|
+
vi.mock('./status-list.helpers', () => ({
|
|
15
|
+
allocateStatusListEntry: mocks.allocate,
|
|
16
|
+
validateStatusEntries: mocks.validate,
|
|
17
|
+
isNetworkStatusEntry: (entry: BitstringStatusListEntry) =>
|
|
18
|
+
new URL(entry.statusListCredential).hostname === 'network.example',
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('@accesslayer/credential-refresh', () => ({ getCredentialRefresh: mocks.getRefresh }));
|
|
21
|
+
vi.mock('./credential-status-mutation.helpers', () => ({
|
|
22
|
+
reconcileCredentialStatus: mocks.reconcile,
|
|
23
|
+
}));
|
|
24
|
+
import { bindRefreshStatusEntries, prepareRefreshStatusEntries } from './managed-status.helpers';
|
|
25
|
+
|
|
26
|
+
const entry: BitstringStatusListEntry = {
|
|
27
|
+
type: 'BitstringStatusListEntry',
|
|
28
|
+
statusPurpose: 'revocation',
|
|
29
|
+
statusListIndex: '7',
|
|
30
|
+
statusListCredential: 'https://network.example/status-lists/list',
|
|
31
|
+
};
|
|
32
|
+
const tx = { run: mocks.run } as unknown as Transaction;
|
|
33
|
+
const record = (values: Record<string, unknown>) => ({
|
|
34
|
+
get: (key: string): unknown => values[key] ?? null,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('managed status binding', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.resetAllMocks();
|
|
40
|
+
mocks.transaction.mockImplementation(
|
|
41
|
+
async (_existing: unknown, callback: (transaction: Transaction) => Promise<unknown>) =>
|
|
42
|
+
callback(tx)
|
|
43
|
+
);
|
|
44
|
+
mocks.getRefresh.mockResolvedValue(null);
|
|
45
|
+
mocks.allocate.mockResolvedValue(entry);
|
|
46
|
+
mocks.run.mockImplementation(async (query: string) => ({
|
|
47
|
+
records: query.includes('RETURN refresh.statusEntries AS entries')
|
|
48
|
+
? [record({ rootId: 'root' })]
|
|
49
|
+
: [],
|
|
50
|
+
}));
|
|
51
|
+
});
|
|
52
|
+
it('returns after the locked read on a second matching bind', async () => {
|
|
53
|
+
await bindRefreshStatusEntries('refresh', [entry], 'owner', 'network.example');
|
|
54
|
+
expect(mocks.reconcile).toHaveBeenCalledWith('root', tx);
|
|
55
|
+
mocks.run.mockClear();
|
|
56
|
+
mocks.reconcile.mockClear();
|
|
57
|
+
mocks.run.mockResolvedValue({
|
|
58
|
+
records: [record({ entries: JSON.stringify([entry]), rootId: 'root' })],
|
|
59
|
+
});
|
|
60
|
+
await bindRefreshStatusEntries('refresh', [entry], 'owner', 'network.example');
|
|
61
|
+
expect(mocks.run).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(mocks.run.mock.calls[0]?.[0]).toContain('SET refresh._statusBindingLock = true');
|
|
63
|
+
expect(mocks.reconcile).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
it('rejects mismatching stored coordinates', async () => {
|
|
66
|
+
mocks.run.mockResolvedValue({
|
|
67
|
+
records: [
|
|
68
|
+
record({
|
|
69
|
+
entries: JSON.stringify([{ ...entry, statusListIndex: '8' }]),
|
|
70
|
+
rootId: 'root',
|
|
71
|
+
}),
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
await expect(
|
|
75
|
+
bindRefreshStatusEntries('refresh', [entry], 'owner', 'network.example')
|
|
76
|
+
).rejects.toThrow('Credential status does not match allocated entries');
|
|
77
|
+
expect(mocks.run).toHaveBeenCalledTimes(1);
|
|
78
|
+
expect(mocks.reconcile).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
it('skips the legacy scan only for freshly allocated entries', async () => {
|
|
81
|
+
await expect(
|
|
82
|
+
prepareRefreshStatusEntries('refresh', 'owner', 'network.example')
|
|
83
|
+
).resolves.toEqual([entry]);
|
|
84
|
+
expect(mocks.run).not.toHaveBeenCalledWith(
|
|
85
|
+
expect.stringContaining('credential.statusEntries CONTAINS $url'),
|
|
86
|
+
expect.anything()
|
|
87
|
+
);
|
|
88
|
+
expect(mocks.run).toHaveBeenCalledWith(
|
|
89
|
+
expect.stringContaining('SET binding.refreshId = $refreshId'),
|
|
90
|
+
expect.anything()
|
|
91
|
+
);
|
|
92
|
+
expect(mocks.reconcile).toHaveBeenCalledWith('root', tx);
|
|
93
|
+
});
|
|
94
|
+
it('retains the legacy scan for pre-signed and repair entries', async () => {
|
|
95
|
+
await bindRefreshStatusEntries('refresh', [entry], 'owner', 'network.example', tx);
|
|
96
|
+
expect(mocks.run).toHaveBeenCalledWith(
|
|
97
|
+
expect.stringContaining('credential.statusEntries CONTAINS $url'),
|
|
98
|
+
{ ownerProfileId: 'owner', refreshId: 'refresh', url: entry.statusListCredential }
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
});
|