@learncard/network-brain-service 4.1.0 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"app-store.d.ts","sourceRoot":"","sources":["../../src/routes/app-store.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AA2qB5C,eAAO,MAAM,yBAAyB,GAClC,KAAK;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,EACvB,SAAS;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,EAC9B,WAAW,MAAM,EACjB,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,cAAc,WAAW,KAC1B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAmTjC,CAAC;AA6qCF,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0yDzB,CAAC;AAEH,MAAM,MAAM,cAAc,GAAG,OAAO,cAAc,CAAC"}
1
+ {"version":3,"file":"app-store.d.ts","sourceRoot":"","sources":["../../src/routes/app-store.ts"],"names":[],"mappings":"AA4BA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AA4qB5C,eAAO,MAAM,yBAAyB,GAClC,KAAK;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,EACvB,SAAS;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,EAC9B,WAAW,MAAM,EACjB,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,cAAc,WAAW,KAC1B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA4SjC,CAAC;AA6qCF,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0yDzB,CAAC;AAEH,MAAM,MAAM,cAAc,GAAG,OAAO,cAAc,CAAC"}
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@learncard/network-brain-service",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=24.12 <25"
7
7
  },
8
- "packageManager": "bun@1.4.2",
8
+ "packageManager": "bun@1.4.4",
9
9
  "main": "dist/index.js",
10
10
  "types": "dist/index.d.ts",
11
11
  "scripts": {
@@ -73,8 +73,8 @@
73
73
  "@learncard/crypto-plugin": "1.1.41",
74
74
  "@learncard/did-web-plugin": "1.1.41",
75
75
  "@learncard/didkey-plugin": "1.1.41",
76
- "@learncard/didkit-plugin": "1.10.6",
77
- "@learncard/didkit-plugin-node": "0.3.6",
76
+ "@learncard/didkit-plugin": "1.10.7",
77
+ "@learncard/didkit-plugin-node": "0.3.7",
78
78
  "@learncard/dynamic-loader-plugin": "1.1.41",
79
79
  "@learncard/encryption-plugin": "1.1.41",
80
80
  "@learncard/expiration-plugin": "1.2.41",
package/project.json CHANGED
@@ -80,7 +80,7 @@
80
80
  },
