@learncard/network-brain-service 4.0.3 → 4.0.4
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/CHANGELOG.md +35 -0
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
- package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
- package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
- package/dist/helpers/boost.helpers.d.ts +1 -0
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/esbuildPlugins.cjs +9 -6
- package/package.json +16 -16
- package/src/accesslayer/consentflowcontract/relationships/create.ts +3 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
- package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
- package/src/accesslayer/inbox-batch/schema.ts +54 -0
- package/src/accesslayer/inbox-batch/store.ts +2 -38
- package/src/helpers/boost-storage.helpers.test.ts +155 -1
- package/src/helpers/boost.helpers.ts +52 -10
- package/src/routes/workflows.ts +8 -1
- package/test/consentflow.spec.ts +101 -0
- package/test/inbox-batch.spec.ts +189 -1
- package/vitest.config.ts +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workflows.d.ts","sourceRoot":"","sources":["../../src/routes/workflows.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAgB,OAAO,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"workflows.d.ts","sourceRoot":"","sources":["../../src/routes/workflows.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAgB,OAAO,EAAE,MAAM,SAAS,CAAC;AA4FhD,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqF1B,CAAC;AAgvBH,MAAM,MAAM,eAAe,GAAG,OAAO,eAAe,CAAC"}
|
package/esbuildPlugins.cjs
CHANGED
|
@@ -2,6 +2,7 @@ const path = require('node:path');
|
|
|
2
2
|
const fs = require('node:fs');
|
|
3
3
|
|
|
4
4
|
const { sentryEsbuildPlugin } = require('@sentry/esbuild-plugin');
|
|
5
|
+
const { withSentryRateLimitRetry } = require('../../../scripts/sentry-rate-limit-retry.cjs');
|
|
5
6
|
|
|
6
7
|
const forceCjsSodiumWrapperPlugin = {
|
|
7
8
|
name: 'force-cjs-sodium-wrapper',
|
|
@@ -40,10 +41,12 @@ const copyDidkitWasmPlugin = {
|
|
|
40
41
|
module.exports = [
|
|
41
42
|
forceCjsSodiumWrapperPlugin,
|
|
42
43
|
copyDidkitWasmPlugin,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
withSentryRateLimitRetry(
|
|
45
|
+
sentryEsbuildPlugin({
|
|
46
|
+
telemetry: process.env.SENTRY_BUILD_TELEMETRY !== 'false',
|
|
47
|
+
authToken: process.env.SENTRY_AUTH_TOKEN,
|
|
48
|
+
org: process.env.SENTRY_ORG,
|
|
49
|
+
project: process.env.SENTRY_PROJECT,
|
|
50
|
+
})
|
|
51
|
+
),
|
|
49
52
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/network-brain-service",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.4",
|
|
4
4
|
"description": "",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24.12 <25"
|
|
@@ -65,23 +65,23 @@
|
|
|
65
65
|
"@digitalcredentials/issuer-registry-client": "^4.1.0",
|
|
66
66
|
"@fastify/cors": "^11.2.0",
|
|
67
67
|
"@google/genai": "^1.27.0",
|
|
68
|
-
"@learncard/credential-library": "2.1.
|
|
69
|
-
"@learncard/core": "9.4.
|
|
70
|
-
"@learncard/crypto-plugin": "1.1.
|
|
71
|
-
"@learncard/did-web-plugin": "1.1.
|
|
72
|
-
"@learncard/didkey-plugin": "1.1.
|
|
73
|
-
"@learncard/didkit-plugin": "1.10.
|
|
74
|
-
"@learncard/didkit-plugin-node": "0.3.
|
|
75
|
-
"@learncard/dynamic-loader-plugin": "1.1.
|
|
76
|
-
"@learncard/encryption-plugin": "1.1.
|
|
77
|
-
"@learncard/expiration-plugin": "1.2.
|
|
68
|
+
"@learncard/credential-library": "2.1.3",
|
|
69
|
+
"@learncard/core": "9.4.40",
|
|
70
|
+
"@learncard/crypto-plugin": "1.1.40",
|
|
71
|
+
"@learncard/did-web-plugin": "1.1.40",
|
|
72
|
+
"@learncard/didkey-plugin": "1.1.40",
|
|
73
|
+
"@learncard/didkit-plugin": "1.10.5",
|
|
74
|
+
"@learncard/didkit-plugin-node": "0.3.5",
|
|
75
|
+
"@learncard/dynamic-loader-plugin": "1.1.40",
|
|
76
|
+
"@learncard/encryption-plugin": "1.1.40",
|
|
77
|
+
"@learncard/expiration-plugin": "1.2.40",
|
|
78
78
|
"@learncard/email-templates": "1.2.1",
|
|
79
|
-
"@learncard/helpers": "1.
|
|
80
|
-
"@learncard/learn-card-plugin": "1.2.
|
|
79
|
+
"@learncard/helpers": "1.7.0",
|
|
80
|
+
"@learncard/learn-card-plugin": "1.2.40",
|
|
81
81
|
"@learncard/partner-connect-core": "0.2.0",
|
|
82
|
-
"@learncard/types": "5.23.
|
|
83
|
-
"@learncard/vc-plugin": "1.6.
|
|
84
|
-
"@learncard/vc-templates-plugin": "1.1.
|
|
82
|
+
"@learncard/types": "5.23.2",
|
|
83
|
+
"@learncard/vc-plugin": "1.6.6",
|
|
84
|
+
"@learncard/vc-templates-plugin": "1.1.40",
|
|
85
85
|
"@sentry/serverless": "7.61.0",
|
|
86
86
|
"@trpc/server": "11.8.0",
|
|
87
87
|
"@types/lodash": "^4.14.191",
|
|
@@ -21,7 +21,7 @@ import { BoostType } from 'types/boost';
|
|
|
21
21
|
import { flattenObject, inflateObject } from '@helpers/objects.helpers';
|
|
22
22
|
import { convertQueryResultToPropertiesObjectArray } from '@helpers/neo4j.helpers';
|
|
23
23
|
import { reconsentTerms } from './update';
|
|
24
|
-
import { getBoostUri, sendBoost } from '@helpers/boost.helpers';
|
|
24
|
+
import { getBoostUri, sendBoost, stampIssuanceDate } from '@helpers/boost.helpers';
|
|
25
25
|
import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
|
|
26
26
|
import { getDidWeb } from '@helpers/did.helpers';
|
|
27
27
|
import { getSigningAuthorityForUserByName } from '@accesslayer/signing-authority/relationships/read';
|
|
@@ -296,6 +296,8 @@ export const consentToContract = async (
|
|
|
296
296
|
|
|
297
297
|
boostCredential.issuer = { id: contractOwnerSigningAuthority.relationship.did };
|
|
298
298
|
|
|
299
|
+
stampIssuanceDate(boostCredential);
|
|
300
|
+
|
|
299
301
|
if (boostCredential.type.includes('BoostCredential')) {
|
|
300
302
|
boostCredential.boostId = getBoostUri(boost.target.id, domain);
|
|
301
303
|
}
|
|
@@ -15,7 +15,7 @@ import { ConsentFlowTerms, ConsentFlowTransaction, ConsentFlowContract } from '@
|
|
|
15
15
|
import { neogma } from '@instance';
|
|
16
16
|
import { flattenObject } from '@helpers/objects.helpers';
|
|
17
17
|
import { DbContractType, DbTermsType } from 'types/consentflowcontract';
|
|
18
|
-
import { getBoostUri, sendBoost } from '@helpers/boost.helpers';
|
|
18
|
+
import { getBoostUri, sendBoost, stampIssuanceDate } from '@helpers/boost.helpers';
|
|
19
19
|
import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
|
|
20
20
|
import { getDidWeb } from '@helpers/did.helpers';
|
|
21
21
|
import { getSigningAuthorityForUserByName } from '@accesslayer/signing-authority/relationships/read';
|
|
@@ -201,6 +201,8 @@ export const reconsentTerms = async (
|
|
|
201
201
|
// Set the issuer and subject
|
|
202
202
|
boostCredential.issuer = { id: contractOwnerSigningAuthority.relationship.did };
|
|
203
203
|
|
|
204
|
+
stampIssuanceDate(boostCredential);
|
|
205
|
+
|
|
204
206
|
if (boostCredential.type.includes('BoostCredential')) {
|
|
205
207
|
boostCredential.boostId = getBoostUri(boost.dataValues.id, domain);
|
|
206
208
|
}
|
|
@@ -439,6 +441,8 @@ export const updateTerms = async (
|
|
|
439
441
|
// Set the issuer and subject
|
|
440
442
|
boostCredential.issuer = { id: contractOwnerSigningAuthority.relationship.did };
|
|
441
443
|
|
|
444
|
+
stampIssuanceDate(boostCredential);
|
|
445
|
+
|
|
442
446
|
if (boostCredential.type.includes('BoostCredential')) {
|
|
443
447
|
boostCredential.boostId = getBoostUri(boost.target.id, domain);
|
|
444
448
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Driver, ManagedTransaction } from 'neo4j-driver';
|
|
3
|
+
|
|
4
|
+
vi.mock('@instance', () => ({ neogma: { driver: { session: vi.fn() } } }));
|
|
5
|
+
import { createInboxBatchSchemaInitializer } from './schema';
|
|
6
|
+
|
|
7
|
+
const harness = () => {
|
|
8
|
+
const run = vi.fn().mockResolvedValue({});
|
|
9
|
+
const close = vi.fn().mockResolvedValue(undefined);
|
|
10
|
+
const executeWrite = vi.fn(async (callback: (tx: ManagedTransaction) => unknown) =>
|
|
11
|
+
callback({ run } as unknown as ManagedTransaction)
|
|
12
|
+
);
|
|
13
|
+
const driver = { session: vi.fn(() => ({ run: vi.fn(), executeWrite, close })) };
|
|
14
|
+
const ensure = createInboxBatchSchemaInitializer(driver as unknown as Driver);
|
|
15
|
+
return { run, close, executeWrite, driver, ensure };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
beforeEach(() => vi.clearAllMocks());
|
|
19
|
+
|
|
20
|
+
describe('inbox batch schema readiness', () => {
|
|
21
|
+
it('shares in-flight readiness and runs every constraint AND index in its own managed write', async () => {
|
|
22
|
+
const { ensure, driver, executeWrite, run, close } = harness();
|
|
23
|
+
const first = ensure();
|
|
24
|
+
expect(ensure()).toBe(first);
|
|
25
|
+
await first;
|
|
26
|
+
await ensure();
|
|
27
|
+
expect(driver.session).toHaveBeenCalledTimes(1);
|
|
28
|
+
expect(executeWrite).toHaveBeenCalledTimes(10);
|
|
29
|
+
expect(run).toHaveBeenCalledTimes(10);
|
|
30
|
+
expect(
|
|
31
|
+
run.mock.calls.filter(([query]) => query.startsWith('CREATE CONSTRAINT'))
|
|
32
|
+
).toHaveLength(4);
|
|
33
|
+
expect(run.mock.calls.filter(([query]) => query.startsWith('CREATE INDEX'))).toHaveLength(
|
|
34
|
+
6
|
|
35
|
+
);
|
|
36
|
+
expect(close).toHaveBeenCalledTimes(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('lets independent API/worker initializers complete through a transient managed-transaction retry', async () => {
|
|
40
|
+
const api = harness();
|
|
41
|
+
const worker = harness();
|
|
42
|
+
// Model the driver re-entering the transaction callback after a DDL deadlock.
|
|
43
|
+
// The real-driver concurrent cold-start regression lives in inbox-batch.spec.ts.
|
|
44
|
+
const deadlock = { code: 'Neo.TransientError.Transaction.DeadlockDetected' };
|
|
45
|
+
api.run.mockRejectedValueOnce(deadlock);
|
|
46
|
+
api.executeWrite.mockImplementationOnce(async callback => {
|
|
47
|
+
await expect(callback({ run: api.run } as unknown as ManagedTransaction)).rejects.toBe(
|
|
48
|
+
deadlock
|
|
49
|
+
);
|
|
50
|
+
return callback({ run: api.run } as unknown as ManagedTransaction);
|
|
51
|
+
});
|
|
52
|
+
await expect(Promise.all([api.ensure(), worker.ensure()])).resolves.toEqual([
|
|
53
|
+
undefined,
|
|
54
|
+
undefined,
|
|
55
|
+
]);
|
|
56
|
+
const firstQuery =
|
|
57
|
+
'CREATE CONSTRAINT inboxbatch_id IF NOT EXISTS FOR (n:InboxBatch) REQUIRE n.id IS UNIQUE';
|
|
58
|
+
expect(api.run.mock.calls.slice(0, 2)).toEqual([[firstQuery], [firstQuery]]);
|
|
59
|
+
expect(api.run).toHaveBeenCalledTimes(11);
|
|
60
|
+
expect(worker.run).toHaveBeenCalledTimes(10);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it.each([0, 4])('accepts equivalent-schema races for statement %i', async position => {
|
|
64
|
+
const { ensure, run } = harness();
|
|
65
|
+
for (let index = 0; index < position; index++) run.mockResolvedValueOnce({});
|
|
66
|
+
run.mockRejectedValueOnce({
|
|
67
|
+
code: 'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists',
|
|
68
|
+
});
|
|
69
|
+
await expect(ensure()).resolves.toBeUndefined();
|
|
70
|
+
expect(run).toHaveBeenCalledTimes(10);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it.each([
|
|
74
|
+
'Neo.ClientError.Security.Forbidden',
|
|
75
|
+
'Neo.TransientError.Transaction.DeadlockDetected',
|
|
76
|
+
])(
|
|
77
|
+
'propagates %s after the managed write fails and allows a later readiness attempt',
|
|
78
|
+
async code => {
|
|
79
|
+
const { ensure, executeWrite, run, close, driver } = harness();
|
|
80
|
+
const failure = { code };
|
|
81
|
+
executeWrite.mockRejectedValueOnce(failure);
|
|
82
|
+
await expect(ensure()).rejects.toBe(failure);
|
|
83
|
+
expect(run).not.toHaveBeenCalled();
|
|
84
|
+
expect(close).toHaveBeenCalledTimes(1);
|
|
85
|
+
await expect(ensure()).resolves.toBeUndefined();
|
|
86
|
+
expect(driver.session).toHaveBeenCalledTimes(2);
|
|
87
|
+
expect(run).toHaveBeenCalledTimes(10);
|
|
88
|
+
expect(close).toHaveBeenCalledTimes(2);
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Driver } from 'neo4j-driver';
|
|
2
|
+
import { neogma } from '@instance';
|
|
3
|
+
|
|
4
|
+
const SCHEMA_QUERIES = [
|
|
5
|
+
...['InboxBatch', 'InboxBatchItem', 'InboxBatchIssuer', 'InboxBatchReplay'].map(
|
|
6
|
+
label =>
|
|
7
|
+
`CREATE CONSTRAINT ${label.toLowerCase()}_id IF NOT EXISTS FOR (n:${label}) REQUIRE n.id IS UNIQUE`
|
|
8
|
+
),
|
|
9
|
+
'CREATE INDEX inbox_batch_request IF NOT EXISTS FOR (b:InboxBatch) ON (b.issuer, b.requestId)',
|
|
10
|
+
'CREATE INDEX inbox_batch_dispatch IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.dispatchAt)',
|
|
11
|
+
'CREATE INDEX inbox_batch_recovery IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.leaseUntil)',
|
|
12
|
+
'CREATE INDEX inbox_batch_replay_owner IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.itemId)',
|
|
13
|
+
'CREATE INDEX inbox_batch_completed IF NOT EXISTS FOR (b:InboxBatch) ON (b.completedAt)',
|
|
14
|
+
'CREATE INDEX inbox_batch_replay_expiry IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.expiresAt)',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** Each API/worker process shares readiness locally; the driver retries cross-process DDL deadlocks. */
|
|
18
|
+
export const createInboxBatchSchemaInitializer = (
|
|
19
|
+
driver: Pick<Driver, 'session'>
|
|
20
|
+
): (() => Promise<void>) => {
|
|
21
|
+
let readiness: Promise<void> | undefined;
|
|
22
|
+
return (): Promise<void> => {
|
|
23
|
+
if (!readiness) {
|
|
24
|
+
const pending = (async (): Promise<void> => {
|
|
25
|
+
const session = driver.session();
|
|
26
|
+
try {
|
|
27
|
+
for (const query of SCHEMA_QUERIES) {
|
|
28
|
+
try {
|
|
29
|
+
// One schema statement per managed transaction. IF NOT EXISTS is
|
|
30
|
+
// idempotent, but competing cold processes can still deadlock while
|
|
31
|
+
// acquiring label locks. Auto-commit queryRunner.run does not retry.
|
|
32
|
+
await session.executeWrite(tx => tx.run(query));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (
|
|
35
|
+
(error as { code?: string } | null)?.code !==
|
|
36
|
+
'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists'
|
|
37
|
+
)
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} finally {
|
|
42
|
+
await session.close();
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
45
|
+
readiness = pending;
|
|
46
|
+
void pending.catch(() => {
|
|
47
|
+
if (readiness === pending) readiness = undefined;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return readiness;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const ensureInboxBatchConstraints = createInboxBatchSchemaInitializer(neogma.driver);
|
|
@@ -6,49 +6,13 @@ import { neogma } from '@instance';
|
|
|
6
6
|
import { encryptInboxCredential, decryptInboxCredential } from '@helpers/inbox-encryption.helpers';
|
|
7
7
|
import type { BatchItem, BatchJob, BatchReplayStore, InboxDispatchLease } from 'types/inbox-batch';
|
|
8
8
|
import { getInboxBatchState } from '@helpers/inbox-batch-status.helpers';
|
|
9
|
+
import { ensureInboxBatchConstraints } from './schema';
|
|
9
10
|
|
|
10
11
|
export const DAY = 86_400_000;
|
|
11
12
|
export const LEASE_MS = 360_000;
|
|
12
13
|
const CLEANUP_MARGIN_MS = 5_000;
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
export const ensureInboxBatchConstraints = (): Promise<void> => {
|
|
16
|
-
if (!readiness) {
|
|
17
|
-
readiness = (async () => {
|
|
18
|
-
for (const label of [
|
|
19
|
-
'InboxBatch',
|
|
20
|
-
'InboxBatchItem',
|
|
21
|
-
'InboxBatchIssuer',
|
|
22
|
-
'InboxBatchReplay',
|
|
23
|
-
]) {
|
|
24
|
-
try {
|
|
25
|
-
await neogma.queryRunner.run(
|
|
26
|
-
`CREATE CONSTRAINT ${label.toLowerCase()}_id IF NOT EXISTS FOR (n:${label}) REQUIRE n.id IS UNIQUE`
|
|
27
|
-
);
|
|
28
|
-
} catch (error) {
|
|
29
|
-
if (
|
|
30
|
-
(error as { code?: string }).code !==
|
|
31
|
-
'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists'
|
|
32
|
-
)
|
|
33
|
-
throw error;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
for (const query of [
|
|
37
|
-
'CREATE INDEX inbox_batch_request IF NOT EXISTS FOR (b:InboxBatch) ON (b.issuer, b.requestId)',
|
|
38
|
-
'CREATE INDEX inbox_batch_dispatch IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.dispatchAt)',
|
|
39
|
-
'CREATE INDEX inbox_batch_recovery IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.leaseUntil)',
|
|
40
|
-
'CREATE INDEX inbox_batch_replay_owner IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.itemId)',
|
|
41
|
-
'CREATE INDEX inbox_batch_completed IF NOT EXISTS FOR (b:InboxBatch) ON (b.completedAt)',
|
|
42
|
-
'CREATE INDEX inbox_batch_replay_expiry IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.expiresAt)',
|
|
43
|
-
])
|
|
44
|
-
await neogma.queryRunner.run(query);
|
|
45
|
-
})();
|
|
46
|
-
void readiness.catch(() => {
|
|
47
|
-
readiness = undefined;
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
return readiness;
|
|
51
|
-
};
|
|
15
|
+
export { ensureInboxBatchConstraints } from './schema';
|
|
52
16
|
|
|
53
17
|
const transaction = async <T>(fn: (tx: ManagedTransaction) => Promise<T>): Promise<T> => {
|
|
54
18
|
await ensureInboxBatchConstraints();
|
|
@@ -65,7 +65,12 @@ vi.mock('@tracing', () => ({
|
|
|
65
65
|
traceInternal: async (_name: string, action: () => unknown) => action(),
|
|
66
66
|
}));
|
|
67
67
|
|
|
68
|
-
import {
|
|
68
|
+
import {
|
|
69
|
+
issueClaimLinkBoost,
|
|
70
|
+
prepareCredentialFromBoost,
|
|
71
|
+
sendBoost,
|
|
72
|
+
stampIssuanceDate,
|
|
73
|
+
} from './boost.helpers';
|
|
69
74
|
|
|
70
75
|
const vc = {
|
|
71
76
|
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
@@ -223,6 +228,155 @@ describe('boost payload storage', () => {
|
|
|
223
228
|
}
|
|
224
229
|
);
|
|
225
230
|
|
|
231
|
+
describe('malformed template issuance dates', () => {
|
|
232
|
+
// Seen on a production Boost template; it is not RFC 3339, so DIDKit refuses to sign it.
|
|
233
|
+
const MALFORMED_DATE = '2025-10-19+053008:18:16';
|
|
234
|
+
// The reused JOTA-JOTI "Health and Well-being" Boost that carried it.
|
|
235
|
+
const BOOST_ID = 'e073d183-8304-419f-b4f6-ef91fda6524c';
|
|
236
|
+
const V1 = ['https://www.w3.org/2018/credentials/v1'];
|
|
237
|
+
const V2 = ['https://www.w3.org/ns/credentials/v2'];
|
|
238
|
+
const authority = { relationship: { did: 'did:example:sa' } } as Parameters<
|
|
239
|
+
typeof issueClaimLinkBoost
|
|
240
|
+
>[4];
|
|
241
|
+
const boostWith = (template: Record<string, unknown>) =>
|
|
242
|
+
({
|
|
243
|
+
...options.boost,
|
|
244
|
+
dataValues: {
|
|
245
|
+
id: BOOST_ID,
|
|
246
|
+
boost: JSON.stringify({ ...vc, boostId: undefined, ...template }),
|
|
247
|
+
},
|
|
248
|
+
}) as Parameters<typeof issueClaimLinkBoost>[0];
|
|
249
|
+
const expectFreshDate = (value: unknown, before: number) => {
|
|
250
|
+
expect(typeof value).toBe('string');
|
|
251
|
+
expect(value).toBe(new Date(value as string).toISOString());
|
|
252
|
+
expect(Date.parse(value as string)).toBeGreaterThanOrEqual(before);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
beforeEach(() => {
|
|
256
|
+
mocks.signingAuthority.mockResolvedValue({
|
|
257
|
+
kind: 'issued-credential',
|
|
258
|
+
credential: jwe,
|
|
259
|
+
statusEntries: [],
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it.each([
|
|
264
|
+
[
|
|
265
|
+
'VC 1.1 issuanceDate',
|
|
266
|
+
{ '@context': V1, issuanceDate: MALFORMED_DATE },
|
|
267
|
+
'issuanceDate',
|
|
268
|
+
],
|
|
269
|
+
[
|
|
270
|
+
'VC 2.0 validFrom',
|
|
271
|
+
{ '@context': V2, issuanceDate: undefined, validFrom: MALFORMED_DATE },
|
|
272
|
+
'validFrom',
|
|
273
|
+
],
|
|
274
|
+
] as const)(
|
|
275
|
+
'restamps a malformed %s when a claim link is claimed',
|
|
276
|
+
async (_kind, template, field) => {
|
|
277
|
+
const before = Date.now();
|
|
278
|
+
await issueClaimLinkBoost(
|
|
279
|
+
boostWith(template),
|
|
280
|
+
options.domain,
|
|
281
|
+
options.from,
|
|
282
|
+
options.to,
|
|
283
|
+
authority
|
|
284
|
+
);
|
|
285
|
+
const unsigned = mocks.signingAuthority.mock.calls[0]![1];
|
|
286
|
+
expectFreshDate(unsigned[field], before);
|
|
287
|
+
}
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
it('drops a stale issuanceDate left on a VC 2.0 template', async () => {
|
|
291
|
+
const before = Date.now();
|
|
292
|
+
await issueClaimLinkBoost(
|
|
293
|
+
boostWith({ '@context': V2, issuanceDate: MALFORMED_DATE }),
|
|
294
|
+
options.domain,
|
|
295
|
+
options.from,
|
|
296
|
+
options.to,
|
|
297
|
+
authority
|
|
298
|
+
);
|
|
299
|
+
const unsigned = mocks.signingAuthority.mock.calls[0]![1];
|
|
300
|
+
expectFreshDate(unsigned.validFrom, before);
|
|
301
|
+
expect(unsigned).not.toHaveProperty('issuanceDate');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('drops a stale issuanceDate left on a VC 2.0 template for direct sends', async () => {
|
|
305
|
+
const before = Date.now();
|
|
306
|
+
const credential = await prepareCredentialFromBoost(
|
|
307
|
+
boostWith({ '@context': V2, issuanceDate: MALFORMED_DATE }),
|
|
308
|
+
`lc:network:network.example/boost:${BOOST_ID}`,
|
|
309
|
+
options.domain
|
|
310
|
+
);
|
|
311
|
+
expectFreshDate(credential.validFrom, before);
|
|
312
|
+
expect(credential).not.toHaveProperty('issuanceDate');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it.each([
|
|
316
|
+
{ credentialSubject: { creditsEarned: 'not a number' } },
|
|
317
|
+
{ awardedDate: 'bad-date' },
|
|
318
|
+
])('reports invalid standard fields as BAD_REQUEST', async fields => {
|
|
319
|
+
await expect(
|
|
320
|
+
prepareCredentialFromBoost(
|
|
321
|
+
boostWith({
|
|
322
|
+
'@context': V2,
|
|
323
|
+
type: ['VerifiableCredential', 'OpenBadgeCredential'],
|
|
324
|
+
proof: undefined,
|
|
325
|
+
...fields,
|
|
326
|
+
}),
|
|
327
|
+
`lc:network:network.example/boost:${BOOST_ID}`,
|
|
328
|
+
options.domain
|
|
329
|
+
)
|
|
330
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it.each(['', ' '])(
|
|
334
|
+
'omits blank awardedDate after template substitution %j',
|
|
335
|
+
async completed => {
|
|
336
|
+
const credential = await prepareCredentialFromBoost(
|
|
337
|
+
boostWith({
|
|
338
|
+
'@context': V2,
|
|
339
|
+
type: ['VerifiableCredential', 'OpenBadgeCredential'],
|
|
340
|
+
proof: undefined,
|
|
341
|
+
awardedDate: '{{completed}}',
|
|
342
|
+
}),
|
|
343
|
+
`lc:network:network.example/boost:${BOOST_ID}`,
|
|
344
|
+
options.domain,
|
|
345
|
+
{ templateData: { completed } }
|
|
346
|
+
);
|
|
347
|
+
expect(credential).not.toHaveProperty('awardedDate');
|
|
348
|
+
}
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
describe('stampIssuanceDate', () => {
|
|
352
|
+
const NOW = '2026-10-18T05:00:00.000Z';
|
|
353
|
+
|
|
354
|
+
it('sets validFrom and removes issuanceDate on VC 2.0', () => {
|
|
355
|
+
const stamped = stampIssuanceDate(
|
|
356
|
+
{
|
|
357
|
+
...vc,
|
|
358
|
+
'@context': V2,
|
|
359
|
+
validFrom: MALFORMED_DATE,
|
|
360
|
+
issuanceDate: MALFORMED_DATE,
|
|
361
|
+
},
|
|
362
|
+
NOW
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
expect(stamped.validFrom).toBe(NOW);
|
|
366
|
+
expect(stamped).not.toHaveProperty('issuanceDate');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('only sets issuanceDate on VC 1.1', () => {
|
|
370
|
+
const template = { ...vc, '@context': V1, issuanceDate: MALFORMED_DATE };
|
|
371
|
+
|
|
372
|
+
expect(stampIssuanceDate({ ...template }, NOW)).toEqual({
|
|
373
|
+
...template,
|
|
374
|
+
issuanceDate: NOW,
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
226
380
|
it('preserves explicit notification and acceptance opt-outs', async () => {
|
|
227
381
|
await sendBoost({
|
|
228
382
|
...options,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import cloneDeep from 'lodash/cloneDeep';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CredentialFieldError,
|
|
4
|
+
isEncrypted,
|
|
5
|
+
isVC2Format,
|
|
6
|
+
materializeCredentialFields,
|
|
7
|
+
} from '@learncard/helpers';
|
|
3
8
|
import { TRPCError } from '@trpc/server';
|
|
4
9
|
import {
|
|
5
10
|
VC,
|
|
@@ -87,6 +92,29 @@ export const isBoostViewableByClaimLink = async (boost: BoostInstance): Promise<
|
|
|
87
92
|
return true;
|
|
88
93
|
};
|
|
89
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Stamps the issuance time onto a credential built from a Boost template.
|
|
97
|
+
*
|
|
98
|
+
* A template's own issuanceDate/validFrom is when the template was written, not when this
|
|
99
|
+
* credential is issued, and templates created through the API are not date-validated: a
|
|
100
|
+
* malformed value (e.g. "2025-10-19+053008:18:16") makes DIDKit refuse to sign. VC 2.0
|
|
101
|
+
* templates get validFrom and lose any leftover issuanceDate, which the VC 2.0 context doesn't
|
|
102
|
+
* define (DIDKit rejects it as an undefined JSON-LD term). VC 1.1 templates get issuanceDate.
|
|
103
|
+
*/
|
|
104
|
+
export const stampIssuanceDate = <T extends UnsignedVC | VC>(
|
|
105
|
+
credential: T,
|
|
106
|
+
now: string = new Date().toISOString()
|
|
107
|
+
): T => {
|
|
108
|
+
if (isVC2Format(credential)) {
|
|
109
|
+
credential.validFrom = now;
|
|
110
|
+
delete credential.issuanceDate;
|
|
111
|
+
} else {
|
|
112
|
+
credential.issuanceDate = now;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return credential;
|
|
116
|
+
};
|
|
117
|
+
|
|
90
118
|
export const convertCredentialToBoostTemplateJSON = (
|
|
91
119
|
credential: VC | UnsignedVC,
|
|
92
120
|
defaultIssuerDid?: string
|
|
@@ -255,6 +283,8 @@ export const issueClaimLinkBoost = async (
|
|
|
255
283
|
|
|
256
284
|
boostCredential.issuer = signingAuthorityForUser.relationship.did;
|
|
257
285
|
|
|
286
|
+
stampIssuanceDate(boostCredential);
|
|
287
|
+
|
|
258
288
|
setCredentialSubjectIds(boostCredential, getDidWeb(domain, to.profileId));
|
|
259
289
|
|
|
260
290
|
// Embed the boostURI into the boost credential for verification purposes.
|
|
@@ -331,17 +361,20 @@ export const prepareCredentialFromBoost = async (
|
|
|
331
361
|
boostJsonString = renderBoostTemplate(boostJsonString, mergedTemplateData);
|
|
332
362
|
}
|
|
333
363
|
|
|
334
|
-
|
|
364
|
+
let credential: UnsignedVC;
|
|
365
|
+
try {
|
|
366
|
+
credential = materializeCredentialFields(
|
|
367
|
+
parseRenderedTemplate<UnsignedVC>(boostJsonString)
|
|
368
|
+
) as UnsignedVC;
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (error instanceof CredentialFieldError)
|
|
371
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: error.message, cause: error });
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
335
374
|
|
|
336
375
|
appendTemplateEvidenceToCredential(credential, mergedTemplateData, allowAutoAppendEvidence);
|
|
337
376
|
|
|
338
|
-
|
|
339
|
-
const now = new Date().toISOString();
|
|
340
|
-
if (isVC2Format(credential)) {
|
|
341
|
-
credential.validFrom = now;
|
|
342
|
-
} else {
|
|
343
|
-
credential.issuanceDate = now;
|
|
344
|
-
}
|
|
377
|
+
stampIssuanceDate(credential);
|
|
345
378
|
|
|
346
379
|
// Set issuer if provided
|
|
347
380
|
if (issuerDid) {
|
|
@@ -432,7 +465,16 @@ export const appendTemplateEvidenceToCredential = (
|
|
|
432
465
|
// EvidenceFile subterms — otherwise the signer fails later with an opaque
|
|
433
466
|
// JSON-LD key-expansion error. Surface a clear message instead so the
|
|
434
467
|
// caller can prompt the user to republish the boost.
|
|
435
|
-
|
|
468
|
+
const usesEvidenceExtensions = normalizedTemplateEvidence.some(item => {
|
|
469
|
+
if (!item || typeof item !== 'object') return false;
|
|
470
|
+
const evidence = item as Record<string, unknown>;
|
|
471
|
+
const types = Array.isArray(evidence.type) ? evidence.type : [evidence.type];
|
|
472
|
+
return (
|
|
473
|
+
types.includes('EvidenceFile') ||
|
|
474
|
+
['fileName', 'fileType', 'fileSize'].some(key => key in evidence)
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
if (usesEvidenceExtensions && !credentialContextSupportsDynamicEvidence(credential)) {
|
|
436
478
|
throw new TRPCError({
|
|
437
479
|
code: 'PRECONDITION_FAILED',
|
|
438
480
|
message:
|
package/src/routes/workflows.ts
CHANGED
|
@@ -37,7 +37,12 @@ import { createClaimedRelationship } from '@accesslayer/inbox-credential/relatio
|
|
|
37
37
|
import { getContactMethodById, getProfileByContactMethod } from '@accesslayer/contact-method/read';
|
|
38
38
|
import { getProfileByDid, getProfileByProfileId } from '@accesslayer/profile/read';
|
|
39
39
|
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
getBoostUri,
|
|
42
|
+
isBoostViewableByClaimLink,
|
|
43
|
+
isDraftBoost,
|
|
44
|
+
stampIssuanceDate,
|
|
45
|
+
} from '@helpers/boost.helpers';
|
|
41
46
|
import { getBoostOwnerProfile } from 'types/boost';
|
|
42
47
|
import { getEmptyLearnCard, getLearnCard } from '@helpers/learnCard.helpers';
|
|
43
48
|
import { issueCredentialWithSigningAuthority } from '@helpers/signingAuthority.helpers';
|
|
@@ -392,6 +397,8 @@ async function handlePresentationForClaim(
|
|
|
392
397
|
|
|
393
398
|
boostCredential.issuer = signingAuthorityForUser.relationship.did;
|
|
394
399
|
|
|
400
|
+
stampIssuanceDate(boostCredential);
|
|
401
|
+
|
|
395
402
|
if (Array.isArray(boostCredential.credentialSubject)) {
|
|
396
403
|
boostCredential.credentialSubject = boostCredential.credentialSubject.map(subject => ({
|
|
397
404
|
...subject,
|