@learncard/network-brain-service 4.1.0 → 4.1.2
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.
- package/AGENTS.md +16 -0
- package/CHANGELOG.md +35 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/app-credential-delivery.helpers.d.ts +4 -0
- package/dist/helpers/app-credential-delivery.helpers.d.ts.map +1 -0
- package/dist/helpers/app-credential-delivery.helpers.test.d.ts +2 -0
- package/dist/helpers/app-credential-delivery.helpers.test.d.ts.map +1 -0
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/sentry.helpers.d.ts.map +1 -1
- package/dist/helpers/sentry.helpers.test.d.ts +2 -0
- package/dist/helpers/sentry.helpers.test.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/app-store.d.ts.map +1 -1
- package/package.json +18 -18
- package/project.json +1 -1
- package/src/helpers/activity.helpers.ts +3 -3
- package/src/helpers/app-credential-delivery.helpers.test.ts +56 -0
- package/src/helpers/app-credential-delivery.helpers.ts +11 -0
- package/src/helpers/boost-storage.helpers.test.ts +63 -0
- package/src/helpers/boost.helpers.ts +23 -18
- package/src/helpers/sentry.helpers.test.ts +56 -0
- package/src/helpers/sentry.helpers.ts +4 -3
- package/src/routes/app-store.ts +9 -16
- package/test/app-credential-delivery.neo4j.spec.ts +110 -0
- package/tsconfig.json +13 -13
- package/vitest.config.ts +2 -0
- package/vitest.inbox-e2e.config.ts +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app-store.d.ts","sourceRoot":"","sources":["../../src/routes/app-store.ts"],"names":[],"mappings":"
|
|
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.
|
|
3
|
+
"version": "4.1.2",
|
|
4
4
|
"description": "",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24.12 <25"
|
|
7
7
|
},
|
|
8
|
-
"packageManager": "bun@1.4.
|
|
8
|
+
"packageManager": "bun@1.4.4",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"types": "dist/index.d.ts",
|
|
11
11
|
"scripts": {
|
|
@@ -68,23 +68,23 @@
|
|
|
68
68
|
"@digitalcredentials/issuer-registry-client": "^4.1.0",
|
|
69
69
|
"@fastify/cors": "^11.2.0",
|
|
70
70
|
"@google/genai": "^1.27.0",
|
|
71
|
-
"@learncard/credential-library": "2.1.
|
|
72
|
-
"@learncard/core": "9.4.
|
|
73
|
-
"@learncard/crypto-plugin": "1.1.
|
|
74
|
-
"@learncard/did-web-plugin": "1.1.
|
|
75
|
-
"@learncard/didkey-plugin": "1.1.
|
|
76
|
-
"@learncard/didkit-plugin": "1.10.
|
|
77
|
-
"@learncard/didkit-plugin-node": "0.3.
|
|
78
|
-
"@learncard/dynamic-loader-plugin": "1.1.
|
|
79
|
-
"@learncard/encryption-plugin": "1.1.
|
|
80
|
-
"@learncard/expiration-plugin": "1.2.
|
|
81
|
-
"@learncard/email-templates": "1.2.
|
|
82
|
-
"@learncard/helpers": "1.7.
|
|
83
|
-
"@learncard/learn-card-plugin": "1.2.
|
|
71
|
+
"@learncard/credential-library": "2.1.5",
|
|
72
|
+
"@learncard/core": "9.4.42",
|
|
73
|
+
"@learncard/crypto-plugin": "1.1.42",
|
|
74
|
+
"@learncard/did-web-plugin": "1.1.42",
|
|
75
|
+
"@learncard/didkey-plugin": "1.1.42",
|
|
76
|
+
"@learncard/didkit-plugin": "1.10.8",
|
|
77
|
+
"@learncard/didkit-plugin-node": "0.3.8",
|
|
78
|
+
"@learncard/dynamic-loader-plugin": "1.1.42",
|
|
79
|
+
"@learncard/encryption-plugin": "1.1.42",
|
|
80
|
+
"@learncard/expiration-plugin": "1.2.42",
|
|
81
|
+
"@learncard/email-templates": "1.2.2",
|
|
82
|
+
"@learncard/helpers": "1.7.2",
|
|
83
|
+
"@learncard/learn-card-plugin": "1.2.42",
|
|
84
84
|
"@learncard/partner-connect-core": "0.2.0",
|
|
85
|
-
"@learncard/types": "5.24.
|
|
86
|
-
"@learncard/vc-plugin": "1.6.
|
|
87
|
-
"@learncard/vc-templates-plugin": "1.1.
|
|
85
|
+
"@learncard/types": "5.24.1",
|
|
86
|
+
"@learncard/vc-plugin": "1.6.8",
|
|
87
|
+
"@learncard/vc-templates-plugin": "1.1.42",
|
|
88
88
|
"@sentry/serverless": "7.61.0",
|
|
89
89
|
"@trpc/server": "11.8.0",
|
|
90
90
|
"@types/lodash": "^4.14.191",
|
package/project.json
CHANGED
|
@@ -32,9 +32,9 @@ export const logCredentialSent = async (params: {
|
|
|
32
32
|
listingId?: string;
|
|
33
33
|
source: CredentialActivitySourceType;
|
|
34
34
|
metadata?: Record<string, unknown>;
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 },
|
package/src/routes/app-store.ts
CHANGED
|
@@ -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
|
-
//
|
|
951
|
-
//
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
22
|
-
"@
|
|
23
|
-
"@cache
|
|
24
|
-
"@
|
|
25
|
-
"@
|
|
26
|
-
"@
|
|
27
|
-
"@
|
|
28
|
-
"@
|
|
29
|
-
"@routes
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"@
|
|
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') },
|