81
81
  "test": {
82
82
  "executor": "nx:run-script",
83
- "inputs": ["source", "tests"],
83
+ "inputs": ["source", "tests", "ciTestConfig"],
84
84
  "options": {
85
85
  "script": "test"
86
86
  }
@@ -32,9 +32,9 @@ export const logCredentialSent = async (params: {
32
32
  listingId?: string;
33
33
  source: CredentialActivitySourceType;
34
34
  metadata?: Record<string, unknown>;
35
- // LC-1644 Phase 1d: callers can pre-generate activityId upfront so
36
- // logCredentialSent can run in parallel with dependent writes (e.g. sendBoost
37
- // which stores activityId on the SentCredential relationship).
35
+ // Callers can supply a correlation ID for related graph writes. Finish
36
+ // activity writes before delivery writes touch the same profile/listing;
37
+ // sharing an ID does not make parallel auto-commit transactions independent.
38
38
  activityId?: string;
39
39
  }): Promise<string> => {
40
40
  const isInboxSend = params.recipientType === 'email' || params.recipientType === 'phone';
@@ -0,0 +1,56 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const mocks = vi.hoisted(() => ({ activity: vi.fn(), send: vi.fn() }));
4
+ vi.mock('./activity.helpers', () => ({ logCredentialSent: mocks.activity }));
5
+ vi.mock('./boost.helpers', () => ({ sendBoost: mocks.send }));
6
+
7
+ import { sendAppCredentialWithActivity } from './app-credential-delivery.helpers';
8
+
9
+ const activity: Parameters<typeof sendAppCredentialWithActivity>[0] = {
10
+ actorProfileId: 'owner',
11
+ recipientType: 'profile',
12
+ recipientIdentifier: 'learner',
13
+ listingId: 'app',
14
+ source: 'appEvent',
15
+ };
16
+ const delivery = { listingId: 'app' } as Parameters<typeof sendAppCredentialWithActivity>[1];
17
+
18
+ describe('app credential delivery graph ordering', () => {
19
+ beforeEach(() => {
20
+ vi.resetAllMocks();
21
+ mocks.send.mockResolvedValue('credential:1');
22
+ });
23
+
24
+ it('waits for the activity write to commit and uses its ID before starting delivery', async () => {
25
+ let resolve!: (activityId: string) => void;
26
+ const promise = new Promise<string>(complete => {
27
+ resolve = complete;
28
+ });
29
+ mocks.activity.mockReturnValue(promise);
30
+ const result = sendAppCredentialWithActivity(activity, delivery);
31
+ expect(mocks.activity).toHaveBeenCalledExactlyOnceWith(activity);
32
+ expect(mocks.send).not.toHaveBeenCalled();
33
+ resolve('activity:1');
34
+ expect(await result).toBe('credential:1');
35
+ expect(mocks.send).toHaveBeenCalledExactlyOnceWith({
36
+ ...delivery,
37
+ activityId: 'activity:1',
38
+ });
39
+ });
40
+
41
+ it('does not deliver a credential after an activity write fails', async () => {
42
+ const error = new Error('activity write failed');
43
+ mocks.activity.mockRejectedValue(error);
44
+ await expect(sendAppCredentialWithActivity(activity, delivery)).rejects.toBe(error);
45
+ expect(mocks.send).not.toHaveBeenCalled();
46
+ });
47
+
48
+ it('propagates delivery failure without retrying activity or delivery', async () => {
49
+ const error = new Error('delivery failed');
50
+ mocks.activity.mockResolvedValue('activity:1');
51
+ mocks.send.mockRejectedValue(error);
52
+ await expect(sendAppCredentialWithActivity(activity, delivery)).rejects.toBe(error);
53
+ expect(mocks.activity).toHaveBeenCalledOnce();
54
+ expect(mocks.send).toHaveBeenCalledOnce();
55
+ });
56
+ });
@@ -0,0 +1,11 @@
1
+ import { logCredentialSent } from './activity.helpers';
2
+ import { sendBoost } from './boost.helpers';
3
+
4
+ /** Complete activity graph writes before delivery writes touch the same listing. */
5
+ export const sendAppCredentialWithActivity = async (
6
+ activity: Parameters<typeof logCredentialSent>[0],
7
+ delivery: Omit<Parameters<typeof sendBoost>[0], 'activityId'>
8
+ ): Promise<string> => {
9
+ const activityId = await logCredentialSent(activity);
10
+ return sendBoost({ ...delivery, activityId });
11
+ };
@@ -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 },
@@ -1,5 +1,4 @@
1
1
  import { TRPCError } from '@trpc/server';
2
- import { v4 as uuid } from 'uuid';
3
2
  import { z } from 'zod';
4
3
  import {
5
4
  PendingListingUpdateValidator,
@@ -48,6 +47,7 @@ import {
48
47
  invalidateConsentContractForListingByScopeHashCache,
49
48
  } from '@cache/app-store.caches';
50
49
  import { logCredentialSent } from '@helpers/activity.helpers';
50
+ import { sendAppCredentialWithActivity } from '@helpers/app-credential-delivery.helpers';
51
51
  import { getCredentialUri } from '@helpers/credential.helpers';
52
52
  import { constructUri, resolveUri } from '@helpers/uri.helpers';
53
53
  import { inflateObject } from '@helpers/objects.helpers';
@@ -947,15 +947,10 @@ export const handleSendCredentialEvent = async (
947
947
 
948
948
  perf.mark('saIssue');
949
949
 
950
- // LC-1644 Phase 1d: run logCredentialSent + sendBoost in parallel. They previously
951
- // ran serially because sendBoost needed activityId as a foreign key on the
952
- // SentCredential relationship. We now pre-generate the activityId upfront (matching
953
- // the uuid the activity log would have chosen itself) and pass it to both calls.
954
- // Both writes produce the same Neo4j linkage as before; we just fan them out.
955
- // At staging this saves ~40ms on the serial sum (logActivity 40ms + sendBoost 50ms).
956
- const activityId = uuid();
957
- const [, credentialUri] = await Promise.all([
958
- logCredentialSent({
950
+ // Activity and delivery both write listing relationships; finish one before
951
+ // starting the other so one request cannot deadlock against its own writes.
952
+ const credentialUri = await sendAppCredentialWithActivity(
953
+ {
959
954
  actorProfileId: integrationOwner.profileId,
960
955
  recipientType: 'profile',
961
956
  recipientIdentifier: target.profileId,
@@ -965,20 +960,18 @@ export const handleSendCredentialEvent = async (
965
960
  listingId,
966
961
  source: 'appEvent',
967
962
  metadata: { listingId, templateAlias },
968
- activityId,
969
- }),
970
- sendBoost({
963
+ },
964
+ {
971
965
  from: issuer,
972
966
  to: target,
973
967
  boost,
974
968
  credential,
975
969
  domain: ctx.domain,
976
970
  skipNotification: false,
977
- activityId,
978
971
  integrationId: integration.id,
979
972
  listingId,
980
- }),
981
- ]);
973
+ }
974
+ );
982
975
 
983
976
  perf.mark('logActivityAndSendBoost');
984
977
 
@@ -0,0 +1,110 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { neogma } from '@instance';
3
+ import { Boost } from '@models';
4
+ import { sendAppCredentialWithActivity } from '@helpers/app-credential-delivery.helpers';
5
+ import { getBoostUri } from '@helpers/boost.helpers';
6
+ import type { CredentialIssuer } from '../src/types/issuer';
7
+
8
+ const domain = 'localhost%3A3000';
9
+ const issuer: CredentialIssuer = {
10
+ type: 'appStoreListing',
11
+ listing: { listing_id: 'race-listing', display_name: 'Race Test App' },
12
+ ownerProfile: { profileId: 'race-owner' },
13
+ } as CredentialIssuer;
14
+ const credential = {
15
+ protected: 'header',
16
+ iv: 'iv',
17
+ ciphertext: 'ciphertext',
18
+ tag: 'tag',
19
+ recipients: [],
20
+ };
21
+
22
+ // The integration and CI inbox configs both supply a fresh Testcontainers database.
23
+ // No environment-configured development or deployed database is used.
24
+ describe('app credential delivery graph (real Neo4j)', () => {
25
+ beforeEach(async () => {
26
+ await neogma.queryRunner.run('MATCH (n) DETACH DELETE n');
27
+ await neogma.queryRunner.run(`
28
+ CREATE (owner:Profile {profileId: 'race-owner'}),
29
+ (learner:Profile {profileId: 'race-learner'}),
30
+ (listing:AppStoreListing {listing_id: 'race-listing'}),
31
+ (boost:Boost {id: 'race-boost'})
32
+ CREATE (owner)-[:PUBLISHED]->(listing), (listing)-[:HAS_BOOST]->(boost)
33
+ `);
34
+ });
35
+
36
+ const issue = async (sequence: number): Promise<string> => {
37
+ const boost = await Boost.findOne({ where: { id: 'race-boost' } });
38
+ if (!boost) throw new Error('Missing fixture boost');
39
+ return sendAppCredentialWithActivity(
40
+ {
41
+ actorProfileId: 'race-owner',
42
+ recipientType: 'profile',
43
+ recipientIdentifier: 'race-learner',
44
+ recipientProfileId: 'race-learner',
45
+ boostUri: getBoostUri(boost.id, domain),
46
+ integrationId: 'race-integration',
47
+ listingId: 'race-listing',
48
+ source: 'appEvent',
49
+ metadata: { sequence },
50
+ },
51
+ {
52
+ from: issuer,
53
+ to: { profileId: 'race-learner' },
54
+ boost,
55
+ credential,
56
+ domain,
57
+ skipNotification: true,
58
+ listingId: 'race-listing',
59
+ integrationId: 'race-integration',
60
+ metadata: { sequence },
61
+ }
62
+ );
63
+ };
64
+
65
+ const assertGraph = async (expected: number): Promise<void> => {
66
+ const { records } = await neogma.queryRunner.run(`
67
+ MATCH (c:Credential)
68
+ RETURN c.credential AS payload,
69
+ [(p:Profile)-[r:CREDENTIAL_SENT]->(c) | properties(r)] AS profileEdges,
70
+ [(l:AppStoreListing)-[r:CREDENTIAL_SENT]->(c) | properties(r)] AS listingEdges,
71
+ [(c)-[:INSTANCE_OF]->(b:Boost) | b.id] AS boosts
72
+ `);
73
+ expect(records).toHaveLength(expected);
74
+ for (const row of records) {
75
+ expect(JSON.parse(row.get('payload'))).toEqual(credential);
76
+ expect(row.get('profileEdges')).toHaveLength(1);
77
+ expect(row.get('listingEdges')).toHaveLength(1);
78
+ expect(row.get('boosts')).toEqual(['race-boost']);
79
+ const [edge] = row.get('listingEdges');
80
+ expect(edge.to).toBe('race-learner');
81
+ expect(edge.integrationId).toBe('race-integration');
82
+ expect(edge.activityId).toBe(row.get('profileEdges')[0].activityId);
83
+ const result = await neogma.queryRunner.run(
84
+ `
85
+ MATCH (:AppStoreListing {listing_id: 'race-listing'})-[:PERFORMED_BY_LISTING]->(a:CredentialActivity {activityId: $activityId})
86
+ MATCH (:Profile {profileId: 'race-owner'})-[:PERFORMED]->(a)
87
+ MATCH (a)-[:TO_RECIPIENT]->(:Profile {profileId: 'race-learner'})
88
+ MATCH (a)-[:FOR_BOOST]->(:Boost {id: 'race-boost'})
89
+ RETURN a.metadata AS metadata
90
+ `,
91
+ { activityId: edge.activityId }
92
+ );
93
+ expect(result.records).toHaveLength(1);
94
+ expect(JSON.parse(result.records[0].get('metadata')).sequence).toBe(
95
+ Number(edge['metadata.sequence'])
96
+ );
97
+ }
98
+ };
99
+
100
+ it('repeated sends create exactly one linked activity and each required delivery edge', async () => {
101
+ for (let i = 0; i < 30; i++) await issue(i);
102
+ await assertGraph(30);
103
+ });
104
+
105
+ it('independent simultaneous sends keep their own activity and attribution', async () => {
106
+ const uris = await Promise.all(Array.from({ length: 8 }, (_, i) => issue(i)));
107
+ expect(new Set(uris).size).toBe(8);
108
+ await assertGraph(8);
109
+ });
110
+ });
package/tsconfig.json CHANGED
@@ -16,20 +16,20 @@
16
16
  "emitDeclarationOnly": true,
17
17
  "sourceMap": true,
18
18
  "rootDir": ".",
19
- "baseUrl": ".",
20
19
  "paths": {
21
- "@accesslayer/*": ["src/accesslayer/*"],
22
- "@cache": ["src/cache/index.ts"],
23
- "@cache/*": ["src/cache/*"],
24
- "@environment": ["src/config/environment.ts"],
25
- "@helpers/*": ["src/helpers/*"],
26
- "@instance": ["src/instance.ts"],
27
- "@models": ["src/models/index.ts"],
28
- "@routes": ["src/routes/index.ts"],
29
- "@routes/*": ["src/routes/*"],
30
- "types/*": ["src/types/*"],
31
- "@services/*": ["src/services/*"],
32
- "@tracing": ["src/tracing/index.ts"]
20
+ "src/*": ["./src/*"],
21
+ "@accesslayer/*": ["./src/accesslayer/*"],
22
+ "@cache": ["./src/cache/index.ts"],
23
+ "@cache/*": ["./src/cache/*"],
24
+ "@environment": ["./src/config/environment.ts"],
25
+ "@helpers/*": ["./src/helpers/*"],
26
+ "@instance": ["./src/instance.ts"],
27
+ "@models": ["./src/models/index.ts"],
28
+ "@routes": ["./src/routes/index.ts"],
29
+ "@routes/*": ["./src/routes/*"],
30
+ "types/*": ["./src/types/*"],
31
+ "@services/*": ["./src/services/*"],
32
+ "@tracing": ["./src/tracing/index.ts"]
33
33
  },
34
34
  "esModuleInterop": true,
35
35
  "allowSyntheticDefaultImports": true,
package/vitest.config.ts CHANGED
@@ -25,6 +25,7 @@ export default createVitestConfig(brainServicePreset, {
25
25
  'test/openapi.spec.ts',
26
26
  'src/helpers/posthog.helpers.test.ts',
27
27
  'src/helpers/rateLimit.helpers.test.ts',
28
+ 'src/helpers/sentry.helpers.test.ts',
28
29
  'src/helpers/credentialSubject.helpers.test.ts',
29
30
  'src/helpers/inbox-batch.helpers.test.ts',
30
31
  'src/helpers/inbox-queue.helpers.test.ts',
@@ -51,6 +52,7 @@ export default createVitestConfig(brainServicePreset, {
51
52
  'src/helpers/signingAuthority.helpers.test.ts',
52
53
  'src/helpers/status-list.helpers.test.ts',
53
54
  'src/helpers/boost-storage.helpers.test.ts',
55
+ 'src/helpers/app-credential-delivery.helpers.test.ts',
54
56
  'src/accesslayer/credential/create.test.ts',
55
57
  'src/accesslayer/inbox-batch/schema.test.ts',
56
58
  'src/helpers/inbox-encryption.helpers.test.ts',
@@ -13,6 +13,7 @@ export default createVitestConfig(serviceIntegrationPreset, {
13
13
  'test/inbox-batch.spec.ts',
14
14
  'test/atomic-counter.e2e.ts',
15
15
  'test/credential-storage.neo4j.spec.ts',
16
+ 'test/app-credential-delivery.neo4j.spec.ts',
16
17
  'test/inbox.spec.ts',
17
18
  ],
18
19
  alias: { '@instance': require.resolve('./test/helpers/mock-instance.ts') },