@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.
- package/.env.example +24 -12
- package/CHANGELOG.md +43 -0
- package/bootstrapLambda.test.ts +68 -0
- package/config/config.dev.json +8 -0
- package/config/config.example.json +8 -0
- package/config/config.json +1 -0
- package/config/config.production.json +8 -0
- package/config/config.scouts.dev.json +3 -0
- package/config/config.scouts.production.json +4 -0
- package/didWebLambda.test.ts +41 -0
- package/didWebLambda.ts +13 -19
- package/didWebLambdaApp.ts +23 -0
- package/dist/accesslayer/share-content/delete.d.ts +5 -0
- package/dist/accesslayer/share-content/delete.d.ts.map +1 -0
- package/dist/accesslayer/share-content/index.d.ts +8 -0
- package/dist/accesslayer/share-content/index.d.ts.map +1 -0
- package/dist/accesslayer/share-content/initialize.d.ts +5 -0
- package/dist/accesslayer/share-content/initialize.d.ts.map +1 -0
- package/dist/accesslayer/share-content/internal.d.ts +12 -0
- package/dist/accesslayer/share-content/internal.d.ts.map +1 -0
- package/dist/accesslayer/share-content/put.d.ts +5 -0
- package/dist/accesslayer/share-content/put.d.ts.map +1 -0
- package/dist/accesslayer/share-content/read.d.ts +7 -0
- package/dist/accesslayer/share-content/read.d.ts.map +1 -0
- package/dist/accesslayer/share-content/types.d.ts +85 -0
- package/dist/accesslayer/share-content/types.d.ts.map +1 -0
- package/dist/config/applyDockerStageConfig.d.ts +2 -0
- package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
- package/dist/config/environment.d.ts +12 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
- package/dist/config/stageConfig.d.ts +10 -0
- package/dist/config/stageConfig.d.ts.map +1 -0
- package/dist/config/stageConfig.test.d.ts +2 -0
- package/dist/config/stageConfig.test.d.ts.map +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/docker-entry.d.ts.map +1 -1
- package/dist/helpers/share-content-auth/adapters.d.ts +12 -0
- package/dist/helpers/share-content-auth/adapters.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/canonical.d.ts +15 -0
- package/dist/helpers/share-content-auth/canonical.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/claims.d.ts +19 -0
- package/dist/helpers/share-content-auth/claims.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/config.d.ts +12 -0
- package/dist/helpers/share-content-auth/config.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/index.d.ts +8 -0
- package/dist/helpers/share-content-auth/index.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/token.d.ts +28 -0
- package/dist/helpers/share-content-auth/token.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/types.d.ts +70 -0
- package/dist/helpers/share-content-auth/types.d.ts.map +1 -0
- package/dist/helpers/share-content-auth/verifier.d.ts +9 -0
- package/dist/helpers/share-content-auth/verifier.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/ShareContent.d.ts +160 -0
- package/dist/models/ShareContent.d.ts.map +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/share-content/config.d.ts +28 -0
- package/dist/share-content/config.d.ts.map +1 -0
- package/dist/share-content/index.d.ts +6 -0
- package/dist/share-content/index.d.ts.map +1 -0
- package/dist/share-content/plugin.d.ts +5 -0
- package/dist/share-content/plugin.d.ts.map +1 -0
- package/dist/share-content/production.d.ts +15 -0
- package/dist/share-content/production.d.ts.map +1 -0
- package/dist/share-content/runtime.d.ts +23 -0
- package/dist/share-content/runtime.d.ts.map +1 -0
- package/dist/share-content/types.d.ts +23 -0
- package/dist/share-content/types.d.ts.map +1 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +22 -92
- package/lambdaApp.ts +163 -0
- package/oidcLambda.test.ts +41 -0
- package/oidcLambda.ts +13 -19
- package/oidcLambdaApp.ts +23 -0
- package/package.json +18 -16
- package/serverless-local.yml +3 -1
- package/serverless.function-env.cjs +31 -0
- package/serverless.yml +31 -22
- package/src/accesslayer/share-content/delete.ts +124 -0
- package/src/accesslayer/share-content/index.ts +70 -0
- package/src/accesslayer/share-content/initialize.ts +24 -0
- package/src/accesslayer/share-content/internal.ts +89 -0
- package/src/accesslayer/share-content/put.ts +93 -0
- package/src/accesslayer/share-content/read.ts +124 -0
- package/src/accesslayer/share-content/types.ts +155 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/environment.ts +33 -0
- package/src/config/serverlessFunctionEnv.test.ts +81 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +21 -0
- package/src/helpers/share-content-auth/adapters.ts +48 -0
- package/src/helpers/share-content-auth/canonical.ts +91 -0
- package/src/helpers/share-content-auth/claims.ts +164 -0
- package/src/helpers/share-content-auth/config.ts +160 -0
- package/src/helpers/share-content-auth/index.ts +59 -0
- package/src/helpers/share-content-auth/token.ts +148 -0
- package/src/helpers/share-content-auth/types.ts +195 -0
- package/src/helpers/share-content-auth/verifier.ts +220 -0
- package/src/models/ShareContent.ts +198 -0
- package/src/models/index.ts +1 -0
- package/src/share-content/config.ts +199 -0
- package/src/share-content/index.ts +5 -0
- package/src/share-content/plugin.ts +317 -0
- package/src/share-content/production.ts +107 -0
- package/src/share-content/runtime.ts +90 -0
- package/src/share-content/types.ts +59 -0
- package/test/share-content-auth.spec.ts +1161 -0
- package/test/share-content-config.spec.ts +252 -0
- package/test/share-content-didkit.spec.ts +411 -0
- package/test/share-content-endpoint.spec.ts +825 -0
- package/test/share-content-redis-setup.ts +18 -0
- package/test/share-content.spec.ts +681 -0
- package/vitest.config.ts +4 -0
- package/vitest.share-content-config-unit.config.ts +8 -0
- package/vitest.share-content-endpoint.config.ts +21 -0
- package/vitest.share-content.config.ts +19 -0
- package/xApiLambda.test.ts +41 -0
- package/xApiLambda.ts +13 -19
- 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
|
+
};
|