@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,419 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import type { ShareLinkOperationKind } from '@helpers/share-link-lifecycle';
|
|
4
|
+
|
|
5
|
+
import type { ShareContentState, ShareLinkRecord, ShareLinkStatus } from '../../models/ShareLink';
|
|
6
|
+
import type { Neo4jQueryResult, ShareLinkTransaction } from './transaction';
|
|
7
|
+
import type {
|
|
8
|
+
ShareContentCleanupJobRecord,
|
|
9
|
+
ShareLinkOperationRecord,
|
|
10
|
+
ShareLinkOperationStatus,
|
|
11
|
+
ShareLinkReservationRecord,
|
|
12
|
+
} from './types';
|
|
13
|
+
|
|
14
|
+
type Neo4jNodeLike = {
|
|
15
|
+
properties?: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Converts a neo4j-driver Integer (or nested array) to plain JS values. */
|
|
19
|
+
export const normalizeNeo4jValue = (value: unknown): unknown => {
|
|
20
|
+
if (value === undefined || value === null) return null;
|
|
21
|
+
|
|
22
|
+
if (typeof value === 'object') {
|
|
23
|
+
const maybeInteger = value as { toNumber?: () => number };
|
|
24
|
+
|
|
25
|
+
if (typeof maybeInteger.toNumber === 'function') {
|
|
26
|
+
return maybeInteger.toNumber();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (Array.isArray(value)) return value.map(normalizeNeo4jValue);
|
|
31
|
+
|
|
32
|
+
return value;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const normalizeProperties = (props: Record<string, unknown>): Record<string, unknown> =>
|
|
36
|
+
Object.fromEntries(
|
|
37
|
+
Object.entries(props).map(([key, value]) => [key, normalizeNeo4jValue(value)])
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** Reads `rowIndex`'s node under `key` and returns normalized properties, or null. */
|
|
41
|
+
export const readNodeProperties = (
|
|
42
|
+
result: Neo4jQueryResult,
|
|
43
|
+
key: string,
|
|
44
|
+
rowIndex = 0
|
|
45
|
+
): Record<string, unknown> | null => {
|
|
46
|
+
const value = result.records[rowIndex]?.get(key);
|
|
47
|
+
|
|
48
|
+
if (!value || typeof value !== 'object') return null;
|
|
49
|
+
|
|
50
|
+
const props = (value as Neo4jNodeLike).properties;
|
|
51
|
+
|
|
52
|
+
return props ? normalizeProperties(props) : null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const SHARE_LINK_STATUSES: readonly ShareLinkStatus[] = ['pending', 'active', 'stopped'];
|
|
56
|
+
const SHARE_CONTENT_STATES: readonly ShareContentState[] = [
|
|
57
|
+
'staging',
|
|
58
|
+
'finalized',
|
|
59
|
+
'content_missing',
|
|
60
|
+
];
|
|
61
|
+
const OPERATION_KINDS: readonly ShareLinkOperationKind[] = ['create', 'update', 'revoke'];
|
|
62
|
+
const OPERATION_STATUSES: readonly ShareLinkOperationStatus[] = [
|
|
63
|
+
'in_progress',
|
|
64
|
+
'committed',
|
|
65
|
+
'abandoned',
|
|
66
|
+
];
|
|
67
|
+
const CLEANUP_REASONS = ['superseded', 'abandoned', 'stopped'] as const;
|
|
68
|
+
const CLEANUP_STATUSES = ['queued', 'claimed', 'completed'] as const;
|
|
69
|
+
|
|
70
|
+
const isOneOf = <T extends string>(value: unknown, allowed: readonly T[]): value is T =>
|
|
71
|
+
typeof value === 'string' && (allowed as readonly string[]).includes(value);
|
|
72
|
+
|
|
73
|
+
const asString = (value: unknown, fallback = ''): string =>
|
|
74
|
+
typeof value === 'string' ? value : fallback;
|
|
75
|
+
|
|
76
|
+
const asNullableString = (value: unknown): string | null =>
|
|
77
|
+
typeof value === 'string' ? value : null;
|
|
78
|
+
|
|
79
|
+
const asNumber = (value: unknown, fallback: number): number =>
|
|
80
|
+
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
81
|
+
|
|
82
|
+
const asNullableNumber = (value: unknown): number | null =>
|
|
83
|
+
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
84
|
+
|
|
85
|
+
const asBoolean = (value: unknown, fallback: boolean): boolean =>
|
|
86
|
+
typeof value === 'boolean' ? value : fallback;
|
|
87
|
+
|
|
88
|
+
const asNullableBoolean = (value: unknown): boolean | null =>
|
|
89
|
+
typeof value === 'boolean' ? value : null;
|
|
90
|
+
|
|
91
|
+
export const toShareLinkRecord = (props: Record<string, unknown>): ShareLinkRecord => ({
|
|
92
|
+
id: asString(props.id),
|
|
93
|
+
namespace: asString(props.namespace),
|
|
94
|
+
ownerProfileId: asString(props.ownerProfileId),
|
|
95
|
+
version: asNumber(props.version, 1),
|
|
96
|
+
contentVersion: asNumber(props.contentVersion, 1),
|
|
97
|
+
generation: asNumber(props.generation, 1),
|
|
98
|
+
status: isOneOf(props.status, SHARE_LINK_STATUSES) ? props.status : 'pending',
|
|
99
|
+
contentState: isOneOf(props.contentState, SHARE_CONTENT_STATES)
|
|
100
|
+
? props.contentState
|
|
101
|
+
: 'staging',
|
|
102
|
+
activeObjectRef: asNullableString(props.activeObjectRef),
|
|
103
|
+
activeObjectOperationId: asNullableString(props.activeObjectOperationId),
|
|
104
|
+
activeContentHash: asNullableString(props.activeContentHash),
|
|
105
|
+
activeContentBytes: asNullableNumber(props.activeContentBytes),
|
|
106
|
+
activeRecoveryHash: asNullableString(props.activeRecoveryHash),
|
|
107
|
+
lastOperationId: asNullableString(props.lastOperationId),
|
|
108
|
+
createdByClientRequestId: asNullableString(props.createdByClientRequestId),
|
|
109
|
+
title: asString(props.title),
|
|
110
|
+
note: asNullableString(props.note),
|
|
111
|
+
selectedCount: asNumber(props.selectedCount, 1),
|
|
112
|
+
expiresAt: asNullableString(props.expiresAt),
|
|
113
|
+
stoppedAt: asNullableString(props.stoppedAt),
|
|
114
|
+
viewCount: asNumber(props.viewCount, 0),
|
|
115
|
+
lastViewedAt: asNullableString(props.lastViewedAt),
|
|
116
|
+
passcodeHash: asNullableString(props.passcodeHash),
|
|
117
|
+
notifyOnView: asBoolean(props.notifyOnView, false),
|
|
118
|
+
minorPolicyIsMinor: asNullableBoolean(props.minorPolicyIsMinor),
|
|
119
|
+
minorPolicyResolved: asBoolean(props.minorPolicyResolved, false),
|
|
120
|
+
minorPolicyDefaultExpiryDays: asNumber(props.minorPolicyDefaultExpiryDays, 30),
|
|
121
|
+
minorPolicyViewCountingEnabled: asBoolean(props.minorPolicyViewCountingEnabled, false),
|
|
122
|
+
createdAt: asString(props.createdAt),
|
|
123
|
+
updatedAt: asString(props.updatedAt),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export const toShareLinkReservationRecord = (
|
|
127
|
+
props: Record<string, unknown>
|
|
128
|
+
): ShareLinkReservationRecord => ({
|
|
129
|
+
shareId: asString(props.shareId),
|
|
130
|
+
namespace: asString(props.namespace),
|
|
131
|
+
ownerProfileId: asString(props.ownerProfileId),
|
|
132
|
+
opKind: isOneOf(props.opKind, OPERATION_KINDS) ? props.opKind : 'create',
|
|
133
|
+
clientRequestId: asString(props.clientRequestId),
|
|
134
|
+
requestHash: asString(props.requestHash),
|
|
135
|
+
operationId: asString(props.operationId),
|
|
136
|
+
objectRef: asNullableString(props.objectRef),
|
|
137
|
+
contentVersion: asNullableNumber(props.contentVersion),
|
|
138
|
+
baseVersion: asNumber(props.baseVersion, 1),
|
|
139
|
+
baseContentVersion: asNumber(props.baseContentVersion, 1),
|
|
140
|
+
contentHash: asNullableString(props.contentHash),
|
|
141
|
+
contentBytes: asNullableNumber(props.contentBytes),
|
|
142
|
+
recoveryHash: asNullableString(props.recoveryHash),
|
|
143
|
+
recoveryBytes: asNullableNumber(props.recoveryBytes),
|
|
144
|
+
title: asString(props.title),
|
|
145
|
+
note: asNullableString(props.note),
|
|
146
|
+
expiresAt: asNullableString(props.expiresAt),
|
|
147
|
+
selectedCount: asNumber(props.selectedCount, 1),
|
|
148
|
+
passcodeHash: asNullableString(props.passcodeHash),
|
|
149
|
+
notifyOnView: asBoolean(props.notifyOnView, false),
|
|
150
|
+
policy: {
|
|
151
|
+
isMinor: asNullableBoolean(props.policyIsMinor),
|
|
152
|
+
policyResolved: asBoolean(props.policyResolved, false),
|
|
153
|
+
defaultExpiryDays: asNumber(props.policyDefaultExpiryDays, 30) === 365 ? 365 : 30,
|
|
154
|
+
viewCountingEnabled: asBoolean(props.policyViewCountingEnabled, false),
|
|
155
|
+
},
|
|
156
|
+
generation: asNumber(props.generation, 1),
|
|
157
|
+
leaseOwner: asString(props.leaseOwner),
|
|
158
|
+
leaseExpiresAt: asString(props.leaseExpiresAt),
|
|
159
|
+
createdAt: asString(props.createdAt),
|
|
160
|
+
updatedAt: asString(props.updatedAt),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export const toShareLinkOperationRecord = (
|
|
164
|
+
props: Record<string, unknown>
|
|
165
|
+
): ShareLinkOperationRecord => ({
|
|
166
|
+
namespace: asString(props.namespace),
|
|
167
|
+
ownerProfileId: asString(props.ownerProfileId),
|
|
168
|
+
opKind: isOneOf(props.opKind, OPERATION_KINDS) ? props.opKind : 'create',
|
|
169
|
+
clientRequestId: asString(props.clientRequestId),
|
|
170
|
+
operationId: asString(props.operationId),
|
|
171
|
+
shareId: asString(props.shareId),
|
|
172
|
+
requestHash: asString(props.requestHash),
|
|
173
|
+
status: isOneOf(props.status, OPERATION_STATUSES) ? props.status : 'in_progress',
|
|
174
|
+
resultJson: asNullableString(props.resultJson),
|
|
175
|
+
resultVersion: asNullableNumber(props.resultVersion),
|
|
176
|
+
createdAt: asString(props.createdAt),
|
|
177
|
+
updatedAt: asString(props.updatedAt),
|
|
178
|
+
pruneAfter: asString(props.pruneAfter),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
export const toShareContentCleanupJobRecord = (
|
|
182
|
+
props: Record<string, unknown>
|
|
183
|
+
): ShareContentCleanupJobRecord => ({
|
|
184
|
+
objectRef: asString(props.objectRef),
|
|
185
|
+
operationId: asString(props.operationId),
|
|
186
|
+
namespace: asString(props.namespace),
|
|
187
|
+
ownerProfileId: asString(props.ownerProfileId),
|
|
188
|
+
shareId: asString(props.shareId),
|
|
189
|
+
contentVersion: asNumber(props.contentVersion, 0),
|
|
190
|
+
reason: isOneOf(props.reason, CLEANUP_REASONS) ? props.reason : 'superseded',
|
|
191
|
+
status: isOneOf(props.status, CLEANUP_STATUSES) ? props.status : 'queued',
|
|
192
|
+
attempts: asNumber(props.attempts, 0),
|
|
193
|
+
nextAttemptAt: asString(props.nextAttemptAt),
|
|
194
|
+
claimToken: asNullableString(props.claimToken),
|
|
195
|
+
claimedBy: asNullableString(props.claimedBy),
|
|
196
|
+
claimExpiresAt: asNullableString(props.claimExpiresAt),
|
|
197
|
+
lastError: asNullableString(props.lastError),
|
|
198
|
+
createdAt: asString(props.createdAt),
|
|
199
|
+
updatedAt: asString(props.updatedAt),
|
|
200
|
+
completedAt: asNullableString(props.completedAt),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/** 32-byte opaque immutable LearnCloud object id (43-char base64url). */
|
|
204
|
+
export const generateObjectRef = (): string => randomBytes(32).toString('base64url');
|
|
205
|
+
|
|
206
|
+
/** Opaque, unique operation id used to bind a reservation to a worker's finalize. */
|
|
207
|
+
export const generateOperationId = (): string => randomUUID();
|
|
208
|
+
|
|
209
|
+
/** Opaque cleanup claim fence; a stale claimant can no longer complete the job. */
|
|
210
|
+
export const generateClaimToken = (): string => randomUUID();
|
|
211
|
+
|
|
212
|
+
export type ShareLinkOperationKey = {
|
|
213
|
+
namespace: string;
|
|
214
|
+
ownerProfileId: string;
|
|
215
|
+
opKind: ShareLinkOperationKind;
|
|
216
|
+
clientRequestId: string;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Acquires the share write lock.
|
|
221
|
+
*
|
|
222
|
+
* The property write is deliberate: once it commits, every other lifecycle
|
|
223
|
+
* transaction that targets this share blocks until this transaction ends, so a
|
|
224
|
+
* caller always reads status/version/reservation state under the lock.
|
|
225
|
+
*/
|
|
226
|
+
export const lockShare = async (
|
|
227
|
+
tx: ShareLinkTransaction,
|
|
228
|
+
shareId: string
|
|
229
|
+
): Promise<Record<string, unknown> | null> => {
|
|
230
|
+
const result = await tx.run(
|
|
231
|
+
`MATCH (s:ShareLink {id: $shareId})
|
|
232
|
+
SET s.lockTick = coalesce(s.lockTick, 0) + 1
|
|
233
|
+
RETURN s`,
|
|
234
|
+
{ shareId }
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
return readNodeProperties(result, 's');
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export const readShareById = async (
|
|
241
|
+
tx: ShareLinkTransaction,
|
|
242
|
+
shareId: string
|
|
243
|
+
): Promise<Record<string, unknown> | null> => {
|
|
244
|
+
const result = await tx.run('MATCH (s:ShareLink {id: $shareId}) RETURN s LIMIT 1', {
|
|
245
|
+
shareId,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return readNodeProperties(result, 's');
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
export const readReservation = async (
|
|
252
|
+
tx: ShareLinkTransaction,
|
|
253
|
+
shareId: string
|
|
254
|
+
): Promise<Record<string, unknown> | null> => {
|
|
255
|
+
const result = await tx.run(
|
|
256
|
+
'MATCH (r:ShareLinkReservation {shareId: $shareId}) RETURN r LIMIT 1',
|
|
257
|
+
{ shareId }
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
return readNodeProperties(result, 'r');
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export const readOperation = async (
|
|
264
|
+
tx: ShareLinkTransaction,
|
|
265
|
+
key: ShareLinkOperationKey
|
|
266
|
+
): Promise<Record<string, unknown> | null> => {
|
|
267
|
+
const result = await tx.run(
|
|
268
|
+
`MATCH (o:ShareLinkOperation {
|
|
269
|
+
namespace: $namespace,
|
|
270
|
+
ownerProfileId: $ownerProfileId,
|
|
271
|
+
opKind: $opKind,
|
|
272
|
+
clientRequestId: $clientRequestId
|
|
273
|
+
})
|
|
274
|
+
RETURN o LIMIT 1`,
|
|
275
|
+
key
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
return readNodeProperties(result, 'o');
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
export const readOperationByOperationId = async (
|
|
282
|
+
tx: ShareLinkTransaction,
|
|
283
|
+
operationId: string
|
|
284
|
+
): Promise<Record<string, unknown> | null> => {
|
|
285
|
+
const result = await tx.run(
|
|
286
|
+
'MATCH (o:ShareLinkOperation {operationId: $operationId}) RETURN o LIMIT 1',
|
|
287
|
+
{ operationId }
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
return readNodeProperties(result, 'o');
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
export type WriteOperationInput = ShareLinkOperationKey & {
|
|
294
|
+
operationId: string;
|
|
295
|
+
shareId: string;
|
|
296
|
+
requestHash: string;
|
|
297
|
+
now: string;
|
|
298
|
+
pruneAfter: string;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Creates or re-arms the idempotency record for an in-flight operation.
|
|
303
|
+
*
|
|
304
|
+
* The caller has already validated the request hash for an existing record, so
|
|
305
|
+
* overwriting the stored hash here is only a re-drive of an abandoned record.
|
|
306
|
+
*/
|
|
307
|
+
export const writeOperationInProgress = async (
|
|
308
|
+
tx: ShareLinkTransaction,
|
|
309
|
+
input: WriteOperationInput
|
|
310
|
+
): Promise<void> => {
|
|
311
|
+
await tx.run(
|
|
312
|
+
`MERGE (o:ShareLinkOperation {
|
|
313
|
+
namespace: $namespace,
|
|
314
|
+
ownerProfileId: $ownerProfileId,
|
|
315
|
+
opKind: $opKind,
|
|
316
|
+
clientRequestId: $clientRequestId
|
|
317
|
+
})
|
|
318
|
+
ON CREATE SET o.createdAt = $now, o.pruneAfter = $pruneAfter
|
|
319
|
+
SET o.operationId = $operationId,
|
|
320
|
+
o.shareId = $shareId,
|
|
321
|
+
o.requestHash = $requestHash,
|
|
322
|
+
o.status = 'in_progress',
|
|
323
|
+
o.resultJson = null,
|
|
324
|
+
o.resultVersion = null,
|
|
325
|
+
o.updatedAt = $now`,
|
|
326
|
+
input
|
|
327
|
+
);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
export const markOperationCommitted = async (
|
|
331
|
+
tx: ShareLinkTransaction,
|
|
332
|
+
input: ShareLinkOperationKey & {
|
|
333
|
+
operationId: string;
|
|
334
|
+
resultJson: string;
|
|
335
|
+
resultVersion: number;
|
|
336
|
+
now: string;
|
|
337
|
+
pruneAfter: string;
|
|
338
|
+
}
|
|
339
|
+
): Promise<void> => {
|
|
340
|
+
await tx.run(
|
|
341
|
+
`MATCH (o:ShareLinkOperation {
|
|
342
|
+
namespace: $namespace,
|
|
343
|
+
ownerProfileId: $ownerProfileId,
|
|
344
|
+
opKind: $opKind,
|
|
345
|
+
clientRequestId: $clientRequestId
|
|
346
|
+
})
|
|
347
|
+
SET o.operationId = $operationId,
|
|
348
|
+
o.status = 'committed',
|
|
349
|
+
o.resultJson = $resultJson,
|
|
350
|
+
o.resultVersion = $resultVersion,
|
|
351
|
+
o.updatedAt = $now,
|
|
352
|
+
o.pruneAfter = $pruneAfter`,
|
|
353
|
+
input
|
|
354
|
+
);
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
export const markOperationAbandoned = async (
|
|
358
|
+
tx: ShareLinkTransaction,
|
|
359
|
+
key: ShareLinkOperationKey & { now: string }
|
|
360
|
+
): Promise<void> => {
|
|
361
|
+
await tx.run(
|
|
362
|
+
`MATCH (o:ShareLinkOperation {
|
|
363
|
+
namespace: $namespace,
|
|
364
|
+
ownerProfileId: $ownerProfileId,
|
|
365
|
+
opKind: $opKind,
|
|
366
|
+
clientRequestId: $clientRequestId
|
|
367
|
+
})
|
|
368
|
+
SET o.status = 'abandoned', o.updatedAt = $now`,
|
|
369
|
+
key
|
|
370
|
+
);
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
export const deleteReservation = async (
|
|
374
|
+
tx: ShareLinkTransaction,
|
|
375
|
+
shareId: string
|
|
376
|
+
): Promise<void> => {
|
|
377
|
+
await tx.run('MATCH (r:ShareLinkReservation {shareId: $shareId}) DELETE r', { shareId });
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
export type EnqueueCleanupJobInput = {
|
|
381
|
+
objectRef: string;
|
|
382
|
+
operationId: string;
|
|
383
|
+
namespace: string;
|
|
384
|
+
ownerProfileId: string;
|
|
385
|
+
shareId: string;
|
|
386
|
+
contentVersion: number;
|
|
387
|
+
reason: 'superseded' | 'abandoned' | 'stopped';
|
|
388
|
+
now: string;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Durable, exact-object cleanup enqueue. `MERGE` on the unique `objectRef` makes
|
|
393
|
+
* a retry idempotent; the repository never deletes LearnCloud content itself.
|
|
394
|
+
*/
|
|
395
|
+
export const enqueueCleanupJob = async (
|
|
396
|
+
tx: ShareLinkTransaction,
|
|
397
|
+
input: EnqueueCleanupJobInput
|
|
398
|
+
): Promise<void> => {
|
|
399
|
+
await tx.run(
|
|
400
|
+
`MERGE (c:ShareContentCleanupJob {objectRef: $objectRef})
|
|
401
|
+
ON CREATE SET c.namespace = $namespace,
|
|
402
|
+
c.ownerProfileId = $ownerProfileId,
|
|
403
|
+
c.shareId = $shareId,
|
|
404
|
+
c.contentVersion = $contentVersion,
|
|
405
|
+
c.operationId = $operationId,
|
|
406
|
+
c.reason = $reason,
|
|
407
|
+
c.status = 'queued',
|
|
408
|
+
c.attempts = 0,
|
|
409
|
+
c.nextAttemptAt = $now,
|
|
410
|
+
c.claimToken = null,
|
|
411
|
+
c.claimedBy = null,
|
|
412
|
+
c.claimExpiresAt = null,
|
|
413
|
+
c.lastError = null,
|
|
414
|
+
c.completedAt = null,
|
|
415
|
+
c.createdAt = $now,
|
|
416
|
+
c.updatedAt = $now`,
|
|
417
|
+
input
|
|
418
|
+
);
|
|
419
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './types';
|
|
2
|
+
export * from './errors';
|
|
3
|
+
export * from './reserve';
|
|
4
|
+
export * from './finalize';
|
|
5
|
+
export * from './abandon';
|
|
6
|
+
export * from './revoke';
|
|
7
|
+
export * from './read';
|
|
8
|
+
export * from './list';
|
|
9
|
+
export * from './cleanup';
|
|
10
|
+
export * from './recovery';
|
|
11
|
+
export * from './receipt';
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { SHARE_LINK_ID_BYTES, isCanonicalBase64Url } from '@learncard/types';
|
|
2
|
+
|
|
3
|
+
import { neogma } from '@instance';
|
|
4
|
+
|
|
5
|
+
import { readNodeProperties, toShareLinkRecord } from './helpers';
|
|
6
|
+
import type { ListShareLinksInput, ListShareLinksResult, ShareLinkListCursor } from './types';
|
|
7
|
+
import type { ShareLinkRecord } from '../../models/ShareLink';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* LC-2187 bounded owner-list repository.
|
|
11
|
+
*
|
|
12
|
+
* Contract:
|
|
13
|
+
* - Every query is scoped by the trusted `namespace` AND `ownerProfileId`; the
|
|
14
|
+
* caller can never widen either through the cursor or any other field.
|
|
15
|
+
* - Keyset pagination over the immutable `(createdAt, id)` key. `createdAt` is
|
|
16
|
+
* never mutated for a share and `id` is globally unique, so the total order is
|
|
17
|
+
* stable and equal-timestamp records neither duplicate nor skip across pages.
|
|
18
|
+
* There is no OFFSET and no unbounded fetch/sort in memory.
|
|
19
|
+
* - The cursor is a canonical, versioned encoding of exactly the two ordering
|
|
20
|
+
* fields. It is an ORDERING HINT only: it never carries namespace, owner,
|
|
21
|
+
* status or a query clause, and the current authenticated scope is always
|
|
22
|
+
* applied independently. A cursor minted for another owner therefore cannot
|
|
23
|
+
* reveal that owner's records; it can only advance the caller's own page.
|
|
24
|
+
* - `limit + 1` rows are fetched so `hasMore`/`nextCursor` are decided without a
|
|
25
|
+
* count query. Limits are defensively bounded again here.
|
|
26
|
+
* - Pending (`staging`), active, retained-expired and stopped records are all
|
|
27
|
+
* listed by owner. Listing is read-only: it never triggers cleanup, never
|
|
28
|
+
* contacts public content, and never mutates a share.
|
|
29
|
+
*
|
|
30
|
+
* Concurrent insert/delete is deliberately not a snapshot: a page reflects the
|
|
31
|
+
* graph at read time, so a new share may appear and a deleted share may vanish
|
|
32
|
+
* between pages. Only the immutable ordering key is guaranteed stable.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const CURSOR_VERSION = 1;
|
|
36
|
+
const CURSOR_PREFIX = `v${CURSOR_VERSION}.`;
|
|
37
|
+
const MAX_CURSOR_LENGTH = 512;
|
|
38
|
+
const MAX_LIST_LIMIT = 50;
|
|
39
|
+
const DEFAULT_LIST_LIMIT = 25;
|
|
40
|
+
|
|
41
|
+
const isCanonicalIsoInstant = (value: string): boolean => {
|
|
42
|
+
const parsed = Date.parse(value);
|
|
43
|
+
|
|
44
|
+
if (!Number.isFinite(parsed)) return false;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return new Date(parsed).toISOString() === value;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Canonical, versioned cursor over exactly `(createdAt, id)`.
|
|
55
|
+
*
|
|
56
|
+
* Encoding: `v1.` + base64url(UTF-8 JSON array `[createdAt, id]`). The version
|
|
57
|
+
* prefix lets a future encoding be rejected rather than mis-parsed, and the
|
|
58
|
+
* ordered-fields-only shape guarantees no database node, extra clause or private
|
|
59
|
+
* field can be smuggled through the cursor.
|
|
60
|
+
*/
|
|
61
|
+
export const encodeShareLinkListCursor = (cursor: ShareLinkListCursor): string =>
|
|
62
|
+
`${CURSOR_PREFIX}${Buffer.from(JSON.stringify([cursor.createdAt, cursor.id]), 'utf8').toString(
|
|
63
|
+
'base64url'
|
|
64
|
+
)}`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Strictly decodes a caller cursor. Returns `null` for anything malformed,
|
|
68
|
+
* oversized, non-canonical or impossible so the route can answer a fixed safe
|
|
69
|
+
* BAD_REQUEST without touching the graph.
|
|
70
|
+
*/
|
|
71
|
+
export const decodeShareLinkListCursor = (raw: unknown): ShareLinkListCursor | null => {
|
|
72
|
+
if (typeof raw !== 'string') return null;
|
|
73
|
+
if (raw.length === 0 || raw.length > MAX_CURSOR_LENGTH) return null;
|
|
74
|
+
if (!raw.startsWith(CURSOR_PREFIX)) return null;
|
|
75
|
+
|
|
76
|
+
const encoded = raw.slice(CURSOR_PREFIX.length);
|
|
77
|
+
|
|
78
|
+
if (encoded.length === 0) return null;
|
|
79
|
+
|
|
80
|
+
let json: string;
|
|
81
|
+
try {
|
|
82
|
+
const bytes = Buffer.from(encoded, 'base64url');
|
|
83
|
+
|
|
84
|
+
// Reject non-canonical base64url (padding/unused-bit variants) so one
|
|
85
|
+
// logical cursor has exactly one spelling.
|
|
86
|
+
if (bytes.toString('base64url') !== encoded) return null;
|
|
87
|
+
|
|
88
|
+
json = bytes.toString('utf8');
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let parsed: unknown;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(json);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!Array.isArray(parsed) || parsed.length !== 2) return null;
|
|
101
|
+
|
|
102
|
+
const [createdAt, id] = parsed;
|
|
103
|
+
|
|
104
|
+
if (typeof createdAt !== 'string' || !isCanonicalIsoInstant(createdAt)) return null;
|
|
105
|
+
if (!isCanonicalBase64Url(id, SHARE_LINK_ID_BYTES)) return null;
|
|
106
|
+
|
|
107
|
+
return { createdAt, id };
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const boundLimit = (limit: number): number => {
|
|
111
|
+
if (!Number.isFinite(limit)) return DEFAULT_LIST_LIMIT;
|
|
112
|
+
|
|
113
|
+
return Math.min(Math.max(Math.trunc(limit), 1), MAX_LIST_LIMIT);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Reads one bounded page of the caller's own shares, newest first. A decoded
|
|
118
|
+
* cursor is the only accepted cursor form; the route decodes/validates the raw
|
|
119
|
+
* caller string before this point.
|
|
120
|
+
*/
|
|
121
|
+
export const listShareLinks = async (input: ListShareLinksInput): Promise<ListShareLinksResult> => {
|
|
122
|
+
const limit = boundLimit(input.limit);
|
|
123
|
+
const cursor = input.cursor ?? null;
|
|
124
|
+
|
|
125
|
+
const result = await neogma.queryRunner.run(
|
|
126
|
+
`MATCH (s:ShareLink {namespace: $namespace, ownerProfileId: $ownerProfileId})
|
|
127
|
+
WHERE $cursorCreatedAt IS NULL
|
|
128
|
+
OR s.createdAt < $cursorCreatedAt
|
|
129
|
+
OR (s.createdAt = $cursorCreatedAt AND s.id < $cursorId)
|
|
130
|
+
RETURN s
|
|
131
|
+
ORDER BY s.createdAt DESC, s.id DESC
|
|
132
|
+
LIMIT toInteger($limit)`,
|
|
133
|
+
{
|
|
134
|
+
namespace: input.namespace,
|
|
135
|
+
ownerProfileId: input.ownerProfileId,
|
|
136
|
+
cursorCreatedAt: cursor?.createdAt ?? null,
|
|
137
|
+
cursorId: cursor?.id ?? null,
|
|
138
|
+
limit: limit + 1,
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const fetched: ShareLinkRecord[] = [];
|
|
143
|
+
|
|
144
|
+
for (let index = 0; index < result.records.length; index += 1) {
|
|
145
|
+
const props = readNodeProperties(result, 's', index);
|
|
146
|
+
|
|
147
|
+
if (props) fetched.push(toShareLinkRecord(props));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const hasMore = fetched.length > limit;
|
|
151
|
+
const records = hasMore ? fetched.slice(0, limit) : fetched;
|
|
152
|
+
const last = records[records.length - 1];
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
records,
|
|
156
|
+
hasMore,
|
|
157
|
+
nextCursor:
|
|
158
|
+
hasMore && last
|
|
159
|
+
? encodeShareLinkListCursor({ createdAt: last.createdAt, id: last.id })
|
|
160
|
+
: null,
|
|
161
|
+
};
|
|
162
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { neogma } from '@instance';
|
|
2
|
+
|
|
3
|
+
import { readNodeProperties, toShareLinkRecord } from './helpers';
|
|
4
|
+
import type {
|
|
5
|
+
CurrentShareContentResult,
|
|
6
|
+
GetCurrentShareContentInput,
|
|
7
|
+
GetShareLinkInput,
|
|
8
|
+
} from './types';
|
|
9
|
+
import type { ShareLinkRecord } from '../../models/ShareLink';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reads one committed share snapshot by opaque id, optionally constrained to the
|
|
13
|
+
* caller's namespace and owner. Cross-namespace/cross-owner reads return null so a
|
|
14
|
+
* route can answer without an existence oracle.
|
|
15
|
+
*/
|
|
16
|
+
export const getShareLink = async (input: GetShareLinkInput): Promise<ShareLinkRecord | null> => {
|
|
17
|
+
const result = await neogma.queryRunner.run(
|
|
18
|
+
`MATCH (s:ShareLink {id: $shareId})
|
|
19
|
+
WHERE ($namespace IS NULL OR s.namespace = $namespace)
|
|
20
|
+
AND ($ownerProfileId IS NULL OR s.ownerProfileId = $ownerProfileId)
|
|
21
|
+
RETURN s LIMIT 1`,
|
|
22
|
+
{
|
|
23
|
+
shareId: input.shareId,
|
|
24
|
+
namespace: input.namespace ?? null,
|
|
25
|
+
ownerProfileId: input.ownerProfileId ?? null,
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const props = readNodeProperties(result, 's');
|
|
30
|
+
|
|
31
|
+
return props ? toShareLinkRecord(props) : null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns the single committed content reference that may be served, or a typed
|
|
36
|
+
* not-active/missing state.
|
|
37
|
+
*
|
|
38
|
+
* The expiry check is synchronous and read-only: an expired share is reported
|
|
39
|
+
* `not_active` while its bytes are retained, so a metadata-only expiry extension
|
|
40
|
+
* can later make it serve again. This method never deletes content and never
|
|
41
|
+
* mutates the graph — it is the read primitive a later Brain proxy route can use.
|
|
42
|
+
*/
|
|
43
|
+
export const getCurrentShareContent = async (
|
|
44
|
+
input: GetCurrentShareContentInput
|
|
45
|
+
): Promise<CurrentShareContentResult> => {
|
|
46
|
+
const share = await getShareLink(input);
|
|
47
|
+
|
|
48
|
+
if (!share) return { state: 'missing' };
|
|
49
|
+
|
|
50
|
+
const base = {
|
|
51
|
+
shareId: share.id,
|
|
52
|
+
namespace: share.namespace,
|
|
53
|
+
ownerProfileId: share.ownerProfileId,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (share.status === 'stopped') {
|
|
57
|
+
return {
|
|
58
|
+
state: 'not_active',
|
|
59
|
+
...base,
|
|
60
|
+
status: share.status,
|
|
61
|
+
contentState: share.contentState,
|
|
62
|
+
reason: 'stopped',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (
|
|
67
|
+
share.status !== 'active' ||
|
|
68
|
+
share.contentState !== 'finalized' ||
|
|
69
|
+
!share.activeObjectRef ||
|
|
70
|
+
!share.activeObjectOperationId
|
|
71
|
+
) {
|
|
72
|
+
return {
|
|
73
|
+
state: 'not_active',
|
|
74
|
+
...base,
|
|
75
|
+
status: share.status,
|
|
76
|
+
contentState: share.contentState,
|
|
77
|
+
reason: share.contentState === 'content_missing' ? 'content_missing' : 'staging',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const now = input.now ?? new Date();
|
|
82
|
+
|
|
83
|
+
if (!input.allowExpired && share.expiresAt && Date.parse(share.expiresAt) <= now.getTime()) {
|
|
84
|
+
return {
|
|
85
|
+
state: 'not_active',
|
|
86
|
+
...base,
|
|
87
|
+
status: share.status,
|
|
88
|
+
contentState: share.contentState,
|
|
89
|
+
reason: 'expired',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
state: 'active',
|
|
95
|
+
...base,
|
|
96
|
+
version: share.version,
|
|
97
|
+
contentVersion: share.contentVersion,
|
|
98
|
+
objectRef: share.activeObjectRef,
|
|
99
|
+
operationId: share.activeObjectOperationId,
|
|
100
|
+
contentHash: share.activeContentHash,
|
|
101
|
+
contentBytes: share.activeContentBytes,
|
|
102
|
+
recoveryHash: share.activeRecoveryHash,
|
|
103
|
+
selectedCount: share.selectedCount,
|
|
104
|
+
expiresAt: share.expiresAt,
|
|
105
|
+
updatedAt: share.updatedAt,
|
|
106
|
+
};
|
|
107
|
+
};
|