@learncard/network-brain-service 4.1.3 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +14 -0
- package/CHANGELOG.md +143 -0
- package/README.md +27 -1
- package/bootstrapLambda.test.ts +70 -32
- package/config/config.dev.json +1 -0
- package/config/config.example.json +2 -1
- package/config/config.production.json +1 -0
- package/config/config.scouts.dev.json +1 -0
- package/config/config.scouts.production.json +1 -0
- package/consentFlowDeadlineLambda.test.ts +108 -0
- package/consentFlowDeadlineLambda.ts +18 -0
- package/consentFlowDeadlineLambdaApp.ts +14 -0
- package/dist/accesslayer/boost/create.d.ts +8 -2
- package/dist/accesslayer/boost/create.d.ts.map +1 -1
- package/dist/accesslayer/campaign/audience.d.ts +4 -4
- package/dist/accesslayer/campaign/audience.d.ts.map +1 -1
- package/dist/accesslayer/campaign/create.d.ts +1 -1
- package/dist/accesslayer/campaign/create.d.ts.map +1 -1
- package/dist/accesslayer/campaign/delivery.d.ts +5 -0
- package/dist/accesslayer/campaign/delivery.d.ts.map +1 -0
- package/dist/accesslayer/campaign/dispatch.d.ts +5 -0
- package/dist/accesslayer/campaign/dispatch.d.ts.map +1 -0
- package/dist/accesslayer/campaign/dispatch.test.d.ts +2 -0
- package/dist/accesslayer/campaign/dispatch.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/email.d.ts +10 -0
- package/dist/accesslayer/campaign/email.d.ts.map +1 -0
- package/dist/accesslayer/campaign/email.test.d.ts +2 -0
- package/dist/accesslayer/campaign/email.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/preferences.d.ts +5 -0
- package/dist/accesslayer/campaign/preferences.d.ts.map +1 -0
- package/dist/accesslayer/campaign/read.d.ts.map +1 -1
- package/dist/accesslayer/campaign/test.d.ts +5 -0
- package/dist/accesslayer/campaign/test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/test.test.d.ts +2 -0
- package/dist/accesslayer/campaign/test.test.d.ts.map +1 -0
- package/dist/accesslayer/campaign/update.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/delete.d.ts +1 -1
- package/dist/accesslayer/consentflowcontract/delete.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +2 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +19 -4
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +6 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/versions.d.ts +68 -0
- package/dist/accesslayer/consentflowcontract/versions.d.ts.map +1 -0
- package/dist/accesslayer/credential/create.d.ts +3 -1
- package/dist/accesslayer/credential/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/delete.d.ts +1 -0
- package/dist/accesslayer/credential/delete.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/create.d.ts +15 -3
- package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/read.d.ts +2 -0
- package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/read.d.ts +1 -0
- package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/create.d.ts +1 -0
- package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/refresh.d.ts +7 -0
- package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -1
- package/dist/app.d.ts +11 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +61 -1
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/campaign-unsubscribe.d.ts +7 -0
- package/dist/campaign-unsubscribe.d.ts.map +1 -0
- package/dist/campaign-unsubscribe.test.d.ts +2 -0
- package/dist/campaign-unsubscribe.test.d.ts.map +1 -0
- package/dist/config/environment.d.ts +42 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/stageConfig.d.ts +4 -0
- package/dist/config/stageConfig.d.ts.map +1 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/helpers/boost.helpers.d.ts +4 -1
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/campaign-app-origin.helpers.d.ts +2 -0
- package/dist/helpers/campaign-app-origin.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-email.helpers.d.ts +13 -0
- package/dist/helpers/campaign-email.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-notifications.helpers.d.ts +7 -0
- package/dist/helpers/campaign-notifications.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-queue.helpers.d.ts +7 -0
- package/dist/helpers/campaign-queue.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-queue.helpers.test.d.ts +2 -0
- package/dist/helpers/campaign-queue.helpers.test.d.ts.map +1 -0
- package/dist/helpers/campaign-unsubscribe-page.d.ts +2 -0
- package/dist/helpers/campaign-unsubscribe-page.d.ts.map +1 -0
- package/dist/helpers/campaign-unsubscribe.helpers.d.ts +7 -0
- package/dist/helpers/campaign-unsubscribe.helpers.d.ts.map +1 -0
- package/dist/helpers/campaign-webhook.helpers.test.d.ts +2 -0
- package/dist/helpers/campaign-webhook.helpers.test.d.ts.map +1 -0
- package/dist/helpers/consentflow-deadline.helpers.d.ts +5 -0
- package/dist/helpers/consentflow-deadline.helpers.d.ts.map +1 -0
- package/dist/helpers/consentflow-legacy-migration.helpers.d.ts +2 -0
- package/dist/helpers/consentflow-legacy-migration.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-events.helpers.d.ts.map +1 -1
- package/dist/helpers/contract.helpers.d.ts +1 -1
- package/dist/helpers/contract.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-display.helpers.d.ts +5 -0
- package/dist/helpers/credential-display.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-display.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-display.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-refresh-materiality.helpers.d.ts +4 -0
- package/dist/helpers/credential-refresh-materiality.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-refresh.helpers.d.ts +30 -1
- package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/credential-status-mutation.helpers.d.ts +5 -0
- package/dist/helpers/credential-status-mutation.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-status-mutation.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-status-mutation.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential.helpers.d.ts +1 -1
- package/dist/helpers/credential.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox-refresh.helpers.d.ts +4 -2
- package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox.helpers.d.ts +1 -0
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/learnCard.helpers.d.ts +1 -0
- package/dist/helpers/learnCard.helpers.d.ts.map +1 -1
- package/dist/helpers/managed-status.helpers.d.ts +7 -0
- package/dist/helpers/managed-status.helpers.d.ts.map +1 -0
- package/dist/helpers/managed-status.helpers.test.d.ts +2 -0
- package/dist/helpers/managed-status.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts +2 -0
- package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notification-webhook-fetch.helpers.d.ts +13 -0
- package/dist/helpers/notification-webhook-fetch.helpers.d.ts.map +1 -0
- package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts +2 -0
- package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts.map +1 -0
- package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
- package/dist/helpers/notifications.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.d.ts +13 -5
- package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.test.d.ts +2 -0
- package/dist/helpers/refresh-send-intent.helpers.test.d.ts.map +1 -0
- package/dist/helpers/send-contract.helpers.d.ts +11 -0
- package/dist/helpers/send-contract.helpers.d.ts.map +1 -0
- package/dist/helpers/status-list.helpers.d.ts +12 -1
- package/dist/helpers/status-list.helpers.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/models/ConsentFlowContract.d.ts.map +1 -1
- package/dist/models/ConsentFlowContractVersion.d.ts +12 -0
- package/dist/models/ConsentFlowContractVersion.d.ts.map +1 -0
- package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
- package/dist/models/Credential.d.ts +2 -0
- package/dist/models/Credential.d.ts.map +1 -1
- package/dist/models/CredentialRefresh.d.ts +5 -0
- package/dist/models/CredentialRefresh.d.ts.map +1 -1
- package/dist/models/Integration.d.ts +1 -1
- package/dist/models/Profile.d.ts.map +1 -1
- package/dist/models/campaign-constraints.d.ts.map +1 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/activity.d.ts +15 -5
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/app-store.d.ts +15 -5
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/auth-grants.d.ts +12 -2
- package/dist/routes/auth-grants.d.ts.map +1 -1
- package/dist/routes/bench.d.ts +11 -1
- package/dist/routes/bench.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +70 -15
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/campaigns.d.ts +183 -51
- package/dist/routes/campaigns.d.ts.map +1 -1
- package/dist/routes/claim-hooks.d.ts +11 -1
- package/dist/routes/claim-hooks.d.ts.map +1 -1
- package/dist/routes/contact-methods.d.ts +11 -1
- package/dist/routes/contact-methods.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +374 -8
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-input-privacy.test.d.ts +2 -0
- package/dist/routes/credential-input-privacy.test.d.ts.map +1 -0
- package/dist/routes/credential-refreshes.d.ts +210 -1
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credential-share-previews.d.ts +11 -1
- package/dist/routes/credential-share-previews.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +132 -3
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/did-metadata.d.ts +11 -1
- package/dist/routes/did-metadata.d.ts.map +1 -1
- package/dist/routes/federation.d.ts +11 -1
- package/dist/routes/federation.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +54 -19
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +100 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/integrations.d.ts +14 -4
- package/dist/routes/integrations.d.ts.map +1 -1
- package/dist/routes/presentations.d.ts +11 -1
- package/dist/routes/presentations.d.ts.map +1 -1
- package/dist/routes/profile-manager.d.ts +11 -1
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts +11 -1
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/public-share-links.d.ts +22 -2
- package/dist/routes/public-share-links.d.ts.map +1 -1
- package/dist/routes/share-links.d.ts +36 -16
- package/dist/routes/share-links.d.ts.map +1 -1
- package/dist/routes/skill-frameworks.d.ts +11 -1
- package/dist/routes/skill-frameworks.d.ts.map +1 -1
- package/dist/routes/skills.d.ts +11 -1
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/storage.d.ts +37 -23
- package/dist/routes/storage.d.ts.map +1 -1
- package/dist/routes/test.d.ts +79 -3
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/utilities.d.ts +11 -1
- package/dist/routes/utilities.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts +11 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.d.ts +3 -0
- package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
- package/dist/services/delivery/delivery.service.d.ts +10 -0
- package/dist/services/delivery/delivery.service.d.ts.map +1 -1
- package/dist/types/activity.d.ts +3 -3
- package/dist/types/app-manifest.d.ts +10 -10
- package/dist/types/campaign.d.ts +13 -1
- package/dist/types/campaign.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +175 -7
- package/dist/types/consentflowcontract.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +27 -0
- package/dist/types/credential-refresh.d.ts.map +1 -1
- package/dist/types/credential.d.ts +5 -0
- package/dist/types/credential.d.ts.map +1 -1
- package/dist/types/integration.d.ts +3 -3
- package/lambda.ts +44 -14
- package/lambdaApp.ts +20 -0
- package/package.json +20 -19
- package/project.json +5 -0
- package/scripts/migrate-consent-flow-versions.ts +9 -0
- package/scripts/report-send-template-exposure.ts +47 -0
- package/scripts/verify-python-contracts.ts +16 -1
- package/serverless-local.yml +15 -2
- package/serverless.function-env.cjs +4 -0
- package/serverless.local.yml +12 -0
- package/serverless.yml +48 -3
- package/src/accesslayer/boost/create.ts +62 -7
- package/src/accesslayer/campaign/README.md +275 -6
- package/src/accesslayer/campaign/audience.ts +39 -7
- package/src/accesslayer/campaign/create.ts +4 -2
- package/src/accesslayer/campaign/delivery.ts +66 -0
- package/src/accesslayer/campaign/dispatch.test.ts +59 -0
- package/src/accesslayer/campaign/dispatch.ts +172 -0
- package/src/accesslayer/campaign/email.test.ts +73 -0
- package/src/accesslayer/campaign/email.ts +91 -0
- package/src/accesslayer/campaign/preferences.ts +53 -0
- package/src/accesslayer/campaign/read.ts +3 -1
- package/src/accesslayer/campaign/state.ts +1 -1
- package/src/accesslayer/campaign/test.test.ts +31 -0
- package/src/accesslayer/campaign/test.ts +75 -0
- package/src/accesslayer/campaign/update.ts +15 -4
- package/src/accesslayer/consentflowcontract/create.ts +20 -1
- package/src/accesslayer/consentflowcontract/delete.ts +121 -3
- package/src/accesslayer/consentflowcontract/read.ts +15 -9
- package/src/accesslayer/consentflowcontract/relationships/create.ts +70 -36
- package/src/accesslayer/consentflowcontract/relationships/read.ts +305 -73
- package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +1 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +269 -98
- package/src/accesslayer/consentflowcontract/versions.ts +790 -0
- package/src/accesslayer/credential/create.ts +33 -9
- package/src/accesslayer/credential/delete.ts +45 -0
- package/src/accesslayer/credential/relationships/create.ts +102 -47
- package/src/accesslayer/credential/relationships/update.ts +8 -116
- package/src/accesslayer/credential-refresh/read.ts +26 -2
- package/src/accesslayer/credential-refresh/update.ts +6 -2
- package/src/accesslayer/inbox-credential/create.ts +7 -1
- package/src/accesslayer/inbox-credential/refresh.ts +14 -4
- package/src/campaign-unsubscribe.test.ts +158 -0
- package/src/campaign-unsubscribe.ts +86 -0
- package/src/config/environment.test.ts +52 -0
- package/src/config/environment.ts +58 -0
- package/src/config/serverlessFunctionEnv.test.ts +59 -0
- package/src/credential-refresh.ts +1 -0
- package/src/docker-entry.ts +26 -1
- package/src/helpers/boost-storage.helpers.test.ts +107 -41
- package/src/helpers/boost.helpers.ts +88 -40
- package/src/helpers/campaign-app-origin.helpers.ts +25 -0
- package/src/helpers/campaign-email.helpers.ts +113 -0
- package/src/helpers/campaign-notifications.helpers.ts +127 -0
- package/src/helpers/campaign-queue.helpers.test.ts +442 -0
- package/src/helpers/campaign-queue.helpers.ts +122 -0
- package/src/helpers/campaign-unsubscribe-page.ts +107 -0
- package/src/helpers/campaign-unsubscribe.helpers.ts +56 -0
- package/src/helpers/campaign-webhook.helpers.test.ts +137 -0
- package/src/helpers/consentflow-deadline.helpers.ts +22 -0
- package/src/helpers/consentflow-legacy-migration.helpers.ts +179 -0
- package/src/helpers/contract-events.helpers.ts +35 -3
- package/src/helpers/contract-requests.helpers.ts +1 -1
- package/src/helpers/contract.helpers.ts +5 -1
- package/src/helpers/credential-display.helpers.test.ts +68 -0
- package/src/helpers/credential-display.helpers.ts +15 -0
- package/src/helpers/credential-refresh-email.helpers.ts +5 -12
- package/src/helpers/credential-refresh-materiality.helpers.test.ts +138 -1
- package/src/helpers/credential-refresh-materiality.helpers.ts +62 -1
- package/src/helpers/credential-refresh-proof.helpers.test.ts +1 -1
- package/src/helpers/credential-refresh-proof.helpers.ts +2 -2
- package/src/helpers/credential-refresh.helpers.ts +1166 -376
- package/src/helpers/credential-status-mutation.helpers.test.ts +155 -0
- package/src/helpers/credential-status-mutation.helpers.ts +166 -0
- package/src/helpers/credential.helpers.ts +19 -11
- package/src/helpers/finalize-inbox.helpers.ts +1 -1
- package/src/helpers/inbox-refresh.helpers.ts +34 -8
- package/src/helpers/inbox.helpers.ts +2 -0
- package/src/helpers/learnCard.helpers.ts +23 -0
- package/src/helpers/managed-status.helpers.test.ts +101 -0
- package/src/helpers/managed-status.helpers.ts +147 -0
- package/src/helpers/notification-delivery-boundary.helpers.test.ts +114 -0
- package/src/helpers/notification-webhook-fetch.helpers.test.ts +228 -0
- package/src/helpers/notification-webhook-fetch.helpers.ts +166 -0
- package/src/helpers/notificationQueue.helpers.ts +5 -0
- package/src/helpers/notifications.helpers.ts +19 -12
- package/src/helpers/refresh-send-intent.helpers.test.ts +60 -0
- package/src/helpers/refresh-send-intent.helpers.ts +81 -18
- package/src/helpers/send-contract.helpers.ts +36 -0
- package/src/helpers/status-list.helpers.test.ts +29 -12
- package/src/helpers/status-list.helpers.ts +329 -20
- package/src/models/ConsentFlowContract.ts +3 -0
- package/src/models/ConsentFlowContractVersion.ts +50 -0
- package/src/models/ConsentFlowTerms.ts +4 -0
- package/src/models/Credential.ts +2 -0
- package/src/models/CredentialRefresh.ts +2 -0
- package/src/models/Profile.ts +2 -0
- package/src/models/campaign-constraints.ts +13 -8
- package/src/models/index.ts +2 -0
- package/src/openapi.ts +21 -0
- package/src/routes/app-store.ts +2 -1
- package/src/routes/boosts.ts +219 -137
- package/src/routes/campaigns.ts +98 -19
- package/src/routes/contracts.ts +433 -115
- package/src/routes/credential-input-privacy.test.ts +92 -0
- package/src/routes/credential-refreshes.ts +273 -2
- package/src/routes/federation.ts +7 -2
- package/src/routes/inbox.ts +6 -1
- package/src/routes/index.ts +31 -2
- package/src/routes/workflows.ts +61 -46
- package/src/services/delivery/adapters/postmark.adapter.test.ts +70 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +84 -11
- package/src/services/delivery/delivery.service.ts +4 -0
- package/src/types/campaign.ts +14 -1
- package/src/types/consentflowcontract.ts +43 -2
- package/src/types/credential-refresh.ts +11 -1
- package/src/types/credential.ts +2 -0
- package/test/boost-status-cleanup.spec.ts +123 -0
- package/test/campaigns-email.unit.spec.ts +268 -0
- package/test/campaigns.spec.ts +804 -30
- package/test/consent-autoboost-idempotency.spec.ts +432 -0
- package/test/consent-data-boundaries.spec.ts +322 -26
- package/test/consent-legacy-migration.spec.ts +315 -0
- package/test/consent-recipients.spec.ts +7 -7
- package/test/consent-version-updates.spec.ts +761 -0
- package/test/consentflow.spec.ts +698 -226
- package/test/contract-requests.spec.ts +55 -9
- package/test/credential-refresh-allocation.spec.ts +212 -14
- package/test/credential-refresh-email.spec.ts +6 -10
- package/test/credential-refresh-endpoint.spec.ts +3 -0
- package/test/credential-refresh-publication.spec.ts +52 -15
- package/test/credential-storage.neo4j.spec.ts +33 -6
- package/test/helpers/legacy-autoboost.ts +48 -0
- package/test/holder-mode-send.spec.ts +710 -0
- package/test/inbox-refresh.spec.ts +105 -0
- package/test/inbox-security.e2e.ts +18 -22
- package/test/managed-status-revocation.spec.ts +970 -0
- package/test/notifications-worker.spec.ts +31 -25
- package/test/refresh-send.spec.ts +334 -5
- package/test/smart-resume-consent.spec.ts +1 -1
- package/test/verification/campaigns.ts +40 -0
- package/vitest.campaigns.config.ts +2 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import type { Transaction } from 'neo4j-driver';
|
|
3
|
+
import { BitstringStatusListEntryValidator, type BitstringStatusListEntry } from '@learncard/types';
|
|
4
|
+
import { getBitstringStatusListEntries } from '@learncard/helpers';
|
|
5
|
+
|
|
6
|
+
import { neogma } from '@instance';
|
|
7
|
+
import {
|
|
8
|
+
allocateStatusListEntry,
|
|
9
|
+
isNetworkStatusEntry,
|
|
10
|
+
validateStatusEntries,
|
|
11
|
+
} from './status-list.helpers';
|
|
12
|
+
import { getCredentialRefresh } from '@accesslayer/credential-refresh';
|
|
13
|
+
import { reconcileCredentialStatus } from './credential-status-mutation.helpers';
|
|
14
|
+
|
|
15
|
+
export const prepareRefreshStatusEntries = async (
|
|
16
|
+
refreshId: string,
|
|
17
|
+
ownerProfileId: string,
|
|
18
|
+
domain: string,
|
|
19
|
+
purposes: ('revocation' | 'suspension')[] = []
|
|
20
|
+
): Promise<BitstringStatusListEntry[]> => {
|
|
21
|
+
const refresh = await getCredentialRefresh(refreshId);
|
|
22
|
+
if (refresh?.statusEntries)
|
|
23
|
+
return BitstringStatusListEntryValidator.array().parse(JSON.parse(refresh.statusEntries));
|
|
24
|
+
const entries = await Promise.all(
|
|
25
|
+
[...new Set(['revocation' as const, ...purposes])].map(purpose =>
|
|
26
|
+
allocateStatusListEntry(ownerProfileId, domain, purpose)
|
|
27
|
+
)
|
|
28
|
+
);
|
|
29
|
+
await bindRefreshStatusEntries(refreshId, entries, ownerProfileId, domain, undefined, {
|
|
30
|
+
freshlyAllocated: true,
|
|
31
|
+
});
|
|
32
|
+
return entries;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const coordinates = (entries: BitstringStatusListEntry[]): string =>
|
|
36
|
+
JSON.stringify(
|
|
37
|
+
entries
|
|
38
|
+
.map(entry => [entry.statusListCredential, entry.statusListIndex, entry.statusPurpose])
|
|
39
|
+
.sort()
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** Bind public coordinates under list locks; an index can belong to only one refresh. */
|
|
43
|
+
export const bindRefreshStatusEntries = async (
|
|
44
|
+
refreshId: string,
|
|
45
|
+
entries: BitstringStatusListEntry[],
|
|
46
|
+
ownerProfileId: string,
|
|
47
|
+
domain: string,
|
|
48
|
+
transaction?: Transaction,
|
|
49
|
+
options: { freshlyAllocated?: boolean } = {}
|
|
50
|
+
): Promise<void> => {
|
|
51
|
+
await validateStatusEntries(entries, ownerProfileId, domain);
|
|
52
|
+
await neogma.getTransaction(transaction ?? null, async tx => {
|
|
53
|
+
const result = await tx.run(
|
|
54
|
+
`MATCH (refresh:CredentialRefresh {refreshId: $refreshId, issuerProfileId: $ownerProfileId})
|
|
55
|
+
OPTIONAL MATCH (refresh)-[:ROOT]->(root:Credential)
|
|
56
|
+
FOREACH (_ IN CASE WHEN root IS NULL THEN [] ELSE [1] END |
|
|
57
|
+
SET root._statusMutationLock = true REMOVE root._statusMutationLock)
|
|
58
|
+
SET refresh._statusBindingLock = true
|
|
59
|
+
REMOVE refresh._statusBindingLock
|
|
60
|
+
RETURN refresh.statusEntries AS entries, root.id AS rootId`,
|
|
61
|
+
{ refreshId, ownerProfileId }
|
|
62
|
+
);
|
|
63
|
+
const row = result.records[0];
|
|
64
|
+
if (!row)
|
|
65
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Credential refresh not found' });
|
|
66
|
+
const stored: unknown = row.get('entries');
|
|
67
|
+
if (typeof stored === 'string') {
|
|
68
|
+
if (
|
|
69
|
+
coordinates(BitstringStatusListEntryValidator.array().parse(JSON.parse(stored))) !==
|
|
70
|
+
coordinates(entries)
|
|
71
|
+
) {
|
|
72
|
+
throw new TRPCError({
|
|
73
|
+
code: 'BAD_REQUEST',
|
|
74
|
+
message: 'Credential status does not match allocated entries',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// A stable order prevents deadlocks when a credential has multiple lists.
|
|
80
|
+
for (const entry of [...entries].sort((a, b) =>
|
|
81
|
+
a.statusListCredential.localeCompare(b.statusListCredential)
|
|
82
|
+
)) {
|
|
83
|
+
if (!isNetworkStatusEntry(entry, domain)) continue;
|
|
84
|
+
const binding = await tx.run(
|
|
85
|
+
`MATCH (list:BitstringStatusList {statusListCredential: $url})
|
|
86
|
+
SET list._statusBindingLock = true
|
|
87
|
+
REMOVE list._statusBindingLock
|
|
88
|
+
WITH list
|
|
89
|
+
OPTIONAL MATCH (list)-[:HAS_STATUS_BINDING]->(binding:CredentialStatusBinding {index: $index})
|
|
90
|
+
RETURN binding.refreshId AS refreshId, binding.credentialId AS credentialId`,
|
|
91
|
+
{ url: entry.statusListCredential, index: String(entry.statusListIndex) }
|
|
92
|
+
);
|
|
93
|
+
const bound: unknown = binding.records[0]?.get('refreshId');
|
|
94
|
+
if ((bound && bound !== refreshId) || binding.records[0]?.get('credentialId')) {
|
|
95
|
+
throw new TRPCError({
|
|
96
|
+
code: 'BAD_REQUEST',
|
|
97
|
+
message: 'Status list index is already bound to another credential',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
// Legacy ordinary deliveries predate index bindings. Do not let a repair
|
|
101
|
+
// or low-level managed send take over one of their signed coordinates.
|
|
102
|
+
const legacy = options.freshlyAllocated
|
|
103
|
+
? { records: [] }
|
|
104
|
+
: await tx.run(
|
|
105
|
+
`MATCH (:Profile {profileId: $ownerProfileId})-[:CREDENTIAL_SENT]->(credential:Credential)
|
|
106
|
+
WHERE coalesce(credential.refreshId, '') <> $refreshId
|
|
107
|
+
AND (credential.statusEntries CONTAINS $url OR credential.credential CONTAINS $url)
|
|
108
|
+
RETURN credential.statusEntries AS entries, credential.credential AS body`,
|
|
109
|
+
{ ownerProfileId, refreshId, url: entry.statusListCredential }
|
|
110
|
+
);
|
|
111
|
+
for (const legacyRow of legacy.records) {
|
|
112
|
+
const rawEntries: unknown = legacyRow.get('entries');
|
|
113
|
+
const body: unknown = legacyRow.get('body');
|
|
114
|
+
const existing: BitstringStatusListEntry[] =
|
|
115
|
+
typeof rawEntries === 'string'
|
|
116
|
+
? BitstringStatusListEntryValidator.array().parse(JSON.parse(rawEntries))
|
|
117
|
+
: typeof body === 'string'
|
|
118
|
+
? getBitstringStatusListEntries(JSON.parse(body))
|
|
119
|
+
: [];
|
|
120
|
+
if (
|
|
121
|
+
existing.some(
|
|
122
|
+
candidate =>
|
|
123
|
+
candidate.statusListCredential === entry.statusListCredential &&
|
|
124
|
+
Number(candidate.statusListIndex) === Number(entry.statusListIndex)
|
|
125
|
+
)
|
|
126
|
+
) {
|
|
127
|
+
throw new TRPCError({
|
|
128
|
+
code: 'BAD_REQUEST',
|
|
129
|
+
message: 'Status list index is already bound to another credential',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
await tx.run(
|
|
134
|
+
`MATCH (list:BitstringStatusList {statusListCredential: $url})
|
|
135
|
+
MERGE (list)-[:HAS_STATUS_BINDING]->(binding:CredentialStatusBinding {index: $index})
|
|
136
|
+
SET binding.refreshId = $refreshId`,
|
|
137
|
+
{ url: entry.statusListCredential, index: String(entry.statusListIndex), refreshId }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
await tx.run(
|
|
141
|
+
`MATCH (refresh:CredentialRefresh {refreshId: $refreshId}) SET refresh.statusEntries = $entries`,
|
|
142
|
+
{ refreshId, entries: JSON.stringify(entries) }
|
|
143
|
+
);
|
|
144
|
+
const rootId: unknown = row.get('rootId');
|
|
145
|
+
if (typeof rootId === 'string') await reconcileCredentialStatus(rootId, tx);
|
|
146
|
+
});
|
|
147
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Campaign } from '@learncard/types';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
fetch: vi.fn(),
|
|
6
|
+
acknowledge: vi.fn(),
|
|
7
|
+
getCampaign: vi.fn(),
|
|
8
|
+
recipient: vi.fn(),
|
|
9
|
+
environment: {
|
|
10
|
+
NODE_ENV: 'test',
|
|
11
|
+
IS_OFFLINE: false,
|
|
12
|
+
DOMAIN_NAME: 'brain.example',
|
|
13
|
+
NOTIFICATIONS_SERVICE_WEBHOOK_URL: 'https://inbox.example/notifications',
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('@environment', () => ({
|
|
18
|
+
environment: mocks.environment,
|
|
19
|
+
getNotificationRuntimeEnvironment: () => mocks.environment,
|
|
20
|
+
}));
|
|
21
|
+
vi.mock('@helpers/learnCard.helpers', () => ({
|
|
22
|
+
getDidWebLearnCard: async () => ({
|
|
23
|
+
id: { did: () => 'did:web:brain.example' },
|
|
24
|
+
invoke: { getDidAuthVp: async () => 'test-jwt' },
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('@helpers/did.helpers', () => ({
|
|
28
|
+
getDidWeb: (domain: string, profileId: string) => `did:web:${domain}:users:${profileId}`,
|
|
29
|
+
}));
|
|
30
|
+
vi.mock('@accesslayer/inbox-credential/relationships/create', () => ({
|
|
31
|
+
createWebhookSentRelationship: vi.fn(),
|
|
32
|
+
}));
|
|
33
|
+
vi.mock('@cache', () => ({ default: {} }));
|
|
34
|
+
// These suites exercise notification delivery without email transport or a database.
|
|
35
|
+
vi.mock('@helpers/campaign-email.helpers', () => ({ sendCampaignEmail: vi.fn() }));
|
|
36
|
+
vi.mock('@accesslayer/campaign/preferences', () => ({
|
|
37
|
+
getAnnouncementPreferences: async () => ({
|
|
38
|
+
announcementEmailOptOut: false,
|
|
39
|
+
announcementPushOptOut: false,
|
|
40
|
+
}),
|
|
41
|
+
}));
|
|
42
|
+
vi.mock('@accesslayer/campaign/read', () => ({ getCampaign: mocks.getCampaign }));
|
|
43
|
+
vi.mock('@accesslayer/campaign/test', () => ({ queueCampaignInAppTest: vi.fn() }));
|
|
44
|
+
vi.mock('@accesslayer/campaign/delivery', () => ({
|
|
45
|
+
getCampaignDeliveryRecipient: mocks.recipient,
|
|
46
|
+
acknowledgeCampaignNotification: mocks.acknowledge,
|
|
47
|
+
}));
|
|
48
|
+
vi.mock('@helpers/contract-events.helpers', () => ({
|
|
49
|
+
authorizeContractNotification: async () => true,
|
|
50
|
+
}));
|
|
51
|
+
vi.mock('@helpers/connectionPrompt.helpers', () => ({
|
|
52
|
+
acknowledgeConnectionPromptNotificationDelivery: vi.fn(),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
import { buildCampaignNotification } from './campaign-notifications.helpers';
|
|
56
|
+
import { sendNotification } from './notifications.helpers';
|
|
57
|
+
import { deliverQueuedNotification } from './notificationQueue.helpers';
|
|
58
|
+
|
|
59
|
+
const campaign = {
|
|
60
|
+
id: 'webhook-pilot',
|
|
61
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
62
|
+
status: 'live',
|
|
63
|
+
channels: ['notify'],
|
|
64
|
+
notification: { title: 'Explore', body: 'Find your next step' },
|
|
65
|
+
} as Campaign;
|
|
66
|
+
const recipient = {
|
|
67
|
+
profileId: 'learner',
|
|
68
|
+
did: 'did:web:brain.example:users:learner',
|
|
69
|
+
notificationsWebhook: 'https://receiver.example/notifications',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
vi.resetAllMocks();
|
|
74
|
+
vi.stubGlobal('fetch', mocks.fetch);
|
|
75
|
+
mocks.environment.NOTIFICATIONS_SERVICE_WEBHOOK_URL = 'https://inbox.example/notifications';
|
|
76
|
+
mocks.getCampaign.mockResolvedValue(campaign);
|
|
77
|
+
mocks.recipient.mockResolvedValue(recipient);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('shared notification delivery boundary', () => {
|
|
81
|
+
it.each(['ANNOUNCEMENT', 'BOOST_ACCEPTED'] as const)(
|
|
82
|
+
'blocks a private profile webhook for %s before POST or acknowledgement',
|
|
83
|
+
async type => {
|
|
84
|
+
const notification = await buildCampaignNotification(campaign, recipient);
|
|
85
|
+
notification.type = type;
|
|
86
|
+
notification.to.notificationsWebhook = 'http://10.0.1.10/internal';
|
|
87
|
+
|
|
88
|
+
await expect(
|
|
89
|
+
sendNotification(notification, {
|
|
90
|
+
propagateDirectWebhookTransportErrors: true,
|
|
91
|
+
})
|
|
92
|
+
).rejects.toThrow('public HTTP(S) destination');
|
|
93
|
+
expect(mocks.fetch).not.toHaveBeenCalled();
|
|
94
|
+
expect(mocks.acknowledge).not.toHaveBeenCalled();
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
it('does not trust an explicit queued webhook override', async () => {
|
|
99
|
+
const notification = await buildCampaignNotification(campaign, recipient);
|
|
100
|
+
notification.webhookUrl = 'http://169.254.169.254/latest/meta-data/';
|
|
101
|
+
await expect(deliverQueuedNotification(JSON.stringify(notification))).rejects.toThrow();
|
|
102
|
+
expect(mocks.fetch).not.toHaveBeenCalled();
|
|
103
|
+
expect(mocks.acknowledge).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('also guards the configured default destination', async () => {
|
|
107
|
+
const notification = await buildCampaignNotification(campaign, recipient);
|
|
108
|
+
delete notification.to.notificationsWebhook;
|
|
109
|
+
mocks.environment.NOTIFICATIONS_SERVICE_WEBHOOK_URL = 'http://[::1]/internal';
|
|
110
|
+
await expect(sendNotification(notification)).resolves.toBe(false);
|
|
111
|
+
expect(mocks.fetch).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { LookupAddress } from 'node:dns';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { PassThrough } from 'node:stream';
|
|
5
|
+
import type { RequestOptions } from 'node:https';
|
|
6
|
+
|
|
7
|
+
const mocks = vi.hoisted(() => ({ lookup: vi.fn(), request: vi.fn() }));
|
|
8
|
+
vi.mock('node:dns', () => ({ lookup: mocks.lookup }));
|
|
9
|
+
vi.mock('node:http', () => ({ request: mocks.request }));
|
|
10
|
+
vi.mock('node:https', () => ({ request: mocks.request }));
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
fetchNotificationWebhook,
|
|
14
|
+
isPublicNotificationAddress,
|
|
15
|
+
} from './notification-webhook-fetch.helpers';
|
|
16
|
+
|
|
17
|
+
const request = (): Parameters<typeof fetchNotificationWebhook>[1] => ({
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { Authorization: 'Bearer synthetic', 'Content-Type': 'application/json' },
|
|
20
|
+
body: '{"type":"ANNOUNCEMENT"}',
|
|
21
|
+
signal: AbortSignal.timeout(1000),
|
|
22
|
+
});
|
|
23
|
+
const resolve = (addresses: LookupAddress[]): void => {
|
|
24
|
+
mocks.lookup.mockImplementation((_host, _options, callback) => callback(null, addresses));
|
|
25
|
+
};
|
|
26
|
+
const respond = (status = 200, body = '{"sent":true}'): void => {
|
|
27
|
+
mocks.request.mockImplementation((_options, callback) => {
|
|
28
|
+
const outgoing = new EventEmitter() as EventEmitter & { end: (body: string) => void };
|
|
29
|
+
outgoing.end = () => {
|
|
30
|
+
const response = Object.assign(new PassThrough(), {
|
|
31
|
+
statusCode: status,
|
|
32
|
+
headers: { location: 'http://127.0.0.1/private' },
|
|
33
|
+
});
|
|
34
|
+
callback(response);
|
|
35
|
+
response.end(body);
|
|
36
|
+
};
|
|
37
|
+
return outgoing;
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.resetAllMocks();
|
|
42
|
+
respond();
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
45
|
+
|
|
46
|
+
describe('notification destination boundary', () => {
|
|
47
|
+
it.each([
|
|
48
|
+
'0.0.0.0',
|
|
49
|
+
'10.0.0.1',
|
|
50
|
+
'100.64.0.1',
|
|
51
|
+
'127.0.0.1',
|
|
52
|
+
'169.254.169.254',
|
|
53
|
+
'172.16.0.1',
|
|
54
|
+
'192.0.0.1',
|
|
55
|
+
'192.0.2.1',
|
|
56
|
+
'192.88.99.1',
|
|
57
|
+
'192.168.1.1',
|
|
58
|
+
'198.18.0.1',
|
|
59
|
+
'198.51.100.1',
|
|
60
|
+
'203.0.113.1',
|
|
61
|
+
'224.0.0.1',
|
|
62
|
+
'255.255.255.255',
|
|
63
|
+
'::',
|
|
64
|
+
'::1',
|
|
65
|
+
'fc00::1',
|
|
66
|
+
'fe80::1',
|
|
67
|
+
'ff02::1',
|
|
68
|
+
'::ffff:127.0.0.1',
|
|
69
|
+
'::ffff:8.8.8.8',
|
|
70
|
+
'64:ff9b::a00:1',
|
|
71
|
+
'2001::1',
|
|
72
|
+
'2001:db8::1',
|
|
73
|
+
'2002:0a00:0001::1',
|
|
74
|
+
'3fff::1',
|
|
75
|
+
'4000::1',
|
|
76
|
+
'not-an-ip',
|
|
77
|
+
])('rejects nonpublic or transition address %s', address => {
|
|
78
|
+
expect(isPublicNotificationAddress(address)).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it.each(['8.8.8.8', '1.1.1.1', '93.184.216.34', '2606:4700:4700::1111'])(
|
|
82
|
+
'accepts public address %s',
|
|
83
|
+
address => {
|
|
84
|
+
expect(isPublicNotificationAddress(address)).toBe(true);
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
it.each([
|
|
89
|
+
'http://127.0.0.1/',
|
|
90
|
+
'http://2130706433/',
|
|
91
|
+
'http://0x7f000001/',
|
|
92
|
+
'http://127.1/',
|
|
93
|
+
'http://0177.0.0.1/',
|
|
94
|
+
'http://10.0.1.10/',
|
|
95
|
+
'http://169.254.169.254/latest/meta-data/',
|
|
96
|
+
'https://[::1]/',
|
|
97
|
+
'http://[::ffff:127.0.0.1]/',
|
|
98
|
+
'ftp://public.example/',
|
|
99
|
+
'https://user:password@public.example/',
|
|
100
|
+
'not a URL',
|
|
101
|
+
])('blocks %s before sending the token or body', async destination => {
|
|
102
|
+
await expect(fetchNotificationWebhook(destination, request(), false)).rejects.toThrow();
|
|
103
|
+
expect(mocks.request).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it.each([
|
|
107
|
+
[],
|
|
108
|
+
[{ address: '10.0.1.10', family: 4 }],
|
|
109
|
+
[
|
|
110
|
+
{ address: '8.8.8.8', family: 4 },
|
|
111
|
+
{ address: '127.0.0.1', family: 4 },
|
|
112
|
+
],
|
|
113
|
+
[
|
|
114
|
+
{ address: '8.8.8.8', family: 4 },
|
|
115
|
+
{ address: 'fd00::1', family: 6 },
|
|
116
|
+
],
|
|
117
|
+
])('rejects unsafe DNS answers before opening a connection', async (...addresses) => {
|
|
118
|
+
resolve(addresses as LookupAddress[]);
|
|
119
|
+
await expect(
|
|
120
|
+
fetchNotificationWebhook('http://receiver.example/events', request(), false)
|
|
121
|
+
).rejects.toThrow();
|
|
122
|
+
expect(mocks.lookup).toHaveBeenCalled();
|
|
123
|
+
expect(mocks.request).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('pins the validated IP, preserves Host/TLS identity, and checks again on retry', async () => {
|
|
127
|
+
resolve([{ address: '8.8.8.8', family: 4 }]);
|
|
128
|
+
const response = await fetchNotificationWebhook(
|
|
129
|
+
'https://receiver.example:8443/events?q=1',
|
|
130
|
+
request(),
|
|
131
|
+
false
|
|
132
|
+
);
|
|
133
|
+
expect(await response.text()).toBe('{"sent":true}');
|
|
134
|
+
expect(mocks.request).toHaveBeenCalledWith(
|
|
135
|
+
expect.objectContaining({
|
|
136
|
+
hostname: '8.8.8.8',
|
|
137
|
+
servername: 'receiver.example',
|
|
138
|
+
port: '8443',
|
|
139
|
+
path: '/events?q=1',
|
|
140
|
+
agent: false,
|
|
141
|
+
headers: expect.objectContaining({
|
|
142
|
+
Host: 'receiver.example:8443',
|
|
143
|
+
Authorization: 'Bearer synthetic',
|
|
144
|
+
}),
|
|
145
|
+
}),
|
|
146
|
+
expect.any(Function)
|
|
147
|
+
);
|
|
148
|
+
const options = mocks.request.mock.calls[0]![0] as RequestOptions;
|
|
149
|
+
expect(
|
|
150
|
+
options.checkServerIdentity!('8.8.8.8', {
|
|
151
|
+
subjectaltname: 'DNS:receiver.example',
|
|
152
|
+
} as never)
|
|
153
|
+
).toBeUndefined();
|
|
154
|
+
expect(
|
|
155
|
+
options.checkServerIdentity!('8.8.8.8', {
|
|
156
|
+
subjectaltname: 'DNS:other.example',
|
|
157
|
+
} as never)
|
|
158
|
+
).toBeInstanceOf(Error);
|
|
159
|
+
expect(mocks.lookup).toHaveBeenCalledOnce();
|
|
160
|
+
resolve([{ address: '10.0.1.10', family: 4 }]);
|
|
161
|
+
await expect(
|
|
162
|
+
fetchNotificationWebhook('https://receiver.example:8443/events?q=1', request(), false)
|
|
163
|
+
).rejects.toThrow();
|
|
164
|
+
expect(mocks.lookup).toHaveBeenCalledTimes(2);
|
|
165
|
+
expect(mocks.request).toHaveBeenCalledOnce();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('pins a public IPv6 DNS result', async () => {
|
|
169
|
+
resolve([{ address: '2606:4700:4700::1111', family: 6 }]);
|
|
170
|
+
await fetchNotificationWebhook('https://receiver.example/', request(), false);
|
|
171
|
+
expect(mocks.request.mock.calls[0]![0].hostname).toBe('2606:4700:4700::1111');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('fails closed on DNS errors and aborts stalled resolution', async () => {
|
|
175
|
+
mocks.lookup.mockImplementationOnce((_host, _options, callback) =>
|
|
176
|
+
callback(new Error('DNS failed'))
|
|
177
|
+
);
|
|
178
|
+
await expect(
|
|
179
|
+
fetchNotificationWebhook('http://unresolved.example/', request(), false)
|
|
180
|
+
).rejects.toThrow('DNS failed');
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const pending = fetchNotificationWebhook(
|
|
183
|
+
'http://stalled.example/',
|
|
184
|
+
{ ...request(), signal: controller.signal },
|
|
185
|
+
false
|
|
186
|
+
);
|
|
187
|
+
controller.abort(new Error('deadline'));
|
|
188
|
+
await expect(pending).rejects.toThrow('deadline');
|
|
189
|
+
expect(mocks.request).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it.each([301, 302, 303, 307, 308])('rejects %i without a second request', async status => {
|
|
193
|
+
respond(status);
|
|
194
|
+
await expect(
|
|
195
|
+
fetchNotificationWebhook('https://8.8.8.8/', request(), false)
|
|
196
|
+
).rejects.toThrow('without redirects');
|
|
197
|
+
expect(mocks.request).toHaveBeenCalledOnce();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('bounds the acknowledgement body', async () => {
|
|
201
|
+
respond(200, 'x'.repeat(64 * 1024 + 1));
|
|
202
|
+
await expect(
|
|
203
|
+
fetchNotificationWebhook('https://8.8.8.8/', request(), false)
|
|
204
|
+
).rejects.toThrow('exceeds 64 KiB');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('never relies on the runtime global fetch for hosted delivery', async () => {
|
|
208
|
+
const globalFetch = vi.fn();
|
|
209
|
+
vi.stubGlobal('fetch', globalFetch);
|
|
210
|
+
const response = await fetchNotificationWebhook('https://8.8.8.8/', request(), false);
|
|
211
|
+
expect(response.ok).toBe(true);
|
|
212
|
+
expect(globalFetch).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('permits local development only with the explicit offline runtime flag', async () => {
|
|
216
|
+
const globalFetch = vi.fn().mockResolvedValue(new Response('{"sent":true}'));
|
|
217
|
+
vi.stubGlobal('fetch', globalFetch);
|
|
218
|
+
await expect(
|
|
219
|
+
fetchNotificationWebhook('http://127.0.0.1:5100/', request(), false)
|
|
220
|
+
).rejects.toThrow();
|
|
221
|
+
expect(globalFetch).not.toHaveBeenCalled();
|
|
222
|
+
await fetchNotificationWebhook('http://127.0.0.1:5100/', request(), true);
|
|
223
|
+
expect(globalFetch).toHaveBeenCalledWith(
|
|
224
|
+
'http://127.0.0.1:5100/',
|
|
225
|
+
expect.objectContaining({ redirect: 'error' })
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { lookup } from 'node:dns';
|
|
2
|
+
import { BlockList, isIP } from 'node:net';
|
|
3
|
+
import { request as httpRequest } from 'node:http';
|
|
4
|
+
import { request as httpsRequest } from 'node:https';
|
|
5
|
+
import { checkServerIdentity } from 'node:tls';
|
|
6
|
+
|
|
7
|
+
const nonpublicV4 = new BlockList();
|
|
8
|
+
for (const [address, prefix] of [
|
|
9
|
+
['0.0.0.0', 8],
|
|
10
|
+
['10.0.0.0', 8],
|
|
11
|
+
['100.64.0.0', 10],
|
|
12
|
+
['127.0.0.0', 8],
|
|
13
|
+
['169.254.0.0', 16],
|
|
14
|
+
['172.16.0.0', 12],
|
|
15
|
+
['192.0.0.0', 24],
|
|
16
|
+
['192.0.2.0', 24],
|
|
17
|
+
['192.88.99.0', 24],
|
|
18
|
+
['192.168.0.0', 16],
|
|
19
|
+
['198.18.0.0', 15],
|
|
20
|
+
['198.51.100.0', 24],
|
|
21
|
+
['203.0.113.0', 24],
|
|
22
|
+
['224.0.0.0', 4],
|
|
23
|
+
['240.0.0.0', 4],
|
|
24
|
+
] as const) {
|
|
25
|
+
nonpublicV4.addSubnet(address, prefix, 'ipv4');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const globalV6 = new BlockList();
|
|
29
|
+
globalV6.addSubnet('2000::', 3, 'ipv6');
|
|
30
|
+
const nonpublicV6 = new BlockList();
|
|
31
|
+
// Special-purpose space, documentation and 6to4 (which can embed a private IPv4).
|
|
32
|
+
nonpublicV6.addSubnet('2001::', 23, 'ipv6');
|
|
33
|
+
nonpublicV6.addSubnet('2001:db8::', 32, 'ipv6');
|
|
34
|
+
nonpublicV6.addSubnet('2002::', 16, 'ipv6');
|
|
35
|
+
nonpublicV6.addSubnet('3fff::', 20, 'ipv6');
|
|
36
|
+
|
|
37
|
+
export const isPublicNotificationAddress = (address: string): boolean => {
|
|
38
|
+
if (isIP(address) === 4) return !nonpublicV4.check(address, 'ipv4');
|
|
39
|
+
// Fail closed for mapped/translated IPv4, NAT64, local, multicast and reserved IPv6.
|
|
40
|
+
return (
|
|
41
|
+
isIP(address) === 6 &&
|
|
42
|
+
!address.includes('%') &&
|
|
43
|
+
globalV6.check(address, 'ipv6') &&
|
|
44
|
+
!nonpublicV6.check(address, 'ipv6')
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export class UnsafeNotificationWebhookError extends Error {
|
|
49
|
+
constructor() {
|
|
50
|
+
super('Notification webhook must use a public HTTP(S) destination without redirects');
|
|
51
|
+
this.name = 'UnsafeNotificationWebhookError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve once; the HTTP transport receives only a validated literal IP. */
|
|
56
|
+
const resolvePublicAddress = (hostname: string, signal: AbortSignal): Promise<string> => {
|
|
57
|
+
signal.throwIfAborted();
|
|
58
|
+
if (isIP(hostname)) {
|
|
59
|
+
if (!isPublicNotificationAddress(hostname)) throw new UnsafeNotificationWebhookError();
|
|
60
|
+
return Promise.resolve(hostname);
|
|
61
|
+
}
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const abort = (): void => reject(signal.reason);
|
|
64
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
65
|
+
lookup(hostname, { all: true, verbatim: true }, (error, addresses) => {
|
|
66
|
+
signal.removeEventListener('abort', abort);
|
|
67
|
+
if (error) return reject(error);
|
|
68
|
+
if (
|
|
69
|
+
!addresses.length ||
|
|
70
|
+
addresses.some(({ address }) => !isPublicNotificationAddress(address))
|
|
71
|
+
) {
|
|
72
|
+
return reject(new UnsafeNotificationWebhookError());
|
|
73
|
+
}
|
|
74
|
+
resolve(addresses[0]!.address);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type WebhookRequest = {
|
|
80
|
+
method: 'POST';
|
|
81
|
+
headers: Record<string, string>;
|
|
82
|
+
body: string;
|
|
83
|
+
signal: AbortSignal;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const fetchNotificationWebhook = async (
|
|
87
|
+
destination: string,
|
|
88
|
+
request: WebhookRequest,
|
|
89
|
+
isOffline: boolean
|
|
90
|
+
): Promise<Pick<Response, 'ok' | 'status' | 'text'>> => {
|
|
91
|
+
let url: URL;
|
|
92
|
+
try {
|
|
93
|
+
url = new URL(destination);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new UnsafeNotificationWebhookError();
|
|
96
|
+
}
|
|
97
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
98
|
+
throw new UnsafeNotificationWebhookError();
|
|
99
|
+
}
|
|
100
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, '');
|
|
101
|
+
// Literal addresses bypass DNS lookup in the transport, so check them here too.
|
|
102
|
+
if (!isOffline && isIP(hostname) && !isPublicNotificationAddress(hostname)) {
|
|
103
|
+
throw new UnsafeNotificationWebhookError();
|
|
104
|
+
}
|
|
105
|
+
// Only the trusted runtime's explicit local-development mode permits private hosts.
|
|
106
|
+
// Neither NODE_ENV=test nor notification/profile fields can disable the guard.
|
|
107
|
+
if (isOffline) return fetch(destination, { ...request, redirect: 'error' });
|
|
108
|
+
const address = await resolvePublicAddress(hostname, request.signal);
|
|
109
|
+
request.signal.throwIfAborted();
|
|
110
|
+
// Pin the connection target instead of relying on a fetch dispatcher/lookup hook:
|
|
111
|
+
// runtimes can ignore those hooks. Preserve HTTP Host, TLS SNI and certificate identity.
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
114
|
+
const outgoing = send(
|
|
115
|
+
{
|
|
116
|
+
protocol: url.protocol,
|
|
117
|
+
hostname: address,
|
|
118
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
119
|
+
path: url.pathname + url.search,
|
|
120
|
+
method: request.method,
|
|
121
|
+
headers: {
|
|
122
|
+
...request.headers,
|
|
123
|
+
Host: url.host,
|
|
124
|
+
'Content-Length': String(Buffer.byteLength(request.body)),
|
|
125
|
+
},
|
|
126
|
+
servername: isIP(hostname) ? undefined : hostname,
|
|
127
|
+
rejectUnauthorized: true,
|
|
128
|
+
checkServerIdentity: (_server, cert) => checkServerIdentity(hostname, cert),
|
|
129
|
+
// A fresh pinned connection per attempt; no pooled connection can bypass validation.
|
|
130
|
+
agent: false,
|
|
131
|
+
signal: request.signal,
|
|
132
|
+
},
|
|
133
|
+
response => {
|
|
134
|
+
const status = response.statusCode ?? 0;
|
|
135
|
+
// node:http never follows redirects. Reject them explicitly as failed delivery.
|
|
136
|
+
if (status >= 300 && status < 400) {
|
|
137
|
+
response.destroy();
|
|
138
|
+
reject(new UnsafeNotificationWebhookError());
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const chunks: Buffer[] = [];
|
|
142
|
+
let bytes = 0;
|
|
143
|
+
response.on('error', reject);
|
|
144
|
+
response.on('data', (chunk: Buffer) => {
|
|
145
|
+
bytes += chunk.length;
|
|
146
|
+
if (bytes > 64 * 1024) {
|
|
147
|
+
response.destroy(
|
|
148
|
+
new Error('Notification webhook acknowledgement exceeds 64 KiB')
|
|
149
|
+
);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
chunks.push(chunk);
|
|
153
|
+
});
|
|
154
|
+
response.on('end', () =>
|
|
155
|
+
resolve({
|
|
156
|
+
ok: status >= 200 && status < 300,
|
|
157
|
+
status,
|
|
158
|
+
text: async () => Buffer.concat(chunks).toString('utf8'),
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
outgoing.on('error', reject);
|
|
164
|
+
outgoing.end(request.body);
|
|
165
|
+
});
|
|
166
|
+
};
|
|
@@ -2,6 +2,8 @@ import { authorizeContractNotification } from '@helpers/contract-events.helpers'
|
|
|
2
2
|
import { ConsentFlowWebhookMetadataValidator, LCNNotificationValidator } from '@learncard/types';
|
|
3
3
|
|
|
4
4
|
import { acknowledgeConnectionPromptNotificationDelivery } from '@helpers/connectionPrompt.helpers';
|
|
5
|
+
import { acknowledgeCampaignNotification } from '@accesslayer/campaign/delivery';
|
|
6
|
+
import { authorizeCampaignNotification } from '@helpers/campaign-notifications.helpers';
|
|
5
7
|
import {
|
|
6
8
|
PermanentNotificationDeliveryError,
|
|
7
9
|
sendNotification,
|
|
@@ -10,6 +12,7 @@ import {
|
|
|
10
12
|
export const deliverQueuedNotification = async (body: string): Promise<void> => {
|
|
11
13
|
const notification = await LCNNotificationValidator.parseAsync(JSON.parse(body));
|
|
12
14
|
|
|
15
|
+
if (!(await authorizeCampaignNotification(notification))) return;
|
|
13
16
|
if (!(await authorizeContractNotification(notification))) return;
|
|
14
17
|
|
|
15
18
|
const isContractEvent = ConsentFlowWebhookMetadataValidator.safeParse(
|
|
@@ -30,6 +33,8 @@ export const deliverQueuedNotification = async (body: string): Promise<void> =>
|
|
|
30
33
|
|
|
31
34
|
if (!stored) throw new Error('Notification was not durably stored');
|
|
32
35
|
|
|
36
|
+
await acknowledgeCampaignNotification(notification);
|
|
37
|
+
|
|
33
38
|
const connectionPrompt = notification.data?.metadata?.connectionPrompt;
|
|
34
39
|
if (!connectionPrompt) return;
|
|
35
40
|
|