@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
|
@@ -26,8 +26,20 @@ const TIMEOUT = 6000;
|
|
|
26
26
|
|
|
27
27
|
type NotificationDeliveryOptions = {
|
|
28
28
|
propagateDirectWebhookTransportErrors?: boolean;
|
|
29
|
+
/** Opt in to terminal rejection errors; legacy callers continue to receive false. */
|
|
30
|
+
throwOnPermanentFailure?: boolean;
|
|
29
31
|
};
|
|
30
32
|
|
|
33
|
+
export class PermanentNotificationDeliveryError extends Error {
|
|
34
|
+
constructor(
|
|
35
|
+
readonly reason: 'no_webhook' | 'invalid_webhook' | 'webhook_rejected',
|
|
36
|
+
readonly statusCode?: number
|
|
37
|
+
) {
|
|
38
|
+
super(`Notification delivery rejected: ${reason}`);
|
|
39
|
+
this.name = 'PermanentNotificationDeliveryError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
type NotificationWebhookResponseRecord = Record<string, unknown>;
|
|
32
44
|
|
|
33
45
|
const isDefinitiveWebhookRejection = (statusCode: number): boolean => {
|
|
@@ -308,6 +320,8 @@ export async function sendNotification(
|
|
|
308
320
|
);
|
|
309
321
|
|
|
310
322
|
if (!notificationsWebhook) {
|
|
323
|
+
if (options.throwOnPermanentFailure)
|
|
324
|
+
throw new PermanentNotificationDeliveryError('no_webhook');
|
|
311
325
|
return false;
|
|
312
326
|
}
|
|
313
327
|
|
|
@@ -360,7 +374,14 @@ export async function sendNotification(
|
|
|
360
374
|
}
|
|
361
375
|
|
|
362
376
|
if (!response.ok) {
|
|
363
|
-
if (isDefinitiveWebhookRejection(response.status))
|
|
377
|
+
if (isDefinitiveWebhookRejection(response.status)) {
|
|
378
|
+
if (options.throwOnPermanentFailure)
|
|
379
|
+
throw new PermanentNotificationDeliveryError(
|
|
380
|
+
'webhook_rejected',
|
|
381
|
+
response.status
|
|
382
|
+
);
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
364
385
|
|
|
365
386
|
throw createWebhookTransportError(response.status);
|
|
366
387
|
}
|
|
@@ -411,7 +432,10 @@ export async function sendNotification(
|
|
|
411
432
|
|
|
412
433
|
return notificationDelivered;
|
|
413
434
|
}
|
|
435
|
+
if (options.throwOnPermanentFailure)
|
|
436
|
+
throw new PermanentNotificationDeliveryError('invalid_webhook');
|
|
414
437
|
} catch (error) {
|
|
438
|
+
if (error instanceof PermanentNotificationDeliveryError) throw error;
|
|
415
439
|
if (runtimeEnvironment.NODE_ENV !== 'test') {
|
|
416
440
|
console.error('Notifications Helpers - Error While Sending:', error);
|
|
417
441
|
}
|
|
@@ -13,6 +13,12 @@ export const PublicProfileIdValidator = LCNProfileValidator.shape.profileId.refi
|
|
|
13
13
|
{ message: 'Profile IDs beginning with "sample-" are reserved.' }
|
|
14
14
|
);
|
|
15
15
|
|
|
16
|
+
/** Legacy service flags never override an explicit child classification. */
|
|
17
|
+
export const isServiceProfileExemptFromGuardianship = (
|
|
18
|
+
isServiceProfile: unknown,
|
|
19
|
+
profileType: unknown
|
|
20
|
+
): boolean => isServiceProfile === true && profileType !== 'child';
|
|
21
|
+
|
|
16
22
|
/**
|
|
17
23
|
* Gets or creates a federated profile for cross-instance credential sending.
|
|
18
24
|
* Federated profiles use email-like format: profileId@service-domain to avoid collisions.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { getShareLinkRequestHashSecret } from './share-link-lifecycle';
|
|
3
|
+
import type { ShareLinkRecord } from '../models/ShareLink';
|
|
4
|
+
|
|
5
|
+
const TTL_MS = 60 * 1000;
|
|
6
|
+
const sign = (text: string): string =>
|
|
7
|
+
createHmac('sha256', getShareLinkRequestHashSecret()).update(text).digest('base64url');
|
|
8
|
+
const binding = (record: ShareLinkRecord, namespace: string, sourceIp?: string): string =>
|
|
9
|
+
sign(
|
|
10
|
+
JSON.stringify([
|
|
11
|
+
'attachment-access-v1',
|
|
12
|
+
namespace,
|
|
13
|
+
record.ownerProfileId,
|
|
14
|
+
record.id,
|
|
15
|
+
record.contentVersion,
|
|
16
|
+
record.attachmentId,
|
|
17
|
+
record.attachmentChunkCount,
|
|
18
|
+
record.passcodeHash ?? null,
|
|
19
|
+
sourceIp ?? null,
|
|
20
|
+
])
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
/** Opaque keyed binding never exposes the password verifier or internal owner/namespace. */
|
|
24
|
+
export const mintShareAttachmentAccessToken = (
|
|
25
|
+
record: ShareLinkRecord,
|
|
26
|
+
namespace: string,
|
|
27
|
+
sourceIp: string | undefined,
|
|
28
|
+
now: Date
|
|
29
|
+
): string => {
|
|
30
|
+
const body = Buffer.from(
|
|
31
|
+
JSON.stringify({
|
|
32
|
+
v: 1,
|
|
33
|
+
expiresAt: now.getTime() + TTL_MS,
|
|
34
|
+
nonce: randomUUID(),
|
|
35
|
+
binding: binding(record, namespace, sourceIp),
|
|
36
|
+
})
|
|
37
|
+
).toString('base64url');
|
|
38
|
+
return `${body}.${sign(`attachment-grant-v1:${body}`)}`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** A grant only amortizes a successful password check; active/current authorization remains mandatory. */
|
|
42
|
+
export const verifyShareAttachmentAccessToken = (
|
|
43
|
+
token: string,
|
|
44
|
+
record: ShareLinkRecord,
|
|
45
|
+
namespace: string,
|
|
46
|
+
sourceIp: string | undefined,
|
|
47
|
+
now: Date
|
|
48
|
+
): boolean => {
|
|
49
|
+
try {
|
|
50
|
+
const parts = token.split('.');
|
|
51
|
+
const bodyPart = parts[0];
|
|
52
|
+
const signature = parts[1];
|
|
53
|
+
if (
|
|
54
|
+
parts.length !== 2 ||
|
|
55
|
+
!bodyPart ||
|
|
56
|
+
!signature ||
|
|
57
|
+
signature.length !== 43 ||
|
|
58
|
+
token.length > 2048
|
|
59
|
+
)
|
|
60
|
+
return false;
|
|
61
|
+
const bytes = Buffer.from(bodyPart, 'base64url');
|
|
62
|
+
if (bytes.toString('base64url') !== bodyPart) return false;
|
|
63
|
+
const actual = Buffer.from(signature, 'base64url');
|
|
64
|
+
const expected = Buffer.from(sign(`attachment-grant-v1:${bodyPart}`), 'base64url');
|
|
65
|
+
if (
|
|
66
|
+
actual.length !== expected.length ||
|
|
67
|
+
actual.toString('base64url') !== signature ||
|
|
68
|
+
!timingSafeEqual(actual, expected)
|
|
69
|
+
)
|
|
70
|
+
return false;
|
|
71
|
+
const body: unknown = JSON.parse(bytes.toString('utf8'));
|
|
72
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
|
|
73
|
+
const value = body as Record<string, unknown>;
|
|
74
|
+
if (
|
|
75
|
+
value.v !== 1 ||
|
|
76
|
+
typeof value.expiresAt !== 'number' ||
|
|
77
|
+
!Number.isSafeInteger(value.expiresAt) ||
|
|
78
|
+
value.expiresAt <= now.getTime() ||
|
|
79
|
+
value.expiresAt > now.getTime() + TTL_MS ||
|
|
80
|
+
value.binding !== binding(record, namespace, sourceIp)
|
|
81
|
+
)
|
|
82
|
+
return false;
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PutShareLinkAttachmentChunkInputValidator,
|
|
3
|
+
DeleteShareLinkAttachmentChunksInputValidator,
|
|
4
|
+
} from '@learncard/types';
|
|
5
|
+
import type {
|
|
6
|
+
PutShareLinkAttachmentChunkInput,
|
|
7
|
+
DeleteShareLinkAttachmentChunksInput,
|
|
8
|
+
} from '@learncard/types';
|
|
9
|
+
import type { ShareContentClient } from './share-content-client';
|
|
10
|
+
import {
|
|
11
|
+
beginShareAttachmentChunk,
|
|
12
|
+
completeShareAttachmentChunk,
|
|
13
|
+
deleteUnreferencedShareAttachment,
|
|
14
|
+
shareAttachmentTuple,
|
|
15
|
+
type ShareAttachmentBinding,
|
|
16
|
+
} from '../accesslayer/share-link/attachment';
|
|
17
|
+
import { ShareLinkCoordinatorError } from './share-link-coordinator/types';
|
|
18
|
+
import type { ShareOwnerContext } from './share-link-coordinator/types';
|
|
19
|
+
import { isShareLinkRepositoryError } from '../accesslayer/share-link/errors';
|
|
20
|
+
|
|
21
|
+
export type ShareLinkAttachmentOwnerApi = {
|
|
22
|
+
put: (
|
|
23
|
+
input: PutShareLinkAttachmentChunkInput,
|
|
24
|
+
owner: ShareOwnerContext
|
|
25
|
+
) => Promise<{ ok: true }>;
|
|
26
|
+
delete: (
|
|
27
|
+
input: DeleteShareLinkAttachmentChunksInput,
|
|
28
|
+
owner: ShareOwnerContext
|
|
29
|
+
) => Promise<{ ok: boolean }>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const createShareLinkAttachmentOwnerApi = (
|
|
33
|
+
client: Pick<ShareContentClient, 'put' | 'delete'>,
|
|
34
|
+
repository = {
|
|
35
|
+
begin: beginShareAttachmentChunk,
|
|
36
|
+
complete: completeShareAttachmentChunk,
|
|
37
|
+
deleteUnreferenced: deleteUnreferencedShareAttachment,
|
|
38
|
+
}
|
|
39
|
+
): ShareLinkAttachmentOwnerApi => {
|
|
40
|
+
const bindingFor = (
|
|
41
|
+
input: DeleteShareLinkAttachmentChunksInput,
|
|
42
|
+
owner: ShareOwnerContext
|
|
43
|
+
): ShareAttachmentBinding => ({
|
|
44
|
+
namespace: owner.namespace,
|
|
45
|
+
ownerProfileId: owner.ownerProfileId,
|
|
46
|
+
shareId: input.id,
|
|
47
|
+
contentVersion: input.contentVersion,
|
|
48
|
+
attachmentId: input.attachmentId,
|
|
49
|
+
chunkCount: input.chunkCount,
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
put: async (input, owner) => {
|
|
53
|
+
const parsed = PutShareLinkAttachmentChunkInputValidator.safeParse(input);
|
|
54
|
+
if (!parsed.success)
|
|
55
|
+
throw new ShareLinkCoordinatorError('INVALID_INPUT', 'invalid attachment');
|
|
56
|
+
const value = parsed.data;
|
|
57
|
+
const binding = bindingFor(value, owner);
|
|
58
|
+
try {
|
|
59
|
+
await repository.begin(binding, value.chunkIndex);
|
|
60
|
+
const result = await client.put({
|
|
61
|
+
...shareAttachmentTuple(binding, value.chunkIndex),
|
|
62
|
+
envelope: value.envelope,
|
|
63
|
+
ownerEncryptedRecovery: value.ownerEncryptedRecovery,
|
|
64
|
+
});
|
|
65
|
+
if (!result.ok)
|
|
66
|
+
throw new ShareLinkCoordinatorError(
|
|
67
|
+
result.error === 'CONFLICT' ? 'CONFLICT' : 'UNAVAILABLE',
|
|
68
|
+
'attachment upload unavailable'
|
|
69
|
+
);
|
|
70
|
+
await repository.complete(binding, value.chunkIndex);
|
|
71
|
+
return { ok: true };
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error instanceof ShareLinkCoordinatorError) throw error;
|
|
74
|
+
const code = isShareLinkRepositoryError(error) ? error.code : undefined;
|
|
75
|
+
throw new ShareLinkCoordinatorError(
|
|
76
|
+
code === 'CONFLICT'
|
|
77
|
+
? 'CONFLICT'
|
|
78
|
+
: code === 'NOT_FOUND'
|
|
79
|
+
? 'NOT_FOUND'
|
|
80
|
+
: code === 'PRECONDITION_FAILED' || code === 'INVALID_INPUT'
|
|
81
|
+
? code
|
|
82
|
+
: 'UNAVAILABLE',
|
|
83
|
+
'attachment upload unavailable'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
delete: async (input, owner) => {
|
|
88
|
+
const parsed = DeleteShareLinkAttachmentChunksInputValidator.safeParse(input);
|
|
89
|
+
if (!parsed.success)
|
|
90
|
+
throw new ShareLinkCoordinatorError('INVALID_INPUT', 'invalid attachment');
|
|
91
|
+
const binding = bindingFor(parsed.data, owner);
|
|
92
|
+
let allowed: boolean;
|
|
93
|
+
try {
|
|
94
|
+
allowed = await repository.deleteUnreferenced(binding);
|
|
95
|
+
} catch {
|
|
96
|
+
throw new ShareLinkCoordinatorError(
|
|
97
|
+
'UNAVAILABLE',
|
|
98
|
+
'attachment cleanup unavailable'
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (!allowed) return { ok: false };
|
|
102
|
+
// Durable jobs were queued before I/O; interrupted/failed deletes are retried by maintenance.
|
|
103
|
+
let ok = true;
|
|
104
|
+
for (let index = 0; index < binding.chunkCount; index++) {
|
|
105
|
+
try {
|
|
106
|
+
const result = await client.delete(shareAttachmentTuple(binding, index));
|
|
107
|
+
if (!result.ok) ok = false;
|
|
108
|
+
} catch {
|
|
109
|
+
ok = false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { ok };
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { shareAttachmentTuple } from '../../accesslayer/share-link/attachment';
|
|
2
|
+
import type { ShareLinkReservationRecord } from '../../accesslayer/share-link/types';
|
|
3
|
+
import type { ShareContentClient } from '../share-content-client';
|
|
4
|
+
import type { MaintenanceBudget } from './types';
|
|
5
|
+
import { budgetAllows } from './budget-helpers';
|
|
6
|
+
|
|
7
|
+
/** Verify the complete immutable set before releasing the atomic commit fence. */
|
|
8
|
+
export const verifyShareAttachmentStats = async (
|
|
9
|
+
client: Pick<ShareContentClient, 'stat'>,
|
|
10
|
+
reservation: ShareLinkReservationRecord,
|
|
11
|
+
budget?: MaintenanceBudget,
|
|
12
|
+
remoteAndFinalizeCostMs = 0
|
|
13
|
+
): Promise<boolean> => {
|
|
14
|
+
if (reservation.contentVersion === null || !reservation.attachmentId) return true;
|
|
15
|
+
const count = reservation.attachmentChunkCount;
|
|
16
|
+
if (!count || count < 1 || count > 16) return false;
|
|
17
|
+
const binding = {
|
|
18
|
+
namespace: reservation.namespace,
|
|
19
|
+
ownerProfileId: reservation.ownerProfileId,
|
|
20
|
+
shareId: reservation.shareId,
|
|
21
|
+
contentVersion: reservation.contentVersion,
|
|
22
|
+
attachmentId: reservation.attachmentId,
|
|
23
|
+
chunkCount: count,
|
|
24
|
+
};
|
|
25
|
+
for (let index = 0; index < count; index++) {
|
|
26
|
+
// Each bounded HTTP call must leave enough time for finalization and the
|
|
27
|
+
// pass reserve. A many-chunk asset cannot overrun the maintenance budget.
|
|
28
|
+
if (!budgetAllows(budget, remoteAndFinalizeCostMs)) return false;
|
|
29
|
+
const tuple = shareAttachmentTuple(binding, index);
|
|
30
|
+
const result = await client.stat(tuple);
|
|
31
|
+
if (!result.ok || result.value.kind !== 'active') return false;
|
|
32
|
+
const active = result.value;
|
|
33
|
+
if (
|
|
34
|
+
Object.entries(tuple).some(
|
|
35
|
+
([key, value]) => active[key as keyof typeof active] !== value
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
};
|
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
ShareContentEnvelope,
|
|
29
29
|
} from '../share-content-client/types';
|
|
30
30
|
import { verifyShareContentActiveStat as verifyActiveStat } from './stat';
|
|
31
|
+
import { verifyShareAttachmentStats } from './attachment-stat';
|
|
31
32
|
import {
|
|
32
33
|
ShareLinkCoordinatorError,
|
|
33
34
|
type PendingShareContentOperation,
|
|
@@ -162,8 +163,11 @@ export const createShareLinkCoordinator = (
|
|
|
162
163
|
const policyResolver: ShareLinkPolicyResolver =
|
|
163
164
|
dependencies.policyResolver ??
|
|
164
165
|
createShareLinkPolicyResolver({
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
resolveOwner: async () => ({
|
|
167
|
+
age: 'unknown',
|
|
168
|
+
isManaged: false,
|
|
169
|
+
isServiceProfile: false,
|
|
170
|
+
}),
|
|
167
171
|
});
|
|
168
172
|
|
|
169
173
|
const finalizeOrPending = async (
|
|
@@ -171,7 +175,14 @@ export const createShareLinkCoordinator = (
|
|
|
171
175
|
objectRef: string | null
|
|
172
176
|
): Promise<ShareLinkCommitResult> => {
|
|
173
177
|
try {
|
|
178
|
+
if (!(await verifyShareAttachmentStats(client, reservation))) {
|
|
179
|
+
return { status: 'pending', reservation, reason: 'stat_missing' };
|
|
180
|
+
}
|
|
174
181
|
const finalized = await repository.finalizeReservation({
|
|
182
|
+
verifiedAttachmentId:
|
|
183
|
+
reservation.contentVersion !== null
|
|
184
|
+
? (reservation.attachmentId ?? undefined)
|
|
185
|
+
: undefined,
|
|
175
186
|
namespace: reservation.namespace,
|
|
176
187
|
ownerProfileId: reservation.ownerProfileId,
|
|
177
188
|
shareId: reservation.shareId,
|
|
@@ -336,6 +347,7 @@ export const createShareLinkCoordinator = (
|
|
|
336
347
|
// policy that disables view counting for minors/unknown age.
|
|
337
348
|
notifyOnView: value.notifyOnView && policy.viewCountingEnabled,
|
|
338
349
|
content,
|
|
350
|
+
attachment: value.attachment,
|
|
339
351
|
requestHash,
|
|
340
352
|
policy,
|
|
341
353
|
leaseOwner: dependencies.leaseOwner,
|
|
@@ -397,6 +409,7 @@ export const createShareLinkCoordinator = (
|
|
|
397
409
|
),
|
|
398
410
|
contentVersion: value.contentVersion!,
|
|
399
411
|
selectedCount: value.selectedCount!,
|
|
412
|
+
attachment: value.attachment,
|
|
400
413
|
},
|
|
401
414
|
}
|
|
402
415
|
: {}),
|
|
@@ -7,6 +7,7 @@ import type { ShareLinkRecord } from '../../models/ShareLink';
|
|
|
7
7
|
import { isTransientShareContentError } from '../share-content-client/types';
|
|
8
8
|
import { budgetAllows, budgetHasUnitReserve, boundTransactionTimeout } from './budget-helpers';
|
|
9
9
|
import { verifyShareContentActiveStat } from './stat';
|
|
10
|
+
import { verifyShareAttachmentStats } from './attachment-stat';
|
|
10
11
|
import {
|
|
11
12
|
ShareLinkCoordinatorError,
|
|
12
13
|
type RecoveryRunnerDependencies,
|
|
@@ -102,7 +103,21 @@ const finalizeClaimedReservation = async (
|
|
|
102
103
|
dependencies: RecoveryRunnerDependencies
|
|
103
104
|
): Promise<RecoveryDriveOutcome> => {
|
|
104
105
|
try {
|
|
106
|
+
if (
|
|
107
|
+
!(await verifyShareAttachmentStats(
|
|
108
|
+
dependencies.client,
|
|
109
|
+
reservation,
|
|
110
|
+
dependencies.budget,
|
|
111
|
+
recoveryRemoteCostMs(dependencies)
|
|
112
|
+
))
|
|
113
|
+
) {
|
|
114
|
+
return { kind: 'deferred', category: 'deferred_stat_mismatch' };
|
|
115
|
+
}
|
|
105
116
|
const finalized = await dependencies.repository.finalizeReservation({
|
|
117
|
+
verifiedAttachmentId:
|
|
118
|
+
reservation.contentVersion !== null
|
|
119
|
+
? (reservation.attachmentId ?? undefined)
|
|
120
|
+
: undefined,
|
|
106
121
|
namespace: reservation.namespace,
|
|
107
122
|
ownerProfileId: reservation.ownerProfileId,
|
|
108
123
|
shareId: reservation.shareId,
|
|
@@ -29,6 +29,7 @@ export type ShareLinkMaintenanceRunnerDependencies = {
|
|
|
29
29
|
* never load the Neo4j `@instance`; production passes the real repository.
|
|
30
30
|
*/
|
|
31
31
|
pruneReceipts: (input: PruneShareViewReceiptsInput) => Promise<number>;
|
|
32
|
+
expireAttachmentStages?: typeof import('../../accesslayer/share-link/attachment').expireShareAttachmentStages;
|
|
32
33
|
client: Pick<ShareContentClient, 'stat' | 'delete'>;
|
|
33
34
|
logger: ShareLinkMaintenanceLogger;
|
|
34
35
|
/** Monotonic clock for the cooperative budget; injectable for tests. */
|
|
@@ -124,6 +125,8 @@ export const runShareLinkMaintenancePass = async (
|
|
|
124
125
|
let budgetStoppedRecovery = false;
|
|
125
126
|
let budgetStoppedCleanup = false;
|
|
126
127
|
let budgetStoppedReceiptPrune = false;
|
|
128
|
+
let attachmentStages: ShareLinkMaintenanceRunSummary['attachmentStages'];
|
|
129
|
+
let stageFailed = false;
|
|
127
130
|
|
|
128
131
|
if (budgetAllows(budget, recoveryDiscoveryCostMs(config))) {
|
|
129
132
|
recoveryAttempted = true;
|
|
@@ -149,6 +152,27 @@ export const runShareLinkMaintenancePass = async (
|
|
|
149
152
|
budgetStoppedRecovery = true;
|
|
150
153
|
}
|
|
151
154
|
|
|
155
|
+
if (dependencies.expireAttachmentStages) {
|
|
156
|
+
if (budgetAllows(budget, config.graphTimeoutMs)) {
|
|
157
|
+
try {
|
|
158
|
+
attachmentStages = {
|
|
159
|
+
queued: await dependencies.expireAttachmentStages({
|
|
160
|
+
namespace: config.namespace,
|
|
161
|
+
limit: 10,
|
|
162
|
+
now: dependencies.now?.(),
|
|
163
|
+
transaction: { timeoutMs: config.graphTimeoutMs, noInlineRetry: true },
|
|
164
|
+
}),
|
|
165
|
+
};
|
|
166
|
+
} catch {
|
|
167
|
+
attachmentStages = null;
|
|
168
|
+
stageFailed = true;
|
|
169
|
+
categories.share_link_maintenance_attachment_stage_failed = 1;
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
budgetStoppedCleanup = true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
152
176
|
if (budgetAllows(budget, cleanupUnitCostMs(config))) {
|
|
153
177
|
cleanupAttempted = true;
|
|
154
178
|
|
|
@@ -196,6 +220,7 @@ export const runShareLinkMaintenancePass = async (
|
|
|
196
220
|
}
|
|
197
221
|
|
|
198
222
|
const failed =
|
|
223
|
+
stageFailed ||
|
|
199
224
|
(recoveryAttempted && recovery === null) ||
|
|
200
225
|
(cleanupAttempted && cleanup === null) ||
|
|
201
226
|
(receiptPruneAttempted && receiptPrune === null);
|
|
@@ -214,6 +239,9 @@ export const runShareLinkMaintenancePass = async (
|
|
|
214
239
|
recovery,
|
|
215
240
|
cleanup,
|
|
216
241
|
receiptPrune,
|
|
242
|
+
...(dependencies.expireAttachmentStages
|
|
243
|
+
? { attachmentStages: attachmentStages ?? null }
|
|
244
|
+
: {}),
|
|
217
245
|
categories,
|
|
218
246
|
};
|
|
219
247
|
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
type MaintenanceClient = Pick<ShareContentClient, 'stat' | 'delete'>;
|
|
18
18
|
|
|
19
19
|
type ResolvedRunnerDependencies = {
|
|
20
|
+
expireAttachmentStages?: typeof import('../../accesslayer/share-link/attachment').expireShareAttachmentStages;
|
|
20
21
|
recoveryRepository: RecoveryRunnerDependencies['repository'];
|
|
21
22
|
cleanupRepository: CleanupRunnerDependencies['repository'];
|
|
22
23
|
pruneReceipts: (input: PruneShareViewReceiptsInput) => Promise<number>;
|
|
@@ -58,6 +59,7 @@ export type CreateShareLinkMaintenanceRuntimeOptions = {
|
|
|
58
59
|
recoveryRepository: RecoveryRunnerDependencies['repository'];
|
|
59
60
|
cleanupRepository: CleanupRunnerDependencies['repository'];
|
|
60
61
|
pruneReceipts: ResolvedRunnerDependencies['pruneReceipts'];
|
|
62
|
+
expireAttachmentStages?: ResolvedRunnerDependencies['expireAttachmentStages'];
|
|
61
63
|
}>;
|
|
62
64
|
};
|
|
63
65
|
|
|
@@ -245,6 +247,7 @@ export const createShareLinkMaintenanceRuntime = (
|
|
|
245
247
|
recoveryRepository: setup.dependencies.recoveryRepository,
|
|
246
248
|
cleanupRepository: setup.dependencies.cleanupRepository,
|
|
247
249
|
pruneReceipts: setup.dependencies.pruneReceipts,
|
|
250
|
+
expireAttachmentStages: setup.dependencies.expireAttachmentStages,
|
|
248
251
|
client: setup.dependencies.client,
|
|
249
252
|
logger,
|
|
250
253
|
monotonicNow: options.monotonicNow,
|
|
@@ -297,6 +300,7 @@ const buildRuntimeRepositories = async (
|
|
|
297
300
|
recoveryRepository: RecoveryRunnerDependencies['repository'];
|
|
298
301
|
cleanupRepository: CleanupRunnerDependencies['repository'];
|
|
299
302
|
pruneReceipts: ResolvedRunnerDependencies['pruneReceipts'];
|
|
303
|
+
expireAttachmentStages?: ResolvedRunnerDependencies['expireAttachmentStages'];
|
|
300
304
|
}> => {
|
|
301
305
|
const runtimeModule = await import('../share-link-coordinator/runtime');
|
|
302
306
|
|
|
@@ -325,6 +329,8 @@ const buildRuntimeRepositories = async (
|
|
|
325
329
|
recoveryRepository: recoveryDependencies.repository,
|
|
326
330
|
cleanupRepository: cleanupDependencies.repository,
|
|
327
331
|
pruneReceipts: pruneShareViewReceipts,
|
|
332
|
+
expireAttachmentStages: (await import('../../accesslayer/share-link/attachment'))
|
|
333
|
+
.expireShareAttachmentStages,
|
|
328
334
|
};
|
|
329
335
|
};
|
|
330
336
|
|
|
@@ -39,6 +39,9 @@ export const toMaintenanceLogEvent = (
|
|
|
39
39
|
...recoveryCounts(summary.recovery),
|
|
40
40
|
...cleanupCounts(summary.cleanup),
|
|
41
41
|
receiptsPruned: summary.receiptPrune?.pruned ?? 0,
|
|
42
|
+
...(summary.attachmentStages
|
|
43
|
+
? { attachmentStagesQueued: summary.attachmentStages.queued }
|
|
44
|
+
: {}),
|
|
42
45
|
},
|
|
43
46
|
categories: {
|
|
44
47
|
...(summary.recovery?.categories ?? {}),
|
|
@@ -50,6 +50,7 @@ export type ShareLinkMaintenanceFailureCategory =
|
|
|
50
50
|
| 'share_link_maintenance_pass_failed'
|
|
51
51
|
| 'share_link_maintenance_recovery_failed'
|
|
52
52
|
| 'share_link_maintenance_cleanup_failed'
|
|
53
|
+
| 'share_link_maintenance_attachment_stage_failed'
|
|
53
54
|
| 'share_link_maintenance_receipt_prune_failed'
|
|
54
55
|
| 'share_link_maintenance_initialization_failed'
|
|
55
56
|
| 'share_link_maintenance_configuration_invalid';
|
|
@@ -67,6 +68,7 @@ export type ShareLinkMaintenanceRunSummary = {
|
|
|
67
68
|
cleanup: CleanupRunSummary | null;
|
|
68
69
|
/** Namespace-scoped expired/consumed receipt nodes deleted this pass. */
|
|
69
70
|
receiptPrune: { pruned: number } | null;
|
|
71
|
+
attachmentStages?: { queued: number } | null;
|
|
70
72
|
categories: Partial<Record<ShareLinkMaintenanceFailureCategory, number>>;
|
|
71
73
|
};
|
|
72
74
|
|
|
@@ -31,6 +31,8 @@ export const toOwnerShareLink = (
|
|
|
31
31
|
selectedCount: record.selectedCount,
|
|
32
32
|
version: record.version,
|
|
33
33
|
contentVersion: record.contentVersion,
|
|
34
|
+
attachmentId: record.attachmentId ?? null,
|
|
35
|
+
attachmentChunkCount: record.attachmentChunkCount ?? null,
|
|
34
36
|
status: record.status,
|
|
35
37
|
contentState: record.contentState,
|
|
36
38
|
createdAt: record.createdAt,
|
|
@@ -2,14 +2,17 @@ import { getProfileByProfileId } from '@accesslayer/profile/read';
|
|
|
2
2
|
import { isProfileManaged } from '@accesslayer/profile/relationships/read';
|
|
3
3
|
import type { ShareViewEligibilitySource } from '@accesslayer/share-link/types';
|
|
4
4
|
import type { ShareLinkTransaction } from '@accesslayer/share-link/transaction';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
isServiceProfileExemptFromGuardianship,
|
|
7
|
+
transformProfileId,
|
|
8
|
+
} from '@helpers/profile.helpers';
|
|
6
9
|
|
|
7
10
|
import { composeShareLinkPolicy } from './resolver';
|
|
8
|
-
import type {
|
|
11
|
+
import type { CurrentShareLinkPolicy, ShareLinkOwnerAge, ShareLinkPolicySource } from './types';
|
|
9
12
|
|
|
10
13
|
const ADULT_AGE = 18;
|
|
11
14
|
|
|
12
|
-
/**
|
|
15
|
+
/** Classify persisted human age without inventing a birthdate for service profiles. */
|
|
13
16
|
export const ageFromPersistedProfile = (
|
|
14
17
|
profile: { dob?: unknown; type?: unknown } | null,
|
|
15
18
|
now: Date = new Date()
|
|
@@ -45,16 +48,23 @@ export const ageFromPersistedProfile = (
|
|
|
45
48
|
};
|
|
46
49
|
|
|
47
50
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
51
|
+
* Personal profiles require a valid persisted birthdate for tracking. Service
|
|
52
|
+
* profiles are age-exempt, but an explicit child type always keeps protections.
|
|
50
53
|
*/
|
|
51
|
-
export const createProductionShareLinkPolicySource = (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
export const createProductionShareLinkPolicySource = (): ShareLinkPolicySource => ({
|
|
55
|
+
resolveOwner: async profileId => {
|
|
56
|
+
const profile = await getProfileByProfileId(profileId);
|
|
57
|
+
const isServiceProfile = isServiceProfileExemptFromGuardianship(
|
|
58
|
+
profile?.isServiceProfile,
|
|
59
|
+
profile?.type
|
|
60
|
+
);
|
|
61
|
+
const isManaged = await isProfileManaged(profileId);
|
|
62
|
+
return {
|
|
63
|
+
age: ageFromPersistedProfile(profile),
|
|
64
|
+
isServiceProfile,
|
|
65
|
+
isManaged,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
58
68
|
});
|
|
59
69
|
|
|
60
70
|
/** Read current eligibility within the share lock; no request values or network I/O. */
|
|
@@ -62,23 +72,33 @@ export const resolveCurrentShareLinkPolicy = async (
|
|
|
62
72
|
tx: ShareLinkTransaction,
|
|
63
73
|
ownerProfileId: string,
|
|
64
74
|
now: Date
|
|
65
|
-
): Promise<
|
|
75
|
+
): Promise<CurrentShareLinkPolicy> => {
|
|
66
76
|
const result = await tx.run(
|
|
67
77
|
`MATCH (p:Profile {profileId: $profileId})
|
|
68
78
|
OPTIONAL MATCH (p)-[:MANAGED_BY]->(directManager:Profile)
|
|
69
79
|
OPTIONAL MATCH (manager:ProfileManager)-[:MANAGES]->(p)
|
|
70
80
|
RETURN p.dob AS dob, p.type AS profileType,
|
|
81
|
+
p.isServiceProfile AS isServiceProfile,
|
|
71
82
|
(directManager IS NOT NULL OR manager IS NOT NULL) AS isManaged
|
|
72
83
|
LIMIT 1`,
|
|
73
84
|
{ profileId: transformProfileId(ownerProfileId) }
|
|
74
85
|
);
|
|
75
86
|
const record = result.records[0];
|
|
76
|
-
if (!record) return composeShareLinkPolicy('unknown', true);
|
|
87
|
+
if (!record) return { ...composeShareLinkPolicy('unknown', true), isServiceProfile: false };
|
|
77
88
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
record.get('
|
|
89
|
+
const profileType = record.get('profileType');
|
|
90
|
+
const isServiceProfile = isServiceProfileExemptFromGuardianship(
|
|
91
|
+
record.get('isServiceProfile'),
|
|
92
|
+
profileType
|
|
81
93
|
);
|
|
94
|
+
return {
|
|
95
|
+
...composeShareLinkPolicy(
|
|
96
|
+
ageFromPersistedProfile({ dob: record.get('dob'), type: profileType }, now),
|
|
97
|
+
record.get('isManaged') !== false,
|
|
98
|
+
isServiceProfile
|
|
99
|
+
),
|
|
100
|
+
isServiceProfile,
|
|
101
|
+
};
|
|
82
102
|
};
|
|
83
103
|
|
|
84
104
|
export const productionShareViewEligibilitySource: ShareViewEligibilitySource = {
|