@learncard/network-brain-service 3.18.0 → 3.19.1
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 +38 -2
- package/CHANGELOG.md +118 -0
- package/README.md +42 -0
- package/compose.inbox.yml +7 -0
- package/dist/accesslayer/auth-grant/read.d.ts +1 -0
- package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
- package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
- package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
- package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
- 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-batch/store.d.ts +33 -0
- package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
- package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
- package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
- package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
- package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
- package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
- package/dist/accesslayer/presentation/read.d.ts.map +1 -1
- package/dist/accesslayer/presentation/relationships/create.d.ts +2 -2
- package/dist/accesslayer/presentation/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/presentation/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/share-link/abandon.d.ts +3 -0
- package/dist/accesslayer/share-link/abandon.d.ts.map +1 -0
- package/dist/accesslayer/share-link/cleanup.d.ts +6 -0
- package/dist/accesslayer/share-link/cleanup.d.ts.map +1 -0
- package/dist/accesslayer/share-link/errors.d.ts +8 -0
- package/dist/accesslayer/share-link/errors.d.ts.map +1 -0
- package/dist/accesslayer/share-link/finalize.d.ts +3 -0
- package/dist/accesslayer/share-link/finalize.d.ts.map +1 -0
- package/dist/accesslayer/share-link/helpers.d.ts +56 -0
- package/dist/accesslayer/share-link/helpers.d.ts.map +1 -0
- package/dist/accesslayer/share-link/index.d.ts +12 -0
- package/dist/accesslayer/share-link/index.d.ts.map +1 -0
- package/dist/accesslayer/share-link/list.d.ts +5 -0
- package/dist/accesslayer/share-link/list.d.ts.map +1 -0
- package/dist/accesslayer/share-link/read.d.ts +5 -0
- package/dist/accesslayer/share-link/read.d.ts.map +1 -0
- package/dist/accesslayer/share-link/receipt.d.ts +11 -0
- package/dist/accesslayer/share-link/receipt.d.ts.map +1 -0
- package/dist/accesslayer/share-link/recovery.d.ts +6 -0
- package/dist/accesslayer/share-link/recovery.d.ts.map +1 -0
- package/dist/accesslayer/share-link/reserve.d.ts +4 -0
- package/dist/accesslayer/share-link/reserve.d.ts.map +1 -0
- package/dist/accesslayer/share-link/revoke.d.ts +3 -0
- package/dist/accesslayer/share-link/revoke.d.ts.map +1 -0
- package/dist/accesslayer/share-link/transaction-execution.d.ts +18 -0
- package/dist/accesslayer/share-link/transaction-execution.d.ts.map +1 -0
- package/dist/accesslayer/share-link/transaction.d.ts +15 -0
- package/dist/accesslayer/share-link/transaction.d.ts.map +1 -0
- package/dist/accesslayer/share-link/types.d.ts +370 -0
- package/dist/accesslayer/share-link/types.d.ts.map +1 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
- package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
- package/dist/cache/atomic-counter.test.d.ts +2 -0
- package/dist/cache/atomic-counter.test.d.ts.map +1 -0
- package/dist/cache/index.d.ts +1 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/config/environment.d.ts +55 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/constants/auth-grant.d.ts +2 -0
- package/dist/constants/auth-grant.d.ts.map +1 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/helpers/activity.helpers.d.ts +4 -0
- package/dist/helpers/activity.helpers.d.ts.map +1 -1
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
- package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
- package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
- package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
- package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
- package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/credential.helpers.d.ts +2 -1
- package/dist/helpers/credential.helpers.d.ts.map +1 -1
- package/dist/helpers/credentialSubject.helpers.d.ts +3 -0
- package/dist/helpers/credentialSubject.helpers.d.ts.map +1 -0
- package/dist/helpers/credentialSubject.helpers.test.d.ts +2 -0
- package/dist/helpers/credentialSubject.helpers.test.d.ts.map +1 -0
- package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
- package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
- package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
- package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
- package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
- package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
- package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox.helpers.d.ts +9 -1
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/notificationMessages.d.ts +1 -1
- package/dist/helpers/notificationMessages.d.ts.map +1 -1
- package/dist/helpers/notifications.helpers.d.ts.map +1 -1
- package/dist/helpers/presentation-metadata.d.ts +4 -0
- package/dist/helpers/presentation-metadata.d.ts.map +1 -0
- package/dist/helpers/presentation.helpers.d.ts +1 -1
- package/dist/helpers/presentation.helpers.d.ts.map +1 -1
- package/dist/helpers/profile.helpers.d.ts +1 -0
- package/dist/helpers/profile.helpers.d.ts.map +1 -1
- package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
- package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
- package/dist/helpers/share-content-client/adapters.d.ts +10 -0
- package/dist/helpers/share-content-client/adapters.d.ts.map +1 -0
- package/dist/helpers/share-content-client/canonical.d.ts +7 -0
- package/dist/helpers/share-content-client/canonical.d.ts.map +1 -0
- package/dist/helpers/share-content-client/client.d.ts +5 -0
- package/dist/helpers/share-content-client/client.d.ts.map +1 -0
- package/dist/helpers/share-content-client/config.d.ts +11 -0
- package/dist/helpers/share-content-client/config.d.ts.map +1 -0
- package/dist/helpers/share-content-client/index.d.ts +6 -0
- package/dist/helpers/share-content-client/index.d.ts.map +1 -0
- package/dist/helpers/share-content-client/responses.d.ts +16 -0
- package/dist/helpers/share-content-client/responses.d.ts.map +1 -0
- package/dist/helpers/share-content-client/types.d.ts +145 -0
- package/dist/helpers/share-content-client/types.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/budget-helpers.d.ts +5 -0
- package/dist/helpers/share-link-coordinator/budget-helpers.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/cleanup-runner.d.ts +3 -0
- package/dist/helpers/share-link-coordinator/cleanup-runner.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/coordinator.d.ts +4 -0
- package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/index.d.ts +5 -0
- package/dist/helpers/share-link-coordinator/index.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/recovery-runner.d.ts +9 -0
- package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/runtime.d.ts +11 -0
- package/dist/helpers/share-link-coordinator/runtime.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/stat.d.ts +4 -0
- package/dist/helpers/share-link-coordinator/stat.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/types.d.ts +160 -0
- package/dist/helpers/share-link-coordinator/types.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/index.d.ts +5 -0
- package/dist/helpers/share-link-lifecycle/index.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/lease.helpers.d.ts +4 -0
- package/dist/helpers/share-link-lifecycle/lease.helpers.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/recovery.helpers.d.ts +21 -0
- package/dist/helpers/share-link-lifecycle/recovery.helpers.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/request-hash.helpers.d.ts +9 -0
- package/dist/helpers/share-link-lifecycle/request-hash.helpers.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/types.d.ts +14 -0
- package/dist/helpers/share-link-lifecycle/types.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/budget.d.ts +12 -0
- package/dist/helpers/share-link-maintenance/budget.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/config.d.ts +48 -0
- package/dist/helpers/share-link-maintenance/config.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/index.d.ts +8 -0
- package/dist/helpers/share-link-maintenance/index.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/runner.d.ts +19 -0
- package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/runtime.d.ts +36 -0
- package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/scheduler.d.ts +18 -0
- package/dist/helpers/share-link-maintenance/scheduler.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/telemetry.d.ts +5 -0
- package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/types.d.ts +53 -0
- package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -0
- package/dist/helpers/share-link-owner/config.d.ts +16 -0
- package/dist/helpers/share-link-owner/config.d.ts.map +1 -0
- package/dist/helpers/share-link-owner/diagnostics.d.ts +26 -0
- package/dist/helpers/share-link-owner/diagnostics.d.ts.map +1 -0
- package/dist/helpers/share-link-owner/lazy-initializer.d.ts +2 -0
- package/dist/helpers/share-link-owner/lazy-initializer.d.ts.map +1 -0
- package/dist/helpers/share-link-owner-projection.d.ts +4 -0
- package/dist/helpers/share-link-owner-projection.d.ts.map +1 -0
- package/dist/helpers/share-link-passcode-abuse.d.ts +2 -0
- package/dist/helpers/share-link-passcode-abuse.d.ts.map +1 -0
- package/dist/helpers/share-link-passcode.d.ts +6 -0
- package/dist/helpers/share-link-passcode.d.ts.map +1 -0
- package/dist/helpers/share-link-policy/index.d.ts +4 -0
- package/dist/helpers/share-link-policy/index.d.ts.map +1 -0
- package/dist/helpers/share-link-policy/production.d.ts +13 -0
- package/dist/helpers/share-link-policy/production.d.ts.map +1 -0
- package/dist/helpers/share-link-policy/resolver.d.ts +6 -0
- package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -0
- package/dist/helpers/share-link-policy/types.d.ts +16 -0
- package/dist/helpers/share-link-policy/types.d.ts.map +1 -0
- package/dist/helpers/share-link-public/config.d.ts +13 -0
- package/dist/helpers/share-link-public/config.d.ts.map +1 -0
- package/dist/helpers/share-link-view-notification.d.ts +2 -0
- package/dist/helpers/share-link-view-notification.d.ts.map +1 -0
- package/dist/helpers/shim.d.ts +1 -1
- package/dist/helpers/shim.d.ts.map +1 -1
- package/dist/inbox-queue-local.d.ts +2 -0
- package/dist/inbox-queue-local.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/AuthGrant.d.ts.map +1 -1
- package/dist/models/CredentialActivity.d.ts +1 -0
- package/dist/models/CredentialActivity.d.ts.map +1 -1
- package/dist/models/CredentialRefresh.d.ts +5 -2
- package/dist/models/CredentialRefresh.d.ts.map +1 -1
- package/dist/models/InboxCredential.d.ts +1 -0
- package/dist/models/InboxCredential.d.ts.map +1 -1
- package/dist/models/Presentation.d.ts +2 -0
- package/dist/models/Presentation.d.ts.map +1 -1
- package/dist/models/Profile.d.ts +2 -0
- package/dist/models/Profile.d.ts.map +1 -1
- package/dist/models/ShareLink.d.ts +44 -0
- package/dist/models/ShareLink.d.ts.map +1 -0
- package/dist/models/ShareViewReceipt.d.ts +22 -0
- package/dist/models/ShareViewReceipt.d.ts.map +1 -0
- package/dist/models/credential-refresh-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/models/share-link-constraints.d.ts +3 -0
- package/dist/models/share-link-constraints.d.ts.map +1 -0
- package/dist/openapi.d.ts.map +1 -1
- package/dist/public-share-link-http.d.ts +4 -0
- package/dist/public-share-link-http.d.ts.map +1 -0
- package/dist/routes/act-as.test.d.ts +2 -0
- package/dist/routes/act-as.test.d.ts.map +1 -0
- package/dist/routes/activity.d.ts +3 -0
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/auth-grants.d.ts +10 -0
- package/dist/routes/auth-grants.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +494 -0
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +2 -2
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +1 -1
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +953 -11
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +154 -85
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/presentations.d.ts +1 -0
- package/dist/routes/presentations.d.ts.map +1 -1
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/public-share-links.d.ts +230 -0
- package/dist/routes/public-share-links.d.ts.map +1 -0
- package/dist/routes/share-links.d.ts +844 -0
- package/dist/routes/share-links.d.ts.map +1 -0
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
- package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
- package/dist/services/registry/registry.factory.d.ts.map +1 -1
- package/dist/types/activity.d.ts +4 -0
- package/dist/types/activity.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +1 -1
- package/dist/types/credential-refresh.d.ts +10 -1
- package/dist/types/credential-refresh.d.ts.map +1 -1
- package/dist/types/inbox-batch.d.ts +38 -0
- package/dist/types/inbox-batch.d.ts.map +1 -0
- package/inbox-e2e-setup.ts +53 -1
- package/inbox-elasticmq.conf +10 -0
- package/lambda.ts +64 -16
- package/package.json +24 -16
- package/scripts/seed-demo-persona.ts +425 -0
- package/scripts/setup-inbox-queues.ts +60 -0
- package/serverless-local.yml +2 -0
- package/serverless.yml +111 -0
- package/shareLinkMaintenanceLambda.ts +49 -0
- package/src/accesslayer/auth-grant/read.ts +2 -1
- package/src/accesslayer/consentflowcontract/relationships/create.ts +9 -16
- package/src/accesslayer/consentflowcontract/relationships/update.ts +15 -28
- package/src/accesslayer/credential-activity/create.ts +1 -0
- package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
- package/src/accesslayer/credential-refresh/index.ts +1 -0
- package/src/accesslayer/credential-refresh/read.ts +8 -2
- package/src/accesslayer/credential-refresh/update.ts +4 -0
- package/src/accesslayer/inbox-batch/store.ts +469 -0
- package/src/accesslayer/inbox-credential/create.ts +51 -19
- package/src/accesslayer/inbox-credential/refresh.ts +221 -0
- package/src/accesslayer/inbox-credential/update.ts +1 -1
- package/src/accesslayer/presentation/read.ts +57 -25
- package/src/accesslayer/presentation/relationships/create.ts +54 -11
- package/src/accesslayer/presentation/relationships/read.ts +21 -6
- package/src/accesslayer/share-link/abandon.ts +100 -0
- package/src/accesslayer/share-link/cleanup.ts +271 -0
- package/src/accesslayer/share-link/errors.ts +32 -0
- package/src/accesslayer/share-link/finalize.ts +265 -0
- package/src/accesslayer/share-link/helpers.ts +419 -0
- package/src/accesslayer/share-link/index.ts +11 -0
- package/src/accesslayer/share-link/list.ts +162 -0
- package/src/accesslayer/share-link/read.ts +107 -0
- package/src/accesslayer/share-link/receipt.ts +548 -0
- package/src/accesslayer/share-link/recovery.ts +545 -0
- package/src/accesslayer/share-link/reserve.ts +684 -0
- package/src/accesslayer/share-link/revoke.ts +197 -0
- package/src/accesslayer/share-link/transaction-execution.ts +72 -0
- package/src/accesslayer/share-link/transaction.ts +126 -0
- package/src/accesslayer/share-link/types.ts +601 -0
- package/src/app.ts +6 -0
- package/src/cache/atomic-counter.test-helpers.ts +27 -0
- package/src/cache/atomic-counter.test.ts +6 -0
- package/src/cache/index.ts +25 -13
- package/src/config/environment.ts +40 -0
- package/src/constants/auth-grant.ts +5 -0
- package/src/credential-refresh.ts +5 -1
- package/src/docker-entry.ts +65 -0
- package/src/helpers/activity.helpers.ts +4 -0
- package/src/helpers/app-counter.helpers.ts +1 -1
- package/src/helpers/boost.helpers.ts +4 -31
- package/src/helpers/contact-method.helpers.test.ts +43 -0
- package/src/helpers/contact-method.helpers.ts +3 -4
- package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
- package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
- package/src/helpers/credential-refresh-email.helpers.ts +201 -0
- package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
- package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
- package/src/helpers/credential-refresh.helpers.ts +375 -99
- package/src/helpers/credential.helpers.ts +4 -1
- package/src/helpers/credentialSubject.helpers.test.ts +114 -0
- package/src/helpers/credentialSubject.helpers.ts +40 -0
- package/src/helpers/finalize-inbox.helpers.ts +29 -14
- package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
- package/src/helpers/inbox-batch-http.helpers.ts +150 -0
- package/src/helpers/inbox-batch-status.helpers.ts +11 -0
- package/src/helpers/inbox-batch.helpers.test.ts +668 -0
- package/src/helpers/inbox-batch.helpers.ts +374 -0
- package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
- package/src/helpers/inbox-queue.helpers.test.ts +253 -0
- package/src/helpers/inbox-queue.helpers.ts +372 -0
- package/src/helpers/inbox-refresh.helpers.ts +432 -0
- package/src/helpers/inbox.helpers.ts +275 -36
- package/src/helpers/notificationMessages.ts +18 -1
- package/src/helpers/notifications.helpers.ts +4 -2
- package/src/helpers/presentation-metadata.ts +41 -0
- package/src/helpers/presentation.helpers.ts +27 -20
- package/src/helpers/profile.helpers.ts +7 -0
- package/src/helpers/rateLimit.helpers.ts +2 -5
- package/src/helpers/refresh-send-intent.helpers.ts +273 -0
- package/src/helpers/share-content-client/adapters.ts +55 -0
- package/src/helpers/share-content-client/canonical.ts +25 -0
- package/src/helpers/share-content-client/client.ts +440 -0
- package/src/helpers/share-content-client/config.ts +189 -0
- package/src/helpers/share-content-client/index.ts +27 -0
- package/src/helpers/share-content-client/responses.ts +273 -0
- package/src/helpers/share-content-client/types.ts +253 -0
- package/src/helpers/share-link-coordinator/budget-helpers.ts +53 -0
- package/src/helpers/share-link-coordinator/cleanup-runner.ts +286 -0
- package/src/helpers/share-link-coordinator/coordinator.ts +714 -0
- package/src/helpers/share-link-coordinator/index.ts +8 -0
- package/src/helpers/share-link-coordinator/recovery-runner.ts +428 -0
- package/src/helpers/share-link-coordinator/runtime.ts +88 -0
- package/src/helpers/share-link-coordinator/stat.ts +30 -0
- package/src/helpers/share-link-coordinator/types.ts +353 -0
- package/src/helpers/share-link-lifecycle/index.ts +4 -0
- package/src/helpers/share-link-lifecycle/lease.helpers.ts +35 -0
- package/src/helpers/share-link-lifecycle/recovery.helpers.ts +173 -0
- package/src/helpers/share-link-lifecycle/request-hash.helpers.ts +130 -0
- package/src/helpers/share-link-lifecycle/types.ts +58 -0
- package/src/helpers/share-link-maintenance/budget.ts +63 -0
- package/src/helpers/share-link-maintenance/config.ts +312 -0
- package/src/helpers/share-link-maintenance/index.ts +24 -0
- package/src/helpers/share-link-maintenance/runner.ts +223 -0
- package/src/helpers/share-link-maintenance/runtime.ts +334 -0
- package/src/helpers/share-link-maintenance/scheduler.ts +126 -0
- package/src/helpers/share-link-maintenance/telemetry.ts +70 -0
- package/src/helpers/share-link-maintenance/types.ts +89 -0
- package/src/helpers/share-link-owner/config.ts +48 -0
- package/src/helpers/share-link-owner/diagnostics.ts +89 -0
- package/src/helpers/share-link-owner/lazy-initializer.ts +29 -0
- package/src/helpers/share-link-owner-projection.ts +52 -0
- package/src/helpers/share-link-passcode-abuse.ts +36 -0
- package/src/helpers/share-link-passcode.ts +47 -0
- package/src/helpers/share-link-policy/index.ts +7 -0
- package/src/helpers/share-link-policy/production.ts +88 -0
- package/src/helpers/share-link-policy/resolver.ts +108 -0
- package/src/helpers/share-link-policy/types.ts +48 -0
- package/src/helpers/share-link-public/config.ts +41 -0
- package/src/helpers/share-link-view-notification.ts +18 -0
- package/src/helpers/shim.ts +32 -31
- package/src/inbox-queue-local.ts +28 -0
- package/src/models/AuthGrant.ts +1 -0
- package/src/models/CredentialActivity.ts +3 -5
- package/src/models/CredentialRefresh.ts +4 -2
- package/src/models/InboxCredential.ts +2 -0
- package/src/models/Presentation.ts +2 -2
- package/src/models/Profile.ts +3 -2
- package/src/models/ShareLink.ts +130 -0
- package/src/models/ShareViewReceipt.ts +69 -0
- package/src/models/credential-refresh-constraints.test.ts +8 -3
- package/src/models/credential-refresh-constraints.ts +5 -0
- package/src/models/index.ts +2 -0
- package/src/models/share-link-constraints.ts +93 -0
- package/src/openapi.ts +8 -0
- package/src/public-share-link-http.ts +49 -0
- package/src/routes/act-as.test.ts +239 -0
- package/src/routes/app-store.ts +4 -13
- package/src/routes/auth-grants.ts +13 -11
- package/src/routes/boosts.ts +916 -103
- package/src/routes/contracts.ts +2 -11
- package/src/routes/credential-refreshes.test.ts +8 -2
- package/src/routes/credential-refreshes.ts +4 -1
- package/src/routes/credentials.ts +2 -1
- package/src/routes/inbox.ts +95 -49
- package/src/routes/index.ts +216 -46
- package/src/routes/presentations.ts +19 -3
- package/src/routes/profile-manager.ts +10 -2
- package/src/routes/profiles.ts +17 -6
- package/src/routes/public-share-links.ts +814 -0
- package/src/routes/share-links.ts +640 -0
- package/src/routes/skills.ts +1 -1
- package/src/routes/workflows.ts +43 -24
- package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
- package/src/services/delivery/adapters/postmark.adapter.ts +16 -1
- package/src/services/registry/registry.factory.ts +2 -1
- package/src/types/activity.ts +2 -0
- package/src/types/credential-refresh.ts +21 -2
- package/src/types/inbox-batch.ts +46 -0
- package/test/atomic-counter.e2e.ts +12 -0
- package/test/auth-grant.spec.ts +21 -21
- package/test/credential-refresh-email.spec.ts +663 -0
- package/test/credential-refresh-publication.spec.ts +2 -0
- package/test/dids.spec.ts +10 -1
- package/test/guardian-gated-routes.spec.ts +13 -0
- package/test/helpers/inbox-e2e-environment.ts +6 -0
- package/test/helpers/share-link-fixtures.ts +94 -0
- package/test/inbox-batch.spec.ts +1338 -0
- package/test/inbox-refresh.spec.ts +534 -0
- package/test/managed-recipient-inbox.spec.ts +130 -0
- package/test/notificationMessages.spec.ts +13 -0
- package/test/openapi.spec.ts +61 -1
- package/test/presentations.spec.ts +54 -6
- package/test/profiles.spec.ts +67 -1
- package/test/refresh-send.spec.ts +1799 -0
- package/test/registry.spec.ts +56 -11
- package/test/share-content-client.contract.spec.ts +365 -0
- package/test/share-content-client.unit.spec.ts +462 -0
- package/test/share-link-abuse.unit.spec.ts +65 -0
- package/test/share-link-coordinator.neo4j.spec.ts +684 -0
- package/test/share-link-coordinator.unit.spec.ts +990 -0
- package/test/share-link-dependency-diagnostics.unit.spec.ts +127 -0
- package/test/share-link-lifecycle.neo4j.spec.ts +1268 -0
- package/test/share-link-lifecycle.unit.spec.ts +205 -0
- package/test/share-link-list-cursor.unit.spec.ts +86 -0
- package/test/share-link-list.neo4j.spec.ts +324 -0
- package/test/share-link-maintenance.neo4j.spec.ts +355 -0
- package/test/share-link-maintenance.unit.spec.ts +1113 -0
- package/test/share-link-owner-policy.unit.spec.ts +210 -0
- package/test/share-link-owner-route.unit.spec.ts +752 -0
- package/test/share-link-owner-runtime.unit.spec.ts +102 -0
- package/test/share-link-passcode.unit.spec.ts +29 -0
- package/test/share-link-policy.unit.spec.ts +135 -0
- package/test/share-link-presentation-metadata.unit.spec.ts +49 -0
- package/test/share-link-production-policy.unit.spec.ts +77 -0
- package/test/share-link-public-config.unit.spec.ts +64 -0
- package/test/share-link-public-http-adapter.unit.spec.ts +315 -0
- package/test/share-link-public-route.unit.spec.ts +800 -0
- package/test/share-link-receipt.neo4j.spec.ts +943 -0
- package/test/share-link-recovery.neo4j.spec.ts +935 -0
- package/test/share-link-recovery.unit.spec.ts +726 -0
- package/vitest.config.ts +10 -0
- package/vitest.inbox-e2e.config.ts +5 -1
- package/vitest.integration.config.ts +2 -0
- package/vitest.share-links-neo4j.config.ts +19 -0
- package/vitest.share-links-unit.config.ts +28 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { acceptCredential } from '@helpers/credential.helpers';
|
|
2
|
+
import { vi } from 'vitest';
|
|
3
|
+
import { environment } from '@environment';
|
|
4
|
+
import { deliverCredentialRefreshEmailNotification } from '@helpers/credential-refresh-email.helpers';
|
|
5
|
+
import { VC, UnsignedVC, LCNNotification } from '@learncard/types';
|
|
6
|
+
|
|
7
|
+
import { neogma } from '@instance';
|
|
8
|
+
|
|
9
|
+
import { getUser } from './helpers/getClient';
|
|
10
|
+
import { addNotificationToQueueSpy } from './helpers/spies';
|
|
11
|
+
import * as Notifications from '@helpers/notifications.helpers';
|
|
12
|
+
import {
|
|
13
|
+
getCredentialRefresh,
|
|
14
|
+
getCredentialRefreshEmailDelivery,
|
|
15
|
+
getCredentialRefreshHead,
|
|
16
|
+
setCredentialRefreshState,
|
|
17
|
+
} from '@accesslayer/credential-refresh';
|
|
18
|
+
import { getProfileByProfileId } from '@accesslayer/profile/read';
|
|
19
|
+
import { isProfileManaged } from '@accesslayer/profile/relationships/read';
|
|
20
|
+
import { createContactMethod } from '@accesslayer/contact-method/create';
|
|
21
|
+
import { createProfileContactMethodRelationship } from '@accesslayer/contact-method/relationships/create';
|
|
22
|
+
import { setPrimaryContactMethod, verifyContactMethod } from '@accesslayer/contact-method/update';
|
|
23
|
+
import { createProfileManager } from '@accesslayer/profile-manager/create';
|
|
24
|
+
import { createManagesRelationship } from '@accesslayer/profile-manager/relationships/create';
|
|
25
|
+
import { LogAdapter } from '@services/delivery/adapters/log.adapter';
|
|
26
|
+
import type { Notification } from '@services/delivery/delivery.service';
|
|
27
|
+
import { computeCredentialRefreshDeliveryKey } from '@helpers/credential-refresh-materiality.helpers';
|
|
28
|
+
import { ensureCredentialRefreshConstraints } from '../src/models/credential-refresh-constraints';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* LC-2198 — production update emails for managed credential refresh.
|
|
32
|
+
*
|
|
33
|
+
* Covers the privacy-minimal recipient/title decisions end-to-end:
|
|
34
|
+
* - verified contact methods of the bound holder only (never the original inbox
|
|
35
|
+
* address, never caller-supplied)
|
|
36
|
+
* - managed children route to a verified guardian or are skipped entirely
|
|
37
|
+
* - notifyHolder:false / non-material / pre-claim paths emit no email
|
|
38
|
+
* - delivery windows collapse retries and concurrent races to one email
|
|
39
|
+
* - email failure never changes the publication outcome or duplicates the push
|
|
40
|
+
*
|
|
41
|
+
* The delivery adapter stays log-only; Postmark is never contacted.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const runQuery = async (cypher: string, params: Record<string, unknown> = {}) =>
|
|
45
|
+
neogma.queryRunner.run(cypher, params);
|
|
46
|
+
|
|
47
|
+
const ISSUER_PROFILE_ID = 'refresh-email-issuer';
|
|
48
|
+
const HOLDER_PROFILE_ID = 'refresh-email-holder';
|
|
49
|
+
const GUARDIAN_PROFILE_ID = 'refresh-email-guardian';
|
|
50
|
+
const CREDENTIAL_ID = 'urn:uuid:refreshable-credential-email';
|
|
51
|
+
|
|
52
|
+
const HOLDER_VERIFIED_EMAIL = 'holder-verified@example.com';
|
|
53
|
+
const HOLDER_UNVERIFIED_EMAIL = 'holder-unverified@example.com';
|
|
54
|
+
const GUARDIAN_VERIFIED_EMAIL = 'guardian-verified@example.com';
|
|
55
|
+
|
|
56
|
+
const SECRET_TITLE = 'Introduction to Biology';
|
|
57
|
+
const SECRET_GRADE_MARKER = 'SECRET-GRADE-MARKER-1111';
|
|
58
|
+
const SECRET_SUMMARY_MARKER = 'SECRET-SUMMARY-MARKER-2222';
|
|
59
|
+
const SECRET_SUBJECT_MARKER = 'SECRET-SUBJECT-MARKER-3333';
|
|
60
|
+
|
|
61
|
+
type AllocationResult = {
|
|
62
|
+
refreshId: string;
|
|
63
|
+
refreshService: { id: string; type: string; authorization?: { type: string } };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
let issuer: Awaited<ReturnType<typeof getUser>>;
|
|
67
|
+
let holder: Awaited<ReturnType<typeof getUser>>;
|
|
68
|
+
let emailSendSpy: ReturnType<typeof vi.spyOn>;
|
|
69
|
+
|
|
70
|
+
const emailDeliveries = (): Notification[] =>
|
|
71
|
+
emailSendSpy.mock.calls
|
|
72
|
+
.map(call => call[0] as Notification)
|
|
73
|
+
.filter(notification => notification.templateId === 'credential-updated');
|
|
74
|
+
|
|
75
|
+
const addEmail = async (
|
|
76
|
+
profileId: string,
|
|
77
|
+
value: string,
|
|
78
|
+
{ verified = true, primary = false }: { verified?: boolean; primary?: boolean } = {}
|
|
79
|
+
) => {
|
|
80
|
+
const contactMethod = await createContactMethod({
|
|
81
|
+
type: 'email',
|
|
82
|
+
value,
|
|
83
|
+
isVerified: false,
|
|
84
|
+
isPrimary: false,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await createProfileContactMethodRelationship(profileId, contactMethod.id);
|
|
88
|
+
|
|
89
|
+
if (verified) await verifyContactMethod(contactMethod.id);
|
|
90
|
+
|
|
91
|
+
if (primary) {
|
|
92
|
+
const profile = await getProfileByProfileId(profileId);
|
|
93
|
+
await setPrimaryContactMethod(profile!.did, contactMethod.id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return contactMethod;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const linkGuardian = async (profileId: string, childProfileId: string) => {
|
|
100
|
+
const manager = await createProfileManager({
|
|
101
|
+
displayName: 'Guardian',
|
|
102
|
+
managerType: 'guardian',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await createManagesRelationship(manager.id, childProfileId);
|
|
106
|
+
await manager.relateTo({ alias: 'administratedBy', where: { profileId } });
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const allocate = async (): Promise<AllocationResult> =>
|
|
110
|
+
issuer.clients.fullAuth.credentialRefresh.allocateCredentialRefresh({
|
|
111
|
+
holder: { profileId: HOLDER_PROFILE_ID, did: holder.learnCard.id.did() },
|
|
112
|
+
credentialId: CREDENTIAL_ID,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const buildUnsignedCredential = (
|
|
116
|
+
allocation: AllocationResult,
|
|
117
|
+
overrides: Record<string, unknown> = {}
|
|
118
|
+
): UnsignedVC =>
|
|
119
|
+
({
|
|
120
|
+
'@context': [
|
|
121
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
122
|
+
{
|
|
123
|
+
'LearnCardCredentialRefresh2026':
|
|
124
|
+
'https://learncard.com/refresh#LearnCardCredentialRefresh2026',
|
|
125
|
+
authorization: {
|
|
126
|
+
'@id': 'https://purl.imsglobal.org/spec/ob/v3p0#authorization',
|
|
127
|
+
'@context': {
|
|
128
|
+
LearnCardDIDAuth: 'https://docs.learncard.com/definitions#LearnCardDIDAuth',
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
id: CREDENTIAL_ID,
|
|
134
|
+
type: ['VerifiableCredential'],
|
|
135
|
+
issuer: issuer.learnCard.id.did(),
|
|
136
|
+
validFrom: '2026-01-01T00:00:00Z',
|
|
137
|
+
name: 'Original Transcript',
|
|
138
|
+
credentialSubject: { id: holder.learnCard.id.did() },
|
|
139
|
+
refreshService: allocation.refreshService,
|
|
140
|
+
...overrides,
|
|
141
|
+
}) as UnsignedVC;
|
|
142
|
+
|
|
143
|
+
const signAs = async (
|
|
144
|
+
user: Awaited<ReturnType<typeof getUser>>,
|
|
145
|
+
unsigned: UnsignedVC
|
|
146
|
+
): Promise<VC> => user.learnCard.invoke.issueCredential(unsigned);
|
|
147
|
+
|
|
148
|
+
const publishIssuerSigned = async (
|
|
149
|
+
refreshId: string,
|
|
150
|
+
credential: VC,
|
|
151
|
+
extras: Record<string, unknown> = {}
|
|
152
|
+
) =>
|
|
153
|
+
issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
|
|
154
|
+
mode: 'issuer-signed',
|
|
155
|
+
refreshId,
|
|
156
|
+
signedCredential: credential,
|
|
157
|
+
...extras,
|
|
158
|
+
} as Parameters<typeof issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh>[0]);
|
|
159
|
+
|
|
160
|
+
const sendOriginal = async (): Promise<{ allocation: AllocationResult; uri: string }> => {
|
|
161
|
+
const allocation = await allocate();
|
|
162
|
+
const credential = await signAs(issuer, buildUnsignedCredential(allocation));
|
|
163
|
+
|
|
164
|
+
const uri = await issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
|
|
165
|
+
refreshId: allocation.refreshId,
|
|
166
|
+
credential,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return { allocation, uri };
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const activate = async (refreshId: string): Promise<void> => {
|
|
173
|
+
await setCredentialRefreshState(refreshId, 'active');
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const updatedCredential = async (
|
|
177
|
+
allocation: AllocationResult,
|
|
178
|
+
overrides: Record<string, unknown> = {}
|
|
179
|
+
): Promise<VC> =>
|
|
180
|
+
signAs(
|
|
181
|
+
issuer,
|
|
182
|
+
buildUnsignedCredential(allocation, {
|
|
183
|
+
validFrom: '2026-02-01T00:00:00Z',
|
|
184
|
+
name: SECRET_TITLE,
|
|
185
|
+
...overrides,
|
|
186
|
+
})
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
describe('credential refresh update emails', () => {
|
|
190
|
+
beforeAll(async () => {
|
|
191
|
+
issuer = await getUser('d'.repeat(64));
|
|
192
|
+
holder = await getUser('b'.repeat(64));
|
|
193
|
+
|
|
194
|
+
vi.spyOn(Notifications, 'addNotificationToQueue').mockImplementation(
|
|
195
|
+
addNotificationToQueueSpy
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
emailSendSpy = vi.spyOn(LogAdapter.prototype, 'send').mockResolvedValue(undefined);
|
|
199
|
+
|
|
200
|
+
await ensureCredentialRefreshConstraints();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
beforeEach(async () => {
|
|
204
|
+
await runQuery('MATCH (d:CredentialRefreshEmailDelivery) DETACH DELETE d');
|
|
205
|
+
await runQuery('MATCH (r:CredentialRefresh) DETACH DELETE r');
|
|
206
|
+
await runQuery('MATCH (c:Credential) DETACH DELETE c');
|
|
207
|
+
await runQuery('MATCH (cm:ContactMethod) DETACH DELETE cm');
|
|
208
|
+
await runQuery('MATCH (pm:ProfileManager) DETACH DELETE pm');
|
|
209
|
+
await runQuery('MATCH (p:Profile) DETACH DELETE p');
|
|
210
|
+
|
|
211
|
+
await issuer.clients.fullAuth.profile.createProfile({
|
|
212
|
+
profileId: ISSUER_PROFILE_ID,
|
|
213
|
+
displayName: 'Email Test School',
|
|
214
|
+
});
|
|
215
|
+
await holder.clients.fullAuth.profile.createProfile({
|
|
216
|
+
profileId: HOLDER_PROFILE_ID,
|
|
217
|
+
displayName: 'Email Test Holder',
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
addNotificationToQueueSpy.mockReset();
|
|
221
|
+
emailSendSpy.mockReset();
|
|
222
|
+
emailSendSpy.mockResolvedValue(undefined);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
afterAll(() => {
|
|
226
|
+
emailSendSpy.mockRestore();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('keeps the email recipient identity after local webhook delivery normalizes notification DIDs', async () => {
|
|
230
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL, { primary: true });
|
|
231
|
+
const { allocation } = await sendOriginal();
|
|
232
|
+
await activate(allocation.refreshId);
|
|
233
|
+
|
|
234
|
+
// The offline webhook path rewrites notification profiles to public DIDs.
|
|
235
|
+
// Those objects must not alias the profiles used for email contact lookup.
|
|
236
|
+
addNotificationToQueueSpy.mockImplementation(async (notification: LCNNotification) => {
|
|
237
|
+
if (typeof notification.to !== 'string') {
|
|
238
|
+
notification.to.did = `did:web:localhost%3A3000:users:${HOLDER_PROFILE_ID}`;
|
|
239
|
+
}
|
|
240
|
+
if (typeof notification.from !== 'string') {
|
|
241
|
+
notification.from.did = `did:web:localhost%3A3000:users:${ISSUER_PROFILE_ID}`;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const result = await publishIssuerSigned(
|
|
246
|
+
allocation.refreshId,
|
|
247
|
+
await updatedCredential(allocation)
|
|
248
|
+
);
|
|
249
|
+
expect(result.notification).toBe('queued');
|
|
250
|
+
expect(emailDeliveries()).toHaveLength(1);
|
|
251
|
+
expect(emailDeliveries()[0].contactMethod.value).toBe(HOLDER_VERIFIED_EMAIL);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('emails the holder verified email with only issuer name and title', async () => {
|
|
255
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL, { primary: true });
|
|
256
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_UNVERIFIED_EMAIL, { verified: false });
|
|
257
|
+
|
|
258
|
+
const { allocation } = await sendOriginal();
|
|
259
|
+
await activate(allocation.refreshId);
|
|
260
|
+
|
|
261
|
+
const updated = await updatedCredential(allocation, {
|
|
262
|
+
// `achievement` is not defined by the VCDM 2.0 context, so it must be
|
|
263
|
+
// mapped inline or data-loss detection refuses to sign.
|
|
264
|
+
'@context': [
|
|
265
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
266
|
+
{
|
|
267
|
+
'LearnCardCredentialRefresh2026':
|
|
268
|
+
'https://learncard.com/refresh#LearnCardCredentialRefresh2026',
|
|
269
|
+
authorization: {
|
|
270
|
+
'@id': 'https://purl.imsglobal.org/spec/ob/v3p0#authorization',
|
|
271
|
+
'@context': {
|
|
272
|
+
LearnCardDIDAuth:
|
|
273
|
+
'https://docs.learncard.com/definitions#LearnCardDIDAuth',
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
achievement: 'https://purl.imsglobal.org/spec/ob/v3p0#Achievement',
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
credentialSubject: {
|
|
280
|
+
id: holder.learnCard.id.did(),
|
|
281
|
+
achievement: { name: SECRET_GRADE_MARKER },
|
|
282
|
+
},
|
|
283
|
+
description: SECRET_SUBJECT_MARKER,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await publishIssuerSigned(allocation.refreshId, updated, {
|
|
287
|
+
updateSummary: SECRET_SUMMARY_MARKER,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const deliveries = emailDeliveries();
|
|
291
|
+
expect(deliveries).toHaveLength(1);
|
|
292
|
+
|
|
293
|
+
const delivery = deliveries[0]!;
|
|
294
|
+
expect(delivery.contactMethod).toEqual({
|
|
295
|
+
type: 'email',
|
|
296
|
+
value: HOLDER_VERIFIED_EMAIL,
|
|
297
|
+
});
|
|
298
|
+
expect(delivery.templateId).toEqual('credential-updated');
|
|
299
|
+
expect(delivery.templateModel).toEqual({
|
|
300
|
+
issuer: { name: expect.any(String) },
|
|
301
|
+
credential: { name: SECRET_TITLE },
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// No grades, description/body content, or issuer summary may reach the template.
|
|
305
|
+
const serialized = JSON.stringify(delivery);
|
|
306
|
+
expect(serialized).not.toContain(SECRET_GRADE_MARKER);
|
|
307
|
+
expect(serialized).not.toContain(SECRET_SUBJECT_MARKER);
|
|
308
|
+
expect(serialized).not.toContain(SECRET_SUMMARY_MARKER);
|
|
309
|
+
expect(serialized).not.toContain(HOLDER_UNVERIFIED_EMAIL);
|
|
310
|
+
expect(serialized).not.toContain('credentialSubject');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('does not email when the holder has no verified email', async () => {
|
|
314
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_UNVERIFIED_EMAIL, { verified: false });
|
|
315
|
+
|
|
316
|
+
const { allocation } = await sendOriginal();
|
|
317
|
+
await activate(allocation.refreshId);
|
|
318
|
+
|
|
319
|
+
const result = await publishIssuerSigned(
|
|
320
|
+
allocation.refreshId,
|
|
321
|
+
await updatedCredential(allocation)
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// In-app remains queued; only the email channel skips.
|
|
325
|
+
expect(result.notification).toEqual('queued');
|
|
326
|
+
expect(emailDeliveries()).toHaveLength(0);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('honors notifyHolder:false and does not email', async () => {
|
|
330
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
|
|
331
|
+
|
|
332
|
+
const { allocation } = await sendOriginal();
|
|
333
|
+
await activate(allocation.refreshId);
|
|
334
|
+
|
|
335
|
+
const result = await publishIssuerSigned(
|
|
336
|
+
allocation.refreshId,
|
|
337
|
+
await updatedCredential(allocation),
|
|
338
|
+
{ notifyHolder: false }
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
expect(result.notification).toEqual('suppressed');
|
|
342
|
+
expect(emailDeliveries()).toHaveLength(0);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('does not email for a non-material update', async () => {
|
|
346
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
|
|
347
|
+
|
|
348
|
+
const { allocation } = await sendOriginal();
|
|
349
|
+
await activate(allocation.refreshId);
|
|
350
|
+
|
|
351
|
+
// Same user-visible content as version 1; only the proof differs.
|
|
352
|
+
const unchanged = await signAs(
|
|
353
|
+
issuer,
|
|
354
|
+
buildUnsignedCredential(allocation, {
|
|
355
|
+
validFrom: '2026-02-01T00:00:00Z',
|
|
356
|
+
name: 'Original Transcript',
|
|
357
|
+
})
|
|
358
|
+
);
|
|
359
|
+
const result = await publishIssuerSigned(allocation.refreshId, unchanged);
|
|
360
|
+
|
|
361
|
+
expect(result.notification).toEqual('suppressed');
|
|
362
|
+
expect(emailDeliveries()).toHaveLength(0);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('does not email pre-claim, then emails once after the holder accepts', async () => {
|
|
366
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
|
|
367
|
+
|
|
368
|
+
const { allocation, uri } = await sendOriginal();
|
|
369
|
+
|
|
370
|
+
await publishIssuerSigned(allocation.refreshId, await updatedCredential(allocation));
|
|
371
|
+
expect(emailDeliveries()).toHaveLength(0);
|
|
372
|
+
|
|
373
|
+
await acceptCredential(
|
|
374
|
+
(await getProfileByProfileId(HOLDER_PROFILE_ID))!,
|
|
375
|
+
uri,
|
|
376
|
+
{},
|
|
377
|
+
{ appUrl: 'https://tenant.example', brandName: 'Tenant School' }
|
|
378
|
+
);
|
|
379
|
+
expect(emailDeliveries()[0]?.branding).toMatchObject({
|
|
380
|
+
appUrl: 'https://tenant.example',
|
|
381
|
+
brandName: 'Tenant School',
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
expect(emailDeliveries()).toHaveLength(1);
|
|
385
|
+
expect(emailDeliveries()[0]!.templateModel).toMatchObject({
|
|
386
|
+
credential: { name: SECRET_TITLE },
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// A replayed accept does not email twice.
|
|
390
|
+
await holder.clients.fullAuth.credential.acceptCredential({ uri });
|
|
391
|
+
expect(emailDeliveries()).toHaveLength(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('collapses repeat material updates in one delivery window to one email', async () => {
|
|
395
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
|
|
396
|
+
|
|
397
|
+
const { allocation } = await sendOriginal();
|
|
398
|
+
await activate(allocation.refreshId);
|
|
399
|
+
|
|
400
|
+
await publishIssuerSigned(allocation.refreshId, await updatedCredential(allocation));
|
|
401
|
+
await publishIssuerSigned(
|
|
402
|
+
allocation.refreshId,
|
|
403
|
+
await updatedCredential(allocation, {
|
|
404
|
+
validFrom: '2026-03-01T00:00:00Z',
|
|
405
|
+
name: 'Final Biology',
|
|
406
|
+
})
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
expect(emailDeliveries()).toHaveLength(1);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('does not email twice when the same publication is replayed', async () => {
|
|
413
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
|
|
414
|
+
|
|
415
|
+
const { allocation } = await sendOriginal();
|
|
416
|
+
await activate(allocation.refreshId);
|
|
417
|
+
|
|
418
|
+
const updated = await updatedCredential(allocation);
|
|
419
|
+
|
|
420
|
+
await publishIssuerSigned(allocation.refreshId, updated, {
|
|
421
|
+
idempotencyKey: 'email-replay',
|
|
422
|
+
});
|
|
423
|
+
await publishIssuerSigned(allocation.refreshId, updated, {
|
|
424
|
+
idempotencyKey: 'email-replay',
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
expect(emailDeliveries()).toHaveLength(1);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('does not resend a persisted publication in a later delivery window', async () => {
|
|
431
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
|
|
432
|
+
const { allocation } = await sendOriginal();
|
|
433
|
+
await activate(allocation.refreshId);
|
|
434
|
+
await publishIssuerSigned(allocation.refreshId, await updatedCredential(allocation));
|
|
435
|
+
const version = (await getCredentialRefreshHead(allocation.refreshId))!;
|
|
436
|
+
const issuerProfile = (await getProfileByProfileId(ISSUER_PROFILE_ID))!;
|
|
437
|
+
const holderProfile = (await getProfileByProfileId(HOLDER_PROFILE_ID))!;
|
|
438
|
+
expect(emailDeliveries()).toHaveLength(1);
|
|
439
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
440
|
+
try {
|
|
441
|
+
vi.setSystemTime(new Date(Date.now() + 3 * 24 * 60 * 60 * 1000));
|
|
442
|
+
expect(
|
|
443
|
+
await deliverCredentialRefreshEmailNotification({
|
|
444
|
+
version,
|
|
445
|
+
issuerProfile,
|
|
446
|
+
holderProfile,
|
|
447
|
+
})
|
|
448
|
+
).toBe('duplicate-window');
|
|
449
|
+
expect(emailDeliveries()).toHaveLength(1);
|
|
450
|
+
} finally {
|
|
451
|
+
vi.useRealTimers();
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('uses the local frontend in update email branding when running offline', async () => {
|
|
456
|
+
// Publish without an email first so we can test delivery without changing
|
|
457
|
+
// proof verification or the rest of the application's environment.
|
|
458
|
+
const { allocation } = await sendOriginal();
|
|
459
|
+
await activate(allocation.refreshId);
|
|
460
|
+
await publishIssuerSigned(allocation.refreshId, await updatedCredential(allocation));
|
|
461
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
|
|
462
|
+
const version = (await getCredentialRefreshHead(allocation.refreshId))!;
|
|
463
|
+
const previous = {
|
|
464
|
+
offline: environment.IS_OFFLINE,
|
|
465
|
+
domain: environment.CLIENT_APP_DOMAIN_NAME,
|
|
466
|
+
};
|
|
467
|
+
environment.IS_OFFLINE = true;
|
|
468
|
+
environment.CLIENT_APP_DOMAIN_NAME = 'localhost:3000';
|
|
469
|
+
try {
|
|
470
|
+
await deliverCredentialRefreshEmailNotification({
|
|
471
|
+
version,
|
|
472
|
+
issuerProfile: (await getProfileByProfileId(ISSUER_PROFILE_ID))!,
|
|
473
|
+
holderProfile: (await getProfileByProfileId(HOLDER_PROFILE_ID))!,
|
|
474
|
+
branding: { appUrl: 'https://production.example', brandName: 'Example School' },
|
|
475
|
+
});
|
|
476
|
+
expect(emailDeliveries()).toHaveLength(1);
|
|
477
|
+
expect(emailDeliveries()[0]?.branding).toMatchObject({
|
|
478
|
+
appUrl: 'http://localhost:3000',
|
|
479
|
+
brandName: 'Example School',
|
|
480
|
+
});
|
|
481
|
+
} finally {
|
|
482
|
+
environment.IS_OFFLINE = previous.offline;
|
|
483
|
+
environment.CLIENT_APP_DOMAIN_NAME = previous.domain;
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('claims the window exactly once when concurrent publications race', async () => {
|
|
488
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
|
|
489
|
+
|
|
490
|
+
const { allocation } = await sendOriginal();
|
|
491
|
+
await activate(allocation.refreshId);
|
|
492
|
+
|
|
493
|
+
const updated = await updatedCredential(allocation);
|
|
494
|
+
|
|
495
|
+
const [first, second] = await Promise.all([
|
|
496
|
+
publishIssuerSigned(allocation.refreshId, updated, {
|
|
497
|
+
idempotencyKey: 'email-concurrent',
|
|
498
|
+
notifyHolder: true,
|
|
499
|
+
}),
|
|
500
|
+
publishIssuerSigned(allocation.refreshId, updated, {
|
|
501
|
+
idempotencyKey: 'email-concurrent',
|
|
502
|
+
notifyHolder: true,
|
|
503
|
+
}),
|
|
504
|
+
]);
|
|
505
|
+
|
|
506
|
+
expect(first.version).toEqual(2);
|
|
507
|
+
expect(second.version).toEqual(2);
|
|
508
|
+
expect(emailDeliveries()).toHaveLength(1);
|
|
509
|
+
|
|
510
|
+
const windowKey = computeCredentialRefreshDeliveryKey(allocation.refreshId);
|
|
511
|
+
const record = await getCredentialRefreshEmailDelivery(allocation.refreshId, windowKey);
|
|
512
|
+
expect(record?.state).toEqual('delivered');
|
|
513
|
+
expect(record?.attempts).toEqual(1);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('records email failure without changing the in-app outcome or retrying the window', async () => {
|
|
517
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
|
|
518
|
+
|
|
519
|
+
const { allocation } = await sendOriginal();
|
|
520
|
+
await activate(allocation.refreshId);
|
|
521
|
+
|
|
522
|
+
emailSendSpy.mockRejectedValueOnce(new Error('smtp unavailable'));
|
|
523
|
+
|
|
524
|
+
const updated = await updatedCredential(allocation);
|
|
525
|
+
|
|
526
|
+
const first = await publishIssuerSigned(allocation.refreshId, updated, {
|
|
527
|
+
idempotencyKey: 'email-failure',
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// Publication + in-app notification are unaffected by the email failure.
|
|
531
|
+
const refreshEvents = addNotificationToQueueSpy.mock.calls
|
|
532
|
+
.map(call => call[0] as LCNNotification)
|
|
533
|
+
.filter(notification => notification.type === 'CREDENTIAL_REFRESHED');
|
|
534
|
+
expect(first.notification).toEqual('queued');
|
|
535
|
+
expect(refreshEvents).toHaveLength(1);
|
|
536
|
+
expect(emailSendSpy).toHaveBeenCalledTimes(1);
|
|
537
|
+
|
|
538
|
+
const windowKey = computeCredentialRefreshDeliveryKey(allocation.refreshId);
|
|
539
|
+
const record = await getCredentialRefreshEmailDelivery(allocation.refreshId, windowKey);
|
|
540
|
+
expect(record?.state).toEqual('failed');
|
|
541
|
+
|
|
542
|
+
// A retry in the same window must not attempt a second send.
|
|
543
|
+
const retry = await publishIssuerSigned(allocation.refreshId, updated, {
|
|
544
|
+
idempotencyKey: 'email-failure',
|
|
545
|
+
});
|
|
546
|
+
expect(retry.notification).toEqual('queued');
|
|
547
|
+
expect(emailSendSpy).toHaveBeenCalledTimes(1);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('routes a managed child update to the verified guardian, not the child', async () => {
|
|
551
|
+
const guardian = await getUser('e'.repeat(64));
|
|
552
|
+
await guardian.clients.fullAuth.profile.createProfile({
|
|
553
|
+
profileId: GUARDIAN_PROFILE_ID,
|
|
554
|
+
displayName: 'Email Test Guardian',
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL, { primary: true });
|
|
558
|
+
await addEmail(GUARDIAN_PROFILE_ID, GUARDIAN_VERIFIED_EMAIL, { primary: true });
|
|
559
|
+
|
|
560
|
+
await linkGuardian(GUARDIAN_PROFILE_ID, HOLDER_PROFILE_ID);
|
|
561
|
+
expect(await isProfileManaged(HOLDER_PROFILE_ID)).toBe(true);
|
|
562
|
+
|
|
563
|
+
const { allocation } = await sendOriginal();
|
|
564
|
+
await activate(allocation.refreshId);
|
|
565
|
+
|
|
566
|
+
const result = await publishIssuerSigned(
|
|
567
|
+
allocation.refreshId,
|
|
568
|
+
await updatedCredential(allocation)
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
// In-app remains addressed to the child (unchanged); email goes to the guardian.
|
|
572
|
+
expect(result.notification).toEqual('queued');
|
|
573
|
+
|
|
574
|
+
const deliveries = emailDeliveries();
|
|
575
|
+
expect(deliveries).toHaveLength(1);
|
|
576
|
+
expect(deliveries[0]!.contactMethod.value).toEqual(GUARDIAN_VERIFIED_EMAIL);
|
|
577
|
+
expect(JSON.stringify(deliveries[0])).not.toContain(HOLDER_VERIFIED_EMAIL);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('skips a managed child when no guardian has a verified email', async () => {
|
|
581
|
+
const guardian = await getUser('e'.repeat(64));
|
|
582
|
+
await guardian.clients.fullAuth.profile.createProfile({
|
|
583
|
+
profileId: GUARDIAN_PROFILE_ID,
|
|
584
|
+
displayName: 'Email Test Guardian',
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL, { primary: true });
|
|
588
|
+
|
|
589
|
+
await linkGuardian(GUARDIAN_PROFILE_ID, HOLDER_PROFILE_ID);
|
|
590
|
+
|
|
591
|
+
const { allocation } = await sendOriginal();
|
|
592
|
+
await activate(allocation.refreshId);
|
|
593
|
+
|
|
594
|
+
const result = await publishIssuerSigned(
|
|
595
|
+
allocation.refreshId,
|
|
596
|
+
await updatedCredential(allocation)
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
expect(result.notification).toEqual('queued');
|
|
600
|
+
expect(emailDeliveries()).toHaveLength(0);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('keeps in-app payloads free of the email title', async () => {
|
|
604
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
|
|
605
|
+
|
|
606
|
+
const { allocation } = await sendOriginal();
|
|
607
|
+
await activate(allocation.refreshId);
|
|
608
|
+
|
|
609
|
+
await publishIssuerSigned(allocation.refreshId, await updatedCredential(allocation));
|
|
610
|
+
|
|
611
|
+
const refreshEvents = addNotificationToQueueSpy.mock.calls
|
|
612
|
+
.map(call => call[0] as LCNNotification)
|
|
613
|
+
.filter(notification => notification.type === 'CREDENTIAL_REFRESHED');
|
|
614
|
+
|
|
615
|
+
expect(refreshEvents).toHaveLength(1);
|
|
616
|
+
expect(JSON.stringify(refreshEvents[0])).not.toContain(SECRET_TITLE);
|
|
617
|
+
|
|
618
|
+
// Email still carries the title.
|
|
619
|
+
expect(emailDeliveries()[0]!.templateModel).toMatchObject({
|
|
620
|
+
credential: { name: SECRET_TITLE },
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('renders generic copy and persists no title when the publication has none', async () => {
|
|
625
|
+
// An issuer-signed update that omits `name` must not fail delivery: the
|
|
626
|
+
// aggregate keeps no display title and the email falls back to generic copy.
|
|
627
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
|
|
628
|
+
|
|
629
|
+
const { allocation } = await sendOriginal();
|
|
630
|
+
await activate(allocation.refreshId);
|
|
631
|
+
|
|
632
|
+
const titleless = await signAs(
|
|
633
|
+
issuer,
|
|
634
|
+
buildUnsignedCredential(allocation, {
|
|
635
|
+
validFrom: '2026-02-01T00:00:00Z',
|
|
636
|
+
name: undefined,
|
|
637
|
+
})
|
|
638
|
+
);
|
|
639
|
+
await publishIssuerSigned(allocation.refreshId, titleless);
|
|
640
|
+
|
|
641
|
+
const deliveries = emailDeliveries();
|
|
642
|
+
expect(deliveries).toHaveLength(1);
|
|
643
|
+
expect(deliveries[0]!.templateModel).toEqual({ issuer: { name: expect.any(String) } });
|
|
644
|
+
|
|
645
|
+
const aggregate = await getCredentialRefresh(allocation.refreshId);
|
|
646
|
+
expect(aggregate?.credentialDisplayName ?? undefined).toBeUndefined();
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('persists the bounded display title on the aggregate and head version', async () => {
|
|
650
|
+
await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL);
|
|
651
|
+
|
|
652
|
+
const { allocation } = await sendOriginal();
|
|
653
|
+
await activate(allocation.refreshId);
|
|
654
|
+
|
|
655
|
+
await publishIssuerSigned(allocation.refreshId, await updatedCredential(allocation));
|
|
656
|
+
|
|
657
|
+
const aggregate = await getCredentialRefresh(allocation.refreshId);
|
|
658
|
+
const head = await getCredentialRefreshHead(allocation.refreshId);
|
|
659
|
+
|
|
660
|
+
expect(aggregate?.credentialDisplayName).toEqual(SECRET_TITLE);
|
|
661
|
+
expect(head?.credentialDisplayName).toEqual(SECRET_TITLE);
|
|
662
|
+
});
|
|
663
|
+
});
|
|
@@ -208,6 +208,8 @@ describe('Credential Refresh Publication', () => {
|
|
|
208
208
|
beforeEach(async () => {
|
|
209
209
|
await runQuery('MATCH (r:CredentialRefresh) DETACH DELETE r');
|
|
210
210
|
await runQuery('MATCH (c:Credential) DETACH DELETE c');
|
|
211
|
+
// Other refresh suites allocate status lists in this shared test database.
|
|
212
|
+
await runQuery('MATCH (l:BitstringStatusList) DETACH DELETE l');
|
|
211
213
|
await runQuery('MATCH (p:Profile) DETACH DELETE p');
|
|
212
214
|
|
|
213
215
|
await issuer.clients.fullAuth.profile.createProfile({ profileId: ISSUER_PROFILE_ID });
|