@learncard/network-brain-service 4.0.4 → 4.1.1

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 (175) hide show
  1. package/.env.example +14 -0
  2. package/AGENTS.md +16 -0
  3. package/CHANGELOG.md +54 -0
  4. package/config/config.dev.json +5 -0
  5. package/config/config.example.json +5 -0
  6. package/config/config.production.json +5 -0
  7. package/config/config.scouts.dev.json +5 -0
  8. package/config/config.scouts.production.json +5 -0
  9. package/dist/accesslayer/campaign/audience.d.ts +7 -0
  10. package/dist/accesslayer/campaign/audience.d.ts.map +1 -0
  11. package/dist/accesslayer/campaign/create.d.ts +3 -0
  12. package/dist/accesslayer/campaign/create.d.ts.map +1 -0
  13. package/dist/accesslayer/campaign/delete.d.ts +2 -0
  14. package/dist/accesslayer/campaign/delete.d.ts.map +1 -0
  15. package/dist/accesslayer/campaign/read.d.ts +6 -0
  16. package/dist/accesslayer/campaign/read.d.ts.map +1 -0
  17. package/dist/accesslayer/campaign/shared.d.ts +16 -0
  18. package/dist/accesslayer/campaign/shared.d.ts.map +1 -0
  19. package/dist/accesslayer/campaign/state.d.ts +4 -0
  20. package/dist/accesslayer/campaign/state.d.ts.map +1 -0
  21. package/dist/accesslayer/campaign/update.d.ts +3 -0
  22. package/dist/accesslayer/campaign/update.d.ts.map +1 -0
  23. package/dist/accesslayer/credential-share-previews.d.ts +5 -0
  24. package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
  25. package/dist/accesslayer/profile/delete.d.ts.map +1 -1
  26. package/dist/accesslayer/skill/read.d.ts +12 -2
  27. package/dist/accesslayer/skill/read.d.ts.map +1 -1
  28. package/dist/accesslayer/skill/update.d.ts +11 -1
  29. package/dist/accesslayer/skill/update.d.ts.map +1 -1
  30. package/dist/app.d.ts +4 -0
  31. package/dist/app.d.ts.map +1 -1
  32. package/dist/cache/app-store.caches.d.ts +2 -1
  33. package/dist/cache/app-store.caches.d.ts.map +1 -1
  34. package/dist/config/environment.d.ts +268 -178
  35. package/dist/config/environment.d.ts.map +1 -1
  36. package/dist/config/skillEmbedding.d.ts +70 -0
  37. package/dist/config/skillEmbedding.d.ts.map +1 -0
  38. package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
  39. package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
  40. package/dist/config/stageConfig.d.ts +20 -0
  41. package/dist/config/stageConfig.d.ts.map +1 -1
  42. package/dist/constants/campaigns.d.ts +3 -0
  43. package/dist/constants/campaigns.d.ts.map +1 -0
  44. package/dist/didkit_wasm_bg.wasm +0 -0
  45. package/dist/helpers/app-credential-delivery.helpers.d.ts +4 -0
  46. package/dist/helpers/app-credential-delivery.helpers.d.ts.map +1 -0
  47. package/dist/helpers/app-credential-delivery.helpers.test.d.ts +2 -0
  48. package/dist/helpers/app-credential-delivery.helpers.test.d.ts.map +1 -0
  49. package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
  50. package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
  51. package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
  52. package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
  53. package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
  54. package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
  55. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  56. package/dist/helpers/sentry.helpers.d.ts.map +1 -1
  57. package/dist/helpers/sentry.helpers.test.d.ts +2 -0
  58. package/dist/helpers/sentry.helpers.test.d.ts.map +1 -0
  59. package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
  60. package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
  61. package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
  62. package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
  63. package/dist/index.js +2 -2
  64. package/dist/models/Campaign.d.ts +10 -0
  65. package/dist/models/Campaign.d.ts.map +1 -0
  66. package/dist/models/CredentialSharePreview.d.ts +14 -0
  67. package/dist/models/CredentialSharePreview.d.ts.map +1 -0
  68. package/dist/models/Integration.d.ts +1 -1
  69. package/dist/models/Profile.d.ts.map +1 -1
  70. package/dist/models/campaign-constraints.d.ts +2 -0
  71. package/dist/models/campaign-constraints.d.ts.map +1 -0
  72. package/dist/models/index.d.ts +2 -0
  73. package/dist/models/index.d.ts.map +1 -1
  74. package/dist/publish-metafile.json +1 -1
  75. package/dist/routes/app-store.d.ts +1 -1
  76. package/dist/routes/app-store.d.ts.map +1 -1
  77. package/dist/routes/boosts.d.ts +24 -4
  78. package/dist/routes/boosts.d.ts.map +1 -1
  79. package/dist/routes/campaigns.d.ts +1411 -0
  80. package/dist/routes/campaigns.d.ts.map +1 -0
  81. package/dist/routes/contracts.d.ts +19 -6
  82. package/dist/routes/contracts.d.ts.map +1 -1
  83. package/dist/routes/credential-share-previews.d.ts +39 -0
  84. package/dist/routes/credential-share-previews.d.ts.map +1 -0
  85. package/dist/routes/credentials.d.ts +4 -1
  86. package/dist/routes/credentials.d.ts.map +1 -1
  87. package/dist/routes/inbox.d.ts +5 -5
  88. package/dist/routes/index.d.ts +4 -0
  89. package/dist/routes/index.d.ts.map +1 -1
  90. package/dist/routes/integrations.d.ts +3 -3
  91. package/dist/routes/profile-manager.d.ts +4 -0
  92. package/dist/routes/profile-manager.d.ts.map +1 -1
  93. package/dist/routes/profiles.d.ts +44 -0
  94. package/dist/routes/profiles.d.ts.map +1 -1
  95. package/dist/routes/skill-frameworks.d.ts +1 -0
  96. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  97. package/dist/routes/skills-semantic-search.test.d.ts +2 -0
  98. package/dist/routes/skills-semantic-search.test.d.ts.map +1 -0
  99. package/dist/routes/skills.d.ts.map +1 -1
  100. package/dist/routes/test.d.ts +3 -1
  101. package/dist/routes/test.d.ts.map +1 -1
  102. package/dist/seed/seedSkillFrameworks.d.ts +1 -0
  103. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  104. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
  105. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
  106. package/dist/types/app-manifest.d.ts +2 -2
  107. package/dist/types/campaign.d.ts +19 -0
  108. package/dist/types/campaign.d.ts.map +1 -0
  109. package/dist/types/consentflowcontract.d.ts +1 -1
  110. package/dist/types/integration.d.ts +3 -3
  111. package/dist/types/profile.d.ts +9 -0
  112. package/dist/types/profile.d.ts.map +1 -1
  113. package/dist/types/skill.d.ts.map +1 -1
  114. package/package.json +21 -18
  115. package/project.json +15 -1
  116. package/scripts/skill-embeddings-backfill.ts +161 -0
  117. package/serverless-local.yml +4 -0
  118. package/serverless.function-env.cjs +3 -0
  119. package/serverless.local.yml +4 -0
  120. package/serverless.yml +12 -1
  121. package/src/accesslayer/campaign/README.md +174 -0
  122. package/src/accesslayer/campaign/audience.ts +152 -0
  123. package/src/accesslayer/campaign/create.ts +47 -0
  124. package/src/accesslayer/campaign/delete.ts +18 -0
  125. package/src/accesslayer/campaign/read.ts +125 -0
  126. package/src/accesslayer/campaign/shared.ts +92 -0
  127. package/src/accesslayer/campaign/state.ts +80 -0
  128. package/src/accesslayer/campaign/update.ts +50 -0
  129. package/src/accesslayer/credential-share-previews.ts +76 -0
  130. package/src/accesslayer/profile/delete.ts +6 -0
  131. package/src/accesslayer/skill/read.ts +56 -16
  132. package/src/accesslayer/skill/update.ts +43 -1
  133. package/src/app.ts +9 -0
  134. package/src/config/environment.test.ts +208 -0
  135. package/src/config/environment.ts +166 -11
  136. package/src/config/skillEmbedding.neo4j.test.ts +138 -0
  137. package/src/config/skillEmbedding.ts +202 -0
  138. package/src/constants/campaigns.ts +9 -0
  139. package/src/helpers/activity.helpers.ts +3 -3
  140. package/src/helpers/app-credential-delivery.helpers.test.ts +56 -0
  141. package/src/helpers/app-credential-delivery.helpers.ts +11 -0
  142. package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
  143. package/src/helpers/bedrock-embedding.helpers.ts +243 -0
  144. package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
  145. package/src/helpers/boost-storage.helpers.test.ts +63 -0
  146. package/src/helpers/boost.helpers.ts +23 -18
  147. package/src/helpers/sentry.helpers.test.ts +56 -0
  148. package/src/helpers/sentry.helpers.ts +4 -3
  149. package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
  150. package/src/helpers/skill-embedding.helpers.ts +611 -123
  151. package/src/models/Campaign.ts +32 -0
  152. package/src/models/CredentialSharePreview.ts +30 -0
  153. package/src/models/Profile.ts +1 -0
  154. package/src/models/campaign-constraints.ts +22 -0
  155. package/src/models/index.ts +29 -1
  156. package/src/routes/app-store.ts +9 -16
  157. package/src/routes/campaigns.ts +179 -0
  158. package/src/routes/credential-share-previews.ts +74 -0
  159. package/src/routes/profiles.ts +7 -0
  160. package/src/routes/skills-semantic-search.test.ts +168 -0
  161. package/src/routes/skills.ts +42 -6
  162. package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
  163. package/src/seed/seedSkillFrameworks.test.ts +6 -0
  164. package/src/seed/seedSkillFrameworks.ts +83 -18
  165. package/src/types/campaign.ts +17 -0
  166. package/src/types/skill.ts +6 -0
  167. package/test/app-credential-delivery.neo4j.spec.ts +110 -0
  168. package/test/campaigns.spec.ts +789 -0
  169. package/test/credential-share-previews.spec.ts +97 -0
  170. package/test/verification/campaigns.ts +49 -0
  171. package/tsconfig.json +13 -13
  172. package/vitest.campaigns.config.ts +21 -0
  173. package/vitest.config.ts +12 -0
  174. package/vitest.inbox-e2e.config.ts +1 -0
  175. package/vitest.integration.config.ts +1 -0
