@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,814 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { TRPCError } from '@trpc/server';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AcknowledgeViewInputValidator,
|
|
7
|
+
AcknowledgeViewOutputValidator,
|
|
8
|
+
ResolveShareLinkInputValidator,
|
|
9
|
+
SHARE_VIEW_RECEIPT_TTL_SECONDS,
|
|
10
|
+
ShareEnvelopeValidator,
|
|
11
|
+
ShareLinkPublicContentViewValidator,
|
|
12
|
+
ShareLinkPublicStateValidator,
|
|
13
|
+
} from '@learncard/types';
|
|
14
|
+
import { LCNNotificationTypeEnumValidator } from '@learncard/types';
|
|
15
|
+
|
|
16
|
+
import { openRouteWithoutInputCapture, t } from '@routes';
|
|
17
|
+
import type { ShareLinkPolicyResolver } from '@helpers/share-link-policy/types';
|
|
18
|
+
import { createShareLinkPolicyResolver } from '@helpers/share-link-policy/resolver';
|
|
19
|
+
import type { ShareContentContentProjection } from '@helpers/share-content-client/types';
|
|
20
|
+
import { createRetryableLazyInitializer } from '@helpers/share-link-owner/lazy-initializer';
|
|
21
|
+
import {
|
|
22
|
+
createBoundedShareLinkDiagnosticReporter,
|
|
23
|
+
createShareLinkDependencyResolver,
|
|
24
|
+
SHARE_LINK_COMPOSITION_DIAGNOSTIC,
|
|
25
|
+
} from '@helpers/share-link-owner/diagnostics';
|
|
26
|
+
import { resolveShareLinkPublicApiConfig } from '@helpers/share-link-public/config';
|
|
27
|
+
import type { ShareLinkPublicApiConfigResolution } from '@helpers/share-link-public/config';
|
|
28
|
+
import type {
|
|
29
|
+
ConsumeShareViewReceiptInput,
|
|
30
|
+
ConsumeShareViewReceiptOutcome,
|
|
31
|
+
PersistShareViewReceiptInput,
|
|
32
|
+
ShareViewEligibilitySource,
|
|
33
|
+
} from '@accesslayer/share-link';
|
|
34
|
+
import type { ShareLinkRecord } from '../models/ShareLink';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* LC-2187 public (anonymous) share-link API: resolve, guarded content and
|
|
38
|
+
* privacy-uniform view acknowledgement.
|
|
39
|
+
*
|
|
40
|
+
* Disabled by default and fail-closed. There is no caller authority of any kind:
|
|
41
|
+
* namespace comes only from trusted server configuration, the owner only from
|
|
42
|
+
* the committed share, and there is never a caller-supplied object ref, URL,
|
|
43
|
+
* key or policy field. Resolve returns metadata only; resolve/previews never
|
|
44
|
+
* create a receipt or a count. The content route proxies the reviewed client
|
|
45
|
+
* transport and only releases bytes from one committed tuple after a final
|
|
46
|
+
* current-state recheck.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
export type PublicShareLinkSharer = { displayName: string; avatar?: string };
|
|
50
|
+
|
|
51
|
+
export type PublicShareLinkRouterDependencies = {
|
|
52
|
+
readonly namespace: string;
|
|
53
|
+
readonly repository: {
|
|
54
|
+
getShareLink: (input: {
|
|
55
|
+
shareId: string;
|
|
56
|
+
namespace?: string;
|
|
57
|
+
}) => Promise<ShareLinkRecord | null>;
|
|
58
|
+
/**
|
|
59
|
+
* Namespace/owner-scoped read of the reviewed committted content tuple.
|
|
60
|
+
* Production delegates to `coordinator.fetchShareContent`, which applies
|
|
61
|
+
* the final committed-tuple recheck after the awaited remote read.
|
|
62
|
+
*/
|
|
63
|
+
fetchContent: (input: {
|
|
64
|
+
shareId: string;
|
|
65
|
+
namespace: string;
|
|
66
|
+
ownerProfileId: string;
|
|
67
|
+
}) => Promise<
|
|
68
|
+
{ ok: true; value: ShareContentContentProjection } | { ok: false; error: string }
|
|
69
|
+
>;
|
|
70
|
+
};
|
|
71
|
+
readonly receipts: {
|
|
72
|
+
persist: (input: PersistShareViewReceiptInput) => Promise<boolean>;
|
|
73
|
+
/** Owner for a receipt that belongs to `namespace`; null otherwise. */
|
|
74
|
+
lookupOwner: (receipt: string, namespace: string) => Promise<string | null>;
|
|
75
|
+
lookupContext?: (
|
|
76
|
+
receipt: string,
|
|
77
|
+
namespace: string
|
|
78
|
+
) => Promise<{ ownerProfileId: string; shareId: string } | null>;
|
|
79
|
+
consume: (input: ConsumeShareViewReceiptInput) => Promise<ConsumeShareViewReceiptOutcome>;
|
|
80
|
+
};
|
|
81
|
+
readonly policyResolver: ShareLinkPolicyResolver;
|
|
82
|
+
/**
|
|
83
|
+
* Optional transaction-compatible trusted eligibility source. Production
|
|
84
|
+
* rechecks persisted profile state under the share lock. An absent source
|
|
85
|
+
* remains fail-closed for tests and other compositions.
|
|
86
|
+
*/
|
|
87
|
+
readonly eligibilitySource?: ShareViewEligibilitySource;
|
|
88
|
+
readonly getSharer: (ownerProfileId: string) => Promise<PublicShareLinkSharer | null>;
|
|
89
|
+
readonly verifyPasscode?: (passcodeHash: string, passcode: string) => Promise<boolean>;
|
|
90
|
+
readonly passcodeAttempts?: {
|
|
91
|
+
reserve: (shareId: string, sourceIp?: string) => Promise<boolean>;
|
|
92
|
+
};
|
|
93
|
+
readonly notifyView?: (input: {
|
|
94
|
+
shareId: string;
|
|
95
|
+
ownerProfileId: string;
|
|
96
|
+
title: string;
|
|
97
|
+
selectedCount: number;
|
|
98
|
+
viewCount: number;
|
|
99
|
+
viewedAt: string;
|
|
100
|
+
}) => Promise<void>;
|
|
101
|
+
readonly enforceRateLimit: (window: {
|
|
102
|
+
key: string;
|
|
103
|
+
limit: number;
|
|
104
|
+
windowSeconds: number;
|
|
105
|
+
description: string;
|
|
106
|
+
}) => Promise<void>;
|
|
107
|
+
readonly contentUrlFor: (shareId: string) => string;
|
|
108
|
+
readonly newReceipt: () => string;
|
|
109
|
+
readonly now: () => Date;
|
|
110
|
+
/** Aggregate-only dependency-failure telemetry; never includes input. */
|
|
111
|
+
readonly reportFailure: (category: string) => void;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
type PublicShareClassification =
|
|
115
|
+
| { state: 'active'; record: ShareLinkRecord }
|
|
116
|
+
| { state: 'expired'; expiresAt: string }
|
|
117
|
+
| { state: 'stopped'; stoppedAt: string }
|
|
118
|
+
| { state: 'not_found' };
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Strict public classification. Stopped wins over expired; `staging`,
|
|
122
|
+
* `content_missing`, `pending` and any missing object binding fail closed to
|
|
123
|
+
* `not_found` so a half-committed revision is never advertised.
|
|
124
|
+
*/
|
|
125
|
+
export const classifyPublicShare = (
|
|
126
|
+
record: ShareLinkRecord | null,
|
|
127
|
+
now: Date
|
|
128
|
+
): PublicShareClassification => {
|
|
129
|
+
if (!record) return { state: 'not_found' };
|
|
130
|
+
|
|
131
|
+
if (record.status === 'stopped') {
|
|
132
|
+
return { state: 'stopped', stoppedAt: record.stoppedAt ?? record.updatedAt };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (record.expiresAt && Date.parse(record.expiresAt) <= now.getTime()) {
|
|
136
|
+
return { state: 'expired', expiresAt: record.expiresAt };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (
|
|
140
|
+
record.status !== 'active' ||
|
|
141
|
+
record.contentState !== 'finalized' ||
|
|
142
|
+
!record.activeObjectRef ||
|
|
143
|
+
!record.activeObjectOperationId
|
|
144
|
+
) {
|
|
145
|
+
return { state: 'not_found' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { state: 'active', record };
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
function notFound(): never {
|
|
152
|
+
throw new TRPCError({ code: 'NOT_FOUND' });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const openapi = (method: 'GET' | 'POST', path: `/${string}`, summary: string) => ({
|
|
156
|
+
protect: false as const,
|
|
157
|
+
method,
|
|
158
|
+
path,
|
|
159
|
+
tags: ['ShareLinks'],
|
|
160
|
+
summary,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const PUBLIC_RESOLVE_PATH = '/public/share-links/{id}' as const;
|
|
164
|
+
const PUBLIC_CONTENT_PATH = '/public/share-links/{id}/content' as const;
|
|
165
|
+
const PUBLIC_ACK_PATH = '/public/share-links/acknowledge-view' as const;
|
|
166
|
+
|
|
167
|
+
const passcodeAccepted = async (
|
|
168
|
+
record: ShareLinkRecord,
|
|
169
|
+
passcode: string | undefined,
|
|
170
|
+
dependencies: PublicShareLinkRouterDependencies,
|
|
171
|
+
sourceIp?: string
|
|
172
|
+
): Promise<'accepted' | 'rejected' | 'try_later'> => {
|
|
173
|
+
if (record.passcodeHash == null) return 'accepted';
|
|
174
|
+
if (!passcode) return 'rejected';
|
|
175
|
+
if (!dependencies.verifyPasscode || !dependencies.passcodeAttempts) return 'try_later';
|
|
176
|
+
try {
|
|
177
|
+
if (!(await dependencies.passcodeAttempts.reserve(record.id, sourceIp))) return 'try_later';
|
|
178
|
+
const accepted = await dependencies.verifyPasscode(record.passcodeHash, passcode);
|
|
179
|
+
return accepted ? 'accepted' : 'rejected';
|
|
180
|
+
} catch (error) {
|
|
181
|
+
dependencies.reportFailure(
|
|
182
|
+
error instanceof Error && error.name === 'SharePasscodeCapacityError'
|
|
183
|
+
? 'passcode_capacity'
|
|
184
|
+
: 'passcode_guard'
|
|
185
|
+
);
|
|
186
|
+
return 'try_later';
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Trusted relative content route mounted under the OpenAPI adapter's `/api`
|
|
192
|
+
* base path. Never a LearnCloud object URL or a Host; a returned URL can be
|
|
193
|
+
* fetched directly from the supported local adapters.
|
|
194
|
+
*/
|
|
195
|
+
export const publicShareContentUrl = (shareId: string): string =>
|
|
196
|
+
`/api/public/share-links/${encodeURIComponent(shareId)}/content`;
|
|
197
|
+
|
|
198
|
+
const rateLimitKey = (kind: string, namespace: string, sourceIp?: string): string =>
|
|
199
|
+
`share-link-public-${kind}:${namespace}:${sourceIp ?? 'unknown'}`;
|
|
200
|
+
|
|
201
|
+
export const createPublicShareLinksRouter = (
|
|
202
|
+
getDependencies: () => Promise<PublicShareLinkRouterDependencies | null>
|
|
203
|
+
) => {
|
|
204
|
+
const resolve = async (): Promise<PublicShareLinkRouterDependencies> => {
|
|
205
|
+
let dependencies: PublicShareLinkRouterDependencies | null;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
dependencies = await getDependencies();
|
|
209
|
+
} catch {
|
|
210
|
+
throw new TRPCError({
|
|
211
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
212
|
+
message: 'share-link service is unavailable',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!dependencies) notFound();
|
|
217
|
+
|
|
218
|
+
return dependencies;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
return t.router({
|
|
222
|
+
resolve: openRouteWithoutInputCapture
|
|
223
|
+
.meta({
|
|
224
|
+
openapi: openapi('POST', PUBLIC_RESOLVE_PATH, 'Resolve public share metadata'),
|
|
225
|
+
})
|
|
226
|
+
.input(ResolveShareLinkInputValidator)
|
|
227
|
+
.output(ShareLinkPublicStateValidator)
|
|
228
|
+
.mutation(async ({ ctx, input }) => {
|
|
229
|
+
let dependencies: PublicShareLinkRouterDependencies | null;
|
|
230
|
+
try {
|
|
231
|
+
dependencies = await getDependencies();
|
|
232
|
+
} catch {
|
|
233
|
+
return { state: 'not_found' as const, id: input.id };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Disabled/invalid config: no repository, signer or remote call.
|
|
237
|
+
if (!dependencies) return { state: 'not_found' as const, id: input.id };
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
await dependencies.enforceRateLimit({
|
|
241
|
+
key: rateLimitKey('resolve', dependencies.namespace, ctx.sourceIp),
|
|
242
|
+
limit: 120,
|
|
243
|
+
windowSeconds: 60,
|
|
244
|
+
description: 'public share-link resolve',
|
|
245
|
+
});
|
|
246
|
+
} catch {
|
|
247
|
+
dependencies.reportFailure('resolve_rate_limit');
|
|
248
|
+
throw new TRPCError({
|
|
249
|
+
code: 'TOO_MANY_REQUESTS',
|
|
250
|
+
message: 'rate limit exceeded',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let record: ShareLinkRecord | null;
|
|
255
|
+
try {
|
|
256
|
+
record = await dependencies.repository.getShareLink({
|
|
257
|
+
shareId: input.id,
|
|
258
|
+
namespace: dependencies.namespace,
|
|
259
|
+
});
|
|
260
|
+
} catch {
|
|
261
|
+
// Raw repository failures must not escape with a message.
|
|
262
|
+
dependencies.reportFailure('resolve_repository');
|
|
263
|
+
return { state: 'not_found' as const, id: input.id };
|
|
264
|
+
}
|
|
265
|
+
const classification = classifyPublicShare(record, dependencies.now());
|
|
266
|
+
|
|
267
|
+
if (classification.state === 'not_found') {
|
|
268
|
+
return { state: 'not_found' as const, id: input.id };
|
|
269
|
+
}
|
|
270
|
+
// Even lifecycle timestamps are private until a protected link
|
|
271
|
+
// has been unlocked. Missing records never enter Argon2.
|
|
272
|
+
if (record) {
|
|
273
|
+
const access = await passcodeAccepted(
|
|
274
|
+
record,
|
|
275
|
+
input.passcode,
|
|
276
|
+
dependencies,
|
|
277
|
+
ctx.sourceIp
|
|
278
|
+
);
|
|
279
|
+
if (access === 'rejected')
|
|
280
|
+
return { state: 'passcode_required' as const, id: input.id };
|
|
281
|
+
if (access === 'try_later')
|
|
282
|
+
return { state: 'try_later' as const, id: input.id };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (classification.state === 'expired') {
|
|
286
|
+
return {
|
|
287
|
+
state: 'expired' as const,
|
|
288
|
+
id: input.id,
|
|
289
|
+
expiresAt: classification.expiresAt,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (classification.state === 'stopped') {
|
|
293
|
+
return {
|
|
294
|
+
state: 'stopped' as const,
|
|
295
|
+
id: input.id,
|
|
296
|
+
stoppedAt: classification.stoppedAt,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
let sharer: PublicShareLinkSharer | null;
|
|
300
|
+
try {
|
|
301
|
+
sharer = await dependencies.getSharer(classification.record.ownerProfileId);
|
|
302
|
+
} catch {
|
|
303
|
+
dependencies.reportFailure('resolve_sharer');
|
|
304
|
+
return { state: 'not_found' as const, id: input.id };
|
|
305
|
+
}
|
|
306
|
+
if (!sharer) return { state: 'not_found' as const, id: input.id };
|
|
307
|
+
|
|
308
|
+
// Final current-state guard AFTER the awaited sharer lookup: if the
|
|
309
|
+
// metadata revision changed while the sharer was being read, the
|
|
310
|
+
// projection is stale and must not be returned.
|
|
311
|
+
let latest: ShareLinkRecord | null;
|
|
312
|
+
try {
|
|
313
|
+
latest = await dependencies.repository.getShareLink({
|
|
314
|
+
shareId: input.id,
|
|
315
|
+
namespace: dependencies.namespace,
|
|
316
|
+
});
|
|
317
|
+
} catch {
|
|
318
|
+
dependencies.reportFailure('resolve_repository');
|
|
319
|
+
return { state: 'not_found' as const, id: input.id };
|
|
320
|
+
}
|
|
321
|
+
const latestClassification = classifyPublicShare(latest, dependencies.now());
|
|
322
|
+
if (
|
|
323
|
+
latestClassification.state !== 'active' ||
|
|
324
|
+
latestClassification.record.version !== classification.record.version
|
|
325
|
+
) {
|
|
326
|
+
return { state: 'not_found' as const, id: input.id };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
state: 'active' as const,
|
|
331
|
+
id: classification.record.id,
|
|
332
|
+
title: classification.record.title,
|
|
333
|
+
...(classification.record.note === null
|
|
334
|
+
? {}
|
|
335
|
+
: { note: classification.record.note }),
|
|
336
|
+
selectedCount: classification.record.selectedCount,
|
|
337
|
+
contentVersion: classification.record.contentVersion,
|
|
338
|
+
contentUrl: dependencies.contentUrlFor(classification.record.id),
|
|
339
|
+
sharer: {
|
|
340
|
+
displayName: sharer.displayName.slice(0, 120),
|
|
341
|
+
...(sharer.avatar ? { avatar: sharer.avatar.slice(0, 2048) } : {}),
|
|
342
|
+
},
|
|
343
|
+
createdAt: classification.record.createdAt,
|
|
344
|
+
updatedAt: classification.record.updatedAt,
|
|
345
|
+
expiresAt: classification.record.expiresAt,
|
|
346
|
+
};
|
|
347
|
+
}),
|
|
348
|
+
|
|
349
|
+
content: openRouteWithoutInputCapture
|
|
350
|
+
.meta({
|
|
351
|
+
openapi: openapi('POST', PUBLIC_CONTENT_PATH, 'Fetch guarded share content'),
|
|
352
|
+
})
|
|
353
|
+
.input(ResolveShareLinkInputValidator)
|
|
354
|
+
.output(ShareLinkPublicContentViewValidator)
|
|
355
|
+
.mutation(async ({ ctx, input }) => {
|
|
356
|
+
const dependencies = await resolve();
|
|
357
|
+
|
|
358
|
+
// The content response is large; a rate-limit/dependency failure
|
|
359
|
+
// fails closed with a fixed safe error (no raw message escapes).
|
|
360
|
+
try {
|
|
361
|
+
await dependencies.enforceRateLimit({
|
|
362
|
+
key: rateLimitKey('content', dependencies.namespace, ctx.sourceIp),
|
|
363
|
+
limit: 60,
|
|
364
|
+
windowSeconds: 60,
|
|
365
|
+
description: 'public share-link content',
|
|
366
|
+
});
|
|
367
|
+
} catch {
|
|
368
|
+
dependencies.reportFailure('content_rate_limit');
|
|
369
|
+
throw new TRPCError({
|
|
370
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
371
|
+
message: 'share-link content is unavailable',
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const readActiveShare = async (): Promise<ShareLinkRecord> => {
|
|
376
|
+
let candidate: ShareLinkRecord | null;
|
|
377
|
+
try {
|
|
378
|
+
candidate = await dependencies.repository.getShareLink({
|
|
379
|
+
shareId: input.id,
|
|
380
|
+
namespace: dependencies.namespace,
|
|
381
|
+
});
|
|
382
|
+
} catch {
|
|
383
|
+
dependencies.reportFailure('content_repository');
|
|
384
|
+
throw new TRPCError({
|
|
385
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
386
|
+
message: 'share-link content is unavailable',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const classification = classifyPublicShare(candidate, dependencies.now());
|
|
391
|
+
if (classification.state !== 'active') notFound();
|
|
392
|
+
|
|
393
|
+
return classification.record;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const tupleMatches = (
|
|
397
|
+
projection: {
|
|
398
|
+
objectId: string;
|
|
399
|
+
operationId: string;
|
|
400
|
+
contentVersion: number;
|
|
401
|
+
payloadHash: string;
|
|
402
|
+
},
|
|
403
|
+
candidate: ShareLinkRecord
|
|
404
|
+
): boolean =>
|
|
405
|
+
projection.objectId === candidate.activeObjectRef &&
|
|
406
|
+
projection.operationId === candidate.activeObjectOperationId &&
|
|
407
|
+
projection.contentVersion === candidate.contentVersion &&
|
|
408
|
+
// Brain's committed `activeContentHash` is the payload hash
|
|
409
|
+
// (envelope + recovery, before IDs): exactly what the
|
|
410
|
+
// reviewed client verifies on stat. Never compare the
|
|
411
|
+
// tuple-inclusive LearnCloud `contentHash` here.
|
|
412
|
+
(candidate.activeContentHash === null ||
|
|
413
|
+
projection.payloadHash === candidate.activeContentHash);
|
|
414
|
+
|
|
415
|
+
const first = await readActiveShare();
|
|
416
|
+
|
|
417
|
+
const access = await passcodeAccepted(
|
|
418
|
+
first,
|
|
419
|
+
input.passcode,
|
|
420
|
+
dependencies,
|
|
421
|
+
ctx.sourceIp
|
|
422
|
+
);
|
|
423
|
+
if (access === 'try_later') {
|
|
424
|
+
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'try again later' });
|
|
425
|
+
}
|
|
426
|
+
if (access === 'rejected') {
|
|
427
|
+
throw new TRPCError({
|
|
428
|
+
code: 'UNAUTHORIZED',
|
|
429
|
+
message: 'share-link passcode required',
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let fetched: Awaited<
|
|
434
|
+
ReturnType<PublicShareLinkRouterDependencies['repository']['fetchContent']>
|
|
435
|
+
>;
|
|
436
|
+
try {
|
|
437
|
+
fetched = await dependencies.repository.fetchContent({
|
|
438
|
+
shareId: input.id,
|
|
439
|
+
namespace: dependencies.namespace,
|
|
440
|
+
ownerProfileId: first.ownerProfileId,
|
|
441
|
+
});
|
|
442
|
+
} catch {
|
|
443
|
+
dependencies.reportFailure('content_fetch');
|
|
444
|
+
throw new TRPCError({
|
|
445
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
446
|
+
message: 'share-link content is unavailable',
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (!fetched.ok) {
|
|
451
|
+
if (
|
|
452
|
+
fetched.error === 'UNAVAILABLE' ||
|
|
453
|
+
fetched.error === 'TIMEOUT' ||
|
|
454
|
+
fetched.error === 'NETWORK_ERROR' ||
|
|
455
|
+
fetched.error === 'MALFORMED_RESPONSE' ||
|
|
456
|
+
fetched.error === 'RESPONSE_TOO_LARGE' ||
|
|
457
|
+
fetched.error === 'UNEXPECTED_STATUS'
|
|
458
|
+
) {
|
|
459
|
+
throw new TRPCError({
|
|
460
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
461
|
+
message: 'share-link content is unavailable',
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
notFound();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Fresh authoritative policy (never an earlier owner snapshot and
|
|
468
|
+
// never a caller field). A policy resolver failure is a fixed safe
|
|
469
|
+
// error, not a raw leak.
|
|
470
|
+
let policy: Awaited<ReturnType<ShareLinkPolicyResolver['resolve']>>;
|
|
471
|
+
try {
|
|
472
|
+
policy = await dependencies.policyResolver.resolve(first.ownerProfileId);
|
|
473
|
+
} catch {
|
|
474
|
+
dependencies.reportFailure('content_policy');
|
|
475
|
+
throw new TRPCError({
|
|
476
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
477
|
+
message: 'share-link content is unavailable',
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Re-read AFTER the awaited policy resolve: a revoke/replacement/
|
|
482
|
+
// expiry during policy resolution must withhold the bytes.
|
|
483
|
+
const persistRecord = await readActiveShare();
|
|
484
|
+
if (
|
|
485
|
+
persistRecord.version !== first.version ||
|
|
486
|
+
!tupleMatches(fetched.value, persistRecord)
|
|
487
|
+
) {
|
|
488
|
+
notFound();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const committedEligible =
|
|
492
|
+
persistRecord.minorPolicyViewCountingEnabled === true &&
|
|
493
|
+
persistRecord.minorPolicyResolved === true &&
|
|
494
|
+
persistRecord.minorPolicyIsMinor === false;
|
|
495
|
+
const eligible =
|
|
496
|
+
policy.viewCountingEnabled &&
|
|
497
|
+
policy.policyResolved &&
|
|
498
|
+
policy.isMinor === false &&
|
|
499
|
+
committedEligible;
|
|
500
|
+
|
|
501
|
+
const receipt = dependencies.newReceipt();
|
|
502
|
+
|
|
503
|
+
if (eligible) {
|
|
504
|
+
try {
|
|
505
|
+
await dependencies.receipts.persist({
|
|
506
|
+
receipt,
|
|
507
|
+
namespace: dependencies.namespace,
|
|
508
|
+
ownerProfileId: persistRecord.ownerProfileId,
|
|
509
|
+
shareId: persistRecord.id,
|
|
510
|
+
shareVersion: persistRecord.version,
|
|
511
|
+
contentVersion: persistRecord.contentVersion,
|
|
512
|
+
objectRef: fetched.value.objectId,
|
|
513
|
+
operationId: fetched.value.operationId,
|
|
514
|
+
ttlSeconds: SHARE_VIEW_RECEIPT_TTL_SECONDS,
|
|
515
|
+
eligibilitySource: dependencies.eligibilitySource,
|
|
516
|
+
now: dependencies.now,
|
|
517
|
+
});
|
|
518
|
+
} catch {
|
|
519
|
+
// Fail closed on counting; the response shape is unchanged.
|
|
520
|
+
dependencies.reportFailure('receipt_persist');
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// FINAL current-state check after ALL awaited work (policy resolve,
|
|
525
|
+
// receipt persistence). No bytes are released unless the complete
|
|
526
|
+
// committed tuple/status/expiry/version is still current, so a
|
|
527
|
+
// revoke, replacement or expiry during persistence cannot return
|
|
528
|
+
// content authorized against an earlier state.
|
|
529
|
+
const finalRecord = await readActiveShare();
|
|
530
|
+
if (
|
|
531
|
+
finalRecord.version !== persistRecord.version ||
|
|
532
|
+
!tupleMatches(fetched.value, finalRecord)
|
|
533
|
+
) {
|
|
534
|
+
notFound();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const envelope = ShareEnvelopeValidator.safeParse(fetched.value.envelope);
|
|
538
|
+
if (!envelope.success) notFound();
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
id: finalRecord.id,
|
|
542
|
+
contentVersion: finalRecord.contentVersion,
|
|
543
|
+
envelope: envelope.data,
|
|
544
|
+
contentUrl: dependencies.contentUrlFor(finalRecord.id),
|
|
545
|
+
receipt,
|
|
546
|
+
};
|
|
547
|
+
}),
|
|
548
|
+
|
|
549
|
+
acknowledgeView: openRouteWithoutInputCapture
|
|
550
|
+
.meta({
|
|
551
|
+
openapi: openapi('POST', PUBLIC_ACK_PATH, 'Acknowledge a share view receipt'),
|
|
552
|
+
})
|
|
553
|
+
.input(AcknowledgeViewInputValidator)
|
|
554
|
+
.output(AcknowledgeViewOutputValidator)
|
|
555
|
+
.mutation(async ({ ctx, input }) => {
|
|
556
|
+
let dependencies: PublicShareLinkRouterDependencies | null;
|
|
557
|
+
try {
|
|
558
|
+
dependencies = await getDependencies();
|
|
559
|
+
} catch {
|
|
560
|
+
return { ok: true as const };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (!dependencies) return { ok: true as const };
|
|
564
|
+
|
|
565
|
+
// Rate limiting and dependency failures must never change the
|
|
566
|
+
// uniform acknowledgement shape. Fail closed on counting only.
|
|
567
|
+
try {
|
|
568
|
+
await dependencies.enforceRateLimit({
|
|
569
|
+
key: rateLimitKey('ack', dependencies.namespace, ctx.sourceIp),
|
|
570
|
+
limit: 120,
|
|
571
|
+
windowSeconds: 60,
|
|
572
|
+
description: 'public share-link acknowledgement',
|
|
573
|
+
});
|
|
574
|
+
} catch {
|
|
575
|
+
dependencies.reportFailure('ack_rate_limit');
|
|
576
|
+
return { ok: true as const };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
// Owner is discovered from persisted receipt state, never from
|
|
581
|
+
// the caller. Unknown/padding tokens and tokens minted in
|
|
582
|
+
// another namespace simply return early.
|
|
583
|
+
const context = dependencies.receipts.lookupContext
|
|
584
|
+
? await dependencies.receipts.lookupContext(
|
|
585
|
+
input.receipt,
|
|
586
|
+
dependencies.namespace
|
|
587
|
+
)
|
|
588
|
+
: null;
|
|
589
|
+
const ownerProfileId =
|
|
590
|
+
context?.ownerProfileId ??
|
|
591
|
+
(await dependencies.receipts.lookupOwner(
|
|
592
|
+
input.receipt,
|
|
593
|
+
dependencies.namespace
|
|
594
|
+
));
|
|
595
|
+
if (ownerProfileId === null) return { ok: true as const };
|
|
596
|
+
|
|
597
|
+
const policy = await dependencies.policyResolver.resolve(ownerProfileId);
|
|
598
|
+
|
|
599
|
+
// The pre-lock resolve is a cheap fail-closed short-circuit,
|
|
600
|
+
// never authority: consume re-checks the committed snapshot
|
|
601
|
+
// and the transaction-compatible source under both locks.
|
|
602
|
+
if (!policy.viewCountingEnabled) return { ok: true as const };
|
|
603
|
+
|
|
604
|
+
const outcome = await dependencies.receipts.consume({
|
|
605
|
+
receipt: input.receipt,
|
|
606
|
+
namespace: dependencies.namespace,
|
|
607
|
+
eligibilitySource: dependencies.eligibilitySource,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
if (outcome === 'consumed' && context && dependencies.notifyView) {
|
|
611
|
+
const share = await dependencies.repository.getShareLink({
|
|
612
|
+
shareId: context.shareId,
|
|
613
|
+
namespace: dependencies.namespace,
|
|
614
|
+
});
|
|
615
|
+
if (
|
|
616
|
+
share &&
|
|
617
|
+
share.ownerProfileId === context.ownerProfileId &&
|
|
618
|
+
share.notifyOnView === true &&
|
|
619
|
+
share.minorPolicyResolved === true &&
|
|
620
|
+
share.minorPolicyIsMinor === false &&
|
|
621
|
+
share.minorPolicyViewCountingEnabled === true &&
|
|
622
|
+
share.lastViewedAt
|
|
623
|
+
) {
|
|
624
|
+
await dependencies.notifyView({
|
|
625
|
+
shareId: share.id,
|
|
626
|
+
ownerProfileId: share.ownerProfileId,
|
|
627
|
+
title: share.title,
|
|
628
|
+
selectedCount: share.selectedCount,
|
|
629
|
+
viewCount: share.viewCount,
|
|
630
|
+
viewedAt: share.lastViewedAt,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
} catch {
|
|
635
|
+
dependencies.reportFailure('ack_consume');
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return { ok: true as const };
|
|
639
|
+
}),
|
|
640
|
+
});
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
export type PublicShareLinksRouter = ReturnType<typeof createPublicShareLinksRouter>;
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Lazy production composition. Config is resolved before any signer/graph/remote
|
|
647
|
+
* import; a disabled configuration is inert and silent, while malformed wiring
|
|
648
|
+
* emits a single sanitized diagnostic and still fails closed. A failed build is
|
|
649
|
+
* not cached, so a later request retries.
|
|
650
|
+
*/
|
|
651
|
+
const initializeProductionDependencies =
|
|
652
|
+
createRetryableLazyInitializer<PublicShareLinkRouterDependencies>(() =>
|
|
653
|
+
buildProductionDependencies(
|
|
654
|
+
resolveShareLinkPublicApiConfig(process.env as Record<string, unknown>) as Extract<
|
|
655
|
+
ShareLinkPublicApiConfigResolution,
|
|
656
|
+
{ status: 'enabled' }
|
|
657
|
+
>
|
|
658
|
+
)
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
export const getProductionDependencies =
|
|
662
|
+
createShareLinkDependencyResolver<PublicShareLinkRouterDependencies>({
|
|
663
|
+
resolveConfig: () =>
|
|
664
|
+
resolveShareLinkPublicApiConfig(process.env as Record<string, unknown>),
|
|
665
|
+
initializeDependencies: initializeProductionDependencies,
|
|
666
|
+
reportDiagnostic: createBoundedShareLinkDiagnosticReporter(),
|
|
667
|
+
configurationInvalidCategory:
|
|
668
|
+
SHARE_LINK_COMPOSITION_DIAGNOSTIC.PUBLIC_CONFIGURATION_INVALID,
|
|
669
|
+
initializationFailedCategory:
|
|
670
|
+
SHARE_LINK_COMPOSITION_DIAGNOSTIC.PUBLIC_INITIALIZATION_FAILED,
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const buildProductionDependencies = async (
|
|
674
|
+
config: Extract<ShareLinkPublicApiConfigResolution, { status: 'enabled' }>
|
|
675
|
+
): Promise<PublicShareLinkRouterDependencies> => {
|
|
676
|
+
const [
|
|
677
|
+
{ getServerDidWebDID },
|
|
678
|
+
{ createDidWebLearnCardTokenSigner },
|
|
679
|
+
clientModule,
|
|
680
|
+
runtimeModule,
|
|
681
|
+
{ ensureShareLinkConstraints },
|
|
682
|
+
{ createProductionShareLinkPolicySource, productionShareViewEligibilitySource },
|
|
683
|
+
{ getProfileByProfileId },
|
|
684
|
+
{ verifySharePasscode },
|
|
685
|
+
passcodeAbuse,
|
|
686
|
+
{ addNotificationToQueue },
|
|
687
|
+
receiptModule,
|
|
688
|
+
{ getShareLink },
|
|
689
|
+
{ claimShareViewNotification },
|
|
690
|
+
{ getNotificationMessage },
|
|
691
|
+
{ resolveRecipientLocale },
|
|
692
|
+
] = await Promise.all([
|
|
693
|
+
import('@helpers/learnCard.helpers'),
|
|
694
|
+
import('@helpers/share-content-client/adapters'),
|
|
695
|
+
import('@helpers/share-content-client'),
|
|
696
|
+
import('@helpers/share-link-coordinator/runtime'),
|
|
697
|
+
import('../models/share-link-constraints'),
|
|
698
|
+
import('@helpers/share-link-policy/production'),
|
|
699
|
+
import('@accesslayer/profile/read'),
|
|
700
|
+
import('@helpers/share-link-passcode'),
|
|
701
|
+
import('@helpers/share-link-passcode-abuse'),
|
|
702
|
+
import('@helpers/notifications.helpers'),
|
|
703
|
+
import('@accesslayer/share-link/receipt'),
|
|
704
|
+
import('@accesslayer/share-link/read'),
|
|
705
|
+
import('@helpers/share-link-view-notification'),
|
|
706
|
+
import('@helpers/notificationMessages'),
|
|
707
|
+
import('@helpers/getRecipientLocale.helpers'),
|
|
708
|
+
]);
|
|
709
|
+
|
|
710
|
+
const clientConfig = clientModule.resolveShareContentClientConfig({
|
|
711
|
+
enabled: true,
|
|
712
|
+
origin: config.origin,
|
|
713
|
+
namespace: config.namespace,
|
|
714
|
+
audience: config.audience,
|
|
715
|
+
signerDid: getServerDidWebDID(),
|
|
716
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
717
|
+
signer: createDidWebLearnCardTokenSigner(),
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
if (!clientConfig.enabled) {
|
|
721
|
+
// Fail the build so the retryable lazy initializer does not cache it.
|
|
722
|
+
throw new Error('share-content client configuration is invalid');
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const client = clientModule.createShareContentClient(clientConfig);
|
|
726
|
+
const policyResolver = createShareLinkPolicyResolver(createProductionShareLinkPolicySource());
|
|
727
|
+
const coordinator = runtimeModule.createRuntimeShareLinkCoordinator({
|
|
728
|
+
client,
|
|
729
|
+
leaseOwner: 'brain-share-link-public-api',
|
|
730
|
+
policyResolver,
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
await ensureShareLinkConstraints();
|
|
734
|
+
|
|
735
|
+
return {
|
|
736
|
+
namespace: config.namespace,
|
|
737
|
+
repository: {
|
|
738
|
+
getShareLink: input => getShareLink(input),
|
|
739
|
+
fetchContent: async input => {
|
|
740
|
+
const result = await coordinator.fetchShareContent(input.shareId, {
|
|
741
|
+
namespace: input.namespace,
|
|
742
|
+
ownerProfileId: input.ownerProfileId,
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
return result.ok ? { ok: true, value: result.value } : result;
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
receipts: {
|
|
749
|
+
persist: receiptModule.persistShareViewReceipt,
|
|
750
|
+
lookupOwner: async (receipt, namespace) => {
|
|
751
|
+
const owner = await receiptModule.readShareViewReceiptOwner(receipt, namespace);
|
|
752
|
+
return owner;
|
|
753
|
+
},
|
|
754
|
+
lookupContext: receiptModule.readShareViewReceiptContext,
|
|
755
|
+
consume: receiptModule.consumeShareViewReceipt,
|
|
756
|
+
},
|
|
757
|
+
policyResolver,
|
|
758
|
+
eligibilitySource: productionShareViewEligibilitySource,
|
|
759
|
+
verifyPasscode: verifySharePasscode,
|
|
760
|
+
passcodeAttempts: {
|
|
761
|
+
reserve: (shareId, sourceIp) =>
|
|
762
|
+
passcodeAbuse.reserveSharePasscodeAttempt(config.namespace, shareId, sourceIp),
|
|
763
|
+
},
|
|
764
|
+
notifyView: async notification => {
|
|
765
|
+
// Best effort: claim before enqueue to avoid duplicate alerts under
|
|
766
|
+
// concurrent views. A queue outage may suppress one window.
|
|
767
|
+
if (!(await claimShareViewNotification(config.namespace, notification.shareId))) return;
|
|
768
|
+
const profile = await getProfileByProfileId(notification.ownerProfileId);
|
|
769
|
+
if (!profile) return;
|
|
770
|
+
await addNotificationToQueue({
|
|
771
|
+
type: LCNNotificationTypeEnumValidator.enum.APP_NOTIFICATION,
|
|
772
|
+
to: profile,
|
|
773
|
+
from: {
|
|
774
|
+
did: getServerDidWebDID(),
|
|
775
|
+
displayName: 'LearnCard',
|
|
776
|
+
},
|
|
777
|
+
message: getNotificationMessage('shareViewed', resolveRecipientLocale(profile), {
|
|
778
|
+
title: notification.title,
|
|
779
|
+
count: String(notification.selectedCount),
|
|
780
|
+
}),
|
|
781
|
+
data: {
|
|
782
|
+
metadata: {
|
|
783
|
+
shareView: {
|
|
784
|
+
count: notification.viewCount,
|
|
785
|
+
viewedAt: notification.viewedAt,
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
},
|
|
791
|
+
getSharer: async ownerProfileId => {
|
|
792
|
+
const profile = await getProfileByProfileId(ownerProfileId);
|
|
793
|
+
if (!profile) return null;
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
displayName: profile.displayName,
|
|
797
|
+
...(profile.image ? { avatar: profile.image } : {}),
|
|
798
|
+
};
|
|
799
|
+
},
|
|
800
|
+
enforceRateLimit: async window => {
|
|
801
|
+
const { enforceRateLimits } = await import('@helpers/rateLimit.helpers');
|
|
802
|
+
await enforceRateLimits([window]);
|
|
803
|
+
},
|
|
804
|
+
contentUrlFor: publicShareContentUrl,
|
|
805
|
+
newReceipt: () => randomBytes(32).toString('base64url'),
|
|
806
|
+
now: () => new Date(),
|
|
807
|
+
reportFailure: category => {
|
|
808
|
+
// Aggregate-only: a fixed category string, no receipt/share/user data.
|
|
809
|
+
console.warn('share_link_public_dependency_failure', { category });
|
|
810
|
+
},
|
|
811
|
+
};
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
export const publicShareLinksRouter = createPublicShareLinksRouter(getProductionDependencies);
|