@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,198 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
JWEValidator,
|
|
4
|
+
MAX_SHARE_CIPHERTEXT_BYTES,
|
|
5
|
+
MAX_SHARE_RECOVERY_JWE_BYTES,
|
|
6
|
+
ShareEnvelopeValidator,
|
|
7
|
+
ShareLinkIdValidator,
|
|
8
|
+
ShareOwnerRecoveryValidator,
|
|
9
|
+
decodedBase64UrlByteLength,
|
|
10
|
+
type ShareEnvelope,
|
|
11
|
+
type ShareOwnerRecovery,
|
|
12
|
+
utf8ByteLength,
|
|
13
|
+
} from '@learncard/types';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
computeShareContentRequestHash,
|
|
17
|
+
isOpaqueIdentifier,
|
|
18
|
+
isSafeContentVersion,
|
|
19
|
+
} from '@helpers/share-content-auth/canonical';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* LC-2187 dedicated LearnCloud `share_content` collection.
|
|
23
|
+
*
|
|
24
|
+
* This module defines the *storage* grammar for an immutable encrypted share
|
|
25
|
+
* object plus its permanent deletion tombstone. It is deliberately separate
|
|
26
|
+
* from the recipient manifest owners in `@learncard/types`: it never models
|
|
27
|
+
* plaintext, source URIs or keys, only opaque bindings and the already-encrypted
|
|
28
|
+
* envelope/recovery bytes.
|
|
29
|
+
*
|
|
30
|
+
* Security invariants encoded here:
|
|
31
|
+
* - The immutable identity is `(namespace, objectId)`. Every other binding
|
|
32
|
+
* (`ownerProfileId`, `shareId`, `contentVersion`, `operationId`) is part of
|
|
33
|
+
* the exact tuple and must match on every read/mutation.
|
|
34
|
+
* - `contentHash` is *derived* by {@link computeShareContentHash} over the full
|
|
35
|
+
* validated immutable record (all bindings plus the ciphertext envelope and
|
|
36
|
+
* the owner-encrypted recovery). A caller-supplied hash is never accepted, so
|
|
37
|
+
* a forged hash cannot mask divergent bytes.
|
|
38
|
+
* - The recovery projection is deliberately owner-only at the route layer. The
|
|
39
|
+
* stored recovery JWE is strict at the top level: unknown metadata/URL fields
|
|
40
|
+
* are rejected, not persisted.
|
|
41
|
+
* - Bound sizes: decoded ciphertext (tag included) is at most
|
|
42
|
+
* {@link MAX_SHARE_CIPHERTEXT_BYTES}; the serialized recovery JWE is at most
|
|
43
|
+
* {@link MAX_SHARE_RECOVERY_JWE_BYTES}.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** MongoDB collection name for immutable encrypted share content. */
|
|
47
|
+
export const SHARE_CONTENT_COLLECTION = 'share_content';
|
|
48
|
+
|
|
49
|
+
const SHARE_CONTENT_HASH_PATTERN = /^[0-9a-f]{64}$/;
|
|
50
|
+
|
|
51
|
+
/** Opaque, URL-safe binding identifier. Deliberately not a UUID-only check. */
|
|
52
|
+
const opaqueIdentifier = (label: string, maxLength = 128) =>
|
|
53
|
+
z.string().refine(value => isOpaqueIdentifier(value, maxLength), {
|
|
54
|
+
message: `${label} must be an opaque URL-safe identifier of 1..${maxLength} characters`,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** Service/network/tenant namespace. Opaque; not a URL or host. */
|
|
58
|
+
export const ShareContentNamespaceValidator = opaqueIdentifier('namespace');
|
|
59
|
+
export type ShareContentNamespace = z.infer<typeof ShareContentNamespaceValidator>;
|
|
60
|
+
|
|
61
|
+
/** Opaque owner profile id (existing ids are opaque strings, not necessarily UUIDs). */
|
|
62
|
+
export const ShareContentOwnerProfileIdValidator = opaqueIdentifier('ownerProfileId');
|
|
63
|
+
export type ShareContentOwnerProfileId = z.infer<typeof ShareContentOwnerProfileIdValidator>;
|
|
64
|
+
|
|
65
|
+
/** Opaque immutable object id reserved by Brain. Never a URI. */
|
|
66
|
+
export const ShareContentObjectIdValidator = opaqueIdentifier('objectId');
|
|
67
|
+
export type ShareContentObjectId = z.infer<typeof ShareContentObjectIdValidator>;
|
|
68
|
+
|
|
69
|
+
/** Opaque operation/idempotency id that owns the immutable object. */
|
|
70
|
+
export const ShareContentOperationIdValidator = opaqueIdentifier('operationId');
|
|
71
|
+
export type ShareContentOperationId = z.infer<typeof ShareContentOperationIdValidator>;
|
|
72
|
+
|
|
73
|
+
/** Positive safe-integer content version (`1..2^31-1`). */
|
|
74
|
+
export const ShareContentVersionValidator = z
|
|
75
|
+
.number()
|
|
76
|
+
.refine(isSafeContentVersion, { message: 'contentVersion must be a safe integer >= 1' });
|
|
77
|
+
export type ShareContentContentVersion = z.infer<typeof ShareContentVersionValidator>;
|
|
78
|
+
|
|
79
|
+
/** Lowercase hex SHA-256 of the derived immutable record. */
|
|
80
|
+
export const ShareContentHashValidator = z
|
|
81
|
+
.string()
|
|
82
|
+
.regex(SHARE_CONTENT_HASH_PATTERN, { message: 'contentHash must be lowercase hex sha256' });
|
|
83
|
+
export type ShareContentHash = z.infer<typeof ShareContentHashValidator>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Strict top-level owner recovery JWE. Wraps the shared
|
|
87
|
+
* {@link ShareOwnerRecoveryValidator} (which enforces the serialized 64 KiB cap)
|
|
88
|
+
* and additionally rejects unknown top-level keys so arbitrary metadata or URLs
|
|
89
|
+
* can never be persisted alongside the opaque recovery.
|
|
90
|
+
*/
|
|
91
|
+
export const ShareContentOwnerRecoveryValidator = JWEValidator.strict().refine(
|
|
92
|
+
value => ShareOwnerRecoveryValidator.safeParse(value).success,
|
|
93
|
+
{ message: 'must be a valid bounded owner-encrypted recovery JWE' }
|
|
94
|
+
);
|
|
95
|
+
export type ShareContentOwnerRecovery = z.infer<typeof ShareContentOwnerRecoveryValidator>;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The exact, immutable object binding. The repository applies this to every
|
|
99
|
+
* operation; a mutation of any single field fails closed.
|
|
100
|
+
*/
|
|
101
|
+
export const ShareContentObjectBindingValidator = z
|
|
102
|
+
.object({
|
|
103
|
+
namespace: ShareContentNamespaceValidator,
|
|
104
|
+
ownerProfileId: ShareContentOwnerProfileIdValidator,
|
|
105
|
+
shareId: ShareLinkIdValidator,
|
|
106
|
+
contentVersion: ShareContentVersionValidator,
|
|
107
|
+
objectId: ShareContentObjectIdValidator,
|
|
108
|
+
operationId: ShareContentOperationIdValidator,
|
|
109
|
+
})
|
|
110
|
+
.strict();
|
|
111
|
+
export type ShareContentObjectBinding = z.infer<typeof ShareContentObjectBindingValidator>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A create-only write. `contentHash` is intentionally absent: the repository
|
|
115
|
+
* derives it, so a caller cannot supply (and therefore cannot forge) it.
|
|
116
|
+
*/
|
|
117
|
+
export const ShareContentPutInputValidator = ShareContentObjectBindingValidator.extend({
|
|
118
|
+
envelope: ShareEnvelopeValidator,
|
|
119
|
+
ownerEncryptedRecovery: ShareContentOwnerRecoveryValidator,
|
|
120
|
+
}).strict();
|
|
121
|
+
export type ShareContentPutInput = z.infer<typeof ShareContentPutInputValidator>;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Derive the content hash over the complete immutable record: all bindings plus
|
|
125
|
+
* the validated ciphertext envelope and the owner-encrypted recovery. The C1
|
|
126
|
+
* canonical serializer makes the result deterministic and rejects unsupported
|
|
127
|
+
* values instead of coercing them.
|
|
128
|
+
*/
|
|
129
|
+
export const computeShareContentHash = (input: ShareContentPutInput): string =>
|
|
130
|
+
computeShareContentRequestHash({
|
|
131
|
+
namespace: input.namespace,
|
|
132
|
+
ownerProfileId: input.ownerProfileId,
|
|
133
|
+
shareId: input.shareId,
|
|
134
|
+
contentVersion: input.contentVersion,
|
|
135
|
+
objectId: input.objectId,
|
|
136
|
+
operationId: input.operationId,
|
|
137
|
+
envelope: input.envelope,
|
|
138
|
+
ownerEncryptedRecovery: input.ownerEncryptedRecovery,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** Hash known before Brain allocates object/operation IDs; reservation binding. */
|
|
142
|
+
export const computeSharePayloadHash = (
|
|
143
|
+
input: Pick<ShareContentPutInput, 'envelope' | 'ownerEncryptedRecovery'>
|
|
144
|
+
): string =>
|
|
145
|
+
computeShareContentRequestHash({
|
|
146
|
+
envelope: input.envelope,
|
|
147
|
+
ownerEncryptedRecovery: input.ownerEncryptedRecovery,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Measure the encoded payload for reconciliation/stats. Assumes `input` already
|
|
152
|
+
* passed {@link ShareContentPutInputValidator}.
|
|
153
|
+
*/
|
|
154
|
+
export const measureShareContentPayload = (
|
|
155
|
+
input: Pick<ShareContentPutInput, 'envelope' | 'ownerEncryptedRecovery'>
|
|
156
|
+
): { ciphertextBytes: number; recoveryBytes: number } => {
|
|
157
|
+
const ciphertextBytes = decodedBase64UrlByteLength(input.envelope.ct);
|
|
158
|
+
|
|
159
|
+
if (ciphertextBytes === null) {
|
|
160
|
+
throw new Error('validated envelope ciphertext must be canonical base64url');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
ciphertextBytes,
|
|
165
|
+
recoveryBytes: utf8ByteLength(JSON.stringify(input.ownerEncryptedRecovery)),
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** Immutable active document. */
|
|
170
|
+
export const MongoShareContentActiveValidator = ShareContentObjectBindingValidator.extend({
|
|
171
|
+
kind: z.literal('active'),
|
|
172
|
+
contentHash: ShareContentHashValidator,
|
|
173
|
+
payloadHash: ShareContentHashValidator,
|
|
174
|
+
envelope: ShareEnvelopeValidator,
|
|
175
|
+
ownerEncryptedRecovery: ShareContentOwnerRecoveryValidator,
|
|
176
|
+
ciphertextBytes: z.number().int().min(16).max(MAX_SHARE_CIPHERTEXT_BYTES),
|
|
177
|
+
recoveryBytes: z.number().int().min(1).max(MAX_SHARE_RECOVERY_JWE_BYTES),
|
|
178
|
+
createdAt: z.date(),
|
|
179
|
+
}).strict();
|
|
180
|
+
export type MongoShareContentActive = z.infer<typeof MongoShareContentActiveValidator>;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Permanent deletion tombstone. Retains the binding tuple and, where known, the
|
|
184
|
+
* derived hash; never retains ciphertext or recovery. A tombstone blocks every
|
|
185
|
+
* later put for the same `(namespace, objectId)` identity.
|
|
186
|
+
*/
|
|
187
|
+
export const MongoShareContentTombstoneValidator = ShareContentObjectBindingValidator.extend({
|
|
188
|
+
kind: z.literal('tombstone'),
|
|
189
|
+
contentHash: ShareContentHashValidator.optional(),
|
|
190
|
+
deletedAt: z.date(),
|
|
191
|
+
}).strict();
|
|
192
|
+
export type MongoShareContentTombstone = z.infer<typeof MongoShareContentTombstoneValidator>;
|
|
193
|
+
|
|
194
|
+
/** Stored document union. */
|
|
195
|
+
export type MongoShareContentDocument = MongoShareContentActive | MongoShareContentTombstone;
|
|
196
|
+
|
|
197
|
+
/** Raw ciphertext envelope carried by an active object. */
|
|
198
|
+
export type { ShareEnvelope, ShareOwnerRecovery };
|
package/src/models/index.ts
CHANGED
|
@@ -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
|
+
});
|