@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
package/src/routes/app-store.ts
CHANGED
|
@@ -2,10 +2,20 @@ import { TRPCError } from '@trpc/server';
|
|
|
2
2
|
import { v4 as uuid } from 'uuid';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import {
|
|
5
|
+
PendingListingUpdateValidator,
|
|
6
|
+
AppManifestDiffValidator,
|
|
7
|
+
AppManifestValidator,
|
|
8
|
+
AppManifestVersionValidator,
|
|
5
9
|
AppEventValidator,
|
|
6
10
|
LCNNotificationTypeEnumValidator,
|
|
7
11
|
SendNotificationEventValidator,
|
|
8
12
|
} from '@learncard/types';
|
|
13
|
+
import { mergeCapturedManifests } from '@learncard/partner-connect-core';
|
|
14
|
+
import type {
|
|
15
|
+
ConsentRequest,
|
|
16
|
+
InlineCredentialTemplate,
|
|
17
|
+
NormalizedConsentScopes,
|
|
18
|
+
} from '@learncard/partner-connect-core';
|
|
9
19
|
import type { UnsignedVC } from '@learncard/types';
|
|
10
20
|
import { isVC2Format, checkAppInstallEligibility, calculateAgeFromDob } from '@learncard/helpers';
|
|
11
21
|
import type { ProfileType } from 'types/profile';
|
|
@@ -18,18 +28,28 @@ import { addNotificationToQueue } from '@helpers/notifications.helpers';
|
|
|
18
28
|
import { getNotificationMessage } from '@helpers/notificationMessages';
|
|
19
29
|
import { resolveRecipientLocale } from '@helpers/getRecipientLocale.helpers';
|
|
20
30
|
import { PerfTracker } from '@helpers/perf';
|
|
31
|
+
import {
|
|
32
|
+
buildConsentFlowContractFromScopes,
|
|
33
|
+
buildScopedConsentContractName,
|
|
34
|
+
computeConsentScopeHash,
|
|
35
|
+
normalizeConsentScopes,
|
|
36
|
+
} from '@helpers/consent-scopes.helpers';
|
|
37
|
+
import { diffManifests, hashManifest } from '@helpers/app-manifest.helpers';
|
|
21
38
|
import cache from '@cache';
|
|
22
39
|
import {
|
|
23
40
|
getBoostForListingCached,
|
|
41
|
+
getConsentContractForListingByScopeHashCached,
|
|
24
42
|
readAppStoreListingByIdCached,
|
|
25
43
|
getIntegrationForListingCached,
|
|
26
44
|
getOwnerProfileForIntegrationCached,
|
|
27
45
|
getPrimarySigningAuthorityForListingCached,
|
|
28
46
|
getPrimarySigningAuthorityForIntegrationCached,
|
|
47
|
+
invalidateBoostForListingCache,
|
|
48
|
+
invalidateConsentContractForListingByScopeHashCache,
|
|
29
49
|
} from '@cache/app-store.caches';
|
|
30
50
|
import { logCredentialSent } from '@helpers/activity.helpers';
|
|
31
51
|
import { getCredentialUri } from '@helpers/credential.helpers';
|
|
32
|
-
import { resolveUri } from '@helpers/uri.helpers';
|
|
52
|
+
import { constructUri, resolveUri } from '@helpers/uri.helpers';
|
|
33
53
|
import { inflateObject } from '@helpers/objects.helpers';
|
|
34
54
|
import { getAvailableAppSlug } from '@helpers/slug.helpers';
|
|
35
55
|
import {
|
|
@@ -52,13 +72,17 @@ import {
|
|
|
52
72
|
countInstalledAppsForProfile,
|
|
53
73
|
checkIfProfileInstalledApp,
|
|
54
74
|
} from '@accesslayer/app-store-listing/read';
|
|
55
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
clearPendingListingUpdate,
|
|
77
|
+
updateAppStoreListing,
|
|
78
|
+
} from '@accesslayer/app-store-listing/update';
|
|
56
79
|
import { deleteAppStoreListing } from '@accesslayer/app-store-listing/delete';
|
|
57
80
|
import {
|
|
58
81
|
associateListingWithIntegration,
|
|
59
82
|
associateListingWithSubmitter,
|
|
60
83
|
installAppForProfile,
|
|
61
84
|
associateBoostWithListing,
|
|
85
|
+
associateConsentContractWithListing,
|
|
62
86
|
} from '@accesslayer/app-store-listing/relationships/create';
|
|
63
87
|
import {
|
|
64
88
|
uninstallAppForProfile,
|
|
@@ -91,11 +115,13 @@ import {
|
|
|
91
115
|
PromotionLevel,
|
|
92
116
|
AppStoreListingValidator,
|
|
93
117
|
AgeRating,
|
|
118
|
+
StoredPendingListingUpdateValidator,
|
|
94
119
|
} from 'types/app-store-listing';
|
|
95
120
|
import type {
|
|
96
121
|
AppStoreListingCreateType,
|
|
97
122
|
AppStoreListingType,
|
|
98
123
|
AppStoreListingUpdateType,
|
|
124
|
+
StoredPendingListingUpdate,
|
|
99
125
|
} from 'types/app-store-listing';
|
|
100
126
|
import { getBoostByUri } from '@accesslayer/boost/read';
|
|
101
127
|
import {
|
|
@@ -106,9 +132,30 @@ import {
|
|
|
106
132
|
} from '@helpers/boost.helpers';
|
|
107
133
|
import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
|
|
108
134
|
import { createBoostForListing } from '@accesslayer/boost/create';
|
|
135
|
+
import { createConsentFlowContract } from '@accesslayer/consentflowcontract/create';
|
|
136
|
+
import { createAppManifestVersion } from '@accesslayer/app-manifest-version/create';
|
|
137
|
+
import {
|
|
138
|
+
getActiveManifestVersionForIntegration,
|
|
139
|
+
getLatestManifestVersionForIntegration,
|
|
140
|
+
getManifestVersionForIntegration,
|
|
141
|
+
getManifestVersionsForIntegration,
|
|
142
|
+
} from '@accesslayer/app-manifest-version/read';
|
|
143
|
+
import {
|
|
144
|
+
associateListingWithManifestVersion,
|
|
145
|
+
markManifestVersionsSuperseded,
|
|
146
|
+
updateAppManifestVersion,
|
|
147
|
+
} from '@accesslayer/app-manifest-version/update';
|
|
109
148
|
import { setBoostAsParent } from '@accesslayer/boost/relationships/create';
|
|
110
149
|
import { issueCredentialWithSigningAuthority } from '@helpers/signingAuthority.helpers';
|
|
111
150
|
import type { IssuedCredential } from '../types/credential';
|
|
151
|
+
import {
|
|
152
|
+
computeInlineTemplateHash,
|
|
153
|
+
ensureManagedSigningAuthorityForListing,
|
|
154
|
+
getInlineTemplateVersion,
|
|
155
|
+
readInlineTemplateBoostMeta,
|
|
156
|
+
upsertInlineTemplateBoostForListing,
|
|
157
|
+
validateInlineTemplateDataOrThrow,
|
|
158
|
+
} from '@helpers/inline-template.helpers';
|
|
112
159
|
import {
|
|
113
160
|
renderBoostTemplate,
|
|
114
161
|
parseRenderedTemplate,
|
|
@@ -179,8 +226,14 @@ const isAllowedImageUrl = (url: string): boolean => {
|
|
|
179
226
|
const isValidIframeUrl = (url: string): boolean => {
|
|
180
227
|
try {
|
|
181
228
|
const parsed = new URL(url);
|
|
182
|
-
// Only allow https URLs for iframes
|
|
183
|
-
if (parsed.protocol !== 'https:')
|
|
229
|
+
// Only allow https URLs for iframes (except for local preview of draft listings)
|
|
230
|
+
if (parsed.protocol !== 'https:') {
|
|
231
|
+
const isLocalhost =
|
|
232
|
+
parsed.hostname === 'localhost' ||
|
|
233
|
+
parsed.hostname === '127.0.0.1' ||
|
|
234
|
+
parsed.hostname === '[::1]';
|
|
235
|
+
if (!isLocalhost) return false;
|
|
236
|
+
}
|
|
184
237
|
// Block javascript:, data:, and vbscript: schemes (already handled by URL parsing, but be explicit)
|
|
185
238
|
const lowerUrl = url.toLowerCase();
|
|
186
239
|
if (
|
|
@@ -292,7 +345,14 @@ const iframeUrlRefinement = (
|
|
|
292
345
|
if (config.iframeUrl && !isValidIframeUrl(config.iframeUrl)) {
|
|
293
346
|
ctx.addIssue({
|
|
294
347
|
code: z.ZodIssueCode.custom,
|
|
295
|
-
message: 'Iframe URL must be a valid HTTPS URL',
|
|
348
|
+
message: 'Iframe URL must be a valid HTTPS URL (or localhost for testing)',
|
|
349
|
+
path: ['launch_config_json'],
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
if (config.url && !isValidIframeUrl(config.url)) {
|
|
353
|
+
ctx.addIssue({
|
|
354
|
+
code: z.ZodIssueCode.custom,
|
|
355
|
+
message: 'URL must be a valid HTTPS URL (or localhost for testing)',
|
|
296
356
|
path: ['launch_config_json'],
|
|
297
357
|
});
|
|
298
358
|
}
|
|
@@ -302,12 +362,16 @@ const iframeUrlRefinement = (
|
|
|
302
362
|
}
|
|
303
363
|
};
|
|
304
364
|
|
|
365
|
+
type PendingStorageField =
|
|
366
|
+
'pending_update_json' | 'pending_update_status' | 'pending_update_submitted_at';
|
|
367
|
+
|
|
305
368
|
type AppStoreListingResponse<T extends AppStoreListingType> = Omit<
|
|
306
369
|
T,
|
|
307
|
-
'highlights_json' | 'screenshots_json'
|
|
370
|
+
'highlights_json' | 'screenshots_json' | PendingStorageField
|
|
308
371
|
> & {
|
|
309
372
|
highlights?: string[];
|
|
310
373
|
screenshots?: string[];
|
|
374
|
+
pending_update?: z.infer<typeof PendingListingUpdateValidator>;
|
|
311
375
|
};
|
|
312
376
|
|
|
313
377
|
type ListingStorageInput<T extends Record<string, unknown>> = Omit<
|
|
@@ -321,18 +385,72 @@ type ListingStorageInput<T extends Record<string, unknown>> = Omit<
|
|
|
321
385
|
// Helper to strip sensitive fields (contact_email) from listings for public responses
|
|
322
386
|
const stripSensitiveFields = <T extends Record<string, unknown>>(
|
|
323
387
|
listing: T
|
|
324
|
-
): Omit<T, 'contact_email'> => {
|
|
325
|
-
const { contact_email, ...rest } = listing;
|
|
326
|
-
return rest as Omit<T, 'contact_email'>;
|
|
388
|
+
): Omit<T, 'contact_email' | 'pending_update'> => {
|
|
389
|
+
const { contact_email, pending_update, ...rest } = listing;
|
|
390
|
+
return rest as Omit<T, 'contact_email' | 'pending_update'>;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const parseJsonArray = (value: unknown): string[] | undefined => {
|
|
394
|
+
if (typeof value !== 'string' || !value) return undefined;
|
|
395
|
+
try {
|
|
396
|
+
const parsed: unknown = JSON.parse(value);
|
|
397
|
+
return Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string') : [];
|
|
398
|
+
} catch {
|
|
399
|
+
return [];
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const readPendingUpdate = (listing: AppStoreListingType): StoredPendingListingUpdate | null => {
|
|
404
|
+
if (!listing.pending_update_json) return null;
|
|
405
|
+
try {
|
|
406
|
+
const parsed = StoredPendingListingUpdateValidator.safeParse(
|
|
407
|
+
JSON.parse(listing.pending_update_json)
|
|
408
|
+
);
|
|
409
|
+
return parsed.success ? parsed.data : null;
|
|
410
|
+
} catch {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const toPendingUpdateResponse = (
|
|
416
|
+
listing: AppStoreListingType
|
|
417
|
+
): z.infer<typeof PendingListingUpdateValidator> | undefined => {
|
|
418
|
+
const pending = readPendingUpdate(listing);
|
|
419
|
+
if (!pending || !listing.pending_update_status) return undefined;
|
|
420
|
+
|
|
421
|
+
const { highlights_json, screenshots_json, ...changes } = pending.changes;
|
|
422
|
+
const highlights = parseJsonArray(highlights_json);
|
|
423
|
+
const screenshots = parseJsonArray(screenshots_json);
|
|
424
|
+
|
|
425
|
+
return PendingListingUpdateValidator.parse({
|
|
426
|
+
status: listing.pending_update_status,
|
|
427
|
+
submitted_at: listing.pending_update_submitted_at,
|
|
428
|
+
changes: {
|
|
429
|
+
...changes,
|
|
430
|
+
...(highlights ? { highlights } : {}),
|
|
431
|
+
...(screenshots ? { screenshots } : {}),
|
|
432
|
+
},
|
|
433
|
+
manifest_version: pending.manifestVersion,
|
|
434
|
+
});
|
|
327
435
|
};
|
|
328
436
|
|
|
329
437
|
// Helper to transform listing for API response (JSON strings -> arrays)
|
|
330
438
|
const transformListingForResponse = <T extends AppStoreListingType & Record<string, unknown>>(
|
|
331
439
|
listing: T
|
|
332
440
|
): AppStoreListingResponse<T> => {
|
|
333
|
-
const {
|
|
441
|
+
const {
|
|
442
|
+
highlights_json,
|
|
443
|
+
screenshots_json,
|
|
444
|
+
pending_update_json,
|
|
445
|
+
pending_update_status,
|
|
446
|
+
pending_update_submitted_at,
|
|
447
|
+
...rest
|
|
448
|
+
} = listing;
|
|
334
449
|
const result = { ...rest } as AppStoreListingResponse<T>;
|
|
335
450
|
|
|
451
|
+
const pendingUpdate = toPendingUpdateResponse(listing);
|
|
452
|
+
if (pendingUpdate) result.pending_update = pendingUpdate;
|
|
453
|
+
|
|
336
454
|
if (highlights_json) {
|
|
337
455
|
try {
|
|
338
456
|
result.highlights = JSON.parse(highlights_json);
|
|
@@ -442,9 +560,13 @@ const AppStoreListingResponseValidator = AppStoreListingValidator.extend({
|
|
|
442
560
|
highlights: z.array(z.string()).optional(),
|
|
443
561
|
screenshots: z.array(z.string()).optional(),
|
|
444
562
|
submitter: AppStoreListingSubmitterValidator.optional(),
|
|
563
|
+
pending_update: PendingListingUpdateValidator.optional(),
|
|
445
564
|
}).omit({
|
|
446
565
|
highlights_json: true,
|
|
447
566
|
screenshots_json: true,
|
|
567
|
+
pending_update_json: true,
|
|
568
|
+
pending_update_status: true,
|
|
569
|
+
pending_update_submitted_at: true,
|
|
448
570
|
});
|
|
449
571
|
|
|
450
572
|
const PaginatedAppStoreListingsValidator = z.object({
|
|
@@ -480,6 +602,45 @@ const PaginatedAppCredentialsValidator = z.object({
|
|
|
480
602
|
totalCount: z.number(),
|
|
481
603
|
});
|
|
482
604
|
|
|
605
|
+
const AppManifestVersionSummaryValidator = AppManifestVersionValidator.omit({ manifest: true });
|
|
606
|
+
|
|
607
|
+
const PaginatedAppManifestVersionsValidator = z.object({
|
|
608
|
+
hasMore: z.boolean(),
|
|
609
|
+
cursor: z.string().optional(),
|
|
610
|
+
records: z.array(AppManifestVersionSummaryValidator),
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
const SubmitAppManifestResponseValidator = z.object({
|
|
614
|
+
version: z.number().int().min(1),
|
|
615
|
+
manifestHash: z.string(),
|
|
616
|
+
diff: AppManifestDiffValidator.nullable(),
|
|
617
|
+
noop: z.boolean(),
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const ApplyManifestVersionResponseValidator = z.object({
|
|
621
|
+
applied: z.boolean(),
|
|
622
|
+
pendingReview: z.boolean().optional(),
|
|
623
|
+
version: z.number().int().min(1),
|
|
624
|
+
reconciled: z.object({
|
|
625
|
+
templatesUpserted: z.number().int().min(0),
|
|
626
|
+
templatesSkipped: z.number().int().min(0),
|
|
627
|
+
contractsUpserted: z.number().int().min(0),
|
|
628
|
+
contractsSkipped: z.number().int().min(0),
|
|
629
|
+
signingAuthorityEnsured: z.boolean(),
|
|
630
|
+
}),
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const toManifestVersionSummary = (
|
|
634
|
+
manifestVersion: z.infer<typeof AppManifestVersionValidator>
|
|
635
|
+
): z.infer<typeof AppManifestVersionSummaryValidator> => ({
|
|
636
|
+
id: manifestVersion.id,
|
|
637
|
+
version: manifestVersion.version,
|
|
638
|
+
manifestHash: manifestVersion.manifestHash,
|
|
639
|
+
status: manifestVersion.status,
|
|
640
|
+
createdAt: manifestVersion.createdAt,
|
|
641
|
+
activatedAt: manifestVersion.activatedAt,
|
|
642
|
+
});
|
|
643
|
+
|
|
483
644
|
// Helper to verify integration ownership
|
|
484
645
|
const verifyIntegrationOwnership = async (integrationId: string, profileId: string) => {
|
|
485
646
|
const associated = await isIntegrationAssociatedWithProfile(integrationId, profileId);
|
|
@@ -558,12 +719,17 @@ export const handleSendCredentialEvent = async (
|
|
|
558
719
|
): Promise<Record<string, unknown>> => {
|
|
559
720
|
const perf = perfTracker ?? new PerfTracker('handleSendCredentialEvent');
|
|
560
721
|
|
|
561
|
-
const templateAlias =
|
|
722
|
+
const templateAlias =
|
|
723
|
+
(event.templateAlias as string | undefined) ?? (event.alias as string | undefined);
|
|
724
|
+
const inlineTemplate = event.template as
|
|
725
|
+
import('@learncard/partner-connect-core').InlineCredentialTemplate | undefined;
|
|
562
726
|
const templateData = event.templateData as Record<string, unknown> | undefined;
|
|
563
727
|
const preventDuplicateClaim = Boolean(event.preventDuplicateClaim);
|
|
728
|
+
const isInlineTemplateEvent = Boolean(inlineTemplate);
|
|
729
|
+
let templateVersion: number | undefined = undefined;
|
|
564
730
|
|
|
565
731
|
if (!templateAlias) {
|
|
566
|
-
throw new TRPCError({ code: 'BAD_REQUEST', message: 'templateAlias required' });
|
|
732
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'templateAlias or alias required' });
|
|
567
733
|
}
|
|
568
734
|
|
|
569
735
|
// LC-1644 Phase 1b: Fire the four input-only lookups in parallel. All depend only on
|
|
@@ -572,17 +738,16 @@ export const handleSendCredentialEvent = async (
|
|
|
572
738
|
// parallel batching + 30s LRU cache on the first three below compounds to ~50-150ms
|
|
573
739
|
// saved on warm lambda invocations. targetProfile is keyed by the requester's
|
|
574
740
|
// profileId (varies per user) so it's not cached.
|
|
575
|
-
const [
|
|
576
|
-
|
|
741
|
+
const [cachedBoostResult, listing, integration, targetProfile] = await Promise.all([
|
|
742
|
+
isInlineTemplateEvent
|
|
743
|
+
? Promise.resolve(null)
|
|
744
|
+
: getBoostForListingCached(listingId, templateAlias, ctx.domain),
|
|
577
745
|
readAppStoreListingByIdCached(listingId),
|
|
578
746
|
getIntegrationForListingCached(listingId),
|
|
579
747
|
getProfilesByProfileIds([profile.profileId]),
|
|
580
748
|
]);
|
|
581
749
|
perf.mark('parallelReads');
|
|
582
750
|
|
|
583
|
-
if (!boostResult) {
|
|
584
|
-
throw new TRPCError({ code: 'NOT_FOUND', message: 'Boost not found for this app' });
|
|
585
|
-
}
|
|
586
751
|
if (!listing) {
|
|
587
752
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'App Store Listing not found' });
|
|
588
753
|
}
|
|
@@ -594,7 +759,30 @@ export const handleSendCredentialEvent = async (
|
|
|
594
759
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found' });
|
|
595
760
|
}
|
|
596
761
|
|
|
762
|
+
const boostResult = isInlineTemplateEvent
|
|
763
|
+
? await upsertInlineTemplateBoostForListing({
|
|
764
|
+
listing,
|
|
765
|
+
integration,
|
|
766
|
+
templateAlias,
|
|
767
|
+
template: inlineTemplate!,
|
|
768
|
+
domain: ctx.domain,
|
|
769
|
+
})
|
|
770
|
+
: cachedBoostResult;
|
|
771
|
+
|
|
772
|
+
if (!boostResult) {
|
|
773
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Boost not found for this app' });
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (isInlineTemplateEvent) {
|
|
777
|
+
const inlineBoostResult = boostResult as Awaited<
|
|
778
|
+
ReturnType<typeof upsertInlineTemplateBoostForListing>
|
|
779
|
+
>;
|
|
780
|
+
validateInlineTemplateDataOrThrow(inlineBoostResult.manifest, templateData);
|
|
781
|
+
templateVersion = inlineBoostResult.templateVersion;
|
|
782
|
+
}
|
|
783
|
+
|
|
597
784
|
const { boost, boostUri } = boostResult;
|
|
785
|
+
templateVersion ??= getInlineTemplateVersion(boost);
|
|
598
786
|
|
|
599
787
|
// NOTE: This is best-effort duplicate prevention, not a guarantee.
|
|
600
788
|
// There is a race condition window between checking and creating the credential
|
|
@@ -618,6 +806,7 @@ export const handleSendCredentialEvent = async (
|
|
|
618
806
|
receivedDate: existingCredential.receivedDate ?? existingCredential.sentDate,
|
|
619
807
|
status: existingCredential.status,
|
|
620
808
|
boostUri,
|
|
809
|
+
...(templateVersion !== undefined ? { templateVersion } : {}),
|
|
621
810
|
// credential not included — frontend skips modal when alreadyClaimed=true
|
|
622
811
|
};
|
|
623
812
|
}
|
|
@@ -652,17 +841,19 @@ export const handleSendCredentialEvent = async (
|
|
|
652
841
|
listingSa || integrationSa
|
|
653
842
|
? undefined
|
|
654
843
|
: await getPrimarySigningAuthorityForUser(integrationOwner);
|
|
655
|
-
const sa =
|
|
844
|
+
const sa =
|
|
845
|
+
listingSa ??
|
|
846
|
+
integrationSa ??
|
|
847
|
+
ownerSa ??
|
|
848
|
+
(await ensureManagedSigningAuthorityForListing(
|
|
849
|
+
listing,
|
|
850
|
+
integration,
|
|
851
|
+
integrationOwner,
|
|
852
|
+
ctx.domain
|
|
853
|
+
));
|
|
656
854
|
|
|
657
855
|
perf.mark('saResolve');
|
|
658
856
|
|
|
659
|
-
if (!sa) {
|
|
660
|
-
throw new TRPCError({
|
|
661
|
-
code: 'NOT_FOUND',
|
|
662
|
-
message: 'No signing authority configured for this app',
|
|
663
|
-
});
|
|
664
|
-
}
|
|
665
|
-
|
|
666
857
|
// Build unsigned credential from boost template
|
|
667
858
|
let unsignedVc: UnsignedVC;
|
|
668
859
|
|
|
@@ -826,6 +1017,7 @@ export const handleSendCredentialEvent = async (
|
|
|
826
1017
|
return {
|
|
827
1018
|
credentialUri,
|
|
828
1019
|
boostUri,
|
|
1020
|
+
...(templateVersion !== undefined ? { templateVersion } : {}),
|
|
829
1021
|
// LC-1644: pre-resolved (enriched) credential so the frontend modal
|
|
830
1022
|
// can render the proper title + issuer info without shimmer or
|
|
831
1023
|
// background re-fetch. See enrichment block above for details.
|
|
@@ -833,6 +1025,330 @@ export const handleSendCredentialEvent = async (
|
|
|
833
1025
|
};
|
|
834
1026
|
};
|
|
835
1027
|
|
|
1028
|
+
const normalizeConsentRequestOrThrow = (scopes: ConsentRequest): NormalizedConsentScopes => {
|
|
1029
|
+
try {
|
|
1030
|
+
return normalizeConsentScopes(scopes);
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
const message = error instanceof Error ? error.message : 'Invalid consent scopes';
|
|
1033
|
+
|
|
1034
|
+
throw new TRPCError({
|
|
1035
|
+
code: 'BAD_REQUEST',
|
|
1036
|
+
message: `CONSENT_SCOPES_INVALID: ${message}`,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
const handleUpsertConsentContractEvent = async (
|
|
1042
|
+
ctx: { domain: string },
|
|
1043
|
+
listing: AppStoreListingType,
|
|
1044
|
+
listingId: string,
|
|
1045
|
+
event: Record<string, unknown>
|
|
1046
|
+
): Promise<{ contractUri: string; created: boolean }> => {
|
|
1047
|
+
const rawScopes = event.scopes as ConsentRequest;
|
|
1048
|
+
const normalizedScopes = normalizeConsentRequestOrThrow(rawScopes);
|
|
1049
|
+
return upsertConsentContractForListingScopes({
|
|
1050
|
+
ctx,
|
|
1051
|
+
listing,
|
|
1052
|
+
listingId,
|
|
1053
|
+
normalizedScopes,
|
|
1054
|
+
reason:
|
|
1055
|
+
typeof rawScopes.reason === 'string' && rawScopes.reason.trim().length > 0
|
|
1056
|
+
? rawScopes.reason
|
|
1057
|
+
: undefined,
|
|
1058
|
+
});
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
const upsertConsentContractForListingScopes = async ({
|
|
1062
|
+
ctx,
|
|
1063
|
+
listing,
|
|
1064
|
+
listingId,
|
|
1065
|
+
normalizedScopes,
|
|
1066
|
+
reason,
|
|
1067
|
+
}: {
|
|
1068
|
+
ctx: { domain: string };
|
|
1069
|
+
listing: AppStoreListingType;
|
|
1070
|
+
listingId: string;
|
|
1071
|
+
normalizedScopes: NormalizedConsentScopes;
|
|
1072
|
+
reason?: string;
|
|
1073
|
+
}): Promise<{ contractUri: string; created: boolean; scopeHash: string }> => {
|
|
1074
|
+
const scopeHash = computeConsentScopeHash(normalizedScopes);
|
|
1075
|
+
const existingContract = await getConsentContractForListingByScopeHashCached(
|
|
1076
|
+
listingId,
|
|
1077
|
+
scopeHash
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
if (existingContract) {
|
|
1081
|
+
return {
|
|
1082
|
+
contractUri: constructUri('contract', existingContract.id, ctx.domain),
|
|
1083
|
+
created: false,
|
|
1084
|
+
scopeHash,
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const integration = await getIntegrationForListingCached(listingId);
|
|
1089
|
+
|
|
1090
|
+
if (!integration) {
|
|
1091
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Integration not found' });
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const ownerProfile = await getOwnerProfileForIntegrationCached(integration.id);
|
|
1095
|
+
|
|
1096
|
+
if (!ownerProfile) {
|
|
1097
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Integration owner not found' });
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const createdContract = await createConsentFlowContract({
|
|
1101
|
+
contract: buildConsentFlowContractFromScopes(normalizedScopes),
|
|
1102
|
+
ownerProfileId: ownerProfile.profileId,
|
|
1103
|
+
name: buildScopedConsentContractName(listing.display_name),
|
|
1104
|
+
description: listing.tagline,
|
|
1105
|
+
reasonForAccessing: reason,
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
await associateConsentContractWithListing(listingId, createdContract.id, scopeHash);
|
|
1109
|
+
invalidateConsentContractForListingByScopeHashCache(listingId, scopeHash);
|
|
1110
|
+
|
|
1111
|
+
return {
|
|
1112
|
+
contractUri: constructUri('contract', createdContract.id, ctx.domain),
|
|
1113
|
+
created: true,
|
|
1114
|
+
scopeHash,
|
|
1115
|
+
};
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
const resolveManifestApplyListing = async ({
|
|
1119
|
+
integrationId,
|
|
1120
|
+
listingId,
|
|
1121
|
+
profileId,
|
|
1122
|
+
}: {
|
|
1123
|
+
integrationId: string;
|
|
1124
|
+
listingId: string | undefined;
|
|
1125
|
+
profileId: string;
|
|
1126
|
+
}): Promise<AppStoreListingType> => {
|
|
1127
|
+
if (listingId) {
|
|
1128
|
+
const { listing, integration } = await verifyListingOwnership(listingId, profileId);
|
|
1129
|
+
|
|
1130
|
+
if (integration.id !== integrationId) {
|
|
1131
|
+
throw new TRPCError({
|
|
1132
|
+
code: 'BAD_REQUEST',
|
|
1133
|
+
message: 'Listing does not belong to the provided Integration',
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
return listing;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const listings = await getListingsForIntegration(integrationId, { limit: 2 });
|
|
1141
|
+
|
|
1142
|
+
if (listings.length === 0) {
|
|
1143
|
+
throw new TRPCError({
|
|
1144
|
+
code: 'NOT_FOUND',
|
|
1145
|
+
message: 'No App Store Listing found for this Integration',
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (listings.length > 1) {
|
|
1150
|
+
throw new TRPCError({
|
|
1151
|
+
code: 'BAD_REQUEST',
|
|
1152
|
+
message: 'listingId is required when an Integration has multiple listings',
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
return listings[0]!;
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
type ManifestVersionRecord = NonNullable<
|
|
1160
|
+
Awaited<ReturnType<typeof getManifestVersionForIntegration>>
|
|
1161
|
+
>;
|
|
1162
|
+
type IntegrationRecord = Awaited<ReturnType<typeof verifyIntegrationOwnership>>;
|
|
1163
|
+
type OwnerProfileRecord = NonNullable<Awaited<ReturnType<typeof getOwnerProfileForIntegration>>>;
|
|
1164
|
+
|
|
1165
|
+
const reconcileManifestVersionForListing = async ({
|
|
1166
|
+
ctx,
|
|
1167
|
+
integration,
|
|
1168
|
+
integrationOwner,
|
|
1169
|
+
listing,
|
|
1170
|
+
manifestVersion,
|
|
1171
|
+
}: {
|
|
1172
|
+
ctx: { domain: string };
|
|
1173
|
+
integration: IntegrationRecord;
|
|
1174
|
+
integrationOwner: OwnerProfileRecord;
|
|
1175
|
+
listing: AppStoreListingType;
|
|
1176
|
+
manifestVersion: ManifestVersionRecord;
|
|
1177
|
+
}) => {
|
|
1178
|
+
let templatesUpserted = 0;
|
|
1179
|
+
let templatesSkipped = 0;
|
|
1180
|
+
let contractsUpserted = 0;
|
|
1181
|
+
let contractsSkipped = 0;
|
|
1182
|
+
|
|
1183
|
+
for (const templateRecord of manifestVersion.manifest.templates) {
|
|
1184
|
+
const inlineTemplate = templateRecord.template as InlineCredentialTemplate;
|
|
1185
|
+
const existingBoost = await getBoostForListingByTemplateAlias(
|
|
1186
|
+
listing.listing_id,
|
|
1187
|
+
templateRecord.alias,
|
|
1188
|
+
ctx.domain
|
|
1189
|
+
);
|
|
1190
|
+
const desiredTemplateHash = computeInlineTemplateHash(inlineTemplate).contentHash;
|
|
1191
|
+
|
|
1192
|
+
const existingBoostMeta = existingBoost
|
|
1193
|
+
? await readInlineTemplateBoostMeta(existingBoost.boost)
|
|
1194
|
+
: undefined;
|
|
1195
|
+
|
|
1196
|
+
if (
|
|
1197
|
+
existingBoostMeta?.inlineTemplateHash === desiredTemplateHash &&
|
|
1198
|
+
existingBoostMeta.inlineTemplateVersion === templateRecord.version
|
|
1199
|
+
) {
|
|
1200
|
+
templatesSkipped += 1;
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
await upsertInlineTemplateBoostForListing({
|
|
1205
|
+
listing,
|
|
1206
|
+
integration,
|
|
1207
|
+
templateAlias: templateRecord.alias,
|
|
1208
|
+
template: inlineTemplate,
|
|
1209
|
+
domain: ctx.domain,
|
|
1210
|
+
desiredVersion: templateRecord.version,
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
templatesUpserted += 1;
|
|
1214
|
+
|
|
1215
|
+
invalidateBoostForListingCache(listing.listing_id, templateRecord.alias, ctx.domain);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
for (const consentRecord of manifestVersion.manifest.consentRequests) {
|
|
1219
|
+
const upsertedConsent = await upsertConsentContractForListingScopes({
|
|
1220
|
+
ctx,
|
|
1221
|
+
listing,
|
|
1222
|
+
listingId: listing.listing_id,
|
|
1223
|
+
normalizedScopes: consentRecord.scopes,
|
|
1224
|
+
reason: consentRecord.reason,
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
if (upsertedConsent.created) {
|
|
1228
|
+
contractsUpserted += 1;
|
|
1229
|
+
} else {
|
|
1230
|
+
contractsSkipped += 1;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
invalidateConsentContractForListingByScopeHashCache(
|
|
1234
|
+
listing.listing_id,
|
|
1235
|
+
upsertedConsent.scopeHash
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
await markManifestVersionsSuperseded(integration.id, manifestVersion.id);
|
|
1240
|
+
|
|
1241
|
+
if (manifestVersion.status !== 'active' || !manifestVersion.activatedAt) {
|
|
1242
|
+
await updateAppManifestVersion(manifestVersion.id, {
|
|
1243
|
+
status: 'active',
|
|
1244
|
+
activatedAt: manifestVersion.activatedAt ?? new Date().toISOString(),
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
await associateListingWithManifestVersion(listing.listing_id, manifestVersion.id);
|
|
1249
|
+
|
|
1250
|
+
await ensureManagedSigningAuthorityForListing(
|
|
1251
|
+
listing,
|
|
1252
|
+
integration,
|
|
1253
|
+
integrationOwner,
|
|
1254
|
+
ctx.domain
|
|
1255
|
+
);
|
|
1256
|
+
|
|
1257
|
+
return {
|
|
1258
|
+
templatesUpserted,
|
|
1259
|
+
templatesSkipped,
|
|
1260
|
+
contractsUpserted,
|
|
1261
|
+
contractsSkipped,
|
|
1262
|
+
signingAuthorityEnsured: true,
|
|
1263
|
+
};
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
const PENDING_UPDATE_IN_REVIEW_MESSAGE =
|
|
1267
|
+
'An update for this app is already in review. Withdraw it to make more changes.';
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Records changes to a LISTED app as a pending update instead of applying them, so
|
|
1271
|
+
* nothing about a live app changes until an admin approves it.
|
|
1272
|
+
*/
|
|
1273
|
+
const holdChangesForReview = async (
|
|
1274
|
+
listing: AppStoreListingType,
|
|
1275
|
+
{
|
|
1276
|
+
changes = {},
|
|
1277
|
+
manifestVersion,
|
|
1278
|
+
}: { changes?: Record<string, unknown>; manifestVersion?: number }
|
|
1279
|
+
): Promise<boolean> => {
|
|
1280
|
+
if (listing.pending_update_status === 'PENDING_REVIEW') {
|
|
1281
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: PENDING_UPDATE_IN_REVIEW_MESSAGE });
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const existing = readPendingUpdate(listing);
|
|
1285
|
+
const next: StoredPendingListingUpdate = {
|
|
1286
|
+
changes: { ...existing?.changes, ...changes },
|
|
1287
|
+
manifestVersion: manifestVersion ?? existing?.manifestVersion,
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
return updateAppStoreListing(listing, {
|
|
1291
|
+
pending_update_json: JSON.stringify(next),
|
|
1292
|
+
pending_update_status: 'DRAFT',
|
|
1293
|
+
});
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
const assertLaunchConfigSubmittable = (launchType: string, launchConfigJson: string): void => {
|
|
1297
|
+
if (launchType !== 'EMBEDDED_IFRAME' || !launchConfigJson) return;
|
|
1298
|
+
|
|
1299
|
+
try {
|
|
1300
|
+
const config = JSON.parse(launchConfigJson);
|
|
1301
|
+
const urlToCheck = config.url || config.iframeUrl;
|
|
1302
|
+
if (!urlToCheck) return;
|
|
1303
|
+
|
|
1304
|
+
const parsed = new URL(urlToCheck);
|
|
1305
|
+
const isLocalhost =
|
|
1306
|
+
parsed.hostname === 'localhost' ||
|
|
1307
|
+
parsed.hostname === '127.0.0.1' ||
|
|
1308
|
+
parsed.hostname === '[::1]';
|
|
1309
|
+
if (isLocalhost || parsed.protocol !== 'https:') {
|
|
1310
|
+
throw new TRPCError({
|
|
1311
|
+
code: 'BAD_REQUEST',
|
|
1312
|
+
message:
|
|
1313
|
+
'Cannot submit an app for review with a localhost or non-HTTPS URL. Please update the launch configuration with your production URL.',
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
} catch (e) {
|
|
1317
|
+
if (e instanceof TRPCError) throw e;
|
|
1318
|
+
// Unparseable config is rejected by input validation.
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
const notifyAppStoreAdmins = async (
|
|
1323
|
+
from: Parameters<typeof addNotificationToQueue>[0]['from'],
|
|
1324
|
+
type: 'APP_LISTING_SUBMITTED' | 'APP_LISTING_WITHDRAWN',
|
|
1325
|
+
listing: AppStoreListingType,
|
|
1326
|
+
metadata: Record<string, unknown> = {}
|
|
1327
|
+
): Promise<void> => {
|
|
1328
|
+
if (APP_STORE_ADMIN_PROFILE_IDS.length === 0) return;
|
|
1329
|
+
|
|
1330
|
+
const adminProfiles = await getProfilesByProfileIds(APP_STORE_ADMIN_PROFILE_IDS);
|
|
1331
|
+
for (const adminProfile of adminProfiles) {
|
|
1332
|
+
await addNotificationToQueue({
|
|
1333
|
+
type,
|
|
1334
|
+
to: adminProfile,
|
|
1335
|
+
from,
|
|
1336
|
+
message: getNotificationMessage(
|
|
1337
|
+
type === 'APP_LISTING_SUBMITTED' ? 'appListingSubmitted' : 'appListingWithdrawn',
|
|
1338
|
+
resolveRecipientLocale(adminProfile),
|
|
1339
|
+
{ displayName: listing.display_name }
|
|
1340
|
+
),
|
|
1341
|
+
data: {
|
|
1342
|
+
metadata: {
|
|
1343
|
+
listingId: listing.listing_id,
|
|
1344
|
+
listingName: listing.display_name,
|
|
1345
|
+
...metadata,
|
|
1346
|
+
},
|
|
1347
|
+
},
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
|
|
836
1352
|
const handleCheckCredentialEvent = async (
|
|
837
1353
|
ctx: { domain: string },
|
|
838
1354
|
profile: { profileId: string },
|
|
@@ -1830,6 +2346,274 @@ export const appStoreRouter = t.router({
|
|
|
1830
2346
|
return countListingsForIntegration(input.integrationId);
|
|
1831
2347
|
}),
|
|
1832
2348
|
|
|
2349
|
+
submitAppManifest: profileRoute
|
|
2350
|
+
.meta({
|
|
2351
|
+
openapi: {
|
|
2352
|
+
protect: true,
|
|
2353
|
+
method: 'POST',
|
|
2354
|
+
path: '/app-store/integration/{integrationId}/manifest/submit',
|
|
2355
|
+
tags: ['App Store'],
|
|
2356
|
+
summary: 'Submit App Manifest',
|
|
2357
|
+
description:
|
|
2358
|
+
'Create a versioned app manifest draft for an integration, idempotently by manifest hash.',
|
|
2359
|
+
},
|
|
2360
|
+
requiredScope: 'app-store:write',
|
|
2361
|
+
})
|
|
2362
|
+
.input(
|
|
2363
|
+
z.object({
|
|
2364
|
+
integrationId: z.string(),
|
|
2365
|
+
manifest: AppManifestValidator,
|
|
2366
|
+
})
|
|
2367
|
+
)
|
|
2368
|
+
.output(SubmitAppManifestResponseValidator)
|
|
2369
|
+
.mutation(async ({ input, ctx }) => {
|
|
2370
|
+
await verifyIntegrationOwnership(input.integrationId, ctx.user.profile.profileId);
|
|
2371
|
+
|
|
2372
|
+
const latestManifestVersion = await getLatestManifestVersionForIntegration(
|
|
2373
|
+
input.integrationId
|
|
2374
|
+
);
|
|
2375
|
+
const activeManifestVersion = await getActiveManifestVersionForIntegration(
|
|
2376
|
+
input.integrationId
|
|
2377
|
+
);
|
|
2378
|
+
// Each capture is one run of the app; merging keeps what earlier runs saw
|
|
2379
|
+
// so an unexercised feature never shows up as removed.
|
|
2380
|
+
const manifest = latestManifestVersion
|
|
2381
|
+
? mergeCapturedManifests(latestManifestVersion.manifest, input.manifest)
|
|
2382
|
+
: input.manifest;
|
|
2383
|
+
const manifestHash = hashManifest(manifest);
|
|
2384
|
+
|
|
2385
|
+
if (latestManifestVersion?.manifestHash === manifestHash) {
|
|
2386
|
+
return {
|
|
2387
|
+
version: latestManifestVersion.version,
|
|
2388
|
+
manifestHash,
|
|
2389
|
+
diff: null,
|
|
2390
|
+
noop: true,
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
const createdManifestVersion = await createAppManifestVersion({
|
|
2395
|
+
integrationId: input.integrationId,
|
|
2396
|
+
version: (latestManifestVersion?.version ?? 0) + 1,
|
|
2397
|
+
manifestHash,
|
|
2398
|
+
manifest,
|
|
2399
|
+
status: 'draft',
|
|
2400
|
+
});
|
|
2401
|
+
|
|
2402
|
+
return {
|
|
2403
|
+
version: createdManifestVersion.version,
|
|
2404
|
+
manifestHash,
|
|
2405
|
+
diff: activeManifestVersion
|
|
2406
|
+
? diffManifests(activeManifestVersion.manifest, manifest)
|
|
2407
|
+
: null,
|
|
2408
|
+
noop: false,
|
|
2409
|
+
};
|
|
2410
|
+
}),
|
|
2411
|
+
|
|
2412
|
+
getManifestVersions: profileRoute
|
|
2413
|
+
.meta({
|
|
2414
|
+
openapi: {
|
|
2415
|
+
protect: true,
|
|
2416
|
+
method: 'GET',
|
|
2417
|
+
path: '/app-store/integration/{integrationId}/manifest/versions',
|
|
2418
|
+
tags: ['App Store'],
|
|
2419
|
+
summary: 'Get App Manifest Versions',
|
|
2420
|
+
description: 'List paginated app manifest versions for an integration.',
|
|
2421
|
+
},
|
|
2422
|
+
requiredScope: 'app-store:read',
|
|
2423
|
+
})
|
|
2424
|
+
.input(
|
|
2425
|
+
z.object({
|
|
2426
|
+
integrationId: z.string(),
|
|
2427
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
2428
|
+
cursor: z.string().optional(),
|
|
2429
|
+
})
|
|
2430
|
+
)
|
|
2431
|
+
.output(PaginatedAppManifestVersionsValidator)
|
|
2432
|
+
.query(async ({ input, ctx }) => {
|
|
2433
|
+
await verifyIntegrationOwnership(input.integrationId, ctx.user.profile.profileId);
|
|
2434
|
+
|
|
2435
|
+
const limit = input.limit ?? 25;
|
|
2436
|
+
const versions = await getManifestVersionsForIntegration(input.integrationId, {
|
|
2437
|
+
limit: limit + 1,
|
|
2438
|
+
cursor: input.cursor,
|
|
2439
|
+
});
|
|
2440
|
+
|
|
2441
|
+
const hasMore = versions.length > limit;
|
|
2442
|
+
const records = (hasMore ? versions.slice(0, limit) : versions).map(
|
|
2443
|
+
toManifestVersionSummary
|
|
2444
|
+
);
|
|
2445
|
+
|
|
2446
|
+
return {
|
|
2447
|
+
hasMore,
|
|
2448
|
+
cursor: hasMore ? String(records[records.length - 1]?.version) : undefined,
|
|
2449
|
+
records,
|
|
2450
|
+
};
|
|
2451
|
+
}),
|
|
2452
|
+
|
|
2453
|
+
getManifestVersion: profileRoute
|
|
2454
|
+
.meta({
|
|
2455
|
+
openapi: {
|
|
2456
|
+
protect: true,
|
|
2457
|
+
method: 'GET',
|
|
2458
|
+
path: '/app-store/integration/{integrationId}/manifest/{version}',
|
|
2459
|
+
tags: ['App Store'],
|
|
2460
|
+
summary: 'Get App Manifest Version',
|
|
2461
|
+
description: 'Get a full app manifest version for an integration.',
|
|
2462
|
+
},
|
|
2463
|
+
requiredScope: 'app-store:read',
|
|
2464
|
+
})
|
|
2465
|
+
.input(
|
|
2466
|
+
z.object({
|
|
2467
|
+
integrationId: z.string(),
|
|
2468
|
+
version: z.number().int().min(1),
|
|
2469
|
+
})
|
|
2470
|
+
)
|
|
2471
|
+
.output(AppManifestVersionValidator)
|
|
2472
|
+
.query(async ({ input, ctx }) => {
|
|
2473
|
+
await verifyIntegrationOwnership(input.integrationId, ctx.user.profile.profileId);
|
|
2474
|
+
|
|
2475
|
+
const manifestVersion = await getManifestVersionForIntegration(
|
|
2476
|
+
input.integrationId,
|
|
2477
|
+
input.version
|
|
2478
|
+
);
|
|
2479
|
+
|
|
2480
|
+
if (!manifestVersion) {
|
|
2481
|
+
throw new TRPCError({
|
|
2482
|
+
code: 'NOT_FOUND',
|
|
2483
|
+
message: 'App manifest version not found',
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
return manifestVersion;
|
|
2488
|
+
}),
|
|
2489
|
+
|
|
2490
|
+
getManifestDiff: profileRoute
|
|
2491
|
+
.meta({
|
|
2492
|
+
openapi: {
|
|
2493
|
+
protect: true,
|
|
2494
|
+
method: 'GET',
|
|
2495
|
+
path: '/app-store/integration/{integrationId}/manifest/diff/{toVersion}',
|
|
2496
|
+
tags: ['App Store'],
|
|
2497
|
+
summary: 'Get App Manifest Diff',
|
|
2498
|
+
description: 'Diff one app manifest version against another for an integration.',
|
|
2499
|
+
},
|
|
2500
|
+
requiredScope: 'app-store:read',
|
|
2501
|
+
})
|
|
2502
|
+
.input(
|
|
2503
|
+
z.object({
|
|
2504
|
+
integrationId: z.string(),
|
|
2505
|
+
fromVersion: z.number().int().min(1).optional(),
|
|
2506
|
+
toVersion: z.number().int().min(1),
|
|
2507
|
+
})
|
|
2508
|
+
)
|
|
2509
|
+
.output(AppManifestDiffValidator)
|
|
2510
|
+
.query(async ({ input, ctx }) => {
|
|
2511
|
+
await verifyIntegrationOwnership(input.integrationId, ctx.user.profile.profileId);
|
|
2512
|
+
|
|
2513
|
+
const toManifestVersion = await getManifestVersionForIntegration(
|
|
2514
|
+
input.integrationId,
|
|
2515
|
+
input.toVersion
|
|
2516
|
+
);
|
|
2517
|
+
|
|
2518
|
+
if (!toManifestVersion) {
|
|
2519
|
+
throw new TRPCError({
|
|
2520
|
+
code: 'NOT_FOUND',
|
|
2521
|
+
message: 'Target app manifest version not found',
|
|
2522
|
+
});
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
const fromManifestVersion = input.fromVersion
|
|
2526
|
+
? await getManifestVersionForIntegration(input.integrationId, input.fromVersion)
|
|
2527
|
+
: await getActiveManifestVersionForIntegration(input.integrationId);
|
|
2528
|
+
|
|
2529
|
+
if (!fromManifestVersion) {
|
|
2530
|
+
throw new TRPCError({
|
|
2531
|
+
code: 'NOT_FOUND',
|
|
2532
|
+
message: 'Source app manifest version not found',
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
return diffManifests(fromManifestVersion.manifest, toManifestVersion.manifest);
|
|
2537
|
+
}),
|
|
2538
|
+
|
|
2539
|
+
applyManifestVersion: profileRoute
|
|
2540
|
+
.meta({
|
|
2541
|
+
openapi: {
|
|
2542
|
+
protect: true,
|
|
2543
|
+
method: 'POST',
|
|
2544
|
+
path: '/app-store/integration/{integrationId}/manifest/{version}/apply',
|
|
2545
|
+
tags: ['App Store'],
|
|
2546
|
+
summary: 'Apply App Manifest Version',
|
|
2547
|
+
description:
|
|
2548
|
+
'Mark an app manifest version active and idempotently reconcile derived listing entities.',
|
|
2549
|
+
},
|
|
2550
|
+
requiredScope: 'app-store:write',
|
|
2551
|
+
})
|
|
2552
|
+
.input(
|
|
2553
|
+
z.object({
|
|
2554
|
+
integrationId: z.string(),
|
|
2555
|
+
version: z.number().int().min(1),
|
|
2556
|
+
listingId: z.string().optional(),
|
|
2557
|
+
})
|
|
2558
|
+
)
|
|
2559
|
+
.output(ApplyManifestVersionResponseValidator)
|
|
2560
|
+
.mutation(async ({ input, ctx }) => {
|
|
2561
|
+
const integration = await verifyIntegrationOwnership(
|
|
2562
|
+
input.integrationId,
|
|
2563
|
+
ctx.user.profile.profileId
|
|
2564
|
+
);
|
|
2565
|
+
const manifestVersion = await getManifestVersionForIntegration(
|
|
2566
|
+
input.integrationId,
|
|
2567
|
+
input.version
|
|
2568
|
+
);
|
|
2569
|
+
|
|
2570
|
+
if (!manifestVersion) {
|
|
2571
|
+
throw new TRPCError({
|
|
2572
|
+
code: 'NOT_FOUND',
|
|
2573
|
+
message: 'App manifest version not found',
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
const listing = await resolveManifestApplyListing({
|
|
2578
|
+
integrationId: input.integrationId,
|
|
2579
|
+
listingId: input.listingId,
|
|
2580
|
+
profileId: ctx.user.profile.profileId,
|
|
2581
|
+
});
|
|
2582
|
+
const integrationOwner = await getOwnerProfileForIntegration(input.integrationId);
|
|
2583
|
+
|
|
2584
|
+
if (!integrationOwner) {
|
|
2585
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Integration owner not found' });
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
if (listing.app_listing_status === 'LISTED' && manifestVersion.status !== 'active') {
|
|
2589
|
+
// A live app's new capabilities wait for review instead of taking effect.
|
|
2590
|
+
await holdChangesForReview(listing, { manifestVersion: manifestVersion.version });
|
|
2591
|
+
|
|
2592
|
+
return {
|
|
2593
|
+
applied: false,
|
|
2594
|
+
pendingReview: true,
|
|
2595
|
+
version: manifestVersion.version,
|
|
2596
|
+
reconciled: {
|
|
2597
|
+
templatesUpserted: 0,
|
|
2598
|
+
templatesSkipped: 0,
|
|
2599
|
+
contractsUpserted: 0,
|
|
2600
|
+
contractsSkipped: 0,
|
|
2601
|
+
signingAuthorityEnsured: false,
|
|
2602
|
+
},
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
const reconciled = await reconcileManifestVersionForListing({
|
|
2607
|
+
ctx,
|
|
2608
|
+
integration,
|
|
2609
|
+
integrationOwner,
|
|
2610
|
+
listing,
|
|
2611
|
+
manifestVersion,
|
|
2612
|
+
});
|
|
2613
|
+
|
|
2614
|
+
return { applied: true, version: manifestVersion.version, reconciled };
|
|
2615
|
+
}),
|
|
2616
|
+
|
|
1833
2617
|
updateListing: profileRoute
|
|
1834
2618
|
.meta({
|
|
1835
2619
|
openapi: {
|
|
@@ -1861,6 +2645,10 @@ export const appStoreRouter = t.router({
|
|
|
1861
2645
|
...input.updates,
|
|
1862
2646
|
});
|
|
1863
2647
|
|
|
2648
|
+
if (listing.app_listing_status === 'LISTED') {
|
|
2649
|
+
return holdChangesForReview(listing, { changes: storageUpdates });
|
|
2650
|
+
}
|
|
2651
|
+
|
|
1864
2652
|
if (!listing.slug) {
|
|
1865
2653
|
const displayName = storageUpdates.display_name ?? listing.display_name;
|
|
1866
2654
|
storageUpdates.slug = await getAvailableAppSlug(displayName, listing.listing_id);
|
|
@@ -2016,6 +2804,8 @@ export const appStoreRouter = t.router({
|
|
|
2016
2804
|
});
|
|
2017
2805
|
}
|
|
2018
2806
|
|
|
2807
|
+
assertLaunchConfigSubmittable(listing.launch_type, listing.launch_config_json);
|
|
2808
|
+
|
|
2019
2809
|
const submittedAt = new Date().toISOString();
|
|
2020
2810
|
|
|
2021
2811
|
// Update status on the listing node
|
|
@@ -2116,6 +2906,125 @@ export const appStoreRouter = t.router({
|
|
|
2116
2906
|
return result;
|
|
2117
2907
|
}),
|
|
2118
2908
|
|
|
2909
|
+
submitListingUpdate: profileRoute
|
|
2910
|
+
.meta({
|
|
2911
|
+
openapi: {
|
|
2912
|
+
protect: true,
|
|
2913
|
+
method: 'POST',
|
|
2914
|
+
path: '/app-store/listing/{listingId}/update/submit',
|
|
2915
|
+
tags: ['App Store'],
|
|
2916
|
+
summary: 'Submit Listing Update for Review',
|
|
2917
|
+
description:
|
|
2918
|
+
'Send pending changes to a LISTED app for admin review. The live listing is unchanged until approval.',
|
|
2919
|
+
},
|
|
2920
|
+
requiredScope: 'app-store:write',
|
|
2921
|
+
})
|
|
2922
|
+
.input(z.object({ listingId: z.string() }))
|
|
2923
|
+
.output(z.boolean())
|
|
2924
|
+
.mutation(async ({ input, ctx }) => {
|
|
2925
|
+
const { listing } = await verifyListingOwnership(
|
|
2926
|
+
input.listingId,
|
|
2927
|
+
ctx.user.profile.profileId
|
|
2928
|
+
);
|
|
2929
|
+
const pending = readPendingUpdate(listing);
|
|
2930
|
+
|
|
2931
|
+
if (listing.app_listing_status !== 'LISTED' || !pending) {
|
|
2932
|
+
throw new TRPCError({
|
|
2933
|
+
code: 'BAD_REQUEST',
|
|
2934
|
+
message: 'There are no changes to submit for this app.',
|
|
2935
|
+
});
|
|
2936
|
+
}
|
|
2937
|
+
if (listing.pending_update_status === 'PENDING_REVIEW') {
|
|
2938
|
+
throw new TRPCError({
|
|
2939
|
+
code: 'BAD_REQUEST',
|
|
2940
|
+
message: PENDING_UPDATE_IN_REVIEW_MESSAGE,
|
|
2941
|
+
});
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
const nextLaunchType = pending.changes.launch_type ?? listing.launch_type;
|
|
2945
|
+
const nextLaunchConfig =
|
|
2946
|
+
pending.changes.launch_config_json ?? listing.launch_config_json;
|
|
2947
|
+
assertLaunchConfigSubmittable(String(nextLaunchType), String(nextLaunchConfig));
|
|
2948
|
+
|
|
2949
|
+
const result = await updateAppStoreListing(listing, {
|
|
2950
|
+
pending_update_status: 'PENDING_REVIEW',
|
|
2951
|
+
pending_update_submitted_at: new Date().toISOString(),
|
|
2952
|
+
});
|
|
2953
|
+
|
|
2954
|
+
await notifyAppStoreAdmins(ctx.user.profile, 'APP_LISTING_SUBMITTED', listing, {
|
|
2955
|
+
isUpdate: true,
|
|
2956
|
+
});
|
|
2957
|
+
|
|
2958
|
+
return result;
|
|
2959
|
+
}),
|
|
2960
|
+
|
|
2961
|
+
withdrawListingUpdate: profileRoute
|
|
2962
|
+
.meta({
|
|
2963
|
+
openapi: {
|
|
2964
|
+
protect: true,
|
|
2965
|
+
method: 'POST',
|
|
2966
|
+
path: '/app-store/listing/{listingId}/update/withdraw',
|
|
2967
|
+
tags: ['App Store'],
|
|
2968
|
+
summary: 'Withdraw Listing Update from Review',
|
|
2969
|
+
description: 'Return a submitted update to a LISTED app back to editable changes.',
|
|
2970
|
+
},
|
|
2971
|
+
requiredScope: 'app-store:write',
|
|
2972
|
+
})
|
|
2973
|
+
.input(z.object({ listingId: z.string() }))
|
|
2974
|
+
.output(z.boolean())
|
|
2975
|
+
.mutation(async ({ input, ctx }) => {
|
|
2976
|
+
const { listing } = await verifyListingOwnership(
|
|
2977
|
+
input.listingId,
|
|
2978
|
+
ctx.user.profile.profileId
|
|
2979
|
+
);
|
|
2980
|
+
|
|
2981
|
+
if (listing.pending_update_status !== 'PENDING_REVIEW') {
|
|
2982
|
+
throw new TRPCError({
|
|
2983
|
+
code: 'BAD_REQUEST',
|
|
2984
|
+
message: 'There is no update in review for this app.',
|
|
2985
|
+
});
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
const result = await updateAppStoreListing(listing, { pending_update_status: 'DRAFT' });
|
|
2989
|
+
|
|
2990
|
+
await notifyAppStoreAdmins(ctx.user.profile, 'APP_LISTING_WITHDRAWN', listing, {
|
|
2991
|
+
isUpdate: true,
|
|
2992
|
+
});
|
|
2993
|
+
|
|
2994
|
+
return result;
|
|
2995
|
+
}),
|
|
2996
|
+
|
|
2997
|
+
discardListingUpdate: profileRoute
|
|
2998
|
+
.meta({
|
|
2999
|
+
openapi: {
|
|
3000
|
+
protect: true,
|
|
3001
|
+
method: 'POST',
|
|
3002
|
+
path: '/app-store/listing/{listingId}/update/discard',
|
|
3003
|
+
tags: ['App Store'],
|
|
3004
|
+
summary: 'Discard Listing Update',
|
|
3005
|
+
description: 'Throw away unsubmitted changes to a LISTED app.',
|
|
3006
|
+
},
|
|
3007
|
+
requiredScope: 'app-store:write',
|
|
3008
|
+
})
|
|
3009
|
+
.input(z.object({ listingId: z.string() }))
|
|
3010
|
+
.output(z.boolean())
|
|
3011
|
+
.mutation(async ({ input, ctx }) => {
|
|
3012
|
+
const { listing } = await verifyListingOwnership(
|
|
3013
|
+
input.listingId,
|
|
3014
|
+
ctx.user.profile.profileId
|
|
3015
|
+
);
|
|
3016
|
+
|
|
3017
|
+
if (listing.pending_update_status === 'PENDING_REVIEW') {
|
|
3018
|
+
throw new TRPCError({
|
|
3019
|
+
code: 'BAD_REQUEST',
|
|
3020
|
+
message: PENDING_UPDATE_IN_REVIEW_MESSAGE,
|
|
3021
|
+
});
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
await clearPendingListingUpdate(listing.listing_id);
|
|
3025
|
+
return true;
|
|
3026
|
+
}),
|
|
3027
|
+
|
|
2119
3028
|
deleteListing: profileRoute
|
|
2120
3029
|
.meta({
|
|
2121
3030
|
openapi: {
|
|
@@ -2124,14 +3033,34 @@ export const appStoreRouter = t.router({
|
|
|
2124
3033
|
path: '/app-store/listing/{listingId}',
|
|
2125
3034
|
tags: ['App Store'],
|
|
2126
3035
|
summary: 'Delete App Store Listing',
|
|
2127
|
-
description:
|
|
3036
|
+
description:
|
|
3037
|
+
'Delete an App Store Listing. Only draft or rejected listings may be deleted; in-review and live listings must be withdrawn or removed from the store first.',
|
|
2128
3038
|
},
|
|
2129
3039
|
requiredScope: 'app-store:delete',
|
|
2130
3040
|
})
|
|
2131
3041
|
.input(z.object({ listingId: z.string() }))
|
|
2132
3042
|
.output(z.boolean())
|
|
2133
3043
|
.mutation(async ({ input, ctx }) => {
|
|
2134
|
-
await verifyListingOwnership(
|
|
3044
|
+
const { listing } = await verifyListingOwnership(
|
|
3045
|
+
input.listingId,
|
|
3046
|
+
ctx.user.profile.profileId
|
|
3047
|
+
);
|
|
3048
|
+
|
|
3049
|
+
// ARCHIVED is how a rejected listing is represented (there is no
|
|
3050
|
+
// separate REJECTED status), so DRAFT and ARCHIVED are deletable.
|
|
3051
|
+
if (listing.app_listing_status === 'PENDING_REVIEW') {
|
|
3052
|
+
throw new TRPCError({
|
|
3053
|
+
code: 'PRECONDITION_FAILED',
|
|
3054
|
+
message: 'Only draft apps can be deleted. Withdraw it from review first.',
|
|
3055
|
+
});
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
if (listing.app_listing_status === 'LISTED') {
|
|
3059
|
+
throw new TRPCError({
|
|
3060
|
+
code: 'PRECONDITION_FAILED',
|
|
3061
|
+
message: "Live apps can't be deleted. Remove it from the store first.",
|
|
3062
|
+
});
|
|
3063
|
+
}
|
|
2135
3064
|
|
|
2136
3065
|
await deleteAppStoreListing(input.listingId);
|
|
2137
3066
|
|
|
@@ -2829,6 +3758,10 @@ export const appStoreRouter = t.router({
|
|
|
2829
3758
|
);
|
|
2830
3759
|
}
|
|
2831
3760
|
|
|
3761
|
+
if (eventType === 'upsert-consent-contract') {
|
|
3762
|
+
return handleUpsertConsentContractEvent(ctx, listing, resolvedListingId, event);
|
|
3763
|
+
}
|
|
3764
|
+
|
|
2832
3765
|
if (eventType === 'send-ai-session-credential') {
|
|
2833
3766
|
return handleSendAiSessionCredentialEvent(ctx, profile, resolvedListingId, event);
|
|
2834
3767
|
}
|
|
@@ -2938,6 +3871,98 @@ export const appStoreRouter = t.router({
|
|
|
2938
3871
|
return result;
|
|
2939
3872
|
}),
|
|
2940
3873
|
|
|
3874
|
+
adminReviewListingUpdate: profileRoute
|
|
3875
|
+
.meta({
|
|
3876
|
+
openapi: {
|
|
3877
|
+
protect: true,
|
|
3878
|
+
method: 'POST',
|
|
3879
|
+
path: '/app-store/admin/listing/{listingId}/update/review',
|
|
3880
|
+
tags: ['App Store Admin'],
|
|
3881
|
+
summary: 'Review Listing Update (Admin)',
|
|
3882
|
+
description:
|
|
3883
|
+
'Approve (apply to the live listing) or reject a submitted update to a LISTED app.',
|
|
3884
|
+
},
|
|
3885
|
+
requiredScope: 'app-store:admin',
|
|
3886
|
+
})
|
|
3887
|
+
.input(z.object({ listingId: z.string(), approve: z.boolean() }))
|
|
3888
|
+
.output(z.boolean())
|
|
3889
|
+
.mutation(async ({ input, ctx }) => {
|
|
3890
|
+
verifyAppStoreAdmin(ctx.user.profile.profileId);
|
|
3891
|
+
|
|
3892
|
+
const listing = await getListingOrThrow(input.listingId);
|
|
3893
|
+
const pending = readPendingUpdate(listing);
|
|
3894
|
+
|
|
3895
|
+
if (listing.pending_update_status !== 'PENDING_REVIEW' || !pending) {
|
|
3896
|
+
throw new TRPCError({
|
|
3897
|
+
code: 'BAD_REQUEST',
|
|
3898
|
+
message: 'This app has no update waiting for review.',
|
|
3899
|
+
});
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
const integration = await getIntegrationForListing(input.listingId);
|
|
3903
|
+
const ownerProfile = integration
|
|
3904
|
+
? await getOwnerProfileForIntegration(integration.id)
|
|
3905
|
+
: null;
|
|
3906
|
+
|
|
3907
|
+
if (input.approve) {
|
|
3908
|
+
if (pending.manifestVersion !== undefined) {
|
|
3909
|
+
if (!integration || !ownerProfile) {
|
|
3910
|
+
throw new TRPCError({
|
|
3911
|
+
code: 'NOT_FOUND',
|
|
3912
|
+
message: 'Integration for this listing was not found',
|
|
3913
|
+
});
|
|
3914
|
+
}
|
|
3915
|
+
const manifestVersion = await getManifestVersionForIntegration(
|
|
3916
|
+
integration.id,
|
|
3917
|
+
pending.manifestVersion
|
|
3918
|
+
);
|
|
3919
|
+
if (manifestVersion) {
|
|
3920
|
+
await reconcileManifestVersionForListing({
|
|
3921
|
+
ctx,
|
|
3922
|
+
integration,
|
|
3923
|
+
integrationOwner: ownerProfile,
|
|
3924
|
+
listing,
|
|
3925
|
+
manifestVersion,
|
|
3926
|
+
});
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3930
|
+
await updateAppStoreListing(listing, pending.changes as AppStoreListingUpdateType);
|
|
3931
|
+
await clearPendingListingUpdate(listing.listing_id);
|
|
3932
|
+
} else {
|
|
3933
|
+
await updateAppStoreListing(listing, { pending_update_status: 'DRAFT' });
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
if (ownerProfile) {
|
|
3937
|
+
const approvedName =
|
|
3938
|
+
typeof pending.changes.display_name === 'string' && input.approve
|
|
3939
|
+
? pending.changes.display_name
|
|
3940
|
+
: listing.display_name;
|
|
3941
|
+
|
|
3942
|
+
await addNotificationToQueue({
|
|
3943
|
+
type: input.approve
|
|
3944
|
+
? LCNNotificationTypeEnumValidator.enum.APP_LISTING_APPROVED
|
|
3945
|
+
: LCNNotificationTypeEnumValidator.enum.APP_LISTING_REJECTED,
|
|
3946
|
+
to: ownerProfile,
|
|
3947
|
+
from: ctx.user.profile,
|
|
3948
|
+
message: getNotificationMessage(
|
|
3949
|
+
input.approve ? 'appListingApproved' : 'appListingRejected',
|
|
3950
|
+
resolveRecipientLocale(ownerProfile),
|
|
3951
|
+
{ displayName: approvedName }
|
|
3952
|
+
),
|
|
3953
|
+
data: {
|
|
3954
|
+
metadata: {
|
|
3955
|
+
listingId: listing.listing_id,
|
|
3956
|
+
listingName: approvedName,
|
|
3957
|
+
isUpdate: true,
|
|
3958
|
+
},
|
|
3959
|
+
},
|
|
3960
|
+
});
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
return true;
|
|
3964
|
+
}),
|
|
3965
|
+
|
|
2941
3966
|
adminUpdatePromotionLevel: profileRoute
|
|
2942
3967
|
.meta({
|
|
2943
3968
|
openapi: {
|
|
@@ -2983,6 +4008,7 @@ export const appStoreRouter = t.router({
|
|
|
2983
4008
|
limit: z.number().optional(),
|
|
2984
4009
|
cursor: z.string().optional(),
|
|
2985
4010
|
status: AppListingStatus.optional(),
|
|
4011
|
+
pendingUpdatesOnly: z.boolean().optional(),
|
|
2986
4012
|
})
|
|
2987
4013
|
.optional()
|
|
2988
4014
|
)
|
|
@@ -3000,6 +4026,7 @@ export const appStoreRouter = t.router({
|
|
|
3000
4026
|
status: input?.status,
|
|
3001
4027
|
includeAllStatuses: !input?.status, // Include all if no specific status filter
|
|
3002
4028
|
excludeDemoted: false, // Admin can see demoted listings
|
|
4029
|
+
pendingUpdatesOnly: input?.pendingUpdatesOnly,
|
|
3003
4030
|
});
|
|
3004
4031
|
|
|
3005
4032
|
const hasMore = results.length > limit;
|