@learncard/learn-cloud-service 2.5.34 → 2.5.35

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.
Files changed (89) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +19 -0
  3. package/dist/accesslayer/share-content/delete.d.ts +5 -0
  4. package/dist/accesslayer/share-content/delete.d.ts.map +1 -0
  5. package/dist/accesslayer/share-content/index.d.ts +8 -0
  6. package/dist/accesslayer/share-content/index.d.ts.map +1 -0
  7. package/dist/accesslayer/share-content/initialize.d.ts +5 -0
  8. package/dist/accesslayer/share-content/initialize.d.ts.map +1 -0
  9. package/dist/accesslayer/share-content/internal.d.ts +12 -0
  10. package/dist/accesslayer/share-content/internal.d.ts.map +1 -0
  11. package/dist/accesslayer/share-content/put.d.ts +5 -0
  12. package/dist/accesslayer/share-content/put.d.ts.map +1 -0
  13. package/dist/accesslayer/share-content/read.d.ts +7 -0
  14. package/dist/accesslayer/share-content/read.d.ts.map +1 -0
  15. package/dist/accesslayer/share-content/types.d.ts +85 -0
  16. package/dist/accesslayer/share-content/types.d.ts.map +1 -0
  17. package/dist/config/environment.d.ts +12 -0
  18. package/dist/config/environment.d.ts.map +1 -1
  19. package/dist/helpers/share-content-auth/adapters.d.ts +12 -0
  20. package/dist/helpers/share-content-auth/adapters.d.ts.map +1 -0
  21. package/dist/helpers/share-content-auth/canonical.d.ts +15 -0
  22. package/dist/helpers/share-content-auth/canonical.d.ts.map +1 -0
  23. package/dist/helpers/share-content-auth/claims.d.ts +19 -0
  24. package/dist/helpers/share-content-auth/claims.d.ts.map +1 -0
  25. package/dist/helpers/share-content-auth/config.d.ts +12 -0
  26. package/dist/helpers/share-content-auth/config.d.ts.map +1 -0
  27. package/dist/helpers/share-content-auth/index.d.ts +8 -0
  28. package/dist/helpers/share-content-auth/index.d.ts.map +1 -0
  29. package/dist/helpers/share-content-auth/token.d.ts +28 -0
  30. package/dist/helpers/share-content-auth/token.d.ts.map +1 -0
  31. package/dist/helpers/share-content-auth/types.d.ts +70 -0
  32. package/dist/helpers/share-content-auth/types.d.ts.map +1 -0
  33. package/dist/helpers/share-content-auth/verifier.d.ts +9 -0
  34. package/dist/helpers/share-content-auth/verifier.d.ts.map +1 -0
  35. package/dist/index.js +2 -2
  36. package/dist/models/ShareContent.d.ts +160 -0
  37. package/dist/models/ShareContent.d.ts.map +1 -0
  38. package/dist/models/index.d.ts +1 -0
  39. package/dist/models/index.d.ts.map +1 -1
  40. package/dist/share-content/config.d.ts +28 -0
  41. package/dist/share-content/config.d.ts.map +1 -0
  42. package/dist/share-content/index.d.ts +6 -0
  43. package/dist/share-content/index.d.ts.map +1 -0
  44. package/dist/share-content/plugin.d.ts +5 -0
  45. package/dist/share-content/plugin.d.ts.map +1 -0
  46. package/dist/share-content/production.d.ts +15 -0
  47. package/dist/share-content/production.d.ts.map +1 -0
  48. package/dist/share-content/runtime.d.ts +23 -0
  49. package/dist/share-content/runtime.d.ts.map +1 -0
  50. package/dist/share-content/types.d.ts +23 -0
  51. package/dist/share-content/types.d.ts.map +1 -0
  52. package/lambda.ts +61 -0
  53. package/package.json +14 -14
  54. package/serverless.yml +25 -0
  55. package/src/accesslayer/share-content/delete.ts +124 -0
  56. package/src/accesslayer/share-content/index.ts +70 -0
  57. package/src/accesslayer/share-content/initialize.ts +24 -0
  58. package/src/accesslayer/share-content/internal.ts +89 -0
  59. package/src/accesslayer/share-content/put.ts +93 -0
  60. package/src/accesslayer/share-content/read.ts +124 -0
  61. package/src/accesslayer/share-content/types.ts +155 -0
  62. package/src/config/environment.ts +33 -0
  63. package/src/docker-entry.ts +19 -0
  64. package/src/helpers/share-content-auth/adapters.ts +48 -0
  65. package/src/helpers/share-content-auth/canonical.ts +91 -0
  66. package/src/helpers/share-content-auth/claims.ts +164 -0
  67. package/src/helpers/share-content-auth/config.ts +160 -0
  68. package/src/helpers/share-content-auth/index.ts +59 -0
  69. package/src/helpers/share-content-auth/token.ts +148 -0
  70. package/src/helpers/share-content-auth/types.ts +195 -0
  71. package/src/helpers/share-content-auth/verifier.ts +220 -0
  72. package/src/models/ShareContent.ts +198 -0
  73. package/src/models/index.ts +1 -0
  74. package/src/share-content/config.ts +199 -0
  75. package/src/share-content/index.ts +5 -0
  76. package/src/share-content/plugin.ts +317 -0
  77. package/src/share-content/production.ts +107 -0
  78. package/src/share-content/runtime.ts +90 -0
  79. package/src/share-content/types.ts +59 -0
  80. package/test/share-content-auth.spec.ts +1161 -0
  81. package/test/share-content-config.spec.ts +252 -0
  82. package/test/share-content-didkit.spec.ts +411 -0
  83. package/test/share-content-endpoint.spec.ts +825 -0
  84. package/test/share-content-redis-setup.ts +18 -0
  85. package/test/share-content.spec.ts +681 -0
  86. package/vitest.config.ts +1 -0
  87. package/vitest.share-content-config-unit.config.ts +8 -0
  88. package/vitest.share-content-endpoint.config.ts +21 -0
  89. package/vitest.share-content.config.ts +19 -0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * LC-2187 — Brain → LearnCloud share-content service authorization types.
