@learncard/learn-cloud-service 2.5.34 → 2.5.36

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 (124) hide show
  1. package/.env.example +24 -12
  2. package/CHANGELOG.md +43 -0
  3. package/bootstrapLambda.test.ts +68 -0
  4. package/config/config.dev.json +8 -0
  5. package/config/config.example.json +8 -0
  6. package/config/config.json +1 -0
  7. package/config/config.production.json +8 -0
  8. package/config/config.scouts.dev.json +3 -0
  9. package/config/config.scouts.production.json +4 -0
  10. package/didWebLambda.test.ts +41 -0
  11. package/didWebLambda.ts +13 -19
  12. package/didWebLambdaApp.ts +23 -0
  13. package/dist/accesslayer/share-content/delete.d.ts +5 -0
  14. package/dist/accesslayer/share-content/delete.d.ts.map +1 -0
  15. package/dist/accesslayer/share-content/index.d.ts +8 -0
  16. package/dist/accesslayer/share-content/index.d.ts.map +1 -0
  17. package/dist/accesslayer/share-content/initialize.d.ts +5 -0
  18. package/dist/accesslayer/share-content/initialize.d.ts.map +1 -0
  19. package/dist/accesslayer/share-content/internal.d.ts +12 -0
  20. package/dist/accesslayer/share-content/internal.d.ts.map +1 -0
  21. package/dist/accesslayer/share-content/put.d.ts +5 -0
  22. package/dist/accesslayer/share-content/put.d.ts.map +1 -0
  23. package/dist/accesslayer/share-content/read.d.ts +7 -0
  24. package/dist/accesslayer/share-content/read.d.ts.map +1 -0
  25. package/dist/accesslayer/share-content/types.d.ts +85 -0
  26. package/dist/accesslayer/share-content/types.d.ts.map +1 -0
  27. package/dist/config/applyDockerStageConfig.d.ts +2 -0
  28. package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
  29. package/dist/config/environment.d.ts +12 -0
  30. package/dist/config/environment.d.ts.map +1 -1
  31. package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
  32. package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
  33. package/dist/config/stageConfig.d.ts +10 -0
  34. package/dist/config/stageConfig.d.ts.map +1 -0
  35. package/dist/config/stageConfig.test.d.ts +2 -0
  36. package/dist/config/stageConfig.test.d.ts.map +1 -0
  37. package/dist/didkit_wasm_bg.wasm +0 -0
  38. package/dist/docker-entry.d.ts +1 -1
  39. package/dist/docker-entry.d.ts.map +1 -1
  40. package/dist/helpers/share-content-auth/adapters.d.ts +12 -0
  41. package/dist/helpers/share-content-auth/adapters.d.ts.map +1 -0
  42. package/dist/helpers/share-content-auth/canonical.d.ts +15 -0
  43. package/dist/helpers/share-content-auth/canonical.d.ts.map +1 -0
  44. package/dist/helpers/share-content-auth/claims.d.ts +19 -0
  45. package/dist/helpers/share-content-auth/claims.d.ts.map +1 -0
  46. package/dist/helpers/share-content-auth/config.d.ts +12 -0
  47. package/dist/helpers/share-content-auth/config.d.ts.map +1 -0
  48. package/dist/helpers/share-content-auth/index.d.ts +8 -0
  49. package/dist/helpers/share-content-auth/index.d.ts.map +1 -0
  50. package/dist/helpers/share-content-auth/token.d.ts +28 -0
  51. package/dist/helpers/share-content-auth/token.d.ts.map +1 -0
  52. package/dist/helpers/share-content-auth/types.d.ts +70 -0
  53. package/dist/helpers/share-content-auth/types.d.ts.map +1 -0
  54. package/dist/helpers/share-content-auth/verifier.d.ts +9 -0
  55. package/dist/helpers/share-content-auth/verifier.d.ts.map +1 -0
  56. package/dist/index.js +2 -2
  57. package/dist/models/ShareContent.d.ts +160 -0
  58. package/dist/models/ShareContent.d.ts.map +1 -0
  59. package/dist/models/index.d.ts +1 -0
  60. package/dist/models/index.d.ts.map +1 -1
  61. package/dist/share-content/config.d.ts +28 -0
  62. package/dist/share-content/config.d.ts.map +1 -0
  63. package/dist/share-content/index.d.ts +6 -0
  64. package/dist/share-content/index.d.ts.map +1 -0
  65. package/dist/share-content/plugin.d.ts +5 -0
  66. package/dist/share-content/plugin.d.ts.map +1 -0
  67. package/dist/share-content/production.d.ts +15 -0
  68. package/dist/share-content/production.d.ts.map +1 -0
  69. package/dist/share-content/runtime.d.ts +23 -0
  70. package/dist/share-content/runtime.d.ts.map +1 -0
  71. package/dist/share-content/types.d.ts +23 -0
  72. package/dist/share-content/types.d.ts.map +1 -0
  73. package/esbuildPlugins.cjs +1 -0
  74. package/lambda.ts +22 -92
  75. package/lambdaApp.ts +163 -0
  76. package/oidcLambda.test.ts +41 -0
  77. package/oidcLambda.ts +13 -19
  78. package/oidcLambdaApp.ts +23 -0
  79. package/package.json +18 -16
  80. package/serverless-local.yml +3 -1
  81. package/serverless.function-env.cjs +31 -0
  82. package/serverless.yml +31 -22
  83. package/src/accesslayer/share-content/delete.ts +124 -0
  84. package/src/accesslayer/share-content/index.ts +70 -0
  85. package/src/accesslayer/share-content/initialize.ts +24 -0
  86. package/src/accesslayer/share-content/internal.ts +89 -0
  87. package/src/accesslayer/share-content/put.ts +93 -0
  88. package/src/accesslayer/share-content/read.ts +124 -0
  89. package/src/accesslayer/share-content/types.ts +155 -0
  90. package/src/config/applyDockerStageConfig.ts +6 -0
  91. package/src/config/environment.ts +33 -0
  92. package/src/config/serverlessFunctionEnv.test.ts +81 -0
  93. package/src/config/stageConfig.test.ts +26 -0
  94. package/src/config/stageConfig.ts +24 -0
  95. package/src/docker-entry.ts +21 -0
  96. package/src/helpers/share-content-auth/adapters.ts +48 -0
  97. package/src/helpers/share-content-auth/canonical.ts +91 -0
  98. package/src/helpers/share-content-auth/claims.ts +164 -0
  99. package/src/helpers/share-content-auth/config.ts +160 -0
  100. package/src/helpers/share-content-auth/index.ts +59 -0
  101. package/src/helpers/share-content-auth/token.ts +148 -0
  102. package/src/helpers/share-content-auth/types.ts +195 -0
  103. package/src/helpers/share-content-auth/verifier.ts +220 -0
  104. package/src/models/ShareContent.ts +198 -0
  105. package/src/models/index.ts +1 -0
  106. package/src/share-content/config.ts +199 -0
  107. package/src/share-content/index.ts +5 -0
  108. package/src/share-content/plugin.ts +317 -0
  109. package/src/share-content/production.ts +107 -0
  110. package/src/share-content/runtime.ts +90 -0
  111. package/src/share-content/types.ts +59 -0
  112. package/test/share-content-auth.spec.ts +1161 -0
  113. package/test/share-content-config.spec.ts +252 -0
  114. package/test/share-content-didkit.spec.ts +411 -0
  115. package/test/share-content-endpoint.spec.ts +825 -0
  116. package/test/share-content-redis-setup.ts +18 -0
  117. package/test/share-content.spec.ts +681 -0
  118. package/vitest.config.ts +4 -0
  119. package/vitest.share-content-config-unit.config.ts +8 -0
  120. package/vitest.share-content-endpoint.config.ts +21 -0
  121. package/vitest.share-content.config.ts +19 -0
  122. package/xApiLambda.test.ts +41 -0
  123. package/xApiLambda.ts +13 -19
  124. package/xApiLambdaApp.ts +23 -0
