@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,199 @@
1
+ import {
2
+ isShareContentTrustConfigActive,
3
+ resolveShareContentTrustConfig,
4
+ type ShareContentTrustConfig,
5
+ } from '@helpers/share-content-auth';
6
+
7
+ import { SHARE_CONTENT_MAX_REQUEST_BYTES, type ShareContentPluginOptions } from './types';
8
+
9
+ /**
10
+ * Fail-closed runtime configuration for the LC-2187 service-only routes.
11
+ *
12
+ * There is deliberately no offline or development bypass: when the configuration
13
+ * is absent, disabled, incomplete or internally inconsistent the resolver
14
+ * returns `disabled`/`invalid` and the routes stay unregistered. Namespace
15
+ * authorization is an explicit per-service binding so a valid service token can
16
+ * never name an arbitrary namespace.
17
+ */
18
+
19
+ export type ShareContentNamespacePolicy = {
20
+ /** True only when this exact allowlisted service may use this exact namespace. */
21
+ isAllowed: (serviceDid: string, namespace: string) => boolean;
22
+ /** Configured namespaces for one service (diagnostics/tests only). */
23
+ allowedNamespacesFor: (serviceDid: string) => readonly string[];
24
+ };
25
+
26
+ export type ResolvedShareContentConfig =
27
+ | { status: 'disabled' }
28
+ | { status: 'invalid'; errors: readonly string[] }
29
+ | {
30
+ status: 'enabled';
31
+ trustConfig: ShareContentTrustConfig;
32
+ namespacePolicy: ShareContentNamespacePolicy;
33
+ };
34
+
35
+ const toStringList = (value: unknown): string[] | null => {
36
+ if (typeof value === 'string') {
37
+ return value
38
+ .split(',')
39
+ .map(part => part.trim())
40
+ .filter(part => part.length > 0);
41
+ }
42
+
43
+ if (Array.isArray(value)) {
44
+ if (!value.every(item => typeof item === 'string')) return null;
45
+
46
+ return value.map(item => item.trim()).filter(item => item.length > 0);
47
+ }
48
+
49
+ return null;
50
+ };
51
+
52
+ /**
53
+ * Parse the per-service namespace bindings. Accepts an object map, or a JSON
54
+ * string of one (deployment manifests cannot express nested maps). Every entry
55
+ * must be a non-empty list of non-empty namespace identifiers.
56
+ */
57
+ export const parseShareContentNamespaceBindings = (
58
+ value: unknown
59
+ ): Map<string, readonly string[]> | null => {
60
+ let source: unknown = value;
61
+
62
+ if (typeof value === 'string') {
63
+ try {
64
+ source = JSON.parse(value);
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ if (typeof source !== 'object' || source === null || Array.isArray(source)) return null;
71
+
72
+ const bindings = new Map<string, readonly string[]>();
73
+
74
+ for (const [serviceDid, namespacesValue] of Object.entries(source as Record<string, unknown>)) {
75
+ if (serviceDid.trim().length === 0) return null;
76
+
77
+ const namespaces = toStringList(namespacesValue);
78
+
79
+ if (!namespaces || namespaces.length === 0) return null;
80
+
81
+ bindings.set(serviceDid.trim(), namespaces);
82
+ }
83
+
84
+ return bindings.size > 0 ? bindings : null;
85
+ };
86
+
87
+ const buildNamespacePolicy = (
88
+ bindings: Map<string, readonly string[]>
89
+ ): ShareContentNamespacePolicy => {
90
+ const frozen = new Map<string, readonly string[]>();
91
+
92
+ for (const [serviceDid, namespaces] of bindings) {
93
+ frozen.set(serviceDid, Object.freeze([...namespaces]));
94
+ }
95
+
96
+ return {
97
+ isAllowed: (serviceDid, namespace) => frozen.get(serviceDid)?.includes(namespace) ?? false,
98
+ allowedNamespacesFor: serviceDid => frozen.get(serviceDid) ?? [],
99
+ };
100
+ };
101
+
102
+ /**
103
+ * Resolve the explicit configuration. An empty allowlist, empty verification
104
+ * methods, or missing namespace bindings all fail closed. A
105
+ * partially supplied configuration is reported as `invalid`; the caller
106
+ * must then refuse to register routes (startup fails explicitly rather than
107
+ * silently widening access).
108
+ */
109
+ export const resolveShareContentConfig = (raw: unknown): ResolvedShareContentConfig => {
110
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
111
+ return { status: 'disabled' };
112
+ }
113
+
114
+ const source = raw as Record<string, unknown>;
115
+
116
+ if (
117
+ !['audience', 'serviceDids', 'verificationMethods', 'namespaceBindings'].some(
118
+ key => source[key] !== undefined && source[key] !== null && source[key] !== ''
119
+ )
120
+ )
121
+ return { status: 'disabled' };
122
+
123
+ const errors: string[] = [];
124
+
125
+ const audience = typeof source.audience === 'string' ? source.audience.trim() : '';
126
+ if (audience.length === 0) errors.push('audience is required');
127
+
128
+ const serviceDids = toStringList(source.serviceDids) ?? [];
129
+ if (serviceDids.length === 0) errors.push('serviceDids must list at least one service DID');
130
+
131
+ const verificationMethods = toStringList(source.verificationMethods) ?? [];
132
+ if (verificationMethods.length === 0) {
133
+ errors.push('verificationMethods must list at least one exact verification method');
134
+ }
135
+
136
+ const bindings = parseShareContentNamespaceBindings(source.namespaceBindings);
137
+ if (!bindings) {
138
+ errors.push('namespaceBindings must map every service DID to at least one namespace');
139
+ }
140
+
141
+ if (bindings) {
142
+ for (const serviceDid of serviceDids) {
143
+ if (!bindings.has(serviceDid)) {
144
+ errors.push(`namespaceBindings is missing an entry for ${serviceDid}`);
145
+ }
146
+ }
147
+
148
+ for (const serviceDid of bindings.keys()) {
149
+ if (!serviceDids.includes(serviceDid)) {
150
+ errors.push(`namespaceBindings references a non-allowlisted service ${serviceDid}`);
151
+ }
152
+ }
153
+ }
154
+
155
+ if (errors.length > 0) return { status: 'invalid', errors };
156
+
157
+ // Reuse the C1 resolver for the cryptographic trust config. Passing a value
158
+ // that C1 would silently disable is an explicit invalid configuration here.
159
+ const trustConfig = resolveShareContentTrustConfig({ ...source, enabled: true });
160
+
161
+ if (!isShareContentTrustConfigActive(trustConfig)) {
162
+ return {
163
+ status: 'invalid',
164
+ errors: ['resolved trust configuration is not active'],
165
+ };
166
+ }
167
+
168
+ return {
169
+ status: 'enabled',
170
+ trustConfig,
171
+ namespacePolicy: buildNamespacePolicy(bindings as Map<string, readonly string[]>),
172
+ };
173
+ };
174
+
175
+ /**
176
+ * Build the exact raw object the resolver expects from an already-parsed
177
+ * LearnCloud environment. Kept tiny so entrypoints cannot drift from each other.
178
+ */
179
+ export const getShareContentRawConfig = (environment: {
180
+ SHARE_CONTENT_AUDIENCE?: string;
181
+ SHARE_CONTENT_SERVICE_DIDS?: string;
182
+ SHARE_CONTENT_VERIFICATION_METHODS?: string;
183
+ SHARE_CONTENT_NAMESPACE_BINDINGS?: string;
184
+ }): Record<string, unknown> => ({
185
+ audience: environment.SHARE_CONTENT_AUDIENCE,
186
+ serviceDids: environment.SHARE_CONTENT_SERVICE_DIDS,
187
+ verificationMethods: environment.SHARE_CONTENT_VERIFICATION_METHODS,
188
+ namespaceBindings: environment.SHARE_CONTENT_NAMESPACE_BINDINGS,
189
+ });
190
+
191
+ /** Build the plugin options from a resolved, enabled configuration. */
192
+ export const toShareContentPluginOptions = (
193
+ config: Extract<ResolvedShareContentConfig, { status: 'enabled' }>,
194
+ dependencies: Pick<ShareContentPluginOptions, 'verifier' | 'repository'>
195
+ ): ShareContentPluginOptions => ({
196
+ ...dependencies,
197
+ namespacePolicy: config.namespacePolicy,
198
+ maxRequestBytes: SHARE_CONTENT_MAX_REQUEST_BYTES,
199
+ });
@@ -0,0 +1,5 @@
1
+ export * from './types';
2
+ export * from './config';
3
+ export * from './plugin';
4
+ export * from './runtime';
5
+ export * from './production';
@@ -0,0 +1,317 @@
1
+ import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
2
+ import {
3
+ MAX_SHARE_CIPHERTEXT_BYTES,
4
+ MAX_SHARE_RECOVERY_JWE_BYTES,
5
+ decodedBase64UrlByteLength,
6
+ utf8ByteLength,
7
+ } from '@learncard/types';
8
+
9
+ import {
10
+ ShareContentObjectBindingValidator,
11
+ ShareContentPutInputValidator,
12
+ type ShareContentObjectBinding,
13
+ } from '@models';
14
+ import { computeShareContentRequestHash, isPlainObject } from '@helpers/share-content-auth';
15
+
16
+ import {
17
+ SHARE_CONTENT_MAX_REQUEST_BYTES,
18
+ type ShareContentErrorCode,
19
+ type ShareContentPluginOptions,
20
+ type ShareContentRouteOperation,
21
+ } from './types';
22
+
23
+ /**
24
+ * Fixed LC-2187 service HTTP contract over the reviewed C1 verifier and C2
25
+ * repository.
26
+ *
27
+ * Invariants enforced here:
28
+ * - The operation is selected by the server route, never by the body; a body
29
+ * carrying an `op` field fails the strict tuple schema.
30
+ * - The request hash is the C1 canonical SHA-256 of the entire validated request
31
+ * body (including the opaque envelope and recovery on `put`), bound to the
32
+ * token claims. A caller-supplied hash is never accepted as authority.
33
+ * - Authentication precedes every repository operation. Parsing is not
34
+ * authentication.
35
+ * - The repository result is mapped to a small fixed error vocabulary; raw
36
+ * errors, tokens, bodies and ciphertext are never returned or logged.
37
+ * - Exact-object `get` never reads recovery; `readRecovery` is a separate route
38
+ * that requires its own signed intent.
39
+ */
40
+
41
+ const BEARER_AUTHORIZATION_RE = /^Bearer ([A-Za-z0-9._-]+)$/;
42
+
43
+ const sendError = (reply: FastifyReply, status: number, error: ShareContentErrorCode) =>
44
+ reply.status(status).send({ ok: false, error });
45
+
46
+ type ExtractedToken = { ok: true; token: string } | { ok: false };
47
+
48
+ /**
49
+ * Bound and extract the bearer token before any parsing or verification. The
50
+ * accepted header is deliberately tiny and the C1 config's `maxTokenBytes` is
51
+ * the hard ceiling, so a caller cannot widen the accepted transport size.
52
+ */
53
+ const extractBearerToken = (header: unknown, maxTokenBytes: number): ExtractedToken => {
54
+ if (typeof header !== 'string') return { ok: false };
55
+ if (header.length > maxTokenBytes + 'Bearer '.length) return { ok: false };
56
+ if (Buffer.byteLength(header, 'utf8') > maxTokenBytes + 'Bearer '.length) return { ok: false };
57
+
58
+ const match = BEARER_AUTHORIZATION_RE.exec(header);
59
+
60
+ if (!match) return { ok: false };
61
+
62
+ return { ok: true, token: match[1] as string };
63
+ };
64
+
65
+ /**
66
+ * Fast, allocation-light size check that runs before strict schema validation so
67
+ * an over-limit decoded ciphertext or serialized recovery is reported as a size
68
+ * failure (413) rather than a generic shape failure (400). Legacy malformed
69
+ * spellings still fall through to the schema.
70
+ */
71
+ const exceedsPayloadBounds = (body: unknown): boolean => {
72
+ if (!isPlainObject(body)) return false;
73
+
74
+ const envelope = body.envelope;
75
+
76
+ if (isPlainObject(envelope) && typeof envelope.ct === 'string') {
77
+ if (envelope.ct.length > Math.ceil((MAX_SHARE_CIPHERTEXT_BYTES * 4) / 3)) return true;
78
+
79
+ const decodedBytes = decodedBase64UrlByteLength(envelope.ct);
80
+
81
+ if (decodedBytes !== null && decodedBytes > MAX_SHARE_CIPHERTEXT_BYTES) return true;
82
+ }
83
+
84
+ const recovery = body.ownerEncryptedRecovery;
85
+
86
+ if (isPlainObject(recovery)) {
87
+ if (utf8ByteLength(JSON.stringify(recovery)) > MAX_SHARE_RECOVERY_JWE_BYTES) return true;
88
+ }
89
+
90
+ return false;
91
+ };
92
+
93
+ const mapRepositoryError = (reply: FastifyReply, error: string | undefined) => {
94
+ switch (error) {
95
+ case 'INVALID_INPUT':
96
+ return sendError(reply, 400, 'INVALID_INPUT');
97
+ case 'NOT_FOUND':
98
+ return sendError(reply, 404, 'NOT_FOUND');
99
+ case 'TOMBSTONED':
100
+ case 'BINDING_MISMATCH':
101
+ case 'CONFLICT':
102
+ return sendError(reply, 409, 'CONFLICT');
103
+ case 'STORE_ERROR':
104
+ default:
105
+ return sendError(reply, 503, 'UNAVAILABLE');
106
+ }
107
+ };
108
+
109
+ /**
110
+ * Verify authorization and the per-service namespace policy. Returns `true` only
111
+ * when the caller may proceed to the repository. Every failure is a generic,
112
+ * non-distinguishing rejection; a dependency outage (unavailable replay store)
113
+ * is reported as `UNAVAILABLE` and still never reaches the repository.
114
+ */
115
+ const authorize = async (
116
+ operation: ShareContentRouteOperation,
117
+ binding: ShareContentObjectBinding,
118
+ validatedBody: unknown,
119
+ authorizationHeader: unknown,
120
+ options: ShareContentPluginOptions,
121
+ reply: FastifyReply
122
+ ): Promise<boolean> => {
123
+ const token = extractBearerToken(authorizationHeader, options.verifier.config.maxTokenBytes);
124
+
125
+ if (!token.ok) {
126
+ sendError(reply, 401, 'UNAUTHORIZED');
127
+
128
+ return false;
129
+ }
130
+
131
+ const requestHash = computeShareContentRequestHash(validatedBody);
132
+
133
+ const result = await options.verifier.authorize(
134
+ {
135
+ namespace: binding.namespace,
136
+ op: operation,
137
+ shareId: binding.shareId,
138
+ contentVersion: binding.contentVersion,
139
+ ownerProfileId: binding.ownerProfileId,
140
+ objectId: binding.objectId,
141
+ operationId: binding.operationId,
142
+ requestHash,
143
+ },
144
+ token.token
145
+ );
146
+
147
+ if (!result.ok) {
148
+ if (result.reason === 'REPLAY_STORE_UNAVAILABLE') {
149
+ sendError(reply, 503, 'UNAVAILABLE');
150
+ } else {
151
+ sendError(reply, 401, 'UNAUTHORIZED');
152
+ }
153
+
154
+ return false;
155
+ }
156
+
157
+ if (!options.namespacePolicy.isAllowed(result.context.signerDid, binding.namespace)) {
158
+ sendError(reply, 401, 'UNAUTHORIZED');
159
+
160
+ return false;
161
+ }
162
+
163
+ return true;
164
+ };
165
+
166
+ const toBinding = (body: ShareContentObjectBinding): ShareContentObjectBinding => ({
167
+ namespace: body.namespace,
168
+ ownerProfileId: body.ownerProfileId,
169
+ shareId: body.shareId,
170
+ contentVersion: body.contentVersion,
171
+ objectId: body.objectId,
172
+ operationId: body.operationId,
173
+ });
174
+
175
+ const handlePut = async (
176
+ request: FastifyRequest,
177
+ reply: FastifyReply,
178
+ options: ShareContentPluginOptions
179
+ ) => {
180
+ if (exceedsPayloadBounds(request.body)) return sendError(reply, 413, 'PAYLOAD_TOO_LARGE');
181
+
182
+ const parsed = ShareContentPutInputValidator.safeParse(request.body);
183
+
184
+ if (!parsed.success) return sendError(reply, 400, 'INVALID_INPUT');
185
+
186
+ const body = parsed.data;
187
+
188
+ if (
189
+ !(await authorize(
190
+ 'put',
191
+ toBinding(body),
192
+ body,
193
+ request.headers.authorization,
194
+ options,
195
+ reply
196
+ ))
197
+ ) {
198
+ return reply;
199
+ }
200
+
201
+ try {
202
+ const result = await options.repository.put(body);
203
+
204
+ if (!result.ok) return mapRepositoryError(reply, result.error);
205
+
206
+ return reply.status(200).send({ ok: true, value: result.value });
207
+ } catch {
208
+ return sendError(reply, 503, 'UNAVAILABLE');
209
+ }
210
+ };
211
+
212
+ const handleExactObject = async (
213
+ operation: Exclude<ShareContentRouteOperation, 'put'>,
214
+ request: FastifyRequest,
215
+ reply: FastifyReply,
216
+ options: ShareContentPluginOptions
217
+ ) => {
218
+ const parsed = ShareContentObjectBindingValidator.safeParse(request.body);
219
+
220
+ if (!parsed.success) return sendError(reply, 400, 'INVALID_INPUT');
221
+
222
+ const binding = parsed.data;
223
+
224
+ if (
225
+ !(await authorize(
226
+ operation,
227
+ binding,
228
+ binding,
229
+ request.headers.authorization,
230
+ options,
231
+ reply
232
+ ))
233
+ ) {
234
+ return reply;
235
+ }
236
+
237
+ try {
238
+ const result =
239
+ operation === 'get'
240
+ ? await options.repository.getContent(binding)
241
+ : operation === 'stat'
242
+ ? await options.repository.stat(binding)
243
+ : operation === 'delete'
244
+ ? await options.repository.delete(binding)
245
+ : await options.repository.getRecovery(binding);
246
+
247
+ if (!result.ok) return mapRepositoryError(reply, result.error);
248
+
249
+ return reply.status(200).send({ ok: true, value: result.value });
250
+ } catch {
251
+ return sendError(reply, 503, 'UNAVAILABLE');
252
+ }
253
+ };
254
+
255
+ /**
256
+ * The dedicated Fastify plugin. It reads no environment and opens no
257
+ * connections: the startup wrapper injects the verifier, the initialized
258
+ * repository and the namespace policy, and only registers the plugin when the
259
+ * configuration is valid and enabled.
260
+ */
261
+ export const shareContentFastifyPlugin: FastifyPluginAsync<ShareContentPluginOptions> = async (
262
+ fastify: FastifyInstance,
263
+ options: ShareContentPluginOptions
264
+ ) => {
265
+ const bodyLimit = Math.max(
266
+ 1,
267
+ Math.min(
268
+ options.maxRequestBytes ?? SHARE_CONTENT_MAX_REQUEST_BYTES,
269
+ SHARE_CONTENT_MAX_REQUEST_BYTES
270
+ )
271
+ );
272
+
273
+ fastify.addHook('onSend', async (_request, reply) => {
274
+ reply.header('Cache-Control', 'no-store');
275
+ });
276
+
277
+ // Route-scoped sanitized error handling. Parser errors (too large, malformed
278
+ // JSON, unsupported content type) never leak a raw message, and unknown
279
+ // failures collapse to the same non-disclosing `UNAVAILABLE` shape.
280
+ fastify.setErrorHandler((error, _request, reply) => {
281
+ const code =
282
+ typeof error === 'object' && error !== null
283
+ ? (error as { code?: unknown }).code
284
+ : undefined;
285
+
286
+ if (code === 'FST_ERR_CTP_BODY_TOO_LARGE') {
287
+ return sendError(reply, 413, 'PAYLOAD_TOO_LARGE');
288
+ }
289
+
290
+ if (
291
+ code === 'FST_ERR_CTP_INVALID_MEDIA_TYPE' ||
292
+ code === 'FST_ERR_CTP_INVALID_JSON' ||
293
+ code === 'FST_ERR_CTP_INVALID_JSON_BODY' ||
294
+ code === 'FST_ERR_CTP_EMPTY_JSON_BODY'
295
+ ) {
296
+ return sendError(reply, 400, 'INVALID_INPUT');
297
+ }
298
+
299
+ return sendError(reply, 503, 'UNAVAILABLE');
300
+ });
301
+
302
+ fastify.post('/put', { bodyLimit }, (request, reply) => handlePut(request, reply, options));
303
+ fastify.post('/get', { bodyLimit }, (request, reply) =>
304
+ handleExactObject('get', request, reply, options)
305
+ );
306
+ fastify.post('/stat', { bodyLimit }, (request, reply) =>
307
+ handleExactObject('stat', request, reply, options)
308
+ );
309
+ fastify.post('/delete', { bodyLimit }, (request, reply) =>
310
+ handleExactObject('delete', request, reply, options)
311
+ );
312
+ fastify.post('/readRecovery', { bodyLimit }, (request, reply) =>
313
+ handleExactObject('readRecovery', request, reply, options)
314
+ );
315
+ };
316
+
317
+ export default shareContentFastifyPlugin;
@@ -0,0 +1,107 @@
1
+ import Redis from 'ioredis';
2
+
3
+ import { environment } from '@environment';
4
+ import {
5
+ createShareContentRepository,
6
+ getShareContentCollection,
7
+ type ShareContentRepository,
8
+ } from '@accesslayer/share-content';
9
+ import { createSetIfAbsentReplayStore, type ReplayStore } from '@helpers/share-content-auth';
10
+ import { getEmptyLearnCard } from '@helpers/learnCard.helpers';
11
+
12
+ import { getShareContentRawConfig, resolveShareContentConfig } from './config';
13
+ import { buildShareContentRuntime, type ShareContentRuntime } from './runtime';
14
+
15
+ /**
16
+ * Production wiring for the LC-2187 service routes. Kept separate from the pure
17
+ * `runtime.ts` builder so the transport logic stays testable without the
18
+ * environment/Mongo connection singletons.
19
+ *
20
+ * The replay store is a real ioredis `SET key 1 EX ttl NX`. There is
21
+ * deliberately no `cache.node` / `ioredis-mock` fallback here: an absent or
22
+ * unreachable Redis must fail closed, not silently accept every nonce.
23
+ */
24
+
25
+ export type ShareContentReplayStoreHandle = {
26
+ store: ReplayStore;
27
+ close: () => Promise<void>;
28
+ };
29
+
30
+ export const createShareContentRedisReplayStore = (options: {
31
+ host: string;
32
+ port: number;
33
+ }): ShareContentReplayStoreHandle => {
34
+ const client = new Redis({
35
+ host: options.host,
36
+ port: options.port,
37
+ connectTimeout: 5_000,
38
+ maxRetriesPerRequest: 1,
39
+ enableReadyCheck: true,
40
+ });
41
+
42
+ // Operational errors surface through the rejected command below. Never log
43
+ // the endpoint, command or payload here.
44
+ client.on('error', () => {});
45
+
46
+ const store = createSetIfAbsentReplayStore(async (key, ttlSeconds) => {
47
+ const result = await client.set(key, '1', 'EX', ttlSeconds, 'NX');
48
+
49
+ // Explicit response handling: `OK` = newly created (allowed), `null` =
50
+ // already present (replay), anything else is an unsafe surprise.
51
+ if (result === 'OK') return true;
52
+ if (result === null) return false;
53
+
54
+ throw new Error('unexpected Redis SET NX response');
55
+ });
56
+
57
+ return {
58
+ store,
59
+ close: async () => {
60
+ try {
61
+ await client.quit();
62
+ } catch {
63
+ client.disconnect();
64
+ }
65
+ },
66
+ };
67
+ };
68
+
69
+ export const createMongoShareContentRepository = async (): Promise<ShareContentRepository> => {
70
+ const { mongodb } = await import('@mongo');
71
+
72
+ return createShareContentRepository(getShareContentCollection(mongodb));
73
+ };
74
+
75
+ const requireRedisEndpoint = (): { host: string; port: number } => {
76
+ const { REDIS_HOST: host, REDIS_PORT: port } = environment;
77
+
78
+ if (!host || !port) {
79
+ throw new Error(
80
+ 'LC-2187 share-content is enabled but REDIS_HOST/REDIS_PORT are not configured'
81
+ );
82
+ }
83
+
84
+ return { host, port };
85
+ };
86
+
87
+ export const createShareContentRuntimeFromEnvironment = async (): Promise<ShareContentRuntime> =>
88
+ buildShareContentRuntime(getShareContentRawConfig(environment), {
89
+ getLearnCard: () => getEmptyLearnCard(),
90
+ createReplayStore: () => createShareContentRedisReplayStore(requireRedisEndpoint()),
91
+ getRepository: createMongoShareContentRepository,
92
+ });
93
+
94
+ /**
95
+ * Synchronous configuration gate for entrypoints that must fail fast before the
96
+ * first request (Lambda cold start). Reads no connections and never weakens a
97
+ * check.
98
+ */
99
+ export const assertShareContentEnvironmentConfiguration = (): void => {
100
+ const resolved = resolveShareContentConfig(getShareContentRawConfig(environment));
101
+
102
+ if (resolved.status === 'invalid') {
103
+ throw new Error(
104
+ `Invalid LC-2187 share-content configuration: ${resolved.errors.join('; ')}`
105
+ );
106
+ }
107
+ };