3
+ *
4
+ * This module is a reusable authorization *verifier foundation*. It is not
5
+ * registered on any router and it does not read the environment. Callers inject
6
+ * the trust configuration, the presentation verifier, the clock and the atomic
7
+ * replay store, so the unit tests exercise the real module without Redis,
8
+ * MongoDB, did:web resolution or unfinished storage/schema work.
9
+ */
10
+
11
+ /** Signed purpose claim. V1 is the only accepted value. */
12
+ export const SHARE_CONTENT_AUTH_PURPOSE = 'lc-share-content/v1' as const;
13
+
14
+ /**
15
+ * Least-privilege operation allowlist. There is deliberately no `list`,
16
+ * `deleteOwner` or other wildcard/collection-wide privilege: every token is
17
+ * bound to exactly one immutable object. `readRecovery` is the separate
18
+ * owner-encrypted recovery read intent.
19
+ */
20
+ export const SHARE_CONTENT_OPERATIONS = ['put', 'get', 'stat', 'delete', 'readRecovery'] as const;
21
+
22
+ export type ShareContentOperation = (typeof SHARE_CONTENT_OPERATIONS)[number];
23
+
24
+ /** Every key that may appear in the signed claims object. Unknown keys are rejected. */
25
+ export const SHARE_CONTENT_CLAIM_FIELDS = [
26
+ 'iss',
27
+ 'aud',
28
+ 'purpose',
29
+ 'namespace',
30
+ 'op',
31
+ 'shareId',
32
+ 'contentVersion',
33
+ 'ownerProfileId',
34
+ 'objectId',
35
+ 'operationId',
36
+ 'requestHash',
37
+ 'iat',
38
+ 'exp',
39
+ 'jti',
40
+ ] as const;
41
+
42
+ export type ShareContentClaimField = (typeof SHARE_CONTENT_CLAIM_FIELDS)[number];
43
+
44
+ /** Claims signed by Brain and re-validated by LearnCloud. */
45
+ export type ShareContentAuthorizationClaims = {
46
+ /** Issuer = holder = the allowlisted Brain service DID. */
47
+ iss: string;
48
+ /** LearnCloud service DID. */
49
+ aud: string;
50
+ purpose: typeof SHARE_CONTENT_AUTH_PURPOSE;
51
+ /** Service/network namespace (tenant or deployment scope). */
52
+ namespace: string;
53
+ op: ShareContentOperation;
54
+ /** 16-byte canonical base64url share id (22 chars). */
55
+ shareId: string;
56
+ contentVersion: number;
57
+ /** Opaque profile id. Existing ids are opaque strings, not necessarily UUIDs. */
58
+ ownerProfileId: string;
59
+ /** Immutable object identity for this exact content version. */
60
+ objectId: string;
61
+ /** Operation/idempotency identity. */
62
+ operationId: string;
63
+ /** Lowercase hex SHA-256 of the canonical full request body, incl. opaque recovery. */
64
+ requestHash: string;
65
+ iat: number;
66
+ exp: number;
67
+ /** CSPRNG nonce; single-use. */
68
+ jti: string;
69
+ };
70
+
71
+ /**
72
+ * The exact request context the token must be bound to. Callers build this from
73
+ * the validated route input; `requestHash` is produced with
74
+ * `computeShareContentRequestHash`.
75
+ */
76
+ export type ShareContentAuthorizationRequest = {
77
+ namespace: string;
78
+ op: ShareContentOperation;
79
+ shareId: string;
80
+ contentVersion: number;
81
+ ownerProfileId: string;
82
+ objectId: string;
83
+ operationId: string;
84
+ requestHash: string;
85
+ };
86
+
87
+ /** Minimal authenticated context returned on success. Never contains the raw token. */
88
+ export type ShareContentAuthorizationContext = {
89
+ signerDid: string;
90
+ /** Full `kid` verification method that signed the presentation. */
91
+ verificationMethod: string;
92
+ claims: ShareContentAuthorizationClaims;
93
+ };
94
+
95
+ /** Result shape of the existing DIDKit `verifyPresentation(..., { proofFormat: 'jwt' })`. */
96
+ export type PresentationVerificationResult = {
97
+ checks: string[];
98
+ warnings: string[];
99
+ errors: string[];
100
+ };
101
+
102
+ /** Injected DIDKit adapter. The module never resolves keys itself. */
103
+ export type PresentationVerifier = (token: string) => Promise<PresentationVerificationResult>;
104
+
105
+ /**
106
+ * Injected atomic replay store. `consumeOnce` must be a single atomic
107
+ * SET-if-absent (e.g. Redis `SET key value NX EX ttl`) and must throw when the
108
+ * backing store is unavailable so the verifier can fail closed.
109
+ */
110
+ export type ReplayStore = {
111
+ consumeOnce: (key: string, ttlSeconds: number) => Promise<boolean>;
112
+ };
113
+
114
+ /**
115
+ * Explicit trust configuration. An empty/disabled configuration denies all
116
+ * access (fail closed). There is deliberately no offline bypass flag.
117
+ *
118
+ * Key rotation assumptions (document-backed, not yet integration-tested):
119
+ * - A `did:web` identifier is stable across signing-seed rotation; the
120
+ * verification method fragment in the JWT `kid` is what changes.
121
+ * - Operators must add the new verification method to
122
+ * `allowedVerificationMethods` *before* the new key signs, and keep the old
123
+ * entry for at least `maxTokenTtlSeconds + 2 * clockSkewSeconds` after the
124
+ * last token signed by the old key.
125
+ * - did:web DID-document/resolver cache propagation must be validated in the
126
+ * integration batch; this module never resolves a DID itself.
127
+ */
128
+ export type ShareContentTrustConfig = {
129
+ enabled: boolean;
130
+ /** LearnCloud's own service DID. Must be non-empty when enabled. */
131
+ audience: string;
132
+ /** Exact allowed Brain service DIDs. A user DID must never be listed. */
133
+ allowedServiceDids: readonly string[];
134
+ /**
135
+ * Exact allowed verification methods (full `kid` values). Required: a DID
136
+ * alone does not authorize a key. Empty disables access.
137
+ */
138
+ allowedVerificationMethods: readonly string[];
139
+ /** Accepted JWS algorithms. Defaults to `EdDSA` only. */
140
+ allowedAlgorithms: readonly string[];
141
+ /** Bounded clock skew, seconds. */
142
+ clockSkewSeconds: number;
143
+ /** Maximum signed lifetime (`exp - iat`), seconds. Never above 60. */
144
+ maxTokenTtlSeconds: number;
145
+ /** Maximum raw token size in bytes. */
146
+ maxTokenBytes: number;
147
+ minJtiLength: number;
148
+ maxJtiLength: number;
149
+ };
150
+
151
+ /**
152
+ * Rejection reasons are stable, non-secret codes. Routes must map every one of
153
+ * them to a single generic external rejection; they must never be logged
154
+ * alongside token or claim payloads.
155
+ */
156
+ export type ShareContentAuthorizationRejectionReason =
157
+ | 'DISABLED'
158
+ | 'INVALID_REQUEST'
159
+ | 'TOKEN_TOO_LARGE'
160
+ | 'MALFORMED_TOKEN'
161
+ | 'UNSUPPORTED_HEADER'
162
+ | 'UNSUPPORTED_ALGORITHM'
163
+ | 'MISSING_KID'
164
+ | 'UNKNOWN_SIGNER'
165
+ | 'UNKNOWN_VERIFICATION_METHOD'
166
+ | 'SIGNATURE_INVALID'
167
+ | 'SIGNER_MISMATCH'
168
+ | 'MALFORMED_CLAIMS'
169
+ | 'UNKNOWN_CLAIM_FIELD'
170
+ | 'INVALID_CLAIMS'
171
+ | 'INVALID_SHARE_ID'
172
+ | 'INVALID_IDENTIFIER'
173
+ | 'INVALID_JTI'
174
+ | 'AUDIENCE_MISMATCH'
175
+ | 'PURPOSE_MISMATCH'
176
+ | 'OPERATION_NOT_ALLOWED'
177
+ | 'INVALID_TTL'
178
+ | 'TOKEN_EXPIRED'
179
+ | 'TOKEN_NOT_YET_VALID'
180
+ | 'REQUEST_BINDING_MISMATCH'
181
+ | 'REQUEST_HASH_MISMATCH'
182
+ | 'REPLAY_DETECTED'
183
+ | 'REPLAY_STORE_UNAVAILABLE';
184
+
185
+ export type ShareContentAuthorizationResult =
186
+ | { ok: true; context: ShareContentAuthorizationContext }
187
+ | { ok: false; reason: ShareContentAuthorizationRejectionReason };
188
+
189
+ export type ShareContentAuthorizationVerifier = {
190
+ config: ShareContentTrustConfig;
191
+ authorize: (
192
+ request: ShareContentAuthorizationRequest,
193
+ token: string
194
+ ) => Promise<ShareContentAuthorizationResult>;
195
+ };
@@ -0,0 +1,220 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import {
4
+ isCanonicalBase64Url,
5
+ isOpaqueIdentifier,
6
+ isPlainObject,
7
+ isSafeContentVersion,
8
+ isSha256Hex,
9
+ isShareContentOperation,
10
+ } from './canonical';
11
+ import {
12
+ SHARE_ID_BYTES,
13
+ extractSignedClaims,
14
+ parseShareContentAuthorizationClaims,
15
+ } from './claims';
16
+ import { isShareContentTrustConfigActive } from './config';
17
+ import { parseCompactJwt, validateShareContentJwtHeader } from './token';
18
+ import {
19
+ SHARE_CONTENT_AUTH_PURPOSE,
20
+ type PresentationVerificationResult,
21
+ type PresentationVerifier,
22
+ type ReplayStore,
23
+ type ShareContentAuthorizationContext,
24
+ type ShareContentAuthorizationRequest,
25
+ type ShareContentAuthorizationResult,
26
+ type ShareContentAuthorizationVerifier,
27
+ type ShareContentTrustConfig,
28
+ } from './types';
29
+
30
+ export type CreateShareContentAuthorizationVerifierDependencies = {
31
+ config: ShareContentTrustConfig;
32
+ /** Injected DIDKit presentation verification. Never called before allowlist filtering. */
33
+ verifyPresentation: PresentationVerifier;
34
+ /** Injected atomic single-use replay store. */
35
+ replayStore: ReplayStore;
36
+ /** Injected epoch-seconds clock. Defaults to `Date.now()`. */
37
+ now?: () => number;
38
+ };
39
+
40
+ const isValidAuthorizationRequest = (
41
+ request: unknown
42
+ ): request is ShareContentAuthorizationRequest => {
43
+ if (!isPlainObject(request)) return false;
44
+
45
+ const {
46
+ namespace,
47
+ op,
48
+ shareId,
49
+ contentVersion,
50
+ ownerProfileId,
51
+ objectId,
52
+ operationId,
53
+ requestHash,
54
+ } = request;
55
+
56
+ return (
57
+ isOpaqueIdentifier(namespace) &&
58
+ isShareContentOperation(op) &&
59
+ isCanonicalBase64Url(shareId, SHARE_ID_BYTES) &&
60
+ isSafeContentVersion(contentVersion) &&
61
+ isOpaqueIdentifier(ownerProfileId) &&
62
+ isOpaqueIdentifier(objectId) &&
63
+ isOpaqueIdentifier(operationId) &&
64
+ isSha256Hex(requestHash)
65
+ );
66
+ };
67
+
68
+ const isSignatureValid = (verification: PresentationVerificationResult | undefined): boolean =>
69
+ Boolean(
70
+ verification &&
71
+ Array.isArray(verification.errors) &&
72
+ Array.isArray(verification.checks) &&
73
+ verification.errors.length === 0 &&
74
+ verification.checks.includes('JWS')
75
+ );
76
+
77
+ /**
78
+ * Build an authorization verifier over a fixed trust configuration.
79
+ *
80
+ * The returned `authorize(request, token)` is the only public entry point. It:
81
+ * 1. denies everything when the configuration is empty/disabled;
82
+ * 2. validates the expected request context;
83
+ * 3. strictly parses the bounded JWT and applies the exact DID + key-method
84
+ * allowlist *before* calling the signature verifier;
85
+ * 4. cryptographically verifies, then re-binds kid → signer DID → `iss` →
86
+ * `vp.holder` → signed `claims.iss`;
87
+ * 5. validates purpose/audience/operation/time/nonce and exact request
88
+ * binding (including the canonical body hash);
89
+ * 6. atomically consumes the nonce for the full remaining acceptance window,
90
+ * failing closed if the replay store errors.
91
+ *
92
+ * A fresh signed token (new `jti`) may retry the same idempotent operation;
93
+ * request idempotency is a separate concern owned by the storage layer.
94
+ */
95
+ export const createShareContentAuthorizationVerifier = (
96
+ dependencies: CreateShareContentAuthorizationVerifierDependencies
97
+ ): ShareContentAuthorizationVerifier => {
98
+ const { config, verifyPresentation, replayStore } = dependencies;
99
+ const now = dependencies.now ?? (() => Math.floor(Date.now() / 1000));
100
+
101
+ const authorize = async (
102
+ request: ShareContentAuthorizationRequest,
103
+ token: string
104
+ ): Promise<ShareContentAuthorizationResult> => {
105
+ if (!isShareContentTrustConfigActive(config)) return { ok: false, reason: 'DISABLED' };
106
+ if (!isValidAuthorizationRequest(request)) return { ok: false, reason: 'INVALID_REQUEST' };
107
+
108
+ const parseResult = parseCompactJwt(token, config.maxTokenBytes);
109
+ if (!parseResult.ok) return parseResult;
110
+
111
+ const headerResult = validateShareContentJwtHeader(parseResult.parsed.header, config);
112
+ if (!headerResult.ok) return headerResult;
113
+
114
+ const { signerDid, verificationMethod } = headerResult.value;
115
+
116
+ let verification: PresentationVerificationResult;
117
+
118
+ try {
119
+ verification = await verifyPresentation(token);
120
+ } catch {
121
+ return { ok: false, reason: 'SIGNATURE_INVALID' };
122
+ }
123
+
124
+ if (!isSignatureValid(verification)) return { ok: false, reason: 'SIGNATURE_INVALID' };
125
+
126
+ const { payload } = parseResult.parsed;
127
+
128
+ // Recheck every identity claim against the key that actually signed,
129
+ // after cryptographic verification. Parsing alone never authenticates.
130
+ if (payload.iss !== signerDid) return { ok: false, reason: 'SIGNER_MISMATCH' };
131
+
132
+ const vp = payload.vp;
133
+ if (!isPlainObject(vp) || vp.holder !== signerDid) {
134
+ return { ok: false, reason: 'SIGNER_MISMATCH' };
135
+ }
136
+
137
+ const claimsResult = extractSignedClaims(payload);
138
+ if (!claimsResult.ok) return claimsResult;
139
+
140
+ const parsedClaims = parseShareContentAuthorizationClaims(claimsResult.raw, config);
141
+ if (!parsedClaims.ok) return parsedClaims;
142
+
143
+ const claims = parsedClaims.claims;
144
+
145
+ if (claims.iss !== signerDid) return { ok: false, reason: 'SIGNER_MISMATCH' };
146
+ if (claims.aud !== config.audience) return { ok: false, reason: 'AUDIENCE_MISMATCH' };
147
+ if (claims.purpose !== SHARE_CONTENT_AUTH_PURPOSE) {
148
+ return { ok: false, reason: 'PURPOSE_MISMATCH' };
149
+ }
150
+
151
+ const currentTime = now();
152
+ if (!Number.isSafeInteger(currentTime)) return { ok: false, reason: 'INVALID_TTL' };
153
+
154
+ const signedTtl = claims.exp - claims.iat;
155
+ if (signedTtl <= 0 || signedTtl > config.maxTokenTtlSeconds) {
156
+ return { ok: false, reason: 'INVALID_TTL' };
157
+ }
158
+
159
+ const { clockSkewSeconds } = config;
160
+
161
+ if (currentTime > claims.exp + clockSkewSeconds)
162
+ return { ok: false, reason: 'TOKEN_EXPIRED' };
163
+ if (claims.iat > currentTime + clockSkewSeconds) {
164
+ return { ok: false, reason: 'TOKEN_NOT_YET_VALID' };
165
+ }
166
+
167
+ // Exact expected context must match the signed claims before any replay
168
+ // consumption, so a mismatched request can never burn a valid nonce.
169
+ if (
170
+ claims.namespace !== request.namespace ||
171
+ claims.op !== request.op ||
172
+ claims.shareId !== request.shareId ||
173
+ claims.contentVersion !== request.contentVersion ||
174
+ claims.ownerProfileId !== request.ownerProfileId ||
175
+ claims.objectId !== request.objectId ||
176
+ claims.operationId !== request.operationId
177
+ ) {
178
+ return { ok: false, reason: 'REQUEST_BINDING_MISMATCH' };
179
+ }
180
+
181
+ if (claims.requestHash !== request.requestHash) {
182
+ return { ok: false, reason: 'REQUEST_HASH_MISMATCH' };
183
+ }
184
+
185
+ // Replay protection covers the whole acceptance window (expiry + skew).
186
+ // `iat` may itself be up to `skew` in the future, so the maximum
187
+ // derivable lifetime is maxTtl + 2 * skew.
188
+ const replayTtlSeconds = claims.exp + clockSkewSeconds - currentTime;
189
+ const maxReplayTtlSeconds = config.maxTokenTtlSeconds + 2 * clockSkewSeconds;
190
+
191
+ if (replayTtlSeconds <= 0 || replayTtlSeconds > maxReplayTtlSeconds) {
192
+ return { ok: false, reason: 'INVALID_TTL' };
193
+ }
194
+
195
+ const replayKey = `share-content-auth:jti:${createHash('sha256')
196
+ .update(`${signerDid}\u0000${claims.jti}`, 'utf8')
197
+ .digest('hex')}`;
198
+
199
+ let consumed: boolean;
200
+
201
+ try {
202
+ consumed = await replayStore.consumeOnce(replayKey, replayTtlSeconds);
203
+ } catch {
204
+ // Fail closed: an unavailable replay store must never widen access.
205
+ return { ok: false, reason: 'REPLAY_STORE_UNAVAILABLE' };
206
+ }
207
+
208
+ if (!consumed) return { ok: false, reason: 'REPLAY_DETECTED' };
209
+
210
+ const context: ShareContentAuthorizationContext = {
211
+ signerDid,
212
+ verificationMethod,
213
+ claims,
214
+ };
215
+
216
+ return { ok: true, context };
217
+ };
218
+
219
+ return { config, authorize };
220
+ };
@@ -0,0 +1,198 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ JWEValidator,
4
+ MAX_SHARE_CIPHERTEXT_BYTES,
5
+ MAX_SHARE_RECOVERY_JWE_BYTES,
6
+ ShareEnvelopeValidator,
7
+ ShareLinkIdValidator,
8
+ ShareOwnerRecoveryValidator,
9
+ decodedBase64UrlByteLength,
10
+ type ShareEnvelope,
11
+ type ShareOwnerRecovery,
12
+ utf8ByteLength,
13
+ } from '@learncard/types';
14
+
15
+ import {
16
+ computeShareContentRequestHash,
17
+ isOpaqueIdentifier,
18
+ isSafeContentVersion,
19
+ } from '@helpers/share-content-auth/canonical';
20
+
21
+ /**
22
+ * LC-2187 dedicated LearnCloud `share_content` collection.
23
+ *
24
+ * This module defines the *storage* grammar for an immutable encrypted share
25
+ * object plus its permanent deletion tombstone. It is deliberately separate
26
+ * from the recipient manifest owners in `@learncard/types`: it never models
27
+ * plaintext, source URIs or keys, only opaque bindings and the already-encrypted
28
+ * envelope/recovery bytes.
29
+ *
30
+ * Security invariants encoded here:
31
+ * - The immutable identity is `(namespace, objectId)`. Every other binding
32
+ * (`ownerProfileId`, `shareId`, `contentVersion`, `operationId`) is part of
33
+ * the exact tuple and must match on every read/mutation.
34
+ * - `contentHash` is *derived* by {@link computeShareContentHash} over the full
35
+ * validated immutable record (all bindings plus the ciphertext envelope and
36
+ * the owner-encrypted recovery). A caller-supplied hash is never accepted, so
37
+ * a forged hash cannot mask divergent bytes.
38
+ * - The recovery projection is deliberately owner-only at the route layer. The
39
+ * stored recovery JWE is strict at the top level: unknown metadata/URL fields
40
+ * are rejected, not persisted.
41
+ * - Bound sizes: decoded ciphertext (tag included) is at most
42
+ * {@link MAX_SHARE_CIPHERTEXT_BYTES}; the serialized recovery JWE is at most
43
+ * {@link MAX_SHARE_RECOVERY_JWE_BYTES}.
44
+ */
45
+
46
+ /** MongoDB collection name for immutable encrypted share content. */
47
+ export const SHARE_CONTENT_COLLECTION = 'share_content';
48
+
49
+ const SHARE_CONTENT_HASH_PATTERN = /^[0-9a-f]{64}$/;
50
+
51
+ /** Opaque, URL-safe binding identifier. Deliberately not a UUID-only check. */
52
+ const opaqueIdentifier = (label: string, maxLength = 128) =>
53
+ z.string().refine(value => isOpaqueIdentifier(value, maxLength), {
54
+ message: `${label} must be an opaque URL-safe identifier of 1..${maxLength} characters`,
55
+ });
56
+
57
+ /** Service/network/tenant namespace. Opaque; not a URL or host. */
58
+ export const ShareContentNamespaceValidator = opaqueIdentifier('namespace');
59
+ export type ShareContentNamespace = z.infer<typeof ShareContentNamespaceValidator>;
60
+
61
+ /** Opaque owner profile id (existing ids are opaque strings, not necessarily UUIDs). */
62
+ export const ShareContentOwnerProfileIdValidator = opaqueIdentifier('ownerProfileId');
63
+ export type ShareContentOwnerProfileId = z.infer<typeof ShareContentOwnerProfileIdValidator>;
64
+
65
+ /** Opaque immutable object id reserved by Brain. Never a URI. */
66
+ export const ShareContentObjectIdValidator = opaqueIdentifier('objectId');
67
+ export type ShareContentObjectId = z.infer<typeof ShareContentObjectIdValidator>;
68
+
69
+ /** Opaque operation/idempotency id that owns the immutable object. */
70
+ export const ShareContentOperationIdValidator = opaqueIdentifier('operationId');
71
+ export type ShareContentOperationId = z.infer<typeof ShareContentOperationIdValidator>;
72
+
73
+ /** Positive safe-integer content version (`1..2^31-1`). */
74
+ export const ShareContentVersionValidator = z
75
+ .number()
76
+ .refine(isSafeContentVersion, { message: 'contentVersion must be a safe integer >= 1' });
77
+ export type ShareContentContentVersion = z.infer<typeof ShareContentVersionValidator>;
78
+
79
+ /** Lowercase hex SHA-256 of the derived immutable record. */
80
+ export const ShareContentHashValidator = z
81
+ .string()
82
+ .regex(SHARE_CONTENT_HASH_PATTERN, { message: 'contentHash must be lowercase hex sha256' });
83
+ export type ShareContentHash = z.infer<typeof ShareContentHashValidator>;
84
+
85
+ /**
86
+ * Strict top-level owner recovery JWE. Wraps the shared
87
+ * {@link ShareOwnerRecoveryValidator} (which enforces the serialized 64 KiB cap)
88
+ * and additionally rejects unknown top-level keys so arbitrary metadata or URLs
89
+ * can never be persisted alongside the opaque recovery.
90
+ */
91
+ export const ShareContentOwnerRecoveryValidator = JWEValidator.strict().refine(
92
+ value => ShareOwnerRecoveryValidator.safeParse(value).success,
93
+ { message: 'must be a valid bounded owner-encrypted recovery JWE' }
94
+ );
95
+ export type ShareContentOwnerRecovery = z.infer<typeof ShareContentOwnerRecoveryValidator>;
96
+
97
+ /**
98
+ * The exact, immutable object binding. The repository applies this to every
99
+ * operation; a mutation of any single field fails closed.
100
+ */
101
+ export const ShareContentObjectBindingValidator = z
102
+ .object({
103
+ namespace: ShareContentNamespaceValidator,
104
+ ownerProfileId: ShareContentOwnerProfileIdValidator,
105
+ shareId: ShareLinkIdValidator,
106
+ contentVersion: ShareContentVersionValidator,
107
+ objectId: ShareContentObjectIdValidator,
108
+ operationId: ShareContentOperationIdValidator,
109
+ })
110
+ .strict();
111
+ export type ShareContentObjectBinding = z.infer<typeof ShareContentObjectBindingValidator>;
112
+
113
+ /**
114
+ * A create-only write. `contentHash` is intentionally absent: the repository
115
+ * derives it, so a caller cannot supply (and therefore cannot forge) it.
116
+ */
117
+ export const ShareContentPutInputValidator = ShareContentObjectBindingValidator.extend({
118
+ envelope: ShareEnvelopeValidator,
119
+ ownerEncryptedRecovery: ShareContentOwnerRecoveryValidator,
120
+ }).strict();
121
+ export type ShareContentPutInput = z.infer<typeof ShareContentPutInputValidator>;
122
+
123
+ /**
124
+ * Derive the content hash over the complete immutable record: all bindings plus
125
+ * the validated ciphertext envelope and the owner-encrypted recovery. The C1
126
+ * canonical serializer makes the result deterministic and rejects unsupported
127
+ * values instead of coercing them.
128
+ */
129
+ export const computeShareContentHash = (input: ShareContentPutInput): string =>
130
+ computeShareContentRequestHash({
131
+ namespace: input.namespace,
132
+ ownerProfileId: input.ownerProfileId,
133
+ shareId: input.shareId,
134
+ contentVersion: input.contentVersion,
135
+ objectId: input.objectId,
136
+ operationId: input.operationId,
137
+ envelope: input.envelope,
138
+ ownerEncryptedRecovery: input.ownerEncryptedRecovery,
139
+ });
140
+
141
+ /** Hash known before Brain allocates object/operation IDs; reservation binding. */
142
+ export const computeSharePayloadHash = (
143
+ input: Pick<ShareContentPutInput, 'envelope' | 'ownerEncryptedRecovery'>
144
+ ): string =>
145
+ computeShareContentRequestHash({
146
+ envelope: input.envelope,
147
+ ownerEncryptedRecovery: input.ownerEncryptedRecovery,
148
+ });
149
+
150
+ /**
151
+ * Measure the encoded payload for reconciliation/stats. Assumes `input` already
152
+ * passed {@link ShareContentPutInputValidator}.
153
+ */
154
+ export const measureShareContentPayload = (
155
+ input: Pick<ShareContentPutInput, 'envelope' | 'ownerEncryptedRecovery'>
156
+ ): { ciphertextBytes: number; recoveryBytes: number } => {
157
+ const ciphertextBytes = decodedBase64UrlByteLength(input.envelope.ct);
158
+
159
+ if (ciphertextBytes === null) {
160
+ throw new Error('validated envelope ciphertext must be canonical base64url');
161
+ }
162
+
163
+ return {
164
+ ciphertextBytes,
165
+ recoveryBytes: utf8ByteLength(JSON.stringify(input.ownerEncryptedRecovery)),
166
+ };
167
+ };
168
+
169
+ /** Immutable active document. */
170
+ export const MongoShareContentActiveValidator = ShareContentObjectBindingValidator.extend({
171
+ kind: z.literal('active'),
172
+ contentHash: ShareContentHashValidator,
173
+ payloadHash: ShareContentHashValidator,
174
+ envelope: ShareEnvelopeValidator,
175
+ ownerEncryptedRecovery: ShareContentOwnerRecoveryValidator,
176
+ ciphertextBytes: z.number().int().min(16).max(MAX_SHARE_CIPHERTEXT_BYTES),
177
+ recoveryBytes: z.number().int().min(1).max(MAX_SHARE_RECOVERY_JWE_BYTES),
178
+ createdAt: z.date(),
179
+ }).strict();
180
+ export type MongoShareContentActive = z.infer<typeof MongoShareContentActiveValidator>;
181
+
182
+ /**
183
+ * Permanent deletion tombstone. Retains the binding tuple and, where known, the
184
+ * derived hash; never retains ciphertext or recovery. A tombstone blocks every
185
+ * later put for the same `(namespace, objectId)` identity.
186
+ */
187
+ export const MongoShareContentTombstoneValidator = ShareContentObjectBindingValidator.extend({
188
+ kind: z.literal('tombstone'),
189
+ contentHash: ShareContentHashValidator.optional(),
190
+ deletedAt: z.date(),
191
+ }).strict();
192
+ export type MongoShareContentTombstone = z.infer<typeof MongoShareContentTombstoneValidator>;
193
+
194
+ /** Stored document union. */
195
+ export type MongoShareContentDocument = MongoShareContentActive | MongoShareContentTombstone;
196
+
197
+ /** Raw ciphertext envelope carried by an active object. */
198
+ export type { ShareEnvelope, ShareOwnerRecovery };
@@ -1,4 +1,5 @@
1
1
  export * from './Credential';
2
2
  export * from './CredentialRecord';
3
3
  export * from './CustomDocument';
4
+ export * from './ShareContent';
4
5
  export * from './User';