@@ -0,0 +1,243 @@
1
+ import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime';
2
+
3
+ import { NOVA2_MAX_TEXT_CHARS } from '../config/skillEmbedding';
4
+
5
+ /**
6
+ * LC-2206 Bedrock adapter for Amazon Nova 2 Multimodal Embeddings.
7
+ *
8
+ * Style follows lca-api's Bedrock image adapter (LC-2205): one InvokeModel per request,
9
+ * SigV4 with the execution role's rotating credentials (never a long-term
10
+ * AWS_BEARER_TOKEN_BEDROCK key), no SDK retries, a hard timeout, and sanitised errors that
11
+ * never carry provider bodies, input text, credentials or vectors.
12
+ */
13
+
14
+ /** `index` embeds stored skills (GENERIC_INDEX); `query` embeds search text (TEXT_RETRIEVAL). */
15
+ export type EmbeddingPurpose = 'index' | 'query';
16
+
17
+ export type BedrockEmbeddingConfig = {
18
+ modelId: string;
19
+ region: string;
20
+ dimension: number;
21
+ timeoutMs: number;
22
+ };
23
+
24
+ export type SkillEmbeddingErrorCode =
25
+ | 'disabled'
26
+ | 'invalid_input'
27
+ | 'throttled'
28
+ | 'unavailable'
29
+ | 'access_denied'
30
+ | 'timeout'
31
+ | 'invalid_response'
32
+ | 'failed';
33
+
34
+ export class SkillEmbeddingError extends Error {
35
+ readonly code: SkillEmbeddingErrorCode;
36
+
37
+ readonly retryable: boolean;
38
+
39
+ readonly httpStatus?: number;
40
+
41
+ constructor(code: SkillEmbeddingErrorCode, httpStatus?: number) {
42
+ super(`Skill embedding failed (${code})`);
43
+ this.name = 'SkillEmbeddingError';
44
+ this.code = code;
45
+ this.retryable = code === 'throttled' || code === 'unavailable' || code === 'timeout';
46
+ this.httpStatus = httpStatus;
47
+ }
48
+ }
49
+
50
+ export const isSkillEmbeddingError = (error: unknown): error is SkillEmbeddingError =>
51
+ error instanceof SkillEmbeddingError;
52
+
53
+ /** Raw InvokeModel transport; injectable so unit tests never reach AWS. */
54
+ export type BedrockInvoke = (
55
+ request: { modelId: string; body: string },
56
+ options: { abortSignal: AbortSignal }
57
+ ) => Promise<Uint8Array>;
58
+
59
+ export const buildNovaEmbeddingRequest = (
60
+ text: string,
61
+ purpose: EmbeddingPurpose,
62
+ dimension: number
63
+ ) => ({
64
+ schemaVersion: 'nova-multimodal-embed-v1',
65
+ taskType: 'SINGLE_EMBEDDING',
66
+ singleEmbeddingParams: {
67
+ embeddingPurpose: purpose === 'index' ? 'GENERIC_INDEX' : 'TEXT_RETRIEVAL',
68
+ embeddingDimension: dimension,
69
+ // Fail instead of silently embedding a truncated text; callers bound input length.
70
+ text: { truncationMode: 'NONE', value: text },
71
+ },
72
+ });
73
+
74
+ /** L2-normalise so cosine and dot-product scores agree (AI Passport does the same). */
75
+ export const normalizeEmbedding = (vector: number[]): number[] => {
76
+ const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
77
+ if (!Number.isFinite(norm) || norm === 0) throw new SkillEmbeddingError('invalid_response');
78
+ return vector.map(value => value / norm);
79
+ };
80
+
81
+ /** Validate the documented Nova InvokeModel envelope: exactly one finite TEXT vector. */
82
+ export const parseNovaEmbeddingResponse = (body: Uint8Array, dimension: number): number[] => {
83
+ let parsed: unknown;
84
+
85
+ try {
86
+ parsed = JSON.parse(new TextDecoder().decode(body));
87
+ } catch {
88
+ throw new SkillEmbeddingError('invalid_response');
89
+ }
90
+
91
+ const rows = (parsed as { embeddings?: unknown } | null)?.embeddings;
92
+ if (!Array.isArray(rows) || rows.length !== 1)
93
+ throw new SkillEmbeddingError('invalid_response');
94
+
95
+ const row = rows[0] as {
96
+ embeddingType?: unknown;
97
+ embedding?: unknown;
98
+ truncatedCharLength?: unknown;
99
+ } | null;
100
+
101
+ if (
102
+ !row ||
103
+ row.embeddingType !== 'TEXT' ||
104
+ row.truncatedCharLength !== undefined ||
105
+ !Array.isArray(row.embedding) ||
106
+ row.embedding.length !== dimension ||
107
+ !row.embedding.every(value => typeof value === 'number' && Number.isFinite(value))
108
+ ) {
109
+ throw new SkillEmbeddingError('invalid_response');
110
+ }
111
+
112
+ return normalizeEmbedding(row.embedding as number[]);
113
+ };
114
+
115
+ const providerStatus = (error: unknown): number | undefined => {
116
+ if (!error || typeof error !== 'object') return undefined;
117
+ const { $metadata: metadata } = error as { $metadata?: { httpStatusCode?: unknown } };
118
+ return typeof metadata?.httpStatusCode === 'number' ? metadata.httpStatusCode : undefined;
119
+ };
120
+
121
+ const providerName = (error: unknown): string | undefined => {
122
+ if (!error || typeof error !== 'object') return undefined;
123
+ const { name } = error as { name?: unknown };
124
+ return typeof name === 'string' ? name : undefined;
125
+ };
126
+
127
+ /** Map an SDK failure to a code. The provider message and body are deliberately dropped. */
128
+ export const sanitizeBedrockError = (error: unknown): SkillEmbeddingError => {
129
+ if (error instanceof SkillEmbeddingError) return error;
130
+
131
+ const status = providerStatus(error);
132
+ const name = providerName(error);
133
+
134
+ if (name === 'AbortError' || name === 'TimeoutError') return new SkillEmbeddingError('timeout');
135
+ if (
136
+ status === 429 ||
137
+ name === 'ThrottlingException' ||
138
+ name === 'ServiceQuotaExceededException'
139
+ ) {
140
+ return new SkillEmbeddingError('throttled', 429);
141
+ }
142
+ if (status === 403 || name === 'AccessDeniedException') {
143
+ return new SkillEmbeddingError('access_denied', 403);
144
+ }
145
+ if (
146
+ status === 503 ||
147
+ status === 504 ||
148
+ name === 'ServiceUnavailableException' ||
149
+ name === 'ModelNotReadyException' ||
150
+ name === 'ModelTimeoutException'
151
+ ) {
152
+ return new SkillEmbeddingError('unavailable', status);
153
+ }
154
+ if (status === 400 || name === 'ValidationException') {
155
+ return new SkillEmbeddingError('invalid_input', 400);
156
+ }
157
+
158
+ return new SkillEmbeddingError('failed', status);
159
+ };
160
+
161
+ const clients = new Map<string, BedrockRuntimeClient>();
162
+
163
+ const getClient = (config: BedrockEmbeddingConfig): BedrockRuntimeClient => {
164
+ const key = `${config.region}:${config.timeoutMs}`;
165
+ let client = clients.get(key);
166
+
167
+ if (!client) {
168
+ client = new BedrockRuntimeClient({
169
+ region: config.region,
170
+ // One paid attempt per call; the backfill decides whether to retry later.
171
+ maxAttempts: 1,
172
+ // Execution-role SigV4 only, matching lca-api's credential policy.
173
+ authSchemePreference: ['sigv4'],
174
+ requestHandler: { requestTimeout: config.timeoutMs, connectionTimeout: 3_000 },
175
+ });
176
+ clients.set(key, client);
177
+ }
178
+
179
+ return client;
180
+ };
181
+
182
+ /** Test hook: drop cached SDK clients. */
183
+ export const resetBedrockEmbeddingClients = (): void => {
184
+ for (const client of clients.values()) client.destroy();
185
+ clients.clear();
186
+ };
187
+
188
+ export const createDefaultBedrockInvoke =
189
+ (config: BedrockEmbeddingConfig): BedrockInvoke =>
190
+ async (request, options) => {
191
+ const response = await getClient(config).send(
192
+ new InvokeModelCommand({
193
+ modelId: request.modelId,
194
+ contentType: 'application/json',
195
+ accept: 'application/json',
196
+ body: request.body,
197
+ }),
198
+ { abortSignal: options.abortSignal }
199
+ );
200
+
201
+ return response.body;
202
+ };
203
+
204
+ /** Embed one text with Nova 2. Exactly one InvokeModel call; throws SkillEmbeddingError. */
205
+ export const embedTextWithBedrock = async (
206
+ text: string,
207
+ purpose: EmbeddingPurpose,
208
+ config: BedrockEmbeddingConfig,
209
+ invoke: BedrockInvoke = createDefaultBedrockInvoke(config)
210
+ ): Promise<number[]> => {
211
+ const value = text.trim();
212
+ if (!value || value.length > NOVA2_MAX_TEXT_CHARS)
213
+ throw new SkillEmbeddingError('invalid_input');
214
+
215
+ const controller = new AbortController();
216
+ let timer: ReturnType<typeof setTimeout> | undefined;
217
+
218
+ try {
219
+ const body = await Promise.race([
220
+ invoke(
221
+ {
222
+ modelId: config.modelId,
223
+ body: JSON.stringify(
224
+ buildNovaEmbeddingRequest(value, purpose, config.dimension)
225
+ ),
226
+ },
227
+ { abortSignal: controller.signal }
228
+ ),
229
+ new Promise<never>((_, reject) => {
230
+ timer = setTimeout(() => {
231
+ controller.abort();
232
+ reject(new SkillEmbeddingError('timeout'));
233
+ }, config.timeoutMs);
234
+ }),
235
+ ]);
236
+
237
+ return parseNovaEmbeddingResponse(body, config.dimension);
238
+ } catch (error) {
239
+ throw sanitizeBedrockError(error);
240
+ } finally {
241
+ clearTimeout(timer);
242
+ }
243
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * LC-2206 opt-in smoke test against REAL Amazon Bedrock (paid: three InvokeModel calls).
3
+ * Skipped unless SKILL_EMBEDDING_BEDROCK_SMOKE=true. Never enable it in CI.
4
+ *
5
+ * SKILL_EMBEDDING_BEDROCK_SMOKE=true AWS_PROFILE=<profile> \
6
+ * bunx vitest run --config vitest.config.ts src/helpers/bedrock-embedding.smoke.test.ts
7
+ *
8
+ * Uses the normal AWS credential chain (SigV4) in us-east-1 and the same request shape,
9
+ * purposes, dimension and normalisation as production.
10
+ */
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import {
14
+ DEFAULT_SKILL_EMBEDDING_DIMENSION,
15
+ DEFAULT_SKILL_EMBEDDING_REGION,
16
+ NOVA2_EMBEDDING_MODEL_ID,
17
+ } from '../config/skillEmbedding';
18
+ import { embedTextWithBedrock, type BedrockEmbeddingConfig } from './bedrock-embedding.helpers';
19
+
20
+ const enabled = process.env.SKILL_EMBEDDING_BEDROCK_SMOKE === 'true';
21
+
22
+ const config: BedrockEmbeddingConfig = {
23
+ modelId: process.env.SKILL_EMBEDDING_BEDROCK_MODEL_ID || NOVA2_EMBEDDING_MODEL_ID,
24
+ region: process.env.SKILL_EMBEDDING_BEDROCK_REGION || DEFAULT_SKILL_EMBEDDING_REGION,
25
+ dimension: Number(process.env.SKILL_EMBEDDING_DIMENSION || DEFAULT_SKILL_EMBEDDING_DIMENSION),
26
+ timeoutMs: 15_000,
27
+ };
28
+
29
+ const dot = (a: number[], b: number[]) =>
30
+ a.reduce((sum, value, index) => sum + value * (b[index] ?? 0), 0);
31
+
32
+ describe.skipIf(!enabled)('Bedrock Nova 2 embeddings smoke (real AWS, opt-in)', () => {
33
+ it('returns unit vectors where a related skill outranks an unrelated one', async () => {
34
+ const query = await embedTextWithBedrock('python data analysis', 'query', config);
35
+ const related = await embedTextWithBedrock(
36
+ 'Data Analysis\nAnalyse datasets with Python and statistics',
37
+ 'index',
38
+ config
39
+ );
40
+ const unrelated = await embedTextWithBedrock(
41
+ 'Customer service\nHelp customers resolve issues politely',
42
+ 'index',
43
+ config
44
+ );
45
+
46
+ for (const vector of [query, related, unrelated]) {
47
+ expect(vector).toHaveLength(config.dimension);
48
+ expect(Math.hypot(...vector)).toBeCloseTo(1, 6);
49
+ }
50
+
51
+ expect(dot(query, related)).toBeGreaterThan(dot(query, unrelated));
52
+ }, 60_000);
53
+ });
@@ -107,6 +107,14 @@ const options = {
107
107
  integrationId: 'integration',
108
108
  } as unknown as Parameters<typeof sendBoost>[0];
109
109
 
110
+ const deferred = () => {
111
+ let resolve!: () => void;
112
+ const promise = new Promise<void>(complete => {
113
+ resolve = complete;
114
+ });
115
+ return { promise, resolve };
116
+ };
117
+
110
118
  describe('boost payload storage', () => {
111
119
  beforeEach(() => {
112
120
  vi.clearAllMocks();
@@ -167,6 +175,61 @@ describe('boost payload storage', () => {
167
175
  }
168
176
  );
169
177
 
178
+ it('does not overlap relationship writes that touch the same credential', async () => {
179
+ const first = deferred();
180
+ const sent = deferred();
181
+ const listing = deferred();
182
+ const contract = deferred();
183
+ mocks.instanceOf.mockReturnValueOnce(first.promise);
184
+ mocks.sent.mockReturnValueOnce(sent.promise);
185
+ mocks.listing.mockReturnValueOnce(listing.promise);
186
+ mocks.contract.mockReturnValueOnce(contract.promise);
187
+ const result = sendBoost(options);
188
+ await vi.waitFor(() => expect(mocks.instanceOf).toHaveBeenCalledOnce());
189
+ expect(mocks.sent).not.toHaveBeenCalled();
190
+ first.resolve();
191
+ await vi.waitFor(() => expect(mocks.sent).toHaveBeenCalledOnce());
192
+ expect(mocks.listing).not.toHaveBeenCalled();
193
+ sent.resolve();
194
+ await vi.waitFor(() => expect(mocks.listing).toHaveBeenCalledOnce());
195
+ expect(mocks.contract).not.toHaveBeenCalled();
196
+ listing.resolve();
197
+ await vi.waitFor(() => expect(mocks.contract).toHaveBeenCalledOnce());
198
+ expect(mocks.accept).not.toHaveBeenCalled();
199
+ expect(mocks.notify).not.toHaveBeenCalled();
200
+ contract.resolve();
201
+ await expect(result).resolves.toBe('lc:network:network.example/credential:stored');
202
+ });
203
+
204
+ it.each(['instanceOf', 'sent', 'listing', 'contract'] as const)(
205
+ 'stops after a failed %s relationship without acceptance or notification',
206
+ async failed => {
207
+ const error = new Error('graph write failed');
208
+ mocks[failed].mockRejectedValueOnce(error);
209
+ await expect(sendBoost(options)).rejects.toBe(error);
210
+ const order = ['instanceOf', 'sent', 'listing', 'contract'] as const;
211
+ for (const later of order.slice(order.indexOf(failed) + 1)) {
212
+ expect(mocks[later]).not.toHaveBeenCalled();
213
+ }
214
+ expect(mocks.accept).not.toHaveBeenCalled();
215
+ expect(mocks.notify).not.toHaveBeenCalled();
216
+ }
217
+ );
218
+
219
+ it.each(['listing', 'another-listing', undefined])(
220
+ 'adds explicit listing attribution only when the listing issuer does not already cover %s',
221
+ async listingId => {
222
+ const from = {
223
+ type: 'appStoreListing',
224
+ listing: { listing_id: 'listing' },
225
+ ownerProfile: { profileId: 'org' },
226
+ } as Parameters<typeof sendBoost>[0]['from'];
227
+ await sendBoost({ ...options, from, listingId });
228
+ expect(mocks.sent).toHaveBeenCalledOnce();
229
+ expect(mocks.listing).toHaveBeenCalledTimes(listingId === 'another-listing' ? 1 : 0);
230
+ }
231
+ );
232
+
170
233
  it('does not inspect template-derived fields or add a network signature', async () => {
171
234
  const credential = { ...vc, display: { backgroundColor: '#bad' } };
172
235
  await sendBoost({ ...options, credential });
@@ -19,6 +19,7 @@ import {
19
19
  getIssuerDisplayName,
20
20
  getIssuerOwnerProfile,
21
21
  getIssuerProfileId,
22
+ isAppStoreListingIssuer,
22
23
  } from '../types/issuer';
23
24
  import { trace, traceDb } from '@tracing';
24
25
  import { injectObv3AlignmentsIntoCredentialForBoost } from '@services/skills-provider/inject';
@@ -182,38 +183,42 @@ export const sendBoost = async ({
182
183
  storeCredential(credential)
183
184
  );
184
185
 
185
- const tasks = [
186
- createBoostInstanceOfRelationship(credentialInstance, boost),
187
- createSentCredentialRelationship(
186
+ // These writes share Credential/Profile/Listing nodes. Separate concurrent
187
+ // auto-commit transactions can deadlock on Neo4j relationship-chain locks.
188
+ await traceDb('createRelationships', async () => {
189
+ await createBoostInstanceOfRelationship(credentialInstance, boost);
190
+ await createSentCredentialRelationship(
188
191
  from,
189
192
  to,
190
193
  credentialInstance,
191
194
  metadata,
192
195
  activityId,
193
196
  integrationId
194
- ),
195
- ];
197
+ );
196
198
 
197
- if (listingId) {
198
- tasks.push(
199
- createListingSentCredentialRelationship(
199
+ // Listing issuers already create this edge inside createSentCredentialRelationship.
200
+ // Keep explicit attribution for profile/integration issuers or another listing.
201
+ if (
202
+ listingId &&
203
+ (!isAppStoreListingIssuer(from) || from.listing.listing_id !== listingId)
204
+ ) {
205
+ await createListingSentCredentialRelationship(
200
206
  listingId,
201
207
  to,
202
208
  credentialInstance,
203
209
  metadata,
204
210
  activityId,
205
211
  integrationId
206
- )
207
- );
208
- }
209
-
210
- if (contractTerms) {
211
- tasks.push(
212
- createCredentialIssuedViaContractRelationship(credentialInstance, contractTerms)
213
- );
214
- }
212
+ );
213
+ }
215
214
 
216
- await traceDb('createRelationships', () => Promise.all(tasks));
215
+ if (contractTerms) {
216
+ await createCredentialIssuedViaContractRelationship(
217
+ credentialInstance,
218
+ contractTerms
219
+ );
220
+ }
221
+ });
217
222
 
218
223
  if (autoAcceptCredential) {
219
224
  await acceptCredential(to, getCredentialUri(credentialInstance.id, domain), {
@@ -0,0 +1,56 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { TRPCError } from '@trpc/server';
3
+
4
+ const sentry = vi.hoisted(() => ({
5
+ captureException: vi.fn(),
6
+ setHttpStatus: vi.fn(),
7
+ }));
8
+
9
+ vi.mock('@sentry/serverless', () => ({
10
+ captureException: sentry.captureException,
11
+ getActiveTransaction: () => ({ setHttpStatus: sentry.setHttpStatus }),
12
+ }));
13
+ vi.mock('@environment', () => ({ environment: { SENTRY_ENV: 'test' } }));
14
+
15
+ import { handleTrpcError, sentryBeforeSend, shouldReportError } from './sentry.helpers';
16
+
17
+ const event: Parameters<typeof sentryBeforeSend>[0] = { event_id: 'event', type: undefined };
18
+
19
+ describe('sentry helpers', () => {
20
+ beforeEach(() => {
21
+ sentry.captureException.mockClear();
22
+ sentry.setHttpStatus.mockClear();
23
+ });
24
+
25
+ it('does not capture a repeated contract consent conflict', () => {
26
+ const error = new TRPCError({
27
+ code: 'CONFLICT',
28
+ message: "You've already consented to this contract!",
29
+ });
30
+
31
+ handleTrpcError({ error, ctx: {}, path: 'contracts.consentToContract' });
32
+
33
+ expect(sentry.captureException).not.toHaveBeenCalled();
34
+ expect(sentry.setHttpStatus).toHaveBeenCalledWith(409);
35
+ expect(sentryBeforeSend(event, { originalException: error })).toBeNull();
36
+ });
37
+
38
+ it.each(['BAD_REQUEST', 'NOT_FOUND', 'FORBIDDEN', 'CONFLICT'])(
39
+ 'treats %s as an expected client error',
40
+ code => {
41
+ expect(shouldReportError(code)).toBe(false);
42
+ }
43
+ );
44
+
45
+ it('still captures unexpected server errors', () => {
46
+ const error = new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'boom' });
47
+
48
+ handleTrpcError({ error, ctx: {}, path: 'contracts.consentToContract' });
49
+
50
+ expect(sentry.captureException).toHaveBeenCalledWith(
51
+ error,
52
+ expect.objectContaining({ level: 'error' })
53
+ );
54
+ expect(sentryBeforeSend(event, { originalException: error })).toBe(event);
55
+ });
56
+ });
@@ -5,9 +5,10 @@ import type { TRPCError } from '@trpc/server';
5
5
 
6
6
  /**
7
7
  * Error codes that are expected/non-critical and should not be sent to Sentry.
8
- * These typically represent user errors or expected authentication failures.
8
+ * These typically represent user errors, expected authentication failures, or
9
+ * client-visible state conflicts such as re-consenting to the same contract.
9
10
  */
10
- const IGNORED_ERROR_CODES = ['BAD_REQUEST', 'NOT_FOUND', 'FORBIDDEN'];
11
+ const IGNORED_ERROR_CODES = ['BAD_REQUEST', 'NOT_FOUND', 'FORBIDDEN', 'CONFLICT'];
11
12
 
12
13
  /**
13
14
  * Determines if an error should be reported to Sentry based on its code.
@@ -38,7 +39,7 @@ export const handleTrpcError = ({
38
39
  !(ctx as { user?: { isChallengeValid?: boolean } })?.user?.isChallengeValid;
39
40
 
40
41
  // Only capture unexpected/critical errors
41
- // Filter out expected errors (BAD_REQUEST, NOT_FOUND, FORBIDDEN) and invalid challenge attempts
42
+ // Filter out expected errors (see IGNORED_ERROR_CODES) and invalid challenge attempts
42
43
  if (shouldReportError(error.code) && !isInvalidChallenge) {
43
44
  Sentry.captureException(error, {
44
45
  extra: { ctx, path },