@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,252 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import type { ShareContentRepository } from '@accesslayer/share-content';
4
+ import type { LearnCardLike, ReplayStore } from '@helpers/share-content-auth';
5
+
6
+ import { parseLearnCloudServiceEnvironment } from '@environment';
7
+ import {
8
+ SHARE_CONTENT_MAX_REQUEST_BYTES,
9
+ buildShareContentRuntime,
10
+ getShareContentRawConfig,
11
+ parseShareContentNamespaceBindings,
12
+ resolveShareContentConfig,
13
+ } from '../src/share-content';
14
+
15
+ const AUDIENCE = 'did:web:cloud.learncard.com';
16
+ const SIGNER = 'did:web:brain.learncard.com';
17
+ const KID = `${SIGNER}#key-1`;
18
+ const NAMESPACE = 'learncard';
19
+
20
+ const fullConfig = (overrides: Record<string, unknown> = {}) => ({
21
+ audience: AUDIENCE,
22
+ serviceDids: SIGNER,
23
+ verificationMethods: KID,
24
+ namespaceBindings: JSON.stringify({ [SIGNER]: [NAMESPACE] }),
25
+ ...overrides,
26
+ });
27
+
28
+ describe('share-content runtime configuration', () => {
29
+ it('does no setup without service wiring', () => {
30
+ expect(resolveShareContentConfig({}).status).toBe('disabled');
31
+ expect(
32
+ resolveShareContentConfig({
33
+ audience: '',
34
+ serviceDids: '',
35
+ verificationMethods: '',
36
+ namespaceBindings: '',
37
+ }).status
38
+ ).toBe('disabled');
39
+ expect(resolveShareContentConfig(null).status).toBe('disabled');
40
+ });
41
+
42
+ it('is available without a rollout flag and ignores its retired value', () => {
43
+ expect(resolveShareContentConfig(fullConfig()).status).toBe('enabled');
44
+ expect(resolveShareContentConfig(fullConfig({ enabled: false })).status).toBe('enabled');
45
+ });
46
+
47
+ it('is invalid when an enabled configuration is incomplete (fail closed)', () => {
48
+ for (const missing of [
49
+ { audience: undefined },
50
+ { serviceDids: undefined },
51
+ { verificationMethods: undefined },
52
+ { namespaceBindings: undefined },
53
+ ]) {
54
+ const result = resolveShareContentConfig(fullConfig(missing));
55
+
56
+ expect(result.status).toBe('invalid');
57
+ }
58
+
59
+ expect(resolveShareContentConfig(fullConfig({ audience: ' ' })).status).toBe('invalid');
60
+ expect(resolveShareContentConfig(fullConfig({ serviceDids: [] })).status).toBe('invalid');
61
+ expect(resolveShareContentConfig(fullConfig({ verificationMethods: '' })).status).toBe(
62
+ 'invalid'
63
+ );
64
+ expect(resolveShareContentConfig(fullConfig({ namespaceBindings: '{}' })).status).toBe(
65
+ 'invalid'
66
+ );
67
+ expect(
68
+ resolveShareContentConfig(fullConfig({ namespaceBindings: 'not-json' })).status
69
+ ).toBe('invalid');
70
+ });
71
+
72
+ it('rejects bindings that do not exactly match the service DID allowlist', () => {
73
+ const missingEntry = resolveShareContentConfig(
74
+ fullConfig({
75
+ serviceDids: `${SIGNER},did:web:brain2.learncard.com`,
76
+ namespaceBindings: JSON.stringify({ [SIGNER]: [NAMESPACE] }),
77
+ })
78
+ );
79
+ expect(missingEntry.status).toBe('invalid');
80
+
81
+ const nonAllowlisted = resolveShareContentConfig(
82
+ fullConfig({
83
+ namespaceBindings: JSON.stringify({
84
+ [SIGNER]: [NAMESPACE],
85
+ 'did:web:brain2.learncard.com': ['other'],
86
+ }),
87
+ })
88
+ );
89
+ expect(nonAllowlisted.status).toBe('invalid');
90
+ });
91
+
92
+ it('parses per-service bindings and enforces them per signer', () => {
93
+ const result = resolveShareContentConfig(
94
+ fullConfig({
95
+ serviceDids: `${SIGNER},did:web:brain2.learncard.com`,
96
+ verificationMethods: `${KID},did:web:brain2.learncard.com#key-2`,
97
+ namespaceBindings: JSON.stringify({
98
+ [SIGNER]: [NAMESPACE],
99
+ 'did:web:brain2.learncard.com': ['tenant-b', 'tenant-c'],
100
+ }),
101
+ })
102
+ );
103
+
104
+ expect(result.status).toBe('enabled');
105
+
106
+ if (result.status !== 'enabled') throw new Error('expected enabled');
107
+
108
+ expect(result.namespacePolicy.isAllowed(SIGNER, NAMESPACE)).toBe(true);
109
+ expect(result.namespacePolicy.isAllowed(SIGNER, 'tenant-b')).toBe(false);
110
+ expect(result.namespacePolicy.isAllowed('did:web:brain2.learncard.com', 'tenant-b')).toBe(
111
+ true
112
+ );
113
+ expect(result.namespacePolicy.isAllowed('did:web:unknown', NAMESPACE)).toBe(false);
114
+ expect(result.namespacePolicy.allowedNamespacesFor(SIGNER)).toEqual([NAMESPACE]);
115
+ });
116
+
117
+ it('parses bindings from a plain object as well as a JSON string', () => {
118
+ expect(parseShareContentNamespaceBindings({ [SIGNER]: [NAMESPACE] })?.get(SIGNER)).toEqual([
119
+ NAMESPACE,
120
+ ]);
121
+ expect(parseShareContentNamespaceBindings(`{"${SIGNER}":["${NAMESPACE}"]}`)?.size).toBe(1);
122
+ expect(parseShareContentNamespaceBindings({ [SIGNER]: [] })).toBeNull();
123
+ expect(parseShareContentNamespaceBindings([])).toBeNull();
124
+ });
125
+ });
126
+
127
+ describe('share-content runtime boot order and disabled side effects', () => {
128
+ const makeDependencies = (order: string[]) => {
129
+ const repository = {
130
+ initialize: vi.fn(async () => {
131
+ order.push('initialize');
132
+ }),
133
+ } as unknown as ShareContentRepository;
134
+
135
+ const replayStore = {
136
+ consumeOnce: vi.fn(async () => true),
137
+ } as unknown as ReplayStore;
138
+
139
+ const getLearnCard = vi.fn(
140
+ async () => ({ invoke: { verifyPresentation: vi.fn() } }) as unknown as LearnCardLike
141
+ );
142
+ const createReplayStore = vi.fn(() => {
143
+ order.push('replay');
144
+
145
+ return { store: replayStore, close: vi.fn(async () => {}) };
146
+ });
147
+ const getRepository = vi.fn(async () => {
148
+ order.push('repository');
149
+
150
+ return repository;
151
+ });
152
+
153
+ return { getLearnCard, createReplayStore, getRepository };
154
+ };
155
+
156
+ it('performs no repository or Redis side effects while disabled', async () => {
157
+ const order: string[] = [];
158
+ const dependencies = makeDependencies(order);
159
+
160
+ const runtime = await buildShareContentRuntime({ enabled: false }, dependencies);
161
+
162
+ expect(runtime.enabled).toBe(false);
163
+ expect(dependencies.getRepository).not.toHaveBeenCalled();
164
+ expect(dependencies.createReplayStore).not.toHaveBeenCalled();
165
+ expect(dependencies.getLearnCard).not.toHaveBeenCalled();
166
+ expect(order).toEqual([]);
167
+ });
168
+
169
+ it('fails explicitly on an invalid enabled configuration without opening resources', async () => {
170
+ const order: string[] = [];
171
+ const dependencies = makeDependencies(order);
172
+
173
+ await expect(
174
+ buildShareContentRuntime({ audience: AUDIENCE }, dependencies)
175
+ ).rejects.toThrow(/Invalid LC-2187 share-content configuration/);
176
+
177
+ expect(dependencies.getRepository).not.toHaveBeenCalled();
178
+ expect(dependencies.createReplayStore).not.toHaveBeenCalled();
179
+ expect(order).toEqual([]);
180
+ });
181
+
182
+ it('awaits repository initialization before building the verifier and replay store', async () => {
183
+ const order: string[] = [];
184
+ const dependencies = makeDependencies(order);
185
+
186
+ const runtime = await buildShareContentRuntime(fullConfig(), dependencies);
187
+
188
+ expect(runtime.enabled).toBe(true);
189
+ expect(order).toEqual(['repository', 'initialize', 'replay']);
190
+ expect(runtime.enabled && runtime.pluginOptions.maxRequestBytes).toBe(
191
+ SHARE_CONTENT_MAX_REQUEST_BYTES
192
+ );
193
+
194
+ if (runtime.enabled) await runtime.close();
195
+ });
196
+ });
197
+
198
+ describe('share-content environment schema', () => {
199
+ it('defaults to disabled and does not require any share-content values', () => {
200
+ const environment = parseLearnCloudServiceEnvironment({ NODE_ENV: 'test' }, 'test');
201
+
202
+ expect(getShareContentRawConfig(environment)).toEqual({
203
+ audience: undefined,
204
+ serviceDids: undefined,
205
+ verificationMethods: undefined,
206
+ namespaceBindings: undefined,
207
+ });
208
+ });
209
+
210
+ it('requires all trust fields when any are supplied', () => {
211
+ expect(() =>
212
+ parseLearnCloudServiceEnvironment(
213
+ { NODE_ENV: 'test', SHARE_CONTENT_SERVICE_DIDS: SIGNER },
214
+ 'test'
215
+ )
216
+ ).toThrow(/SHARE_CONTENT_AUDIENCE/);
217
+
218
+ const environment = parseLearnCloudServiceEnvironment(
219
+ {
220
+ NODE_ENV: 'test',
221
+ SHARE_CONTENT_AUDIENCE: AUDIENCE,
222
+ SHARE_CONTENT_SERVICE_DIDS: SIGNER,
223
+ SHARE_CONTENT_VERIFICATION_METHODS: KID,
224
+ SHARE_CONTENT_NAMESPACE_BINDINGS: JSON.stringify({ [SIGNER]: [NAMESPACE] }),
225
+ },
226
+ 'test'
227
+ );
228
+
229
+ expect(resolveShareContentConfig(getShareContentRawConfig(environment)).status).toBe(
230
+ 'enabled'
231
+ );
232
+ });
233
+ });
234
+
235
+ describe('share-content environment wiring refuses absent Redis', () => {
236
+ it('fails the boot when the real replay store cannot be created', async () => {
237
+ const repository = {
238
+ initialize: vi.fn(async () => {}),
239
+ } as unknown as ShareContentRepository;
240
+
241
+ await expect(
242
+ buildShareContentRuntime(fullConfig(), {
243
+ getLearnCard: async () =>
244
+ ({ invoke: { verifyPresentation: vi.fn() } }) as unknown as LearnCardLike,
245
+ getRepository: async () => repository,
246
+ createReplayStore: () => {
247
+ throw new Error('Redis endpoint missing');
248
+ },
249
+ })
250
+ ).rejects.toThrow(/Redis endpoint missing/);
251
+ });
252
+ });
@@ -0,0 +1,411 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { inject } from 'vitest';
3
+ import { createShareContentClient } from '../../brain-service/src/helpers/share-content-client/client';
4
+ import { resolveShareContentClientConfig } from '../../brain-service/src/helpers/share-content-client/config';
5
+ import { createShareContentRedisReplayStore } from '../src/share-content/production';
6
+
7
+ import Fastify, { type FastifyInstance } from 'fastify';
8
+ import { MongoClient, type Collection, type Db } from 'mongodb';
9
+ import { MongoMemoryServer } from 'mongodb-memory-server';
10
+
11
+ import { encodeBase64Url, type ShareEnvelope } from '@learncard/types';
12
+
13
+ import {
14
+ createShareContentRepository,
15
+ type ShareContentObjectBinding,
16
+ } from '@accesslayer/share-content';
17
+ import {
18
+ SHARE_CONTENT_AUTH_PURPOSE,
19
+ createDidkitPresentationVerifier,
20
+ createShareContentAuthorizationVerifier,
21
+ computeShareContentRequestHash,
22
+ type ShareContentAuthorizationVerifier,
23
+ } from '@helpers/share-content-auth';
24
+ import { getEmptyLearnCard, getLearnCard } from '@helpers/learnCard.helpers';
25
+ import type { MongoShareContentDocument } from '@models';
26
+
27
+ import {
28
+ SHARE_CONTENT_PLUGIN_PREFIX,
29
+ resolveShareContentConfig,
30
+ shareContentFastifyPlugin,
31
+ type ShareContentPluginOptions,
32
+ } from '../src/share-content';
33
+
34
+ const NOW = 1_700_000_000;
35
+ const AUDIENCE = 'did:web:cloud.learncard.com';
36
+ const NAMESPACE = 'learncard';
37
+ const SHARE_ID = Buffer.from(new Uint8Array(16).fill(7)).toString('base64url');
38
+
39
+ const SIGNER_SEED = 'c'.repeat(64);
40
+ const USER_SEED = 'd'.repeat(64);
41
+
42
+ const makeEnvelope = (fill = 1, bytes = 64): ShareEnvelope => ({
43
+ v: 1,
44
+ alg: 'A256GCM',
45
+ iv: encodeBase64Url(new Uint8Array(12).fill(7)),
46
+ ct: encodeBase64Url(new Uint8Array(bytes).fill(fill)),
47
+ });
48
+
49
+ const makeRecovery = () => ({
50
+ protected: 'protected-a',
51
+ iv: 'iv-a',
52
+ ciphertext: 'recovery-real-didkit',
53
+ tag: 'tag-a',
54
+ });
55
+
56
+ const makeBinding = (): ShareContentObjectBinding => ({
57
+ namespace: NAMESPACE,
58
+ ownerProfileId: 'owner-profile-1',
59
+ shareId: SHARE_ID,
60
+ contentVersion: 1,
61
+ objectId: 'object-didkit-1',
62
+ operationId: 'operation-didkit-1',
63
+ });
64
+
65
+ const makePutBody = () => ({
66
+ ...makeBinding(),
67
+ envelope: makeEnvelope(1),
68
+ ownerEncryptedRecovery: makeRecovery(),
69
+ });
70
+
71
+ let mongoServer: MongoMemoryServer;
72
+ let mongoClient: MongoClient;
73
+ let db: Db;
74
+
75
+ let signerDid: string;
76
+ let signerLearnCard: Awaited<ReturnType<typeof getLearnCard>>;
77
+ let userLearnCard: Awaited<ReturnType<typeof getLearnCard>>;
78
+
79
+ beforeAll(async () => {
80
+ mongoServer = await MongoMemoryServer.create();
81
+ mongoClient = new MongoClient(mongoServer.getUri());
82
+ await mongoClient.connect();
83
+ db = mongoClient.db('share_content_didkit');
84
+
85
+ signerLearnCard = await getLearnCard(SIGNER_SEED);
86
+ userLearnCard = await getLearnCard(USER_SEED);
87
+ signerDid = signerLearnCard.id.did();
88
+ }, 120_000);
89
+
90
+ afterAll(async () => {
91
+ await mongoClient?.close();
92
+ await mongoServer?.stop();
93
+ });
94
+
95
+ const buildClaims = (
96
+ op: string,
97
+ body: Record<string, unknown>,
98
+ overrides: Record<string, unknown> = {}
99
+ ) => ({
100
+ iss: signerDid,
101
+ aud: AUDIENCE,
102
+ purpose: SHARE_CONTENT_AUTH_PURPOSE,
103
+ namespace: body.namespace,
104
+ op,
105
+ shareId: body.shareId,
106
+ contentVersion: body.contentVersion,
107
+ ownerProfileId: body.ownerProfileId,
108
+ objectId: body.objectId,
109
+ operationId: body.operationId,
110
+ requestHash: computeShareContentRequestHash(body),
111
+ iat: NOW,
112
+ exp: NOW + 30,
113
+ jti: `jti-${randomUUID().replace(/-/g, '')}`,
114
+ ...overrides,
115
+ });
116
+
117
+ const signClaims = async (
118
+ learnCard: Awaited<ReturnType<typeof getLearnCard>>,
119
+ holderDid: string,
120
+ claims: unknown
121
+ ): Promise<string> =>
122
+ learnCard.invoke.issuePresentation(
123
+ {
124
+ '@context': ['https://www.w3.org/2018/credentials/v1'],
125
+ type: ['VerifiablePresentation'],
126
+ holder: holderDid,
127
+ verifiableCredential: [],
128
+ },
129
+ {
130
+ proofFormat: 'jwt',
131
+ proofPurpose: 'authentication',
132
+ challenge: JSON.stringify(claims),
133
+ }
134
+ );
135
+
136
+ const decodeKid = (token: string): string => {
137
+ const header = JSON.parse(Buffer.from(token.split('.')[0] as string, 'base64url').toString());
138
+
139
+ return header.kid;
140
+ };
141
+
142
+ const makeReplayStore = () => {
143
+ const seen = new Set<string>();
144
+
145
+ return {
146
+ store: {
147
+ consumeOnce: async (key: string) => {
148
+ if (seen.has(key)) return false;
149
+
150
+ seen.add(key);
151
+
152
+ return true;
153
+ },
154
+ },
155
+ close: async () => {},
156
+ };
157
+ };
158
+
159
+ type DidkitHarness = {
160
+ app: FastifyInstance;
161
+ verifier: ShareContentAuthorizationVerifier;
162
+ collection: Collection<MongoShareContentDocument>;
163
+ close: () => Promise<void>;
164
+ };
165
+
166
+ const makeHarness = async (realRedis = false): Promise<DidkitHarness> => {
167
+ const collection = db.collection<MongoShareContentDocument>(
168
+ `share_content_didkit_${randomUUID().replace(/-/g, '')}`
169
+ );
170
+ const repository = createShareContentRepository(collection);
171
+ await repository.initialize();
172
+
173
+ // A real signed token is required to learn the exact `kid`, exactly like an
174
+ // operator provisions the allowlist before rotating a signing key.
175
+ const probeBody = makePutBody();
176
+ const probeToken = await signClaims(
177
+ signerLearnCard,
178
+ signerDid,
179
+ buildClaims('put', probeBody as unknown as Record<string, unknown>)
180
+ );
181
+ const kid = decodeKid(probeToken);
182
+
183
+ const resolved = resolveShareContentConfig({
184
+ enabled: true,
185
+ audience: AUDIENCE,
186
+ serviceDids: [signerDid],
187
+ verificationMethods: [kid],
188
+ namespaceBindings: { [signerDid]: [NAMESPACE] },
189
+ });
190
+
191
+ if (resolved.status !== 'enabled') {
192
+ throw new Error(`test config not enabled: ${JSON.stringify(resolved)}`);
193
+ }
194
+
195
+ const replay = realRedis
196
+ ? createShareContentRedisReplayStore({
197
+ host: inject('share-content-redis-host'),
198
+ port: inject('share-content-redis-port'),
199
+ })
200
+ : makeReplayStore();
201
+
202
+ const verifier = createShareContentAuthorizationVerifier({
203
+ config: resolved.trustConfig,
204
+ verifyPresentation: createDidkitPresentationVerifier(() => getEmptyLearnCard()),
205
+ replayStore: replay.store,
206
+ now: () => NOW,
207
+ });
208
+
209
+ const pluginOptions: ShareContentPluginOptions = {
210
+ verifier,
211
+ repository,
212
+ namespacePolicy: resolved.namespacePolicy,
213
+ };
214
+
215
+ const app = Fastify({ logger: false });
216
+ await app.register(shareContentFastifyPlugin, {
217
+ prefix: SHARE_CONTENT_PLUGIN_PREFIX,
218
+ ...pluginOptions,
219
+ });
220
+ await app.ready();
221
+
222
+ return {
223
+ app,
224
+ verifier,
225
+ collection,
226
+ close: async () => {
227
+ await app.close();
228
+ await replay.close();
229
+ },
230
+ };
231
+ };
232
+
233
+ const injectPut = (app: FastifyInstance, body: unknown, token: string) =>
234
+ app.inject({
235
+ method: 'POST',
236
+ url: `${SHARE_CONTENT_PLUGIN_PREFIX}/put`,
237
+ headers: { authorization: `Bearer ${token}` },
238
+ payload: body as object,
239
+ });
240
+
241
+ describe('share-content real DIDKit signed JWT VP acceptance', () => {
242
+ it('accepts a real holder-signed presentation and serves the put through the full stack', async () => {
243
+ const harness = await makeHarness();
244
+
245
+ try {
246
+ const body = makePutBody();
247
+ const claims = buildClaims('put', body as unknown as Record<string, unknown>);
248
+ const token = await signClaims(signerLearnCard, signerDid, claims);
249
+
250
+ const response = await injectPut(harness.app, body, token);
251
+
252
+ expect(response.statusCode).toBe(200);
253
+ expect(JSON.parse(response.body).value.status).toBe('created');
254
+ } finally {
255
+ await harness.close();
256
+ }
257
+ });
258
+
259
+ it('rejects a tampered signature', async () => {
260
+ const harness = await makeHarness();
261
+
262
+ try {
263
+ const body = makePutBody();
264
+ const token = await signClaims(
265
+ signerLearnCard,
266
+ signerDid,
267
+ buildClaims('put', body as unknown as Record<string, unknown>)
268
+ );
269
+
270
+ const parts = token.split('.');
271
+ const signature = parts[2] as string;
272
+ const tampered = signature.slice(0, -1) + (signature.endsWith('A') ? 'B' : 'A');
273
+ const tamperedToken = `${parts[0]}.${parts[1]}.${tampered}`;
274
+
275
+ const response = await injectPut(harness.app, body, tamperedToken);
276
+
277
+ expect(response.statusCode).toBe(401);
278
+ } finally {
279
+ await harness.close();
280
+ }
281
+ });
282
+
283
+ it('rejects a token signed by a non-allowlisted user DID', async () => {
284
+ const harness = await makeHarness();
285
+
286
+ try {
287
+ const body = makePutBody();
288
+ const userDid = userLearnCard.id.did();
289
+ const claims = buildClaims('put', body as unknown as Record<string, unknown>, {
290
+ iss: userDid,
291
+ });
292
+ const token = await signClaims(userLearnCard, userDid, claims);
293
+
294
+ const response = await injectPut(harness.app, body, token);
295
+
296
+ expect(response.statusCode).toBe(401);
297
+ } finally {
298
+ await harness.close();
299
+ }
300
+ });
301
+
302
+ it('rejects a replayed real token', async () => {
303
+ const harness = await makeHarness();
304
+
305
+ try {
306
+ const body = makePutBody();
307
+ const token = await signClaims(
308
+ signerLearnCard,
309
+ signerDid,
310
+ buildClaims('put', body as unknown as Record<string, unknown>)
311
+ );
312
+
313
+ const first = await injectPut(harness.app, body, token);
314
+ const second = await injectPut(harness.app, body, token);
315
+
316
+ expect(first.statusCode).toBe(200);
317
+ expect(second.statusCode).toBe(401);
318
+ } finally {
319
+ await harness.close();
320
+ }
321
+ });
322
+
323
+ it('rejects a real token bound to a different body hash', async () => {
324
+ const harness = await makeHarness();
325
+
326
+ try {
327
+ const signedBody = makePutBody();
328
+ const token = await signClaims(
329
+ signerLearnCard,
330
+ signerDid,
331
+ buildClaims('put', signedBody as unknown as Record<string, unknown>)
332
+ );
333
+ const differentBody = {
334
+ ...signedBody,
335
+ envelope: makeEnvelope(9),
336
+ };
337
+
338
+ const response = await injectPut(harness.app, differentBody, token);
339
+
340
+ expect(response.statusCode).toBe(401);
341
+ } finally {
342
+ await harness.close();
343
+ }
344
+ });
345
+ });
346
+
347
+ describe('Brain client against the actual LearnCloud HTTP endpoint', () => {
348
+ it('signs all operations, retries a lost committed response, and preserves permanent deletion', async () => {
349
+ const harness = await makeHarness(true);
350
+ try {
351
+ const origin = await harness.app.listen({ host: '127.0.0.1', port: 0 });
352
+ const nonces: string[] = [];
353
+ let loseFirstResponse = true;
354
+ const client = createShareContentClient(
355
+ resolveShareContentClientConfig({
356
+ enabled: true,
357
+ origin,
358
+ allowInsecureLoopback: true,
359
+ namespace: NAMESPACE,
360
+ audience: AUDIENCE,
361
+ signerDid,
362
+ now: () => NOW * 1000,
363
+ maxAttempts: 2,
364
+ retryBackoffMs: 0,
365
+ signer: async (claims: { jti: string }) => {
366
+ nonces.push(claims.jti);
367
+ return signClaims(signerLearnCard, signerDid, claims);
368
+ },
369
+ fetchImpl: async (...args: Parameters<typeof fetch>) => {
370
+ const response = await fetch(...args);
371
+ if (loseFirstResponse) {
372
+ loseFirstResponse = false;
373
+ expect(response.status).toBe(200);
374
+ await response.text();
375
+ throw new Error('Simulated lost response after Mongo commit');
376
+ }
377
+ return response;
378
+ },
379
+ })
380
+ );
381
+ const body = makePutBody();
382
+ const binding = makeBinding();
383
+ const put = await client.put(body);
384
+ expect(put.ok).toBe(true);
385
+ expect(nonces).toHaveLength(2);
386
+ expect(new Set(nonces).size).toBe(2);
387
+ const content = await client.get(binding);
388
+ expect(content).toMatchObject({ ok: true, value: { envelope: body.envelope } });
389
+ if (content.ok) expect(content.value).not.toHaveProperty('ownerEncryptedRecovery');
390
+ const recovery = await client.readRecovery(binding);
391
+ expect(recovery).toMatchObject({
392
+ ok: true,
393
+ value: { ownerEncryptedRecovery: body.ownerEncryptedRecovery },
394
+ });
395
+ if (recovery.ok) expect(recovery.value).not.toHaveProperty('envelope');
396
+ expect(await client.stat(binding)).toMatchObject({
397
+ ok: true,
398
+ value: { kind: 'active' },
399
+ });
400
+ expect(await client.delete(binding)).toMatchObject({ ok: true });
401
+ expect(await client.stat(binding)).toMatchObject({
402
+ ok: true,
403
+ value: { kind: 'tombstone' },
404
+ });
405
+ expect(await client.get(binding)).toMatchObject({ ok: false });
406
+ expect(await client.put(body)).toEqual({ ok: false, error: 'CONFLICT' });
407
+ } finally {
408
+ await harness.close();
409
+ }
410
+ });
411
+ });