@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.
Files changed (124) hide show
  1. package/.env.example +24 -12
  2. package/CHANGELOG.md +43 -0
  3. package/bootstrapLambda.test.ts +68 -0
  4. package/config/config.dev.json +8 -0
  5. package/config/config.example.json +8 -0
  6. package/config/config.json +1 -0
  7. package/config/config.production.json +8 -0
  8. package/config/config.scouts.dev.json +3 -0
  9. package/config/config.scouts.production.json +4 -0
  10. package/didWebLambda.test.ts +41 -0
  11. package/didWebLambda.ts +13 -19
  12. package/didWebLambdaApp.ts +23 -0
  13. package/dist/accesslayer/share-content/delete.d.ts +5 -0
  14. package/dist/accesslayer/share-content/delete.d.ts.map +1 -0
  15. package/dist/accesslayer/share-content/index.d.ts +8 -0
  16. package/dist/accesslayer/share-content/index.d.ts.map +1 -0
  17. package/dist/accesslayer/share-content/initialize.d.ts +5 -0
  18. package/dist/accesslayer/share-content/initialize.d.ts.map +1 -0
  19. package/dist/accesslayer/share-content/internal.d.ts +12 -0
  20. package/dist/accesslayer/share-content/internal.d.ts.map +1 -0
  21. package/dist/accesslayer/share-content/put.d.ts +5 -0
  22. package/dist/accesslayer/share-content/put.d.ts.map +1 -0
  23. package/dist/accesslayer/share-content/read.d.ts +7 -0
  24. package/dist/accesslayer/share-content/read.d.ts.map +1 -0
  25. package/dist/accesslayer/share-content/types.d.ts +85 -0
  26. package/dist/accesslayer/share-content/types.d.ts.map +1 -0
  27. package/dist/config/applyDockerStageConfig.d.ts +2 -0
  28. package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
  29. package/dist/config/environment.d.ts +12 -0
  30. package/dist/config/environment.d.ts.map +1 -1
  31. package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
  32. package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
  33. package/dist/config/stageConfig.d.ts +10 -0
  34. package/dist/config/stageConfig.d.ts.map +1 -0
  35. package/dist/config/stageConfig.test.d.ts +2 -0
  36. package/dist/config/stageConfig.test.d.ts.map +1 -0
  37. package/dist/didkit_wasm_bg.wasm +0 -0
  38. package/dist/docker-entry.d.ts +1 -1
  39. package/dist/docker-entry.d.ts.map +1 -1
  40. package/dist/helpers/share-content-auth/adapters.d.ts +12 -0
  41. package/dist/helpers/share-content-auth/adapters.d.ts.map +1 -0
  42. package/dist/helpers/share-content-auth/canonical.d.ts +15 -0
  43. package/dist/helpers/share-content-auth/canonical.d.ts.map +1 -0
  44. package/dist/helpers/share-content-auth/claims.d.ts +19 -0
  45. package/dist/helpers/share-content-auth/claims.d.ts.map +1 -0
  46. package/dist/helpers/share-content-auth/config.d.ts +12 -0
  47. package/dist/helpers/share-content-auth/config.d.ts.map +1 -0
  48. package/dist/helpers/share-content-auth/index.d.ts +8 -0
  49. package/dist/helpers/share-content-auth/index.d.ts.map +1 -0
  50. package/dist/helpers/share-content-auth/token.d.ts +28 -0
  51. package/dist/helpers/share-content-auth/token.d.ts.map +1 -0
  52. package/dist/helpers/share-content-auth/types.d.ts +70 -0
  53. package/dist/helpers/share-content-auth/types.d.ts.map +1 -0
  54. package/dist/helpers/share-content-auth/verifier.d.ts +9 -0
  55. package/dist/helpers/share-content-auth/verifier.d.ts.map +1 -0
  56. package/dist/index.js +2 -2
  57. package/dist/models/ShareContent.d.ts +160 -0
  58. package/dist/models/ShareContent.d.ts.map +1 -0
  59. package/dist/models/index.d.ts +1 -0
  60. package/dist/models/index.d.ts.map +1 -1
  61. package/dist/share-content/config.d.ts +28 -0
  62. package/dist/share-content/config.d.ts.map +1 -0
  63. package/dist/share-content/index.d.ts +6 -0
  64. package/dist/share-content/index.d.ts.map +1 -0
  65. package/dist/share-content/plugin.d.ts +5 -0
  66. package/dist/share-content/plugin.d.ts.map +1 -0
  67. package/dist/share-content/production.d.ts +15 -0
  68. package/dist/share-content/production.d.ts.map +1 -0
  69. package/dist/share-content/runtime.d.ts +23 -0
  70. package/dist/share-content/runtime.d.ts.map +1 -0
  71. package/dist/share-content/types.d.ts +23 -0
  72. package/dist/share-content/types.d.ts.map +1 -0
  73. package/esbuildPlugins.cjs +1 -0
  74. package/lambda.ts +22 -92
  75. package/lambdaApp.ts +163 -0
  76. package/oidcLambda.test.ts +41 -0
  77. package/oidcLambda.ts +13 -19
  78. package/oidcLambdaApp.ts +23 -0
  79. package/package.json +18 -16
  80. package/serverless-local.yml +3 -1
  81. package/serverless.function-env.cjs +31 -0
  82. package/serverless.yml +31 -22
  83. package/src/accesslayer/share-content/delete.ts +124 -0
  84. package/src/accesslayer/share-content/index.ts +70 -0
  85. package/src/accesslayer/share-content/initialize.ts +24 -0
  86. package/src/accesslayer/share-content/internal.ts +89 -0
  87. package/src/accesslayer/share-content/put.ts +93 -0
  88. package/src/accesslayer/share-content/read.ts +124 -0
  89. package/src/accesslayer/share-content/types.ts +155 -0
  90. package/src/config/applyDockerStageConfig.ts +6 -0
  91. package/src/config/environment.ts +33 -0
  92. package/src/config/serverlessFunctionEnv.test.ts +81 -0
  93. package/src/config/stageConfig.test.ts +26 -0
  94. package/src/config/stageConfig.ts +24 -0
  95. package/src/docker-entry.ts +21 -0
  96. package/src/helpers/share-content-auth/adapters.ts +48 -0
  97. package/src/helpers/share-content-auth/canonical.ts +91 -0
  98. package/src/helpers/share-content-auth/claims.ts +164 -0
  99. package/src/helpers/share-content-auth/config.ts +160 -0
  100. package/src/helpers/share-content-auth/index.ts +59 -0
  101. package/src/helpers/share-content-auth/token.ts +148 -0
  102. package/src/helpers/share-content-auth/types.ts +195 -0
  103. package/src/helpers/share-content-auth/verifier.ts +220 -0
  104. package/src/models/ShareContent.ts +198 -0
  105. package/src/models/index.ts +1 -0
  106. package/src/share-content/config.ts +199 -0
  107. package/src/share-content/index.ts +5 -0
  108. package/src/share-content/plugin.ts +317 -0
  109. package/src/share-content/production.ts +107 -0
  110. package/src/share-content/runtime.ts +90 -0
  111. package/src/share-content/types.ts +59 -0
  112. package/test/share-content-auth.spec.ts +1161 -0
  113. package/test/share-content-config.spec.ts +252 -0
  114. package/test/share-content-didkit.spec.ts +411 -0
  115. package/test/share-content-endpoint.spec.ts +825 -0
  116. package/test/share-content-redis-setup.ts +18 -0
  117. package/test/share-content.spec.ts +681 -0
  118. package/vitest.config.ts +4 -0
  119. package/vitest.share-content-config-unit.config.ts +8 -0
  120. package/vitest.share-content-endpoint.config.ts +21 -0
  121. package/vitest.share-content.config.ts +19 -0
  122. package/xApiLambda.test.ts +41 -0
  123. package/xApiLambda.ts +13 -19
  124. 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);