@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,548 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { ensureShareLinkConstraints } from '../../models/share-link-constraints';
|
|
4
|
+
import { failShareLink } from './errors';
|
|
5
|
+
import { lockShare, readNodeProperties } from './helpers';
|
|
6
|
+
import { withShareLinkRead, withShareLinkTransaction } from './transaction';
|
|
7
|
+
import type { ShareLinkTransaction } from './transaction';
|
|
8
|
+
import type {
|
|
9
|
+
ConsumeShareViewReceiptInput,
|
|
10
|
+
ConsumeShareViewReceiptOutcome,
|
|
11
|
+
PersistShareViewReceiptInput,
|
|
12
|
+
PruneShareViewReceiptsInput,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* LC-2187 view-receipt repository.
|
|
17
|
+
*
|
|
18
|
+
* Security invariants:
|
|
19
|
+
* - The raw opaque token is NEVER stored; only `sha256(token)` keyed by a
|
|
20
|
+
* uniqueness constraint. Padding values returned to ineligible viewers are
|
|
21
|
+
* never persisted, so they can never be consumed.
|
|
22
|
+
* - Consume + `viewCount` increment + `lastViewedAt` happen in ONE Neo4j
|
|
23
|
+
* transaction that (1) locks the share, (2) locks the receipt, then (3)
|
|
24
|
+
* re-reads status/version/expiry/policy/consumed state. The share lock is the
|
|
25
|
+
* SAME lock revoke/update/finalize take, so an acknowledgement that races a
|
|
26
|
+
* revoke/replacement either commits before it or observes the stop and does
|
|
27
|
+
* not count.
|
|
28
|
+
* - Eligibility is a conjunction of the committed snapshot policy re-read under
|
|
29
|
+
* the lock and a narrowly typed, transaction-compatible trusted policy source
|
|
30
|
+
* consulted inside the same transaction. A pre-lock boolean is never authority;
|
|
31
|
+
* absence or failure of the source is ineligible. A uniqueness constraint alone
|
|
32
|
+
* is insufficient.
|
|
33
|
+
* - No remote/network I/O ever runs inside the transaction.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const DEFAULT_PRUNE_LIMIT = 200;
|
|
37
|
+
const MAX_PRUNE_LIMIT = 1000;
|
|
38
|
+
const MAX_RECEIPT_TTL_SECONDS = 86_400;
|
|
39
|
+
|
|
40
|
+
const RECEIPT_HASH_RE = /^[0-9a-f]{64}$/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A durable receipt is only trustworthy when every required binding field is
|
|
44
|
+
* present and well-formed. Malformed state must never be interpreted as a
|
|
45
|
+
* permissive default, so these helpers return `null` and callers fail closed.
|
|
46
|
+
*/
|
|
47
|
+
const requiredString = (value: unknown): string | null =>
|
|
48
|
+
typeof value === 'string' && value.length > 0 ? value : null;
|
|
49
|
+
|
|
50
|
+
const requiredFiniteNumber = (value: unknown): number | null =>
|
|
51
|
+
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Strict version validator. A durable version is a safe positive integer:
|
|
55
|
+
* negative, fractional, zero, NaN and Infinity values are malformed and fail
|
|
56
|
+
* closed rather than being coerced or defaulted.
|
|
57
|
+
*/
|
|
58
|
+
const requiredPositiveInteger = (value: unknown): number | null =>
|
|
59
|
+
typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null;
|
|
60
|
+
|
|
61
|
+
/** A required ISO timestamp that actually parses; `null`/garbage fail closed. */
|
|
62
|
+
const requiredInstantMs = (value: unknown): number | null => {
|
|
63
|
+
if (typeof value !== 'string' || value.length === 0) return null;
|
|
64
|
+
|
|
65
|
+
const parsed = Date.parse(value);
|
|
66
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A valid optional instant. Absent/`null` is a legitimate "unset" and returns
|
|
71
|
+
* `null`; any other value MUST parse, otherwise `undefined` is returned so the
|
|
72
|
+
* caller can fail closed instead of treating malformed state as unset.
|
|
73
|
+
*/
|
|
74
|
+
const optionalInstantMs = (value: unknown): number | null | undefined => {
|
|
75
|
+
if (value === null || value === undefined) return null;
|
|
76
|
+
|
|
77
|
+
return requiredInstantMs(value) ?? undefined;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const hashShareViewReceipt = (receipt: string): string =>
|
|
81
|
+
createHash('sha256').update(receipt, 'utf8').digest('hex');
|
|
82
|
+
|
|
83
|
+
const readReceiptByHash = async (
|
|
84
|
+
tx: ShareLinkTransaction,
|
|
85
|
+
receiptHash: string
|
|
86
|
+
): Promise<Record<string, unknown> | null> => {
|
|
87
|
+
const result = await tx.run(
|
|
88
|
+
'MATCH (v:ShareViewReceipt {receiptHash: $receiptHash}) RETURN v LIMIT 1',
|
|
89
|
+
{ receiptHash }
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return readNodeProperties(result, 'v');
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Takes the receipt write lock. Must only run AFTER the share lock is held by
|
|
97
|
+
* the same transaction, so every receipt/consume path takes locks in the same
|
|
98
|
+
* share-then-receipt order as revoke/update.
|
|
99
|
+
*/
|
|
100
|
+
const lockReceipt = async (
|
|
101
|
+
tx: ShareLinkTransaction,
|
|
102
|
+
receiptHash: string
|
|
103
|
+
): Promise<Record<string, unknown> | null> => {
|
|
104
|
+
const result = await tx.run(
|
|
105
|
+
`MATCH (v:ShareViewReceipt {receiptHash: $receiptHash})
|
|
106
|
+
SET v.lockTick = coalesce(v.lockTick, 0) + 1
|
|
107
|
+
RETURN v`,
|
|
108
|
+
{ receiptHash }
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return readNodeProperties(result, 'v');
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Persists one eligible receipt. Returns `false` (never throws for the caller's
|
|
116
|
+
* counting decision) when a hash collision, malformed input, stale share,
|
|
117
|
+
* missing/unverifiable eligibility source or ineligible owner makes the node
|
|
118
|
+
* unwritable; the public route still returns an identically shaped padding
|
|
119
|
+
* token, so no eligibility-dependent response shape is exposed.
|
|
120
|
+
*
|
|
121
|
+
* The share lock is taken FIRST (the same lock revoke/update/finalize take),
|
|
122
|
+
* then the complete current tuple/status/version/expiry is re-read under it,
|
|
123
|
+
* then the trusted policy source is consulted inside the same transaction, and
|
|
124
|
+
* only then is the receipt created. A pre-lock boolean is never authority.
|
|
125
|
+
*/
|
|
126
|
+
export const persistShareViewReceipt = async (
|
|
127
|
+
input: PersistShareViewReceiptInput
|
|
128
|
+
): Promise<boolean> => {
|
|
129
|
+
const receiptHash = hashShareViewReceipt(input.receipt);
|
|
130
|
+
if (!RECEIPT_HASH_RE.test(receiptHash)) return false;
|
|
131
|
+
|
|
132
|
+
// Required full binding. A receipt missing any immutable identity field must
|
|
133
|
+
// never be written, because consume could not distinguish it from a receipt
|
|
134
|
+
// for a different revision.
|
|
135
|
+
const namespace = requiredString(input.namespace);
|
|
136
|
+
const ownerProfileId = requiredString(input.ownerProfileId);
|
|
137
|
+
const shareId = requiredString(input.shareId);
|
|
138
|
+
const objectRef = requiredString(input.objectRef);
|
|
139
|
+
const operationId = requiredString(input.operationId);
|
|
140
|
+
const shareVersion = requiredPositiveInteger(input.shareVersion);
|
|
141
|
+
const contentVersion = requiredPositiveInteger(input.contentVersion);
|
|
142
|
+
const ttlSeconds = requiredFiniteNumber(input.ttlSeconds);
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
namespace === null ||
|
|
146
|
+
ownerProfileId === null ||
|
|
147
|
+
shareId === null ||
|
|
148
|
+
objectRef === null ||
|
|
149
|
+
operationId === null ||
|
|
150
|
+
shareVersion === null ||
|
|
151
|
+
contentVersion === null ||
|
|
152
|
+
ttlSeconds === null ||
|
|
153
|
+
ttlSeconds < 0 ||
|
|
154
|
+
ttlSeconds > MAX_RECEIPT_TTL_SECONDS
|
|
155
|
+
) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// No trusted, transaction-compatible eligibility source means no write. The
|
|
160
|
+
// production age source is unknown today, so production passes no source and
|
|
161
|
+
// never issues a receipt or a count.
|
|
162
|
+
const eligibilitySource = input.eligibilitySource;
|
|
163
|
+
if (!eligibilitySource) return false;
|
|
164
|
+
|
|
165
|
+
await ensureShareLinkConstraints();
|
|
166
|
+
|
|
167
|
+
const now = input.now ?? (() => new Date());
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
return await withShareLinkTransaction(async tx => {
|
|
171
|
+
// Share lock FIRST (same lock revoke/update/finalize take).
|
|
172
|
+
const shareProps = await lockShare(tx, shareId);
|
|
173
|
+
if (!shareProps) return false;
|
|
174
|
+
|
|
175
|
+
const currentNamespace = requiredString(shareProps.namespace);
|
|
176
|
+
const currentOwner = requiredString(shareProps.ownerProfileId);
|
|
177
|
+
const status = requiredString(shareProps.status);
|
|
178
|
+
const contentState = requiredString(shareProps.contentState);
|
|
179
|
+
const currentVersion = requiredPositiveInteger(shareProps.version);
|
|
180
|
+
const currentContentVersion = requiredPositiveInteger(shareProps.contentVersion);
|
|
181
|
+
const currentObjectRef = requiredString(shareProps.activeObjectRef);
|
|
182
|
+
const currentOperationId = requiredString(shareProps.activeObjectOperationId);
|
|
183
|
+
|
|
184
|
+
if (
|
|
185
|
+
currentNamespace === null ||
|
|
186
|
+
currentNamespace !== namespace ||
|
|
187
|
+
currentOwner === null ||
|
|
188
|
+
currentOwner !== ownerProfileId ||
|
|
189
|
+
status !== 'active' ||
|
|
190
|
+
contentState !== 'finalized' ||
|
|
191
|
+
currentVersion === null ||
|
|
192
|
+
currentVersion !== shareVersion ||
|
|
193
|
+
currentContentVersion === null ||
|
|
194
|
+
currentContentVersion !== contentVersion ||
|
|
195
|
+
currentObjectRef === null ||
|
|
196
|
+
currentObjectRef !== objectRef ||
|
|
197
|
+
currentOperationId === null ||
|
|
198
|
+
currentOperationId !== operationId
|
|
199
|
+
) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (
|
|
204
|
+
shareProps.minorPolicyViewCountingEnabled !== true ||
|
|
205
|
+
shareProps.minorPolicyResolved !== true ||
|
|
206
|
+
shareProps.minorPolicyIsMinor !== false
|
|
207
|
+
)
|
|
208
|
+
return false;
|
|
209
|
+
|
|
210
|
+
// Optional share expiry: absent/null is unlimited, but a malformed
|
|
211
|
+
// value must never be interpreted as unlimited.
|
|
212
|
+
const shareExpiresMs = optionalInstantMs(shareProps.expiresAt);
|
|
213
|
+
if (shareExpiresMs === undefined) return false;
|
|
214
|
+
|
|
215
|
+
// Clock sampled only AFTER the blocking share lock, so a delayed lock
|
|
216
|
+
// can never produce a receipt whose TTL was computed against stale time.
|
|
217
|
+
const lockedNow = now();
|
|
218
|
+
if (shareExpiresMs !== null && shareExpiresMs <= lockedNow.getTime()) return false;
|
|
219
|
+
|
|
220
|
+
// Trusted policy source consulted under the same lock. A throwing or
|
|
221
|
+
// non-true result is ineligible and writes nothing.
|
|
222
|
+
let eligible: boolean;
|
|
223
|
+
try {
|
|
224
|
+
eligible = await eligibilitySource.isEligible(tx, {
|
|
225
|
+
namespace,
|
|
226
|
+
ownerProfileId,
|
|
227
|
+
shareId,
|
|
228
|
+
shareVersion,
|
|
229
|
+
contentVersion,
|
|
230
|
+
objectRef,
|
|
231
|
+
operationId,
|
|
232
|
+
now: lockedNow,
|
|
233
|
+
});
|
|
234
|
+
} catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
if (eligible !== true) return false;
|
|
238
|
+
|
|
239
|
+
// Eligibility may itself wait on graph locks; sample again before writing.
|
|
240
|
+
const writeNow = now();
|
|
241
|
+
if (shareExpiresMs !== null && shareExpiresMs <= writeNow.getTime()) return false;
|
|
242
|
+
const nowIso = writeNow.toISOString();
|
|
243
|
+
const expiresAt = new Date(writeNow.getTime() + ttlSeconds * 1000).toISOString();
|
|
244
|
+
|
|
245
|
+
// CREATE, not MERGE: the `receiptHash` uniqueness constraint turns a
|
|
246
|
+
// collision into a typed failure instead of silently rebinding an
|
|
247
|
+
// existing receipt (and its future count) to a different immutable
|
|
248
|
+
// object. On collision we return `false` and count nothing.
|
|
249
|
+
await tx.run(
|
|
250
|
+
`CREATE (v:ShareViewReceipt {
|
|
251
|
+
receiptHash: $receiptHash,
|
|
252
|
+
namespace: $namespace,
|
|
253
|
+
ownerProfileId: $ownerProfileId,
|
|
254
|
+
shareId: $shareId,
|
|
255
|
+
shareVersion: $shareVersion,
|
|
256
|
+
contentVersion: $contentVersion,
|
|
257
|
+
objectRef: $objectRef,
|
|
258
|
+
operationId: $operationId,
|
|
259
|
+
createdAt: $now,
|
|
260
|
+
expiresAt: $expiresAt,
|
|
261
|
+
consumedAt: null
|
|
262
|
+
})`,
|
|
263
|
+
{
|
|
264
|
+
receiptHash,
|
|
265
|
+
namespace,
|
|
266
|
+
ownerProfileId,
|
|
267
|
+
shareId,
|
|
268
|
+
shareVersion,
|
|
269
|
+
contentVersion,
|
|
270
|
+
objectRef,
|
|
271
|
+
operationId,
|
|
272
|
+
now: nowIso,
|
|
273
|
+
expiresAt,
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
return true;
|
|
278
|
+
});
|
|
279
|
+
} catch {
|
|
280
|
+
// A collision/constraint race is not an eligibility signal. Fail closed
|
|
281
|
+
// on counting (the receipt simply never exists) without changing shape.
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Returns the owner bound to a persisted receipt, or `null` for any unknown or
|
|
288
|
+
* padding token. The caller uses this only to re-resolve authoritative policy
|
|
289
|
+
* BEFORE the consume transaction; the transaction re-reads the binding under the
|
|
290
|
+
* locks, so this pre-read is never authority by itself.
|
|
291
|
+
*/
|
|
292
|
+
export const readShareViewReceiptContext = async (
|
|
293
|
+
receipt: string,
|
|
294
|
+
namespace: string
|
|
295
|
+
): Promise<{ ownerProfileId: string; shareId: string } | null> => {
|
|
296
|
+
const receiptHash = hashShareViewReceipt(receipt);
|
|
297
|
+
if (!RECEIPT_HASH_RE.test(receiptHash)) return null;
|
|
298
|
+
|
|
299
|
+
const trustedNamespace = requiredString(namespace);
|
|
300
|
+
if (trustedNamespace === null) return null;
|
|
301
|
+
|
|
302
|
+
await ensureShareLinkConstraints();
|
|
303
|
+
|
|
304
|
+
const props = await withShareLinkRead(runner => readReceiptByHash(runner, receiptHash));
|
|
305
|
+
|
|
306
|
+
if (!props) return null;
|
|
307
|
+
|
|
308
|
+
// A valid token minted in a different namespace is not this deployment's
|
|
309
|
+
// receipt. The persisted namespace must match the trusted configured one.
|
|
310
|
+
const receiptNamespace = requiredString(props.namespace);
|
|
311
|
+
if (receiptNamespace === null || receiptNamespace !== trustedNamespace) return null;
|
|
312
|
+
|
|
313
|
+
const ownerProfileId = requiredString(props.ownerProfileId);
|
|
314
|
+
const shareId = requiredString(props.shareId);
|
|
315
|
+
return ownerProfileId && shareId ? { ownerProfileId, shareId } : null;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
export const readShareViewReceiptOwner = async (
|
|
319
|
+
receipt: string,
|
|
320
|
+
namespace: string
|
|
321
|
+
): Promise<string | null> =>
|
|
322
|
+
(await readShareViewReceiptContext(receipt, namespace))?.ownerProfileId ?? null;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Atomically consumes one eligible receipt and increments the share view
|
|
326
|
+
* counter exactly once, even under concurrent duplicate acknowledgements.
|
|
327
|
+
*
|
|
328
|
+
* The whole guard chain runs under the share lock and then the receipt lock:
|
|
329
|
+
* - share must exist, be `active`/`finalized`, unexpired, and still bound to
|
|
330
|
+
* the same `contentVersion`/owner/namespace as the receipt;
|
|
331
|
+
* - the receipt must exist, be unconsumed and unexpired;
|
|
332
|
+
* - counting requires BOTH the freshly resolved authoritative policy and the
|
|
333
|
+
* committed snapshot policy to permit it.
|
|
334
|
+
*
|
|
335
|
+
* Any guard failure performs no write. Time is sampled after the locks are held.
|
|
336
|
+
*/
|
|
337
|
+
export const consumeShareViewReceipt = async (
|
|
338
|
+
input: ConsumeShareViewReceiptInput
|
|
339
|
+
): Promise<ConsumeShareViewReceiptOutcome> => {
|
|
340
|
+
const receiptHash = hashShareViewReceipt(input.receipt);
|
|
341
|
+
if (!RECEIPT_HASH_RE.test(receiptHash)) return 'not_found';
|
|
342
|
+
|
|
343
|
+
const trustedNamespace = requiredString(input.namespace);
|
|
344
|
+
if (trustedNamespace === null) return 'not_found';
|
|
345
|
+
|
|
346
|
+
await ensureShareLinkConstraints();
|
|
347
|
+
|
|
348
|
+
const now = input.now ?? (() => new Date());
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
return await withShareLinkTransaction(async tx => {
|
|
352
|
+
// Discover the share from durable receipt state only; no caller field
|
|
353
|
+
// other than the opaque token is trusted.
|
|
354
|
+
const discovered = await readReceiptByHash(tx, receiptHash);
|
|
355
|
+
if (!discovered) return 'not_found';
|
|
356
|
+
|
|
357
|
+
const shareId = requiredString(discovered.shareId);
|
|
358
|
+
if (!shareId) return 'not_found';
|
|
359
|
+
|
|
360
|
+
// Share lock FIRST (same lock revoke/update/finalize take).
|
|
361
|
+
const shareProps = await lockShare(tx, shareId);
|
|
362
|
+
if (!shareProps) return 'not_found';
|
|
363
|
+
|
|
364
|
+
// Then the receipt lock, then re-read both under the locks.
|
|
365
|
+
const receiptProps = await lockReceipt(tx, receiptHash);
|
|
366
|
+
if (!receiptProps) return 'not_found';
|
|
367
|
+
|
|
368
|
+
// Production time sampled AFTER both locks are held.
|
|
369
|
+
const lockedNow = now();
|
|
370
|
+
|
|
371
|
+
// Complete, well-formed immutable binding. Every identity/version
|
|
372
|
+
// field is required; malformed durable state fails closed and can
|
|
373
|
+
// never be interpreted as a default.
|
|
374
|
+
const namespace = requiredString(shareProps.namespace);
|
|
375
|
+
const ownerProfileId = requiredString(shareProps.ownerProfileId);
|
|
376
|
+
const status = requiredString(shareProps.status);
|
|
377
|
+
const contentState = requiredString(shareProps.contentState);
|
|
378
|
+
const contentVersion = requiredPositiveInteger(shareProps.contentVersion);
|
|
379
|
+
const shareVersion = requiredPositiveInteger(shareProps.version);
|
|
380
|
+
const shareObjectRef = requiredString(shareProps.activeObjectRef);
|
|
381
|
+
const shareOperationId = requiredString(shareProps.activeObjectOperationId);
|
|
382
|
+
|
|
383
|
+
const receiptNamespace = requiredString(receiptProps.namespace);
|
|
384
|
+
const receiptOwner = requiredString(receiptProps.ownerProfileId);
|
|
385
|
+
const receiptShareId = requiredString(receiptProps.shareId);
|
|
386
|
+
const receiptShareVersion = requiredPositiveInteger(receiptProps.shareVersion);
|
|
387
|
+
const receiptContentVersion = requiredPositiveInteger(receiptProps.contentVersion);
|
|
388
|
+
const receiptObjectRef = requiredString(receiptProps.objectRef);
|
|
389
|
+
const receiptOperationId = requiredString(receiptProps.operationId);
|
|
390
|
+
const receiptExpiresMs = requiredInstantMs(receiptProps.expiresAt);
|
|
391
|
+
|
|
392
|
+
// Optional instants: absent/null is valid "unset"; a malformed
|
|
393
|
+
// value must never be read as unset/unlimited.
|
|
394
|
+
const consumedAtMs = optionalInstantMs(receiptProps.consumedAt);
|
|
395
|
+
const shareExpiresMs = optionalInstantMs(shareProps.expiresAt);
|
|
396
|
+
if (consumedAtMs === undefined || shareExpiresMs === undefined) return 'not_found';
|
|
397
|
+
|
|
398
|
+
// The trusted configured namespace must match BOTH the receipt and the
|
|
399
|
+
// share. A token from another namespace can never be consumed here.
|
|
400
|
+
if (
|
|
401
|
+
namespace === null ||
|
|
402
|
+
namespace !== trustedNamespace ||
|
|
403
|
+
receiptNamespace === null ||
|
|
404
|
+
receiptNamespace !== trustedNamespace
|
|
405
|
+
) {
|
|
406
|
+
return 'binding_mismatch';
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (
|
|
410
|
+
ownerProfileId === null ||
|
|
411
|
+
shareId === '' ||
|
|
412
|
+
shareObjectRef === null ||
|
|
413
|
+
shareOperationId === null ||
|
|
414
|
+
shareVersion === null ||
|
|
415
|
+
contentVersion === null
|
|
416
|
+
) {
|
|
417
|
+
return 'not_found';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// A malformed receipt version is unreadable durable state, not a
|
|
421
|
+
// binding mismatch to compare; fail closed before any write.
|
|
422
|
+
if (receiptShareVersion === null || receiptContentVersion === null) {
|
|
423
|
+
return 'not_found';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (
|
|
427
|
+
receiptOwner !== ownerProfileId ||
|
|
428
|
+
receiptShareId !== shareId ||
|
|
429
|
+
// Full immutable identity, not just the content version.
|
|
430
|
+
receiptShareVersion !== shareVersion ||
|
|
431
|
+
receiptObjectRef !== shareObjectRef ||
|
|
432
|
+
receiptOperationId !== shareOperationId
|
|
433
|
+
) {
|
|
434
|
+
return 'binding_mismatch';
|
|
435
|
+
}
|
|
436
|
+
// A present `consumedAt` is authoritative: a malformed non-null value
|
|
437
|
+
// must never be treated as "unconsumed". Absence/null is unconsumed.
|
|
438
|
+
if (consumedAtMs !== null) return 'already_consumed';
|
|
439
|
+
// Required, parseable receipt expiry. Missing/garbage fails closed.
|
|
440
|
+
if (receiptExpiresMs === null || receiptExpiresMs <= lockedNow.getTime()) {
|
|
441
|
+
return 'expired';
|
|
442
|
+
}
|
|
443
|
+
if (status !== 'active' || contentState !== 'finalized') return 'not_active';
|
|
444
|
+
if (shareExpiresMs !== null && shareExpiresMs <= lockedNow.getTime()) {
|
|
445
|
+
return 'not_active';
|
|
446
|
+
}
|
|
447
|
+
if (receiptContentVersion !== contentVersion) return 'version_mismatch';
|
|
448
|
+
|
|
449
|
+
const committedEligible =
|
|
450
|
+
shareProps.minorPolicyViewCountingEnabled === true &&
|
|
451
|
+
shareProps.minorPolicyResolved === true &&
|
|
452
|
+
shareProps.minorPolicyIsMinor === false;
|
|
453
|
+
|
|
454
|
+
// Counting requires BOTH the committed snapshot policy and the
|
|
455
|
+
// transaction-compatible trusted policy source to permit it. There
|
|
456
|
+
// is no pre-lock boolean authority; an absent source is ineligible.
|
|
457
|
+
if (!committedEligible) return 'ineligible';
|
|
458
|
+
|
|
459
|
+
const eligibilitySource = input.eligibilitySource;
|
|
460
|
+
if (!eligibilitySource) return 'ineligible';
|
|
461
|
+
|
|
462
|
+
let eligible: boolean;
|
|
463
|
+
try {
|
|
464
|
+
eligible = await eligibilitySource.isEligible(tx, {
|
|
465
|
+
namespace,
|
|
466
|
+
ownerProfileId,
|
|
467
|
+
shareId,
|
|
468
|
+
shareVersion,
|
|
469
|
+
contentVersion,
|
|
470
|
+
objectRef: shareObjectRef,
|
|
471
|
+
operationId: shareOperationId,
|
|
472
|
+
now: lockedNow,
|
|
473
|
+
});
|
|
474
|
+
} catch {
|
|
475
|
+
return 'ineligible';
|
|
476
|
+
}
|
|
477
|
+
if (eligible !== true) return 'ineligible';
|
|
478
|
+
|
|
479
|
+
const writeNow = now();
|
|
480
|
+
if (receiptExpiresMs <= writeNow.getTime()) return 'expired';
|
|
481
|
+
if (shareExpiresMs !== null && shareExpiresMs <= writeNow.getTime())
|
|
482
|
+
return 'not_active';
|
|
483
|
+
const nowIso = writeNow.toISOString();
|
|
484
|
+
|
|
485
|
+
const write = await tx.run(
|
|
486
|
+
`MATCH (v:ShareViewReceipt {receiptHash: $receiptHash})
|
|
487
|
+
MATCH (s:ShareLink {id: $shareId})
|
|
488
|
+
SET v.consumedAt = $now,
|
|
489
|
+
s.viewCount = coalesce(s.viewCount, 0) + 1,
|
|
490
|
+
s.lastViewedAt = $now`,
|
|
491
|
+
{ receiptHash, shareId, now: nowIso }
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
if (!write) return 'not_found';
|
|
495
|
+
return 'consumed';
|
|
496
|
+
});
|
|
497
|
+
} catch {
|
|
498
|
+
return 'not_found';
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Explicit one-shot pruning of expired/consumed receipt nodes.
|
|
504
|
+
*
|
|
505
|
+
* `MATCH ... WITH v LIMIT n DETACH DELETE v RETURN count(*)` reclaims at most
|
|
506
|
+
* `limit` nodes per call. It never touches `ShareLink`,
|
|
507
|
+
* `ShareLinkOperation`, reservations, cleanup jobs or share content — those
|
|
508
|
+
* have their own retention rules. The disabled maintenance pass invokes this
|
|
509
|
+
* with a bounded limit and an explicit no-inline-retry graph transaction.
|
|
510
|
+
*/
|
|
511
|
+
export const pruneShareViewReceipts = async (
|
|
512
|
+
input: PruneShareViewReceiptsInput
|
|
513
|
+
): Promise<number> => {
|
|
514
|
+
const namespace = requiredString(input.namespace);
|
|
515
|
+
if (namespace === null) {
|
|
516
|
+
failShareLink('INVALID_INPUT', 'a trusted namespace is required for receipt pruning');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (input.limit !== undefined && (!Number.isFinite(input.limit) || input.limit < 1)) {
|
|
520
|
+
// Never let NaN/Infinity reach Cypher as an unbounded or malformed limit.
|
|
521
|
+
failShareLink('INVALID_INPUT', 'receipt prune limit must be a positive finite number');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const requested = input.limit ?? DEFAULT_PRUNE_LIMIT;
|
|
525
|
+
const limit = Math.max(1, Math.min(MAX_PRUNE_LIMIT, Math.floor(requested)));
|
|
526
|
+
const nowIso = (input.now ?? new Date()).toISOString();
|
|
527
|
+
|
|
528
|
+
await ensureShareLinkConstraints();
|
|
529
|
+
|
|
530
|
+
return withShareLinkRead(async runner => {
|
|
531
|
+
const result = await runner.run(
|
|
532
|
+
`MATCH (v:ShareViewReceipt {namespace: $namespace})
|
|
533
|
+
WHERE (v.expiresAt IS NOT NULL AND v.expiresAt <= $now)
|
|
534
|
+
OR v.consumedAt IS NOT NULL
|
|
535
|
+
WITH v LIMIT toInteger($limit)
|
|
536
|
+
DETACH DELETE v
|
|
537
|
+
RETURN count(*) AS deleted`,
|
|
538
|
+
{ namespace, now: nowIso, limit }
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const raw = result.records[0]?.get('deleted') as
|
|
542
|
+
number | { toNumber?: () => number } | undefined;
|
|
543
|
+
if (typeof raw === 'number') return raw;
|
|
544
|
+
if (raw && typeof raw.toNumber === 'function') return raw.toNumber();
|
|
545
|
+
|
|
546
|
+
return 0;
|
|
547
|
+
}, input.transaction);
|
|
548
|
+
};
|