@learncard/network-brain-service 3.19.1 → 4.0.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 +44 -111
- package/AGENTS.md +52 -0
- package/CHANGELOG.md +120 -0
- package/README.md +9 -2
- package/bootstrapLambda.test.ts +72 -0
- package/bundle.mjs +9 -1
- package/config/config.dev.json +29 -0
- package/config/config.example.json +29 -0
- package/config/config.json +1 -0
- package/config/config.production.json +29 -0
- package/config/config.scouts.dev.json +24 -0
- package/config/config.scouts.production.json +24 -0
- package/contractEventsLambda.ts +18 -0
- package/contractEventsLambdaApp.ts +19 -0
- package/didWebLambda.ts +13 -18
- package/didWebLambdaApp.ts +22 -0
- package/dist/accesslayer/app-manifest-version/create.d.ts +10 -0
- package/dist/accesslayer/app-manifest-version/create.d.ts.map +1 -0
- package/dist/accesslayer/app-manifest-version/read.d.ts +11 -0
- package/dist/accesslayer/app-manifest-version/read.d.ts.map +1 -0
- package/dist/accesslayer/app-manifest-version/update.d.ts +7 -0
- package/dist/accesslayer/app-manifest-version/update.d.ts.map +1 -0
- package/dist/accesslayer/app-store-listing/read.d.ts +2 -1
- package/dist/accesslayer/app-store-listing/read.d.ts.map +1 -1
- package/dist/accesslayer/app-store-listing/relationships/create.d.ts +1 -0
- package/dist/accesslayer/app-store-listing/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/app-store-listing/relationships/read.d.ts +2 -0
- package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/app-store-listing/update.d.ts +1 -0
- package/dist/accesslayer/app-store-listing/update.d.ts.map +1 -1
- package/dist/accesslayer/boost/relationships/create.d.ts +2 -1
- package/dist/accesslayer/boost/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts +9 -1
- package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/read.d.ts +34 -3
- package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +4 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/delete.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +3 -2
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts +12 -0
- package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts.map +1 -0
- package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts +12 -0
- package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts.map +1 -0
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +8 -5
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential/create.d.ts +2 -1
- package/dist/accesslayer/credential/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/relationships/create.d.ts +4 -3
- package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/relationships/create.d.ts +2 -1
- package/dist/accesslayer/inbox-credential/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/update.d.ts +2 -1
- package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
- package/dist/accesslayer/share-link/abandon.d.ts.map +1 -1
- package/dist/accesslayer/share-link/attachment.d.ts +37 -0
- package/dist/accesslayer/share-link/attachment.d.ts.map +1 -0
- package/dist/accesslayer/share-link/finalize.d.ts.map +1 -1
- package/dist/accesslayer/share-link/helpers.d.ts +3 -0
- package/dist/accesslayer/share-link/helpers.d.ts.map +1 -1
- package/dist/accesslayer/share-link/recovery.d.ts.map +1 -1
- package/dist/accesslayer/share-link/reserve.d.ts.map +1 -1
- package/dist/accesslayer/share-link/revoke.d.ts.map +1 -1
- package/dist/accesslayer/share-link/types.d.ts +8 -3
- package/dist/accesslayer/share-link/types.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +49 -0
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/config/applyDockerStageConfig.d.ts +2 -0
- package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
- package/dist/config/environment.d.ts +5 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
- package/dist/config/stageConfig.d.ts +112 -0
- package/dist/config/stageConfig.d.ts.map +1 -0
- package/dist/config/stageConfig.test.d.ts +2 -0
- package/dist/config/stageConfig.test.d.ts.map +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/docker-entry.d.ts.map +1 -1
- package/dist/helpers/app-manifest.helpers.d.ts +5 -0
- package/dist/helpers/app-manifest.helpers.d.ts.map +1 -0
- package/dist/helpers/claim-hooks.helpers.d.ts +2 -1
- package/dist/helpers/claim-hooks.helpers.d.ts.map +1 -1
- package/dist/helpers/consent-referral.helpers.d.ts +2 -0
- package/dist/helpers/consent-referral.helpers.d.ts.map +1 -0
- package/dist/helpers/consent-scopes.helpers.d.ts +7 -0
- package/dist/helpers/consent-scopes.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-event-maintenance.helpers.d.ts +3 -0
- package/dist/helpers/contract-event-maintenance.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-events.helpers.d.ts +25 -0
- package/dist/helpers/contract-events.helpers.d.ts.map +1 -0
- package/dist/helpers/contract-requests.helpers.d.ts +19 -0
- package/dist/helpers/contract-requests.helpers.d.ts.map +1 -0
- package/dist/helpers/finalize-inbox.helpers.d.ts +3 -3
- package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/inline-template.helpers.d.ts +45 -0
- package/dist/helpers/inline-template.helpers.d.ts.map +1 -0
- package/dist/helpers/notificationMessages.d.ts +1 -1
- package/dist/helpers/notificationMessages.d.ts.map +1 -1
- package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
- package/dist/helpers/notifications.helpers.d.ts +6 -0
- package/dist/helpers/notifications.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/share-link-attachment-access.d.ts +4 -0
- package/dist/helpers/share-link-attachment-access.d.ts.map +1 -0
- package/dist/helpers/share-link-attachment.d.ts +18 -0
- package/dist/helpers/share-link-attachment.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/attachment-stat.d.ts +5 -0
- package/dist/helpers/share-link-coordinator/attachment-stat.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -1
- package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/runner.d.ts +1 -0
- package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/runtime.d.ts +2 -0
- package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -1
- package/dist/helpers/share-link-maintenance/types.d.ts +4 -1
- package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -1
- package/dist/helpers/share-link-owner-projection.d.ts.map +1 -1
- package/dist/helpers/share-link-policy/production.d.ts +3 -5
- package/dist/helpers/share-link-policy/production.d.ts.map +1 -1
- package/dist/helpers/share-link-policy/resolver.d.ts +1 -1
- package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -1
- package/dist/helpers/share-link-policy/types.d.ts +8 -2
- package/dist/helpers/share-link-policy/types.d.ts.map +1 -1
- package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
- package/dist/helpers/smartResume.helpers.d.ts +3 -0
- package/dist/helpers/smartResume.helpers.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/AppManifestVersion.d.ts +20 -0
- package/dist/models/AppManifestVersion.d.ts.map +1 -0
- package/dist/models/AppStoreListing.d.ts +13 -0
- package/dist/models/AppStoreListing.d.ts.map +1 -1
- package/dist/models/ConsentFlowContract.d.ts +13 -2
- package/dist/models/ConsentFlowContract.d.ts.map +1 -1
- package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
- package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
- package/dist/models/Integration.d.ts +2 -0
- package/dist/models/Integration.d.ts.map +1 -1
- package/dist/models/ShareLink.d.ts +2 -0
- package/dist/models/ShareLink.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.map +1 -1
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -0
- package/dist/routes/app-store.d.ts +619 -0
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +2 -2
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +146 -3
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +34 -0
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +52 -2
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.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 +55 -0
- package/dist/routes/public-share-links.d.ts.map +1 -1
- package/dist/routes/share-links.d.ts +175 -14
- package/dist/routes/share-links.d.ts.map +1 -1
- package/dist/routes/test.d.ts +5 -0
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/types/app-manifest.d.ts +339 -0
- package/dist/types/app-manifest.d.ts.map +1 -0
- package/dist/types/app-store-listing.d.ts +34 -0
- package/dist/types/app-store-listing.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +30 -3
- package/dist/types/consentflowcontract.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +1 -1
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +49 -195
- package/lambdaApp.ts +203 -0
- package/package.json +20 -19
- package/project.json +8 -1
- package/scripts/export-openapi.ts +20 -0
- package/scripts/offline-schema-environment.ts +18 -0
- package/scripts/verify-python-contracts.ts +72 -0
- package/serverless-local.yml +3 -6
- package/serverless.function-env.cjs +50 -0
- package/serverless.local.yml +0 -1
- package/serverless.yml +29 -74
- package/shareLinkMaintenanceLambda.ts +13 -44
- package/shareLinkMaintenanceLambdaApp.ts +50 -0
- package/src/accesslayer/app-manifest-version/create.ts +57 -0
- package/src/accesslayer/app-manifest-version/read.ts +129 -0
- package/src/accesslayer/app-manifest-version/update.ts +61 -0
- package/src/accesslayer/app-store-listing/read.ts +6 -0
- package/src/accesslayer/app-store-listing/relationships/create.ts +20 -0
- package/src/accesslayer/app-store-listing/relationships/read.ts +24 -1
- package/src/accesslayer/app-store-listing/update.ts +15 -0
- package/src/accesslayer/boost/relationships/create.ts +8 -2
- package/src/accesslayer/consentflowcontract/create.ts +81 -8
- package/src/accesslayer/consentflowcontract/read.ts +84 -10
- package/src/accesslayer/consentflowcontract/relationships/create.ts +116 -93
- package/src/accesslayer/consentflowcontract/relationships/delete.ts +1 -0
- package/src/accesslayer/consentflowcontract/relationships/read.ts +184 -108
- package/src/accesslayer/consentflowcontract/relationships/recipients.ts +125 -0
- package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +125 -0
- package/src/accesslayer/consentflowcontract/relationships/update.ts +240 -189
- package/src/accesslayer/credential/create.test.ts +2 -115
- package/src/accesslayer/credential/create.ts +11 -6
- package/src/accesslayer/credential/relationships/create.ts +17 -10
- package/src/accesslayer/inbox-credential/relationships/create.ts +70 -48
- package/src/accesslayer/inbox-credential/update.ts +4 -2
- package/src/accesslayer/share-link/abandon.ts +20 -0
- package/src/accesslayer/share-link/attachment.ts +360 -0
- package/src/accesslayer/share-link/finalize.ts +55 -7
- package/src/accesslayer/share-link/helpers.ts +33 -2
- package/src/accesslayer/share-link/recovery.ts +20 -0
- package/src/accesslayer/share-link/reserve.ts +85 -6
- package/src/accesslayer/share-link/revoke.ts +35 -0
- package/src/accesslayer/share-link/types.ts +9 -4
- package/src/cache/app-store.caches.ts +35 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/environment.test.ts +17 -12
- package/src/config/environment.ts +1 -0
- package/src/config/serverlessFunctionEnv.test.ts +105 -0
- package/src/config/stageConfig.test.ts +25 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +24 -0
- package/src/helpers/app-manifest.helpers.ts +185 -0
- package/src/helpers/claim-hooks.helpers.ts +20 -7
- package/src/helpers/consent-referral.helpers.ts +6 -0
- package/src/helpers/consent-scopes.helpers.ts +58 -0
- package/src/helpers/contract-event-maintenance.helpers.ts +43 -0
- package/src/helpers/contract-events.helpers.ts +540 -0
- package/src/helpers/contract-requests.helpers.ts +211 -0
- package/src/helpers/finalize-inbox.helpers.ts +163 -44
- package/src/helpers/inbox.helpers.ts +51 -66
- package/src/helpers/inline-template.helpers.ts +560 -0
- package/src/helpers/notificationMessages.ts +59 -0
- package/src/helpers/notificationQueue.helpers.ts +23 -5
- package/src/helpers/notifications.helpers.ts +25 -1
- package/src/helpers/profile.helpers.ts +6 -0
- package/src/helpers/share-link-attachment-access.ts +87 -0
- package/src/helpers/share-link-attachment.ts +115 -0
- package/src/helpers/share-link-coordinator/attachment-stat.ts +41 -0
- package/src/helpers/share-link-coordinator/coordinator.ts +15 -2
- package/src/helpers/share-link-coordinator/recovery-runner.ts +15 -0
- package/src/helpers/share-link-maintenance/runner.ts +28 -0
- package/src/helpers/share-link-maintenance/runtime.ts +6 -0
- package/src/helpers/share-link-maintenance/telemetry.ts +3 -0
- package/src/helpers/share-link-maintenance/types.ts +2 -0
- package/src/helpers/share-link-owner-projection.ts +2 -0
- package/src/helpers/share-link-policy/production.ts +37 -17
- package/src/helpers/share-link-policy/resolver.ts +16 -11
- package/src/helpers/share-link-policy/types.ts +15 -7
- package/src/helpers/signingAuthority.helpers.ts +23 -14
- package/src/helpers/smartResume.helpers.ts +123 -0
- package/src/models/AppManifestVersion.ts +43 -0
- package/src/models/AppStoreListing.ts +32 -0
- package/src/models/ConsentFlowContract.ts +34 -3
- package/src/models/ConsentFlowTerms.ts +10 -0
- package/src/models/ConsentFlowTransaction.ts +3 -0
- package/src/models/Integration.ts +2 -0
- package/src/models/ShareLink.ts +4 -0
- package/src/models/index.ts +23 -0
- package/src/models/share-link-constraints.ts +5 -0
- package/src/openapi.ts +44 -8
- package/src/routes/app-store.ts +1054 -27
- package/src/routes/boosts.ts +12 -14
- package/src/routes/contracts.ts +382 -262
- package/src/routes/inbox.ts +4 -2
- package/src/routes/index.ts +4 -1
- package/src/routes/profile-manager.ts +19 -0
- package/src/routes/profiles.ts +12 -0
- package/src/routes/public-share-links.ts +169 -0
- package/src/routes/share-links.ts +69 -0
- package/src/routes/workflows.ts +3 -2
- package/src/types/app-manifest.ts +28 -0
- package/src/types/app-store-listing.ts +13 -0
- package/src/types/consentflowcontract.ts +25 -4
- package/test/app-manifest.helpers.spec.ts +170 -0
- package/test/app-manifest.routes.spec.ts +299 -0
- package/test/app-store-listing-updates.spec.ts +236 -0
- package/test/app-store-listing.spec.ts +65 -3
- package/test/consent-data-boundaries.spec.ts +352 -0
- package/test/consent-recipients.spec.ts +513 -0
- package/test/consent-scopes.helpers.spec.ts +67 -0
- package/test/consentflow.spec.ts +63 -2
- package/test/contract-requests.spec.ts +1588 -0
- package/test/credential-storage.neo4j.spec.ts +122 -0
- package/test/guardian-gated-routes.spec.ts +36 -0
- package/test/inbox-security.e2e.ts +26 -10
- package/test/inbox.spec.ts +184 -8
- package/test/inline-template.helpers.spec.ts +49 -0
- package/test/managed-recipient-inbox.spec.ts +24 -2
- package/test/openapi.spec.ts +14 -73
- package/test/profiles.spec.ts +30 -0
- package/test/share-link-attachment.neo4j.spec.ts +262 -0
- package/test/share-link-attachment.unit.spec.ts +244 -0
- package/test/share-link-lifecycle.neo4j.spec.ts +145 -2
- package/test/share-link-maintenance.unit.spec.ts +32 -5
- package/test/share-link-owner-route.unit.spec.ts +70 -2
- package/test/share-link-policy.unit.spec.ts +18 -4
- package/test/share-link-production-policy.unit.spec.ts +58 -9
- package/test/share-link-public-route.unit.spec.ts +279 -0
- package/test/share-link-service-policy.neo4j.spec.ts +403 -0
- package/test/signing-authority-encryption.spec.ts +75 -0
- package/test/smart-resume-consent.spec.ts +233 -0
- package/test/smart-resume-upload.spec.ts +65 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +13 -0
- package/vitest.inbox-e2e.config.ts +2 -0
- package/workerLambda.test.ts +100 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isServiceProfileExemptFromGuardianship } from '@helpers/profile.helpers';
|
|
1
2
|
import { QueryBuilder, BindParam } from 'neogma';
|
|
2
3
|
import mapValues from 'lodash/mapValues';
|
|
3
4
|
import {
|
|
@@ -38,13 +39,15 @@ import { ConsentFlowGuardianApprovalValidator } from '@learncard/types';
|
|
|
38
39
|
import { FlatBoostType } from 'types/boost';
|
|
39
40
|
import { constructUri, getIdFromUri } from '@helpers/uri.helpers';
|
|
40
41
|
import { flattenObject, inflateObject } from '@helpers/objects.helpers';
|
|
41
|
-
import {
|
|
42
|
+
import { shouldIncludeCategory } from '@helpers/contract.helpers';
|
|
42
43
|
import { ProfileType } from 'types/profile';
|
|
43
44
|
import { CredentialType } from 'types/credential';
|
|
44
45
|
import { getBoostUri } from '@helpers/boost.helpers';
|
|
45
46
|
import { getCredentialUri } from '@helpers/credential.helpers';
|
|
46
47
|
import { getProfilesThatManageAProfile } from '@accesslayer/profile/relationships/read';
|
|
47
|
-
import { getDidWeb } from '@helpers/did.helpers';
|
|
48
|
+
import { getDidWeb, updateDidForProfile } from '@helpers/did.helpers';
|
|
49
|
+
import { getProfileByProfileId } from '@accesslayer/profile/read';
|
|
50
|
+
import { sanitizeProfileForTier } from '@helpers/profile-privacy.helpers';
|
|
48
51
|
|
|
49
52
|
export const isProfileConsentFlowContractAdmin = async (
|
|
50
53
|
profile: ProfileType,
|
|
@@ -279,6 +282,7 @@ export const getConsentedContractsForProfile = async (
|
|
|
279
282
|
owner: LCNProfile;
|
|
280
283
|
terms: DbTermsType;
|
|
281
284
|
autoBoosts: string[];
|
|
285
|
+
recipients: ProfileType[];
|
|
282
286
|
}[]
|
|
283
287
|
> => {
|
|
284
288
|
let query = new QueryBuilder(
|
|
@@ -313,9 +317,19 @@ export const getConsentedContractsForProfile = async (
|
|
|
313
317
|
owner: LCNProfile;
|
|
314
318
|
terms: FlatDbTermsType;
|
|
315
319
|
boost: any[];
|
|
320
|
+
recipients: { properties: ProfileType }[];
|
|
316
321
|
}>(
|
|
317
322
|
await query
|
|
318
|
-
.
|
|
323
|
+
.with('contract, owner, terms, COLLECT(boost) as boost')
|
|
324
|
+
.match({
|
|
325
|
+
optional: true,
|
|
326
|
+
related: [
|
|
327
|
+
{ identifier: 'contract' },
|
|
328
|
+
ConsentFlowContract.getRelationshipByAlias('sharesDataWith'),
|
|
329
|
+
{ identifier: 'recipient', model: Profile },
|
|
330
|
+
],
|
|
331
|
+
})
|
|
332
|
+
.return('contract, owner, terms, boost, COLLECT(DISTINCT recipient) as recipients')
|
|
319
333
|
.orderBy('terms.updatedAt')
|
|
320
334
|
.limit(limit)
|
|
321
335
|
.run()
|
|
@@ -323,6 +337,7 @@ export const getConsentedContractsForProfile = async (
|
|
|
323
337
|
|
|
324
338
|
return results.map(result => ({
|
|
325
339
|
...result,
|
|
340
|
+
recipients: result.recipients.map(recipient => recipient.properties),
|
|
326
341
|
contract: inflateObject(result.contract),
|
|
327
342
|
terms: inflateObject(result.terms),
|
|
328
343
|
autoBoosts: result.boost.map(boost => getBoostUri(boost.properties.id, domain)),
|
|
@@ -412,11 +427,21 @@ export const getHolderExportMetadataForProfile = async (
|
|
|
412
427
|
createdAt: record.contract.createdAt,
|
|
413
428
|
updatedAt: record.contract.updatedAt,
|
|
414
429
|
uri: constructUri('contract', record.contract.id, domain),
|
|
415
|
-
owner: record.owner,
|
|
430
|
+
owner: updateDidForProfile(domain, record.owner),
|
|
416
431
|
...(record.contract.expiresAt ? { expiresAt: record.contract.expiresAt } : {}),
|
|
417
432
|
autoBoosts: record.autoBoosts,
|
|
433
|
+
// This is the audience at export time, not a historical consent snapshot.
|
|
434
|
+
audienceVersion: Number(record.contract.audienceVersion ?? 0),
|
|
435
|
+
recipients: record.recipients.map(recipient => {
|
|
436
|
+
const publicProfile = updateDidForProfile(domain, recipient);
|
|
437
|
+
return {
|
|
438
|
+
...sanitizeProfileForTier(publicProfile, 'unauthenticated'),
|
|
439
|
+
did: publicProfile.did,
|
|
440
|
+
};
|
|
441
|
+
}),
|
|
418
442
|
},
|
|
419
443
|
terms: record.terms.terms,
|
|
444
|
+
...(record.terms.referral ? { referral: record.terms.referral } : {}),
|
|
420
445
|
transactions,
|
|
421
446
|
});
|
|
422
447
|
}
|
|
@@ -442,103 +467,135 @@ export const getHolderExportMetadataForProfile = async (
|
|
|
442
467
|
};
|
|
443
468
|
};
|
|
444
469
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
// If query key is not present, return all terms whether they have it or not
|
|
466
|
-
}).where(`
|
|
467
|
-
all(key IN keys($params) WHERE
|
|
468
|
-
CASE $params[key]
|
|
469
|
-
WHEN true THEN terms[key] IS NOT NULL AND terms[key] <> []
|
|
470
|
-
WHEN false THEN terms[key] IS NULL OR terms[key] = []
|
|
471
|
-
END
|
|
472
|
-
)
|
|
473
|
-
`);
|
|
474
|
-
|
|
475
|
-
const dbQuery = cursor ? _dbQuery.raw('terms.updatedAt < $cursor') : _dbQuery;
|
|
476
|
-
|
|
477
|
-
const results = convertQueryResultToPropertiesObjectArray<{
|
|
478
|
-
terms: FlatDbTermsType;
|
|
479
|
-
}>(await dbQuery.return('DISTINCT terms').orderBy('terms.updatedAt DESC').limit(limit).run());
|
|
480
|
-
|
|
481
|
-
const terms = results.map(result => inflateObject<any>(result.terms));
|
|
470
|
+
/** Match filters against currently permitted data before counting a page. */
|
|
471
|
+
const matchesConsentedDataQuery = (
|
|
472
|
+
term: DbTermsType,
|
|
473
|
+
query: ConsentFlowDataQuery,
|
|
474
|
+
now: number
|
|
475
|
+
): boolean =>
|
|
476
|
+
// Legacy boolean data queries test field presence, including an explicit false value.
|
|
477
|
+
(query.anonymize === undefined || query.anonymize === (term.terms.read.anonymize != null)) &&
|
|
478
|
+
Object.entries(query.personal ?? {}).every(
|
|
479
|
+
([key, required]) => required === (term.terms.read.personal?.[key] != null)
|
|
480
|
+
) &&
|
|
481
|
+
Object.entries(query.credentials?.categories ?? {}).every(([name, required]) => {
|
|
482
|
+
const category = term.terms.read.credentials.categories[name];
|
|
483
|
+
const shared =
|
|
484
|
+
term.terms.read.credentials.sharing !== false &&
|
|
485
|
+
!!category?.sharing &&
|
|
486
|
+
isConsentExpiryActive(category.shareUntil, now) &&
|
|
487
|
+
!!category.shared?.length;
|
|
488
|
+
return required === shared;
|
|
489
|
+
});
|
|
482
490
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
},
|
|
491
|
-
|
|
491
|
+
const projectConsentedData = (
|
|
492
|
+
term: DbTermsType,
|
|
493
|
+
query: ConsentFlowDataQuery,
|
|
494
|
+
now: number
|
|
495
|
+
): ConsentFlowContractData => ({
|
|
496
|
+
date: term.updatedAt!,
|
|
497
|
+
credentials: {
|
|
498
|
+
categories: mapValues(term.terms.read.credentials?.categories ?? {}, (category, name) =>
|
|
499
|
+
term.terms.read.credentials.sharing !== false &&
|
|
500
|
+
category.sharing &&
|
|
501
|
+
isConsentExpiryActive(category.shareUntil, now) &&
|
|
502
|
+
shouldIncludeCategory(query.credentials?.categories, name)
|
|
503
|
+
? (category.shared ?? [])
|
|
504
|
+
: []
|
|
505
|
+
),
|
|
506
|
+
},
|
|
507
|
+
personal: term.terms.read.personal ?? {},
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
/** Filters reduce candidates in Neo4j; batches then reject invalid/expired grants.
|
|
511
|
+
* Do not cap scanned candidates: doing so would silently hide valid matches later in a page.
|
|
512
|
+
* Expiry and negative category filters still require the authoritative JS permission check.
|
|
513
|
+
*/
|
|
514
|
+
const getPermittedContractData = async (
|
|
515
|
+
viewerProfileId: string,
|
|
516
|
+
{ query = {}, limit, cursor }: { query?: ConsentFlowDataQuery; limit: number; cursor?: string },
|
|
517
|
+
contractId?: string
|
|
518
|
+
): Promise<ConsentFlowContractData[]> => {
|
|
519
|
+
const now = Date.now();
|
|
520
|
+
const records: ConsentFlowContractData[] = [];
|
|
521
|
+
const personalFilters = Object.entries(query.personal ?? {}).map(([name, required]) => ({
|
|
522
|
+
key: `terms.read.personal.${name}`,
|
|
523
|
+
required,
|
|
492
524
|
}));
|
|
525
|
+
const sharedCategories = Object.entries(query.credentials?.categories ?? {})
|
|
526
|
+
.filter(([, required]) => required)
|
|
527
|
+
.map(([name]) => ({
|
|
528
|
+
sharingKey: `terms.read.credentials.categories.${name}.sharing`,
|
|
529
|
+
sharedKey: `terms.read.credentials.categories.${name}.shared`,
|
|
530
|
+
}));
|
|
531
|
+
const batchSize = Math.max(limit, 50);
|
|
532
|
+
let offset = 0;
|
|
533
|
+
while (records.length < limit) {
|
|
534
|
+
const dbQuery = new QueryBuilder(
|
|
535
|
+
new BindParam({ cursor, personalFilters, sharedCategories })
|
|
536
|
+
)
|
|
537
|
+
.match({
|
|
538
|
+
related: [
|
|
539
|
+
{ identifier: 'terms', model: ConsentFlowTerms },
|
|
540
|
+
ConsentFlowTerms.getRelationshipByAlias('consentsTo'),
|
|
541
|
+
{
|
|
542
|
+
model: ConsentFlowContract,
|
|
543
|
+
identifier: 'contract',
|
|
544
|
+
...(contractId ? { where: { id: contractId } } : {}),
|
|
545
|
+
},
|
|
546
|
+
'-[:CREATED_BY|SHARES_DATA_WITH]->',
|
|
547
|
+
{ model: Profile, where: { profileId: viewerProfileId } },
|
|
548
|
+
],
|
|
549
|
+
})
|
|
550
|
+
.where(
|
|
551
|
+
"(terms.status = 'live' OR (terms.status = 'stale' AND terms.oneTime = true))" +
|
|
552
|
+
(cursor ? ' AND terms.updatedAt < $cursor' : '') +
|
|
553
|
+
' AND all(filter IN $personalFilters WHERE filter.required = (terms[filter.key] IS NOT NULL))' +
|
|
554
|
+
' AND (size($sharedCategories) = 0 OR coalesce(terms.`terms.read.credentials.sharing`, true) = true)' +
|
|
555
|
+
' AND all(category IN $sharedCategories WHERE terms[category.sharingKey] = true AND' +
|
|
556
|
+
" (terms[category.sharedKey] IS NOT NULL OR any(key IN keys(terms) WHERE key STARTS WITH category.sharedKey + '.')))"
|
|
557
|
+
);
|
|
558
|
+
const results = convertQueryResultToPropertiesObjectArray<{
|
|
559
|
+
terms: FlatDbTermsType;
|
|
560
|
+
contract: FlatDbContractType;
|
|
561
|
+
}>(
|
|
562
|
+
await dbQuery
|
|
563
|
+
.return('DISTINCT terms, contract')
|
|
564
|
+
.orderBy('terms.updatedAt DESC, terms.id ASC')
|
|
565
|
+
.skip(offset)
|
|
566
|
+
.limit(batchSize)
|
|
567
|
+
.run()
|
|
568
|
+
);
|
|
569
|
+
for (const result of results) {
|
|
570
|
+
const term = DbTermsValidator.safeParse(inflateObject(result.terms));
|
|
571
|
+
const contract = DbContractValidator.safeParse(inflateObject(result.contract));
|
|
572
|
+
if (
|
|
573
|
+
!term.success ||
|
|
574
|
+
!contract.success ||
|
|
575
|
+
!isConsentExpiryActive(term.data.expiresAt, now) ||
|
|
576
|
+
!isConsentExpiryActive(contract.data.expiresAt, now) ||
|
|
577
|
+
!matchesConsentedDataQuery(term.data, query, now)
|
|
578
|
+
)
|
|
579
|
+
continue;
|
|
580
|
+
records.push(projectConsentedData(term.data, query, now));
|
|
581
|
+
if (records.length >= limit) return records;
|
|
582
|
+
}
|
|
583
|
+
if (results.length < batchSize) break;
|
|
584
|
+
offset += batchSize;
|
|
585
|
+
}
|
|
586
|
+
return records;
|
|
493
587
|
};
|
|
494
588
|
|
|
589
|
+
export const getConsentedDataForProfile = async (
|
|
590
|
+
profileId: string,
|
|
591
|
+
options: { query?: ConsentFlowDataQuery; limit: number; cursor?: string }
|
|
592
|
+
): Promise<ConsentFlowContractData[]> => getPermittedContractData(profileId, options);
|
|
593
|
+
|
|
495
594
|
export const getConsentedDataForContract = async (
|
|
496
595
|
id: string,
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
new BindParam({
|
|
501
|
-
params: flattenObject({ terms: flattenObject(convertDataQueryToNeo4jQuery(query)) }),
|
|
502
|
-
cursor,
|
|
503
|
-
})
|
|
504
|
-
).match({
|
|
505
|
-
related: [
|
|
506
|
-
{ identifier: 'terms', model: ConsentFlowTerms },
|
|
507
|
-
ConsentFlowTerms.getRelationshipByAlias('consentsTo'),
|
|
508
|
-
{ model: ConsentFlowContract, where: { id: id } },
|
|
509
|
-
],
|
|
510
|
-
// The following is custom Cypher logic that has the following behavior:
|
|
511
|
-
// If query key is true, ensure all resulting terms have that key
|
|
512
|
-
// If query key is false, ensure no resulting terms have that key
|
|
513
|
-
// If query key is not present, return all terms whether they have it or not
|
|
514
|
-
}).where(`
|
|
515
|
-
all(key IN keys($params) WHERE
|
|
516
|
-
CASE $params[key]
|
|
517
|
-
WHEN true THEN terms[key] IS NOT NULL AND terms[key] <> []
|
|
518
|
-
WHEN false THEN terms[key] IS NULL OR terms[key] = []
|
|
519
|
-
END
|
|
520
|
-
)
|
|
521
|
-
`);
|
|
522
|
-
|
|
523
|
-
const dbQuery = cursor ? _dbQuery.where('terms.updatedAt < $cursor') : _dbQuery;
|
|
524
|
-
|
|
525
|
-
const results = convertQueryResultToPropertiesObjectArray<{
|
|
526
|
-
terms: FlatDbTermsType;
|
|
527
|
-
}>(await dbQuery.return('DISTINCT terms').orderBy('terms.updatedAt DESC').limit(limit).run());
|
|
528
|
-
|
|
529
|
-
const terms = results.map(result => inflateObject<any>(result.terms));
|
|
530
|
-
|
|
531
|
-
return terms.map(term => ({
|
|
532
|
-
date: term.updatedAt!,
|
|
533
|
-
credentials: {
|
|
534
|
-
categories: mapValues(
|
|
535
|
-
term.terms.read.credentials.categories,
|
|
536
|
-
category => category.shared ?? []
|
|
537
|
-
),
|
|
538
|
-
},
|
|
539
|
-
personal: term.terms.read.personal,
|
|
540
|
-
}));
|
|
541
|
-
};
|
|
596
|
+
viewerProfileId: string,
|
|
597
|
+
options: { query?: ConsentFlowDataQuery; limit: number; cursor?: string }
|
|
598
|
+
): Promise<ConsentFlowContractData[]> => getPermittedContractData(viewerProfileId, options, id);
|
|
542
599
|
|
|
543
600
|
// Empty expiry values historically mean no expiry. Invalid nonempty values never authorize access.
|
|
544
601
|
const isConsentExpiryActive = (expiresAt: string | undefined, now: number): boolean => {
|
|
@@ -562,7 +619,17 @@ export const getConsentedDataBetweenProfiles = async (
|
|
|
562
619
|
const { whereClause: contractWhereClause, params: contractQueryParams } =
|
|
563
620
|
buildWhereForQueryBuilder('contract', convertedContractQuery as any);
|
|
564
621
|
const now = Date.now();
|
|
565
|
-
const managers = await
|
|
622
|
+
const [managers, consenterProfile] = await Promise.all([
|
|
623
|
+
getProfilesThatManageAProfile(consenterProfileId),
|
|
624
|
+
getProfileByProfileId(consenterProfileId),
|
|
625
|
+
]);
|
|
626
|
+
const isManagedProfile = managers.length > 0;
|
|
627
|
+
const requiresManagerApproval =
|
|
628
|
+
isManagedProfile &&
|
|
629
|
+
!isServiceProfileExemptFromGuardianship(
|
|
630
|
+
consenterProfile?.isServiceProfile,
|
|
631
|
+
consenterProfile?.type
|
|
632
|
+
);
|
|
566
633
|
const records: ConsentFlowContractDataForDid[] = [];
|
|
567
634
|
const batchSize = Math.max(limit, 50);
|
|
568
635
|
let offset = 0;
|
|
@@ -572,9 +639,6 @@ export const getConsentedDataBetweenProfiles = async (
|
|
|
572
639
|
while (records.length < limit) {
|
|
573
640
|
const _dbQuery = new QueryBuilder(
|
|
574
641
|
new BindParam({
|
|
575
|
-
params: flattenObject({
|
|
576
|
-
terms: flattenObject(convertDataQueryToNeo4jQuery(params)),
|
|
577
|
-
}),
|
|
578
642
|
cursor,
|
|
579
643
|
...contractQueryParams,
|
|
580
644
|
})
|
|
@@ -585,7 +649,7 @@ export const getConsentedDataBetweenProfiles = async (
|
|
|
585
649
|
{ identifier: 'terms', model: ConsentFlowTerms },
|
|
586
650
|
ConsentFlowTerms.getRelationshipByAlias('consentsTo'),
|
|
587
651
|
{ model: ConsentFlowContract, identifier: 'contract' },
|
|
588
|
-
|
|
652
|
+
'-[:CREATED_BY|SHARES_DATA_WITH]->',
|
|
589
653
|
{ model: Profile, where: { profileId: ownerProfileId } },
|
|
590
654
|
],
|
|
591
655
|
// The following is custom Cypher logic that has the following behavior:
|
|
@@ -594,12 +658,6 @@ export const getConsentedDataBetweenProfiles = async (
|
|
|
594
658
|
// If query key is not present, return all terms whether they have it or not
|
|
595
659
|
}).where(`
|
|
596
660
|
(terms.status = 'live' OR (terms.status = 'stale' AND terms.oneTime = true))
|
|
597
|
-
AND all(key IN keys($params) WHERE
|
|
598
|
-
CASE $params[key]
|
|
599
|
-
WHEN true THEN terms[key] IS NOT NULL AND terms[key] <> []
|
|
600
|
-
WHEN false THEN terms[key] IS NULL OR terms[key] = []
|
|
601
|
-
END
|
|
602
|
-
)
|
|
603
661
|
AND ${contractWhereClause}
|
|
604
662
|
`);
|
|
605
663
|
|
|
@@ -648,12 +706,14 @@ AND ${contractWhereClause}
|
|
|
648
706
|
)
|
|
649
707
|
continue;
|
|
650
708
|
|
|
709
|
+
if (!matchesConsentedDataQuery(term, params, now)) continue;
|
|
710
|
+
|
|
651
711
|
const parsedApproval = ConsentFlowGuardianApprovalValidator.safeParse(
|
|
652
712
|
term.guardianApproval
|
|
653
713
|
);
|
|
654
714
|
const approval = parsedApproval.success ? parsedApproval.data : undefined;
|
|
655
715
|
const required =
|
|
656
|
-
|
|
716
|
+
requiresManagerApproval || !!term.guardianApproval || result.hadGuardianApproval;
|
|
657
717
|
const approved =
|
|
658
718
|
!!approval &&
|
|
659
719
|
approval.contractUpdatedAt === contract.updatedAt &&
|
|
@@ -664,6 +724,7 @@ AND ${contractWhereClause}
|
|
|
664
724
|
getDidWeb(domain, manager.profileId) === approval.guardianDid)
|
|
665
725
|
);
|
|
666
726
|
|
|
727
|
+
const permittedData = projectConsentedData(term, params, now);
|
|
667
728
|
records.push({
|
|
668
729
|
date: result.date,
|
|
669
730
|
...(result.createdAt ? { createdAt: result.createdAt } : {}),
|
|
@@ -677,7 +738,22 @@ AND ${contractWhereClause}
|
|
|
677
738
|
termsUri: constructUri('terms', term.id, domain),
|
|
678
739
|
status: term.status,
|
|
679
740
|
...(term.expiresAt?.trim() ? { expiresAt: term.expiresAt } : {}),
|
|
680
|
-
terms:
|
|
741
|
+
terms: {
|
|
742
|
+
...term.terms,
|
|
743
|
+
read: {
|
|
744
|
+
...term.terms.read,
|
|
745
|
+
credentials: {
|
|
746
|
+
...term.terms.read.credentials,
|
|
747
|
+
categories: mapValues(
|
|
748
|
+
term.terms.read.credentials.categories,
|
|
749
|
+
(category, name) => ({
|
|
750
|
+
...category,
|
|
751
|
+
shared: permittedData.credentials.categories[name] ?? [],
|
|
752
|
+
})
|
|
753
|
+
),
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
},
|
|
681
757
|
credentials: Object.entries(term.terms.read.credentials.categories).flatMap(
|
|
682
758
|
([category, { shared, sharing, shareUntil }]) => {
|
|
683
759
|
if (
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { BindParam, QueryBuilder } from 'neogma';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import { ConsentFlowContract, Profile } from '@models';
|
|
4
|
+
import { ProfileType } from 'types/profile';
|
|
5
|
+
|
|
6
|
+
/** Lock the contract before checking its audience, in the same database statement. */
|
|
7
|
+
export const lockContractAudience = (query: QueryBuilder, contractId: string): QueryBuilder =>
|
|
8
|
+
query
|
|
9
|
+
.match({ model: ConsentFlowContract, identifier: 'contract', where: { id: contractId } })
|
|
10
|
+
.set('contract.audienceLock = coalesce(contract.audienceLock, 0) + 1')
|
|
11
|
+
.with('contract');
|
|
12
|
+
|
|
13
|
+
/** Retry only rolled-back transient transactions, before any notifications or issuance. */
|
|
14
|
+
export const runAudienceMutation = async (query: QueryBuilder): ReturnType<QueryBuilder['run']> => {
|
|
15
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
16
|
+
try {
|
|
17
|
+
return await query.run();
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (
|
|
20
|
+
attempt >= 8 ||
|
|
21
|
+
!error ||
|
|
22
|
+
typeof error !== 'object' ||
|
|
23
|
+
!('code' in error) ||
|
|
24
|
+
error.code !== 'Neo.TransientError.Transaction.DeadlockDetected'
|
|
25
|
+
)
|
|
26
|
+
throw error;
|
|
27
|
+
await new Promise(resolve =>
|
|
28
|
+
setTimeout(resolve, Math.min(1000, 25 * 2 ** attempt) + Math.random() * 25)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const audienceVersionWhere = `
|
|
35
|
+
(($audienceVersion IS NULL AND coalesce(contract.audienceVersion, 0) = 0)
|
|
36
|
+
OR coalesce(contract.audienceVersion, 0) = $audienceVersion)
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
/** Checked under the contract lock; a referral accepts only the invitation the learner reviewed. */
|
|
40
|
+
export const consentMutationWhere = `
|
|
41
|
+
(CASE WHEN contract.expiresAt IS NULL OR trim(contract.expiresAt) = '' THEN true
|
|
42
|
+
ELSE datetime(contract.expiresAt) > datetime.realtime() END)
|
|
43
|
+
AND ($expectedRequestId IS NULL OR EXISTS {
|
|
44
|
+
MATCH (contract)-[request:REQUESTED_FOR]->(:Profile {profileId: $consenterProfileId})
|
|
45
|
+
WHERE request.requestId = $expectedRequestId AND request.status = 'pending'
|
|
46
|
+
})
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
export const assertAudienceMutation = (count: number): void => {
|
|
50
|
+
if (!count)
|
|
51
|
+
throw new TRPCError({
|
|
52
|
+
code: 'CONFLICT',
|
|
53
|
+
message: 'The sharing audience or consent changed. Review the contract and try again.',
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const getRecipientsForContract = async (contractId: string): Promise<ProfileType[]> => {
|
|
58
|
+
const result = await new QueryBuilder()
|
|
59
|
+
.match({
|
|
60
|
+
related: [
|
|
61
|
+
{ model: ConsentFlowContract, where: { id: contractId } },
|
|
62
|
+
ConsentFlowContract.getRelationshipByAlias('sharesDataWith'),
|
|
63
|
+
{ model: Profile, identifier: 'recipient' },
|
|
64
|
+
],
|
|
65
|
+
})
|
|
66
|
+
.return('DISTINCT recipient')
|
|
67
|
+
.run();
|
|
68
|
+
return result.records.map(record => record.get('recipient').properties);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const canReadContractData = async (
|
|
72
|
+
contractId: string,
|
|
73
|
+
profileId: string
|
|
74
|
+
): Promise<boolean> => {
|
|
75
|
+
const result = await new QueryBuilder(new BindParam({ contractId, profileId }))
|
|
76
|
+
.match(
|
|
77
|
+
'(contract:ConsentFlowContract {id: $contractId})-[:CREATED_BY|SHARES_DATA_WITH]->(profile:Profile {profileId: $profileId})'
|
|
78
|
+
)
|
|
79
|
+
.return('count(profile) AS count')
|
|
80
|
+
.run();
|
|
81
|
+
return Number(result.records[0]?.get('count') ?? 0) > 0;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Additions freeze after first consent; removals never restore that permission. */
|
|
85
|
+
export const addRecipientToContract = async (
|
|
86
|
+
contractId: string,
|
|
87
|
+
profileId: string
|
|
88
|
+
): Promise<boolean> => {
|
|
89
|
+
const query = lockContractAudience(new QueryBuilder(new BindParam({ profileId })), contractId)
|
|
90
|
+
.match('(recipient:Profile {profileId: $profileId})')
|
|
91
|
+
.where(
|
|
92
|
+
`NOT EXISTS { MATCH (contract)-[:CREATED_BY]->(recipient) }
|
|
93
|
+
AND (EXISTS { MATCH (contract)-[:SHARES_DATA_WITH]->(recipient) }
|
|
94
|
+
OR (coalesce(contract.hasConsented, false) = false
|
|
95
|
+
AND NOT EXISTS { MATCH (:ConsentFlowTerms)-[:CONSENTS_TO]->(contract) }
|
|
96
|
+
AND size([(contract)-[:SHARES_DATA_WITH]->(existing:Profile) | existing]) < 50))`
|
|
97
|
+
)
|
|
98
|
+
.merge('(contract)-[audience:SHARES_DATA_WITH]->(recipient)')
|
|
99
|
+
.raw(
|
|
100
|
+
'ON CREATE SET contract.audienceVersion = coalesce(contract.audienceVersion, 0) + 1, audience.addedAt = $addedAt'
|
|
101
|
+
);
|
|
102
|
+
query.getBindParam().add({ addedAt: new Date().toISOString() });
|
|
103
|
+
const result = await runAudienceMutation(query.return('count(audience) AS count'));
|
|
104
|
+
if (!Number(result.records[0]?.get('count') ?? 0))
|
|
105
|
+
throw new TRPCError({
|
|
106
|
+
code: 'CONFLICT',
|
|
107
|
+
message:
|
|
108
|
+
'Recipients can only be added before first consent, and the owner is already in the audience.',
|
|
109
|
+
});
|
|
110
|
+
return true;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const removeRecipientFromContract = async (
|
|
114
|
+
contractId: string,
|
|
115
|
+
profileId: string
|
|
116
|
+
): Promise<boolean> => {
|
|
117
|
+
const result = await runAudienceMutation(
|
|
118
|
+
lockContractAudience(new QueryBuilder(new BindParam({ profileId })), contractId)
|
|
119
|
+
.match('(contract)-[audience:SHARES_DATA_WITH]->(:Profile {profileId: $profileId})')
|
|
120
|
+
.delete('audience')
|
|
121
|
+
.set('contract.audienceVersion = coalesce(contract.audienceVersion, 0) + 1')
|
|
122
|
+
.return('count(contract) AS count')
|
|
123
|
+
);
|
|
124
|
+
return Number(result.records[0]?.get('count') ?? 0) > 0;
|
|
125
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { BindParam, QueryBuilder } from 'neogma';
|
|
3
|
+
import { TRPCError } from '@trpc/server';
|
|
4
|
+
import type { ConsentFlowTerms } from '@learncard/types';
|
|
5
|
+
import { inflateObject } from '@helpers/objects.helpers';
|
|
6
|
+
import type { DbTermsType } from 'types/consentflowcontract';
|
|
7
|
+
import {
|
|
8
|
+
audienceVersionWhere,
|
|
9
|
+
assertAudienceMutation,
|
|
10
|
+
lockContractAudience,
|
|
11
|
+
runAudienceMutation,
|
|
12
|
+
} from './recipients';
|
|
13
|
+
|
|
14
|
+
const canonicalize = (value: unknown): unknown => {
|
|
15
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
16
|
+
if (value && typeof value === 'object')
|
|
17
|
+
return Object.fromEntries(
|
|
18
|
+
Object.entries(value)
|
|
19
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
20
|
+
.map(([key, entry]) => [key, canonicalize(entry)])
|
|
21
|
+
);
|
|
22
|
+
return value;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Bind retries to the original decision without persisting the recipient token. */
|
|
26
|
+
export const smartResumeFingerprint = (submission: unknown): string =>
|
|
27
|
+
createHash('sha256')
|
|
28
|
+
.update(JSON.stringify(canonicalize(submission)))
|
|
29
|
+
.digest('hex');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Claim publication only after consent committed, under the same audience lock.
|
|
33
|
+
* A bounded lease prevents concurrent retries; completed retries reuse the redirect.
|
|
34
|
+
* A failed publication retains consent and can retry the identical submission.
|
|
35
|
+
*/
|
|
36
|
+
export const publishSmartResume = async ({
|
|
37
|
+
contractId,
|
|
38
|
+
profileId,
|
|
39
|
+
termsId,
|
|
40
|
+
fingerprint,
|
|
41
|
+
audienceVersion,
|
|
42
|
+
expectedRequestId,
|
|
43
|
+
upload,
|
|
44
|
+
}: {
|
|
45
|
+
contractId: string;
|
|
46
|
+
profileId: string;
|
|
47
|
+
termsId: string;
|
|
48
|
+
fingerprint: string;
|
|
49
|
+
audienceVersion?: number;
|
|
50
|
+
expectedRequestId?: string;
|
|
51
|
+
upload: (terms: ConsentFlowTerms) => Promise<string | undefined>;
|
|
52
|
+
}): Promise<string | undefined> => {
|
|
53
|
+
const leaseId = randomUUID();
|
|
54
|
+
// Token request and credentials POST each time out after 30s. URI resolution
|
|
55
|
+
// must also finish before this lease expires, or publication must be retried.
|
|
56
|
+
const leaseDurationMs = 120_000;
|
|
57
|
+
const result = await runAudienceMutation(
|
|
58
|
+
lockContractAudience(
|
|
59
|
+
new QueryBuilder(
|
|
60
|
+
new BindParam({
|
|
61
|
+
profileId,
|
|
62
|
+
termsId,
|
|
63
|
+
fingerprint,
|
|
64
|
+
audienceVersion: audienceVersion ?? null,
|
|
65
|
+
expectedRequestId: expectedRequestId ?? null,
|
|
66
|
+
leaseId,
|
|
67
|
+
leaseDurationMs,
|
|
68
|
+
})
|
|
69
|
+
),
|
|
70
|
+
contractId
|
|
71
|
+
)
|
|
72
|
+
.where(audienceVersionWhere)
|
|
73
|
+
.match(
|
|
74
|
+
'(profile:Profile {profileId: $profileId})-[:CREATED_BY]->(terms:ConsentFlowTerms {id: $termsId})-[:CONSENTS_TO]->(contract)'
|
|
75
|
+
)
|
|
76
|
+
.where(
|
|
77
|
+
`terms.smartResumeFingerprint = $fingerprint
|
|
78
|
+
AND ($expectedRequestId IS NULL OR EXISTS {
|
|
79
|
+
MATCH (contract)-[request:REQUESTED_FOR]->(profile)
|
|
80
|
+
WHERE request.requestId = $expectedRequestId AND request.status = 'accepted'
|
|
81
|
+
})
|
|
82
|
+
AND terms.smartResumeMutationVersion = coalesce(terms.mutationVersion, 0)
|
|
83
|
+
AND (terms.status = 'live' OR (terms.status = 'stale' AND terms.oneTime = true))
|
|
84
|
+
AND CASE WHEN terms.expiresAt IS NULL OR trim(terms.expiresAt) = '' THEN true ELSE datetime(terms.expiresAt) > datetime.realtime() END
|
|
85
|
+
AND CASE WHEN contract.expiresAt IS NULL OR trim(contract.expiresAt) = '' THEN true ELSE datetime(contract.expiresAt) > datetime.realtime() END
|
|
86
|
+
AND (terms.smartResumePublicationStatus IN ['pending', 'failed', 'succeeded']
|
|
87
|
+
OR (terms.smartResumePublicationStatus = 'sending' AND terms.smartResumeLeaseUntil < datetime.realtime().epochMillis))`
|
|
88
|
+
)
|
|
89
|
+
.set(
|
|
90
|
+
`terms.smartResumeLeaseId = CASE WHEN terms.smartResumePublicationStatus = 'succeeded' THEN terms.smartResumeLeaseId ELSE $leaseId END,
|
|
91
|
+
terms.smartResumeLeaseUntil = CASE WHEN terms.smartResumePublicationStatus = 'succeeded' THEN terms.smartResumeLeaseUntil ELSE datetime.realtime().epochMillis + $leaseDurationMs END,
|
|
92
|
+
terms.smartResumePublicationStatus = CASE WHEN terms.smartResumePublicationStatus = 'succeeded' THEN 'succeeded' ELSE 'sending' END`
|
|
93
|
+
)
|
|
94
|
+
.return('terms')
|
|
95
|
+
);
|
|
96
|
+
assertAudienceMutation(result.records.length);
|
|
97
|
+
const accepted = inflateObject(result.records[0]!.get('terms').properties) as DbTermsType;
|
|
98
|
+
if (accepted.smartResumePublicationStatus === 'succeeded')
|
|
99
|
+
return accepted.smartResumeRedirectUrl;
|
|
100
|
+
|
|
101
|
+
const finish = async (status: 'failed' | 'succeeded', redirectUrl?: string): Promise<void> => {
|
|
102
|
+
await new QueryBuilder(
|
|
103
|
+
new BindParam({ termsId, leaseId, status, redirectUrl: redirectUrl ?? null })
|
|
104
|
+
)
|
|
105
|
+
.match('(terms:ConsentFlowTerms {id: $termsId})')
|
|
106
|
+
.where('terms.smartResumeLeaseId = $leaseId')
|
|
107
|
+
.set(
|
|
108
|
+
'terms.smartResumePublicationStatus = $status, terms.smartResumeRedirectUrl = $redirectUrl'
|
|
109
|
+
)
|
|
110
|
+
.run();
|
|
111
|
+
};
|
|
112
|
+
try {
|
|
113
|
+
const redirectUrl = await upload(accepted.terms);
|
|
114
|
+
await finish('succeeded', redirectUrl);
|
|
115
|
+
return redirectUrl;
|
|
116
|
+
} catch (cause) {
|
|
117
|
+
await finish('failed');
|
|
118
|
+
throw new TRPCError({
|
|
119
|
+
code: 'BAD_GATEWAY',
|
|
120
|
+
message:
|
|
121
|
+
'Your consent was saved, but the SmartResume upload failed. Retry to finish connecting.',
|
|
122
|
+
cause,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
};
|