@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,970 @@
|
|
|
1
|
+
import { beforeAll, beforeEach, afterAll, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { BitstringStatusListEntry, UnsignedVC } from '@learncard/types';
|
|
3
|
+
import { VPValidator } from '@learncard/types';
|
|
4
|
+
import { assertManagedCredentialBinding, injectManagedRefreshService } from '@learncard/helpers';
|
|
5
|
+
|
|
6
|
+
import { neogma } from '@instance';
|
|
7
|
+
import { getUser } from './helpers/getClient';
|
|
8
|
+
import { sendSpy } from './helpers/spies';
|
|
9
|
+
import { InboxCredential } from '@models';
|
|
10
|
+
import * as statusLists from '@helpers/status-list.helpers';
|
|
11
|
+
import { bindRefreshStatusEntries } from '@helpers/managed-status.helpers';
|
|
12
|
+
import { mutateCredentialStatus } from '@helpers/credential-status-mutation.helpers';
|
|
13
|
+
import { storeCredential } from '@accesslayer/credential/create';
|
|
14
|
+
import { getCredentialUri, sendCredential } from '@helpers/credential.helpers';
|
|
15
|
+
import { getUriParts } from '@helpers/uri.helpers';
|
|
16
|
+
import { releaseUnlinkedCredential } from '@accesslayer/credential/delete';
|
|
17
|
+
import { getProfileByProfileId } from '@accesslayer/profile/read';
|
|
18
|
+
import * as credentialRelationships from '@accesslayer/credential/relationships/create';
|
|
19
|
+
import {
|
|
20
|
+
computeCredentialStatusDigest,
|
|
21
|
+
matchesCredentialStatusDigest,
|
|
22
|
+
} from '@helpers/credential-refresh-materiality.helpers';
|
|
23
|
+
|
|
24
|
+
let issuer: Awaited<ReturnType<typeof getUser>>;
|
|
25
|
+
let holder: Awaited<ReturnType<typeof getUser>>;
|
|
26
|
+
const domain = 'localhost:3000';
|
|
27
|
+
|
|
28
|
+
vi.mock('@services/delivery/delivery.factory', () => ({
|
|
29
|
+
getDeliveryService: () => ({ send: sendSpy }),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const bit = async (entry: BitstringStatusListEntry): Promise<boolean> => {
|
|
33
|
+
const list = await statusLists.getStatusListById(entry.statusListCredential.split('/').pop()!);
|
|
34
|
+
if (!list) throw new Error('Missing public status list');
|
|
35
|
+
const signed = await statusLists.getSignedStatusListCredential(list.id);
|
|
36
|
+
const subject = Array.isArray(signed?.credentialSubject)
|
|
37
|
+
? signed.credentialSubject[0]
|
|
38
|
+
: signed?.credentialSubject;
|
|
39
|
+
if (typeof subject?.encodedList !== 'string')
|
|
40
|
+
throw new Error('Missing signed public bitstring');
|
|
41
|
+
const bytes = statusLists.decodeBitstring(subject.encodedList, list.size);
|
|
42
|
+
const index = Number(entry.statusListIndex);
|
|
43
|
+
return Boolean(bytes[Math.floor(index / 8)]! & (1 << (index % 8)));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const send = async (arrayShape = false, suppliedEntries?: BitstringStatusListEntry[]) => {
|
|
47
|
+
const template: UnsignedVC = {
|
|
48
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
49
|
+
type: ['VerifiableCredential'],
|
|
50
|
+
issuer: issuer.learnCard.id.did(),
|
|
51
|
+
credentialSubject: { id: holder.learnCard.id.did() },
|
|
52
|
+
validFrom: '2026-01-01T00:00:00Z',
|
|
53
|
+
};
|
|
54
|
+
const allocation = await issuer.clients.fullAuth.boost.prepareRefreshableSend({
|
|
55
|
+
...(suppliedEntries ? {} : { statusBinding: 'server' as const }),
|
|
56
|
+
recipient: 'status-holder',
|
|
57
|
+
template: { credential: template },
|
|
58
|
+
credentialId: 'urn:uuid:managed-status-test',
|
|
59
|
+
statusPurposes: arrayShape ? ['suspension'] : [],
|
|
60
|
+
});
|
|
61
|
+
const entries = suppliedEntries ?? allocation.statusEntries!;
|
|
62
|
+
if (!suppliedEntries)
|
|
63
|
+
expect(entries.map(entry => entry.statusPurpose)).toEqual(
|
|
64
|
+
arrayShape ? ['revocation', 'suspension'] : ['revocation']
|
|
65
|
+
);
|
|
66
|
+
const unsigned = injectManagedRefreshService(
|
|
67
|
+
{
|
|
68
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
69
|
+
id: 'urn:uuid:managed-status-test',
|
|
70
|
+
type: ['VerifiableCredential'],
|
|
71
|
+
issuer: issuer.learnCard.id.did(),
|
|
72
|
+
credentialSubject: { id: allocation.holderDid },
|
|
73
|
+
validFrom: '2026-01-01T00:00:00Z',
|
|
74
|
+
credentialStatus: arrayShape ? entries : entries[0],
|
|
75
|
+
} satisfies UnsignedVC,
|
|
76
|
+
allocation.refreshService
|
|
77
|
+
);
|
|
78
|
+
const credential = await issuer.learnCard.invoke.issueCredential(unsigned);
|
|
79
|
+
const result = await issuer.clients.fullAuth.boost.send({
|
|
80
|
+
type: 'boost',
|
|
81
|
+
recipient: 'status-holder',
|
|
82
|
+
refresh: true,
|
|
83
|
+
signedCredential: credential,
|
|
84
|
+
templateUri: allocation.boostUri,
|
|
85
|
+
});
|
|
86
|
+
const uri = result.credentialUri;
|
|
87
|
+
const root = (
|
|
88
|
+
await neogma.queryRunner.run(
|
|
89
|
+
'MATCH (:CredentialRefresh {refreshId: $refreshId})-[:ROOT]->(root) RETURN root.id AS id',
|
|
90
|
+
{ refreshId: allocation.refreshId }
|
|
91
|
+
)
|
|
92
|
+
).records[0]!.get('id') as string;
|
|
93
|
+
return { allocation, entries, unsigned, credential, uri, root, result };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
describe('Managed public status', () => {
|
|
97
|
+
const ordinary = async () => {
|
|
98
|
+
const entries = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
|
|
99
|
+
statusPurposes: ['revocation'],
|
|
100
|
+
});
|
|
101
|
+
const credential = await issuer.learnCard.invoke.issueCredential({
|
|
102
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
103
|
+
type: ['VerifiableCredential'],
|
|
104
|
+
issuer: issuer.learnCard.id.did(),
|
|
105
|
+
credentialSubject: { id: holder.learnCard.id.did() },
|
|
106
|
+
credentialStatus: entries,
|
|
107
|
+
});
|
|
108
|
+
const from = await getProfileByProfileId('status-issuer');
|
|
109
|
+
const to = await getProfileByProfileId('status-holder');
|
|
110
|
+
if (!from || !to) throw new Error('Missing test profiles');
|
|
111
|
+
return { entries, credential, from, to };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
it('forwards foreign-owner plaintext without reserving or mutating its public bit', async () => {
|
|
115
|
+
const { entries, credential, from, to } = await ordinary();
|
|
116
|
+
await expect(storeCredential(credential, undefined, to.profileId)).rejects.toMatchObject({
|
|
117
|
+
code: 'BAD_REQUEST',
|
|
118
|
+
});
|
|
119
|
+
const uri = await sendCredential(to, from, credential, domain);
|
|
120
|
+
const bindings = await neogma.queryRunner.run('MATCH (b:CredentialStatusBinding) RETURN b');
|
|
121
|
+
expect(bindings.records).toHaveLength(0);
|
|
122
|
+
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
123
|
+
try {
|
|
124
|
+
expect(
|
|
125
|
+
await mutateCredentialStatus(
|
|
126
|
+
getUriParts(uri).id,
|
|
127
|
+
from.profileId,
|
|
128
|
+
'revocation',
|
|
129
|
+
true
|
|
130
|
+
)
|
|
131
|
+
).toBe(true);
|
|
132
|
+
expect(warning).toHaveBeenCalledWith(
|
|
133
|
+
'[credential-status] RELATIONSHIP_ONLY_UNCONTROLLED_STATUS_LIST'
|
|
134
|
+
);
|
|
135
|
+
} finally {
|
|
136
|
+
warning.mockRestore();
|
|
137
|
+
}
|
|
138
|
+
expect(await bit(entries[0]!)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('delivers identical signed content twice with one binding and revokes the shared bit', async () => {
|
|
142
|
+
const { entries, credential, from, to } = await ordinary();
|
|
143
|
+
const first = await sendCredential(from, to, credential, domain);
|
|
144
|
+
// Object key order is not part of signed JSON identity; proof content is.
|
|
145
|
+
const { proof, ...body } = credential;
|
|
146
|
+
const second = await sendCredential(from, from, { proof, ...body }, domain);
|
|
147
|
+
expect(second).not.toBe(first);
|
|
148
|
+
const bindings = await neogma.queryRunner.run('MATCH (b:CredentialStatusBinding) RETURN b');
|
|
149
|
+
expect(bindings.records).toHaveLength(1);
|
|
150
|
+
expect(
|
|
151
|
+
await mutateCredentialStatus(getUriParts(second).id, from.profileId, 'revocation', true)
|
|
152
|
+
).toBe(true);
|
|
153
|
+
expect(await bit(entries[0]!)).toBe(true);
|
|
154
|
+
await expect(storeCredential(credential, undefined, from.profileId)).rejects.toMatchObject({
|
|
155
|
+
code: 'BAD_REQUEST',
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it.each([false, true])(
|
|
160
|
+
'preserves shared reservations during unlinked cleanup (concurrent=%s)',
|
|
161
|
+
async concurrent => {
|
|
162
|
+
const { entries, credential, from, to } = await ordinary();
|
|
163
|
+
const first = await storeCredential(credential, undefined, from.profileId);
|
|
164
|
+
let second: string;
|
|
165
|
+
if (concurrent) {
|
|
166
|
+
[second] = await Promise.all([
|
|
167
|
+
sendCredential(from, to, credential, domain),
|
|
168
|
+
releaseUnlinkedCredential(first.id),
|
|
169
|
+
]);
|
|
170
|
+
} else {
|
|
171
|
+
second = await sendCredential(from, to, credential, domain);
|
|
172
|
+
await releaseUnlinkedCredential(first.id);
|
|
173
|
+
}
|
|
174
|
+
const secondId = getUriParts(second).id;
|
|
175
|
+
const bindings = await neogma.queryRunner.run(
|
|
176
|
+
'MATCH (b:CredentialStatusBinding) RETURN b.credentialId AS id'
|
|
177
|
+
);
|
|
178
|
+
expect(bindings.records).toHaveLength(1);
|
|
179
|
+
expect(bindings.records[0]!.get('id')).toBe(secondId);
|
|
180
|
+
const removed = await neogma.queryRunner.run(
|
|
181
|
+
'MATCH (c:Credential {id: $id}) RETURN c',
|
|
182
|
+
{ id: first.id }
|
|
183
|
+
);
|
|
184
|
+
expect(removed.records).toHaveLength(0);
|
|
185
|
+
const different = await issuer.learnCard.invoke.issueCredential({
|
|
186
|
+
...credential,
|
|
187
|
+
proof: undefined,
|
|
188
|
+
id: 'urn:uuid:cleanup-reuse',
|
|
189
|
+
});
|
|
190
|
+
await expect(sendCredential(from, to, different, domain)).rejects.toMatchObject({
|
|
191
|
+
code: 'BAD_REQUEST',
|
|
192
|
+
});
|
|
193
|
+
expect(await mutateCredentialStatus(secondId, to.profileId, 'revocation', true)).toBe(
|
|
194
|
+
true
|
|
195
|
+
);
|
|
196
|
+
expect(await bit(entries[0]!)).toBe(true);
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
it('rejects different content or proof reusing an ordinary bound index', async () => {
|
|
201
|
+
const { credential, from, to } = await ordinary();
|
|
202
|
+
await sendCredential(from, to, credential, domain);
|
|
203
|
+
const different = await issuer.learnCard.invoke.issueCredential({
|
|
204
|
+
...credential,
|
|
205
|
+
proof: undefined,
|
|
206
|
+
id: 'urn:uuid:different',
|
|
207
|
+
});
|
|
208
|
+
await expect(sendCredential(from, from, different, domain)).rejects.toMatchObject({
|
|
209
|
+
code: 'BAD_REQUEST',
|
|
210
|
+
});
|
|
211
|
+
await expect(
|
|
212
|
+
sendCredential(from, from, { ...credential, proof: [] }, domain)
|
|
213
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it.each(['credentialSubject', 'proof'] as const)(
|
|
217
|
+
'rejects differences in own JSON prototype-named keys within %s',
|
|
218
|
+
async field => {
|
|
219
|
+
const { credential, from, to } = await ordinary();
|
|
220
|
+
Object.defineProperty(credential[field], 'details', { value: {}, enumerable: true });
|
|
221
|
+
await sendCredential(from, to, credential, domain);
|
|
222
|
+
const different = structuredClone(credential);
|
|
223
|
+
Object.defineProperty(different[field], 'details', {
|
|
224
|
+
value: JSON.parse('{"__proto__":{"claim":"changed"}}'),
|
|
225
|
+
enumerable: true,
|
|
226
|
+
});
|
|
227
|
+
await expect(sendCredential(from, from, different, domain)).rejects.toMatchObject({
|
|
228
|
+
code: 'BAD_REQUEST',
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
it('preserves legacy contract-autoboost revocation when sender differs from list owner', async () => {
|
|
234
|
+
const { entries, credential, to } = await ordinary();
|
|
235
|
+
// Historical autoboost storage had no validated status owner or reservation mode.
|
|
236
|
+
const stored = await storeCredential(credential);
|
|
237
|
+
await neogma.queryRunner.run(
|
|
238
|
+
'MATCH (sender:Profile {profileId: $sender}), (c:Credential {id: $id}) CREATE (sender)-[:CREDENTIAL_SENT {to: $recipient}]->(c)',
|
|
239
|
+
{ sender: to.profileId, recipient: to.profileId, id: stored.id }
|
|
240
|
+
);
|
|
241
|
+
expect(await mutateCredentialStatus(stored.id, to.profileId, 'revocation', true)).toBe(
|
|
242
|
+
true
|
|
243
|
+
);
|
|
244
|
+
expect(await bit(entries[0]!)).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('does not treat a stored owner mismatch as a legacy delivery', async () => {
|
|
248
|
+
const { entries, credential, from, to } = await ordinary();
|
|
249
|
+
const stored = await storeCredential(credential, undefined, from.profileId);
|
|
250
|
+
await neogma.queryRunner.run(
|
|
251
|
+
`MATCH (sender:Profile {profileId: $sender}), (c:Credential {id: $id})
|
|
252
|
+
SET c.statusOwnerProfileId = $sender
|
|
253
|
+
CREATE (sender)-[:CREDENTIAL_SENT {to: $recipient}]->(c)`,
|
|
254
|
+
{ sender: to.profileId, recipient: to.profileId, id: stored.id }
|
|
255
|
+
);
|
|
256
|
+
await expect(
|
|
257
|
+
mutateCredentialStatus(stored.id, to.profileId, 'revocation', true)
|
|
258
|
+
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
259
|
+
expect(await bit(entries[0]!)).toBe(false);
|
|
260
|
+
const delivery = await neogma.queryRunner.run(
|
|
261
|
+
'MATCH ()-[sent:CREDENTIAL_SENT]->(:Credential {id: $id}) RETURN sent.status AS status',
|
|
262
|
+
{ id: stored.id }
|
|
263
|
+
);
|
|
264
|
+
expect(delivery.records[0]!.get('status')).toBeNull();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('accepts external managed status lists and preserves ordinary storage', async () => {
|
|
268
|
+
const allocation =
|
|
269
|
+
await issuer.clients.fullAuth.credentialRefresh.allocateCredentialRefresh({
|
|
270
|
+
holder: { profileId: 'status-holder', did: holder.learnCard.id.did() },
|
|
271
|
+
credentialId: 'urn:uuid:external-managed-status',
|
|
272
|
+
});
|
|
273
|
+
const credential = await issuer.learnCard.invoke.issueCredential(
|
|
274
|
+
injectManagedRefreshService(
|
|
275
|
+
{
|
|
276
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
277
|
+
id: 'urn:uuid:external-managed-status',
|
|
278
|
+
type: ['VerifiableCredential'],
|
|
279
|
+
issuer: issuer.learnCard.id.did(),
|
|
280
|
+
credentialSubject: { id: holder.learnCard.id.did() },
|
|
281
|
+
credentialStatus: {
|
|
282
|
+
id: 'https://other.example/status-lists/id#0',
|
|
283
|
+
type: 'BitstringStatusListEntry',
|
|
284
|
+
statusPurpose: 'revocation',
|
|
285
|
+
statusListIndex: '0',
|
|
286
|
+
statusListCredential: 'https://other.example/status-lists/id',
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
allocation.refreshService
|
|
290
|
+
)
|
|
291
|
+
);
|
|
292
|
+
await expect(
|
|
293
|
+
issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
|
|
294
|
+
refreshId: allocation.refreshId,
|
|
295
|
+
credential,
|
|
296
|
+
})
|
|
297
|
+
).resolves.toEqual(expect.any(String));
|
|
298
|
+
await expect(
|
|
299
|
+
storeCredential(credential, undefined, 'status-issuer')
|
|
300
|
+
).resolves.toBeDefined();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it.each([false, true])(
|
|
304
|
+
'recovers authenticated absent legacy status (array=%s)',
|
|
305
|
+
async arrayShape => {
|
|
306
|
+
const allocation =
|
|
307
|
+
await issuer.clients.fullAuth.credentialRefresh.allocateCredentialRefresh({
|
|
308
|
+
holder: { profileId: 'status-holder', did: holder.learnCard.id.did() },
|
|
309
|
+
credentialId: 'urn:uuid:legacy-absent',
|
|
310
|
+
});
|
|
311
|
+
const unsigned = injectManagedRefreshService(
|
|
312
|
+
{
|
|
313
|
+
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
314
|
+
id: 'urn:uuid:legacy-absent',
|
|
315
|
+
type: ['VerifiableCredential'],
|
|
316
|
+
issuer: issuer.learnCard.id.did(),
|
|
317
|
+
issuanceDate: '2026-01-01T00:00:00Z',
|
|
318
|
+
credentialSubject: { id: holder.learnCard.id.did() },
|
|
319
|
+
},
|
|
320
|
+
allocation.refreshService
|
|
321
|
+
);
|
|
322
|
+
if (arrayShape)
|
|
323
|
+
unsigned.refreshService = [
|
|
324
|
+
allocation.refreshService,
|
|
325
|
+
{
|
|
326
|
+
id: 'https://issuer.example/refresh',
|
|
327
|
+
type: 'ManualRefreshService2018',
|
|
328
|
+
},
|
|
329
|
+
];
|
|
330
|
+
const credential = await issuer.learnCard.invoke.issueCredential(unsigned);
|
|
331
|
+
expect(credential.credentialStatus).toBeUndefined();
|
|
332
|
+
const uri = await issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
|
|
333
|
+
refreshId: allocation.refreshId,
|
|
334
|
+
credential,
|
|
335
|
+
});
|
|
336
|
+
await neogma.queryRunner.run(
|
|
337
|
+
'MATCH (r:CredentialRefresh {refreshId: $refreshId}) REMOVE r.statusEntries, r.credentialStatusBinding, r.refreshServiceBinding',
|
|
338
|
+
{ refreshId: allocation.refreshId }
|
|
339
|
+
);
|
|
340
|
+
const receipt = await holder.clients.fullAuth.credentialRefresh.getReceipt({ uri });
|
|
341
|
+
expect(receipt?.refreshServiceBinding).toBe('managed-entry');
|
|
342
|
+
expect(receipt?.credentialStatus).toBeUndefined();
|
|
343
|
+
expect(() => assertManagedCredentialBinding(credential, receipt!)).not.toThrow();
|
|
344
|
+
const stored = await neogma.queryRunner.run(
|
|
345
|
+
'MATCH (r:CredentialRefresh {refreshId: $refreshId}) RETURN r.credentialStatusBinding AS binding',
|
|
346
|
+
{ refreshId: allocation.refreshId }
|
|
347
|
+
);
|
|
348
|
+
expect(stored.records[0]!.get('binding')).toBe('null');
|
|
349
|
+
await expect(
|
|
350
|
+
issuer.clients.fullAuth.credentialRefresh.attachStatusEntries({
|
|
351
|
+
refreshId: allocation.refreshId,
|
|
352
|
+
credentialStatus: null,
|
|
353
|
+
})
|
|
354
|
+
).resolves.toBe(true);
|
|
355
|
+
expect(await holder.clients.fullAuth.credential.acceptCredential({ uri })).toBeTruthy();
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
it.each([false, true])(
|
|
360
|
+
'repairs the exact legacy receipt before claim (array=%s)',
|
|
361
|
+
async arrayShape => {
|
|
362
|
+
const { allocation, credential, uri } = await send(arrayShape);
|
|
363
|
+
await neogma.queryRunner.run(
|
|
364
|
+
'MATCH (r:CredentialRefresh {refreshId: $refreshId}) REMOVE r.statusEntries, r.credentialStatusBinding',
|
|
365
|
+
{ refreshId: allocation.refreshId }
|
|
366
|
+
);
|
|
367
|
+
await expect(
|
|
368
|
+
holder.clients.fullAuth.credentialRefresh.getReceipt({ uri })
|
|
369
|
+
).rejects.toThrow('Delivery binding unavailable');
|
|
370
|
+
await expect(
|
|
371
|
+
issuer.clients.fullAuth.credentialRefresh.attachStatusEntries({
|
|
372
|
+
refreshId: allocation.refreshId,
|
|
373
|
+
credentialStatus: null,
|
|
374
|
+
})
|
|
375
|
+
).rejects.toThrow('Credential status does not match the original credential');
|
|
376
|
+
await issuer.clients.fullAuth.credentialRefresh.attachStatusEntries({
|
|
377
|
+
refreshId: allocation.refreshId,
|
|
378
|
+
credentialStatus: credential.credentialStatus!,
|
|
379
|
+
});
|
|
380
|
+
const receipt = await holder.clients.fullAuth.credentialRefresh.getReceipt({ uri });
|
|
381
|
+
expect(receipt?.credentialStatus).toEqual(credential.credentialStatus);
|
|
382
|
+
expect(await holder.clients.fullAuth.credential.acceptCredential({ uri })).toBeTruthy();
|
|
383
|
+
}
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
it.each([false, true])(
|
|
387
|
+
'returns the exact signed status shape before acceptance (array=%s)',
|
|
388
|
+
async arrayShape => {
|
|
389
|
+
const { credential, uri } = await send(arrayShape);
|
|
390
|
+
const receipt = await holder.clients.fullAuth.credentialRefresh.getReceipt({ uri });
|
|
391
|
+
expect(receipt?.credentialStatus).toEqual(credential.credentialStatus);
|
|
392
|
+
expect(receipt?.refreshService).toEqual(credential.refreshService);
|
|
393
|
+
expect(receipt).not.toHaveProperty('credentialSubject');
|
|
394
|
+
expect(receipt).not.toHaveProperty('proof');
|
|
395
|
+
}
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
it('never exposes a managed receipt after delivery revocation', async () => {
|
|
399
|
+
const { uri, root } = await send();
|
|
400
|
+
await neogma.queryRunner.run(
|
|
401
|
+
'MATCH ()-[sent:CREDENTIAL_SENT]->(:Credential {id: $root}) SET sent.status = "revoked"',
|
|
402
|
+
{ root }
|
|
403
|
+
);
|
|
404
|
+
await expect(
|
|
405
|
+
holder.clients.fullAuth.credentialRefresh.getReceipt({ uri })
|
|
406
|
+
).rejects.toThrow();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('distinguishes an authorized ordinary delivery from a missing delivery', async () => {
|
|
410
|
+
await neogma.queryRunner.run(
|
|
411
|
+
'MATCH (p:Profile {profileId: "status-issuer"}) CREATE (p)-[:CREDENTIAL_SENT {to: "status-holder"}]->(:Credential {id: "ordinary-root"})'
|
|
412
|
+
);
|
|
413
|
+
expect(
|
|
414
|
+
await holder.clients.fullAuth.credentialRefresh.getReceipt({
|
|
415
|
+
uri: getCredentialUri('ordinary-root', 'localhost%3A3000'),
|
|
416
|
+
})
|
|
417
|
+
).toBeNull();
|
|
418
|
+
await expect(
|
|
419
|
+
holder.clients.fullAuth.credentialRefresh.getReceipt({
|
|
420
|
+
uri: getCredentialUri('unknown-root', 'localhost%3A3000'),
|
|
421
|
+
})
|
|
422
|
+
).rejects.toThrow();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it.each([false, true])(
|
|
426
|
+
'preserves client allocation unless server binding is requested (server=%s)',
|
|
427
|
+
async serverBinding => {
|
|
428
|
+
const template: UnsignedVC = {
|
|
429
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
430
|
+
type: ['VerifiableCredential'],
|
|
431
|
+
issuer: issuer.learnCard.id.did(),
|
|
432
|
+
credentialSubject: { id: holder.learnCard.id.did() },
|
|
433
|
+
};
|
|
434
|
+
const input = {
|
|
435
|
+
recipient: 'status-holder',
|
|
436
|
+
template: { credential: template },
|
|
437
|
+
idempotencyKey: 'status-binding-compatibility',
|
|
438
|
+
...(serverBinding ? { statusBinding: 'server' as const } : {}),
|
|
439
|
+
};
|
|
440
|
+
const prepared = await issuer.clients.fullAuth.boost.prepareRefreshableSend(input);
|
|
441
|
+
const aggregate = await neogma.queryRunner.run(
|
|
442
|
+
'MATCH (r:CredentialRefresh {refreshId: $refreshId}) RETURN r.statusEntries AS entries',
|
|
443
|
+
{ refreshId: prepared.refreshId }
|
|
444
|
+
);
|
|
445
|
+
if (serverBinding) {
|
|
446
|
+
expect(prepared.statusEntries).toHaveLength(1);
|
|
447
|
+
expect(JSON.parse(aggregate.records[0]!.get('entries'))).toEqual(
|
|
448
|
+
prepared.statusEntries
|
|
449
|
+
);
|
|
450
|
+
} else {
|
|
451
|
+
expect(prepared.statusEntries).toBeUndefined();
|
|
452
|
+
expect(aggregate.records[0]!.get('entries')).toBeNull();
|
|
453
|
+
const bindings = await neogma.queryRunner.run(
|
|
454
|
+
'MATCH (b:CredentialStatusBinding) RETURN b'
|
|
455
|
+
);
|
|
456
|
+
expect(bindings.records).toHaveLength(0);
|
|
457
|
+
}
|
|
458
|
+
expect(await issuer.clients.fullAuth.boost.prepareRefreshableSend(input)).toEqual(
|
|
459
|
+
prepared
|
|
460
|
+
);
|
|
461
|
+
await expect(
|
|
462
|
+
issuer.clients.fullAuth.boost.prepareRefreshableSend({
|
|
463
|
+
...input,
|
|
464
|
+
statusBinding: serverBinding ? undefined : 'server',
|
|
465
|
+
})
|
|
466
|
+
).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
467
|
+
|
|
468
|
+
// Previous SDKs independently allocate through this route after preparation.
|
|
469
|
+
const clientEntries = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
|
|
470
|
+
statusPurposes: ['revocation'],
|
|
471
|
+
});
|
|
472
|
+
const handoff = async (entries: BitstringStatusListEntry[]) => {
|
|
473
|
+
const signedCredential = await issuer.learnCard.invoke.issueCredential(
|
|
474
|
+
injectManagedRefreshService(
|
|
475
|
+
{
|
|
476
|
+
...template,
|
|
477
|
+
id: prepared.credentialId,
|
|
478
|
+
credentialSubject: { id: prepared.holderDid },
|
|
479
|
+
credentialStatus: entries,
|
|
480
|
+
},
|
|
481
|
+
prepared.refreshService
|
|
482
|
+
)
|
|
483
|
+
);
|
|
484
|
+
return issuer.clients.fullAuth.boost.send({
|
|
485
|
+
type: 'boost',
|
|
486
|
+
recipient: input.recipient,
|
|
487
|
+
templateUri: prepared.boostUri,
|
|
488
|
+
idempotencyKey: input.idempotencyKey,
|
|
489
|
+
refresh: true,
|
|
490
|
+
signedCredential,
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
if (serverBinding) {
|
|
494
|
+
await expect(handoff(clientEntries)).rejects.toThrow(
|
|
495
|
+
'Credential status does not match allocated entries'
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
const entries = prepared.statusEntries ?? clientEntries;
|
|
499
|
+
const result = await handoff(entries);
|
|
500
|
+
expect(result.refresh?.refreshId).toBe(prepared.refreshId);
|
|
501
|
+
expect(await bit(entries[0]!)).toBe(false);
|
|
502
|
+
await issuer.clients.fullAuth.boost.revokeBoostRecipient({
|
|
503
|
+
boostUri: prepared.boostUri,
|
|
504
|
+
recipientProfileId: input.recipient,
|
|
505
|
+
credentialUri: result.credentialUri,
|
|
506
|
+
});
|
|
507
|
+
expect(await bit(entries[0]!)).toBe(true);
|
|
508
|
+
const retry = await issuer.clients.fullAuth.boost.prepareRefreshableSend(input);
|
|
509
|
+
expect(retry.completed).toEqual(result);
|
|
510
|
+
expect(retry.statusEntries).toEqual(prepared.statusEntries);
|
|
511
|
+
}
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
it('unsuspends legacy revocation-only deliveries and supports new suspension and revocation', async () => {
|
|
515
|
+
const { root, entries } = await send();
|
|
516
|
+
await neogma.queryRunner.run(
|
|
517
|
+
'MATCH ()-[sent:CREDENTIAL_SENT]->(:Credential {id: $root}) SET sent.status = $status',
|
|
518
|
+
{ root, status: 'suspended' }
|
|
519
|
+
);
|
|
520
|
+
expect(await mutateCredentialStatus(root, 'status-holder', 'suspension', false)).toBe(true);
|
|
521
|
+
expect(await mutateCredentialStatus(root, 'status-holder', 'suspension', true)).toBe(true);
|
|
522
|
+
expect(await bit(entries[0]!)).toBe(false);
|
|
523
|
+
expect(await mutateCredentialStatus(root, 'status-holder', 'revocation', true)).toBe(true);
|
|
524
|
+
expect(await bit(entries[0]!)).toBe(true);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('uses the stored status owner when the delivery sender differs', async () => {
|
|
528
|
+
const entries = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
|
|
529
|
+
statusPurposes: ['revocation'],
|
|
530
|
+
});
|
|
531
|
+
const credential = await issuer.learnCard.invoke.issueCredential({
|
|
532
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
533
|
+
type: ['VerifiableCredential'],
|
|
534
|
+
issuer: issuer.learnCard.id.did(),
|
|
535
|
+
credentialSubject: { id: holder.learnCard.id.did() },
|
|
536
|
+
credentialStatus: entries,
|
|
537
|
+
});
|
|
538
|
+
const stored = await storeCredential(credential, undefined, 'status-issuer');
|
|
539
|
+
await neogma.queryRunner.run(
|
|
540
|
+
'MATCH (sender:Profile {profileId: $sender}), (c:Credential {id: $id}) CREATE (sender)-[:CREDENTIAL_SENT {to: $recipient}]->(c)',
|
|
541
|
+
{ sender: 'status-holder', recipient: 'status-holder', id: stored.id }
|
|
542
|
+
);
|
|
543
|
+
expect(await mutateCredentialStatus(stored.id, 'status-holder', 'revocation', true)).toBe(
|
|
544
|
+
true
|
|
545
|
+
);
|
|
546
|
+
expect(await bit(entries[0]!)).toBe(true);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('rolls back a failed ordinary send and retries the same signed status index', async () => {
|
|
550
|
+
const entries = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
|
|
551
|
+
statusPurposes: ['revocation'],
|
|
552
|
+
});
|
|
553
|
+
const credential = await issuer.learnCard.invoke.issueCredential({
|
|
554
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
555
|
+
type: ['VerifiableCredential'],
|
|
556
|
+
issuer: issuer.learnCard.id.did(),
|
|
557
|
+
credentialSubject: { id: holder.learnCard.id.did() },
|
|
558
|
+
credentialStatus: entries,
|
|
559
|
+
});
|
|
560
|
+
const from = await getProfileByProfileId('status-issuer');
|
|
561
|
+
const to = await getProfileByProfileId('status-holder');
|
|
562
|
+
if (!from || !to) throw new Error('Missing test profiles');
|
|
563
|
+
const failure = vi
|
|
564
|
+
.spyOn(credentialRelationships, 'createSentCredentialRelationship')
|
|
565
|
+
.mockRejectedValueOnce(new Error('forced relationship failure'));
|
|
566
|
+
try {
|
|
567
|
+
await expect(sendCredential(from, to, credential, domain)).rejects.toThrow(
|
|
568
|
+
'forced relationship failure'
|
|
569
|
+
);
|
|
570
|
+
} finally {
|
|
571
|
+
failure.mockRestore();
|
|
572
|
+
}
|
|
573
|
+
await expect(sendCredential(from, to, credential, domain)).resolves.toEqual(
|
|
574
|
+
expect.any(String)
|
|
575
|
+
);
|
|
576
|
+
const rows = await neogma.queryRunner.run(
|
|
577
|
+
'MATCH (binding:CredentialStatusBinding) RETURN binding'
|
|
578
|
+
);
|
|
579
|
+
expect(rows.records).toHaveLength(1);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('releases unlinked reservations but preserves linked credentials', async () => {
|
|
583
|
+
const entries = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
|
|
584
|
+
statusPurposes: ['revocation'],
|
|
585
|
+
});
|
|
586
|
+
const credential = await issuer.learnCard.invoke.issueCredential({
|
|
587
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
588
|
+
type: ['VerifiableCredential'],
|
|
589
|
+
issuer: issuer.learnCard.id.did(),
|
|
590
|
+
credentialSubject: { id: holder.learnCard.id.did() },
|
|
591
|
+
credentialStatus: entries,
|
|
592
|
+
});
|
|
593
|
+
const first = await storeCredential(credential, undefined, 'status-issuer');
|
|
594
|
+
await releaseUnlinkedCredential(first.id);
|
|
595
|
+
const second = await storeCredential(credential, undefined, 'status-issuer');
|
|
596
|
+
await neogma.queryRunner.run(
|
|
597
|
+
'MATCH (p:Profile {profileId: $profile}), (c:Credential {id: $id}) CREATE (p)-[:CREDENTIAL_SENT {to: $recipient}]->(c)',
|
|
598
|
+
{ profile: 'status-issuer', recipient: 'status-holder', id: second.id }
|
|
599
|
+
);
|
|
600
|
+
await releaseUnlinkedCredential(second.id);
|
|
601
|
+
await expect(storeCredential(credential, undefined, 'status-issuer')).rejects.toThrow(
|
|
602
|
+
'already bound'
|
|
603
|
+
);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it('does not create orphan credentials for status-free inbox claims without a boost', async () => {
|
|
607
|
+
const credential = await issuer.learnCard.invoke.issueCredential({
|
|
608
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
609
|
+
type: ['VerifiableCredential'],
|
|
610
|
+
issuer: issuer.learnCard.id.did(),
|
|
611
|
+
credentialSubject: { id: holder.learnCard.id.did() },
|
|
612
|
+
});
|
|
613
|
+
const inbox = await issuer.clients.fullAuth.inbox.issue({
|
|
614
|
+
credential,
|
|
615
|
+
recipient: { type: 'email', value: 'holder@example.com' },
|
|
616
|
+
});
|
|
617
|
+
const localExchangeId = new URL(inbox.claimUrl!).pathname.split('/').pop()!;
|
|
618
|
+
const start = await holder.clients.fullAuth.workflows.participateInExchange({
|
|
619
|
+
localWorkflowId: 'inbox-claim',
|
|
620
|
+
localExchangeId,
|
|
621
|
+
});
|
|
622
|
+
const presentation = VPValidator.parse(
|
|
623
|
+
await holder.learnCard.invoke.getDidAuthVp({
|
|
624
|
+
challenge: start.verifiablePresentationRequest?.challenge,
|
|
625
|
+
domain: start.verifiablePresentationRequest?.domain,
|
|
626
|
+
})
|
|
627
|
+
);
|
|
628
|
+
const response = await holder.clients.fullAuth.workflows.participateInExchange({
|
|
629
|
+
localWorkflowId: 'inbox-claim',
|
|
630
|
+
localExchangeId,
|
|
631
|
+
verifiablePresentation: presentation,
|
|
632
|
+
});
|
|
633
|
+
expect(response.inboxDeliveries).toHaveLength(1);
|
|
634
|
+
const nodes = await neogma.queryRunner.run('MATCH (c:Credential) RETURN c');
|
|
635
|
+
expect(nodes.records).toHaveLength(0);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('keeps inbox escrow pending when a workflow claim reuses a managed index', async () => {
|
|
639
|
+
const { entries } = await send();
|
|
640
|
+
const credential = await issuer.learnCard.invoke.issueCredential({
|
|
641
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
642
|
+
type: ['VerifiableCredential'],
|
|
643
|
+
id: 'urn:uuid:ordinary-inbox-reuse',
|
|
644
|
+
issuer: issuer.learnCard.id.did(),
|
|
645
|
+
credentialSubject: { id: holder.learnCard.id.did() },
|
|
646
|
+
credentialStatus: entries,
|
|
647
|
+
});
|
|
648
|
+
const inbox = await issuer.clients.fullAuth.inbox.issue({
|
|
649
|
+
credential,
|
|
650
|
+
recipient: { type: 'email', value: 'holder@example.com' },
|
|
651
|
+
});
|
|
652
|
+
const localExchangeId = new URL(inbox.claimUrl!).pathname.split('/').pop()!;
|
|
653
|
+
const start = await holder.clients.fullAuth.workflows.participateInExchange({
|
|
654
|
+
localWorkflowId: 'inbox-claim',
|
|
655
|
+
localExchangeId,
|
|
656
|
+
});
|
|
657
|
+
const presentation = VPValidator.parse(
|
|
658
|
+
await holder.learnCard.invoke.getDidAuthVp({
|
|
659
|
+
challenge: start.verifiablePresentationRequest?.challenge,
|
|
660
|
+
domain: start.verifiablePresentationRequest?.domain,
|
|
661
|
+
})
|
|
662
|
+
);
|
|
663
|
+
const response = await holder.clients.fullAuth.workflows.participateInExchange({
|
|
664
|
+
localWorkflowId: 'inbox-claim',
|
|
665
|
+
localExchangeId,
|
|
666
|
+
verifiablePresentation: presentation,
|
|
667
|
+
});
|
|
668
|
+
expect(response.inboxDeliveries).toEqual([]);
|
|
669
|
+
const stored = await InboxCredential.findOne({ where: { id: inbox.issuanceId } });
|
|
670
|
+
expect(stored?.currentStatus).toBe('PENDING');
|
|
671
|
+
expect(stored?.credential).toBeDefined();
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it('returns a clear repair error for a credential without supported status entries', async () => {
|
|
675
|
+
const { allocation } = await send();
|
|
676
|
+
await neogma.queryRunner.run(
|
|
677
|
+
'MATCH (r:CredentialRefresh {refreshId: $refreshId}) SET r.credentialStatusDigest = $digest REMOVE r.statusEntries',
|
|
678
|
+
{ refreshId: allocation.refreshId, digest: computeCredentialStatusDigest([]) }
|
|
679
|
+
);
|
|
680
|
+
await expect(
|
|
681
|
+
issuer.clients.fullAuth.credentialRefresh.attachStatusEntries({
|
|
682
|
+
refreshId: allocation.refreshId,
|
|
683
|
+
credentialStatus: [],
|
|
684
|
+
})
|
|
685
|
+
).rejects.toThrow('no supported public status entries');
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
beforeAll(async () => {
|
|
689
|
+
process.env.CREDENTIAL_REFRESH_ENABLED = 'true';
|
|
690
|
+
issuer = await getUser('d'.repeat(64));
|
|
691
|
+
holder = await getUser('b'.repeat(64));
|
|
692
|
+
});
|
|
693
|
+
afterAll(() => {
|
|
694
|
+
delete process.env.CREDENTIAL_REFRESH_ENABLED;
|
|
695
|
+
});
|
|
696
|
+
beforeEach(async () => {
|
|
697
|
+
await neogma.queryRunner.run('MATCH (n) DETACH DELETE n');
|
|
698
|
+
await issuer.clients.fullAuth.profile.createProfile({ profileId: 'status-issuer' });
|
|
699
|
+
await holder.clients.fullAuth.profile.createProfile({ profileId: 'status-holder' });
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it('shares public bits across v1/v2/v3 and rolls back failed unsuspension', async () => {
|
|
703
|
+
const { allocation, entries, unsigned } = await send(true);
|
|
704
|
+
for (const month of ['02', '03']) {
|
|
705
|
+
await issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
|
|
706
|
+
mode: 'issuer-signed',
|
|
707
|
+
refreshId: allocation.refreshId,
|
|
708
|
+
signedCredential: await issuer.learnCard.invoke.issueCredential({
|
|
709
|
+
...unsigned,
|
|
710
|
+
validFrom: `2026-${month}-01T00:00:00Z`,
|
|
711
|
+
}),
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
const versions = await neogma.queryRunner.run(
|
|
715
|
+
'MATCH (c:Credential {refreshId: $refreshId}) RETURN c.id AS id, c.version AS version ORDER BY version',
|
|
716
|
+
{ refreshId: allocation.refreshId }
|
|
717
|
+
);
|
|
718
|
+
expect(versions.records).toHaveLength(3);
|
|
719
|
+
expect(Number(versions.records[2]!.get('version'))).toBe(3);
|
|
720
|
+
for (const row of versions.records) {
|
|
721
|
+
expect(await statusLists.getStatusEntriesForCredential(String(row.get('id')))).toEqual(
|
|
722
|
+
entries
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
const suspension = entries.find(entry => entry.statusPurpose === 'suspension')!;
|
|
726
|
+
const mutation = {
|
|
727
|
+
boostUri: allocation.boostUri,
|
|
728
|
+
recipientProfileId: 'status-holder',
|
|
729
|
+
credentialUri: getCredentialUri(
|
|
730
|
+
String(versions.records[2]!.get('id')),
|
|
731
|
+
'localhost%3A3000'
|
|
732
|
+
),
|
|
733
|
+
};
|
|
734
|
+
await issuer.clients.fullAuth.boost.suspendBoostRecipient(mutation);
|
|
735
|
+
expect(await bit(suspension)).toBe(true);
|
|
736
|
+
const failure = vi
|
|
737
|
+
.spyOn(statusLists, 'setStatusListEntryBit')
|
|
738
|
+
.mockRejectedValueOnce(new Error('forced write failure'));
|
|
739
|
+
await expect(
|
|
740
|
+
issuer.clients.fullAuth.boost.unsuspendBoostRecipient(mutation)
|
|
741
|
+
).rejects.toThrow('forced write failure');
|
|
742
|
+
failure.mockRestore();
|
|
743
|
+
expect(await bit(suspension)).toBe(true);
|
|
744
|
+
expect(await issuer.clients.fullAuth.boost.unsuspendBoostRecipient(mutation)).toBe(true);
|
|
745
|
+
expect(await bit(suspension)).toBe(false);
|
|
746
|
+
await issuer.clients.fullAuth.boost.revokeBoostRecipient(mutation);
|
|
747
|
+
expect(await bit(entries[0]!)).toBe(true);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('reconciles an earlier revocation when the original send is retried', async () => {
|
|
751
|
+
const { allocation, credential, root, entries } = await send();
|
|
752
|
+
await neogma.queryRunner.run(
|
|
753
|
+
'MATCH (r:CredentialRefresh {refreshId: $refreshId}) REMOVE r.statusEntries',
|
|
754
|
+
{ refreshId: allocation.refreshId }
|
|
755
|
+
);
|
|
756
|
+
await mutateCredentialStatus(root, 'status-holder', 'revocation', true);
|
|
757
|
+
expect(await bit(entries[0]!)).toBe(false);
|
|
758
|
+
await issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
|
|
759
|
+
refreshId: allocation.refreshId,
|
|
760
|
+
credential,
|
|
761
|
+
boostUri: allocation.boostUri,
|
|
762
|
+
});
|
|
763
|
+
expect(await bit(entries[0]!)).toBe(true);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('serializes concurrent binding and revocation without losing the public bit', async () => {
|
|
767
|
+
const { allocation, entries, root } = await send();
|
|
768
|
+
await Promise.all([
|
|
769
|
+
bindRefreshStatusEntries(allocation.refreshId, entries, 'status-issuer', domain),
|
|
770
|
+
mutateCredentialStatus(root, 'status-holder', 'revocation', true),
|
|
771
|
+
]);
|
|
772
|
+
expect(await bit(entries[0]!)).toBe(true);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it('rejects ordinary delivery reusing a managed index and concurrent cross-kind reservations', async () => {
|
|
776
|
+
const { allocation, credential, entries } = await send();
|
|
777
|
+
const ordinaryCredential = await issuer.learnCard.invoke.issueCredential({
|
|
778
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
779
|
+
type: ['VerifiableCredential'],
|
|
780
|
+
id: 'urn:uuid:ordinary-status-test',
|
|
781
|
+
issuer: issuer.learnCard.id.did(),
|
|
782
|
+
credentialSubject: { id: holder.learnCard.id.did() },
|
|
783
|
+
credentialStatus: entries,
|
|
784
|
+
});
|
|
785
|
+
await expect(
|
|
786
|
+
issuer.clients.fullAuth.boost.sendBoost({
|
|
787
|
+
uri: allocation.boostUri,
|
|
788
|
+
profileId: 'status-holder',
|
|
789
|
+
credential: ordinaryCredential,
|
|
790
|
+
})
|
|
791
|
+
).rejects.toThrow('already bound');
|
|
792
|
+
const other = await issuer.clients.fullAuth.credentialRefresh.allocateCredentialRefresh({
|
|
793
|
+
holder: { profileId: 'status-holder', did: holder.learnCard.id.did() },
|
|
794
|
+
credentialId: 'urn:uuid:race',
|
|
795
|
+
});
|
|
796
|
+
const fresh = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
|
|
797
|
+
statusPurposes: ['revocation'],
|
|
798
|
+
});
|
|
799
|
+
const outcomes = await Promise.allSettled([
|
|
800
|
+
bindRefreshStatusEntries(other.refreshId, fresh, 'status-issuer', domain),
|
|
801
|
+
storeCredential(
|
|
802
|
+
{ ...credential, id: 'urn:uuid:ordinary-race', credentialStatus: fresh },
|
|
803
|
+
undefined,
|
|
804
|
+
'status-issuer'
|
|
805
|
+
),
|
|
806
|
+
]);
|
|
807
|
+
expect(outcomes.filter(result => result.status === 'fulfilled')).toHaveLength(1);
|
|
808
|
+
expect(outcomes.filter(result => result.status === 'rejected')).toHaveLength(1);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it.each([false, true])(
|
|
812
|
+
'repairs already revoked credentials without changing status shape (array=%s)',
|
|
813
|
+
async arrayShape => {
|
|
814
|
+
const { allocation, credential, root, entries } = await send(arrayShape);
|
|
815
|
+
await neogma.queryRunner.run(
|
|
816
|
+
'MATCH (r:CredentialRefresh {refreshId: $refreshId}) REMOVE r.statusEntries',
|
|
817
|
+
{ refreshId: allocation.refreshId }
|
|
818
|
+
);
|
|
819
|
+
await mutateCredentialStatus(root, 'status-holder', 'revocation', true);
|
|
820
|
+
expect(await bit(entries[0]!)).toBe(false);
|
|
821
|
+
await issuer.clients.fullAuth.credentialRefresh.attachStatusEntries({
|
|
822
|
+
refreshId: allocation.refreshId,
|
|
823
|
+
credentialStatus: credential.credentialStatus!,
|
|
824
|
+
});
|
|
825
|
+
expect(await bit(entries[0]!)).toBe(true);
|
|
826
|
+
expect(
|
|
827
|
+
matchesCredentialStatusDigest(
|
|
828
|
+
credential.credentialStatus,
|
|
829
|
+
computeCredentialStatusDigest(credential.credentialStatus)
|
|
830
|
+
)
|
|
831
|
+
).toBe(true);
|
|
832
|
+
expect(
|
|
833
|
+
matchesCredentialStatusDigest(
|
|
834
|
+
arrayShape ? entries[0] : entries,
|
|
835
|
+
computeCredentialStatusDigest(credential.credentialStatus)
|
|
836
|
+
)
|
|
837
|
+
).toBe(false);
|
|
838
|
+
}
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
it('rejects another refresh reusing an index, wrong owners, purpose, bounds and malformed local URLs', async () => {
|
|
842
|
+
const { entries } = await send();
|
|
843
|
+
const allocation =
|
|
844
|
+
await issuer.clients.fullAuth.credentialRefresh.allocateCredentialRefresh({
|
|
845
|
+
holder: { profileId: 'status-holder', did: holder.learnCard.id.did() },
|
|
846
|
+
credentialId: 'urn:uuid:second',
|
|
847
|
+
});
|
|
848
|
+
await expect(
|
|
849
|
+
bindRefreshStatusEntries(allocation.refreshId, entries, 'status-issuer', domain)
|
|
850
|
+
).rejects.toThrow('already bound');
|
|
851
|
+
const entry = entries[0]!;
|
|
852
|
+
await expect(
|
|
853
|
+
statusLists.validateStatusEntries(entries, 'status-holder', domain)
|
|
854
|
+
).rejects.toThrow('owner');
|
|
855
|
+
await expect(
|
|
856
|
+
statusLists.setStatusListEntryBit(entry, true, 'status-holder')
|
|
857
|
+
).rejects.toThrow('owner');
|
|
858
|
+
await expect(
|
|
859
|
+
statusLists.validateStatusEntries(
|
|
860
|
+
[{ ...entry, statusPurpose: 'suspension' }],
|
|
861
|
+
'status-issuer',
|
|
862
|
+
domain
|
|
863
|
+
)
|
|
864
|
+
).rejects.toThrow('purpose');
|
|
865
|
+
await expect(
|
|
866
|
+
statusLists.validateStatusEntries(
|
|
867
|
+
[{ ...entry, statusListIndex: '999999999' }],
|
|
868
|
+
'status-issuer',
|
|
869
|
+
domain
|
|
870
|
+
)
|
|
871
|
+
).rejects.toThrow('range');
|
|
872
|
+
await expect(
|
|
873
|
+
statusLists.validateStatusEntries(
|
|
874
|
+
[{ ...entry, statusListCredential: 'https://localhost:3000/not-status-lists/id' }],
|
|
875
|
+
'status-issuer',
|
|
876
|
+
domain
|
|
877
|
+
)
|
|
878
|
+
).rejects.toMatchObject({
|
|
879
|
+
code: 'BAD_REQUEST',
|
|
880
|
+
message: 'Status list URL must be canonical and local',
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
const externalEntry = (
|
|
885
|
+
statusPurpose: 'revocation' | 'suspension'
|
|
886
|
+
): BitstringStatusListEntry => ({
|
|
887
|
+
type: 'BitstringStatusListEntry',
|
|
888
|
+
statusPurpose,
|
|
889
|
+
statusListCredential: 'https://provider.example/status-list',
|
|
890
|
+
statusListIndex: '7',
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it('sends, publishes and repairs external status while rejecting provider-side revocation', async () => {
|
|
894
|
+
const entry = externalEntry('revocation');
|
|
895
|
+
const { allocation, unsigned, root, result } = await send(false, [entry]);
|
|
896
|
+
expect(result.statusManagement).toBe('external');
|
|
897
|
+
expect(
|
|
898
|
+
(await neogma.queryRunner.run('MATCH (b:CredentialStatusBinding) RETURN b')).records
|
|
899
|
+
).toHaveLength(0);
|
|
900
|
+
await issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
|
|
901
|
+
mode: 'issuer-signed',
|
|
902
|
+
refreshId: allocation.refreshId,
|
|
903
|
+
signedCredential: await issuer.learnCard.invoke.issueCredential({
|
|
904
|
+
...unsigned,
|
|
905
|
+
validFrom: '2026-02-01T00:00:00Z',
|
|
906
|
+
}),
|
|
907
|
+
});
|
|
908
|
+
await expect(
|
|
909
|
+
issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
|
|
910
|
+
mode: 'issuer-signed',
|
|
911
|
+
refreshId: allocation.refreshId,
|
|
912
|
+
signedCredential: await issuer.learnCard.invoke.issueCredential({
|
|
913
|
+
...unsigned,
|
|
914
|
+
validFrom: '2026-03-01T00:00:00Z',
|
|
915
|
+
credentialStatus: { ...entry, statusListIndex: '8' },
|
|
916
|
+
}),
|
|
917
|
+
})
|
|
918
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
919
|
+
await neogma.queryRunner.run(
|
|
920
|
+
'MATCH (r:CredentialRefresh {refreshId: $refreshId}) REMOVE r.statusEntries',
|
|
921
|
+
{ refreshId: allocation.refreshId }
|
|
922
|
+
);
|
|
923
|
+
await issuer.clients.fullAuth.credentialRefresh.attachStatusEntries({
|
|
924
|
+
refreshId: allocation.refreshId,
|
|
925
|
+
credentialStatus: entry,
|
|
926
|
+
});
|
|
927
|
+
expect(await statusLists.getStatusEntriesForCredential(root)).toEqual([entry]);
|
|
928
|
+
await expect(
|
|
929
|
+
issuer.clients.fullAuth.boost.revokeBoostRecipient({
|
|
930
|
+
boostUri: allocation.boostUri,
|
|
931
|
+
recipientProfileId: 'status-holder',
|
|
932
|
+
credentialUri: result.credentialUri,
|
|
933
|
+
})
|
|
934
|
+
).rejects.toMatchObject({
|
|
935
|
+
code: 'PRECONDITION_FAILED',
|
|
936
|
+
message:
|
|
937
|
+
"This credential's public revocation is managed outside LearnCard. Revoke it with your status-list provider.",
|
|
938
|
+
});
|
|
939
|
+
const state = await neogma.queryRunner.run(
|
|
940
|
+
'MATCH ()-[sent:CREDENTIAL_SENT]->(:Credential {id: $root}) RETURN sent.status AS status',
|
|
941
|
+
{ root }
|
|
942
|
+
);
|
|
943
|
+
expect(state.records[0]!.get('status')).toBeNull();
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it('revokes mixed local revocation but rejects external suspension and unsuspension', async () => {
|
|
947
|
+
const local = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
|
|
948
|
+
statusPurposes: ['revocation'],
|
|
949
|
+
});
|
|
950
|
+
const { allocation, result } = await send(true, [...local, externalEntry('suspension')]);
|
|
951
|
+
expect(result.statusManagement).toBe('mixed');
|
|
952
|
+
const input = {
|
|
953
|
+
boostUri: allocation.boostUri,
|
|
954
|
+
recipientProfileId: 'status-holder',
|
|
955
|
+
credentialUri: result.credentialUri,
|
|
956
|
+
};
|
|
957
|
+
for (const mutate of [
|
|
958
|
+
issuer.clients.fullAuth.boost.suspendBoostRecipient,
|
|
959
|
+
issuer.clients.fullAuth.boost.unsuspendBoostRecipient,
|
|
960
|
+
]) {
|
|
961
|
+
await expect(mutate(input)).rejects.toMatchObject({
|
|
962
|
+
code: 'PRECONDITION_FAILED',
|
|
963
|
+
message:
|
|
964
|
+
"This credential's public suspension is managed outside LearnCard. Update it with your status-list provider.",
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
await issuer.clients.fullAuth.boost.revokeBoostRecipient(input);
|
|
968
|
+
expect(await bit(local[0]!)).toBe(true);
|
|
969
|
+
});
|
|
970
|
+
});
|