@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,124 @@
|
|
|
1
|
+
import type { Collection } from 'mongodb';
|
|
2
|
+
|
|
3
|
+
import type { MongoShareContentDocument } from '@models';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
MAX_SHARE_CONTENT_ATTEMPTS,
|
|
7
|
+
bindingMatchesDocument,
|
|
8
|
+
parseShareContentBinding,
|
|
9
|
+
shareContentBindingFilter,
|
|
10
|
+
shareContentIdentityFilter,
|
|
11
|
+
toTombstoneSummary,
|
|
12
|
+
} from './internal';
|
|
13
|
+
import type { ShareContentRepositoryResult, ShareContentTombstoneSummary } from './types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Atomically tombstone the exact object.
|
|
17
|
+
*
|
|
18
|
+
* The active -> tombstone transition is a single atomic `findOneAndUpdate` that
|
|
19
|
+
* also removes the ciphertext/recovery fields, so once it returns the payload is
|
|
20
|
+
* gone. If the identity does not exist yet, a tombstone is inserted with an
|
|
21
|
+
* atomic `$setOnInsert` upsert (delete-before-put), so a late concurrent put can
|
|
22
|
+
* never resurrect the identity. The loop only re-runs when a racing writer
|
|
23
|
+
* changed the document between the two atomic steps; active documents are
|
|
24
|
+
* otherwise immutable and tombstones are permanent, so it always settles.
|
|
25
|
+
*
|
|
26
|
+
* A binding mismatch (including another owner's object at the same identity)
|
|
27
|
+
* never modifies the stored document and returns `BINDING_MISMATCH`.
|
|
28
|
+
*/
|
|
29
|
+
export const deleteShareContent = async (
|
|
30
|
+
collection: Collection<MongoShareContentDocument>,
|
|
31
|
+
rawBinding: unknown
|
|
32
|
+
): Promise<ShareContentRepositoryResult<ShareContentTombstoneSummary>> => {
|
|
33
|
+
const binding = parseShareContentBinding(rawBinding);
|
|
34
|
+
|
|
35
|
+
if (binding === null) return { ok: false, error: 'INVALID_INPUT' };
|
|
36
|
+
|
|
37
|
+
const exactFilter = shareContentBindingFilter(binding);
|
|
38
|
+
const identityFilter = shareContentIdentityFilter(binding);
|
|
39
|
+
|
|
40
|
+
for (let attempt = 0; attempt < MAX_SHARE_CONTENT_ATTEMPTS; attempt += 1) {
|
|
41
|
+
// 1. Atomic active -> tombstone, dropping the payload in the same update.
|
|
42
|
+
const deletedAt = new Date();
|
|
43
|
+
|
|
44
|
+
const replaced = await collection.findOneAndUpdate(
|
|
45
|
+
{ ...exactFilter, kind: 'active' },
|
|
46
|
+
{
|
|
47
|
+
$set: { kind: 'tombstone', deletedAt },
|
|
48
|
+
$unset: {
|
|
49
|
+
payloadHash: '',
|
|
50
|
+
envelope: '',
|
|
51
|
+
ownerEncryptedRecovery: '',
|
|
52
|
+
ciphertextBytes: '',
|
|
53
|
+
recoveryBytes: '',
|
|
54
|
+
createdAt: '',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{ returnDocument: 'after' }
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (replaced !== null) {
|
|
61
|
+
if (replaced.kind === 'tombstone') {
|
|
62
|
+
return { ok: true, value: toTombstoneSummary(replaced) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Unreachable under the `kind: 'active'` filter; a racing writer
|
|
66
|
+
// changed the document. Re-classify rather than trust the shape.
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2. Delete-before-put: create a permanent tombstone only if absent.
|
|
71
|
+
const inserted = await collection.updateOne(
|
|
72
|
+
identityFilter,
|
|
73
|
+
{
|
|
74
|
+
$setOnInsert: {
|
|
75
|
+
kind: 'tombstone',
|
|
76
|
+
namespace: binding.namespace,
|
|
77
|
+
ownerProfileId: binding.ownerProfileId,
|
|
78
|
+
shareId: binding.shareId,
|
|
79
|
+
contentVersion: binding.contentVersion,
|
|
80
|
+
objectId: binding.objectId,
|
|
81
|
+
operationId: binding.operationId,
|
|
82
|
+
deletedAt,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{ upsert: true }
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (inserted.upsertedCount === 1) {
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
value: {
|
|
92
|
+
kind: 'tombstone',
|
|
93
|
+
namespace: binding.namespace,
|
|
94
|
+
ownerProfileId: binding.ownerProfileId,
|
|
95
|
+
shareId: binding.shareId,
|
|
96
|
+
contentVersion: binding.contentVersion,
|
|
97
|
+
objectId: binding.objectId,
|
|
98
|
+
operationId: binding.operationId,
|
|
99
|
+
deletedAt,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. The identity exists but was not replaced: classify and fail closed.
|
|
105
|
+
const existing = await collection.findOne(identityFilter);
|
|
106
|
+
|
|
107
|
+
if (existing === null) continue;
|
|
108
|
+
|
|
109
|
+
if (existing.kind === 'tombstone') {
|
|
110
|
+
return bindingMatchesDocument(existing, binding)
|
|
111
|
+
? { ok: true, value: toTombstoneSummary(existing) }
|
|
112
|
+
: { ok: false, error: 'BINDING_MISMATCH' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!bindingMatchesDocument(existing, binding)) {
|
|
116
|
+
return { ok: false, error: 'BINDING_MISMATCH' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Exact active document: a concurrent writer slipped in between the two
|
|
120
|
+
// atomic steps; retry the replacement.
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { ok: false, error: 'CONFLICT' };
|
|
124
|
+
};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { applyLearnCloudStageConfig } from './stageConfig';
|
|
2
|
+
|
|
3
|
+
// Side-effect import: runs before @environment is evaluated so Docker/local processes
|
|
4
|
+
// pick up checked-in per-stage config. Keyed on CONFIG_STAGE (Lambda uses LAMBDA_STAGE);
|
|
5
|
+
// explicit non-empty env values from the developer's .env still win.
|
|
6
|
+
applyLearnCloudStageConfig(process.env.CONFIG_STAGE);
|