@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.
- package/.env.example +13 -0
- package/CHANGELOG.md +19 -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/environment.d.ts +12 -0
- package/dist/config/environment.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/lambda.ts +61 -0
- package/package.json +14 -14
- package/serverless.yml +25 -0
- 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/environment.ts +33 -0
- package/src/docker-entry.ts +19 -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 +1 -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
|
@@ -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,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
|
+
};
|