@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.
Files changed (89) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +19 -0
  3. package/dist/accesslayer/share-content/delete.d.ts +5 -0
  4. package/dist/accesslayer/share-content/delete.d.ts.map +1 -0
  5. package/dist/accesslayer/share-content/index.d.ts +8 -0
  6. package/dist/accesslayer/share-content/index.d.ts.map +1 -0
  7. package/dist/accesslayer/share-content/initialize.d.ts +5 -0
  8. package/dist/accesslayer/share-content/initialize.d.ts.map +1 -0
  9. package/dist/accesslayer/share-content/internal.d.ts +12 -0
  10. package/dist/accesslayer/share-content/internal.d.ts.map +1 -0
  11. package/dist/accesslayer/share-content/put.d.ts +5 -0
  12. package/dist/accesslayer/share-content/put.d.ts.map +1 -0
  13. package/dist/accesslayer/share-content/read.d.ts +7 -0
  14. package/dist/accesslayer/share-content/read.d.ts.map +1 -0
  15. package/dist/accesslayer/share-content/types.d.ts +85 -0
  16. package/dist/accesslayer/share-content/types.d.ts.map +1 -0
  17. package/dist/config/environment.d.ts +12 -0
  18. package/dist/config/environment.d.ts.map +1 -1
  19. package/dist/helpers/share-content-auth/adapters.d.ts +12 -0
  20. package/dist/helpers/share-content-auth/adapters.d.ts.map +1 -0
  21. package/dist/helpers/share-content-auth/canonical.d.ts +15 -0
  22. package/dist/helpers/share-content-auth/canonical.d.ts.map +1 -0
  23. package/dist/helpers/share-content-auth/claims.d.ts +19 -0
  24. package/dist/helpers/share-content-auth/claims.d.ts.map +1 -0
  25. package/dist/helpers/share-content-auth/config.d.ts +12 -0
  26. package/dist/helpers/share-content-auth/config.d.ts.map +1 -0
  27. package/dist/helpers/share-content-auth/index.d.ts +8 -0
  28. package/dist/helpers/share-content-auth/index.d.ts.map +1 -0
  29. package/dist/helpers/share-content-auth/token.d.ts +28 -0
  30. package/dist/helpers/share-content-auth/token.d.ts.map +1 -0
  31. package/dist/helpers/share-content-auth/types.d.ts +70 -0
  32. package/dist/helpers/share-content-auth/types.d.ts.map +1 -0
  33. package/dist/helpers/share-content-auth/verifier.d.ts +9 -0
  34. package/dist/helpers/share-content-auth/verifier.d.ts.map +1 -0
  35. package/dist/index.js +2 -2
  36. package/dist/models/ShareContent.d.ts +160 -0
  37. package/dist/models/ShareContent.d.ts.map +1 -0
  38. package/dist/models/index.d.ts +1 -0
  39. package/dist/models/index.d.ts.map +1 -1
  40. package/dist/share-content/config.d.ts +28 -0
  41. package/dist/share-content/config.d.ts.map +1 -0
  42. package/dist/share-content/index.d.ts +6 -0
  43. package/dist/share-content/index.d.ts.map +1 -0
  44. package/dist/share-content/plugin.d.ts +5 -0
  45. package/dist/share-content/plugin.d.ts.map +1 -0
  46. package/dist/share-content/production.d.ts +15 -0
  47. package/dist/share-content/production.d.ts.map +1 -0
  48. package/dist/share-content/runtime.d.ts +23 -0
  49. package/dist/share-content/runtime.d.ts.map +1 -0
  50. package/dist/share-content/types.d.ts +23 -0
  51. package/dist/share-content/types.d.ts.map +1 -0
  52. package/lambda.ts +61 -0
  53. package/package.json +14 -14
  54. package/serverless.yml +25 -0
  55. package/src/accesslayer/share-content/delete.ts +124 -0
  56. package/src/accesslayer/share-content/index.ts +70 -0
  57. package/src/accesslayer/share-content/initialize.ts +24 -0
  58. package/src/accesslayer/share-content/internal.ts +89 -0
  59. package/src/accesslayer/share-content/put.ts +93 -0
  60. package/src/accesslayer/share-content/read.ts +124 -0
  61. package/src/accesslayer/share-content/types.ts +155 -0
  62. package/src/config/environment.ts +33 -0
  63. package/src/docker-entry.ts +19 -0
  64. package/src/helpers/share-content-auth/adapters.ts +48 -0
  65. package/src/helpers/share-content-auth/canonical.ts +91 -0
  66. package/src/helpers/share-content-auth/claims.ts +164 -0
  67. package/src/helpers/share-content-auth/config.ts +160 -0
  68. package/src/helpers/share-content-auth/index.ts +59 -0
  69. package/src/helpers/share-content-auth/token.ts +148 -0
  70. package/src/helpers/share-content-auth/types.ts +195 -0
  71. package/src/helpers/share-content-auth/verifier.ts +220 -0
  72. package/src/models/ShareContent.ts +198 -0
  73. package/src/models/index.ts +1 -0
  74. package/src/share-content/config.ts +199 -0
  75. package/src/share-content/index.ts +5 -0
  76. package/src/share-content/plugin.ts +317 -0
  77. package/src/share-content/production.ts +107 -0
  78. package/src/share-content/runtime.ts +90 -0
  79. package/src/share-content/types.ts +59 -0
  80. package/test/share-content-auth.spec.ts +1161 -0
  81. package/test/share-content-config.spec.ts +252 -0
  82. package/test/share-content-didkit.spec.ts +411 -0
  83. package/test/share-content-endpoint.spec.ts +825 -0
  84. package/test/share-content-redis-setup.ts +18 -0
  85. package/test/share-content.spec.ts +681 -0
  86. package/vitest.config.ts +1 -0
  87. package/vitest.share-content-config-unit.config.ts +8 -0
  88. package/vitest.share-content-endpoint.config.ts +21 -0
  89. 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>;
@@ -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
+ });