@learncard/network-brain-service 4.1.3 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +14 -0
- package/CHANGELOG.md +143 -0
- package/README.md +27 -1
- package/bootstrapLambda.test.ts +70 -32
- package/config/config.dev.json +1 -0
- package/config/config.example.json +2 -1
- package/config/config.production.json +1 -0
- package/config/config.scouts.dev.json +1 -0
- package/config/config.scouts.production.json +1 -0
- package/consentFlowDeadlineLambda.test.ts +108 -0
- package/consentFlowDeadlineLambda.ts +18 -0
- package/consentFlowDeadlineLambdaApp.ts +14 -0
- package/dist/accesslayer/boost/create.d.ts +8 -2
- package/dist/accesslayer/boost/create.d.ts.map +1 -1
- package/dist/accesslayer/campaign/audience.d.ts +4 -4
- package/dist/accesslayer/campaign/audience.d.ts.map +1 -1
- package/dist/accesslayer/campaign/create.d.ts +1 -1
- package/dist/accesslayer/campaign/create.d.ts.map +1 -1
- package/dist/accesslayer/campaign/delivery.d.ts +5 -0
- package/dist/accesslayer/campaign/delivery.d.ts.map +1 -0
- package/dist/accesslayer/campaign/dispatch.d.ts +5 -0
- package/dist/accesslayer/campaign/dispatch.d.ts.map +1 -0
- package/dist/accesslayer/campaign/dispatch.test.d.ts +2 -0
- package/dist/accesslayer/campaign/dispatch.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/email.d.ts +10 -0
- package/dist/accesslayer/campaign/email.d.ts.map +1 -0
- package/dist/accesslayer/campaign/email.test.d.ts +2 -0
- package/dist/accesslayer/campaign/email.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/preferences.d.ts +5 -0
- package/dist/accesslayer/campaign/preferences.d.ts.map +1 -0
- package/dist/accesslayer/campaign/read.d.ts.map +1 -1
- package/dist/accesslayer/campaign/test.d.ts +5 -0
- package/dist/accesslayer/campaign/test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/test.test.d.ts +2 -0
- package/dist/accesslayer/campaign/test.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/update.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/delete.d.ts +1 -1
- package/dist/accesslayer/consentflowcontract/delete.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +2 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +19 -4
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +6 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/versions.d.ts +68 -0
- package/dist/accesslayer/consentflowcontract/versions.d.ts.map +1 -0
- package/dist/accesslayer/credential/create.d.ts +3 -1
- package/dist/accesslayer/credential/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/delete.d.ts +1 -0
- package/dist/accesslayer/credential/delete.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/create.d.ts +15 -3
- package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/read.d.ts +2 -0
- package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/read.d.ts +1 -0
- package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/create.d.ts +1 -0
- package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/refresh.d.ts +7 -0
- package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -1
- package/dist/app.d.ts +11 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +61 -1
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/campaign-unsubscribe.d.ts +7 -0
- package/dist/campaign-unsubscribe.d.ts.map +1 -0
- package/dist/campaign-unsubscribe.test.d.ts +2 -0
- package/dist/campaign-unsubscribe.test.d.ts.map +1 -0
- package/dist/config/environment.d.ts +42 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/stageConfig.d.ts +4 -0
- package/dist/config/stageConfig.d.ts.map +1 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/helpers/boost.helpers.d.ts +4 -1
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/campaign-app-origin.helpers.d.ts +2 -0
- package/dist/helpers/campaign-app-origin.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-email.helpers.d.ts +13 -0
- package/dist/helpers/campaign-email.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-notifications.helpers.d.ts +7 -0
- package/dist/helpers/campaign-notifications.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-queue.helpers.d.ts +7 -0
- package/dist/helpers/campaign-queue.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-queue.helpers.test.d.ts +2 -0
- package/dist/helpers/campaign-queue.helpers.test.d.ts.map +1 -0
- package/dist/helpers/campaign-unsubscribe-page.d.ts +2 -0
- package/dist/helpers/campaign-unsubscribe-page.d.ts.map +1 -0
- package/dist/helpers/campaign-unsubscribe.helpers.d.ts +7 -0
- package/dist/helpers/campaign-unsubscribe.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-webhook.helpers.test.d.ts +2 -0
- package/dist/helpers/campaign-webhook.helpers.test.d.ts.map +1 -0
- package/dist/helpers/consentflow-deadline.helpers.d.ts +5 -0
- package/dist/helpers/consentflow-deadline.helpers.d.ts.map +1 -0
- package/dist/helpers/consentflow-legacy-migration.helpers.d.ts +2 -0
- package/dist/helpers/consentflow-legacy-migration.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-events.helpers.d.ts.map +1 -1
- package/dist/helpers/contract.helpers.d.ts +1 -1
- package/dist/helpers/contract.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-display.helpers.d.ts +5 -0
- package/dist/helpers/credential-display.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-display.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-display.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-refresh-materiality.helpers.d.ts +4 -0
- package/dist/helpers/credential-refresh-materiality.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-refresh.helpers.d.ts +30 -1
- package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-status-mutation.helpers.d.ts +5 -0
- package/dist/helpers/credential-status-mutation.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-status-mutation.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-status-mutation.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential.helpers.d.ts +1 -1
- package/dist/helpers/credential.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox-refresh.helpers.d.ts +4 -2
- package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox.helpers.d.ts +1 -0
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/learnCard.helpers.d.ts +1 -0
- package/dist/helpers/learnCard.helpers.d.ts.map +1 -1
- package/dist/helpers/managed-status.helpers.d.ts +7 -0
- package/dist/helpers/managed-status.helpers.d.ts.map +1 -0
- package/dist/helpers/managed-status.helpers.test.d.ts +2 -0
- package/dist/helpers/managed-status.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts +2 -0
- package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notification-webhook-fetch.helpers.d.ts +13 -0
- package/dist/helpers/notification-webhook-fetch.helpers.d.ts.map +1 -0
- package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts +2 -0
- package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
- package/dist/helpers/notifications.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.d.ts +13 -5
- package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.test.d.ts +2 -0
- package/dist/helpers/refresh-send-intent.helpers.test.d.ts.map +1 -0
- package/dist/helpers/send-contract.helpers.d.ts +11 -0
- package/dist/helpers/send-contract.helpers.d.ts.map +1 -0
- package/dist/helpers/status-list.helpers.d.ts +12 -1
- package/dist/helpers/status-list.helpers.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/models/ConsentFlowContract.d.ts.map +1 -1
- package/dist/models/ConsentFlowContractVersion.d.ts +12 -0
- package/dist/models/ConsentFlowContractVersion.d.ts.map +1 -0
- package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
- package/dist/models/Credential.d.ts +2 -0
- package/dist/models/Credential.d.ts.map +1 -1
- package/dist/models/CredentialRefresh.d.ts +5 -0
- package/dist/models/CredentialRefresh.d.ts.map +1 -1
- package/dist/models/Integration.d.ts +1 -1
- package/dist/models/Profile.d.ts.map +1 -1
- package/dist/models/campaign-constraints.d.ts.map +1 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/activity.d.ts +15 -5
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/app-store.d.ts +15 -5
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/auth-grants.d.ts +12 -2
- package/dist/routes/auth-grants.d.ts.map +1 -1
- package/dist/routes/bench.d.ts +11 -1
- package/dist/routes/bench.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +70 -15
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/campaigns.d.ts +183 -51
- package/dist/routes/campaigns.d.ts.map +1 -1
- package/dist/routes/claim-hooks.d.ts +11 -1
- package/dist/routes/claim-hooks.d.ts.map +1 -1
- package/dist/routes/contact-methods.d.ts +11 -1
- package/dist/routes/contact-methods.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +374 -8
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-input-privacy.test.d.ts +2 -0
- package/dist/routes/credential-input-privacy.test.d.ts.map +1 -0
- package/dist/routes/credential-refreshes.d.ts +210 -1
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credential-share-previews.d.ts +11 -1
- package/dist/routes/credential-share-previews.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +132 -3
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/did-metadata.d.ts +11 -1
- package/dist/routes/did-metadata.d.ts.map +1 -1
- package/dist/routes/federation.d.ts +11 -1
- package/dist/routes/federation.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +54 -19
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +100 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/integrations.d.ts +14 -4
- package/dist/routes/integrations.d.ts.map +1 -1
- package/dist/routes/presentations.d.ts +11 -1
- package/dist/routes/presentations.d.ts.map +1 -1
- package/dist/routes/profile-manager.d.ts +11 -1
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts +11 -1
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/public-share-links.d.ts +22 -2
- package/dist/routes/public-share-links.d.ts.map +1 -1
- package/dist/routes/share-links.d.ts +36 -16
- package/dist/routes/share-links.d.ts.map +1 -1
- package/dist/routes/skill-frameworks.d.ts +11 -1
- package/dist/routes/skill-frameworks.d.ts.map +1 -1
- package/dist/routes/skills.d.ts +11 -1
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/storage.d.ts +37 -23
- package/dist/routes/storage.d.ts.map +1 -1
- package/dist/routes/test.d.ts +79 -3
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/utilities.d.ts +11 -1
- package/dist/routes/utilities.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts +11 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.d.ts +3 -0
- package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
- package/dist/services/delivery/delivery.service.d.ts +10 -0
- package/dist/services/delivery/delivery.service.d.ts.map +1 -1
- package/dist/types/activity.d.ts +3 -3
- package/dist/types/app-manifest.d.ts +10 -10
- package/dist/types/campaign.d.ts +13 -1
- package/dist/types/campaign.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +175 -7
- package/dist/types/consentflowcontract.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +27 -0
- package/dist/types/credential-refresh.d.ts.map +1 -1
- package/dist/types/credential.d.ts +5 -0
- package/dist/types/credential.d.ts.map +1 -1
- package/dist/types/integration.d.ts +3 -3
- package/lambda.ts +44 -14
- package/lambdaApp.ts +20 -0
- package/package.json +20 -19
- package/project.json +5 -0
- package/scripts/migrate-consent-flow-versions.ts +9 -0
- package/scripts/report-send-template-exposure.ts +47 -0
- package/scripts/verify-python-contracts.ts +16 -1
- package/serverless-local.yml +15 -2
- package/serverless.function-env.cjs +4 -0
- package/serverless.local.yml +12 -0
- package/serverless.yml +48 -3
- package/src/accesslayer/boost/create.ts +62 -7
- package/src/accesslayer/campaign/README.md +275 -6
- package/src/accesslayer/campaign/audience.ts +39 -7
- package/src/accesslayer/campaign/create.ts +4 -2
- package/src/accesslayer/campaign/delivery.ts +66 -0
- package/src/accesslayer/campaign/dispatch.test.ts +59 -0
- package/src/accesslayer/campaign/dispatch.ts +172 -0
- package/src/accesslayer/campaign/email.test.ts +73 -0
- package/src/accesslayer/campaign/email.ts +91 -0
- package/src/accesslayer/campaign/preferences.ts +53 -0
- package/src/accesslayer/campaign/read.ts +3 -1
- package/src/accesslayer/campaign/state.ts +1 -1
- package/src/accesslayer/campaign/test.test.ts +31 -0
- package/src/accesslayer/campaign/test.ts +75 -0
- package/src/accesslayer/campaign/update.ts +15 -4
- package/src/accesslayer/consentflowcontract/create.ts +20 -1
- package/src/accesslayer/consentflowcontract/delete.ts +121 -3
- package/src/accesslayer/consentflowcontract/read.ts +15 -9
- package/src/accesslayer/consentflowcontract/relationships/create.ts +70 -36
- package/src/accesslayer/consentflowcontract/relationships/read.ts +305 -73
- package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +1 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +269 -98
- package/src/accesslayer/consentflowcontract/versions.ts +790 -0
- package/src/accesslayer/credential/create.ts +33 -9
- package/src/accesslayer/credential/delete.ts +45 -0
- package/src/accesslayer/credential/relationships/create.ts +102 -47
- package/src/accesslayer/credential/relationships/update.ts +8 -116
- package/src/accesslayer/credential-refresh/read.ts +26 -2
- package/src/accesslayer/credential-refresh/update.ts +6 -2
- package/src/accesslayer/inbox-credential/create.ts +7 -1
- package/src/accesslayer/inbox-credential/refresh.ts +14 -4
- package/src/campaign-unsubscribe.test.ts +158 -0
- package/src/campaign-unsubscribe.ts +86 -0
- package/src/config/environment.test.ts +52 -0
- package/src/config/environment.ts +58 -0
- package/src/config/serverlessFunctionEnv.test.ts +59 -0
- package/src/credential-refresh.ts +1 -0
- package/src/docker-entry.ts +26 -1
- package/src/helpers/boost-storage.helpers.test.ts +107 -41
- package/src/helpers/boost.helpers.ts +88 -40
- package/src/helpers/campaign-app-origin.helpers.ts +25 -0
- package/src/helpers/campaign-email.helpers.ts +113 -0
- package/src/helpers/campaign-notifications.helpers.ts +127 -0
- package/src/helpers/campaign-queue.helpers.test.ts +442 -0
- package/src/helpers/campaign-queue.helpers.ts +122 -0
- package/src/helpers/campaign-unsubscribe-page.ts +107 -0
- package/src/helpers/campaign-unsubscribe.helpers.ts +56 -0
- package/src/helpers/campaign-webhook.helpers.test.ts +137 -0
- package/src/helpers/consentflow-deadline.helpers.ts +22 -0
- package/src/helpers/consentflow-legacy-migration.helpers.ts +179 -0
- package/src/helpers/contract-events.helpers.ts +35 -3
- package/src/helpers/contract-requests.helpers.ts +1 -1
- package/src/helpers/contract.helpers.ts +5 -1
- package/src/helpers/credential-display.helpers.test.ts +68 -0
- package/src/helpers/credential-display.helpers.ts +15 -0
- package/src/helpers/credential-refresh-email.helpers.ts +5 -12
- package/src/helpers/credential-refresh-materiality.helpers.test.ts +138 -1
- package/src/helpers/credential-refresh-materiality.helpers.ts +62 -1
- package/src/helpers/credential-refresh-proof.helpers.test.ts +1 -1
- package/src/helpers/credential-refresh-proof.helpers.ts +2 -2
- package/src/helpers/credential-refresh.helpers.ts +1166 -376
- package/src/helpers/credential-status-mutation.helpers.test.ts +155 -0
- package/src/helpers/credential-status-mutation.helpers.ts +166 -0
- package/src/helpers/credential.helpers.ts +19 -11
- package/src/helpers/finalize-inbox.helpers.ts +1 -1
- package/src/helpers/inbox-refresh.helpers.ts +34 -8
- package/src/helpers/inbox.helpers.ts +2 -0
- package/src/helpers/learnCard.helpers.ts +23 -0
- package/src/helpers/managed-status.helpers.test.ts +101 -0
- package/src/helpers/managed-status.helpers.ts +147 -0
- package/src/helpers/notification-delivery-boundary.helpers.test.ts +114 -0
- package/src/helpers/notification-webhook-fetch.helpers.test.ts +228 -0
- package/src/helpers/notification-webhook-fetch.helpers.ts +166 -0
- package/src/helpers/notificationQueue.helpers.ts +5 -0
- package/src/helpers/notifications.helpers.ts +19 -12
- package/src/helpers/refresh-send-intent.helpers.test.ts +60 -0
- package/src/helpers/refresh-send-intent.helpers.ts +81 -18
- package/src/helpers/send-contract.helpers.ts +36 -0
- package/src/helpers/status-list.helpers.test.ts +29 -12
- package/src/helpers/status-list.helpers.ts +329 -20
- package/src/models/ConsentFlowContract.ts +3 -0
- package/src/models/ConsentFlowContractVersion.ts +50 -0
- package/src/models/ConsentFlowTerms.ts +4 -0
- package/src/models/Credential.ts +2 -0
- package/src/models/CredentialRefresh.ts +2 -0
- package/src/models/Profile.ts +2 -0
- package/src/models/campaign-constraints.ts +13 -8
- package/src/models/index.ts +2 -0
- package/src/openapi.ts +21 -0
- package/src/routes/app-store.ts +2 -1
- package/src/routes/boosts.ts +219 -137
- package/src/routes/campaigns.ts +98 -19
- package/src/routes/contracts.ts +433 -115
- package/src/routes/credential-input-privacy.test.ts +92 -0
- package/src/routes/credential-refreshes.ts +273 -2
- package/src/routes/federation.ts +7 -2
- package/src/routes/inbox.ts +6 -1
- package/src/routes/index.ts +31 -2
- package/src/routes/workflows.ts +61 -46
- package/src/services/delivery/adapters/postmark.adapter.test.ts +70 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +84 -11
- package/src/services/delivery/delivery.service.ts +4 -0
- package/src/types/campaign.ts +14 -1
- package/src/types/consentflowcontract.ts +43 -2
- package/src/types/credential-refresh.ts +11 -1
- package/src/types/credential.ts +2 -0
- package/test/boost-status-cleanup.spec.ts +123 -0
- package/test/campaigns-email.unit.spec.ts +268 -0
- package/test/campaigns.spec.ts +804 -30
- package/test/consent-autoboost-idempotency.spec.ts +432 -0
- package/test/consent-data-boundaries.spec.ts +322 -26
- package/test/consent-legacy-migration.spec.ts +315 -0
- package/test/consent-recipients.spec.ts +7 -7
- package/test/consent-version-updates.spec.ts +761 -0
- package/test/consentflow.spec.ts +698 -226
- package/test/contract-requests.spec.ts +55 -9
- package/test/credential-refresh-allocation.spec.ts +212 -14
- package/test/credential-refresh-email.spec.ts +6 -10
- package/test/credential-refresh-endpoint.spec.ts +3 -0
- package/test/credential-refresh-publication.spec.ts +52 -15
- package/test/credential-storage.neo4j.spec.ts +33 -6
- package/test/helpers/legacy-autoboost.ts +48 -0
- package/test/holder-mode-send.spec.ts +710 -0
- package/test/inbox-refresh.spec.ts +105 -0
- package/test/inbox-security.e2e.ts +18 -22
- package/test/managed-status-revocation.spec.ts +970 -0
- package/test/notifications-worker.spec.ts +31 -25
- package/test/refresh-send.spec.ts +334 -5
- package/test/smart-resume-consent.spec.ts +1 -1
- package/test/verification/campaigns.ts +40 -0
- package/vitest.campaigns.config.ts +2 -0
- package/vitest.config.ts +14 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import type { JWE, VC } from '@learncard/types';
|
|
3
|
+
import type { Transaction } from 'neo4j-driver';
|
|
3
4
|
|
|
4
5
|
const mocks = vi.hoisted(() => ({
|
|
5
6
|
decrypt: vi.fn(),
|
|
@@ -12,6 +13,17 @@ const mocks = vi.hoisted(() => ({
|
|
|
12
13
|
accept: vi.fn(),
|
|
13
14
|
notify: vi.fn(async () => undefined),
|
|
14
15
|
signingAuthority: vi.fn(),
|
|
16
|
+
release: vi.fn(),
|
|
17
|
+
transaction: { run: vi.fn() },
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('@instance', () => ({
|
|
21
|
+
neogma: {
|
|
22
|
+
getTransaction: async (
|
|
23
|
+
_transaction: Transaction | null,
|
|
24
|
+
action: (transaction: unknown) => Promise<unknown>
|
|
25
|
+
) => action(mocks.transaction),
|
|
26
|
+
},
|
|
15
27
|
}));
|
|
16
28
|
|
|
17
29
|
vi.mock('@models', () => ({}));
|
|
@@ -24,12 +36,13 @@ vi.mock('./uri.helpers', () => ({
|
|
|
24
36
|
`lc:network:${domain}/${kind}:${id}`,
|
|
25
37
|
}));
|
|
26
38
|
vi.mock('@accesslayer/credential/create', () => ({ storeCredential: mocks.store }));
|
|
39
|
+
vi.mock('@accesslayer/credential/delete', () => ({ releaseUnlinkedCredential: mocks.release }));
|
|
27
40
|
vi.mock('@accesslayer/boost/relationships/create', () => ({
|
|
28
41
|
createBoostInstanceOfRelationship: mocks.instanceOf,
|
|
29
42
|
}));
|
|
30
43
|
vi.mock('@accesslayer/credential/relationships/create', () => ({
|
|
31
44
|
createSentCredentialRelationship: mocks.sent,
|
|
32
|
-
|
|
45
|
+
createContractCredentialRelationships: mocks.contract,
|
|
33
46
|
createListingSentCredentialRelationship: mocks.listing,
|
|
34
47
|
}));
|
|
35
48
|
vi.mock('./credential.helpers', () => ({
|
|
@@ -137,41 +150,13 @@ describe('boost payload storage', () => {
|
|
|
137
150
|
),
|
|
138
151
|
],
|
|
139
152
|
] as const)(
|
|
140
|
-
'
|
|
153
|
+
'preserves %s bytes without decrypting or replacing the issuer signature',
|
|
141
154
|
async (_kind, credential) => {
|
|
142
155
|
const uri = await sendBoost({ ...options, credential });
|
|
143
156
|
expect(uri).toBe('lc:network:network.example/credential:stored');
|
|
144
157
|
expect(mocks.store.mock.calls[0]?.[0]).toBe(credential);
|
|
145
158
|
expect(mocks.decrypt).not.toHaveBeenCalled();
|
|
146
159
|
expect(mocks.issue).not.toHaveBeenCalled();
|
|
147
|
-
expect(mocks.instanceOf).toHaveBeenCalledExactlyOnceWith(
|
|
148
|
-
{ id: 'stored' },
|
|
149
|
-
options.boost
|
|
150
|
-
);
|
|
151
|
-
expect(mocks.sent).toHaveBeenCalledExactlyOnceWith(
|
|
152
|
-
options.from,
|
|
153
|
-
options.to,
|
|
154
|
-
{ id: 'stored' },
|
|
155
|
-
options.metadata,
|
|
156
|
-
'activity',
|
|
157
|
-
'integration'
|
|
158
|
-
);
|
|
159
|
-
expect(mocks.contract).toHaveBeenCalledExactlyOnceWith(
|
|
160
|
-
{ id: 'stored' },
|
|
161
|
-
options.contractTerms
|
|
162
|
-
);
|
|
163
|
-
expect(mocks.listing).toHaveBeenCalledExactlyOnceWith(
|
|
164
|
-
'listing',
|
|
165
|
-
options.to,
|
|
166
|
-
{ id: 'stored' },
|
|
167
|
-
options.metadata,
|
|
168
|
-
'activity',
|
|
169
|
-
'integration'
|
|
170
|
-
);
|
|
171
|
-
expect(mocks.accept).toHaveBeenCalledExactlyOnceWith(options.to, uri, {
|
|
172
|
-
skipNotification: false,
|
|
173
|
-
});
|
|
174
|
-
expect(mocks.notify).toHaveBeenCalledOnce();
|
|
175
160
|
}
|
|
176
161
|
);
|
|
177
162
|
|
|
@@ -179,12 +164,10 @@ describe('boost payload storage', () => {
|
|
|
179
164
|
const first = deferred();
|
|
180
165
|
const sent = deferred();
|
|
181
166
|
const listing = deferred();
|
|
182
|
-
const contract = deferred();
|
|
183
167
|
mocks.instanceOf.mockReturnValueOnce(first.promise);
|
|
184
168
|
mocks.sent.mockReturnValueOnce(sent.promise);
|
|
185
169
|
mocks.listing.mockReturnValueOnce(listing.promise);
|
|
186
|
-
|
|
187
|
-
const result = sendBoost(options);
|
|
170
|
+
const result = sendBoost({ ...options, contractTerms: undefined });
|
|
188
171
|
await vi.waitFor(() => expect(mocks.instanceOf).toHaveBeenCalledOnce());
|
|
189
172
|
expect(mocks.sent).not.toHaveBeenCalled();
|
|
190
173
|
first.resolve();
|
|
@@ -194,19 +177,67 @@ describe('boost payload storage', () => {
|
|
|
194
177
|
await vi.waitFor(() => expect(mocks.listing).toHaveBeenCalledOnce());
|
|
195
178
|
expect(mocks.contract).not.toHaveBeenCalled();
|
|
196
179
|
listing.resolve();
|
|
197
|
-
await vi.waitFor(() => expect(mocks.contract).toHaveBeenCalledOnce());
|
|
198
|
-
expect(mocks.accept).not.toHaveBeenCalled();
|
|
199
|
-
expect(mocks.notify).not.toHaveBeenCalled();
|
|
200
|
-
contract.resolve();
|
|
201
180
|
await expect(result).resolves.toBe('lc:network:network.example/credential:stored');
|
|
181
|
+
expect(mocks.contract).not.toHaveBeenCalled();
|
|
182
|
+
expect(mocks.instanceOf.mock.calls[0]?.[2]).toBe(mocks.transaction);
|
|
183
|
+
expect(mocks.sent.mock.calls[0]?.[6]).toBe(mocks.transaction);
|
|
184
|
+
expect(mocks.listing.mock.calls[0]?.[6]).toBe(mocks.transaction);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('releases a failed contract send so the same signed payload can be retried', async () => {
|
|
188
|
+
let reserved = false;
|
|
189
|
+
mocks.store
|
|
190
|
+
.mockImplementationOnce(async () => {
|
|
191
|
+
reserved = true;
|
|
192
|
+
return { id: 'stored' };
|
|
193
|
+
})
|
|
194
|
+
.mockImplementationOnce(async () => {
|
|
195
|
+
if (reserved) throw new Error('Status list index is already bound');
|
|
196
|
+
return { id: 'retried' };
|
|
197
|
+
});
|
|
198
|
+
mocks.release.mockImplementationOnce(async () => {
|
|
199
|
+
reserved = false;
|
|
200
|
+
});
|
|
201
|
+
const error = new Error('contract not approved');
|
|
202
|
+
mocks.contract.mockRejectedValueOnce(error);
|
|
203
|
+
await expect(sendBoost({ ...options, credential: vc })).rejects.toBe(error);
|
|
204
|
+
expect(mocks.release).toHaveBeenCalledWith('stored');
|
|
205
|
+
await expect(sendBoost({ ...options, credential: vc })).resolves.toBe(
|
|
206
|
+
'lc:network:network.example/credential:retried'
|
|
207
|
+
);
|
|
208
|
+
expect(mocks.store.mock.calls[0]?.[0]).toBe(vc);
|
|
209
|
+
expect(mocks.store.mock.calls[1]?.[0]).toBe(vc);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('preserves the relationship error when cleanup fails', async () => {
|
|
213
|
+
const error = new Error('relationship failed');
|
|
214
|
+
mocks.contract.mockRejectedValueOnce(error);
|
|
215
|
+
mocks.release.mockRejectedValueOnce(new Error('cleanup failed'));
|
|
216
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
217
|
+
try {
|
|
218
|
+
await expect(sendBoost(options)).rejects.toBe(error);
|
|
219
|
+
expect(log).toHaveBeenCalledWith('BOOST_UNLINKED_CREDENTIAL_CLEANUP_FAILED');
|
|
220
|
+
} finally {
|
|
221
|
+
log.mockRestore();
|
|
222
|
+
}
|
|
202
223
|
});
|
|
203
224
|
|
|
204
225
|
it.each(['instanceOf', 'sent', 'listing', 'contract'] as const)(
|
|
205
|
-
'stops after a failed %s
|
|
226
|
+
'stops after a failed %s graph write without acceptance or notification',
|
|
206
227
|
async failed => {
|
|
207
228
|
const error = new Error('graph write failed');
|
|
208
229
|
mocks[failed].mockRejectedValueOnce(error);
|
|
209
|
-
await expect(
|
|
230
|
+
await expect(
|
|
231
|
+
sendBoost({
|
|
232
|
+
...options,
|
|
233
|
+
contractTerms: failed === 'contract' ? options.contractTerms : undefined,
|
|
234
|
+
})
|
|
235
|
+
).rejects.toBe(error);
|
|
236
|
+
if (failed === 'contract') {
|
|
237
|
+
expect(mocks.instanceOf).not.toHaveBeenCalled();
|
|
238
|
+
expect(mocks.sent).not.toHaveBeenCalled();
|
|
239
|
+
expect(mocks.listing).not.toHaveBeenCalled();
|
|
240
|
+
}
|
|
210
241
|
const order = ['instanceOf', 'sent', 'listing', 'contract'] as const;
|
|
211
242
|
for (const later of order.slice(order.indexOf(failed) + 1)) {
|
|
212
243
|
expect(mocks[later]).not.toHaveBeenCalled();
|
|
@@ -224,7 +255,7 @@ describe('boost payload storage', () => {
|
|
|
224
255
|
listing: { listing_id: 'listing' },
|
|
225
256
|
ownerProfile: { profileId: 'org' },
|
|
226
257
|
} as Parameters<typeof sendBoost>[0]['from'];
|
|
227
|
-
await sendBoost({ ...options, from, listingId });
|
|
258
|
+
await sendBoost({ ...options, from, listingId, contractTerms: undefined });
|
|
228
259
|
expect(mocks.sent).toHaveBeenCalledOnce();
|
|
229
260
|
expect(mocks.listing).toHaveBeenCalledTimes(listingId === 'another-listing' ? 1 : 0);
|
|
230
261
|
}
|
|
@@ -287,7 +318,7 @@ describe('boost payload storage', () => {
|
|
|
287
318
|
} else {
|
|
288
319
|
expect(unsigned).not.toHaveProperty('boostId');
|
|
289
320
|
}
|
|
290
|
-
expect(mocks.store).toHaveBeenCalledWith(issued);
|
|
321
|
+
expect(mocks.store).toHaveBeenCalledWith(issued, mocks.transaction, 'org');
|
|
291
322
|
}
|
|
292
323
|
);
|
|
293
324
|
|
|
@@ -411,6 +442,41 @@ describe('boost payload storage', () => {
|
|
|
411
442
|
}
|
|
412
443
|
);
|
|
413
444
|
|
|
445
|
+
describe('display-only anchor detection', () => {
|
|
446
|
+
const anchorBoostWith = (template: Record<string, unknown>) =>
|
|
447
|
+
({
|
|
448
|
+
dataValues: {
|
|
449
|
+
id: BOOST_ID,
|
|
450
|
+
boost: JSON.stringify({ ...vc, boostId: undefined, ...template }),
|
|
451
|
+
},
|
|
452
|
+
}) as Parameters<typeof prepareCredentialFromBoost>[0];
|
|
453
|
+
|
|
454
|
+
it("does not mistake an ordinary Boost's own display object for a display-only anchor", async () => {
|
|
455
|
+
const credential = await prepareCredentialFromBoost(
|
|
456
|
+
anchorBoostWith({
|
|
457
|
+
display: {
|
|
458
|
+
backgroundColor: '#fff',
|
|
459
|
+
backgroundImage: '',
|
|
460
|
+
displayType: 'card',
|
|
461
|
+
},
|
|
462
|
+
}),
|
|
463
|
+
`lc:network:network.example/boost:${BOOST_ID}`,
|
|
464
|
+
options.domain
|
|
465
|
+
);
|
|
466
|
+
expect(credential.boostId).toBe(`lc:network:network.example/boost:${BOOST_ID}`);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('rejects reusing a boost marked as a display-only anchor without an issuance credential', async () => {
|
|
470
|
+
await expect(
|
|
471
|
+
prepareCredentialFromBoost(
|
|
472
|
+
anchorBoostWith({ displayOnlyAnchor: true, display: {} }),
|
|
473
|
+
`lc:network:network.example/boost:${BOOST_ID}`,
|
|
474
|
+
options.domain
|
|
475
|
+
)
|
|
476
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
414
480
|
describe('stampIssuanceDate', () => {
|
|
415
481
|
const NOW = '2026-10-18T05:00:00.000Z';
|
|
416
482
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import cloneDeep from 'lodash/cloneDeep';
|
|
2
|
+
import type { Transaction } from 'neo4j-driver';
|
|
3
|
+
import { neogma } from '@instance';
|
|
2
4
|
import {
|
|
3
5
|
CredentialFieldError,
|
|
6
|
+
isDisplayOnlyAnchorTemplate,
|
|
4
7
|
isEncrypted,
|
|
5
8
|
isVC2Format,
|
|
6
9
|
materializeCredentialFields,
|
|
@@ -31,13 +34,14 @@ import {
|
|
|
31
34
|
import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
|
|
32
35
|
|
|
33
36
|
import { getBoostOwner } from '@accesslayer/boost/relationships/read';
|
|
34
|
-
import { BoostInstance } from '@models';
|
|
37
|
+
import { BoostInstance, CredentialInstance } from '@models';
|
|
35
38
|
import { constructUri } from './uri.helpers';
|
|
36
39
|
import { storeCredential } from '@accesslayer/credential/create';
|
|
40
|
+
import { releaseUnlinkedCredential } from '@accesslayer/credential/delete';
|
|
37
41
|
import { createBoostInstanceOfRelationship } from '@accesslayer/boost/relationships/create';
|
|
38
42
|
import {
|
|
39
43
|
createSentCredentialRelationship,
|
|
40
|
-
|
|
44
|
+
createContractCredentialRelationships,
|
|
41
45
|
createListingSentCredentialRelationship,
|
|
42
46
|
} from '@accesslayer/credential/relationships/create';
|
|
43
47
|
import { acceptCredential, getCredentialUri } from './credential.helpers';
|
|
@@ -85,7 +89,7 @@ export const isEditableBoost = (boost: BoostInstance): boolean => {
|
|
|
85
89
|
*/
|
|
86
90
|
export const isBoostViewableByClaimLink = async (boost: BoostInstance): Promise<boolean> => {
|
|
87
91
|
const defaultRoles = await boost.findRelationships({ alias: 'defaultRole' });
|
|
88
|
-
const defaultRole = defaultRoles[0]?.target
|
|
92
|
+
const defaultRole = defaultRoles[0]?.target;
|
|
89
93
|
const canView = defaultRole?.canView ?? defaultRole?.dataValues?.canView;
|
|
90
94
|
|
|
91
95
|
if (typeof canView === 'boolean') return canView;
|
|
@@ -138,6 +142,8 @@ export const sendBoost = async ({
|
|
|
138
142
|
skipNotification = false,
|
|
139
143
|
autoAcceptCredential = false,
|
|
140
144
|
contractTerms,
|
|
145
|
+
contractWriterProfileId,
|
|
146
|
+
allowConsumedOneTime = false,
|
|
141
147
|
metadata,
|
|
142
148
|
activityId,
|
|
143
149
|
integrationId,
|
|
@@ -151,6 +157,8 @@ export const sendBoost = async ({
|
|
|
151
157
|
skipNotification?: boolean;
|
|
152
158
|
autoAcceptCredential?: boolean;
|
|
153
159
|
contractTerms?: DbTermsType;
|
|
160
|
+
contractWriterProfileId?: string;
|
|
161
|
+
allowConsumedOneTime?: boolean;
|
|
154
162
|
metadata?: Record<string, unknown>;
|
|
155
163
|
activityId?: string;
|
|
156
164
|
integrationId?: string;
|
|
@@ -179,47 +187,78 @@ export const sendBoost = async ({
|
|
|
179
187
|
}
|
|
180
188
|
|
|
181
189
|
// Preserve the issuer's payload without decrypting or counter-signing it.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
to,
|
|
193
|
-
credentialInstance,
|
|
194
|
-
metadata,
|
|
195
|
-
activityId,
|
|
196
|
-
integrationId
|
|
190
|
+
// Contract auto-boosts are signed by the writer, whose profile owns the status list.
|
|
191
|
+
const persistDelivery = async (
|
|
192
|
+
transaction?: Transaction
|
|
193
|
+
): Promise<CredentialInstance> => {
|
|
194
|
+
const stored = await traceDb('storeCredential', () =>
|
|
195
|
+
storeCredential(
|
|
196
|
+
credential,
|
|
197
|
+
transaction,
|
|
198
|
+
contractWriterProfileId ?? fromProfile.profileId
|
|
199
|
+
)
|
|
197
200
|
);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
201
|
+
if (!contractTerms) {
|
|
202
|
+
await traceDb('createRelationships', async () => {
|
|
203
|
+
await createBoostInstanceOfRelationship(stored, boost, transaction);
|
|
204
|
+
await createSentCredentialRelationship(
|
|
205
|
+
from,
|
|
206
|
+
to,
|
|
207
|
+
stored,
|
|
208
|
+
metadata,
|
|
209
|
+
activityId,
|
|
210
|
+
integrationId,
|
|
211
|
+
transaction
|
|
212
|
+
);
|
|
213
|
+
if (
|
|
214
|
+
listingId &&
|
|
215
|
+
(!isAppStoreListingIssuer(from) ||
|
|
216
|
+
from.listing.listing_id !== listingId)
|
|
217
|
+
) {
|
|
218
|
+
await createListingSentCredentialRelationship(
|
|
219
|
+
listingId,
|
|
220
|
+
to,
|
|
221
|
+
stored,
|
|
222
|
+
metadata,
|
|
223
|
+
activityId,
|
|
224
|
+
integrationId,
|
|
225
|
+
transaction
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
213
229
|
}
|
|
230
|
+
return stored;
|
|
231
|
+
};
|
|
232
|
+
const credentialInstance = contractTerms
|
|
233
|
+
? await persistDelivery()
|
|
234
|
+
: await neogma.getTransaction(null, persistDelivery);
|
|
214
235
|
|
|
236
|
+
try {
|
|
215
237
|
if (contractTerms) {
|
|
216
|
-
await
|
|
217
|
-
|
|
218
|
-
|
|
238
|
+
await traceDb('createRelationships', () =>
|
|
239
|
+
createContractCredentialRelationships({
|
|
240
|
+
issuer: from,
|
|
241
|
+
writerProfileId: contractWriterProfileId ?? fromProfile.profileId,
|
|
242
|
+
recipient: to,
|
|
243
|
+
credential: credentialInstance,
|
|
244
|
+
boost,
|
|
245
|
+
terms: contractTerms,
|
|
246
|
+
metadata,
|
|
247
|
+
activityId,
|
|
248
|
+
integrationId,
|
|
249
|
+
listingId,
|
|
250
|
+
allowConsumedOneTime,
|
|
251
|
+
})
|
|
219
252
|
);
|
|
220
253
|
}
|
|
221
|
-
})
|
|
222
|
-
|
|
254
|
+
} catch (error) {
|
|
255
|
+
try {
|
|
256
|
+
await releaseUnlinkedCredential(credentialInstance.id);
|
|
257
|
+
} catch {
|
|
258
|
+
console.error('BOOST_UNLINKED_CREDENTIAL_CLEANUP_FAILED');
|
|
259
|
+
}
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
223
262
|
if (autoAcceptCredential) {
|
|
224
263
|
await acceptCredential(to, getCredentialUri(credentialInstance.id, domain), {
|
|
225
264
|
skipNotification,
|
|
@@ -349,11 +388,21 @@ export const prepareCredentialFromBoost = async (
|
|
|
349
388
|
recipientDid?: string;
|
|
350
389
|
recipientName?: string;
|
|
351
390
|
issuerDid?: string;
|
|
391
|
+
/** Transient issuance body for a display-only managed anchor; never persisted. */
|
|
392
|
+
credential?: UnsignedVC | VC;
|
|
352
393
|
} = {}
|
|
353
394
|
): Promise<UnsignedVC> => {
|
|
354
395
|
const { templateData, recipientDid, recipientName, issuerDid } = options;
|
|
355
396
|
|
|
356
|
-
let boostJsonString =
|
|
397
|
+
let boostJsonString = options.credential
|
|
398
|
+
? convertCredentialToBoostTemplateJSON(options.credential)
|
|
399
|
+
: boost.dataValues.boost;
|
|
400
|
+
if (!options.credential && isDisplayOnlyAnchorTemplate(JSON.parse(boostJsonString))) {
|
|
401
|
+
throw new TRPCError({
|
|
402
|
+
code: 'BAD_REQUEST',
|
|
403
|
+
message: 'Display-only anchors require an issuance credential in the request.',
|
|
404
|
+
});
|
|
405
|
+
}
|
|
357
406
|
const allowAutoAppendEvidence = shouldAutoAppendTemplateEvidence(boostJsonString);
|
|
358
407
|
|
|
359
408
|
// Auto-inject known system variables into templateData
|
|
@@ -529,7 +578,6 @@ const credentialContextSupportsDynamicEvidence = (credential: UnsignedVC): boole
|
|
|
529
578
|
* Throws TRPCError if not found.
|
|
530
579
|
*/
|
|
531
580
|
export const resolveBoostByUri = async (boostUri: string): Promise<BoostInstance> => {
|
|
532
|
-
const { TRPCError } = await import('@trpc/server');
|
|
533
581
|
const { getBoostByUri } = await import('@accesslayer/boost/read');
|
|
534
582
|
|
|
535
583
|
const boost = await getBoostByUri(boostUri);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { environment } from '@environment';
|
|
3
|
+
|
|
4
|
+
/** Deployment-owned origins keep queued links on the same environment as the campaign. */
|
|
5
|
+
export const getCampaignAppOrigin = (tenantId: string): string => {
|
|
6
|
+
try {
|
|
7
|
+
const origins = z
|
|
8
|
+
.record(z.string(), z.string())
|
|
9
|
+
.parse(JSON.parse(environment.CAMPAIGN_APP_ORIGINS ?? '{}'));
|
|
10
|
+
const origin = new URL(origins[tenantId]!);
|
|
11
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname);
|
|
12
|
+
if (
|
|
13
|
+
(origin.protocol !== 'https:' && !(local && origin.protocol === 'http:')) ||
|
|
14
|
+
origin.username ||
|
|
15
|
+
origin.password ||
|
|
16
|
+
origin.pathname !== '/' ||
|
|
17
|
+
origin.search ||
|
|
18
|
+
origin.hash
|
|
19
|
+
)
|
|
20
|
+
throw new Error('Invalid origin');
|
|
21
|
+
return origin.origin;
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error('Campaign app origin is not configured for this tenant');
|
|
24
|
+
}
|
|
25
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Campaign } from '@learncard/types';
|
|
2
|
+
import { resolveTenantFromRequest } from '@learncard/email-templates';
|
|
3
|
+
import { environment } from '@environment';
|
|
4
|
+
import { claimCampaignEmail, acknowledgeCampaignEmail } from '@accesslayer/campaign/email';
|
|
5
|
+
import { PostmarkAdapter } from '../services/delivery/adapters/postmark.adapter';
|
|
6
|
+
import { signCampaignUnsubscribeToken } from './campaign-unsubscribe.helpers';
|
|
7
|
+
import { getCampaignAppOrigin } from './campaign-app-origin.helpers';
|
|
8
|
+
|
|
9
|
+
/** Campaign delivery must never fall through to a logging or transactional adapter. */
|
|
10
|
+
export const assertCampaignEmailConfigured = (): void => {
|
|
11
|
+
if (!environment.POSTMARK_API_KEY || !environment.DOMAIN_NAME)
|
|
12
|
+
throw new Error('Campaign email requires Postmark and a public Brain domain');
|
|
13
|
+
const stream = environment.POSTMARK_BROADCAST_STREAM;
|
|
14
|
+
if (!stream || stream.toLowerCase() === 'outbound')
|
|
15
|
+
throw new Error('Campaign email requires a broadcast stream');
|
|
16
|
+
signCampaignUnsubscribeToken('00000000-0000-4000-8000-000000000000', 'configuration-check');
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const campaignEmailLinks = (
|
|
20
|
+
campaign: Campaign,
|
|
21
|
+
unsubscribeId: string
|
|
22
|
+
): { ctaUrl?: string; unsubscribeUrl: string; oneClickUrl: string } => {
|
|
23
|
+
const token = signCampaignUnsubscribeToken(unsubscribeId, campaign.id);
|
|
24
|
+
let ctaUrl: string | undefined;
|
|
25
|
+
if (campaign.channels.includes('inApp') && campaign.message?.enabled) {
|
|
26
|
+
const tenantId = campaign.tenantId ?? resolveTenantFromRequest({}).id;
|
|
27
|
+
const cta = new URL(`/m/${campaign.id}`, getCampaignAppOrigin(tenantId));
|
|
28
|
+
cta.searchParams.set('utm_source', 'email');
|
|
29
|
+
cta.searchParams.set('utm_campaign', campaign.id);
|
|
30
|
+
ctaUrl = cta.toString();
|
|
31
|
+
}
|
|
32
|
+
const domain = environment.DOMAIN_NAME?.replace(/%3A/gi, ':').replace(/\/trpc\/?$/, '');
|
|
33
|
+
if (!domain) throw new Error('Campaign unsubscribe endpoint is not configured');
|
|
34
|
+
const api = new URL(`https://${domain}`);
|
|
35
|
+
if (api.username || api.password || api.pathname !== '/' || api.search || api.hash)
|
|
36
|
+
throw new Error('Invalid Brain domain');
|
|
37
|
+
if (['localhost', '127.0.0.1', '[::1]'].includes(api.hostname)) api.protocol = 'http:';
|
|
38
|
+
const unsubscribe = new URL('/campaigns/unsubscribe/browser', api);
|
|
39
|
+
unsubscribe.searchParams.set('token', token);
|
|
40
|
+
const oneClick = new URL('/campaigns/unsubscribe', api);
|
|
41
|
+
oneClick.searchParams.set('token', token);
|
|
42
|
+
return {
|
|
43
|
+
...(ctaUrl ? { ctaUrl } : {}),
|
|
44
|
+
unsubscribeUrl: unsubscribe.toString(),
|
|
45
|
+
oneClickUrl: oneClick.toString(),
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export class CampaignEmailDeliveryError extends Error {}
|
|
50
|
+
|
|
51
|
+
/** One adapter per wave keeps stream validation ahead of every durable claim. */
|
|
52
|
+
export const prepareCampaignEmailDelivery = async (): Promise<PostmarkAdapter> => {
|
|
53
|
+
assertCampaignEmailConfigured();
|
|
54
|
+
const adapter = new PostmarkAdapter(environment.POSTMARK_API_KEY!);
|
|
55
|
+
await adapter.assertBroadcastStream(environment.POSTMARK_BROADCAST_STREAM);
|
|
56
|
+
return adapter;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const sendCampaignEmail = async (
|
|
60
|
+
campaign: Campaign,
|
|
61
|
+
profileId: string,
|
|
62
|
+
test = false,
|
|
63
|
+
preparedAdapter?: PostmarkAdapter
|
|
64
|
+
): Promise<boolean> => {
|
|
65
|
+
const adapter = preparedAdapter ?? (await prepareCampaignEmailDelivery());
|
|
66
|
+
let deliver: (() => Promise<void>) | undefined;
|
|
67
|
+
const recipient = await claimCampaignEmail(
|
|
68
|
+
campaign.id,
|
|
69
|
+
campaign.createdAt,
|
|
70
|
+
profileId,
|
|
71
|
+
test,
|
|
72
|
+
async current => {
|
|
73
|
+
try {
|
|
74
|
+
const links = campaignEmailLinks(current.campaign, current.unsubscribeId);
|
|
75
|
+
deliver = await adapter.prepareCampaignEmail({
|
|
76
|
+
contactMethod: { type: 'email', value: current.email },
|
|
77
|
+
templateId: 'campaign-announcement',
|
|
78
|
+
templateModel: {
|
|
79
|
+
...current.campaign.email!,
|
|
80
|
+
ctaUrl: links.ctaUrl,
|
|
81
|
+
unsubscribeUrl: links.unsubscribeUrl,
|
|
82
|
+
},
|
|
83
|
+
subject: current.campaign.email!.subject,
|
|
84
|
+
messageStream: environment.POSTMARK_BROADCAST_STREAM,
|
|
85
|
+
headers: [
|
|
86
|
+
{ Name: 'List-Unsubscribe', Value: `<${links.oneClickUrl}>` },
|
|
87
|
+
{ Name: 'List-Unsubscribe-Post', Value: 'List-Unsubscribe=One-Click' },
|
|
88
|
+
],
|
|
89
|
+
branding: resolveTenantFromRequest({
|
|
90
|
+
'x-tenant-id': current.campaign.tenantId,
|
|
91
|
+
}).emailBranding,
|
|
92
|
+
locale: current.locale,
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
throw new Error('Campaign email preparation failed');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
if (!recipient) return false;
|
|
100
|
+
if (!deliver) throw new Error('Campaign email was not prepared');
|
|
101
|
+
try {
|
|
102
|
+
await deliver();
|
|
103
|
+
if (!test) await acknowledgeCampaignEmail(campaign.id, campaign.createdAt, profileId);
|
|
104
|
+
} catch {
|
|
105
|
+
// Provider errors may contain recipient addresses and bearer unsubscribe URLs.
|
|
106
|
+
throw new CampaignEmailDeliveryError(
|
|
107
|
+
test
|
|
108
|
+
? 'Campaign test email delivery failed'
|
|
109
|
+
: 'Campaign email delivery failed; reservation retained'
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
};
|