@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,714 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CreateShareLinkInputValidator,
|
|
5
|
+
ShareLinkIdValidator,
|
|
6
|
+
UpdateShareLinkInputValidator,
|
|
7
|
+
decodedBase64UrlByteLength,
|
|
8
|
+
utf8ByteLength,
|
|
9
|
+
} from '@learncard/types';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
canonicalizeJson,
|
|
13
|
+
computeShareLinkPayloadHash,
|
|
14
|
+
computeShareLinkRequestHash,
|
|
15
|
+
} from '@helpers/share-link-lifecycle';
|
|
16
|
+
import {
|
|
17
|
+
createShareLinkPolicyResolver,
|
|
18
|
+
resolveShareLinkExpiry,
|
|
19
|
+
} from '@helpers/share-link-policy/resolver';
|
|
20
|
+
import type { ShareLinkPolicyResolver } from '@helpers/share-link-policy/types';
|
|
21
|
+
import { hashSharePasscode } from '@helpers/share-link-passcode';
|
|
22
|
+
|
|
23
|
+
import { isShareLinkRepositoryError } from '../../accesslayer/share-link/errors';
|
|
24
|
+
import type { ShareLinkReservationRecord } from '../../accesslayer/share-link';
|
|
25
|
+
import { isTransientShareContentError } from '../share-content-client/types';
|
|
26
|
+
import type {
|
|
27
|
+
ShareContentClientErrorCode,
|
|
28
|
+
ShareContentEnvelope,
|
|
29
|
+
} from '../share-content-client/types';
|
|
30
|
+
import { verifyShareContentActiveStat as verifyActiveStat } from './stat';
|
|
31
|
+
import {
|
|
32
|
+
ShareLinkCoordinatorError,
|
|
33
|
+
type PendingShareContentOperation,
|
|
34
|
+
type ShareContentPayload,
|
|
35
|
+
type ShareLinkCommitResult,
|
|
36
|
+
type ShareLinkCoordinator,
|
|
37
|
+
type ShareLinkCoordinatorErrorCode,
|
|
38
|
+
type ShareLinkCoordinatorDependencies,
|
|
39
|
+
type ShareLinkResumeResult,
|
|
40
|
+
type ShareOwnerContext,
|
|
41
|
+
} from './types';
|
|
42
|
+
|
|
43
|
+
const OPAQUE_IDENTIFIER_RE = /^[A-Za-z0-9._~-]+$/;
|
|
44
|
+
|
|
45
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
46
|
+
typeof value === 'object' &&
|
|
47
|
+
value !== null &&
|
|
48
|
+
!Array.isArray(value) &&
|
|
49
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
50
|
+
|
|
51
|
+
const isOpaqueIdentifier = (value: unknown, maxLength = 128): value is string =>
|
|
52
|
+
typeof value === 'string' &&
|
|
53
|
+
value.length >= 1 &&
|
|
54
|
+
value.length <= maxLength &&
|
|
55
|
+
OPAQUE_IDENTIFIER_RE.test(value);
|
|
56
|
+
|
|
57
|
+
function fail(code: ShareLinkCoordinatorErrorCode, message: string): never {
|
|
58
|
+
throw new ShareLinkCoordinatorError(code, message);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const mapRepositoryErrorCode = (code: string): ShareLinkCoordinatorErrorCode => {
|
|
62
|
+
switch (code) {
|
|
63
|
+
case 'NOT_FOUND':
|
|
64
|
+
case 'CONFLICT':
|
|
65
|
+
case 'OPERATION_IN_FLIGHT':
|
|
66
|
+
case 'LEASE_EXPIRED':
|
|
67
|
+
case 'STALE_GENERATION':
|
|
68
|
+
case 'PRECONDITION_FAILED':
|
|
69
|
+
case 'INVALID_INPUT':
|
|
70
|
+
return code;
|
|
71
|
+
default:
|
|
72
|
+
return 'UNEXPECTED';
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const toCoordinatorError = (error: unknown): ShareLinkCoordinatorError => {
|
|
77
|
+
if (isShareLinkRepositoryError(error)) {
|
|
78
|
+
return new ShareLinkCoordinatorError(
|
|
79
|
+
mapRepositoryErrorCode(error.code),
|
|
80
|
+
'share-link lifecycle operation failed'
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return new ShareLinkCoordinatorError('UNEXPECTED', 'share-link lifecycle operation failed');
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const parseOwnerContext = (context: ShareOwnerContext): ShareOwnerContext => {
|
|
88
|
+
if (!isPlainObject(context)) fail('INVALID_INPUT', 'owner context must be an object');
|
|
89
|
+
if (!isOpaqueIdentifier(context.namespace)) {
|
|
90
|
+
fail('INVALID_INPUT', 'namespace must be an opaque configured identifier');
|
|
91
|
+
}
|
|
92
|
+
if (!isOpaqueIdentifier(context.ownerProfileId)) {
|
|
93
|
+
fail('INVALID_INPUT', 'ownerProfileId must be an opaque identifier');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { namespace: context.namespace, ownerProfileId: context.ownerProfileId };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const isRetryableClientError = (code: ShareContentClientErrorCode): boolean =>
|
|
100
|
+
isTransientShareContentError(code) ||
|
|
101
|
+
code === 'MALFORMED_RESPONSE' ||
|
|
102
|
+
code === 'RESPONSE_TOO_LARGE';
|
|
103
|
+
|
|
104
|
+
/** Preserve the real cause for deterministic client failures instead of a generic conflict. */
|
|
105
|
+
const mapClientErrorCode = (code: ShareContentClientErrorCode): ShareLinkCoordinatorErrorCode => {
|
|
106
|
+
switch (code) {
|
|
107
|
+
case 'UNAUTHORIZED':
|
|
108
|
+
return 'UNAUTHORIZED';
|
|
109
|
+
case 'SIGNING_FAILED':
|
|
110
|
+
return 'SIGNING_FAILED';
|
|
111
|
+
case 'PAYLOAD_TOO_LARGE':
|
|
112
|
+
return 'PAYLOAD_TOO_LARGE';
|
|
113
|
+
case 'NOT_FOUND':
|
|
114
|
+
return 'NOT_FOUND';
|
|
115
|
+
case 'CONFLICT':
|
|
116
|
+
return 'CONFLICT';
|
|
117
|
+
case 'INVALID_INPUT':
|
|
118
|
+
return 'INVALID_INPUT';
|
|
119
|
+
default:
|
|
120
|
+
return 'UNEXPECTED';
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const measurementFor = (
|
|
125
|
+
envelope: ShareContentEnvelope,
|
|
126
|
+
ownerEncryptedRecovery: Record<string, unknown>
|
|
127
|
+
): { contentBytes: number; recoveryBytes: number; recoveryHash: string } => {
|
|
128
|
+
const contentBytes = decodedBase64UrlByteLength(envelope.ct);
|
|
129
|
+
|
|
130
|
+
if (contentBytes === null) fail('INVALID_INPUT', 'envelope ciphertext must be base64url');
|
|
131
|
+
|
|
132
|
+
const recoveryBytes = utf8ByteLength(JSON.stringify(ownerEncryptedRecovery));
|
|
133
|
+
const recoveryHash = createHash('sha256')
|
|
134
|
+
.update(canonicalizeJson(ownerEncryptedRecovery))
|
|
135
|
+
.digest('hex');
|
|
136
|
+
|
|
137
|
+
return { contentBytes, recoveryBytes, recoveryHash };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const contentBindingFor = (
|
|
141
|
+
envelope: ShareContentEnvelope,
|
|
142
|
+
ownerEncryptedRecovery: Record<string, unknown>
|
|
143
|
+
): {
|
|
144
|
+
contentHash: string;
|
|
145
|
+
contentBytes: number;
|
|
146
|
+
recoveryHash: string;
|
|
147
|
+
recoveryBytes: number;
|
|
148
|
+
} => ({
|
|
149
|
+
contentHash: computeShareLinkPayloadHash({ envelope, ownerEncryptedRecovery }),
|
|
150
|
+
...measurementFor(envelope, ownerEncryptedRecovery),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export const createShareLinkCoordinator = (
|
|
154
|
+
dependencies: ShareLinkCoordinatorDependencies
|
|
155
|
+
): ShareLinkCoordinator => {
|
|
156
|
+
const { repository, client } = dependencies;
|
|
157
|
+
const now = (): Date => dependencies.now?.() ?? new Date();
|
|
158
|
+
|
|
159
|
+
// Fail-closed fallback policy: no views, 30 days, age unknown. Production
|
|
160
|
+
// wiring injects the authoritative source; a missing resolver must never
|
|
161
|
+
// behave as a known unmanaged adult.
|
|
162
|
+
const policyResolver: ShareLinkPolicyResolver =
|
|
163
|
+
dependencies.policyResolver ??
|
|
164
|
+
createShareLinkPolicyResolver({
|
|
165
|
+
resolveOwnerAge: async () => 'unknown',
|
|
166
|
+
isManaged: async () => false,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const finalizeOrPending = async (
|
|
170
|
+
reservation: ShareLinkReservationRecord,
|
|
171
|
+
objectRef: string | null
|
|
172
|
+
): Promise<ShareLinkCommitResult> => {
|
|
173
|
+
try {
|
|
174
|
+
const finalized = await repository.finalizeReservation({
|
|
175
|
+
namespace: reservation.namespace,
|
|
176
|
+
ownerProfileId: reservation.ownerProfileId,
|
|
177
|
+
shareId: reservation.shareId,
|
|
178
|
+
operationId: reservation.operationId,
|
|
179
|
+
objectRef,
|
|
180
|
+
generation: reservation.generation,
|
|
181
|
+
leaseOwner: reservation.leaseOwner,
|
|
182
|
+
verifiedContentHash:
|
|
183
|
+
objectRef === null || reservation.contentHash === null
|
|
184
|
+
? undefined
|
|
185
|
+
: reservation.contentHash,
|
|
186
|
+
now: now(),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return finalized.outcome === 'finalized'
|
|
190
|
+
? { status: 'committed', share: finalized.share }
|
|
191
|
+
: { status: 'replayed', share: finalized.share, recorded: finalized.share };
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (isShareLinkRepositoryError(error)) throw toCoordinatorError(error);
|
|
194
|
+
|
|
195
|
+
// An unexpected failure may have committed the swap but lost the
|
|
196
|
+
// response. Keep the reservation resumable; a stateless retry will
|
|
197
|
+
// re-stat and finalize idempotently.
|
|
198
|
+
return {
|
|
199
|
+
status: 'pending',
|
|
200
|
+
reservation,
|
|
201
|
+
reason: 'finalize_unavailable',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const abandonBestEffort = async (reservation: ShareLinkReservationRecord): Promise<void> => {
|
|
207
|
+
try {
|
|
208
|
+
await repository.abandonReservation({
|
|
209
|
+
namespace: reservation.namespace,
|
|
210
|
+
ownerProfileId: reservation.ownerProfileId,
|
|
211
|
+
shareId: reservation.shareId,
|
|
212
|
+
operationId: reservation.operationId,
|
|
213
|
+
generation: reservation.generation,
|
|
214
|
+
leaseOwner: reservation.leaseOwner,
|
|
215
|
+
now: now(),
|
|
216
|
+
});
|
|
217
|
+
} catch {
|
|
218
|
+
// The reservation lease still bounds a later supersede; never hide the
|
|
219
|
+
// original failure that triggered the abandon.
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const driveContentReservation = async (
|
|
224
|
+
reservation: ShareLinkReservationRecord,
|
|
225
|
+
payload: ShareContentPayload
|
|
226
|
+
): Promise<ShareLinkCommitResult> => {
|
|
227
|
+
if (reservation.objectRef === null || reservation.contentVersion === null) {
|
|
228
|
+
fail('PRECONDITION_FAILED', 'content reservation is missing its object binding');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const tuple = {
|
|
232
|
+
namespace: reservation.namespace,
|
|
233
|
+
ownerProfileId: reservation.ownerProfileId,
|
|
234
|
+
shareId: reservation.shareId,
|
|
235
|
+
contentVersion: reservation.contentVersion,
|
|
236
|
+
objectId: reservation.objectRef,
|
|
237
|
+
operationId: reservation.operationId,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const upload = await client.put({
|
|
241
|
+
...tuple,
|
|
242
|
+
envelope: payload.envelope,
|
|
243
|
+
ownerEncryptedRecovery: payload.ownerEncryptedRecovery,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (!upload.ok) {
|
|
247
|
+
if (isRetryableClientError(upload.error)) {
|
|
248
|
+
return { status: 'pending', reservation, reason: 'upload_unavailable' };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Deterministic rejection: release the reservation and never hide the
|
|
252
|
+
// failure behind a pending state. The exact object is queued for
|
|
253
|
+
// tracked cleanup by abandon.
|
|
254
|
+
await abandonBestEffort(reservation);
|
|
255
|
+
fail(mapClientErrorCode(upload.error), 'LearnCloud rejected the content upload');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const stat = await client.stat(tuple);
|
|
259
|
+
|
|
260
|
+
if (!stat.ok) {
|
|
261
|
+
if (stat.error === 'NOT_FOUND') {
|
|
262
|
+
return { status: 'pending', reservation, reason: 'stat_missing' };
|
|
263
|
+
}
|
|
264
|
+
if (isRetryableClientError(stat.error)) {
|
|
265
|
+
return { status: 'pending', reservation, reason: 'stat_unavailable' };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Do not finalize on a failed stat, and do not destroy the staged
|
|
269
|
+
// object on an ambiguous response.
|
|
270
|
+
fail(mapClientErrorCode(stat.error), 'LearnCloud stat failed');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!verifyActiveStat(stat.value, reservation)) {
|
|
274
|
+
if (stat.value.kind === 'tombstone') {
|
|
275
|
+
await abandonBestEffort(reservation);
|
|
276
|
+
fail('CONFLICT', 'the reserved object is a permanent tombstone');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { status: 'pending', reservation, reason: 'stat_mismatch' };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return finalizeOrPending(reservation, reservation.objectRef);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const handleReserveResult = async (
|
|
286
|
+
result: Awaited<
|
|
287
|
+
ReturnType<ShareLinkCoordinatorDependencies['repository']['reserveCreate']>
|
|
288
|
+
>,
|
|
289
|
+
payload?: ShareContentPayload
|
|
290
|
+
): Promise<ShareLinkCommitResult> => {
|
|
291
|
+
if (result.outcome === 'already_committed') {
|
|
292
|
+
return { status: 'replayed', share: result.current, recorded: result.recorded };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!payload || result.reservation.objectRef === null) {
|
|
296
|
+
return finalizeOrPending(result.reservation, null);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return driveContentReservation(result.reservation, payload);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
createShareLink: async (request, context) => {
|
|
304
|
+
const owner = parseOwnerContext(context);
|
|
305
|
+
const parsed = CreateShareLinkInputValidator.safeParse(request);
|
|
306
|
+
|
|
307
|
+
if (!parsed.success) fail('INVALID_INPUT', 'invalid create share-link request');
|
|
308
|
+
|
|
309
|
+
const value = parsed.data;
|
|
310
|
+
const content = contentBindingFor(value.envelope, value.ownerEncryptedRecovery);
|
|
311
|
+
// Caller intent only: `expiresAt` remains exactly as supplied
|
|
312
|
+
// (including omitted) so a later-clock retry of an omitted-expiry
|
|
313
|
+
// create replays the original operation instead of deriving a new one.
|
|
314
|
+
const requestHash = computeShareLinkRequestHash(
|
|
315
|
+
'create',
|
|
316
|
+
value as unknown as Record<string, unknown>
|
|
317
|
+
);
|
|
318
|
+
const policy = await policyResolver.resolve(owner.ownerProfileId);
|
|
319
|
+
const effectiveExpiresAt = resolveShareLinkExpiry(policy, value.expiresAt, now());
|
|
320
|
+
const passcodeHash = value.passcode ? await hashSharePasscode(value.passcode) : null;
|
|
321
|
+
|
|
322
|
+
let reserved;
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
reserved = await repository.reserveCreate({
|
|
326
|
+
namespace: owner.namespace,
|
|
327
|
+
ownerProfileId: owner.ownerProfileId,
|
|
328
|
+
clientRequestId: value.clientRequestId,
|
|
329
|
+
shareId: value.id,
|
|
330
|
+
title: value.title,
|
|
331
|
+
note: value.note ?? null,
|
|
332
|
+
expiresAt: effectiveExpiresAt,
|
|
333
|
+
selectedCount: value.selectedCount,
|
|
334
|
+
passcodeHash,
|
|
335
|
+
// Notification consent cannot override the same trusted
|
|
336
|
+
// policy that disables view counting for minors/unknown age.
|
|
337
|
+
notifyOnView: value.notifyOnView && policy.viewCountingEnabled,
|
|
338
|
+
content,
|
|
339
|
+
requestHash,
|
|
340
|
+
policy,
|
|
341
|
+
leaseOwner: dependencies.leaseOwner,
|
|
342
|
+
leaseMs: dependencies.leaseMs,
|
|
343
|
+
now: now(),
|
|
344
|
+
});
|
|
345
|
+
} catch (error) {
|
|
346
|
+
throw toCoordinatorError(error);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return handleReserveResult(reserved, {
|
|
350
|
+
envelope: value.envelope,
|
|
351
|
+
ownerEncryptedRecovery: value.ownerEncryptedRecovery,
|
|
352
|
+
});
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
updateShareLink: async (request, context) => {
|
|
356
|
+
const owner = parseOwnerContext(context);
|
|
357
|
+
const parsed = UpdateShareLinkInputValidator.safeParse(request);
|
|
358
|
+
|
|
359
|
+
if (!parsed.success) fail('INVALID_INPUT', 'invalid update share-link request');
|
|
360
|
+
|
|
361
|
+
const value = parsed.data;
|
|
362
|
+
const hasContent = value.envelope !== undefined;
|
|
363
|
+
const requestHash = computeShareLinkRequestHash(
|
|
364
|
+
'update',
|
|
365
|
+
value as unknown as Record<string, unknown>
|
|
366
|
+
);
|
|
367
|
+
// Policy is re-resolved on every mutation, even a metadata-only one,
|
|
368
|
+
// so a profile that became managed stops accumulating views going
|
|
369
|
+
// forward. An omitted `expiresAt` leaves the existing expiry intact.
|
|
370
|
+
const policy = await policyResolver.resolve(owner.ownerProfileId);
|
|
371
|
+
const passcodeHash =
|
|
372
|
+
value.passcode === undefined
|
|
373
|
+
? undefined
|
|
374
|
+
: value.passcode === null
|
|
375
|
+
? null
|
|
376
|
+
: await hashSharePasscode(value.passcode);
|
|
377
|
+
|
|
378
|
+
let reserved;
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
reserved = await repository.reserveReplacement({
|
|
382
|
+
namespace: owner.namespace,
|
|
383
|
+
ownerProfileId: owner.ownerProfileId,
|
|
384
|
+
clientRequestId: value.clientRequestId,
|
|
385
|
+
shareId: value.id,
|
|
386
|
+
expectedVersion: value.expectedVersion,
|
|
387
|
+
requestHash,
|
|
388
|
+
leaseOwner: dependencies.leaseOwner,
|
|
389
|
+
leaseMs: dependencies.leaseMs,
|
|
390
|
+
now: now(),
|
|
391
|
+
...(hasContent
|
|
392
|
+
? {
|
|
393
|
+
content: {
|
|
394
|
+
...contentBindingFor(
|
|
395
|
+
value.envelope!,
|
|
396
|
+
value.ownerEncryptedRecovery!
|
|
397
|
+
),
|
|
398
|
+
contentVersion: value.contentVersion!,
|
|
399
|
+
selectedCount: value.selectedCount!,
|
|
400
|
+
},
|
|
401
|
+
}
|
|
402
|
+
: {}),
|
|
403
|
+
...(value.title !== undefined ? { title: value.title } : {}),
|
|
404
|
+
...(value.note !== undefined ? { note: value.note } : {}),
|
|
405
|
+
...(value.expiresAt !== undefined ? { expiresAt: value.expiresAt } : {}),
|
|
406
|
+
...(passcodeHash !== undefined ? { passcodeHash } : {}),
|
|
407
|
+
...(value.notifyOnView !== undefined
|
|
408
|
+
? { notifyOnView: value.notifyOnView && policy.viewCountingEnabled }
|
|
409
|
+
: {}),
|
|
410
|
+
policy,
|
|
411
|
+
});
|
|
412
|
+
} catch (error) {
|
|
413
|
+
throw toCoordinatorError(error);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (reserved.outcome === 'already_committed') {
|
|
417
|
+
return {
|
|
418
|
+
status: 'replayed',
|
|
419
|
+
share: reserved.current,
|
|
420
|
+
recorded: reserved.recorded,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!hasContent || reserved.reservation.objectRef === null) {
|
|
425
|
+
// Metadata-only revision: never uploads, preserves the original
|
|
426
|
+
// object operation id via the C3 finalize path.
|
|
427
|
+
return finalizeOrPending(reserved.reservation, null);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return driveContentReservation(reserved.reservation, {
|
|
431
|
+
envelope: value.envelope!,
|
|
432
|
+
ownerEncryptedRecovery: value.ownerEncryptedRecovery!,
|
|
433
|
+
});
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
revokeShareLink: async (request, context) => {
|
|
437
|
+
const owner = parseOwnerContext(context);
|
|
438
|
+
|
|
439
|
+
if (!isPlainObject(request)) fail('INVALID_INPUT', 'invalid revoke request');
|
|
440
|
+
|
|
441
|
+
const allowedKeys = ['id', 'expectedVersion', 'clientRequestId'];
|
|
442
|
+
|
|
443
|
+
if (Object.keys(request).some(key => !allowedKeys.includes(key))) {
|
|
444
|
+
fail('INVALID_INPUT', 'invalid revoke request');
|
|
445
|
+
}
|
|
446
|
+
if (!ShareLinkIdValidator.safeParse(request.id).success) {
|
|
447
|
+
fail('INVALID_INPUT', 'invalid share id');
|
|
448
|
+
}
|
|
449
|
+
if (
|
|
450
|
+
request.expectedVersion !== undefined &&
|
|
451
|
+
(!Number.isSafeInteger(request.expectedVersion) ||
|
|
452
|
+
(request.expectedVersion as number) < 1)
|
|
453
|
+
) {
|
|
454
|
+
fail('INVALID_INPUT', 'invalid expectedVersion');
|
|
455
|
+
}
|
|
456
|
+
if (
|
|
457
|
+
request.clientRequestId !== undefined &&
|
|
458
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
|
459
|
+
String(request.clientRequestId)
|
|
460
|
+
)
|
|
461
|
+
) {
|
|
462
|
+
fail('INVALID_INPUT', 'invalid clientRequestId');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const clientRequestId = request.clientRequestId as string | undefined;
|
|
466
|
+
const requestHash = clientRequestId
|
|
467
|
+
? computeShareLinkRequestHash('revoke', {
|
|
468
|
+
id: request.id,
|
|
469
|
+
expectedVersion: request.expectedVersion,
|
|
470
|
+
})
|
|
471
|
+
: undefined;
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
const revoked = await repository.revokeShareLink({
|
|
475
|
+
namespace: owner.namespace,
|
|
476
|
+
ownerProfileId: owner.ownerProfileId,
|
|
477
|
+
shareId: request.id as string,
|
|
478
|
+
expectedVersion: request.expectedVersion as number | undefined,
|
|
479
|
+
clientRequestId,
|
|
480
|
+
requestHash,
|
|
481
|
+
now: now(),
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
status: revoked.outcome,
|
|
486
|
+
share: revoked.share,
|
|
487
|
+
cleanupQueuedFor: revoked.cleanupQueuedFor,
|
|
488
|
+
};
|
|
489
|
+
} catch (error) {
|
|
490
|
+
throw toCoordinatorError(error);
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
resumePendingOperation: async (pending, content): Promise<ShareLinkResumeResult> => {
|
|
495
|
+
const reservation = pending.reservation;
|
|
496
|
+
|
|
497
|
+
if (reservation.objectRef === null || reservation.contentVersion === null) {
|
|
498
|
+
const finalized = await finalizeOrPending(reservation, null);
|
|
499
|
+
|
|
500
|
+
return finalized.status === 'pending'
|
|
501
|
+
? { status: 'pending', reservation, reason: finalized.reason }
|
|
502
|
+
: finalized;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (content) {
|
|
506
|
+
const result = await driveContentReservation(reservation, content);
|
|
507
|
+
|
|
508
|
+
return result.status === 'pending'
|
|
509
|
+
? { status: 'pending', reservation, reason: result.reason }
|
|
510
|
+
: result;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const tuple = {
|
|
514
|
+
namespace: reservation.namespace,
|
|
515
|
+
ownerProfileId: reservation.ownerProfileId,
|
|
516
|
+
shareId: reservation.shareId,
|
|
517
|
+
contentVersion: reservation.contentVersion,
|
|
518
|
+
objectId: reservation.objectRef,
|
|
519
|
+
operationId: reservation.operationId,
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const stat = await client.stat(tuple);
|
|
523
|
+
|
|
524
|
+
if (stat.ok) {
|
|
525
|
+
if (verifyActiveStat(stat.value, reservation)) {
|
|
526
|
+
const finalized = await finalizeOrPending(reservation, reservation.objectRef);
|
|
527
|
+
|
|
528
|
+
return finalized.status === 'pending'
|
|
529
|
+
? { status: 'pending', reservation, reason: finalized.reason }
|
|
530
|
+
: finalized;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (stat.value.kind === 'tombstone') {
|
|
534
|
+
const abandoned = await repository.abandonReservation({
|
|
535
|
+
namespace: reservation.namespace,
|
|
536
|
+
ownerProfileId: reservation.ownerProfileId,
|
|
537
|
+
shareId: reservation.shareId,
|
|
538
|
+
operationId: reservation.operationId,
|
|
539
|
+
generation: reservation.generation,
|
|
540
|
+
leaseOwner: reservation.leaseOwner,
|
|
541
|
+
now: now(),
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
status: 'abandoned',
|
|
546
|
+
cleanupQueuedFor:
|
|
547
|
+
abandoned.outcome === 'abandoned' ? abandoned.cleanupQueuedFor : null,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Active but divergent: never finalize and never destroy bytes.
|
|
552
|
+
return { status: 'pending', reservation, reason: 'stat_mismatch' };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (stat.error === 'NOT_FOUND') {
|
|
556
|
+
const abandoned = await repository.abandonReservation({
|
|
557
|
+
namespace: reservation.namespace,
|
|
558
|
+
ownerProfileId: reservation.ownerProfileId,
|
|
559
|
+
shareId: reservation.shareId,
|
|
560
|
+
operationId: reservation.operationId,
|
|
561
|
+
generation: reservation.generation,
|
|
562
|
+
leaseOwner: reservation.leaseOwner,
|
|
563
|
+
now: now(),
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
status: 'abandoned',
|
|
568
|
+
cleanupQueuedFor:
|
|
569
|
+
abandoned.outcome === 'abandoned' ? abandoned.cleanupQueuedFor : null,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (isRetryableClientError(stat.error)) {
|
|
574
|
+
return { status: 'pending', reservation, reason: 'stat_unavailable' };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
fail(mapClientErrorCode(stat.error), 'LearnCloud stat failed during recovery');
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
abandonPendingOperation: async pending =>
|
|
581
|
+
repository.abandonReservation({
|
|
582
|
+
namespace: pending.reservation.namespace,
|
|
583
|
+
ownerProfileId: pending.reservation.ownerProfileId,
|
|
584
|
+
shareId: pending.reservation.shareId,
|
|
585
|
+
operationId: pending.reservation.operationId,
|
|
586
|
+
generation: pending.reservation.generation,
|
|
587
|
+
leaseOwner: pending.reservation.leaseOwner,
|
|
588
|
+
now: now(),
|
|
589
|
+
}),
|
|
590
|
+
|
|
591
|
+
getShareLink: async (shareId, context) => {
|
|
592
|
+
const owner = parseOwnerContext(context);
|
|
593
|
+
|
|
594
|
+
return repository.getShareLink({
|
|
595
|
+
shareId,
|
|
596
|
+
namespace: owner.namespace,
|
|
597
|
+
ownerProfileId: owner.ownerProfileId,
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
getActiveShareContent: async (shareId, context) => {
|
|
602
|
+
const owner = parseOwnerContext(context);
|
|
603
|
+
|
|
604
|
+
return repository.getCurrentShareContent({
|
|
605
|
+
shareId,
|
|
606
|
+
namespace: owner.namespace,
|
|
607
|
+
ownerProfileId: owner.ownerProfileId,
|
|
608
|
+
now: now(),
|
|
609
|
+
});
|
|
610
|
+
},
|
|
611
|
+
|
|
612
|
+
fetchShareContent: async (shareId, context, options = {}) => {
|
|
613
|
+
const owner = parseOwnerContext(context);
|
|
614
|
+
const current = await repository.getCurrentShareContent({
|
|
615
|
+
shareId,
|
|
616
|
+
namespace: owner.namespace,
|
|
617
|
+
ownerProfileId: owner.ownerProfileId,
|
|
618
|
+
now: now(),
|
|
619
|
+
allowExpired: options.allowExpired,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
if (current.state !== 'active') {
|
|
623
|
+
return {
|
|
624
|
+
ok: false,
|
|
625
|
+
error: 'NOT_FOUND',
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Use the object's ORIGINAL operation id, never the latest metadata
|
|
630
|
+
// operation id, for read/stat/delete of an immutable object.
|
|
631
|
+
const result = await client.get({
|
|
632
|
+
namespace: current.namespace,
|
|
633
|
+
ownerProfileId: current.ownerProfileId,
|
|
634
|
+
shareId: current.shareId,
|
|
635
|
+
contentVersion: current.contentVersion,
|
|
636
|
+
objectId: current.objectRef,
|
|
637
|
+
operationId: current.operationId,
|
|
638
|
+
});
|
|
639
|
+
if (!result.ok) return result;
|
|
640
|
+
|
|
641
|
+
// The remote read can overlap a stop, expiry or replacement. Only
|
|
642
|
+
// release bytes if the same committed snapshot still permits it.
|
|
643
|
+
const latest = await repository.getCurrentShareContent({
|
|
644
|
+
shareId,
|
|
645
|
+
namespace: owner.namespace,
|
|
646
|
+
ownerProfileId: owner.ownerProfileId,
|
|
647
|
+
now: now(),
|
|
648
|
+
allowExpired: options.allowExpired,
|
|
649
|
+
});
|
|
650
|
+
if (
|
|
651
|
+
latest.state !== 'active' ||
|
|
652
|
+
latest.version !== current.version ||
|
|
653
|
+
latest.objectRef !== current.objectRef ||
|
|
654
|
+
latest.operationId !== current.operationId ||
|
|
655
|
+
latest.contentVersion !== current.contentVersion
|
|
656
|
+
) {
|
|
657
|
+
return { ok: false, error: 'NOT_FOUND' };
|
|
658
|
+
}
|
|
659
|
+
return result;
|
|
660
|
+
},
|
|
661
|
+
|
|
662
|
+
readOwnerRecovery: async (shareId, context) => {
|
|
663
|
+
const owner = parseOwnerContext(context);
|
|
664
|
+
// Owner recovery deliberately admits retained *expired* content so the
|
|
665
|
+
// owner can extend/edit it. Stopped, staging, content_missing and
|
|
666
|
+
// missing shares still fail closed here.
|
|
667
|
+
const current = await repository.getCurrentShareContent({
|
|
668
|
+
shareId,
|
|
669
|
+
namespace: owner.namespace,
|
|
670
|
+
ownerProfileId: owner.ownerProfileId,
|
|
671
|
+
now: now(),
|
|
672
|
+
allowExpired: true,
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
if (current.state !== 'active') {
|
|
676
|
+
return { ok: false, error: 'NOT_FOUND' };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const result = await client.readRecovery({
|
|
680
|
+
namespace: current.namespace,
|
|
681
|
+
ownerProfileId: current.ownerProfileId,
|
|
682
|
+
shareId: current.shareId,
|
|
683
|
+
contentVersion: current.contentVersion,
|
|
684
|
+
objectId: current.objectRef,
|
|
685
|
+
operationId: current.operationId,
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
if (!result.ok) return result;
|
|
689
|
+
|
|
690
|
+
// The remote read can overlap a stop/replacement. Only release the
|
|
691
|
+
// recovery for the same immutable committed tuple that authorized it.
|
|
692
|
+
const latest = await repository.getCurrentShareContent({
|
|
693
|
+
shareId,
|
|
694
|
+
namespace: owner.namespace,
|
|
695
|
+
ownerProfileId: owner.ownerProfileId,
|
|
696
|
+
now: now(),
|
|
697
|
+
allowExpired: true,
|
|
698
|
+
});
|
|
699
|
+
if (
|
|
700
|
+
latest.state !== 'active' ||
|
|
701
|
+
latest.version !== current.version ||
|
|
702
|
+
latest.objectRef !== current.objectRef ||
|
|
703
|
+
latest.operationId !== current.operationId ||
|
|
704
|
+
latest.contentVersion !== current.contentVersion
|
|
705
|
+
) {
|
|
706
|
+
return { ok: false, error: 'NOT_FOUND' };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return result;
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
export type { PendingShareContentOperation };
|