@learncard/network-brain-service 3.19.0 → 3.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +34 -2
- package/CHANGELOG.md +38 -0
- package/README.md +42 -0
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/presentation/read.d.ts.map +1 -1
- package/dist/accesslayer/presentation/relationships/create.d.ts +2 -2
- package/dist/accesslayer/presentation/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/presentation/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/share-link/abandon.d.ts +3 -0
- package/dist/accesslayer/share-link/abandon.d.ts.map +1 -0
- package/dist/accesslayer/share-link/cleanup.d.ts +6 -0
- package/dist/accesslayer/share-link/cleanup.d.ts.map +1 -0
- package/dist/accesslayer/share-link/errors.d.ts +8 -0
- package/dist/accesslayer/share-link/errors.d.ts.map +1 -0
- package/dist/accesslayer/share-link/finalize.d.ts +3 -0
- package/dist/accesslayer/share-link/finalize.d.ts.map +1 -0
- package/dist/accesslayer/share-link/helpers.d.ts +56 -0
- package/dist/accesslayer/share-link/helpers.d.ts.map +1 -0
- package/dist/accesslayer/share-link/index.d.ts +12 -0
- package/dist/accesslayer/share-link/index.d.ts.map +1 -0
- package/dist/accesslayer/share-link/list.d.ts +5 -0
- package/dist/accesslayer/share-link/list.d.ts.map +1 -0
- package/dist/accesslayer/share-link/read.d.ts +5 -0
- package/dist/accesslayer/share-link/read.d.ts.map +1 -0
- package/dist/accesslayer/share-link/receipt.d.ts +11 -0
- package/dist/accesslayer/share-link/receipt.d.ts.map +1 -0
- package/dist/accesslayer/share-link/recovery.d.ts +6 -0
- package/dist/accesslayer/share-link/recovery.d.ts.map +1 -0
- package/dist/accesslayer/share-link/reserve.d.ts +4 -0
- package/dist/accesslayer/share-link/reserve.d.ts.map +1 -0
- package/dist/accesslayer/share-link/revoke.d.ts +3 -0
- package/dist/accesslayer/share-link/revoke.d.ts.map +1 -0
- package/dist/accesslayer/share-link/transaction-execution.d.ts +18 -0
- package/dist/accesslayer/share-link/transaction-execution.d.ts.map +1 -0
- package/dist/accesslayer/share-link/transaction.d.ts +15 -0
- package/dist/accesslayer/share-link/transaction.d.ts.map +1 -0
- package/dist/accesslayer/share-link/types.d.ts +370 -0
- package/dist/accesslayer/share-link/types.d.ts.map +1 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/config/environment.d.ts +9 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/constants/auth-grant.d.ts +2 -0
- package/dist/constants/auth-grant.d.ts.map +1 -1
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/credentialSubject.helpers.d.ts +3 -0
- package/dist/helpers/credentialSubject.helpers.d.ts.map +1 -0
- package/dist/helpers/credentialSubject.helpers.test.d.ts +2 -0
- package/dist/helpers/credentialSubject.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/notificationMessages.d.ts +1 -1
- package/dist/helpers/notificationMessages.d.ts.map +1 -1
- package/dist/helpers/presentation-metadata.d.ts +4 -0
- package/dist/helpers/presentation-metadata.d.ts.map +1 -0
- package/dist/helpers/presentation.helpers.d.ts +1 -1
- package/dist/helpers/presentation.helpers.d.ts.map +1 -1
- package/dist/helpers/profile.helpers.d.ts +1 -0
- package/dist/helpers/profile.helpers.d.ts.map +1 -1
- package/dist/helpers/share-content-client/adapters.d.ts +10 -0
- package/dist/helpers/share-content-client/adapters.d.ts.map +1 -0
- package/dist/helpers/share-content-client/canonical.d.ts +7 -0
- package/dist/helpers/share-content-client/canonical.d.ts.map +1 -0
- package/dist/helpers/share-content-client/client.d.ts +5 -0
- package/dist/helpers/share-content-client/client.d.ts.map +1 -0
- package/dist/helpers/share-content-client/config.d.ts +11 -0
- package/dist/helpers/share-content-client/config.d.ts.map +1 -0
- package/dist/helpers/share-content-client/index.d.ts +6 -0
- package/dist/helpers/share-content-client/index.d.ts.map +1 -0
- package/dist/helpers/share-content-client/responses.d.ts +16 -0
- package/dist/helpers/share-content-client/responses.d.ts.map +1 -0
- package/dist/helpers/share-content-client/types.d.ts +145 -0
- package/dist/helpers/share-content-client/types.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/budget-helpers.d.ts +5 -0
- package/dist/helpers/share-link-coordinator/budget-helpers.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/cleanup-runner.d.ts +3 -0
- package/dist/helpers/share-link-coordinator/cleanup-runner.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/coordinator.d.ts +4 -0
- package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/index.d.ts +5 -0
- package/dist/helpers/share-link-coordinator/index.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/recovery-runner.d.ts +9 -0
- package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/runtime.d.ts +11 -0
- package/dist/helpers/share-link-coordinator/runtime.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/stat.d.ts +4 -0
- package/dist/helpers/share-link-coordinator/stat.d.ts.map +1 -0
- package/dist/helpers/share-link-coordinator/types.d.ts +160 -0
- package/dist/helpers/share-link-coordinator/types.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/index.d.ts +5 -0
- package/dist/helpers/share-link-lifecycle/index.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/lease.helpers.d.ts +4 -0
- package/dist/helpers/share-link-lifecycle/lease.helpers.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/recovery.helpers.d.ts +21 -0
- package/dist/helpers/share-link-lifecycle/recovery.helpers.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/request-hash.helpers.d.ts +9 -0
- package/dist/helpers/share-link-lifecycle/request-hash.helpers.d.ts.map +1 -0
- package/dist/helpers/share-link-lifecycle/types.d.ts +14 -0
- package/dist/helpers/share-link-lifecycle/types.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/budget.d.ts +12 -0
- package/dist/helpers/share-link-maintenance/budget.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/config.d.ts +48 -0
- package/dist/helpers/share-link-maintenance/config.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/index.d.ts +8 -0
- package/dist/helpers/share-link-maintenance/index.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/runner.d.ts +19 -0
- package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/runtime.d.ts +36 -0
- package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/scheduler.d.ts +18 -0
- package/dist/helpers/share-link-maintenance/scheduler.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/telemetry.d.ts +5 -0
- package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -0
- package/dist/helpers/share-link-maintenance/types.d.ts +53 -0
- package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -0
- package/dist/helpers/share-link-owner/config.d.ts +16 -0
- package/dist/helpers/share-link-owner/config.d.ts.map +1 -0
- package/dist/helpers/share-link-owner/diagnostics.d.ts +26 -0
- package/dist/helpers/share-link-owner/diagnostics.d.ts.map +1 -0
- package/dist/helpers/share-link-owner/lazy-initializer.d.ts +2 -0
- package/dist/helpers/share-link-owner/lazy-initializer.d.ts.map +1 -0
- package/dist/helpers/share-link-owner-projection.d.ts +4 -0
- package/dist/helpers/share-link-owner-projection.d.ts.map +1 -0
- package/dist/helpers/share-link-passcode-abuse.d.ts +2 -0
- package/dist/helpers/share-link-passcode-abuse.d.ts.map +1 -0
- package/dist/helpers/share-link-passcode.d.ts +6 -0
- package/dist/helpers/share-link-passcode.d.ts.map +1 -0
- package/dist/helpers/share-link-policy/index.d.ts +4 -0
- package/dist/helpers/share-link-policy/index.d.ts.map +1 -0
- package/dist/helpers/share-link-policy/production.d.ts +13 -0
- package/dist/helpers/share-link-policy/production.d.ts.map +1 -0
- package/dist/helpers/share-link-policy/resolver.d.ts +6 -0
- package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -0
- package/dist/helpers/share-link-policy/types.d.ts +16 -0
- package/dist/helpers/share-link-policy/types.d.ts.map +1 -0
- package/dist/helpers/share-link-public/config.d.ts +13 -0
- package/dist/helpers/share-link-public/config.d.ts.map +1 -0
- package/dist/helpers/share-link-view-notification.d.ts +2 -0
- package/dist/helpers/share-link-view-notification.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/Presentation.d.ts +2 -0
- package/dist/models/Presentation.d.ts.map +1 -1
- package/dist/models/Profile.d.ts +2 -0
- package/dist/models/Profile.d.ts.map +1 -1
- package/dist/models/ShareLink.d.ts +44 -0
- package/dist/models/ShareLink.d.ts.map +1 -0
- package/dist/models/ShareViewReceipt.d.ts +22 -0
- package/dist/models/ShareViewReceipt.d.ts.map +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/models/share-link-constraints.d.ts +3 -0
- package/dist/models/share-link-constraints.d.ts.map +1 -0
- package/dist/public-share-link-http.d.ts +4 -0
- package/dist/public-share-link-http.d.ts.map +1 -0
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +2 -2
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +1 -1
- package/dist/routes/index.d.ts +153 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/presentations.d.ts +1 -0
- package/dist/routes/presentations.d.ts.map +1 -1
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/public-share-links.d.ts +230 -0
- package/dist/routes/public-share-links.d.ts.map +1 -0
- package/dist/routes/share-links.d.ts +844 -0
- package/dist/routes/share-links.d.ts.map +1 -0
- package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +1 -1
- package/lambda.ts +4 -1
- package/package.json +22 -16
- package/scripts/seed-demo-persona.ts +425 -0
- package/serverless.yml +36 -0
- package/shareLinkMaintenanceLambda.ts +49 -0
- package/src/accesslayer/consentflowcontract/relationships/create.ts +9 -16
- package/src/accesslayer/consentflowcontract/relationships/update.ts +15 -28
- package/src/accesslayer/presentation/read.ts +57 -25
- package/src/accesslayer/presentation/relationships/create.ts +54 -11
- package/src/accesslayer/presentation/relationships/read.ts +21 -6
- package/src/accesslayer/share-link/abandon.ts +100 -0
- package/src/accesslayer/share-link/cleanup.ts +271 -0
- package/src/accesslayer/share-link/errors.ts +32 -0
- package/src/accesslayer/share-link/finalize.ts +265 -0
- package/src/accesslayer/share-link/helpers.ts +419 -0
- package/src/accesslayer/share-link/index.ts +11 -0
- package/src/accesslayer/share-link/list.ts +162 -0
- package/src/accesslayer/share-link/read.ts +107 -0
- package/src/accesslayer/share-link/receipt.ts +548 -0
- package/src/accesslayer/share-link/recovery.ts +545 -0
- package/src/accesslayer/share-link/reserve.ts +684 -0
- package/src/accesslayer/share-link/revoke.ts +197 -0
- package/src/accesslayer/share-link/transaction-execution.ts +72 -0
- package/src/accesslayer/share-link/transaction.ts +126 -0
- package/src/accesslayer/share-link/types.ts +601 -0
- package/src/app.ts +6 -0
- package/src/config/environment.ts +14 -0
- package/src/constants/auth-grant.ts +5 -0
- package/src/docker-entry.ts +57 -0
- package/src/helpers/boost.helpers.ts +4 -31
- package/src/helpers/credentialSubject.helpers.test.ts +114 -0
- package/src/helpers/credentialSubject.helpers.ts +40 -0
- package/src/helpers/inbox.helpers.ts +7 -4
- package/src/helpers/notificationMessages.ts +18 -1
- package/src/helpers/presentation-metadata.ts +41 -0
- package/src/helpers/presentation.helpers.ts +27 -20
- package/src/helpers/profile.helpers.ts +7 -0
- package/src/helpers/share-content-client/adapters.ts +55 -0
- package/src/helpers/share-content-client/canonical.ts +25 -0
- package/src/helpers/share-content-client/client.ts +440 -0
- package/src/helpers/share-content-client/config.ts +189 -0
- package/src/helpers/share-content-client/index.ts +27 -0
- package/src/helpers/share-content-client/responses.ts +273 -0
- package/src/helpers/share-content-client/types.ts +253 -0
- package/src/helpers/share-link-coordinator/budget-helpers.ts +53 -0
- package/src/helpers/share-link-coordinator/cleanup-runner.ts +286 -0
- package/src/helpers/share-link-coordinator/coordinator.ts +714 -0
- package/src/helpers/share-link-coordinator/index.ts +8 -0
- package/src/helpers/share-link-coordinator/recovery-runner.ts +428 -0
- package/src/helpers/share-link-coordinator/runtime.ts +88 -0
- package/src/helpers/share-link-coordinator/stat.ts +30 -0
- package/src/helpers/share-link-coordinator/types.ts +353 -0
- package/src/helpers/share-link-lifecycle/index.ts +4 -0
- package/src/helpers/share-link-lifecycle/lease.helpers.ts +35 -0
- package/src/helpers/share-link-lifecycle/recovery.helpers.ts +173 -0
- package/src/helpers/share-link-lifecycle/request-hash.helpers.ts +130 -0
- package/src/helpers/share-link-lifecycle/types.ts +58 -0
- package/src/helpers/share-link-maintenance/budget.ts +63 -0
- package/src/helpers/share-link-maintenance/config.ts +312 -0
- package/src/helpers/share-link-maintenance/index.ts +24 -0
- package/src/helpers/share-link-maintenance/runner.ts +223 -0
- package/src/helpers/share-link-maintenance/runtime.ts +334 -0
- package/src/helpers/share-link-maintenance/scheduler.ts +126 -0
- package/src/helpers/share-link-maintenance/telemetry.ts +70 -0
- package/src/helpers/share-link-maintenance/types.ts +89 -0
- package/src/helpers/share-link-owner/config.ts +48 -0
- package/src/helpers/share-link-owner/diagnostics.ts +89 -0
- package/src/helpers/share-link-owner/lazy-initializer.ts +29 -0
- package/src/helpers/share-link-owner-projection.ts +52 -0
- package/src/helpers/share-link-passcode-abuse.ts +36 -0
- package/src/helpers/share-link-passcode.ts +47 -0
- package/src/helpers/share-link-policy/index.ts +7 -0
- package/src/helpers/share-link-policy/production.ts +88 -0
- package/src/helpers/share-link-policy/resolver.ts +108 -0
- package/src/helpers/share-link-policy/types.ts +48 -0
- package/src/helpers/share-link-public/config.ts +41 -0
- package/src/helpers/share-link-view-notification.ts +18 -0
- package/src/models/Presentation.ts +2 -2
- package/src/models/Profile.ts +3 -2
- package/src/models/ShareLink.ts +130 -0
- package/src/models/ShareViewReceipt.ts +69 -0
- package/src/models/index.ts +2 -0
- package/src/models/share-link-constraints.ts +93 -0
- package/src/public-share-link-http.ts +49 -0
- package/src/routes/app-store.ts +2 -11
- package/src/routes/boosts.ts +2 -11
- package/src/routes/contracts.ts +2 -11
- package/src/routes/index.ts +139 -44
- package/src/routes/presentations.ts +19 -3
- package/src/routes/profile-manager.ts +10 -2
- package/src/routes/profiles.ts +17 -6
- package/src/routes/public-share-links.ts +814 -0
- package/src/routes/share-links.ts +640 -0
- package/src/services/delivery/adapters/postmark.adapter.ts +4 -1
- package/test/dids.spec.ts +10 -1
- package/test/guardian-gated-routes.spec.ts +13 -0
- package/test/helpers/share-link-fixtures.ts +94 -0
- package/test/managed-recipient-inbox.spec.ts +130 -0
- package/test/notificationMessages.spec.ts +13 -0
- package/test/openapi.spec.ts +20 -0
- package/test/presentations.spec.ts +54 -6
- package/test/profiles.spec.ts +67 -1
- package/test/share-content-client.contract.spec.ts +365 -0
- package/test/share-content-client.unit.spec.ts +462 -0
- package/test/share-link-abuse.unit.spec.ts +65 -0
- package/test/share-link-coordinator.neo4j.spec.ts +684 -0
- package/test/share-link-coordinator.unit.spec.ts +990 -0
- package/test/share-link-dependency-diagnostics.unit.spec.ts +127 -0
- package/test/share-link-lifecycle.neo4j.spec.ts +1268 -0
- package/test/share-link-lifecycle.unit.spec.ts +205 -0
- package/test/share-link-list-cursor.unit.spec.ts +86 -0
- package/test/share-link-list.neo4j.spec.ts +324 -0
- package/test/share-link-maintenance.neo4j.spec.ts +355 -0
- package/test/share-link-maintenance.unit.spec.ts +1113 -0
- package/test/share-link-owner-policy.unit.spec.ts +210 -0
- package/test/share-link-owner-route.unit.spec.ts +752 -0
- package/test/share-link-owner-runtime.unit.spec.ts +102 -0
- package/test/share-link-passcode.unit.spec.ts +29 -0
- package/test/share-link-policy.unit.spec.ts +135 -0
- package/test/share-link-presentation-metadata.unit.spec.ts +49 -0
- package/test/share-link-production-policy.unit.spec.ts +77 -0
- package/test/share-link-public-config.unit.spec.ts +64 -0
- package/test/share-link-public-http-adapter.unit.spec.ts +315 -0
- package/test/share-link-public-route.unit.spec.ts +800 -0
- package/test/share-link-receipt.neo4j.spec.ts +943 -0
- package/test/share-link-recovery.neo4j.spec.ts +935 -0
- package/test/share-link-recovery.unit.spec.ts +726 -0
- package/vitest.config.ts +1 -0
- package/vitest.share-links-neo4j.config.ts +19 -0
- package/vitest.share-links-unit.config.ts +28 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AbandonRecoveredReservationInput,
|
|
3
|
+
AbandonRecoveredReservationResult,
|
|
4
|
+
AbandonReservationInput,
|
|
5
|
+
AbandonReservationResult,
|
|
6
|
+
ClaimCleanupJobsInput,
|
|
7
|
+
ClaimCleanupJobsResult,
|
|
8
|
+
ClaimRecoverableReservationInput,
|
|
9
|
+
ClaimRecoverableReservationResult,
|
|
10
|
+
CompleteCleanupJobInput,
|
|
11
|
+
CompleteCleanupJobResult,
|
|
12
|
+
CurrentShareContentResult,
|
|
13
|
+
DiscoverRecoverableReservationsInput,
|
|
14
|
+
DiscoverRecoverableReservationsResult,
|
|
15
|
+
FinalizeReservationInput,
|
|
16
|
+
FinalizeReservationResult,
|
|
17
|
+
GetCurrentShareContentInput,
|
|
18
|
+
GetShareLinkInput,
|
|
19
|
+
RecoveryClaimRejection,
|
|
20
|
+
ReserveCreateInput,
|
|
21
|
+
ReserveReplacementInput,
|
|
22
|
+
ReserveShareLinkResult,
|
|
23
|
+
RevokeShareLinkInput,
|
|
24
|
+
RevokeShareLinkResult,
|
|
25
|
+
ShareLinkRecoveryKey,
|
|
26
|
+
ShareLinkRecoveryTarget,
|
|
27
|
+
ShareLinkReservationRecord,
|
|
28
|
+
} from '../../accesslayer/share-link';
|
|
29
|
+
import type {
|
|
30
|
+
ShareContentClient,
|
|
31
|
+
ShareContentClientErrorCode,
|
|
32
|
+
ShareContentClientResult,
|
|
33
|
+
ShareContentContentProjection,
|
|
34
|
+
ShareContentPutRequest,
|
|
35
|
+
ShareContentRecoveryProjection,
|
|
36
|
+
} from '../share-content-client/types';
|
|
37
|
+
import type { ShareEnvelope } from '@learncard/types';
|
|
38
|
+
import type { ShareLinkRecord } from '../../models/ShareLink';
|
|
39
|
+
import type { ShareLinkPolicyResolver } from '../share-link-policy/types';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Bounded, monotonic wall-clock budget shared by the recovery and cleanup runners.
|
|
43
|
+
*
|
|
44
|
+
* `exhausted()` is true once the remaining time is at or below the reserve the
|
|
45
|
+
* orchestrator needs to finalize (write telemetry, return a summary). A runner
|
|
46
|
+
* checks it before every claim so it never starts work it cannot finish; it must
|
|
47
|
+
* never be implemented with `Promise.race` because that would leave graph or
|
|
48
|
+
* remote mutations running untracked after the pass returns.
|
|
49
|
+
*/
|
|
50
|
+
export type MaintenanceBudget = {
|
|
51
|
+
remainingMs: () => number;
|
|
52
|
+
exhausted: () => boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Optional finalization reserve in milliseconds. Direct/legacy callers omit
|
|
55
|
+
* it (only `exhausted()` is enforced). The maintenance pass supplies it so a
|
|
56
|
+
* runner can additionally require the remaining time to cover the whole next
|
|
57
|
+
* unit's bounded graph/HTTP calls before it starts one.
|
|
58
|
+
*/
|
|
59
|
+
reserveMs?: () => number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Structural type of the C3 lifecycle repository consumed by the coordinator.
|
|
64
|
+
*
|
|
65
|
+
* It is a type-only import of the reviewed lifecycle API: production wiring
|
|
66
|
+
* passes the real functions, tests pass fakes. The coordinator never performs
|
|
67
|
+
* network I/O inside a Neo4j transaction because it always awaits the
|
|
68
|
+
* repository call first and only then drives the LearnCloud client.
|
|
69
|
+
*/
|
|
70
|
+
export type ShareLinkLifecycleRepository = {
|
|
71
|
+
reserveCreate: (input: ReserveCreateInput) => Promise<ReserveShareLinkResult>;
|
|
72
|
+
reserveReplacement: (input: ReserveReplacementInput) => Promise<ReserveShareLinkResult>;
|
|
73
|
+
finalizeReservation: (input: FinalizeReservationInput) => Promise<FinalizeReservationResult>;
|
|
74
|
+
abandonReservation: (input: AbandonReservationInput) => Promise<AbandonReservationResult>;
|
|
75
|
+
revokeShareLink: (input: RevokeShareLinkInput) => Promise<RevokeShareLinkResult>;
|
|
76
|
+
getShareLink: (input: GetShareLinkInput) => Promise<ShareLinkRecord | null>;
|
|
77
|
+
getCurrentShareContent: (
|
|
78
|
+
input: GetCurrentShareContentInput
|
|
79
|
+
) => Promise<CurrentShareContentResult>;
|
|
80
|
+
claimCleanupJobs: (input: ClaimCleanupJobsInput) => Promise<ClaimCleanupJobsResult>;
|
|
81
|
+
completeCleanupJob: (input: CompleteCleanupJobInput) => Promise<CompleteCleanupJobResult>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Dependencies injected into the coordinator. */
|
|
85
|
+
export type ShareLinkCoordinatorDependencies = {
|
|
86
|
+
repository: ShareLinkLifecycleRepository;
|
|
87
|
+
client: Pick<ShareContentClient, 'put' | 'get' | 'stat' | 'delete' | 'readRecovery'>;
|
|
88
|
+
/** Stable bounded lease owner for this worker/process. */
|
|
89
|
+
leaseOwner: string;
|
|
90
|
+
leaseMs?: number;
|
|
91
|
+
now?: () => Date;
|
|
92
|
+
/**
|
|
93
|
+
* Server-side policy resolver. Omitted dependencies fall back to the
|
|
94
|
+
* conservative unknown-age policy (30 days, no views), never to adult.
|
|
95
|
+
*/
|
|
96
|
+
policyResolver?: ShareLinkPolicyResolver;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Trusted server-side context. Never taken from the caller's request body. */
|
|
100
|
+
export type ShareOwnerContext = {
|
|
101
|
+
namespace: string;
|
|
102
|
+
ownerProfileId: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type ShareContentPendingReason =
|
|
106
|
+
| 'upload_unavailable'
|
|
107
|
+
| 'stat_unavailable'
|
|
108
|
+
| 'stat_missing'
|
|
109
|
+
| 'stat_mismatch'
|
|
110
|
+
| 'finalize_unavailable';
|
|
111
|
+
|
|
112
|
+
export type ShareLinkCommitResult =
|
|
113
|
+
| { status: 'committed'; share: ShareLinkRecord }
|
|
114
|
+
| { status: 'replayed'; share: ShareLinkRecord; recorded: ShareLinkRecord }
|
|
115
|
+
| {
|
|
116
|
+
status: 'pending';
|
|
117
|
+
reservation: ShareLinkReservationRecord;
|
|
118
|
+
reason: ShareContentPendingReason;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export type ShareLinkRevokeResult = {
|
|
122
|
+
status: 'revoked' | 'already_stopped';
|
|
123
|
+
share: ShareLinkRecord;
|
|
124
|
+
cleanupQueuedFor: string[];
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export type ShareLinkResumeResult =
|
|
128
|
+
| { status: 'committed'; share: ShareLinkRecord }
|
|
129
|
+
| { status: 'replayed'; share: ShareLinkRecord }
|
|
130
|
+
| {
|
|
131
|
+
status: 'pending';
|
|
132
|
+
reservation: ShareLinkReservationRecord;
|
|
133
|
+
reason: ShareContentPendingReason;
|
|
134
|
+
}
|
|
135
|
+
| { status: 'abandoned'; cleanupQueuedFor: string | null };
|
|
136
|
+
|
|
137
|
+
/** Opaque content payload supplied on a stateless retry. */
|
|
138
|
+
export type ShareContentPayload = {
|
|
139
|
+
envelope: ShareEnvelope;
|
|
140
|
+
ownerEncryptedRecovery: Record<string, unknown>;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* INTERNAL ONLY. A pending reservation descriptor may only be produced by this
|
|
145
|
+
* server from a prior coordinator call; it must never be accepted from a client
|
|
146
|
+
* request body. Durable restart recovery must instead use the persisted-state
|
|
147
|
+
* entry points (`runShareLinkRecoveryOnce` / `recoverShareLinkOperation`), which
|
|
148
|
+
* never trust a caller-supplied object ref, hash, generation or lease.
|
|
149
|
+
*/
|
|
150
|
+
export type PendingShareContentOperation = {
|
|
151
|
+
reservation: ShareLinkReservationRecord;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export type ShareLinkCoordinatorErrorCode =
|
|
155
|
+
| 'INVALID_INPUT'
|
|
156
|
+
| 'NOT_FOUND'
|
|
157
|
+
| 'CONFLICT'
|
|
158
|
+
| 'UNAUTHORIZED'
|
|
159
|
+
| 'SIGNING_FAILED'
|
|
160
|
+
| 'PAYLOAD_TOO_LARGE'
|
|
161
|
+
| 'UNAVAILABLE'
|
|
162
|
+
| 'LEASE_EXPIRED'
|
|
163
|
+
| 'STALE_GENERATION'
|
|
164
|
+
| 'OPERATION_IN_FLIGHT'
|
|
165
|
+
| 'PRECONDITION_FAILED'
|
|
166
|
+
| 'UNEXPECTED';
|
|
167
|
+
|
|
168
|
+
export class ShareLinkCoordinatorError extends Error {
|
|
169
|
+
readonly code: ShareLinkCoordinatorErrorCode;
|
|
170
|
+
|
|
171
|
+
constructor(code: ShareLinkCoordinatorErrorCode, message: string) {
|
|
172
|
+
super(message);
|
|
173
|
+
this.name = 'ShareLinkCoordinatorError';
|
|
174
|
+
this.code = code;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Fixed, non-sensitive cleanup outcome categories. Client transport codes are
|
|
180
|
+
* reused verbatim; the extra literals cover a claim that could not be confirmed
|
|
181
|
+
* and a throwing client/complete dependency. No value carries an identifier.
|
|
182
|
+
*/
|
|
183
|
+
export type ShareCleanupCategory =
|
|
184
|
+
| ShareContentClientErrorCode
|
|
185
|
+
| 'claim_lost'
|
|
186
|
+
| 'claim_error'
|
|
187
|
+
| 'client_error'
|
|
188
|
+
| 'budget_exhausted';
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* One-shot cleanup runner result.
|
|
192
|
+
*
|
|
193
|
+
* `skipped` is a count, never a per-item array: a deterministic delete failure
|
|
194
|
+
* must not leak object refs, share/owner ids or exception text into telemetry.
|
|
195
|
+
* `categories` is a fixed histogram drawn only from `ShareCleanupCategory`.
|
|
196
|
+
*/
|
|
197
|
+
export type CleanupRunSummary = {
|
|
198
|
+
claimed: number;
|
|
199
|
+
completed: number;
|
|
200
|
+
retried: number;
|
|
201
|
+
claimLost: number;
|
|
202
|
+
skipped: number;
|
|
203
|
+
categories: Partial<Record<ShareCleanupCategory, number>>;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export type CleanupRunnerDependencies = {
|
|
207
|
+
repository: Pick<ShareLinkLifecycleRepository, 'claimCleanupJobs' | 'completeCleanupJob'>;
|
|
208
|
+
client: Pick<ShareContentClient, 'delete'>;
|
|
209
|
+
claimant: string;
|
|
210
|
+
/** Required explicit namespace; cleanup is never global. */
|
|
211
|
+
namespace: string;
|
|
212
|
+
limit?: number;
|
|
213
|
+
claimMs?: number;
|
|
214
|
+
/**
|
|
215
|
+
* Max jobs per claim transaction. The scheduled maintenance pass sets this to
|
|
216
|
+
* 1 so a deadline cannot strand a whole claimed batch; the default preserves
|
|
217
|
+
* the reviewed one-shot batch behaviour.
|
|
218
|
+
*/
|
|
219
|
+
claimBatchSize?: number;
|
|
220
|
+
/** Optional bounded budget; stops before claiming work it cannot finish. */
|
|
221
|
+
budget?: MaintenanceBudget;
|
|
222
|
+
/** Bounded managed-transaction timeout for each claim/complete unit. */
|
|
223
|
+
transactionTimeoutMs?: number;
|
|
224
|
+
/**
|
|
225
|
+
* Configured HTTP allowance for one bounded delete. Only used for whole-unit
|
|
226
|
+
* admission; the transport timeout itself is fixed when the client is built.
|
|
227
|
+
*/
|
|
228
|
+
requestTimeoutMs?: number;
|
|
229
|
+
/** Maintenance-only: single explicit graph attempt with no inline retry. */
|
|
230
|
+
noInlineRetry?: boolean;
|
|
231
|
+
now?: () => Date;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Structural type of the durable reservation recovery repository.
|
|
236
|
+
*
|
|
237
|
+
* Recovery authority is always loaded from persisted Brain state: the runner is
|
|
238
|
+
* handed a namespace (bounded discovery) or an exact scoped key, never a
|
|
239
|
+
* serialized caller reservation. The coordinator only calls `finalizeReservation`
|
|
240
|
+
* after that repository call returns, so no remote I/O happens inside a Neo4j
|
|
241
|
+
* transaction.
|
|
242
|
+
*/
|
|
243
|
+
export type ShareLinkRecoveryRepository = {
|
|
244
|
+
discoverRecoverableReservations: (
|
|
245
|
+
input: DiscoverRecoverableReservationsInput
|
|
246
|
+
) => Promise<DiscoverRecoverableReservationsResult>;
|
|
247
|
+
readShareLinkRecoveryTarget: (input: ShareLinkRecoveryKey) => Promise<ShareLinkRecoveryTarget>;
|
|
248
|
+
claimRecoverableReservation: (
|
|
249
|
+
input: ClaimRecoverableReservationInput
|
|
250
|
+
) => Promise<ClaimRecoverableReservationResult>;
|
|
251
|
+
abandonRecoveredReservation: (
|
|
252
|
+
input: AbandonRecoveredReservationInput
|
|
253
|
+
) => Promise<AbandonRecoveredReservationResult>;
|
|
254
|
+
finalizeReservation: (input: FinalizeReservationInput) => Promise<FinalizeReservationResult>;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Bounded, non-sensitive recovery categories. These are the only per-item
|
|
259
|
+
* outcomes a runner summary ever exposes; records, ciphertext, owner ids and
|
|
260
|
+
* object refs never leave the repository call.
|
|
261
|
+
*/
|
|
262
|
+
export type ShareRecoveryCategory =
|
|
263
|
+
| 'finalized'
|
|
264
|
+
| 'abandoned_missing'
|
|
265
|
+
| 'abandoned_tombstone'
|
|
266
|
+
| 'deferred_stat_mismatch'
|
|
267
|
+
| 'deferred_stat_transient'
|
|
268
|
+
| 'deferred_stat_error'
|
|
269
|
+
| 'deferred_finalize'
|
|
270
|
+
| 'claim_lost'
|
|
271
|
+
| 'malformed_binding'
|
|
272
|
+
| 'budget_exhausted';
|
|
273
|
+
|
|
274
|
+
/** Aggregate result of one bounded recovery pass; no per-item identifiers. */
|
|
275
|
+
export type ShareRecoveryRunSummary = {
|
|
276
|
+
discovered: number;
|
|
277
|
+
claimed: number;
|
|
278
|
+
finalized: number;
|
|
279
|
+
abandoned: number;
|
|
280
|
+
deferred: number;
|
|
281
|
+
claimLost: number;
|
|
282
|
+
anomalies: number;
|
|
283
|
+
categories: Partial<Record<ShareRecoveryCategory, number>>;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
export type RecoveryRunnerDependencies = {
|
|
287
|
+
repository: ShareLinkRecoveryRepository;
|
|
288
|
+
client: Pick<ShareContentClient, 'stat'>;
|
|
289
|
+
/** Stable bounded lease owner for this recovery worker/process. */
|
|
290
|
+
claimant: string;
|
|
291
|
+
/** Required explicit namespace; discovery is never global. */
|
|
292
|
+
namespace: string;
|
|
293
|
+
limit?: number;
|
|
294
|
+
leaseMs?: number;
|
|
295
|
+
/** Optional bounded budget; stops before claiming work it cannot finish. */
|
|
296
|
+
budget?: MaintenanceBudget;
|
|
297
|
+
/** Bounded transaction timeout propagated to every recovery graph path. */
|
|
298
|
+
transactionTimeoutMs?: number;
|
|
299
|
+
/**
|
|
300
|
+
* Configured HTTP allowance for one bounded stat. Only used for whole-unit
|
|
301
|
+
* admission; the transport timeout itself is fixed when the client is built.
|
|
302
|
+
*/
|
|
303
|
+
requestTimeoutMs?: number;
|
|
304
|
+
/** Maintenance-only: single explicit graph attempt with no inline retry. */
|
|
305
|
+
noInlineRetry?: boolean;
|
|
306
|
+
now?: () => Date;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** Scoped-key recovery result. Internal only; never returned by a public route. */
|
|
310
|
+
export type ScopedShareRecoveryResult =
|
|
311
|
+
| { status: 'committed'; share: ShareLinkRecord }
|
|
312
|
+
| { status: 'abandoned' }
|
|
313
|
+
| { status: 'deferred'; category: ShareRecoveryCategory }
|
|
314
|
+
| { status: 'absent' }
|
|
315
|
+
| { status: 'not_claimable'; reason: RecoveryClaimRejection };
|
|
316
|
+
|
|
317
|
+
export type ShareLinkCoordinator = {
|
|
318
|
+
createShareLink: (
|
|
319
|
+
request: unknown,
|
|
320
|
+
context: ShareOwnerContext
|
|
321
|
+
) => Promise<ShareLinkCommitResult>;
|
|
322
|
+
updateShareLink: (
|
|
323
|
+
request: unknown,
|
|
324
|
+
context: ShareOwnerContext
|
|
325
|
+
) => Promise<ShareLinkCommitResult>;
|
|
326
|
+
revokeShareLink: (
|
|
327
|
+
request: unknown,
|
|
328
|
+
context: ShareOwnerContext
|
|
329
|
+
) => Promise<ShareLinkRevokeResult>;
|
|
330
|
+
resumePendingOperation: (
|
|
331
|
+
pending: PendingShareContentOperation,
|
|
332
|
+
content?: ShareContentPayload
|
|
333
|
+
) => Promise<ShareLinkResumeResult>;
|
|
334
|
+
abandonPendingOperation: (
|
|
335
|
+
pending: PendingShareContentOperation
|
|
336
|
+
) => Promise<AbandonReservationResult>;
|
|
337
|
+
getShareLink: (shareId: string, context: ShareOwnerContext) => Promise<ShareLinkRecord | null>;
|
|
338
|
+
getActiveShareContent: (
|
|
339
|
+
shareId: string,
|
|
340
|
+
context: ShareOwnerContext
|
|
341
|
+
) => Promise<CurrentShareContentResult>;
|
|
342
|
+
fetchShareContent: (
|
|
343
|
+
shareId: string,
|
|
344
|
+
context: ShareOwnerContext,
|
|
345
|
+
options?: { allowExpired?: boolean }
|
|
346
|
+
) => Promise<ShareContentClientResult<ShareContentContentProjection>>;
|
|
347
|
+
readOwnerRecovery: (
|
|
348
|
+
shareId: string,
|
|
349
|
+
context: ShareOwnerContext
|
|
350
|
+
) => Promise<ShareContentClientResult<ShareContentRecoveryProjection>>;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
export type { ShareContentPutRequest };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SHARE_LINK_CLEANUP_BASE_BACKOFF_MS,
|
|
3
|
+
SHARE_LINK_CLEANUP_MAX_BACKOFF_MS,
|
|
4
|
+
SHARE_LINK_DEFAULT_LEASE_MS,
|
|
5
|
+
} from './types';
|
|
6
|
+
|
|
7
|
+
/** ISO timestamp `leaseMs` milliseconds after `now`. */
|
|
8
|
+
export const computeLeaseExpiry = (
|
|
9
|
+
now: Date,
|
|
10
|
+
leaseMs: number = SHARE_LINK_DEFAULT_LEASE_MS
|
|
11
|
+
): string => new Date(now.getTime() + Math.max(0, Math.trunc(leaseMs))).toISOString();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A lease is active only while `now` is strictly before its expiry. At the exact
|
|
15
|
+
* expiry instant the lease has lapsed so a stale worker cannot finalize.
|
|
16
|
+
*/
|
|
17
|
+
export const isLeaseActive = (leaseExpiresAt: string | null, now: Date): boolean => {
|
|
18
|
+
if (!leaseExpiresAt) return false;
|
|
19
|
+
|
|
20
|
+
const expiry = Date.parse(leaseExpiresAt);
|
|
21
|
+
|
|
22
|
+
return Number.isFinite(expiry) && now.getTime() < expiry;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Exponential cleanup backoff capped at one day. `attempts` is the number of
|
|
27
|
+
* completed retry cycles already recorded on the job (starts at 0 for a new job,
|
|
28
|
+
* so the first retry waits one base interval).
|
|
29
|
+
*/
|
|
30
|
+
export const computeCleanupBackoffMs = (attempts: number): number => {
|
|
31
|
+
const safeAttempts = Number.isSafeInteger(attempts) && attempts > 0 ? attempts : 0;
|
|
32
|
+
const backoff = SHARE_LINK_CLEANUP_BASE_BACKOFF_MS * 2 ** safeAttempts;
|
|
33
|
+
|
|
34
|
+
return Math.min(backoff, SHARE_LINK_CLEANUP_MAX_BACKOFF_MS);
|
|
35
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { ShareLinkOperationKind } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bounded discovery batch for durable reservation recovery.
|
|
5
|
+
*
|
|
6
|
+
* The runner must never enumerate an unbounded set: an operator gets one capped
|
|
7
|
+
* pass, and any remaining expired work is picked up by a later pass after its
|
|
8
|
+
* lease lapses again. The hard cap matches the cleanup-job batch cap.
|
|
9
|
+
*/
|
|
10
|
+
export const SHARE_LINK_RECOVERY_DEFAULT_BATCH = 25;
|
|
11
|
+
export const SHARE_LINK_RECOVERY_MAX_BATCH = 100;
|
|
12
|
+
|
|
13
|
+
/** Clamps a caller-supplied recovery batch into the documented 1..100 window. */
|
|
14
|
+
export const clampShareLinkRecoveryBatch = (limit: number | undefined): number => {
|
|
15
|
+
if (!Number.isSafeInteger(limit) || (limit as number) < 1) {
|
|
16
|
+
return SHARE_LINK_RECOVERY_DEFAULT_BATCH;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return Math.min(limit as number, SHARE_LINK_RECOVERY_MAX_BATCH);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Exact scoped recovery key, always derived from persisted Brain state. */
|
|
23
|
+
export type ShareLinkRecoveryKeyShape = {
|
|
24
|
+
namespace: string;
|
|
25
|
+
ownerProfileId: string;
|
|
26
|
+
shareId: string;
|
|
27
|
+
operationId: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Outcome of checking one persisted record against an exact scoped key. */
|
|
31
|
+
export type ShareLinkRecoveryBindingCheck = 'ok' | 'binding_mismatch' | 'malformed_binding';
|
|
32
|
+
|
|
33
|
+
const RESERVATION_OP_KINDS: readonly ShareLinkOperationKind[] = ['create', 'update'];
|
|
34
|
+
const OPERATION_STATUSES = ['in_progress', 'committed', 'abandoned'] as const;
|
|
35
|
+
|
|
36
|
+
const isOneOf = <T extends string>(value: unknown, allowed: readonly T[]): value is T =>
|
|
37
|
+
typeof value === 'string' && (allowed as readonly string[]).includes(value);
|
|
38
|
+
|
|
39
|
+
const isNonEmptyString = (value: unknown): value is string =>
|
|
40
|
+
typeof value === 'string' && value.length > 0;
|
|
41
|
+
|
|
42
|
+
const isPositiveSafeInteger = (value: unknown): value is number =>
|
|
43
|
+
Number.isSafeInteger(value) && (value as number) >= 1;
|
|
44
|
+
|
|
45
|
+
const isNonNegativeSafeInteger = (value: unknown): value is number =>
|
|
46
|
+
Number.isSafeInteger(value) && (value as number) >= 0;
|
|
47
|
+
|
|
48
|
+
const isTimestamp = (value: unknown): value is string =>
|
|
49
|
+
typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
50
|
+
|
|
51
|
+
const isAbsentOrNull = (value: unknown): boolean => value === undefined || value === null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validates a persisted `:ShareLinkReservation` against an exact scoped key.
|
|
55
|
+
*
|
|
56
|
+
* Any missing/typed-wrong core field, an inconsistent object/content tuple, or a
|
|
57
|
+
* content binding without its hashes and byte counts is `malformed_binding` and
|
|
58
|
+
* must never be reclaimed. A well-formed record that simply does not match the
|
|
59
|
+
* key is `binding_mismatch`, which is an expected stale/foreign selection rather
|
|
60
|
+
* than corruption.
|
|
61
|
+
*/
|
|
62
|
+
export const classifyReservationBinding = (
|
|
63
|
+
props: Record<string, unknown>,
|
|
64
|
+
key: ShareLinkRecoveryKeyShape
|
|
65
|
+
): ShareLinkRecoveryBindingCheck => {
|
|
66
|
+
if (
|
|
67
|
+
!isNonEmptyString(props.shareId) ||
|
|
68
|
+
!isNonEmptyString(props.namespace) ||
|
|
69
|
+
!isNonEmptyString(props.ownerProfileId) ||
|
|
70
|
+
!isNonEmptyString(props.operationId) ||
|
|
71
|
+
!isNonEmptyString(props.clientRequestId) ||
|
|
72
|
+
!isNonEmptyString(props.leaseOwner) ||
|
|
73
|
+
!isPositiveSafeInteger(props.generation) ||
|
|
74
|
+
!isPositiveSafeInteger(props.baseVersion) ||
|
|
75
|
+
!isPositiveSafeInteger(props.baseContentVersion) ||
|
|
76
|
+
!isTimestamp(props.leaseExpiresAt)
|
|
77
|
+
) {
|
|
78
|
+
return 'malformed_binding';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!isOneOf(props.opKind, RESERVATION_OP_KINDS)) return 'malformed_binding';
|
|
82
|
+
|
|
83
|
+
const hasObject = !isAbsentOrNull(props.objectRef);
|
|
84
|
+
const hasContentVersion = !isAbsentOrNull(props.contentVersion);
|
|
85
|
+
|
|
86
|
+
if (hasObject !== hasContentVersion) return 'malformed_binding';
|
|
87
|
+
|
|
88
|
+
if (hasObject) {
|
|
89
|
+
if (
|
|
90
|
+
!isNonEmptyString(props.objectRef) ||
|
|
91
|
+
!isPositiveSafeInteger(props.contentVersion) ||
|
|
92
|
+
!isNonEmptyString(props.contentHash) ||
|
|
93
|
+
!isNonNegativeSafeInteger(props.contentBytes) ||
|
|
94
|
+
!isNonEmptyString(props.recoveryHash) ||
|
|
95
|
+
!isNonNegativeSafeInteger(props.recoveryBytes)
|
|
96
|
+
) {
|
|
97
|
+
return 'malformed_binding';
|
|
98
|
+
}
|
|
99
|
+
} else if (
|
|
100
|
+
!isAbsentOrNull(props.contentHash) ||
|
|
101
|
+
!isAbsentOrNull(props.contentBytes) ||
|
|
102
|
+
!isAbsentOrNull(props.recoveryHash) ||
|
|
103
|
+
!isAbsentOrNull(props.recoveryBytes)
|
|
104
|
+
) {
|
|
105
|
+
return 'malformed_binding';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (
|
|
109
|
+
props.namespace !== key.namespace ||
|
|
110
|
+
props.ownerProfileId !== key.ownerProfileId ||
|
|
111
|
+
props.shareId !== key.shareId ||
|
|
112
|
+
props.operationId !== key.operationId
|
|
113
|
+
) {
|
|
114
|
+
return 'binding_mismatch';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return 'ok';
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Expected binding for a reservation's idempotency `:ShareLinkOperation` record. */
|
|
121
|
+
export type ShareLinkRecoveryOperationExpectation = {
|
|
122
|
+
namespace: string;
|
|
123
|
+
ownerProfileId: string;
|
|
124
|
+
shareId: string;
|
|
125
|
+
operationId: string;
|
|
126
|
+
opKind?: string;
|
|
127
|
+
clientRequestId?: string;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Validates a persisted `:ShareLinkOperation` against the reservation it must
|
|
132
|
+
* drive. A missing/wrong-typed field or an unknown status is `malformed_binding`;
|
|
133
|
+
* a well-formed record bound to a different operation is `binding_mismatch`.
|
|
134
|
+
*/
|
|
135
|
+
export const classifyOperationBinding = (
|
|
136
|
+
props: Record<string, unknown>,
|
|
137
|
+
expected: ShareLinkRecoveryOperationExpectation
|
|
138
|
+
): ShareLinkRecoveryBindingCheck => {
|
|
139
|
+
if (
|
|
140
|
+
!isNonEmptyString(props.namespace) ||
|
|
141
|
+
!isNonEmptyString(props.ownerProfileId) ||
|
|
142
|
+
!isNonEmptyString(props.shareId) ||
|
|
143
|
+
!isNonEmptyString(props.operationId) ||
|
|
144
|
+
!isNonEmptyString(props.opKind) ||
|
|
145
|
+
!isNonEmptyString(props.clientRequestId)
|
|
146
|
+
) {
|
|
147
|
+
return 'malformed_binding';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!isOneOf(props.status, OPERATION_STATUSES)) return 'malformed_binding';
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
props.namespace !== expected.namespace ||
|
|
154
|
+
props.ownerProfileId !== expected.ownerProfileId ||
|
|
155
|
+
props.shareId !== expected.shareId ||
|
|
156
|
+
props.operationId !== expected.operationId
|
|
157
|
+
) {
|
|
158
|
+
return 'binding_mismatch';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (expected.opKind !== undefined && props.opKind !== expected.opKind) {
|
|
162
|
+
return 'binding_mismatch';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (
|
|
166
|
+
expected.clientRequestId !== undefined &&
|
|
167
|
+
props.clientRequestId !== expected.clientRequestId
|
|
168
|
+
) {
|
|
169
|
+
return 'binding_mismatch';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return 'ok';
|
|
173
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { getShareLinkRequestHashRuntimeEnvironment } from '@environment';
|
|
4
|
+
|
|
5
|
+
import type { ShareLinkOperationKind } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Deterministic JSON encoding used for the canonical operation request hash.
|
|
9
|
+
*
|
|
10
|
+
* Object keys are sorted recursively so two semantically identical requests that
|
|
11
|
+
* differ only in key insertion order hash the same. Values that are not valid JSON
|
|
12
|
+
* (undefined, functions, bigint, symbols) are rejected instead of being silently
|
|
13
|
+
* dropped: a dropped field would weaken the "same request id, different request"
|
|
14
|
+
* conflict check.
|
|
15
|
+
*/
|
|
16
|
+
export const canonicalizeJson = (
|
|
17
|
+
value: unknown,
|
|
18
|
+
ancestors = new Set<object>(),
|
|
19
|
+
depth = 0
|
|
20
|
+
): string => {
|
|
21
|
+
if (depth > 32) throw new TypeError('canonical JSON exceeds depth bound');
|
|
22
|
+
if (value === null) return 'null';
|
|
23
|
+
|
|
24
|
+
switch (typeof value) {
|
|
25
|
+
case 'boolean':
|
|
26
|
+
return value ? 'true' : 'false';
|
|
27
|
+
case 'number':
|
|
28
|
+
if (!Number.isSafeInteger(value) || Object.is(value, -0)) {
|
|
29
|
+
throw new TypeError('canonicalizeJson only supports finite numbers');
|
|
30
|
+
}
|
|
31
|
+
return JSON.stringify(value);
|
|
32
|
+
case 'string':
|
|
33
|
+
return JSON.stringify(value);
|
|
34
|
+
case 'object': {
|
|
35
|
+
if (ancestors.has(value)) throw new TypeError('cyclic JSON');
|
|
36
|
+
ancestors.add(value);
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
try {
|
|
39
|
+
const entries: string[] = [];
|
|
40
|
+
for (let i = 0; i < value.length; i++) {
|
|
41
|
+
if (!(i in value)) throw new TypeError('sparse JSON array');
|
|
42
|
+
entries.push(canonicalizeJson(value[i], ancestors, depth + 1));
|
|
43
|
+
}
|
|
44
|
+
return `[${entries.join(',')}]`;
|
|
45
|
+
} finally {
|
|
46
|
+
ancestors.delete(value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
Object.getPrototypeOf(value) !== Object.prototype &&
|
|
51
|
+
Object.getPrototypeOf(value) !== null
|
|
52
|
+
) {
|
|
53
|
+
throw new TypeError('only plain JSON objects supported');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const record = value as Record<string, unknown>;
|
|
57
|
+
const keys = Object.keys(record).sort();
|
|
58
|
+
const entries = keys
|
|
59
|
+
.filter(key => record[key] !== undefined)
|
|
60
|
+
.map(
|
|
61
|
+
key =>
|
|
62
|
+
`${JSON.stringify(key)}:${canonicalizeJson(record[key], ancestors, depth + 1)}`
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
ancestors.delete(value);
|
|
66
|
+
return `{${entries.join(',')}}`;
|
|
67
|
+
}
|
|
68
|
+
default:
|
|
69
|
+
throw new TypeError(`canonicalizeJson cannot encode a ${typeof value} value`);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Dedicated key for operation fingerprints. It must be stable across every Brain
|
|
75
|
+
* instance and deploy, otherwise an in-flight retry could become a conflict.
|
|
76
|
+
*
|
|
77
|
+
* Test runs use a fixed, test-only key. Enabled non-test deployments must
|
|
78
|
+
* provision an independent secret before serving share-link owner requests.
|
|
79
|
+
*/
|
|
80
|
+
export const getShareLinkRequestHashSecret = (): string => {
|
|
81
|
+
const runtime = getShareLinkRequestHashRuntimeEnvironment();
|
|
82
|
+
const secret = runtime.SHARE_LINK_REQUEST_HASH_SECRET;
|
|
83
|
+
if (secret && Buffer.byteLength(secret, 'utf8') >= 32) return secret;
|
|
84
|
+
if (runtime.NODE_ENV === 'test') return 'share-link-test-only-request-hash-secret';
|
|
85
|
+
throw new Error('SHARE_LINK_REQUEST_HASH_SECRET must contain at least 32 bytes');
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Canonical fingerprint of the full validated request.
|
|
90
|
+
*
|
|
91
|
+
* The caller passes the *validated* request including the opaque envelope and the
|
|
92
|
+
* owner-encrypted recovery: the hash therefore covers the encrypted recovery even
|
|
93
|
+
* though no ciphertext is ever persisted in Neo4j. `clientRequestId` is the
|
|
94
|
+
* operation key identity and is deliberately excluded from the payload hash; all
|
|
95
|
+
* other fields, including `opKind`, are covered. Protected requests use a
|
|
96
|
+
* server-keyed HMAC so a persisted fingerprint cannot become a fast offline
|
|
97
|
+
* oracle for a short passcode. Unprotected requests retain the previous SHA-256
|
|
98
|
+
* format so their in-flight operation retries remain compatible.
|
|
99
|
+
*/
|
|
100
|
+
export const computeShareLinkRequestHash = (
|
|
101
|
+
opKind: ShareLinkOperationKind,
|
|
102
|
+
request: Record<string, unknown>
|
|
103
|
+
): string => {
|
|
104
|
+
const { clientRequestId: _clientRequestId, ...requestPayload } = request;
|
|
105
|
+
|
|
106
|
+
const canonical = canonicalizeJson({ opKind, request: requestPayload });
|
|
107
|
+
if (typeof requestPayload.passcode !== 'string') {
|
|
108
|
+
return createHash('sha256').update(canonical).digest('hex');
|
|
109
|
+
}
|
|
110
|
+
return createHmac('sha256', getShareLinkRequestHashSecret())
|
|
111
|
+
.update('share-link-request:protected:v2\0')
|
|
112
|
+
.update(canonical)
|
|
113
|
+
.digest('hex');
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Digest of validated opaque content, computable before reserving object IDs.
|
|
117
|
+
* Compare this with LearnCloud stat.payloadHash, not its identity-bound contentHash.
|
|
118
|
+
*/
|
|
119
|
+
export const computeShareLinkPayloadHash = (payload: {
|
|
120
|
+
envelope: unknown;
|
|
121
|
+
ownerEncryptedRecovery: unknown;
|
|
122
|
+
}): string =>
|
|
123
|
+
createHash('sha256')
|
|
124
|
+
.update(
|
|
125
|
+
canonicalizeJson({
|
|
126
|
+
envelope: payload.envelope,
|
|
127
|
+
ownerEncryptedRecovery: payload.ownerEncryptedRecovery,
|
|
128
|
+
})
|
|
129
|
+
)
|
|
130
|
+
.digest('hex');
|