@@ -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
+ };
@@ -0,0 +1,90 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+
3
+ import type { ShareContentRepository } from '@accesslayer/share-content';
4
+ import {
5
+ createDidkitPresentationVerifier,
6
+ createShareContentAuthorizationVerifier,
7
+ type LearnCardLike,
8
+ type ReplayStore,
9
+ } from '@helpers/share-content-auth';
10
+
11
+ import { resolveShareContentConfig, toShareContentPluginOptions } from './config';
12
+ import { shareContentFastifyPlugin } from './plugin';
13
+ import { SHARE_CONTENT_PLUGIN_PREFIX, type ShareContentPluginOptions } from './types';
14
+
15
+ /**
16
+ * Startup wrapper for the service-only share-content routes.
17
+ *
18
+ * The builder owns the fail-closed boot order: resolve the explicit config,
19
+ * then (only when it is valid and enabled) open the repository, *await* its
20
+ * index initialization, build the DIDKit verifier and the real Redis replay
21
+ * store, and return injectable plugin options. A disabled configuration returns
22
+ * immediately and performs none of those side effects, so a disabled boot never
23
+ * installs indexes or opens connections.
24
+ */
25
+
26
+ export type ShareContentRuntimeDependencies = {
27
+ /** Existing DIDKit verification seam; never constructed at import time. */
28
+ getLearnCard: () => Promise<LearnCardLike>;
29
+ /** Real `SET NX EX` replay store (tests may inject an explicit double). */
30
+ createReplayStore: () => { store: ReplayStore; close: () => Promise<void> };
31
+ /** Repository factory; must not connect until the enabled branch. */
32
+ getRepository: () => Promise<ShareContentRepository>;
33
+ };
34
+
35
+ export type EnabledShareContentRuntime = {
36
+ enabled: true;
37
+ pluginOptions: ShareContentPluginOptions;
38
+ /** Releases the replay-store connection. Repository pooling is owned by @mongo. */
39
+ close: () => Promise<void>;
40
+ };
41
+
42
+ export type ShareContentRuntime = { enabled: false } | EnabledShareContentRuntime;
43
+
44
+ export const buildShareContentRuntime = async (
45
+ rawConfig: unknown,
46
+ dependencies: ShareContentRuntimeDependencies
47
+ ): Promise<ShareContentRuntime> => {
48
+ const resolved = resolveShareContentConfig(rawConfig);
49
+
50
+ if (resolved.status === 'disabled') return { enabled: false };
51
+
52
+ if (resolved.status === 'invalid') {
53
+ // Explicit startup failure: never fall back to a weaker check.
54
+ throw new Error(
55
+ `Invalid LC-2187 share-content configuration: ${resolved.errors.join('; ')}`
56
+ );
57
+ }
58
+
59
+ const repository = await dependencies.getRepository();
60
+
61
+ // Enabled boot awaits index initialization before any route can serve.
62
+ await repository.initialize();
63
+
64
+ const replayStore = dependencies.createReplayStore();
65
+
66
+ const verifier = createShareContentAuthorizationVerifier({
67
+ config: resolved.trustConfig,
68
+ verifyPresentation: createDidkitPresentationVerifier(dependencies.getLearnCard),
69
+ replayStore: replayStore.store,
70
+ });
71
+
72
+ return {
73
+ enabled: true,
74
+ pluginOptions: toShareContentPluginOptions(resolved, { verifier, repository }),
75
+ close: async () => {
76
+ await replayStore.close();
77
+ },
78
+ };
79
+ };
80
+
81
+ /** Register the routes under the fixed prefix on an already-built Fastify server. */
82
+ export const registerShareContentRoutes = async (
83
+ fastify: FastifyInstance,
84
+ runtime: EnabledShareContentRuntime
85
+ ): Promise<void> => {
86
+ await fastify.register(shareContentFastifyPlugin, {
87
+ prefix: SHARE_CONTENT_PLUGIN_PREFIX,
88
+ ...runtime.pluginOptions,
89
+ });
90
+ };
@@ -0,0 +1,59 @@
1
+ import type { ShareContentRepository } from '@accesslayer/share-content';
2
+ import type { ReplayStore, ShareContentAuthorizationVerifier } from '@helpers/share-content-auth';
3
+
4
+ import type { ShareContentNamespacePolicy } from './config';
5
+
6
+ /**
7
+ * LC-2187 LearnCloud service-only share-content transport.
8
+ *
9
+ * This module is the C4 transport seam: it turns the reviewed C1 authorization
10
+ * verifier and the C2 immutable repository into the fixed service HTTP contract
11
+ * (`POST /internal/share-content/{op}`). It never redesigns the claim format,
12
+ * the repository state machine or the public share feature; those stay owned by
13
+ * their batches.
14
+ */
15
+
16
+ /** All shared-content service routes live under this configured prefix. */
17
+ export const SHARE_CONTENT_PLUGIN_PREFIX = '/internal/share-content';
18
+
19
+ /** The exact operation is selected by the server route, never by the body. */
20
+ export type ShareContentRouteOperation = 'put' | 'get' | 'stat' | 'delete' | 'readRecovery';
21
+
22
+ /** Hard transport bound mandated by the A2 review; the Fastify default. */
23
+ export const SHARE_CONTENT_MAX_REQUEST_BYTES = 1024 * 1024;
24
+
25
+ /**
26
+ * Contract error statuses. Response bodies are always the generic
27
+ * `{ ok: false, error }` shape; internal rejection reasons are never returned.
28
+ */
29
+ export const SHARE_CONTENT_ERROR_CODES = [
30
+ 'UNAUTHORIZED',
31
+ 'INVALID_INPUT',
32
+ 'PAYLOAD_TOO_LARGE',
33
+ 'NOT_FOUND',
34
+ 'CONFLICT',
35
+ 'UNAVAILABLE',
36
+ ] as const;
37
+
38
+ export type ShareContentErrorCode = (typeof SHARE_CONTENT_ERROR_CODES)[number];
39
+
40
+ export type ShareContentErrorBody = { ok: false; error: ShareContentErrorCode };
41
+
42
+ /**
43
+ * Injected transport dependencies. The route layer only orchestrates: it
44
+ * validates the wire body, hashes it, asks the injected verifier to authorize,
45
+ * checks the configured namespace policy, then calls the repository.
46
+ */
47
+ export type ShareContentPluginOptions = {
48
+ verifier: ShareContentAuthorizationVerifier;
49
+ repository: ShareContentRepository;
50
+ namespacePolicy: ShareContentNamespacePolicy;
51
+ /** Route body limit; must never exceed {@link SHARE_CONTENT_MAX_REQUEST_BYTES}. */
52
+ maxRequestBytes?: number;
53
+ };
54
+
55
+ /** Structural view of a Redis-like atomic replay store used by the runtime. */
56
+ export type ShareContentReplayStoreFactory = () => {
57
+ store: ReplayStore;
58
+ close: () => Promise<void>;
59
+ };