@learncard/network-brain-service 4.1.2 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +14 -0
- package/CHANGELOG.md +164 -0
- package/README.md +27 -1
- package/bootstrapLambda.test.ts +70 -32
- package/config/config.dev.json +1 -0
- package/config/config.example.json +2 -1
- package/config/config.production.json +1 -0
- package/config/config.scouts.dev.json +1 -0
- package/config/config.scouts.production.json +1 -0
- package/consentFlowDeadlineLambda.test.ts +108 -0
- package/consentFlowDeadlineLambda.ts +18 -0
- package/consentFlowDeadlineLambdaApp.ts +14 -0
- package/dist/accesslayer/boost/create.d.ts +8 -2
- package/dist/accesslayer/boost/create.d.ts.map +1 -1
- package/dist/accesslayer/campaign/audience.d.ts +4 -4
- package/dist/accesslayer/campaign/audience.d.ts.map +1 -1
- package/dist/accesslayer/campaign/create.d.ts +1 -1
- package/dist/accesslayer/campaign/create.d.ts.map +1 -1
- package/dist/accesslayer/campaign/delivery.d.ts +5 -0
- package/dist/accesslayer/campaign/delivery.d.ts.map +1 -0
- package/dist/accesslayer/campaign/dispatch.d.ts +5 -0
- package/dist/accesslayer/campaign/dispatch.d.ts.map +1 -0
- package/dist/accesslayer/campaign/dispatch.test.d.ts +2 -0
- package/dist/accesslayer/campaign/dispatch.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/email.d.ts +10 -0
- package/dist/accesslayer/campaign/email.d.ts.map +1 -0
- package/dist/accesslayer/campaign/email.test.d.ts +2 -0
- package/dist/accesslayer/campaign/email.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/preferences.d.ts +5 -0
- package/dist/accesslayer/campaign/preferences.d.ts.map +1 -0
- package/dist/accesslayer/campaign/read.d.ts.map +1 -1
- package/dist/accesslayer/campaign/test.d.ts +5 -0
- package/dist/accesslayer/campaign/test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/test.test.d.ts +2 -0
- package/dist/accesslayer/campaign/test.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/update.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/delete.d.ts +1 -1
- package/dist/accesslayer/consentflowcontract/delete.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +2 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +19 -4
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +6 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/versions.d.ts +68 -0
- package/dist/accesslayer/consentflowcontract/versions.d.ts.map +1 -0
- package/dist/accesslayer/credential/create.d.ts +3 -1
- package/dist/accesslayer/credential/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/delete.d.ts +1 -0
- package/dist/accesslayer/credential/delete.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/create.d.ts +15 -3
- package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/read.d.ts +2 -0
- package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/read.d.ts +1 -0
- package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/create.d.ts +1 -0
- package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/refresh.d.ts +7 -0
- package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -1
- package/dist/app.d.ts +11 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +61 -1
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/campaign-unsubscribe.d.ts +7 -0
- package/dist/campaign-unsubscribe.d.ts.map +1 -0
- package/dist/campaign-unsubscribe.test.d.ts +2 -0
- package/dist/campaign-unsubscribe.test.d.ts.map +1 -0
- package/dist/config/environment.d.ts +42 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/stageConfig.d.ts +4 -0
- package/dist/config/stageConfig.d.ts.map +1 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/helpers/boost.helpers.d.ts +4 -1
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/campaign-app-origin.helpers.d.ts +2 -0
- package/dist/helpers/campaign-app-origin.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-email.helpers.d.ts +13 -0
- package/dist/helpers/campaign-email.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-notifications.helpers.d.ts +7 -0
- package/dist/helpers/campaign-notifications.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-queue.helpers.d.ts +7 -0
- package/dist/helpers/campaign-queue.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-queue.helpers.test.d.ts +2 -0
- package/dist/helpers/campaign-queue.helpers.test.d.ts.map +1 -0
- package/dist/helpers/campaign-unsubscribe-page.d.ts +2 -0
- package/dist/helpers/campaign-unsubscribe-page.d.ts.map +1 -0
- package/dist/helpers/campaign-unsubscribe.helpers.d.ts +7 -0
- package/dist/helpers/campaign-unsubscribe.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-webhook.helpers.test.d.ts +2 -0
- package/dist/helpers/campaign-webhook.helpers.test.d.ts.map +1 -0
- package/dist/helpers/consentflow-deadline.helpers.d.ts +5 -0
- package/dist/helpers/consentflow-deadline.helpers.d.ts.map +1 -0
- package/dist/helpers/consentflow-legacy-migration.helpers.d.ts +2 -0
- package/dist/helpers/consentflow-legacy-migration.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-events.helpers.d.ts.map +1 -1
- package/dist/helpers/contract.helpers.d.ts +1 -1
- package/dist/helpers/contract.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-display.helpers.d.ts +5 -0
- package/dist/helpers/credential-display.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-display.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-display.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-refresh-materiality.helpers.d.ts +4 -0
- package/dist/helpers/credential-refresh-materiality.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-refresh.helpers.d.ts +30 -1
- package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-status-mutation.helpers.d.ts +5 -0
- package/dist/helpers/credential-status-mutation.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-status-mutation.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-status-mutation.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential.helpers.d.ts +1 -1
- package/dist/helpers/credential.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox-refresh.helpers.d.ts +4 -2
- package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox.helpers.d.ts +1 -0
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/learnCard.helpers.d.ts +1 -0
- package/dist/helpers/learnCard.helpers.d.ts.map +1 -1
- package/dist/helpers/managed-status.helpers.d.ts +7 -0
- package/dist/helpers/managed-status.helpers.d.ts.map +1 -0
- package/dist/helpers/managed-status.helpers.test.d.ts +2 -0
- package/dist/helpers/managed-status.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts +2 -0
- package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notification-webhook-fetch.helpers.d.ts +13 -0
- package/dist/helpers/notification-webhook-fetch.helpers.d.ts.map +1 -0
- package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts +2 -0
- package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
- package/dist/helpers/notifications.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.d.ts +13 -5
- package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.test.d.ts +2 -0
- package/dist/helpers/refresh-send-intent.helpers.test.d.ts.map +1 -0
- package/dist/helpers/send-contract.helpers.d.ts +11 -0
- package/dist/helpers/send-contract.helpers.d.ts.map +1 -0
- package/dist/helpers/status-list.helpers.d.ts +12 -1
- package/dist/helpers/status-list.helpers.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/models/ConsentFlowContract.d.ts.map +1 -1
- package/dist/models/ConsentFlowContractVersion.d.ts +12 -0
- package/dist/models/ConsentFlowContractVersion.d.ts.map +1 -0
- package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
- package/dist/models/Credential.d.ts +2 -0
- package/dist/models/Credential.d.ts.map +1 -1
- package/dist/models/CredentialRefresh.d.ts +5 -0
- package/dist/models/CredentialRefresh.d.ts.map +1 -1
- package/dist/models/Integration.d.ts +1 -1
- package/dist/models/Profile.d.ts.map +1 -1
- package/dist/models/campaign-constraints.d.ts.map +1 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/activity.d.ts +15 -5
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/app-store.d.ts +15 -5
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/auth-grants.d.ts +12 -2
- package/dist/routes/auth-grants.d.ts.map +1 -1
- package/dist/routes/bench.d.ts +11 -1
- package/dist/routes/bench.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +70 -15
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/campaigns.d.ts +183 -51
- package/dist/routes/campaigns.d.ts.map +1 -1
- package/dist/routes/claim-hooks.d.ts +11 -1
- package/dist/routes/claim-hooks.d.ts.map +1 -1
- package/dist/routes/contact-methods.d.ts +11 -1
- package/dist/routes/contact-methods.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +374 -8
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-input-privacy.test.d.ts +2 -0
- package/dist/routes/credential-input-privacy.test.d.ts.map +1 -0
- package/dist/routes/credential-refreshes.d.ts +210 -1
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credential-share-previews.d.ts +11 -1
- package/dist/routes/credential-share-previews.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +132 -3
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/did-metadata.d.ts +11 -1
- package/dist/routes/did-metadata.d.ts.map +1 -1
- package/dist/routes/federation.d.ts +11 -1
- package/dist/routes/federation.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +54 -19
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +100 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/integrations.d.ts +14 -4
- package/dist/routes/integrations.d.ts.map +1 -1
- package/dist/routes/presentations.d.ts +11 -1
- package/dist/routes/presentations.d.ts.map +1 -1
- package/dist/routes/profile-manager.d.ts +11 -1
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts +11 -1
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/public-share-links.d.ts +22 -2
- package/dist/routes/public-share-links.d.ts.map +1 -1
- package/dist/routes/share-links.d.ts +36 -16
- package/dist/routes/share-links.d.ts.map +1 -1
- package/dist/routes/skill-frameworks.d.ts +11 -1
- package/dist/routes/skill-frameworks.d.ts.map +1 -1
- package/dist/routes/skills.d.ts +11 -1
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/storage.d.ts +37 -23
- package/dist/routes/storage.d.ts.map +1 -1
- package/dist/routes/test.d.ts +79 -3
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/utilities.d.ts +11 -1
- package/dist/routes/utilities.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts +11 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.d.ts +3 -0
- package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
- package/dist/services/delivery/delivery.service.d.ts +10 -0
- package/dist/services/delivery/delivery.service.d.ts.map +1 -1
- package/dist/types/activity.d.ts +3 -3
- package/dist/types/app-manifest.d.ts +10 -10
- package/dist/types/campaign.d.ts +13 -1
- package/dist/types/campaign.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +175 -7
- package/dist/types/consentflowcontract.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +27 -0
- package/dist/types/credential-refresh.d.ts.map +1 -1
- package/dist/types/credential.d.ts +5 -0
- package/dist/types/credential.d.ts.map +1 -1
- package/dist/types/integration.d.ts +3 -3
- package/lambda.ts +44 -14
- package/lambdaApp.ts +20 -0
- package/package.json +20 -19
- package/project.json +5 -0
- package/scripts/migrate-consent-flow-versions.ts +9 -0
- package/scripts/report-send-template-exposure.ts +47 -0
- package/scripts/verify-python-contracts.ts +16 -1
- package/serverless-local.yml +15 -2
- package/serverless.function-env.cjs +4 -0
- package/serverless.local.yml +12 -0
- package/serverless.yml +48 -3
- package/src/accesslayer/boost/create.ts +62 -7
- package/src/accesslayer/campaign/README.md +275 -6
- package/src/accesslayer/campaign/audience.ts +39 -7
- package/src/accesslayer/campaign/create.ts +4 -2
- package/src/accesslayer/campaign/delivery.ts +66 -0
- package/src/accesslayer/campaign/dispatch.test.ts +59 -0
- package/src/accesslayer/campaign/dispatch.ts +172 -0
- package/src/accesslayer/campaign/email.test.ts +73 -0
- package/src/accesslayer/campaign/email.ts +91 -0
- package/src/accesslayer/campaign/preferences.ts +53 -0
- package/src/accesslayer/campaign/read.ts +3 -1
- package/src/accesslayer/campaign/state.ts +1 -1
- package/src/accesslayer/campaign/test.test.ts +31 -0
- package/src/accesslayer/campaign/test.ts +75 -0
- package/src/accesslayer/campaign/update.ts +15 -4
- package/src/accesslayer/consentflowcontract/create.ts +20 -1
- package/src/accesslayer/consentflowcontract/delete.ts +121 -3
- package/src/accesslayer/consentflowcontract/read.ts +15 -9
- package/src/accesslayer/consentflowcontract/relationships/create.ts +70 -36
- package/src/accesslayer/consentflowcontract/relationships/read.ts +305 -73
- package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +1 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +269 -98
- package/src/accesslayer/consentflowcontract/versions.ts +790 -0
- package/src/accesslayer/credential/create.ts +33 -9
- package/src/accesslayer/credential/delete.ts +45 -0
- package/src/accesslayer/credential/relationships/create.ts +102 -47
- package/src/accesslayer/credential/relationships/update.ts +8 -116
- package/src/accesslayer/credential-refresh/read.ts +26 -2
- package/src/accesslayer/credential-refresh/update.ts +6 -2
- package/src/accesslayer/inbox-credential/create.ts +7 -1
- package/src/accesslayer/inbox-credential/refresh.ts +14 -4
- package/src/campaign-unsubscribe.test.ts +158 -0
- package/src/campaign-unsubscribe.ts +86 -0
- package/src/config/environment.test.ts +52 -0
- package/src/config/environment.ts +58 -0
- package/src/config/serverlessFunctionEnv.test.ts +59 -0
- package/src/credential-refresh.ts +1 -0
- package/src/docker-entry.ts +26 -1
- package/src/helpers/boost-storage.helpers.test.ts +107 -41
- package/src/helpers/boost.helpers.ts +88 -40
- package/src/helpers/campaign-app-origin.helpers.ts +25 -0
- package/src/helpers/campaign-email.helpers.ts +113 -0
- package/src/helpers/campaign-notifications.helpers.ts +127 -0
- package/src/helpers/campaign-queue.helpers.test.ts +442 -0
- package/src/helpers/campaign-queue.helpers.ts +122 -0
- package/src/helpers/campaign-unsubscribe-page.ts +107 -0
- package/src/helpers/campaign-unsubscribe.helpers.ts +56 -0
- package/src/helpers/campaign-webhook.helpers.test.ts +137 -0
- package/src/helpers/consentflow-deadline.helpers.ts +22 -0
- package/src/helpers/consentflow-legacy-migration.helpers.ts +179 -0
- package/src/helpers/contract-events.helpers.ts +35 -3
- package/src/helpers/contract-requests.helpers.ts +1 -1
- package/src/helpers/contract.helpers.ts +5 -1
- package/src/helpers/credential-display.helpers.test.ts +68 -0
- package/src/helpers/credential-display.helpers.ts +15 -0
- package/src/helpers/credential-refresh-email.helpers.ts +5 -12
- package/src/helpers/credential-refresh-materiality.helpers.test.ts +138 -1
- package/src/helpers/credential-refresh-materiality.helpers.ts +62 -1
- package/src/helpers/credential-refresh-proof.helpers.test.ts +1 -1
- package/src/helpers/credential-refresh-proof.helpers.ts +2 -2
- package/src/helpers/credential-refresh.helpers.ts +1166 -376
- package/src/helpers/credential-status-mutation.helpers.test.ts +155 -0
- package/src/helpers/credential-status-mutation.helpers.ts +166 -0
- package/src/helpers/credential.helpers.ts +19 -11
- package/src/helpers/finalize-inbox.helpers.ts +1 -1
- package/src/helpers/inbox-refresh.helpers.ts +34 -8
- package/src/helpers/inbox.helpers.ts +2 -0
- package/src/helpers/learnCard.helpers.ts +23 -0
- package/src/helpers/managed-status.helpers.test.ts +101 -0
- package/src/helpers/managed-status.helpers.ts +147 -0
- package/src/helpers/notification-delivery-boundary.helpers.test.ts +114 -0
- package/src/helpers/notification-webhook-fetch.helpers.test.ts +228 -0
- package/src/helpers/notification-webhook-fetch.helpers.ts +166 -0
- package/src/helpers/notificationQueue.helpers.ts +5 -0
- package/src/helpers/notifications.helpers.ts +19 -12
- package/src/helpers/refresh-send-intent.helpers.test.ts +60 -0
- package/src/helpers/refresh-send-intent.helpers.ts +81 -18
- package/src/helpers/send-contract.helpers.ts +36 -0
- package/src/helpers/status-list.helpers.test.ts +29 -12
- package/src/helpers/status-list.helpers.ts +329 -20
- package/src/models/ConsentFlowContract.ts +3 -0
- package/src/models/ConsentFlowContractVersion.ts +50 -0
- package/src/models/ConsentFlowTerms.ts +4 -0
- package/src/models/Credential.ts +2 -0
- package/src/models/CredentialRefresh.ts +2 -0
- package/src/models/Profile.ts +2 -0
- package/src/models/campaign-constraints.ts +13 -8
- package/src/models/index.ts +2 -0
- package/src/openapi.ts +21 -0
- package/src/routes/app-store.ts +2 -1
- package/src/routes/boosts.ts +219 -137
- package/src/routes/campaigns.ts +98 -19
- package/src/routes/contracts.ts +433 -115
- package/src/routes/credential-input-privacy.test.ts +92 -0
- package/src/routes/credential-refreshes.ts +273 -2
- package/src/routes/federation.ts +7 -2
- package/src/routes/inbox.ts +6 -1
- package/src/routes/index.ts +31 -2
- package/src/routes/workflows.ts +61 -46
- package/src/services/delivery/adapters/postmark.adapter.test.ts +70 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +84 -11
- package/src/services/delivery/delivery.service.ts +4 -0
- package/src/types/campaign.ts +14 -1
- package/src/types/consentflowcontract.ts +43 -2
- package/src/types/credential-refresh.ts +11 -1
- package/src/types/credential.ts +2 -0
- package/test/boost-status-cleanup.spec.ts +123 -0
- package/test/campaigns-email.unit.spec.ts +268 -0
- package/test/campaigns.spec.ts +804 -30
- package/test/consent-autoboost-idempotency.spec.ts +432 -0
- package/test/consent-data-boundaries.spec.ts +322 -26
- package/test/consent-legacy-migration.spec.ts +315 -0
- package/test/consent-recipients.spec.ts +7 -7
- package/test/consent-version-updates.spec.ts +761 -0
- package/test/consentflow.spec.ts +698 -226
- package/test/contract-requests.spec.ts +55 -9
- package/test/credential-refresh-allocation.spec.ts +212 -14
- package/test/credential-refresh-email.spec.ts +6 -10
- package/test/credential-refresh-endpoint.spec.ts +3 -0
- package/test/credential-refresh-publication.spec.ts +52 -15
- package/test/credential-storage.neo4j.spec.ts +33 -6
- package/test/helpers/legacy-autoboost.ts +48 -0
- package/test/holder-mode-send.spec.ts +710 -0
- package/test/inbox-refresh.spec.ts +105 -0
- package/test/inbox-security.e2e.ts +18 -22
- package/test/managed-status-revocation.spec.ts +970 -0
- package/test/notifications-worker.spec.ts +31 -25
- package/test/refresh-send.spec.ts +334 -5
- package/test/smart-resume-consent.spec.ts +1 -1
- package/test/verification/campaigns.ts +40 -0
- package/vitest.campaigns.config.ts +2 -0
- package/vitest.config.ts +14 -0
|
@@ -1,19 +1,29 @@
|
|
|
1
|
-
import { createHash } from 'crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
2
|
|
|
3
3
|
import { TRPCError } from '@trpc/server';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import {
|
|
6
6
|
VC,
|
|
7
|
+
ManagedCredentialRefreshBindingValidator,
|
|
7
8
|
type AllocateCredentialRefreshResult,
|
|
9
|
+
type BitstringStatusListEntry,
|
|
8
10
|
type CredentialRefreshSigningMode,
|
|
11
|
+
type CredentialRefreshUpdateReason,
|
|
12
|
+
type CredentialStatus,
|
|
13
|
+
type HolderEncryptedHandoff,
|
|
14
|
+
type JWE,
|
|
9
15
|
type ManagedCredentialRefreshReceipt,
|
|
10
16
|
type ManagedCredentialRefreshService,
|
|
11
17
|
type PublishCredentialRefreshInput,
|
|
12
18
|
type PublishCredentialRefreshNotification,
|
|
13
19
|
type PublishCredentialRefreshResult,
|
|
20
|
+
type PublishHolderEncryptedRefreshInput,
|
|
14
21
|
} from '@learncard/types';
|
|
15
22
|
import {
|
|
23
|
+
assertExactJweRecipients,
|
|
24
|
+
canonicalizeCredentialJson,
|
|
16
25
|
getCredentialEffectiveTime,
|
|
26
|
+
getBitstringStatusListEntries,
|
|
17
27
|
getCredentialIssuerId,
|
|
18
28
|
getManagedRefreshServices,
|
|
19
29
|
prepareManagedRefreshContext,
|
|
@@ -23,7 +33,7 @@ import { neogma } from '@instance';
|
|
|
23
33
|
|
|
24
34
|
import { storeCredential } from '@accesslayer/credential/create';
|
|
25
35
|
import { deleteCredential } from '@accesslayer/credential/delete';
|
|
26
|
-
import { getBoostByUri } from '@accesslayer/boost/read';
|
|
36
|
+
import { getBoostByUri, getBoostById } from '@accesslayer/boost/read';
|
|
27
37
|
import { canProfileIssueBoost } from '@accesslayer/boost/relationships/read';
|
|
28
38
|
import { getProfileByProfileId } from '@accesslayer/profile/read';
|
|
29
39
|
import { getProfilesThatManageAProfile } from '@accesslayer/profile/relationships/read';
|
|
@@ -43,22 +53,29 @@ import type {
|
|
|
43
53
|
CredentialRefreshVersionNode,
|
|
44
54
|
} from 'types/credential-refresh';
|
|
45
55
|
|
|
46
|
-
import {
|
|
47
|
-
import type { CredentialInstance } from '@models';
|
|
56
|
+
import { ConsentFlowTransaction, Credential } from '@models';
|
|
48
57
|
import type { DbTermsType } from 'types/consentflowcontract';
|
|
58
|
+
import { BindParam, QueryBuilder } from 'neogma';
|
|
59
|
+
import { runAudienceMutation } from '@accesslayer/consentflowcontract/relationships/recipients';
|
|
60
|
+
import { termsReferralSnapshotCypher } from './consent-referral.helpers';
|
|
49
61
|
|
|
50
|
-
import { createDagJweForRecipients, getLearnCard } from './learnCard.helpers';
|
|
62
|
+
import { createDagJweForRecipients, getKeyAgreementKeyId, getLearnCard } from './learnCard.helpers';
|
|
51
63
|
import { verifyManagedRefreshProof } from './credential-refresh-proof.helpers';
|
|
52
64
|
import { issueCredentialWithSigningAuthority } from './signingAuthority.helpers';
|
|
65
|
+
import { logCredentialSent } from './activity.helpers';
|
|
53
66
|
import {
|
|
54
67
|
computeCredentialMaterialDigest,
|
|
68
|
+
matchesCredentialMaterialDigest,
|
|
69
|
+
matchesCredentialSubjectDigest,
|
|
55
70
|
computeCredentialStatusDigest,
|
|
71
|
+
matchesCredentialStatusDigest,
|
|
56
72
|
computeCredentialSubjectDigest,
|
|
57
73
|
decideCredentialRefreshNotification,
|
|
58
74
|
} from './credential-refresh-materiality.helpers';
|
|
59
75
|
import { isInitialRefreshVersionUniquenessRace } from './credential-refresh-initial-binding.helpers';
|
|
60
76
|
import { ensureInitialNotificationPolicy } from './credential-refresh-notification-policy.helpers';
|
|
61
|
-
import { getStatusListBaseUrl } from './status-list.helpers';
|
|
77
|
+
import { getStatusListBaseUrl, getStatusManagement } from './status-list.helpers';
|
|
78
|
+
import { bindRefreshStatusEntries } from './managed-status.helpers';
|
|
62
79
|
import { constructUri } from './uri.helpers';
|
|
63
80
|
import {
|
|
64
81
|
addNotificationToQueue,
|
|
@@ -67,10 +84,19 @@ import {
|
|
|
67
84
|
} from './notifications.helpers';
|
|
68
85
|
import { deliverCredentialRefreshEmailNotification } from './credential-refresh-email.helpers';
|
|
69
86
|
import { extractBoundedCredentialDisplayTitle } from './credential-refresh-email-content.helpers';
|
|
87
|
+
import { getPublicBoostDisplayName } from './credential-display.helpers';
|
|
70
88
|
import type { TenantBranding } from '@learncard/email-templates';
|
|
71
89
|
import { getDidWeb } from './did.helpers';
|
|
72
90
|
import { isRelationshipBlocked } from './connection.helpers';
|
|
73
91
|
import { ProfileType } from 'types/profile';
|
|
92
|
+
import { resolveContractForSend } from './send-contract.helpers';
|
|
93
|
+
import {
|
|
94
|
+
getRefreshSendIntent,
|
|
95
|
+
getRefreshSendIntentForRefresh,
|
|
96
|
+
markRefreshSendIntentDelivered,
|
|
97
|
+
reconcileBoundRefreshSendIntent,
|
|
98
|
+
recordRefreshSendIntentPending,
|
|
99
|
+
} from './refresh-send-intent.helpers';
|
|
74
100
|
|
|
75
101
|
/**
|
|
76
102
|
* Managed credential refresh issuance helpers (LC-2135).
|
|
@@ -95,6 +121,8 @@ export type AllocateCredentialRefreshParams = {
|
|
|
95
121
|
holderDid: string;
|
|
96
122
|
credentialId: string;
|
|
97
123
|
domain: string;
|
|
124
|
+
/** Pins the aggregate to student-only encryption (LC-2345). Omit for network mode. */
|
|
125
|
+
encryptionMode?: 'holder';
|
|
98
126
|
};
|
|
99
127
|
|
|
100
128
|
/**
|
|
@@ -105,7 +133,8 @@ export type AllocateCredentialRefreshParams = {
|
|
|
105
133
|
export const allocateCredentialRefresh = async (
|
|
106
134
|
params: AllocateCredentialRefreshParams
|
|
107
135
|
): Promise<AllocateCredentialRefreshResult> => {
|
|
108
|
-
const { issuerProfile, holderProfile, holderDid, credentialId, domain } =
|
|
136
|
+
const { issuerProfile, holderProfile, holderDid, credentialId, domain, encryptionMode } =
|
|
137
|
+
params;
|
|
109
138
|
|
|
110
139
|
const refreshId = generateRefreshId();
|
|
111
140
|
const now = new Date().toISOString();
|
|
@@ -127,6 +156,7 @@ export const allocateCredentialRefresh = async (
|
|
|
127
156
|
currentVersion: 1,
|
|
128
157
|
createdAt: now,
|
|
129
158
|
updatedAt: now,
|
|
159
|
+
...(encryptionMode ? { encryptionMode } : {}),
|
|
130
160
|
};
|
|
131
161
|
|
|
132
162
|
await neogma.queryRunner.run(
|
|
@@ -237,10 +267,12 @@ export const peekCredentialRefreshInitialBinding = async (params: {
|
|
|
237
267
|
|
|
238
268
|
assertInitialCredentialMatches(
|
|
239
269
|
root,
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
270
|
+
stored =>
|
|
271
|
+
matchesCredentialMaterialDigest(
|
|
272
|
+
credential as unknown as Record<string, unknown>,
|
|
273
|
+
stored
|
|
274
|
+
),
|
|
275
|
+
credential.credentialStatus,
|
|
244
276
|
boostId
|
|
245
277
|
);
|
|
246
278
|
|
|
@@ -259,28 +291,6 @@ export const peekCredentialRefreshInitialBinding = async (params: {
|
|
|
259
291
|
};
|
|
260
292
|
};
|
|
261
293
|
|
|
262
|
-
/**
|
|
263
|
-
* Idempotently links a managed root credential to the approved consent-flow contract
|
|
264
|
-
* terms it was issued through, on both fresh binds and resume paths.
|
|
265
|
-
*/
|
|
266
|
-
const ensureCredentialIssuedViaContractRelationship = async (
|
|
267
|
-
credentialNodeId: string,
|
|
268
|
-
terms: DbTermsType
|
|
269
|
-
): Promise<void> => {
|
|
270
|
-
const existing = await neogma.queryRunner.run(
|
|
271
|
-
`MATCH (root:Credential {id: $nodeId})-[:ISSUED_VIA_TRANSACTION]->()
|
|
272
|
-
RETURN 1 AS one LIMIT 1`,
|
|
273
|
-
{ nodeId: credentialNodeId }
|
|
274
|
-
);
|
|
275
|
-
|
|
276
|
-
if (existing.records.length > 0) return;
|
|
277
|
-
|
|
278
|
-
await createCredentialIssuedViaContractRelationship(
|
|
279
|
-
{ id: credentialNodeId } as unknown as CredentialInstance,
|
|
280
|
-
terms
|
|
281
|
-
);
|
|
282
|
-
};
|
|
283
|
-
|
|
284
294
|
/** Collects the subject identifiers of a credential (single object or array form). */
|
|
285
295
|
const getCredentialSubjectIds = (credential: VC): string[] => {
|
|
286
296
|
const subjects = Array.isArray(credential.credentialSubject)
|
|
@@ -335,6 +345,8 @@ export type SendRefreshableCredentialParams = {
|
|
|
335
345
|
export type SendRefreshableCredentialResult = {
|
|
336
346
|
uri: string;
|
|
337
347
|
receipt: ManagedCredentialRefreshReceipt;
|
|
348
|
+
/** Public status providers; external entries require provider-side lifecycle updates. */
|
|
349
|
+
statusManagement?: ReturnType<typeof getStatusManagement>;
|
|
338
350
|
};
|
|
339
351
|
|
|
340
352
|
type InitialRefreshRoot = {
|
|
@@ -379,15 +391,15 @@ export const getBoundRefreshBoostId = async (refreshId: string): Promise<string
|
|
|
379
391
|
|
|
380
392
|
const assertInitialCredentialMatches = (
|
|
381
393
|
root: InitialRefreshRoot,
|
|
382
|
-
|
|
383
|
-
|
|
394
|
+
matchesMaterialDigest: (stored: string) => boolean,
|
|
395
|
+
credentialStatus: unknown,
|
|
384
396
|
boostId?: string
|
|
385
397
|
): void => {
|
|
386
398
|
if (
|
|
387
399
|
!root.materialDigest ||
|
|
388
400
|
!root.credentialStatusDigest ||
|
|
389
|
-
root.materialDigest
|
|
390
|
-
root.credentialStatusDigest
|
|
401
|
+
!matchesMaterialDigest(root.materialDigest) ||
|
|
402
|
+
!matchesCredentialStatusDigest(credentialStatus, root.credentialStatusDigest)
|
|
391
403
|
) {
|
|
392
404
|
throw new TRPCError({
|
|
393
405
|
code: 'CONFLICT',
|
|
@@ -410,34 +422,90 @@ const ensureInitialRefreshRelationships = async (params: {
|
|
|
410
422
|
boostId?: string;
|
|
411
423
|
activityId?: string;
|
|
412
424
|
integrationId?: string;
|
|
425
|
+
contractTerms?: DbTermsType;
|
|
413
426
|
}): Promise<string | undefined> => {
|
|
414
|
-
const {
|
|
415
|
-
|
|
427
|
+
const {
|
|
428
|
+
refreshId,
|
|
429
|
+
issuerProfileId,
|
|
430
|
+
holderProfileId,
|
|
431
|
+
boostId,
|
|
432
|
+
activityId,
|
|
433
|
+
integrationId,
|
|
434
|
+
contractTerms,
|
|
435
|
+
} = params;
|
|
416
436
|
const now = new Date().toISOString();
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
437
|
+
const values = {
|
|
438
|
+
refreshId,
|
|
439
|
+
issuerProfileId,
|
|
440
|
+
holderProfileId,
|
|
441
|
+
boostId: boostId ?? null,
|
|
442
|
+
activityId: activityId ?? null,
|
|
443
|
+
integrationId: integrationId ?? null,
|
|
444
|
+
now,
|
|
445
|
+
};
|
|
446
|
+
const query = contractTerms
|
|
447
|
+
? new QueryBuilder(
|
|
448
|
+
new BindParam({
|
|
449
|
+
...values,
|
|
450
|
+
termsId: contractTerms.id,
|
|
451
|
+
expectedVersion: contractTerms.consentedVersion,
|
|
452
|
+
transactionId: randomUUID(),
|
|
453
|
+
})
|
|
454
|
+
).raw(
|
|
455
|
+
`MATCH (terms:ConsentFlowTerms {id:$termsId})-[:CONSENTS_TO]->(contract:ConsentFlowContract)
|
|
456
|
+
SET contract.audienceLock = coalesce(contract.audienceLock, 0) + 1
|
|
457
|
+
WITH terms, contract
|
|
458
|
+
WHERE coalesce(contract.status, 'active') <> 'retired'
|
|
459
|
+
AND contract.currentVersion = $expectedVersion
|
|
460
|
+
AND terms.consentedVersion = $expectedVersion
|
|
461
|
+
AND (terms.status = 'live' OR (terms.status = 'consumed' AND terms.oneTime = true
|
|
462
|
+
AND EXISTS {
|
|
463
|
+
MATCH (:CredentialRefresh {refreshId:$refreshId})-[:ROOT]->(:Credential)
|
|
464
|
+
-[:${Credential.getRelationshipByAlias('issuedViaTransaction').name}]->(:ConsentFlowTransaction)
|
|
465
|
+
-[:${ConsentFlowTransaction.getRelationshipByAlias('isFor').name}]->(terms)
|
|
466
|
+
}))
|
|
467
|
+
AND (terms.expiresAt IS NULL OR trim(terms.expiresAt) = '' OR datetime(terms.expiresAt) > datetime.realtime())
|
|
468
|
+
AND (contract.expiresAt IS NULL OR trim(contract.expiresAt) = '' OR datetime(contract.expiresAt) > datetime.realtime())
|
|
469
|
+
AND NOT $issuerProfileId IN coalesce(terms['terms.deniedWriters'], [])
|
|
470
|
+
AND EXISTS { MATCH (:Profile {profileId:$holderProfileId})-[:CREATED_BY]->(terms) }
|
|
471
|
+
MATCH (refresh:CredentialRefresh {refreshId: $refreshId})-[:ROOT]->(root:Credential)
|
|
472
|
+
WHERE coalesce(refresh.boostId, '') = coalesce($boostId, '')
|
|
473
|
+
MATCH (issuer:Profile {profileId: $issuerProfileId})
|
|
474
|
+
OPTIONAL MATCH (boost:Boost {id: $boostId})
|
|
475
|
+
WITH terms, root, issuer, boost
|
|
476
|
+
WHERE boost IS NULL OR boost.category IS NULL OR terms['terms.write.credentials.categories.' + boost.category] = true
|
|
477
|
+
MERGE (issuer)-[sent:CREDENTIAL_SENT {to: $holderProfileId}]->(root)
|
|
478
|
+
ON CREATE SET sent.date = $now
|
|
479
|
+
SET sent.activityId = coalesce(sent.activityId, $activityId),
|
|
480
|
+
sent.integrationId = coalesce(sent.integrationId, $integrationId)
|
|
481
|
+
FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
|
|
482
|
+
MERGE (root)-[:INSTANCE_OF]->(boost)
|
|
483
|
+
)
|
|
484
|
+
OPTIONAL MATCH (root)-[:${Credential.getRelationshipByAlias('issuedViaTransaction').name}]->(existing:ConsentFlowTransaction)-[:${ConsentFlowTransaction.getRelationshipByAlias('isFor').name}]->(terms)
|
|
485
|
+
WITH root, terms, existing
|
|
486
|
+
FOREACH (_ IN CASE WHEN existing IS NULL THEN [1] ELSE [] END |
|
|
487
|
+
CREATE (transaction:ConsentFlowTransaction {id:$transactionId, action:'write', date:$now, boostId:$boostId})
|
|
488
|
+
-[:${ConsentFlowTransaction.getRelationshipByAlias('isFor').name}]->(terms)
|
|
489
|
+
CREATE (root)-[:${Credential.getRelationshipByAlias('issuedViaTransaction').name} {date:$now}]->(transaction)
|
|
490
|
+
${termsReferralSnapshotCypher()}
|
|
491
|
+
)
|
|
492
|
+
RETURN root.id AS rootId`
|
|
493
|
+
)
|
|
494
|
+
: new QueryBuilder(new BindParam(values)).raw(
|
|
495
|
+
`MATCH (refresh:CredentialRefresh {refreshId: $refreshId})-[:ROOT]->(root:Credential)
|
|
496
|
+
WHERE coalesce(refresh.boostId, '') = coalesce($boostId, '')
|
|
497
|
+
MATCH (issuer:Profile {profileId: $issuerProfileId})
|
|
498
|
+
OPTIONAL MATCH (boost:Boost {id: $boostId})
|
|
499
|
+
MERGE (issuer)-[sent:CREDENTIAL_SENT {to: $holderProfileId}]->(root)
|
|
500
|
+
ON CREATE SET sent.date = $now
|
|
501
|
+
SET sent.activityId = coalesce(sent.activityId, $activityId),
|
|
502
|
+
sent.integrationId = coalesce(sent.integrationId, $integrationId)
|
|
503
|
+
FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
|
|
504
|
+
MERGE (root)-[:INSTANCE_OF]->(boost)
|
|
505
|
+
)
|
|
506
|
+
RETURN root.id AS rootId`
|
|
507
|
+
);
|
|
508
|
+
const result = contractTerms ? await runAudienceMutation(query) : await query.run();
|
|
441
509
|
return result.records[0]?.get('rootId') ?? undefined;
|
|
442
510
|
};
|
|
443
511
|
|
|
@@ -493,120 +561,103 @@ const getHolderEncryptionRecipients = async (holderProfile: ProfileType): Promis
|
|
|
493
561
|
};
|
|
494
562
|
|
|
495
563
|
/**
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
* CREDENTIAL_SENT relationship plus ROOT/HEAD bindings and records version 1.
|
|
564
|
+
* Current key-agreement verification-method ids (LC-2345 holder mode): the same
|
|
565
|
+
* holder + managers as {@link getHolderEncryptionRecipients}, resolved to the exact
|
|
566
|
+
* `kid`s a DAG-JWE must carry. Recomputed on every call so a manager change is
|
|
567
|
+
* reflected immediately — callers compare this against a handoff's JWE recipients
|
|
568
|
+
* (`assertExactJweRecipients`) rather than trusting a previously-prepared set.
|
|
502
569
|
*/
|
|
503
|
-
export const
|
|
504
|
-
|
|
505
|
-
): Promise<
|
|
570
|
+
export const getHolderEncryptionRecipientKeyIds = async (
|
|
571
|
+
holderProfile: ProfileType
|
|
572
|
+
): Promise<string[]> => {
|
|
573
|
+
const dids = await getHolderEncryptionRecipients(holderProfile);
|
|
574
|
+
|
|
575
|
+
return Promise.all(dids.map(did => getKeyAgreementKeyId(did)));
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* True when `issuerDid` is an identity `issuerProfile` controls: either its internal
|
|
580
|
+
* controller did:key or its public network did:web. Both are legitimate on version 1
|
|
581
|
+
* of a managed credential (see {@link sendRefreshableCredential}); later versions must
|
|
582
|
+
* match whichever one version 1 actually used.
|
|
583
|
+
*/
|
|
584
|
+
export const isControlledIssuerDid = (
|
|
585
|
+
issuerDid: string,
|
|
586
|
+
issuerProfile: ProfileType,
|
|
587
|
+
domain: string
|
|
588
|
+
): boolean => [issuerProfile.did, getDidWeb(domain, issuerProfile.profileId)].includes(issuerDid);
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Shared delivery tail for version 1 of a managed credential, common to network mode
|
|
592
|
+
* (plaintext checked and encrypted in {@link sendRefreshableCredential}) and holder
|
|
593
|
+
* mode (already-encrypted handoff checked in {@link sendHolderEncryptedRefreshableCredential}):
|
|
594
|
+
* binds the public status entries, stores the JWE as the Credential, creates the
|
|
595
|
+
* ROOT/HEAD/CREDENTIAL_SENT bindings (and any consent-flow transaction) in one
|
|
596
|
+
* transaction, resolves initial-send races, and sends the (generic-copy) initial
|
|
597
|
+
* notification exactly once.
|
|
598
|
+
*/
|
|
599
|
+
const deliverManagedRefreshVersion1Credential = async (params: {
|
|
600
|
+
refreshId: string;
|
|
601
|
+
issuerProfile: ProfileType;
|
|
602
|
+
holderProfile: ProfileType;
|
|
603
|
+
aggregate: CredentialRefreshRecord;
|
|
604
|
+
credentialIssuerDid: string;
|
|
605
|
+
statusEntries: BitstringStatusListEntry[];
|
|
606
|
+
credentialStatus: CredentialStatus | CredentialStatus[] | undefined;
|
|
607
|
+
refreshService: ManagedCredentialRefreshReceipt['refreshService'];
|
|
608
|
+
jwe: JWE;
|
|
609
|
+
materialDigest: string;
|
|
610
|
+
/**
|
|
611
|
+
* Compares a stored root material digest with this delivery. Network sends match
|
|
612
|
+
* under any retained digest key; holder handoffs (ciphertext digests) match exactly.
|
|
613
|
+
*/
|
|
614
|
+
matchesMaterialDigest?: (stored: string) => boolean;
|
|
615
|
+
credentialStatusDigest: string;
|
|
616
|
+
credentialSubjectDigest: string | null;
|
|
617
|
+
boostUri?: string;
|
|
618
|
+
skipNotification: boolean;
|
|
619
|
+
activityId?: string;
|
|
620
|
+
integrationId?: string;
|
|
621
|
+
contractTerms?: DbTermsType;
|
|
622
|
+
domain: string;
|
|
623
|
+
}): Promise<SendRefreshableCredentialResult> => {
|
|
506
624
|
const {
|
|
507
|
-
issuerProfile,
|
|
508
625
|
refreshId,
|
|
509
|
-
|
|
626
|
+
issuerProfile,
|
|
627
|
+
holderProfile,
|
|
628
|
+
aggregate,
|
|
629
|
+
credentialIssuerDid,
|
|
630
|
+
statusEntries,
|
|
631
|
+
credentialStatus,
|
|
632
|
+
refreshService,
|
|
633
|
+
jwe,
|
|
634
|
+
materialDigest: rootMaterialDigest,
|
|
635
|
+
credentialStatusDigest,
|
|
636
|
+
credentialSubjectDigest,
|
|
510
637
|
boostUri,
|
|
511
|
-
skipNotification
|
|
638
|
+
skipNotification,
|
|
512
639
|
activityId,
|
|
513
640
|
integrationId,
|
|
514
641
|
contractTerms,
|
|
515
642
|
domain,
|
|
516
643
|
} = params;
|
|
644
|
+
const matchesMaterialDigest =
|
|
645
|
+
params.matchesMaterialDigest ?? ((stored: string) => stored === rootMaterialDigest);
|
|
517
646
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
throw new TRPCError({ code: 'NOT_FOUND', message: 'Credential refresh not found' });
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
if (aggregate.issuerProfileId !== issuerProfile.profileId) {
|
|
525
|
-
throw new TRPCError({
|
|
526
|
-
code: 'UNAUTHORIZED',
|
|
527
|
-
message: 'Profile did not allocate this credential refresh',
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
if (
|
|
532
|
-
!aggregate.holderDid ||
|
|
533
|
-
aggregate.state === 'pending_holder' ||
|
|
534
|
-
aggregate.inboxCredentialId
|
|
535
|
-
) {
|
|
536
|
-
throw new TRPCError({
|
|
537
|
-
code: 'CONFLICT',
|
|
538
|
-
message: 'Inbox refresh must be bound through its verified claim flow',
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (credential.id !== aggregate.credentialId) {
|
|
543
|
-
throw new TRPCError({
|
|
544
|
-
code: 'BAD_REQUEST',
|
|
545
|
-
message: 'Credential ID does not match the allocated refresh',
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const credentialIssuerDid = getCredentialIssuerId(credential);
|
|
550
|
-
const publicIssuerDid = getDidWeb(domain, issuerProfile.profileId);
|
|
551
|
-
|
|
552
|
-
// A profile controls both its internal controller did:key and its public network
|
|
553
|
-
// did:web. The network plugin normally issues with did:web, while lower-level
|
|
554
|
-
// callers may legitimately issue with the controller DID. Bind the aggregate to
|
|
555
|
-
// whichever controlled identity appears on version 1; later versions must match
|
|
556
|
-
// that exact normalized issuer.
|
|
557
|
-
if (
|
|
558
|
-
!credentialIssuerDid ||
|
|
559
|
-
![issuerProfile.did, publicIssuerDid].includes(credentialIssuerDid)
|
|
560
|
-
) {
|
|
561
|
-
throw new TRPCError({
|
|
562
|
-
code: 'BAD_REQUEST',
|
|
563
|
-
message: 'Credential issuer does not match the allocated refresh',
|
|
564
|
-
});
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
if (
|
|
568
|
-
!aggregate.holderDid ||
|
|
569
|
-
!getCredentialSubjectIds(credential).includes(aggregate.holderDid)
|
|
570
|
-
) {
|
|
571
|
-
throw new TRPCError({
|
|
572
|
-
code: 'BAD_REQUEST',
|
|
573
|
-
message: 'Credential subject does not match the intended holder',
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
if (!hasAllocatedRefreshService(credential, refreshId, domain)) {
|
|
578
|
-
throw new TRPCError({
|
|
579
|
-
code: 'BAD_REQUEST',
|
|
580
|
-
message: 'Credential does not contain the allocated refresh service',
|
|
581
|
-
});
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// Metadata-only issuance receipt (LC-2198 decision 2): populated from the actual
|
|
585
|
-
// signed credential and the validated allocation. It deliberately excludes all
|
|
586
|
-
// credential claims, subject bodies, plaintext VC content, and JWE payloads.
|
|
647
|
+
// Metadata-only issuance receipt (LC-2198 decision 2): populated from the
|
|
648
|
+
// validated allocation. It deliberately excludes all credential claims, subject
|
|
649
|
+
// bodies, plaintext VC content, and JWE payloads.
|
|
587
650
|
const buildReceipt = (): ManagedCredentialRefreshReceipt => ({
|
|
588
651
|
refreshId,
|
|
589
|
-
refreshService
|
|
590
|
-
id: getCredentialRefreshServiceUrl(refreshId, domain),
|
|
591
|
-
type: 'LearnCardCredentialRefresh2026',
|
|
592
|
-
authorization: { type: 'LearnCardDIDAuth' },
|
|
593
|
-
},
|
|
652
|
+
refreshService,
|
|
594
653
|
credentialId: aggregate.credentialId,
|
|
595
654
|
issuerDid: credentialIssuerDid,
|
|
596
655
|
holderDid: aggregate.holderDid!,
|
|
597
|
-
...(
|
|
598
|
-
? { credentialStatus
|
|
656
|
+
...(credentialStatus !== undefined && credentialStatus !== null
|
|
657
|
+
? { credentialStatus }
|
|
599
658
|
: {}),
|
|
600
659
|
});
|
|
601
660
|
|
|
602
|
-
const holderProfile = aggregate.holderProfileId
|
|
603
|
-
? await getProfileByProfileId(aggregate.holderProfileId)
|
|
604
|
-
: null;
|
|
605
|
-
|
|
606
|
-
if (!holderProfile) {
|
|
607
|
-
throw new TRPCError({ code: 'NOT_FOUND', message: 'Recipient profile not found' });
|
|
608
|
-
}
|
|
609
|
-
|
|
610
661
|
if (await isRelationshipBlocked(issuerProfile, holderProfile)) {
|
|
611
662
|
throw new TRPCError({
|
|
612
663
|
code: 'NOT_FOUND',
|
|
@@ -630,25 +681,16 @@ export const sendRefreshableCredential = async (
|
|
|
630
681
|
});
|
|
631
682
|
}
|
|
632
683
|
|
|
633
|
-
await verifyManagedRefreshProof((await getLearnCard()).invoke, credential, publicIssuerDid);
|
|
634
|
-
|
|
635
|
-
const rootMaterialDigest = computeCredentialMaterialDigest(
|
|
636
|
-
credential as unknown as Record<string, unknown>
|
|
637
|
-
);
|
|
638
|
-
const credentialStatusDigest = computeCredentialStatusDigest(
|
|
639
|
-
(credential as unknown as Record<string, unknown>).credentialStatus
|
|
640
|
-
);
|
|
641
|
-
|
|
642
684
|
const existingRoot = await getInitialRefreshRoot(refreshId);
|
|
643
685
|
|
|
644
686
|
if (existingRoot) {
|
|
645
687
|
assertInitialCredentialMatches(
|
|
646
688
|
existingRoot,
|
|
647
|
-
|
|
648
|
-
|
|
689
|
+
matchesMaterialDigest,
|
|
690
|
+
credentialStatus,
|
|
649
691
|
boost?.id
|
|
650
692
|
);
|
|
651
|
-
|
|
693
|
+
await bindRefreshStatusEntries(refreshId, statusEntries, issuerProfile.profileId, domain);
|
|
652
694
|
const rootId = await ensureInitialRefreshRelationships({
|
|
653
695
|
refreshId,
|
|
654
696
|
issuerProfileId: issuerProfile.profileId,
|
|
@@ -656,19 +698,16 @@ export const sendRefreshableCredential = async (
|
|
|
656
698
|
boostId: boost?.id,
|
|
657
699
|
activityId,
|
|
658
700
|
integrationId,
|
|
701
|
+
contractTerms,
|
|
659
702
|
});
|
|
660
|
-
|
|
661
703
|
if (!rootId) {
|
|
662
704
|
throw new TRPCError({
|
|
663
|
-
code: 'CONFLICT',
|
|
664
|
-
message:
|
|
705
|
+
code: contractTerms ? 'FORBIDDEN' : 'CONFLICT',
|
|
706
|
+
message: contractTerms
|
|
707
|
+
? 'User has not yet approved the latest version of this contract'
|
|
708
|
+
: 'Credential refresh initial delivery could not be resumed',
|
|
665
709
|
});
|
|
666
710
|
}
|
|
667
|
-
|
|
668
|
-
if (contractTerms) {
|
|
669
|
-
await ensureCredentialIssuedViaContractRelationship(rootId, contractTerms);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
711
|
const uri = getManagedCredentialUri(rootId, domain);
|
|
673
712
|
const initialNotificationSuppressed = await ensureInitialNotificationPolicy(
|
|
674
713
|
refreshId,
|
|
@@ -681,16 +720,14 @@ export const sendRefreshableCredential = async (
|
|
|
681
720
|
holderProfile,
|
|
682
721
|
initialNotificationSuppressed,
|
|
683
722
|
});
|
|
684
|
-
|
|
685
|
-
|
|
723
|
+
return {
|
|
724
|
+
uri,
|
|
725
|
+
receipt: buildReceipt(),
|
|
726
|
+
statusManagement: getStatusManagement(statusEntries, domain),
|
|
727
|
+
};
|
|
686
728
|
}
|
|
687
729
|
|
|
688
|
-
|
|
689
|
-
// managers' did:keys — see getHolderEncryptionRecipients.
|
|
690
|
-
const jwe = await createDagJweForRecipients(
|
|
691
|
-
credential,
|
|
692
|
-
await getHolderEncryptionRecipients(holderProfile)
|
|
693
|
-
);
|
|
730
|
+
await bindRefreshStatusEntries(refreshId, statusEntries, issuerProfile.profileId, domain);
|
|
694
731
|
|
|
695
732
|
const credentialInstance = await storeCredential(jwe);
|
|
696
733
|
|
|
@@ -700,52 +737,110 @@ export const sendRefreshableCredential = async (
|
|
|
700
737
|
let boundRecordsLength = 0;
|
|
701
738
|
|
|
702
739
|
try {
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
740
|
+
const bindingParams = {
|
|
741
|
+
refreshId,
|
|
742
|
+
rootCredentialNodeId: credentialInstance.id,
|
|
743
|
+
issuerProfileId: issuerProfile.profileId,
|
|
744
|
+
holderProfileId: holderProfile.profileId,
|
|
745
|
+
boostId: boost?.id ?? null,
|
|
746
|
+
activityId: activityId ?? null,
|
|
747
|
+
integrationId: integrationId ?? null,
|
|
748
|
+
versionKey: `${refreshId}:1`,
|
|
749
|
+
now,
|
|
750
|
+
credentialIssuerDid,
|
|
751
|
+
materialDigest: rootMaterialDigest,
|
|
752
|
+
credentialSubjectDigest,
|
|
753
|
+
credentialStatusDigest,
|
|
754
|
+
credentialStatusBinding: JSON.stringify(credentialStatus ?? null),
|
|
755
|
+
refreshServiceBinding: JSON.stringify(refreshService),
|
|
756
|
+
initialNotificationSuppressed: skipNotification,
|
|
757
|
+
};
|
|
758
|
+
const bindQuery = contractTerms
|
|
759
|
+
? new QueryBuilder(
|
|
760
|
+
new BindParam({
|
|
761
|
+
...bindingParams,
|
|
762
|
+
termsId: contractTerms.id,
|
|
763
|
+
expectedVersion: contractTerms.consentedVersion,
|
|
764
|
+
transactionId: randomUUID(),
|
|
765
|
+
})
|
|
766
|
+
).raw(
|
|
767
|
+
`MATCH (terms:ConsentFlowTerms {id:$termsId})-[:CONSENTS_TO]->(contract:ConsentFlowContract)
|
|
768
|
+
SET contract.audienceLock = coalesce(contract.audienceLock, 0) + 1
|
|
769
|
+
WITH terms, contract
|
|
770
|
+
WHERE coalesce(contract.status, 'active') <> 'retired'
|
|
771
|
+
AND contract.currentVersion = $expectedVersion
|
|
772
|
+
AND terms.consentedVersion = $expectedVersion
|
|
773
|
+
AND terms.status = 'live'
|
|
774
|
+
AND (terms.expiresAt IS NULL OR trim(terms.expiresAt) = '' OR datetime(terms.expiresAt) > datetime.realtime())
|
|
775
|
+
AND (contract.expiresAt IS NULL OR trim(contract.expiresAt) = '' OR datetime(contract.expiresAt) > datetime.realtime())
|
|
776
|
+
AND NOT $issuerProfileId IN coalesce(terms['terms.deniedWriters'], [])
|
|
777
|
+
AND EXISTS { MATCH (:Profile {profileId:$holderProfileId})-[:CREATED_BY]->(terms) }
|
|
778
|
+
MATCH (refresh:CredentialRefresh {refreshId: $refreshId})
|
|
779
|
+
WHERE NOT EXISTS { MATCH (refresh)-[:ROOT]->(:Credential) }
|
|
780
|
+
MATCH (root:Credential {id: $rootCredentialNodeId})
|
|
781
|
+
MATCH (issuer:Profile {profileId: $issuerProfileId})
|
|
782
|
+
OPTIONAL MATCH (boost:Boost {id: $boostId})
|
|
783
|
+
WITH terms, refresh, root, issuer, boost
|
|
784
|
+
WHERE boost IS NULL OR boost.category IS NULL OR terms['terms.write.credentials.categories.' + boost.category] = true
|
|
785
|
+
CREATE (refresh)-[:ROOT]->(root)
|
|
786
|
+
CREATE (refresh)-[:HEAD]->(root)
|
|
787
|
+
CREATE (issuer)-[:CREDENTIAL_SENT {to: $holderProfileId, date: $now, activityId: $activityId, integrationId: $integrationId}]->(root)
|
|
788
|
+
FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
|
|
789
|
+
CREATE (root)-[:INSTANCE_OF]->(boost)
|
|
790
|
+
)
|
|
791
|
+
CREATE (transaction:ConsentFlowTransaction {id:$transactionId, action:'write', date:$now, boostId:$boostId})
|
|
792
|
+
-[:${ConsentFlowTransaction.getRelationshipByAlias('isFor').name}]->(terms)
|
|
793
|
+
${termsReferralSnapshotCypher()}
|
|
794
|
+
CREATE (root)-[:${Credential.getRelationshipByAlias('issuedViaTransaction').name} {date:$now}]->(transaction)
|
|
795
|
+
SET root.refreshId = $refreshId,
|
|
796
|
+
root.version = 1,
|
|
797
|
+
root.refreshVersionKey = $versionKey,
|
|
798
|
+
root.publishedAt = $now,
|
|
799
|
+
root.signingMode = 'issuer-signed',
|
|
800
|
+
refresh.issuerDid = $credentialIssuerDid,
|
|
801
|
+
refresh.materialDigest = $materialDigest,
|
|
802
|
+
refresh.rootMaterialDigest = $materialDigest,
|
|
803
|
+
refresh.credentialStatusDigest = $credentialStatusDigest,
|
|
804
|
+
refresh.credentialStatusBinding = $credentialStatusBinding,
|
|
805
|
+
refresh.refreshServiceBinding = $refreshServiceBinding,
|
|
806
|
+
refresh.credentialSubjectDigest = $credentialSubjectDigest,
|
|
807
|
+
refresh.boostId = $boostId,
|
|
808
|
+
refresh.initialNotificationSuppressed = $initialNotificationSuppressed,
|
|
809
|
+
refresh.lastPublishedAt = $now,
|
|
810
|
+
refresh.updatedAt = $now
|
|
811
|
+
RETURN refresh`
|
|
812
|
+
)
|
|
813
|
+
: new QueryBuilder(new BindParam(bindingParams)).raw(
|
|
814
|
+
`MATCH (refresh:CredentialRefresh {refreshId: $refreshId})
|
|
815
|
+
WHERE NOT EXISTS { MATCH (refresh)-[:ROOT]->(:Credential) }
|
|
816
|
+
MATCH (root:Credential {id: $rootCredentialNodeId})
|
|
817
|
+
MATCH (issuer:Profile {profileId: $issuerProfileId})
|
|
818
|
+
OPTIONAL MATCH (boost:Boost {id: $boostId})
|
|
819
|
+
CREATE (refresh)-[:ROOT]->(root)
|
|
820
|
+
CREATE (refresh)-[:HEAD]->(root)
|
|
821
|
+
CREATE (issuer)-[:CREDENTIAL_SENT {to: $holderProfileId, date: $now, activityId: $activityId, integrationId: $integrationId}]->(root)
|
|
822
|
+
FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
|
|
823
|
+
CREATE (root)-[:INSTANCE_OF]->(boost)
|
|
824
|
+
)
|
|
825
|
+
SET root.refreshId = $refreshId,
|
|
826
|
+
root.version = 1,
|
|
827
|
+
root.refreshVersionKey = $versionKey,
|
|
828
|
+
root.publishedAt = $now,
|
|
829
|
+
root.signingMode = 'issuer-signed',
|
|
830
|
+
refresh.issuerDid = $credentialIssuerDid,
|
|
831
|
+
refresh.materialDigest = $materialDigest,
|
|
832
|
+
refresh.rootMaterialDigest = $materialDigest,
|
|
833
|
+
refresh.credentialStatusDigest = $credentialStatusDigest,
|
|
834
|
+
refresh.credentialStatusBinding = $credentialStatusBinding,
|
|
835
|
+
refresh.refreshServiceBinding = $refreshServiceBinding,
|
|
836
|
+
refresh.credentialSubjectDigest = $credentialSubjectDigest,
|
|
837
|
+
refresh.boostId = $boostId,
|
|
838
|
+
refresh.initialNotificationSuppressed = $initialNotificationSuppressed,
|
|
839
|
+
refresh.lastPublishedAt = $now,
|
|
840
|
+
refresh.updatedAt = $now
|
|
841
|
+
RETURN refresh`
|
|
842
|
+
);
|
|
843
|
+
const bound = contractTerms ? await runAudienceMutation(bindQuery) : await bindQuery.run();
|
|
749
844
|
|
|
750
845
|
boundRecordsLength = bound.records.length;
|
|
751
846
|
} catch (error) {
|
|
@@ -769,15 +864,16 @@ export const sendRefreshableCredential = async (
|
|
|
769
864
|
|
|
770
865
|
if (!concurrentlyBoundRoot) {
|
|
771
866
|
throw new TRPCError({
|
|
772
|
-
code: 'CONFLICT',
|
|
773
|
-
message:
|
|
867
|
+
code: contractTerms ? 'FORBIDDEN' : 'CONFLICT',
|
|
868
|
+
message: contractTerms
|
|
869
|
+
? 'User has not yet approved the latest version of this contract'
|
|
870
|
+
: 'Credential refresh was bound concurrently; retry initial delivery',
|
|
774
871
|
});
|
|
775
872
|
}
|
|
776
|
-
|
|
777
873
|
assertInitialCredentialMatches(
|
|
778
874
|
concurrentlyBoundRoot,
|
|
779
|
-
|
|
780
|
-
|
|
875
|
+
matchesMaterialDigest,
|
|
876
|
+
credentialStatus,
|
|
781
877
|
boost?.id
|
|
782
878
|
);
|
|
783
879
|
|
|
@@ -788,19 +884,16 @@ export const sendRefreshableCredential = async (
|
|
|
788
884
|
boostId: boost?.id,
|
|
789
885
|
activityId,
|
|
790
886
|
integrationId,
|
|
887
|
+
contractTerms,
|
|
791
888
|
});
|
|
792
|
-
|
|
793
889
|
if (!rootId) {
|
|
794
890
|
throw new TRPCError({
|
|
795
|
-
code: 'CONFLICT',
|
|
796
|
-
message:
|
|
891
|
+
code: contractTerms ? 'FORBIDDEN' : 'CONFLICT',
|
|
892
|
+
message: contractTerms
|
|
893
|
+
? 'User has not yet approved the latest version of this contract'
|
|
894
|
+
: 'Credential refresh initial delivery could not be resumed',
|
|
797
895
|
});
|
|
798
896
|
}
|
|
799
|
-
|
|
800
|
-
if (contractTerms) {
|
|
801
|
-
await ensureCredentialIssuedViaContractRelationship(rootId, contractTerms);
|
|
802
|
-
}
|
|
803
|
-
|
|
804
897
|
const uri = getManagedCredentialUri(rootId, domain);
|
|
805
898
|
const initialNotificationSuppressed = await ensureInitialNotificationPolicy(
|
|
806
899
|
refreshId,
|
|
@@ -813,13 +906,14 @@ export const sendRefreshableCredential = async (
|
|
|
813
906
|
holderProfile,
|
|
814
907
|
initialNotificationSuppressed,
|
|
815
908
|
});
|
|
816
|
-
|
|
817
|
-
|
|
909
|
+
return {
|
|
910
|
+
uri,
|
|
911
|
+
receipt: buildReceipt(),
|
|
912
|
+
statusManagement: getStatusManagement(statusEntries, domain),
|
|
913
|
+
};
|
|
818
914
|
}
|
|
819
915
|
|
|
820
|
-
|
|
821
|
-
await ensureCredentialIssuedViaContractRelationship(credentialInstance.id, contractTerms);
|
|
822
|
-
}
|
|
916
|
+
// Contract-linked root history was committed atomically with the root binding.
|
|
823
917
|
|
|
824
918
|
const uri = getManagedCredentialUri(credentialInstance.id, domain);
|
|
825
919
|
const initialNotificationSuppressed = await ensureInitialNotificationPolicy(
|
|
@@ -834,49 +928,415 @@ export const sendRefreshableCredential = async (
|
|
|
834
928
|
initialNotificationSuppressed,
|
|
835
929
|
});
|
|
836
930
|
|
|
837
|
-
return {
|
|
931
|
+
return {
|
|
932
|
+
uri,
|
|
933
|
+
receipt: buildReceipt(),
|
|
934
|
+
statusManagement: getStatusManagement(statusEntries, domain),
|
|
935
|
+
};
|
|
838
936
|
};
|
|
839
937
|
|
|
840
|
-
// --- Publication (LC-2135) -----------------------------------------------------
|
|
841
|
-
|
|
842
938
|
/**
|
|
843
|
-
*
|
|
844
|
-
*
|
|
939
|
+
* Binds the signed original credential to a previously allocated refresh aggregate.
|
|
940
|
+
*
|
|
941
|
+
* The plaintext VC is handled only within this request: its proof is verified and
|
|
942
|
+
* its invariants are checked against the allocation, then it is encrypted to the
|
|
943
|
+
* intended holder and only the resulting JWE is persisted via the shared delivery
|
|
944
|
+
* tail (also used by holder-mode sends, which never see plaintext).
|
|
845
945
|
*/
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
946
|
+
export const sendRefreshableCredential = async (
|
|
947
|
+
params: SendRefreshableCredentialParams
|
|
948
|
+
): Promise<SendRefreshableCredentialResult> => {
|
|
949
|
+
const {
|
|
950
|
+
issuerProfile,
|
|
951
|
+
refreshId,
|
|
952
|
+
credential,
|
|
953
|
+
boostUri,
|
|
954
|
+
skipNotification = false,
|
|
955
|
+
activityId,
|
|
956
|
+
integrationId,
|
|
957
|
+
contractTerms,
|
|
958
|
+
domain,
|
|
959
|
+
} = params;
|
|
857
960
|
|
|
858
|
-
const
|
|
859
|
-
version: CredentialRefreshVersionNode;
|
|
860
|
-
issuerProfile: ProfileType;
|
|
861
|
-
holderProfile: ProfileType;
|
|
862
|
-
branding?: Partial<TenantBranding>;
|
|
863
|
-
}): Promise<PublishCredentialRefreshNotification> => {
|
|
864
|
-
const { version, issuerProfile, holderProfile, branding } = params;
|
|
961
|
+
const aggregate = await getCredentialRefresh(refreshId);
|
|
865
962
|
|
|
866
|
-
|
|
867
|
-
|
|
963
|
+
if (!aggregate) {
|
|
964
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Credential refresh not found' });
|
|
965
|
+
}
|
|
868
966
|
|
|
869
|
-
if (
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
refreshId: version.refreshId,
|
|
874
|
-
version: version.version,
|
|
875
|
-
notificationId: version.notificationId,
|
|
876
|
-
deliveryKey: version.notificationDeliveryKey,
|
|
877
|
-
notifiedAt: version.notificationCreatedAt,
|
|
967
|
+
if (aggregate.issuerProfileId !== issuerProfile.profileId) {
|
|
968
|
+
throw new TRPCError({
|
|
969
|
+
code: 'UNAUTHORIZED',
|
|
970
|
+
message: 'Profile did not allocate this credential refresh',
|
|
878
971
|
});
|
|
879
|
-
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// A holder-mode aggregate (LC-2345) never accepts a plaintext credential: it was
|
|
975
|
+
// prepared for an encrypted handoff and its recipient set was published instead
|
|
976
|
+
// of being used here to encrypt.
|
|
977
|
+
if (aggregate.encryptionMode === 'holder') {
|
|
978
|
+
throw new TRPCError({
|
|
979
|
+
code: 'CONFLICT',
|
|
980
|
+
message:
|
|
981
|
+
'This credential refresh uses holder-mode encryption; send the encrypted handoff via credentialRefresh.sendHolderEncryptedRefreshable instead.',
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
if (
|
|
986
|
+
!aggregate.holderDid ||
|
|
987
|
+
aggregate.state === 'pending_holder' ||
|
|
988
|
+
aggregate.inboxCredentialId
|
|
989
|
+
) {
|
|
990
|
+
throw new TRPCError({
|
|
991
|
+
code: 'CONFLICT',
|
|
992
|
+
message: 'Inbox refresh must be bound through its verified claim flow',
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (credential.id !== aggregate.credentialId) {
|
|
997
|
+
throw new TRPCError({
|
|
998
|
+
code: 'BAD_REQUEST',
|
|
999
|
+
message: 'Credential ID does not match the allocated refresh',
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const credentialIssuerDid = getCredentialIssuerId(credential);
|
|
1004
|
+
const publicIssuerDid = getDidWeb(domain, issuerProfile.profileId);
|
|
1005
|
+
|
|
1006
|
+
// A profile controls both its internal controller did:key and its public network
|
|
1007
|
+
// did:web. The network plugin normally issues with did:web, while lower-level
|
|
1008
|
+
// callers may legitimately issue with the controller DID. Bind the aggregate to
|
|
1009
|
+
// whichever controlled identity appears on version 1; later versions must match
|
|
1010
|
+
// that exact normalized issuer.
|
|
1011
|
+
if (
|
|
1012
|
+
!credentialIssuerDid ||
|
|
1013
|
+
!isControlledIssuerDid(credentialIssuerDid, issuerProfile, domain)
|
|
1014
|
+
) {
|
|
1015
|
+
throw new TRPCError({
|
|
1016
|
+
code: 'BAD_REQUEST',
|
|
1017
|
+
message: 'Credential issuer does not match the allocated refresh',
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (
|
|
1022
|
+
!aggregate.holderDid ||
|
|
1023
|
+
!getCredentialSubjectIds(credential).includes(aggregate.holderDid)
|
|
1024
|
+
) {
|
|
1025
|
+
throw new TRPCError({
|
|
1026
|
+
code: 'BAD_REQUEST',
|
|
1027
|
+
message: 'Credential subject does not match the intended holder',
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (!hasAllocatedRefreshService(credential, refreshId, domain)) {
|
|
1032
|
+
throw new TRPCError({
|
|
1033
|
+
code: 'BAD_REQUEST',
|
|
1034
|
+
message: 'Credential does not contain the allocated refresh service',
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const holderProfile = aggregate.holderProfileId
|
|
1039
|
+
? await getProfileByProfileId(aggregate.holderProfileId)
|
|
1040
|
+
: null;
|
|
1041
|
+
|
|
1042
|
+
if (!holderProfile) {
|
|
1043
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Recipient profile not found' });
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
await verifyManagedRefreshProof((await getLearnCard()).invoke, credential, publicIssuerDid);
|
|
1047
|
+
|
|
1048
|
+
const rootMaterialDigest = computeCredentialMaterialDigest(
|
|
1049
|
+
credential as unknown as Record<string, unknown>
|
|
1050
|
+
);
|
|
1051
|
+
const credentialStatusDigest = computeCredentialStatusDigest(
|
|
1052
|
+
(credential as unknown as Record<string, unknown>).credentialStatus
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
// Holder-side encryption only (never the brain DID): the holder's did:key plus its
|
|
1056
|
+
// managers' did:keys — see getHolderEncryptionRecipients.
|
|
1057
|
+
const jwe = await createDagJweForRecipients(
|
|
1058
|
+
credential,
|
|
1059
|
+
await getHolderEncryptionRecipients(holderProfile)
|
|
1060
|
+
);
|
|
1061
|
+
|
|
1062
|
+
return deliverManagedRefreshVersion1Credential({
|
|
1063
|
+
refreshId,
|
|
1064
|
+
issuerProfile,
|
|
1065
|
+
holderProfile,
|
|
1066
|
+
aggregate,
|
|
1067
|
+
credentialIssuerDid,
|
|
1068
|
+
statusEntries: getBitstringStatusListEntries(credential),
|
|
1069
|
+
credentialStatus: credential.credentialStatus,
|
|
1070
|
+
refreshService: ManagedCredentialRefreshBindingValidator.parse(credential.refreshService),
|
|
1071
|
+
jwe,
|
|
1072
|
+
materialDigest: rootMaterialDigest,
|
|
1073
|
+
matchesMaterialDigest: stored =>
|
|
1074
|
+
matchesCredentialMaterialDigest(
|
|
1075
|
+
credential as unknown as Record<string, unknown>,
|
|
1076
|
+
stored
|
|
1077
|
+
),
|
|
1078
|
+
credentialStatusDigest,
|
|
1079
|
+
credentialSubjectDigest: computeCredentialSubjectDigest(credential.credentialSubject),
|
|
1080
|
+
boostUri,
|
|
1081
|
+
skipNotification,
|
|
1082
|
+
activityId,
|
|
1083
|
+
integrationId,
|
|
1084
|
+
contractTerms,
|
|
1085
|
+
domain,
|
|
1086
|
+
});
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
export type SendHolderEncryptedRefreshableCredentialParams = {
|
|
1090
|
+
issuerProfile: ProfileType;
|
|
1091
|
+
handoff: HolderEncryptedHandoff;
|
|
1092
|
+
idempotencyKey?: string;
|
|
1093
|
+
boostUri?: string;
|
|
1094
|
+
skipNotification?: boolean;
|
|
1095
|
+
domain: string;
|
|
1096
|
+
onBehalfOf?: string;
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Holder-mode counterpart of {@link sendRefreshableCredential} (LC-2345): the
|
|
1101
|
+
* credential never reaches this process in plaintext. Every invariant that would
|
|
1102
|
+
* normally be read off the signed VC is instead checked against the public fields of
|
|
1103
|
+
* the handoff and the allocation; the JWE's recipient `kid`s must exactly equal the
|
|
1104
|
+
* holder's current encryption recipients (see `getHolderEncryptionRecipientKeyIds`).
|
|
1105
|
+
* Proof and refreshService verification are the receiving device's responsibility
|
|
1106
|
+
* (`ingestManagedCredential`) — they are exactly what the server cannot check here.
|
|
1107
|
+
*/
|
|
1108
|
+
export const sendHolderEncryptedRefreshableCredential = async (
|
|
1109
|
+
params: SendHolderEncryptedRefreshableCredentialParams
|
|
1110
|
+
): Promise<SendRefreshableCredentialResult & { activityId: string }> => {
|
|
1111
|
+
const { issuerProfile, handoff, skipNotification = false, domain } = params;
|
|
1112
|
+
|
|
1113
|
+
const aggregate = await getCredentialRefresh(handoff.refreshId);
|
|
1114
|
+
|
|
1115
|
+
if (!aggregate) {
|
|
1116
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Credential refresh not found' });
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (aggregate.issuerProfileId !== issuerProfile.profileId) {
|
|
1120
|
+
throw new TRPCError({
|
|
1121
|
+
code: 'UNAUTHORIZED',
|
|
1122
|
+
message: 'Profile did not allocate this credential refresh',
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (aggregate.encryptionMode !== 'holder') {
|
|
1127
|
+
throw new TRPCError({
|
|
1128
|
+
code: 'CONFLICT',
|
|
1129
|
+
message:
|
|
1130
|
+
'This credential refresh was not prepared for holder-mode encryption; use credentialRefresh.sendRefreshableCredential instead.',
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (
|
|
1135
|
+
!aggregate.holderDid ||
|
|
1136
|
+
aggregate.state === 'pending_holder' ||
|
|
1137
|
+
aggregate.inboxCredentialId
|
|
1138
|
+
) {
|
|
1139
|
+
throw new TRPCError({
|
|
1140
|
+
code: 'CONFLICT',
|
|
1141
|
+
message: 'Inbox refresh must be bound through its verified claim flow',
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
if (handoff.credentialId !== aggregate.credentialId) {
|
|
1146
|
+
throw new TRPCError({
|
|
1147
|
+
code: 'BAD_REQUEST',
|
|
1148
|
+
message: 'Credential ID does not match the allocated refresh',
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if (handoff.holderDid !== aggregate.holderDid) {
|
|
1153
|
+
throw new TRPCError({
|
|
1154
|
+
code: 'BAD_REQUEST',
|
|
1155
|
+
message: 'Credential subject does not match the intended holder',
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
if (!isControlledIssuerDid(handoff.issuerDid, issuerProfile, domain)) {
|
|
1160
|
+
throw new TRPCError({
|
|
1161
|
+
code: 'BAD_REQUEST',
|
|
1162
|
+
message: 'Credential issuer does not match the allocated refresh',
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const intent = params.idempotencyKey
|
|
1167
|
+
? await getRefreshSendIntent(issuerProfile.profileId, params.idempotencyKey)
|
|
1168
|
+
: await getRefreshSendIntentForRefresh(issuerProfile.profileId, handoff.refreshId);
|
|
1169
|
+
if (
|
|
1170
|
+
!intent ||
|
|
1171
|
+
intent.state === 'preparing' ||
|
|
1172
|
+
intent.refreshId !== handoff.refreshId ||
|
|
1173
|
+
intent.holderDid !== handoff.holderDid ||
|
|
1174
|
+
intent.credentialId !== handoff.credentialId ||
|
|
1175
|
+
!intent.boostUri ||
|
|
1176
|
+
(params.boostUri !== undefined && params.boostUri !== intent.boostUri)
|
|
1177
|
+
) {
|
|
1178
|
+
throw new TRPCError({
|
|
1179
|
+
code: 'CONFLICT',
|
|
1180
|
+
message: 'This request does not match the prepared refresh send.',
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
const boostUri = intent.boostUri;
|
|
1184
|
+
const completed = await reconcileBoundRefreshSendIntent(intent, domain);
|
|
1185
|
+
if (completed?.type === 'boost' && completed.refresh && completed.credentialUri) {
|
|
1186
|
+
return {
|
|
1187
|
+
uri: completed.credentialUri,
|
|
1188
|
+
receipt: completed.refresh,
|
|
1189
|
+
activityId: completed.activityId,
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const refreshService = handoff.refreshService ?? {
|
|
1194
|
+
id: getCredentialRefreshServiceUrl(handoff.refreshId, domain),
|
|
1195
|
+
type: 'LearnCardCredentialRefresh2026' as const,
|
|
1196
|
+
authorization: { type: 'LearnCardDIDAuth' as const },
|
|
1197
|
+
};
|
|
1198
|
+
const managedServices = getManagedRefreshServices({ refreshService });
|
|
1199
|
+
if (
|
|
1200
|
+
managedServices.length !== 1 ||
|
|
1201
|
+
managedServices[0]!.id !== getCredentialRefreshServiceUrl(handoff.refreshId, domain)
|
|
1202
|
+
) {
|
|
1203
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid refresh service binding' });
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const holderProfile = aggregate.holderProfileId
|
|
1207
|
+
? await getProfileByProfileId(aggregate.holderProfileId)
|
|
1208
|
+
: null;
|
|
1209
|
+
|
|
1210
|
+
if (!holderProfile) {
|
|
1211
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Recipient profile not found' });
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
try {
|
|
1215
|
+
assertExactJweRecipients(
|
|
1216
|
+
handoff.jwe,
|
|
1217
|
+
await getHolderEncryptionRecipientKeyIds(holderProfile)
|
|
1218
|
+
);
|
|
1219
|
+
} catch {
|
|
1220
|
+
// A stale prepared set (a manager was added or removed since prepare) and a
|
|
1221
|
+
// structurally wrong recipient set are indistinguishable to the server; both
|
|
1222
|
+
// require the caller to refetch via `credentialRefresh.getEncryptionRecipients`.
|
|
1223
|
+
throw new TRPCError({ code: 'CONFLICT', message: 'RECIPIENTS_CHANGED' });
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const boost = await getBoostByUri(boostUri);
|
|
1227
|
+
if (!boost) throw new TRPCError({ code: 'NOT_FOUND', message: 'Boost not found' });
|
|
1228
|
+
const contractTerms = await resolveContractForSend({
|
|
1229
|
+
profile: issuerProfile,
|
|
1230
|
+
targetProfile: holderProfile,
|
|
1231
|
+
boost,
|
|
1232
|
+
boostCreated: false,
|
|
1233
|
+
contractUri: intent.contractUri,
|
|
1234
|
+
});
|
|
1235
|
+
if (intent.contractUri && !contractTerms) {
|
|
1236
|
+
throw new TRPCError({
|
|
1237
|
+
code: 'FORBIDDEN',
|
|
1238
|
+
message: 'User has not yet approved the latest version of this contract',
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const activityId =
|
|
1243
|
+
intent.pendingActivityId ??
|
|
1244
|
+
(await logCredentialSent({
|
|
1245
|
+
actorProfileId: issuerProfile.profileId,
|
|
1246
|
+
onBehalfOf: params.onBehalfOf,
|
|
1247
|
+
recipientType: 'profile',
|
|
1248
|
+
recipientIdentifier: holderProfile.profileId,
|
|
1249
|
+
recipientProfileId: holderProfile.profileId,
|
|
1250
|
+
boostUri,
|
|
1251
|
+
source: 'send',
|
|
1252
|
+
integrationId: intent.integrationId,
|
|
1253
|
+
}));
|
|
1254
|
+
|
|
1255
|
+
await recordRefreshSendIntentPending(
|
|
1256
|
+
intent,
|
|
1257
|
+
{
|
|
1258
|
+
refreshId: handoff.refreshId,
|
|
1259
|
+
credentialId: handoff.credentialId,
|
|
1260
|
+
issuerDid: handoff.issuerDid,
|
|
1261
|
+
holderDid: handoff.holderDid,
|
|
1262
|
+
credentialStatus: handoff.credentialStatus,
|
|
1263
|
+
refreshService,
|
|
1264
|
+
},
|
|
1265
|
+
activityId
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
const delivered = await deliverManagedRefreshVersion1Credential({
|
|
1269
|
+
activityId,
|
|
1270
|
+
refreshId: handoff.refreshId,
|
|
1271
|
+
issuerProfile,
|
|
1272
|
+
holderProfile,
|
|
1273
|
+
aggregate,
|
|
1274
|
+
credentialIssuerDid: handoff.issuerDid,
|
|
1275
|
+
statusEntries: getBitstringStatusListEntries({
|
|
1276
|
+
credentialStatus: handoff.credentialStatus,
|
|
1277
|
+
}),
|
|
1278
|
+
credentialStatus: handoff.credentialStatus,
|
|
1279
|
+
jwe: handoff.jwe,
|
|
1280
|
+
refreshService,
|
|
1281
|
+
materialDigest: intent.requestDigest,
|
|
1282
|
+
credentialStatusDigest: computeCredentialStatusDigest(handoff.credentialStatus),
|
|
1283
|
+
credentialSubjectDigest: null,
|
|
1284
|
+
boostUri,
|
|
1285
|
+
skipNotification,
|
|
1286
|
+
integrationId: intent.integrationId,
|
|
1287
|
+
contractTerms: contractTerms ?? undefined,
|
|
1288
|
+
domain,
|
|
1289
|
+
});
|
|
1290
|
+
await markRefreshSendIntentDelivered(intent, {
|
|
1291
|
+
type: 'boost',
|
|
1292
|
+
uri: boostUri,
|
|
1293
|
+
credentialUri: delivered.uri,
|
|
1294
|
+
activityId,
|
|
1295
|
+
refresh: delivered.receipt,
|
|
1296
|
+
});
|
|
1297
|
+
return { ...delivered, activityId };
|
|
1298
|
+
};
|
|
1299
|
+
|
|
1300
|
+
// --- Publication (LC-2135) -----------------------------------------------------
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Route-level refinement of the Task 1 contract's permissive signing-authority
|
|
1304
|
+
* descriptor: resolving ownership requires the registered name + endpoint.
|
|
1305
|
+
*/
|
|
1306
|
+
const SigningAuthorityReferenceValidator = z
|
|
1307
|
+
.object({
|
|
1308
|
+
type: z.string().min(1),
|
|
1309
|
+
name: z.string().min(1),
|
|
1310
|
+
endpoint: z.string().min(1),
|
|
1311
|
+
})
|
|
1312
|
+
.catchall(z.any());
|
|
1313
|
+
|
|
1314
|
+
/** Opaque ETag derived from the stored encrypted bytes (never from plaintext). */
|
|
1315
|
+
export const computeRefreshEtag = (encryptedCredential: string): string =>
|
|
1316
|
+
createHash('sha256').update(encryptedCredential).digest('base64url');
|
|
1317
|
+
|
|
1318
|
+
const deliverCredentialRefreshNotification = async (params: {
|
|
1319
|
+
version: CredentialRefreshVersionNode;
|
|
1320
|
+
issuerProfile: ProfileType;
|
|
1321
|
+
holderProfile: ProfileType;
|
|
1322
|
+
branding?: Partial<TenantBranding>;
|
|
1323
|
+
}): Promise<PublishCredentialRefreshNotification> => {
|
|
1324
|
+
const { version, issuerProfile, holderProfile, branding } = params;
|
|
1325
|
+
|
|
1326
|
+
let inAppOutcome: PublishCredentialRefreshNotification = 'queued';
|
|
1327
|
+
let deliveryWindowKey = version.notificationDeliveryKey;
|
|
1328
|
+
|
|
1329
|
+
if (!version.notificationDeliveredAt) {
|
|
1330
|
+
const event = buildCredentialRefreshedNotification({
|
|
1331
|
+
holderProfile,
|
|
1332
|
+
issuerProfile,
|
|
1333
|
+
refreshId: version.refreshId,
|
|
1334
|
+
version: version.version,
|
|
1335
|
+
notificationId: version.notificationId,
|
|
1336
|
+
deliveryKey: version.notificationDeliveryKey,
|
|
1337
|
+
notifiedAt: version.notificationCreatedAt,
|
|
1338
|
+
});
|
|
1339
|
+
deliveryWindowKey = event.deliveryKey;
|
|
880
1340
|
|
|
881
1341
|
try {
|
|
882
1342
|
await addNotificationToQueue(event.notification);
|
|
@@ -980,11 +1440,11 @@ export const deliverPendingCredentialRefreshNotificationForAcceptedCredential =
|
|
|
980
1440
|
* (signing-authority mode). Error messages are deliberately generic — credential
|
|
981
1441
|
* content must never appear in exception messages, tracing attributes, or logs.
|
|
982
1442
|
*/
|
|
983
|
-
const assertRefreshVersionInvariants = (
|
|
1443
|
+
const assertRefreshVersionInvariants = async (
|
|
984
1444
|
credential: VC,
|
|
985
1445
|
aggregate: CredentialRefreshRecord,
|
|
986
1446
|
domain: string
|
|
987
|
-
): void => {
|
|
1447
|
+
): Promise<void> => {
|
|
988
1448
|
if (credential.id !== aggregate.credentialId) {
|
|
989
1449
|
throw new TRPCError({
|
|
990
1450
|
code: 'BAD_REQUEST',
|
|
@@ -1011,8 +1471,10 @@ const assertRefreshVersionInvariants = (
|
|
|
1011
1471
|
|
|
1012
1472
|
if (
|
|
1013
1473
|
!aggregate.credentialSubjectDigest ||
|
|
1014
|
-
|
|
1474
|
+
!matchesCredentialSubjectDigest(
|
|
1475
|
+
credential.credentialSubject,
|
|
1015
1476
|
aggregate.credentialSubjectDigest
|
|
1477
|
+
)
|
|
1016
1478
|
) {
|
|
1017
1479
|
throw new TRPCError({
|
|
1018
1480
|
code: 'BAD_REQUEST',
|
|
@@ -1028,51 +1490,188 @@ const assertRefreshVersionInvariants = (
|
|
|
1028
1490
|
});
|
|
1029
1491
|
}
|
|
1030
1492
|
|
|
1493
|
+
if (
|
|
1494
|
+
aggregate.refreshServiceBinding &&
|
|
1495
|
+
canonicalizeCredentialJson(credential.refreshService) !==
|
|
1496
|
+
canonicalizeCredentialJson(JSON.parse(aggregate.refreshServiceBinding))
|
|
1497
|
+
) {
|
|
1498
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid refresh service binding' });
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1031
1501
|
if (
|
|
1032
1502
|
!aggregate.credentialStatusDigest ||
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1503
|
+
!matchesCredentialStatusDigest(
|
|
1504
|
+
credential.credentialStatus,
|
|
1505
|
+
aggregate.credentialStatusDigest
|
|
1506
|
+
)
|
|
1036
1507
|
) {
|
|
1037
1508
|
throw new TRPCError({
|
|
1038
1509
|
code: 'BAD_REQUEST',
|
|
1039
1510
|
message: 'Credential status does not match the original credential',
|
|
1040
1511
|
});
|
|
1041
1512
|
}
|
|
1042
|
-
|
|
1513
|
+
await bindRefreshStatusEntries(
|
|
1514
|
+
aggregate.refreshId,
|
|
1515
|
+
getBitstringStatusListEntries(credential),
|
|
1516
|
+
aggregate.issuerProfileId,
|
|
1517
|
+
domain
|
|
1518
|
+
);
|
|
1519
|
+
};
|
|
1520
|
+
|
|
1521
|
+
export type PublishCredentialRefreshParams = {
|
|
1522
|
+
issuerProfile: ProfileType;
|
|
1523
|
+
input: PublishCredentialRefreshInput;
|
|
1524
|
+
domain: string;
|
|
1525
|
+
/** Optional tenant branding for the update email; defaults to LearnCard. */
|
|
1526
|
+
branding?: Partial<TenantBranding>;
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Publishes a new immutable version of a managed refreshable credential and
|
|
1531
|
+
* atomically advances the aggregate head.
|
|
1532
|
+
*
|
|
1533
|
+
* Plaintext exists only transiently inside this request: invariants and proofs are
|
|
1534
|
+
* checked in memory, a server-keyed HMAC over the canonical user-visible projection
|
|
1535
|
+
* is computed for materiality, and only the holder-encrypted JWE is persisted.
|
|
1536
|
+
* The version chain is advanced through the single-writer compare-and-advance in
|
|
1537
|
+
* the access layer; losers of a concurrent race receive CONFLICT and may retry.
|
|
1538
|
+
*/
|
|
1539
|
+
/**
|
|
1540
|
+
* Shared publish tail, common to network mode ({@link publishCredentialRefresh}) and
|
|
1541
|
+
* holder mode ({@link publishHolderEncryptedRefreshableCredential}): advances the
|
|
1542
|
+
* aggregate head through the single-writer compare-and-advance, resolves
|
|
1543
|
+
* conflict/replay, and delivers the notification decision the caller already made
|
|
1544
|
+
* (server-inferred materiality for network mode, the issuer's explicit `notify` for
|
|
1545
|
+
* holder mode, which cannot compare encrypted content across versions).
|
|
1546
|
+
*/
|
|
1547
|
+
const advanceManagedRefreshVersion = async (params: {
|
|
1548
|
+
refreshId: string;
|
|
1549
|
+
issuerProfile: ProfileType;
|
|
1550
|
+
aggregate: CredentialRefreshRecord;
|
|
1551
|
+
encryptedCredential: string;
|
|
1552
|
+
signingMode: CredentialRefreshSigningMode;
|
|
1553
|
+
idempotencyKey?: string;
|
|
1554
|
+
etag: string;
|
|
1555
|
+
materialDigest?: string;
|
|
1556
|
+
updateSummary?: string;
|
|
1557
|
+
updateReason?: CredentialRefreshUpdateReason;
|
|
1558
|
+
credentialDisplayName?: string;
|
|
1559
|
+
effectiveAt?: string;
|
|
1560
|
+
notification: PublishCredentialRefreshNotification;
|
|
1561
|
+
notificationPendingAfterClaim: boolean;
|
|
1562
|
+
holderProfile: ProfileType | null;
|
|
1563
|
+
branding?: Partial<TenantBranding>;
|
|
1564
|
+
}): Promise<PublishCredentialRefreshResult> => {
|
|
1565
|
+
const {
|
|
1566
|
+
refreshId,
|
|
1567
|
+
issuerProfile,
|
|
1568
|
+
aggregate,
|
|
1569
|
+
encryptedCredential,
|
|
1570
|
+
signingMode,
|
|
1571
|
+
idempotencyKey,
|
|
1572
|
+
etag,
|
|
1573
|
+
materialDigest,
|
|
1574
|
+
updateSummary,
|
|
1575
|
+
updateReason,
|
|
1576
|
+
credentialDisplayName,
|
|
1577
|
+
effectiveAt,
|
|
1578
|
+
notification,
|
|
1579
|
+
notificationPendingAfterClaim,
|
|
1580
|
+
holderProfile,
|
|
1581
|
+
branding,
|
|
1582
|
+
} = params;
|
|
1583
|
+
|
|
1584
|
+
const notificationEvent = holderProfile
|
|
1585
|
+
? buildCredentialRefreshedNotification({
|
|
1586
|
+
holderProfile,
|
|
1587
|
+
issuerProfile,
|
|
1588
|
+
refreshId,
|
|
1589
|
+
version: aggregate.currentVersion + 1,
|
|
1590
|
+
})
|
|
1591
|
+
: undefined;
|
|
1592
|
+
|
|
1593
|
+
const advance = await advanceCredentialRefreshHead({
|
|
1594
|
+
refreshId,
|
|
1595
|
+
expectedVersion: aggregate.currentVersion,
|
|
1596
|
+
expectedState: aggregate.state,
|
|
1597
|
+
encryptedCredential,
|
|
1598
|
+
signingMode,
|
|
1599
|
+
idempotencyKey,
|
|
1600
|
+
etag,
|
|
1601
|
+
materialDigest,
|
|
1602
|
+
updateSummary,
|
|
1603
|
+
updateReason,
|
|
1604
|
+
credentialDisplayName,
|
|
1605
|
+
effectiveAt,
|
|
1606
|
+
notificationOutcome: notification,
|
|
1607
|
+
notificationId: notificationEvent?.notificationId,
|
|
1608
|
+
notificationDeliveryKey: notificationEvent?.deliveryKey,
|
|
1609
|
+
notificationCreatedAt: notificationEvent?.notifiedAt,
|
|
1610
|
+
notificationPendingAfterClaim,
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
if (advance.status === 'conflict') {
|
|
1614
|
+
// A concurrent publication advanced the head first; nothing was written.
|
|
1615
|
+
throw new TRPCError({
|
|
1616
|
+
code: 'CONFLICT',
|
|
1617
|
+
message: 'Credential refresh was updated concurrently; retry the publication',
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
if (advance.status === 'replay') {
|
|
1622
|
+
const replayed = await getCredentialRefreshVersion(refreshId, advance.version);
|
|
1623
|
+
|
|
1624
|
+
if (!replayed) {
|
|
1625
|
+
throw new TRPCError({
|
|
1626
|
+
code: 'CONFLICT',
|
|
1627
|
+
message: 'Credential refresh replay could not be resolved; retry the publication',
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
return {
|
|
1632
|
+
refreshId,
|
|
1633
|
+
version: replayed.version,
|
|
1634
|
+
publishedAt: replayed.publishedAt,
|
|
1635
|
+
notification: await resolveCredentialRefreshReplayNotification({
|
|
1636
|
+
version: replayed,
|
|
1637
|
+
aggregate,
|
|
1638
|
+
issuerProfile,
|
|
1639
|
+
branding,
|
|
1640
|
+
}),
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
let deliveredNotification = notification;
|
|
1645
|
+
|
|
1646
|
+
if (notification === 'queued') {
|
|
1647
|
+
const persistedVersion = await getCredentialRefreshVersion(refreshId, advance.version);
|
|
1648
|
+
|
|
1649
|
+
deliveredNotification =
|
|
1650
|
+
persistedVersion && holderProfile
|
|
1651
|
+
? await deliverCredentialRefreshNotification({
|
|
1652
|
+
version: persistedVersion,
|
|
1653
|
+
issuerProfile,
|
|
1654
|
+
holderProfile,
|
|
1655
|
+
branding,
|
|
1656
|
+
})
|
|
1657
|
+
: 'delivery-failed';
|
|
1658
|
+
}
|
|
1043
1659
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1660
|
+
return {
|
|
1661
|
+
refreshId,
|
|
1662
|
+
version: advance.version,
|
|
1663
|
+
publishedAt: advance.publishedAt ?? new Date().toISOString(),
|
|
1664
|
+
notification: deliveredNotification,
|
|
1665
|
+
};
|
|
1050
1666
|
};
|
|
1051
1667
|
|
|
1052
|
-
/**
|
|
1053
|
-
* Publishes a new immutable version of a managed refreshable credential and
|
|
1054
|
-
* atomically advances the aggregate head.
|
|
1055
|
-
*
|
|
1056
|
-
* Plaintext exists only transiently inside this request: invariants and proofs are
|
|
1057
|
-
* checked in memory, a server-keyed HMAC over the canonical user-visible projection
|
|
1058
|
-
* is computed for materiality, and only the holder-encrypted JWE is persisted.
|
|
1059
|
-
* The version chain is advanced through the single-writer compare-and-advance in
|
|
1060
|
-
* the access layer; losers of a concurrent race receive CONFLICT and may retry.
|
|
1061
|
-
*/
|
|
1062
1668
|
export const publishCredentialRefresh = async (
|
|
1063
1669
|
params: PublishCredentialRefreshParams
|
|
1064
1670
|
): Promise<PublishCredentialRefreshResult> => {
|
|
1065
1671
|
const { issuerProfile, input, domain } = params;
|
|
1066
|
-
const { refreshId, idempotencyKey, notifyHolder, updateSummary } = input;
|
|
1672
|
+
const { refreshId, idempotencyKey, notifyHolder, updateSummary, updateReason } = input;
|
|
1067
1673
|
const branding = params.branding;
|
|
1068
1674
|
|
|
1069
|
-
// Email-only bounded display title. Read from the issuer-supplied publication
|
|
1070
|
-
// input (never from the holder-encrypted payload); `undefined` falls back to
|
|
1071
|
-
// the original boost template at delivery time, then to generic copy.
|
|
1072
|
-
const credentialDisplayName = extractBoundedCredentialDisplayTitle(
|
|
1073
|
-
input.mode === 'signing-authority' ? input.credential.name : input.signedCredential.name
|
|
1074
|
-
);
|
|
1075
|
-
|
|
1076
1675
|
const aggregate = await getCredentialRefresh(refreshId);
|
|
1077
1676
|
|
|
1078
1677
|
if (!aggregate) {
|
|
@@ -1086,6 +1685,21 @@ export const publishCredentialRefresh = async (
|
|
|
1086
1685
|
});
|
|
1087
1686
|
}
|
|
1088
1687
|
|
|
1688
|
+
// A holder-mode aggregate never accepts a plaintext or signing-authority
|
|
1689
|
+
// publication (LC-2345): both would require the server to process plaintext.
|
|
1690
|
+
if (aggregate.encryptionMode === 'holder') {
|
|
1691
|
+
throw new TRPCError({
|
|
1692
|
+
code: 'CONFLICT',
|
|
1693
|
+
message:
|
|
1694
|
+
'This credential refresh uses holder-mode encryption; publish the encrypted handoff via credentialRefresh.publishHolderEncryptedRefreshable instead.',
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
const displayBoost = aggregate.boostId ? await getBoostById(aggregate.boostId) : null;
|
|
1699
|
+
const credentialDisplayName = extractBoundedCredentialDisplayTitle(
|
|
1700
|
+
getPublicBoostDisplayName(displayBoost)
|
|
1701
|
+
);
|
|
1702
|
+
|
|
1089
1703
|
if (aggregate.state === 'revoked') {
|
|
1090
1704
|
throw new TRPCError({
|
|
1091
1705
|
code: 'CONFLICT',
|
|
@@ -1152,7 +1766,7 @@ export const publishCredentialRefresh = async (
|
|
|
1152
1766
|
signedCredential = input.signedCredential;
|
|
1153
1767
|
signingMode = 'issuer-signed';
|
|
1154
1768
|
|
|
1155
|
-
assertRefreshVersionInvariants(signedCredential, aggregate, domain);
|
|
1769
|
+
await assertRefreshVersionInvariants(signedCredential, aggregate, domain);
|
|
1156
1770
|
await verifyManagedRefreshProof(
|
|
1157
1771
|
(await getLearnCard()).invoke,
|
|
1158
1772
|
signedCredential,
|
|
@@ -1184,7 +1798,7 @@ export const publishCredentialRefresh = async (
|
|
|
1184
1798
|
// Enforce the full invariant set on the unsigned body before proof creation.
|
|
1185
1799
|
// The signing authority is the issuer's own registered delegate and signs the
|
|
1186
1800
|
// body as supplied, so the completed credential is rechecked in full plus proof.
|
|
1187
|
-
assertRefreshVersionInvariants(input.credential as VC, aggregate, domain);
|
|
1801
|
+
await assertRefreshVersionInvariants(input.credential as VC, aggregate, domain);
|
|
1188
1802
|
|
|
1189
1803
|
// Managed refresh services require their inline JSON-LD context terms to be
|
|
1190
1804
|
// defined or signing fails (LC-2198): prepare the context exactly as the SDK
|
|
@@ -1207,7 +1821,7 @@ export const publishCredentialRefresh = async (
|
|
|
1207
1821
|
).credential as VC;
|
|
1208
1822
|
signingMode = 'signing-authority';
|
|
1209
1823
|
|
|
1210
|
-
assertRefreshVersionInvariants(signedCredential, aggregate, domain);
|
|
1824
|
+
await assertRefreshVersionInvariants(signedCredential, aggregate, domain);
|
|
1211
1825
|
await verifyManagedRefreshProof(
|
|
1212
1826
|
(await getLearnCard()).invoke,
|
|
1213
1827
|
signedCredential,
|
|
@@ -1240,7 +1854,12 @@ export const publishCredentialRefresh = async (
|
|
|
1240
1854
|
const activeNotification = decideCredentialRefreshNotification({
|
|
1241
1855
|
state: 'active',
|
|
1242
1856
|
notifyHolder,
|
|
1243
|
-
previousDigest:
|
|
1857
|
+
previousDigest: matchesCredentialMaterialDigest(
|
|
1858
|
+
signedCredential as unknown as Record<string, unknown>,
|
|
1859
|
+
aggregate.materialDigest
|
|
1860
|
+
)
|
|
1861
|
+
? nextDigest
|
|
1862
|
+
: aggregate.materialDigest,
|
|
1244
1863
|
nextDigest,
|
|
1245
1864
|
});
|
|
1246
1865
|
const notification: PublishCredentialRefreshNotification =
|
|
@@ -1255,14 +1874,6 @@ export const publishCredentialRefresh = async (
|
|
|
1255
1874
|
? await getProfileByProfileId(aggregate.holderProfileId)
|
|
1256
1875
|
: null
|
|
1257
1876
|
: null;
|
|
1258
|
-
const notificationEvent = holderProfile
|
|
1259
|
-
? buildCredentialRefreshedNotification({
|
|
1260
|
-
holderProfile,
|
|
1261
|
-
issuerProfile,
|
|
1262
|
-
refreshId,
|
|
1263
|
-
version: aggregate.currentVersion + 1,
|
|
1264
|
-
})
|
|
1265
|
-
: undefined;
|
|
1266
1877
|
|
|
1267
1878
|
const holderProfileForEncryption = aggregate.holderProfileId
|
|
1268
1879
|
? await getProfileByProfileId(aggregate.holderProfileId)
|
|
@@ -1276,79 +1887,258 @@ export const publishCredentialRefresh = async (
|
|
|
1276
1887
|
: [aggregate.holderDid!]
|
|
1277
1888
|
);
|
|
1278
1889
|
const encryptedCredential = JSON.stringify(jwe);
|
|
1279
|
-
const etag = computeRefreshEtag(encryptedCredential);
|
|
1280
1890
|
|
|
1281
|
-
|
|
1891
|
+
return advanceManagedRefreshVersion({
|
|
1282
1892
|
refreshId,
|
|
1283
|
-
|
|
1284
|
-
|
|
1893
|
+
issuerProfile,
|
|
1894
|
+
aggregate,
|
|
1285
1895
|
encryptedCredential,
|
|
1286
1896
|
signingMode,
|
|
1287
1897
|
idempotencyKey,
|
|
1288
|
-
etag,
|
|
1898
|
+
etag: computeRefreshEtag(encryptedCredential),
|
|
1289
1899
|
materialDigest: nextDigest,
|
|
1290
1900
|
updateSummary,
|
|
1901
|
+
updateReason,
|
|
1291
1902
|
credentialDisplayName,
|
|
1292
1903
|
effectiveAt:
|
|
1293
1904
|
effectiveTime !== undefined ? new Date(effectiveTime).toISOString() : undefined,
|
|
1294
|
-
|
|
1295
|
-
notificationId: notificationEvent?.notificationId,
|
|
1296
|
-
notificationDeliveryKey: notificationEvent?.deliveryKey,
|
|
1297
|
-
notificationCreatedAt: notificationEvent?.notifiedAt,
|
|
1905
|
+
notification,
|
|
1298
1906
|
notificationPendingAfterClaim,
|
|
1907
|
+
holderProfile,
|
|
1908
|
+
branding,
|
|
1299
1909
|
});
|
|
1910
|
+
};
|
|
1300
1911
|
|
|
1301
|
-
|
|
1302
|
-
|
|
1912
|
+
export type PublishHolderEncryptedRefreshParams = {
|
|
1913
|
+
issuerProfile: ProfileType;
|
|
1914
|
+
input: PublishHolderEncryptedRefreshInput;
|
|
1915
|
+
domain: string;
|
|
1916
|
+
/** Optional tenant branding for the update email; defaults to LearnCard. */
|
|
1917
|
+
branding?: Partial<TenantBranding>;
|
|
1918
|
+
};
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* Holder-mode counterpart of {@link publishCredentialRefresh} (LC-2345): a handoff
|
|
1922
|
+
* replaces the signed/unsigned credential, the issuer must equal version 1's exactly
|
|
1923
|
+
* (no signing-authority publication is possible for holder aggregates — it would
|
|
1924
|
+
* require the server to process plaintext), and `notify` replaces server-inferred
|
|
1925
|
+
* materiality, which requires comparing decrypted content across versions.
|
|
1926
|
+
*/
|
|
1927
|
+
export const publishHolderEncryptedRefreshableCredential = async (
|
|
1928
|
+
params: PublishHolderEncryptedRefreshParams
|
|
1929
|
+
): Promise<PublishCredentialRefreshResult> => {
|
|
1930
|
+
const { issuerProfile, input, domain, branding } = params;
|
|
1931
|
+
const { handoff, notify, updateSummary, updateReason, idempotencyKey } = input;
|
|
1932
|
+
const refreshId = handoff.refreshId;
|
|
1933
|
+
|
|
1934
|
+
const aggregate = await getCredentialRefresh(refreshId);
|
|
1935
|
+
|
|
1936
|
+
if (!aggregate) {
|
|
1937
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Credential refresh not found' });
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
if (aggregate.issuerProfileId !== issuerProfile.profileId) {
|
|
1941
|
+
throw new TRPCError({
|
|
1942
|
+
code: 'UNAUTHORIZED',
|
|
1943
|
+
message: 'Profile did not allocate this credential refresh',
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
if (aggregate.encryptionMode !== 'holder') {
|
|
1303
1948
|
throw new TRPCError({
|
|
1304
1949
|
code: 'CONFLICT',
|
|
1305
|
-
message:
|
|
1950
|
+
message:
|
|
1951
|
+
'This credential refresh was not prepared for holder-mode encryption; use credentialRefresh.publishCredentialRefresh instead.',
|
|
1306
1952
|
});
|
|
1307
1953
|
}
|
|
1308
1954
|
|
|
1309
|
-
if (
|
|
1310
|
-
|
|
1955
|
+
if (aggregate.inboxCredentialId) {
|
|
1956
|
+
throw new TRPCError({
|
|
1957
|
+
code: 'CONFLICT',
|
|
1958
|
+
message: 'Inbox refresh must be bound through its verified claim flow',
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1311
1961
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
});
|
|
1317
|
-
}
|
|
1962
|
+
const displayBoost = aggregate.boostId ? await getBoostById(aggregate.boostId) : null;
|
|
1963
|
+
const credentialDisplayName = extractBoundedCredentialDisplayTitle(
|
|
1964
|
+
getPublicBoostDisplayName(displayBoost)
|
|
1965
|
+
);
|
|
1318
1966
|
|
|
1319
|
-
|
|
1967
|
+
if (aggregate.state === 'revoked') {
|
|
1968
|
+
throw new TRPCError({ code: 'CONFLICT', message: 'Credential refresh has been revoked' });
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
const canonicalLifecycle = await getCredentialRefreshCanonicalLifecycle(refreshId);
|
|
1972
|
+
|
|
1973
|
+
if (canonicalLifecycle?.revoked) {
|
|
1974
|
+
throw new TRPCError({ code: 'CONFLICT', message: 'Credential refresh has been revoked' });
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
if (idempotencyKey) {
|
|
1978
|
+
const replayed = await getCredentialRefreshVersionByIdempotencyKey(
|
|
1320
1979
|
refreshId,
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1980
|
+
idempotencyKey
|
|
1981
|
+
);
|
|
1982
|
+
|
|
1983
|
+
if (replayed) {
|
|
1984
|
+
return {
|
|
1985
|
+
refreshId,
|
|
1986
|
+
version: replayed.version,
|
|
1987
|
+
publishedAt: replayed.publishedAt,
|
|
1988
|
+
notification: await resolveCredentialRefreshReplayNotification({
|
|
1989
|
+
version: replayed,
|
|
1990
|
+
aggregate,
|
|
1991
|
+
issuerProfile,
|
|
1992
|
+
branding,
|
|
1993
|
+
}),
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1330
1996
|
}
|
|
1331
1997
|
|
|
1332
|
-
|
|
1998
|
+
const head = await getCredentialRefreshHead(refreshId);
|
|
1333
1999
|
|
|
1334
|
-
if (
|
|
1335
|
-
|
|
2000
|
+
if (!head) {
|
|
2001
|
+
throw new TRPCError({
|
|
2002
|
+
code: 'CONFLICT',
|
|
2003
|
+
message: 'Credential refresh is not bound to a credential yet',
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
1336
2006
|
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
holderProfile,
|
|
1343
|
-
branding,
|
|
1344
|
-
})
|
|
1345
|
-
: 'delivery-failed';
|
|
2007
|
+
if (handoff.credentialId !== aggregate.credentialId) {
|
|
2008
|
+
throw new TRPCError({
|
|
2009
|
+
code: 'BAD_REQUEST',
|
|
2010
|
+
message: 'Credential ID does not match the allocated refresh',
|
|
2011
|
+
});
|
|
1346
2012
|
}
|
|
1347
2013
|
|
|
1348
|
-
|
|
2014
|
+
if (handoff.holderDid !== aggregate.holderDid) {
|
|
2015
|
+
throw new TRPCError({
|
|
2016
|
+
code: 'BAD_REQUEST',
|
|
2017
|
+
message: 'Credential subject does not match the intended holder',
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// Later versions must match the exact issuer identity version 1 bound — the same
|
|
2022
|
+
// invariant assertRefreshVersionInvariants enforces for network mode.
|
|
2023
|
+
if (handoff.issuerDid !== aggregate.issuerDid) {
|
|
2024
|
+
throw new TRPCError({
|
|
2025
|
+
code: 'BAD_REQUEST',
|
|
2026
|
+
message: 'Credential issuer does not match the allocated refresh',
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
if (
|
|
2031
|
+
!matchesCredentialStatusDigest(handoff.credentialStatus, aggregate.credentialStatusDigest)
|
|
2032
|
+
) {
|
|
2033
|
+
throw new TRPCError({
|
|
2034
|
+
code: 'BAD_REQUEST',
|
|
2035
|
+
message: 'Credential status does not match the original credential',
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
if (
|
|
2040
|
+
aggregate.refreshServiceBinding &&
|
|
2041
|
+
canonicalizeCredentialJson(
|
|
2042
|
+
handoff.refreshService ?? {
|
|
2043
|
+
id: getCredentialRefreshServiceUrl(refreshId, domain),
|
|
2044
|
+
type: 'LearnCardCredentialRefresh2026',
|
|
2045
|
+
authorization: { type: 'LearnCardDIDAuth' },
|
|
2046
|
+
}
|
|
2047
|
+
) !== canonicalizeCredentialJson(JSON.parse(aggregate.refreshServiceBinding))
|
|
2048
|
+
) {
|
|
2049
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid refresh service binding' });
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
await bindRefreshStatusEntries(
|
|
1349
2053
|
refreshId,
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
2054
|
+
getBitstringStatusListEntries({ credentialStatus: handoff.credentialStatus }),
|
|
2055
|
+
aggregate.issuerProfileId,
|
|
2056
|
+
domain
|
|
2057
|
+
);
|
|
2058
|
+
|
|
2059
|
+
const holderProfile = aggregate.holderProfileId
|
|
2060
|
+
? await getProfileByProfileId(aggregate.holderProfileId)
|
|
2061
|
+
: null;
|
|
2062
|
+
|
|
2063
|
+
if (!holderProfile) {
|
|
2064
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Recipient profile not found' });
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
try {
|
|
2068
|
+
assertExactJweRecipients(
|
|
2069
|
+
handoff.jwe,
|
|
2070
|
+
await getHolderEncryptionRecipientKeyIds(holderProfile)
|
|
2071
|
+
);
|
|
2072
|
+
} catch {
|
|
2073
|
+
throw new TRPCError({ code: 'CONFLICT', message: 'RECIPIENTS_CHANGED' });
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
const notification: PublishCredentialRefreshNotification =
|
|
2077
|
+
aggregate.state === 'active' ? (notify ? 'queued' : 'suppressed') : 'not-applicable';
|
|
2078
|
+
const notificationPendingAfterClaim =
|
|
2079
|
+
aggregate.state === 'awaiting_claim' &&
|
|
2080
|
+
(head.notificationPendingAfterClaim === true || notify === true);
|
|
2081
|
+
const notifiedHolderProfile =
|
|
2082
|
+
notification === 'queued' || notificationPendingAfterClaim ? holderProfile : null;
|
|
2083
|
+
|
|
2084
|
+
const encryptedCredential = JSON.stringify(handoff.jwe);
|
|
2085
|
+
|
|
2086
|
+
return advanceManagedRefreshVersion({
|
|
2087
|
+
refreshId,
|
|
2088
|
+
issuerProfile,
|
|
2089
|
+
aggregate,
|
|
2090
|
+
encryptedCredential,
|
|
2091
|
+
signingMode: 'issuer-signed',
|
|
2092
|
+
idempotencyKey,
|
|
2093
|
+
etag: computeRefreshEtag(encryptedCredential),
|
|
2094
|
+
updateSummary,
|
|
2095
|
+
updateReason,
|
|
2096
|
+
credentialDisplayName,
|
|
2097
|
+
notification,
|
|
2098
|
+
notificationPendingAfterClaim,
|
|
2099
|
+
holderProfile: notifiedHolderProfile,
|
|
2100
|
+
branding,
|
|
2101
|
+
});
|
|
2102
|
+
};
|
|
2103
|
+
|
|
2104
|
+
export type GetCurrentEncryptionRecipientsParams = {
|
|
2105
|
+
issuerProfile: ProfileType;
|
|
2106
|
+
refreshId: string;
|
|
2107
|
+
};
|
|
2108
|
+
|
|
2109
|
+
/** Issuer-only lookup backing `credentialRefresh.getEncryptionRecipients` (LC-2345). */
|
|
2110
|
+
export const getCurrentEncryptionRecipients = async (
|
|
2111
|
+
params: GetCurrentEncryptionRecipientsParams
|
|
2112
|
+
): Promise<string[]> => {
|
|
2113
|
+
const { issuerProfile, refreshId } = params;
|
|
2114
|
+
|
|
2115
|
+
const aggregate = await getCredentialRefresh(refreshId);
|
|
2116
|
+
|
|
2117
|
+
if (!aggregate) {
|
|
2118
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Credential refresh not found' });
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
if (aggregate.issuerProfileId !== issuerProfile.profileId) {
|
|
2122
|
+
throw new TRPCError({
|
|
2123
|
+
code: 'UNAUTHORIZED',
|
|
2124
|
+
message: 'Profile did not allocate this credential refresh',
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
if (aggregate.encryptionMode !== 'holder') {
|
|
2129
|
+
throw new TRPCError({
|
|
2130
|
+
code: 'CONFLICT',
|
|
2131
|
+
message: 'This credential refresh does not use holder-mode encryption',
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
const holderProfile = aggregate.holderProfileId
|
|
2136
|
+
? await getProfileByProfileId(aggregate.holderProfileId)
|
|
2137
|
+
: null;
|
|
2138
|
+
|
|
2139
|
+
if (!holderProfile) {
|
|
2140
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Recipient profile not found' });
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
return getHolderEncryptionRecipientKeyIds(holderProfile);
|
|
1354
2144
|
};
|