@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,70 @@
|
|
|
1
|
+
import type { Collection, Db } from 'mongodb';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SHARE_CONTENT_COLLECTION,
|
|
5
|
+
type MongoShareContentDocument,
|
|
6
|
+
type ShareContentObjectBinding,
|
|
7
|
+
type ShareContentPutInput,
|
|
8
|
+
} from '@models';
|
|
9
|
+
|
|
10
|
+
import { deleteShareContent } from './delete';
|
|
11
|
+
import { ensureShareContentIndexes } from './initialize';
|
|
12
|
+
import { putShareContent } from './put';
|
|
13
|
+
import { getShareContentContent, getShareContentRecovery, statShareContent } from './read';
|
|
14
|
+
import { ShareContentRepositoryNotInitializedError } from './types';
|
|
15
|
+
import type { ShareContentRepository } from './types';
|
|
16
|
+
|
|
17
|
+
export * from './types';
|
|
18
|
+
export { ensureShareContentIndexes, SHARE_CONTENT_IDENTITY_INDEX } from './initialize';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Bind the dedicated `share_content` collection from a caller-provided `Db`.
|
|
22
|
+
* Deliberately does not import the service `@mongo` singleton, so nothing here
|
|
23
|
+
* opens a connection at import time and tests can inject a real/memory database.
|
|
24
|
+
*/
|
|
25
|
+
export const getShareContentCollection = (db: Db): Collection<MongoShareContentDocument> =>
|
|
26
|
+
db.collection<MongoShareContentDocument>(SHARE_CONTENT_COLLECTION);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build the narrow exact-object share-content repository over an injected
|
|
30
|
+
* collection.
|
|
31
|
+
*
|
|
32
|
+
* Operational contract:
|
|
33
|
+
* - `initialize()` must be awaited at startup; it installs the unique
|
|
34
|
+
* `(namespace, objectId)` index. Every operation throws
|
|
35
|
+
* {@link ShareContentRepositoryNotInitializedError} until then, so an
|
|
36
|
+
* un-indexed database can never serve a write.
|
|
37
|
+
* - This repository is **not** an authorization boundary. The route batch must
|
|
38
|
+
* verify the C1 service context (namespace/op/shareId/contentVersion/
|
|
39
|
+
* ownerProfileId/objectId/operationId/requestHash) first, then pass the exact
|
|
40
|
+
* verified tuple. The repository only enforces the data-level binding and the
|
|
41
|
+
* active/tombstone state machine.
|
|
42
|
+
*/
|
|
43
|
+
export const createShareContentRepository = (
|
|
44
|
+
collection: Collection<MongoShareContentDocument>
|
|
45
|
+
): ShareContentRepository => {
|
|
46
|
+
let initialized = false;
|
|
47
|
+
|
|
48
|
+
const requireInitialized = (): Collection<MongoShareContentDocument> => {
|
|
49
|
+
if (!initialized) throw new ShareContentRepositoryNotInitializedError();
|
|
50
|
+
|
|
51
|
+
return collection;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
collection,
|
|
56
|
+
initialize: async () => {
|
|
57
|
+
await ensureShareContentIndexes(collection);
|
|
58
|
+
initialized = true;
|
|
59
|
+
},
|
|
60
|
+
put: async (input: ShareContentPutInput) => putShareContent(requireInitialized(), input),
|
|
61
|
+
getContent: async (binding: ShareContentObjectBinding) =>
|
|
62
|
+
getShareContentContent(requireInitialized(), binding),
|
|
63
|
+
getRecovery: async (binding: ShareContentObjectBinding) =>
|
|
64
|
+
getShareContentRecovery(requireInitialized(), binding),
|
|
65
|
+
stat: async (binding: ShareContentObjectBinding) =>
|
|
66
|
+
statShareContent(requireInitialized(), binding),
|
|
67
|
+
delete: async (binding: ShareContentObjectBinding) =>
|
|
68
|
+
deleteShareContent(requireInitialized(), binding),
|
|
69
|
+
};
|
|
70
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Collection } from 'mongodb';
|
|
2
|
+
|
|
3
|
+
import type { MongoShareContentDocument } from '@models';
|
|
4
|
+
|
|
5
|
+
/** Stable index name so operators can verify/report the installed index. */
|
|
6
|
+
export const SHARE_CONTENT_IDENTITY_INDEX = 'share_content_identity_unique';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Explicitly install the required indexes before any operation is served.
|
|
10
|
+
*
|
|
11
|
+
* The unique `(namespace, objectId)` index is what makes create-only writes and
|
|
12
|
+
* permanent tombstones atomic: without it, a duplicate `insertOne` would create a
|
|
13
|
+
* second document instead of failing closed. Index creation is awaited by
|
|
14
|
+
* {@link createShareContentRepository}.initialize; it is never left to an
|
|
15
|
+
* eventual background build.
|
|
16
|
+
*/
|
|
17
|
+
export const ensureShareContentIndexes = async (
|
|
18
|
+
collection: Collection<MongoShareContentDocument>
|
|
19
|
+
): Promise<void> => {
|
|
20
|
+
await collection.createIndex(
|
|
21
|
+
{ namespace: 1, objectId: 1 },
|
|
22
|
+
{ unique: true, name: SHARE_CONTENT_IDENTITY_INDEX }
|
|
23
|
+
);
|
|
24
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Filter } from 'mongodb';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ShareContentObjectBindingValidator,
|
|
5
|
+
type MongoShareContentActive,
|
|
6
|
+
type MongoShareContentDocument,
|
|
7
|
+
type MongoShareContentTombstone,
|
|
8
|
+
type ShareContentObjectBinding,
|
|
9
|
+
} from '@models';
|
|
10
|
+
|
|
11
|
+
import type { ShareContentActiveSummary, ShareContentTombstoneSummary } from './types';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bounded retry count for the atomic insert/delete race loops. The state machine
|
|
15
|
+
* is monotonic (active -> tombstone, never back), so the loop always settles; the
|
|
16
|
+
* bound protects against a pathological unique-index/driver anomaly rather than
|
|
17
|
+
* ordinary contention.
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_SHARE_CONTENT_ATTEMPTS = 5;
|
|
20
|
+
|
|
21
|
+
export const parseShareContentBinding = (raw: unknown): ShareContentObjectBinding | null => {
|
|
22
|
+
const parsed = ShareContentObjectBindingValidator.safeParse(raw);
|
|
23
|
+
|
|
24
|
+
return parsed.success ? parsed.data : null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Exact six-field binding filter. Missing any field would widen the query. */
|
|
28
|
+
export const shareContentBindingFilter = (
|
|
29
|
+
binding: ShareContentObjectBinding
|
|
30
|
+
): Filter<MongoShareContentDocument> => ({
|
|
31
|
+
namespace: binding.namespace,
|
|
32
|
+
ownerProfileId: binding.ownerProfileId,
|
|
33
|
+
shareId: binding.shareId,
|
|
34
|
+
contentVersion: binding.contentVersion,
|
|
35
|
+
objectId: binding.objectId,
|
|
36
|
+
operationId: binding.operationId,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Identity filter for the unique `(namespace, objectId)` index. */
|
|
40
|
+
export const shareContentIdentityFilter = (
|
|
41
|
+
binding: Pick<ShareContentObjectBinding, 'namespace' | 'objectId'>
|
|
42
|
+
): Filter<MongoShareContentDocument> => ({
|
|
43
|
+
namespace: binding.namespace,
|
|
44
|
+
objectId: binding.objectId,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const bindingMatchesDocument = (
|
|
48
|
+
doc: MongoShareContentDocument,
|
|
49
|
+
binding: ShareContentObjectBinding
|
|
50
|
+
): boolean =>
|
|
51
|
+
doc.namespace === binding.namespace &&
|
|
52
|
+
doc.ownerProfileId === binding.ownerProfileId &&
|
|
53
|
+
doc.shareId === binding.shareId &&
|
|
54
|
+
doc.contentVersion === binding.contentVersion &&
|
|
55
|
+
doc.objectId === binding.objectId &&
|
|
56
|
+
doc.operationId === binding.operationId;
|
|
57
|
+
|
|
58
|
+
/** MongoDB duplicate-key error (unique index violation). */
|
|
59
|
+
export const isDuplicateKeyError = (error: unknown): boolean =>
|
|
60
|
+
typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 11000;
|
|
61
|
+
|
|
62
|
+
export const toActiveSummary = (doc: MongoShareContentActive): ShareContentActiveSummary => ({
|
|
63
|
+
kind: 'active',
|
|
64
|
+
namespace: doc.namespace,
|
|
65
|
+
ownerProfileId: doc.ownerProfileId,
|
|
66
|
+
shareId: doc.shareId,
|
|
67
|
+
contentVersion: doc.contentVersion,
|
|
68
|
+
objectId: doc.objectId,
|
|
69
|
+
operationId: doc.operationId,
|
|
70
|
+
contentHash: doc.contentHash,
|
|
71
|
+
payloadHash: doc.payloadHash,
|
|
72
|
+
ciphertextBytes: doc.ciphertextBytes,
|
|
73
|
+
recoveryBytes: doc.recoveryBytes,
|
|
74
|
+
createdAt: doc.createdAt,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const toTombstoneSummary = (
|
|
78
|
+
doc: MongoShareContentTombstone
|
|
79
|
+
): ShareContentTombstoneSummary => ({
|
|
80
|
+
kind: 'tombstone',
|
|
81
|
+
namespace: doc.namespace,
|
|
82
|
+
ownerProfileId: doc.ownerProfileId,
|
|
83
|
+
shareId: doc.shareId,
|
|
84
|
+
contentVersion: doc.contentVersion,
|
|
85
|
+
objectId: doc.objectId,
|
|
86
|
+
operationId: doc.operationId,
|
|
87
|
+
...(doc.contentHash === undefined ? {} : { contentHash: doc.contentHash }),
|
|
88
|
+
deletedAt: doc.deletedAt,
|
|
89
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Collection } from 'mongodb';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ShareContentPutInputValidator,
|
|
5
|
+
computeShareContentHash,
|
|
6
|
+
computeSharePayloadHash,
|
|
7
|
+
measureShareContentPayload,
|
|
8
|
+
type MongoShareContentActive,
|
|
9
|
+
type MongoShareContentDocument,
|
|
10
|
+
type ShareContentPutInput,
|
|
11
|
+
} from '@models';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
MAX_SHARE_CONTENT_ATTEMPTS,
|
|
15
|
+
bindingMatchesDocument,
|
|
16
|
+
isDuplicateKeyError,
|
|
17
|
+
shareContentIdentityFilter,
|
|
18
|
+
toActiveSummary,
|
|
19
|
+
} from './internal';
|
|
20
|
+
import type { ShareContentPutResult } from './types';
|
|
21
|
+
|
|
22
|
+
const buildActiveDocument = (
|
|
23
|
+
input: ShareContentPutInput,
|
|
24
|
+
contentHash: string
|
|
25
|
+
): MongoShareContentActive => {
|
|
26
|
+
const { ciphertextBytes, recoveryBytes } = measureShareContentPayload(input);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
kind: 'active',
|
|
30
|
+
namespace: input.namespace,
|
|
31
|
+
ownerProfileId: input.ownerProfileId,
|
|
32
|
+
shareId: input.shareId,
|
|
33
|
+
contentVersion: input.contentVersion,
|
|
34
|
+
objectId: input.objectId,
|
|
35
|
+
operationId: input.operationId,
|
|
36
|
+
contentHash,
|
|
37
|
+
payloadHash: computeSharePayloadHash(input),
|
|
38
|
+
envelope: input.envelope,
|
|
39
|
+
ownerEncryptedRecovery: input.ownerEncryptedRecovery,
|
|
40
|
+
ciphertextBytes,
|
|
41
|
+
recoveryBytes,
|
|
42
|
+
createdAt: new Date(),
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create-only immutable write.
|
|
48
|
+
*
|
|
49
|
+
* The write is an atomic `insertOne` against the unique `(namespace, objectId)`
|
|
50
|
+
* index; there is no read-then-write window. On a duplicate key the existing
|
|
51
|
+
* document is classified: a tombstone is permanent, an active document with the
|
|
52
|
+
* same derived hash and all six bindings is an exact idempotent success, and
|
|
53
|
+
* anything else is a conflict. Because active documents are never replaced and
|
|
54
|
+
* tombstones are permanent, the classification read cannot be invalidated by a
|
|
55
|
+
* concurrent writer in a way that resurrects content.
|
|
56
|
+
*/
|
|
57
|
+
export const putShareContent = async (
|
|
58
|
+
collection: Collection<MongoShareContentDocument>,
|
|
59
|
+
rawInput: unknown
|
|
60
|
+
): Promise<ShareContentPutResult> => {
|
|
61
|
+
const parsed = ShareContentPutInputValidator.safeParse(rawInput);
|
|
62
|
+
|
|
63
|
+
if (!parsed.success) return { ok: false, error: 'INVALID_INPUT' };
|
|
64
|
+
|
|
65
|
+
const input = parsed.data;
|
|
66
|
+
const contentHash = computeShareContentHash(input);
|
|
67
|
+
|
|
68
|
+
for (let attempt = 0; attempt < MAX_SHARE_CONTENT_ATTEMPTS; attempt += 1) {
|
|
69
|
+
const document = buildActiveDocument(input, contentHash);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await collection.insertOne(document);
|
|
73
|
+
|
|
74
|
+
return { ok: true, value: { status: 'created', record: toActiveSummary(document) } };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (!isDuplicateKeyError(error)) return { ok: false, error: 'STORE_ERROR' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const existing = await collection.findOne(shareContentIdentityFilter(input));
|
|
80
|
+
|
|
81
|
+
if (existing === null) continue;
|
|
82
|
+
|
|
83
|
+
if (existing.kind === 'tombstone') return { ok: false, error: 'TOMBSTONED' };
|
|
84
|
+
|
|
85
|
+
if (existing.contentHash === contentHash && bindingMatchesDocument(existing, input)) {
|
|
86
|
+
return { ok: true, value: { status: 'idempotent', record: toActiveSummary(existing) } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ok: false, error: 'CONFLICT' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { ok: false, error: 'CONFLICT' };
|
|
93
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { Collection } from 'mongodb';
|
|
2
|
+
|
|
3
|
+
import type { MongoShareContentDocument } from '@models';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
parseShareContentBinding,
|
|
7
|
+
shareContentBindingFilter,
|
|
8
|
+
toActiveSummary,
|
|
9
|
+
toTombstoneSummary,
|
|
10
|
+
} from './internal';
|
|
11
|
+
import type {
|
|
12
|
+
ShareContentContentProjection,
|
|
13
|
+
ShareContentRecoveryProjection,
|
|
14
|
+
ShareContentRepositoryResult,
|
|
15
|
+
ShareContentStatProjection,
|
|
16
|
+
} from './types';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Content projection for the exact active object. The owner recovery is never
|
|
20
|
+
* read into this projection. A missing object, a tombstone or any binding
|
|
21
|
+
* mismatch is reported as the same opaque `NOT_FOUND`, so the repository does
|
|
22
|
+
* not disclose another owner's object existence.
|
|
23
|
+
*/
|
|
24
|
+
export const getShareContentContent = async (
|
|
25
|
+
collection: Collection<MongoShareContentDocument>,
|
|
26
|
+
rawBinding: unknown
|
|
27
|
+
): Promise<ShareContentRepositoryResult<ShareContentContentProjection>> => {
|
|
28
|
+
const binding = parseShareContentBinding(rawBinding);
|
|
29
|
+
|
|
30
|
+
if (binding === null) return { ok: false, error: 'INVALID_INPUT' };
|
|
31
|
+
|
|
32
|
+
const document = await collection.findOne(
|
|
33
|
+
{ ...shareContentBindingFilter(binding), kind: 'active' },
|
|
34
|
+
{ projection: { ownerEncryptedRecovery: 0 } }
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (document === null || document.kind !== 'active') return { ok: false, error: 'NOT_FOUND' };
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
value: {
|
|
42
|
+
kind: 'active',
|
|
43
|
+
namespace: binding.namespace,
|
|
44
|
+
ownerProfileId: binding.ownerProfileId,
|
|
45
|
+
shareId: binding.shareId,
|
|
46
|
+
contentVersion: binding.contentVersion,
|
|
47
|
+
objectId: binding.objectId,
|
|
48
|
+
operationId: binding.operationId,
|
|
49
|
+
contentHash: document.contentHash,
|
|
50
|
+
payloadHash: document.payloadHash,
|
|
51
|
+
envelope: document.envelope,
|
|
52
|
+
ciphertextBytes: document.ciphertextBytes,
|
|
53
|
+
createdAt: document.createdAt,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Owner-only recovery projection for the exact active object. The ciphertext
|
|
60
|
+
* envelope is never read into this projection and the returned value carries no
|
|
61
|
+
* content bytes. The route layer must call this only after verifying the owner
|
|
62
|
+
* context; the repository is not an authorization boundary.
|
|
63
|
+
*/
|
|
64
|
+
export const getShareContentRecovery = async (
|
|
65
|
+
collection: Collection<MongoShareContentDocument>,
|
|
66
|
+
rawBinding: unknown
|
|
67
|
+
): Promise<ShareContentRepositoryResult<ShareContentRecoveryProjection>> => {
|
|
68
|
+
const binding = parseShareContentBinding(rawBinding);
|
|
69
|
+
|
|
70
|
+
if (binding === null) return { ok: false, error: 'INVALID_INPUT' };
|
|
71
|
+
|
|
72
|
+
const document = await collection.findOne(
|
|
73
|
+
{ ...shareContentBindingFilter(binding), kind: 'active' },
|
|
74
|
+
{ projection: { envelope: 0, ciphertextBytes: 0 } }
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (document === null || document.kind !== 'active') return { ok: false, error: 'NOT_FOUND' };
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
value: {
|
|
82
|
+
kind: 'active',
|
|
83
|
+
namespace: binding.namespace,
|
|
84
|
+
ownerProfileId: binding.ownerProfileId,
|
|
85
|
+
shareId: binding.shareId,
|
|
86
|
+
contentVersion: binding.contentVersion,
|
|
87
|
+
objectId: binding.objectId,
|
|
88
|
+
operationId: binding.operationId,
|
|
89
|
+
contentHash: document.contentHash,
|
|
90
|
+
payloadHash: document.payloadHash,
|
|
91
|
+
ownerEncryptedRecovery: document.ownerEncryptedRecovery,
|
|
92
|
+
recoveryBytes: document.recoveryBytes,
|
|
93
|
+
createdAt: document.createdAt,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Existence/hash/byte summary for the exact tuple. Returns the tombstone summary
|
|
100
|
+
* for a matching permanent tombstone (reconciliation needs it) and the opaque
|
|
101
|
+
* `NOT_FOUND` for any binding mismatch. No ciphertext or recovery is read.
|
|
102
|
+
*/
|
|
103
|
+
export const statShareContent = async (
|
|
104
|
+
collection: Collection<MongoShareContentDocument>,
|
|
105
|
+
rawBinding: unknown
|
|
106
|
+
): Promise<ShareContentRepositoryResult<ShareContentStatProjection>> => {
|
|
107
|
+
const binding = parseShareContentBinding(rawBinding);
|
|
108
|
+
|
|
109
|
+
if (binding === null) return { ok: false, error: 'INVALID_INPUT' };
|
|
110
|
+
|
|
111
|
+
const document = await collection.findOne(shareContentBindingFilter(binding), {
|
|
112
|
+
projection: { envelope: 0, ownerEncryptedRecovery: 0 },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (document === null) return { ok: false, error: 'NOT_FOUND' };
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
value:
|
|
120
|
+
document.kind === 'tombstone'
|
|
121
|
+
? toTombstoneSummary(document)
|
|
122
|
+
: toActiveSummary(document),
|
|
123
|
+
};
|
|
124
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { Collection } from 'mongodb';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
MongoShareContentDocument,
|
|
5
|
+
ShareContentHash,
|
|
6
|
+
ShareContentObjectBinding,
|
|
7
|
+
ShareContentPutInput,
|
|
8
|
+
ShareEnvelope,
|
|
9
|
+
ShareOwnerRecovery,
|
|
10
|
+
} from '@models';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Stable, non-secret repository result codes. Routes map every failure to a
|
|
14
|
+
* single generic external rejection; a repository error must never be returned
|
|
15
|
+
* verbatim to a caller.
|
|
16
|
+
*/
|
|
17
|
+
export type ShareContentRepositoryError =
|
|
18
|
+
'INVALID_INPUT' | 'NOT_FOUND' | 'TOMBSTONED' | 'BINDING_MISMATCH' | 'CONFLICT' | 'STORE_ERROR';
|
|
19
|
+
|
|
20
|
+
export type ShareContentRepositoryFailure = { ok: false; error: ShareContentRepositoryError };
|
|
21
|
+
|
|
22
|
+
export type ShareContentRepositoryResult<T> =
|
|
23
|
+
{ ok: true; value: T } | ShareContentRepositoryFailure;
|
|
24
|
+
|
|
25
|
+
/** `created` for the first write, `idempotent` for an exact retry. */
|
|
26
|
+
export type ShareContentWriteStatus = 'created' | 'idempotent';
|
|
27
|
+
|
|
28
|
+
/** Non-sensitive active summary; never includes ciphertext or recovery. */
|
|
29
|
+
export type ShareContentActiveSummary = {
|
|
30
|
+
kind: 'active';
|
|
31
|
+
namespace: string;
|
|
32
|
+
ownerProfileId: string;
|
|
33
|
+
shareId: string;
|
|
34
|
+
contentVersion: number;
|
|
35
|
+
objectId: string;
|
|
36
|
+
operationId: string;
|
|
37
|
+
contentHash: ShareContentHash;
|
|
38
|
+
payloadHash: ShareContentHash;
|
|
39
|
+
ciphertextBytes: number;
|
|
40
|
+
recoveryBytes: number;
|
|
41
|
+
createdAt: Date;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Tombstone summary retained after deletion. */
|
|
45
|
+
export type ShareContentTombstoneSummary = {
|
|
46
|
+
kind: 'tombstone';
|
|
47
|
+
namespace: string;
|
|
48
|
+
ownerProfileId: string;
|
|
49
|
+
shareId: string;
|
|
50
|
+
contentVersion: number;
|
|
51
|
+
objectId: string;
|
|
52
|
+
operationId: string;
|
|
53
|
+
contentHash?: ShareContentHash;
|
|
54
|
+
deletedAt: Date;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Content projection. The owner recovery is deliberately absent: it is only
|
|
59
|
+
* reachable through the separately authorized {@link
|
|
60
|
+
* ShareContentRepository.getRecovery} method.
|
|
61
|
+
*/
|
|
62
|
+
export type ShareContentContentProjection = {
|
|
63
|
+
kind: 'active';
|
|
64
|
+
namespace: string;
|
|
65
|
+
ownerProfileId: string;
|
|
66
|
+
shareId: string;
|
|
67
|
+
contentVersion: number;
|
|
68
|
+
objectId: string;
|
|
69
|
+
operationId: string;
|
|
70
|
+
contentHash: ShareContentHash;
|
|
71
|
+
payloadHash: ShareContentHash;
|
|
72
|
+
envelope: ShareEnvelope;
|
|
73
|
+
ciphertextBytes: number;
|
|
74
|
+
createdAt: Date;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Owner-only recovery projection. The ciphertext envelope is absent. The route
|
|
79
|
+
* layer must call this only for an authenticated owner-recovery request; the
|
|
80
|
+
* repository is not an authorization boundary by itself.
|
|
81
|
+
*/
|
|
82
|
+
export type ShareContentRecoveryProjection = {
|
|
83
|
+
kind: 'active';
|
|
84
|
+
namespace: string;
|
|
85
|
+
ownerProfileId: string;
|
|
86
|
+
shareId: string;
|
|
87
|
+
contentVersion: number;
|
|
88
|
+
objectId: string;
|
|
89
|
+
operationId: string;
|
|
90
|
+
contentHash: ShareContentHash;
|
|
91
|
+
payloadHash: ShareContentHash;
|
|
92
|
+
ownerEncryptedRecovery: ShareOwnerRecovery;
|
|
93
|
+
recoveryBytes: number;
|
|
94
|
+
createdAt: Date;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type ShareContentStatProjection = ShareContentActiveSummary | ShareContentTombstoneSummary;
|
|
98
|
+
|
|
99
|
+
/** Successful put value: `created` or an exact idempotent retry. */
|
|
100
|
+
export type ShareContentPutResultValue = {
|
|
101
|
+
status: ShareContentWriteStatus;
|
|
102
|
+
record: ShareContentActiveSummary;
|
|
103
|
+
};
|
|
104
|
+
export type ShareContentPutResult = ShareContentRepositoryResult<ShareContentPutResultValue>;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Narrow exact-object repository. There is deliberately no list, scan,
|
|
108
|
+
* wildcard or `deleteOwner` method: every call is bound to one immutable tuple.
|
|
109
|
+
*
|
|
110
|
+
* The repository enforces data-level bindings only. It is **not** an
|
|
111
|
+
* authorization boundary: callers must have already verified the C1 service
|
|
112
|
+
* authorization context (namespace/op/shareId/contentVersion/ownerProfileId/
|
|
113
|
+
* objectId/operationId/requestHash) before invoking it.
|
|
114
|
+
*/
|
|
115
|
+
export type ShareContentRepository = {
|
|
116
|
+
/** The injected collection. Useful for diagnostics; not a query surface. */
|
|
117
|
+
readonly collection: Collection<MongoShareContentDocument>;
|
|
118
|
+
/**
|
|
119
|
+
* Explicitly install the required unique identity index before serving any
|
|
120
|
+
* operation. Must be awaited during startup; index creation is never left to
|
|
121
|
+
* an eventual background build.
|
|
122
|
+
*/
|
|
123
|
+
initialize: () => Promise<void>;
|
|
124
|
+
/** Create-only write. Same tuple + derived hash is an idempotent success. */
|
|
125
|
+
put: (input: ShareContentPutInput) => Promise<ShareContentPutResult>;
|
|
126
|
+
/** Content projection (no recovery) for the active object, or a failure. */
|
|
127
|
+
getContent: (
|
|
128
|
+
binding: ShareContentObjectBinding
|
|
129
|
+
) => Promise<ShareContentRepositoryResult<ShareContentContentProjection>>;
|
|
130
|
+
/** Owner-only recovery projection for the active object, or a failure. */
|
|
131
|
+
getRecovery: (
|
|
132
|
+
binding: ShareContentObjectBinding
|
|
133
|
+
) => Promise<ShareContentRepositoryResult<ShareContentRecoveryProjection>>;
|
|
134
|
+
/** Existence/hash/byte summary for the exact tuple (active or tombstone). */
|
|
135
|
+
stat: (
|
|
136
|
+
binding: ShareContentObjectBinding
|
|
137
|
+
) => Promise<ShareContentRepositoryResult<ShareContentStatProjection>>;
|
|
138
|
+
/**
|
|
139
|
+
* Atomically tombstone the exact object. Deleting an absent identity writes a
|
|
140
|
+
* permanent tombstone first, so a late concurrent put can never resurrect it.
|
|
141
|
+
*/
|
|
142
|
+
delete: (
|
|
143
|
+
binding: ShareContentObjectBinding
|
|
144
|
+
) => Promise<ShareContentRepositoryResult<ShareContentTombstoneSummary>>;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Thrown when an operation is attempted before {@link ShareContentRepository.initialize}. */
|
|
148
|
+
export class ShareContentRepositoryNotInitializedError extends Error {
|
|
149
|
+
readonly code = 'SHARE_CONTENT_REPOSITORY_NOT_INITIALIZED';
|
|
150
|
+
|
|
151
|
+
constructor() {
|
|
152
|
+
super('share-content repository must be initialized before serving operations');
|
|
153
|
+
this.name = 'ShareContentRepositoryNotInitializedError';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -29,6 +29,13 @@ export const learnCloudServiceEnvironmentShape = {
|
|
|
29
29
|
IS_OFFLINE: optionalEnvironmentBoolean.default(false),
|
|
30
30
|
SKIP_DIDKIT_NAPI: optionalEnvironmentBoolean.default(false),
|
|
31
31
|
CI: optionalEnvironmentBoolean.default(false),
|
|
32
|
+
// LC-2187 service-only share-content routes. Available once an operator
|
|
33
|
+
// provisions an explicit audience, service DID allowlist, exact verification
|
|
34
|
+
// methods and per-service namespace bindings.
|
|
35
|
+
SHARE_CONTENT_AUDIENCE: optionalEnvironmentString,
|
|
36
|
+
SHARE_CONTENT_SERVICE_DIDS: optionalEnvironmentString,
|
|
37
|
+
SHARE_CONTENT_VERIFICATION_METHODS: optionalEnvironmentString,
|
|
38
|
+
SHARE_CONTENT_NAMESPACE_BINDINGS: optionalEnvironmentString,
|
|
32
39
|
} satisfies z.ZodRawShape;
|
|
33
40
|
|
|
34
41
|
export const learnCloudServiceEnvironmentSchema = z
|
|
@@ -56,6 +63,32 @@ export const learnCloudServiceEnvironmentSchema = z
|
|
|
56
63
|
message: 'RSA_PRIVATE_KEY and RSA_PUBLIC_KEY must be configured together',
|
|
57
64
|
});
|
|
58
65
|
}
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
[
|
|
69
|
+
environment.SHARE_CONTENT_AUDIENCE,
|
|
70
|
+
environment.SHARE_CONTENT_SERVICE_DIDS,
|
|
71
|
+
environment.SHARE_CONTENT_VERIFICATION_METHODS,
|
|
72
|
+
environment.SHARE_CONTENT_NAMESPACE_BINDINGS,
|
|
73
|
+
].some(Boolean)
|
|
74
|
+
) {
|
|
75
|
+
const requiredShareContentFields = [
|
|
76
|
+
'SHARE_CONTENT_AUDIENCE',
|
|
77
|
+
'SHARE_CONTENT_SERVICE_DIDS',
|
|
78
|
+
'SHARE_CONTENT_VERIFICATION_METHODS',
|
|
79
|
+
'SHARE_CONTENT_NAMESPACE_BINDINGS',
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
82
|
+
for (const field of requiredShareContentFields) {
|
|
83
|
+
if (!environment[field]) {
|
|
84
|
+
context.addIssue({
|
|
85
|
+
code: 'custom',
|
|
86
|
+
path: [field],
|
|
87
|
+
message: 'Required when share-content service configuration is supplied',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
59
92
|
});
|
|
60
93
|
|
|
61
94
|
export type LearnCloudServiceEnvironment = z.output<typeof learnCloudServiceEnvironmentSchema>;
|
package/src/docker-entry.ts
CHANGED
|
@@ -11,6 +11,11 @@ import { openApiDocument } from './openapi';
|
|
|
11
11
|
import { didFastifyPlugin } from './dids';
|
|
12
12
|
import { xapiFastifyPlugin } from './xapi';
|
|
13
13
|
import { oidcFastifyPlugin } from './oidc';
|
|
14
|
+
import {
|
|
15
|
+
createShareContentRuntimeFromEnvironment,
|
|
16
|
+
registerShareContentRoutes,
|
|
17
|
+
type ShareContentRuntime,
|
|
18
|
+
} from './share-content';
|
|
14
19
|
|
|
15
20
|
const server = Fastify({ maxParamLength: 5000 });
|
|
16
21
|
|
|
@@ -52,10 +57,24 @@ server.register(xapiFastifyPlugin);
|
|
|
52
57
|
server.register(oidcFastifyPlugin);
|
|
53
58
|
|
|
54
59
|
(async () => {
|
|
60
|
+
let shareContentRuntime: ShareContentRuntime | null = null;
|
|
61
|
+
|
|
55
62
|
try {
|
|
63
|
+
// Disabled by default: this performs no Mongo/Redis/index work unless an
|
|
64
|
+
// operator explicitly enables the routes with a valid configuration.
|
|
65
|
+
shareContentRuntime = await createShareContentRuntimeFromEnvironment();
|
|
66
|
+
|
|
67
|
+
if (shareContentRuntime.enabled) {
|
|
68
|
+
await registerShareContentRoutes(server, shareContentRuntime);
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
console.log('Server starting on port ', environment.PORT || 3000);
|
|
57
72
|
await server.listen({ host: '0.0.0.0', port: Number(environment.PORT || 3000) });
|
|
58
73
|
} catch (err) {
|
|
74
|
+
if (shareContentRuntime?.enabled) {
|
|
75
|
+
await shareContentRuntime.close().catch(() => {});
|
|
76
|
+
}
|
|
77
|
+
|
|
59
78
|
console.error(err);
|
|
60
79
|
process.exit(1);
|
|
61
80
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { PresentationVerificationResult, PresentationVerifier, ReplayStore } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thin, injectable adapters over the existing LearnCloud primitives.
|
|
5
|
+
*
|
|
6
|
+
* These are the seams the future service routes will wire. They are not
|
|
7
|
+
* registered anywhere in this batch: no router, no server entrypoint, no
|
|
8
|
+
* environment bootstrap.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Minimal structural view of `getEmptyLearnCard()` for presentation verification. */
|
|
12
|
+
export type LearnCardLike = {
|
|
13
|
+
invoke: {
|
|
14
|
+
verifyPresentation: (
|
|
15
|
+
token: string,
|
|
16
|
+
options: { proofFormat: 'jwt' }
|
|
17
|
+
) => Promise<PresentationVerificationResult>;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Adapter for the existing DIDKit verification used by LearnCloud's context:
|
|
23
|
+
* `learnCard.invoke.verifyPresentation(jwt, { proofFormat: 'jwt' })`.
|
|
24
|
+
*
|
|
25
|
+
* This adapter is intentionally not constructed at import time and is not
|
|
26
|
+
* registered on any route in this batch.
|
|
27
|
+
*/
|
|
28
|
+
export const createDidkitPresentationVerifier = (
|
|
29
|
+
getLearnCard: () => Promise<LearnCardLike>
|
|
30
|
+
): PresentationVerifier => {
|
|
31
|
+
return async (token: string) => {
|
|
32
|
+
const learnCard = await getLearnCard();
|
|
33
|
+
|
|
34
|
+
return learnCard.invoke.verifyPresentation(token, { proofFormat: 'jwt' });
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `SET key value NX EX ttl` style atomic primitive. Must resolve `true` only
|
|
40
|
+
* when the key was absent and this call created it, and must throw when the
|
|
41
|
+
* store is unavailable.
|
|
42
|
+
*/
|
|
43
|
+
export type SetIfAbsent = (key: string, ttlSeconds: number) => Promise<boolean>;
|
|
44
|
+
|
|
45
|
+
/** Adapt an atomic set-if-absent primitive (e.g. Redis) into a `ReplayStore`. */
|
|
46
|
+
export const createSetIfAbsentReplayStore = (setIfAbsent: SetIfAbsent): ReplayStore => ({
|
|
47
|
+
consumeOnce: (key, ttlSeconds) => setIfAbsent(key, ttlSeconds),
|
|
48
|
+
});
|