@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
package/test/consentflow.spec.ts
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import { vi, describe, beforeAll, beforeEach, afterEach, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Wraps the real signer so a test can inspect the credential it was handed.
|
|
4
|
+
const signingAuthorityMocks = vi.hoisted(() => ({ issueCredential: vi.fn() }));
|
|
5
|
+
|
|
6
|
+
vi.mock('@helpers/signingAuthority.helpers', async importOriginal => {
|
|
7
|
+
const actual = await importOriginal<typeof import('@helpers/signingAuthority.helpers')>();
|
|
8
|
+
signingAuthorityMocks.issueCredential.mockImplementation(
|
|
9
|
+
actual.issueCredentialWithSigningAuthority
|
|
10
|
+
);
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
issueCredentialWithSigningAuthority: signingAuthorityMocks.issueCredential,
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
2
17
|
import {
|
|
3
18
|
promiscuousTerms,
|
|
4
19
|
minimalContract,
|
|
@@ -1951,6 +1966,92 @@ describe('Consent Flow Contracts', () => {
|
|
|
1951
1966
|
expect(hasAutoBoostTx).toBeTruthy();
|
|
1952
1967
|
});
|
|
1953
1968
|
|
|
1969
|
+
describe('first-consent auto-boost issuance dates', () => {
|
|
1970
|
+
// A malformed date seen on a production Boost template; DIDKit refuses to sign it.
|
|
1971
|
+
const MALFORMED_DATE = '2025-10-19+053008:18:16';
|
|
1972
|
+
const TEMPLATE_DATE = '2025-10-19T08:18:16.000Z';
|
|
1973
|
+
|
|
1974
|
+
const consentWithAutoBoost = async (credential: Record<string, unknown>) => {
|
|
1975
|
+
await userA.clients.fullAuth.profile.registerSigningAuthority({
|
|
1976
|
+
endpoint: 'http://localhost:5000/api',
|
|
1977
|
+
name: 'mysa',
|
|
1978
|
+
did: 'did:key:z6MkitsQTk2GDNYXAFckVcQHtC68S9j9ruVFYWrixM6RG5Mw',
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
const boostUri = await userA.clients.fullAuth.boost.createBoost({
|
|
1982
|
+
credential: credential as typeof testUnsignedBoost,
|
|
1983
|
+
category: 'Achievement',
|
|
1984
|
+
name: 'Auto-issued Boost',
|
|
1985
|
+
});
|
|
1986
|
+
|
|
1987
|
+
const contractUri =
|
|
1988
|
+
await userA.clients.fullAuth.contracts.createConsentFlowContract({
|
|
1989
|
+
contract: normalContract,
|
|
1990
|
+
name: 'Auto Boost Contract',
|
|
1991
|
+
autoboosts: [
|
|
1992
|
+
{
|
|
1993
|
+
boostUri,
|
|
1994
|
+
signingAuthority: {
|
|
1995
|
+
endpoint: 'http://localhost:5000/api',
|
|
1996
|
+
name: 'mysa',
|
|
1997
|
+
},
|
|
1998
|
+
},
|
|
1999
|
+
],
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
signingAuthorityMocks.issueCredential.mockClear();
|
|
2003
|
+
const before = Date.now();
|
|
2004
|
+
const { termsUri } = await userB.clients.fullAuth.contracts.consentToContract({
|
|
2005
|
+
contractUri,
|
|
2006
|
+
terms: normalFullTerms,
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
expect(signingAuthorityMocks.issueCredential).toHaveBeenCalledTimes(1);
|
|
2010
|
+
const unsigned = signingAuthorityMocks.issueCredential.mock.calls[0]![1];
|
|
2011
|
+
const { records } =
|
|
2012
|
+
await userB.clients.fullAuth.contracts.getCredentialsForContract({ termsUri });
|
|
2013
|
+
|
|
2014
|
+
return { unsigned, records, before };
|
|
2015
|
+
};
|
|
2016
|
+
|
|
2017
|
+
const expectFreshDate = (value: unknown, before: number) => {
|
|
2018
|
+
expect(typeof value).toBe('string');
|
|
2019
|
+
expect(value).toBe(new Date(value as string).toISOString());
|
|
2020
|
+
expect(Date.parse(value as string)).toBeGreaterThanOrEqual(before);
|
|
2021
|
+
};
|
|
2022
|
+
|
|
2023
|
+
it('restamps a malformed issuanceDate on a VC 1.1 template', async () => {
|
|
2024
|
+
const { unsigned, records, before } = await consentWithAutoBoost({
|
|
2025
|
+
...testUnsignedBoost,
|
|
2026
|
+
issuanceDate: MALFORMED_DATE,
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
expectFreshDate(unsigned.issuanceDate, before);
|
|
2030
|
+
// Consent swallows signing errors, so a missing record means signing failed.
|
|
2031
|
+
expect(records).toHaveLength(1);
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
it('drops a leftover issuanceDate on a VC 2.0 template', async () => {
|
|
2035
|
+
const { unsigned, records, before } = await consentWithAutoBoost({
|
|
2036
|
+
'@context': [
|
|
2037
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
2038
|
+
'https://ctx.learncard.com/boosts/1.0.1.json',
|
|
2039
|
+
],
|
|
2040
|
+
type: ['VerifiableCredential', 'BoostCredential'],
|
|
2041
|
+
issuer: 'did:example:issuer',
|
|
2042
|
+
validFrom: TEMPLATE_DATE,
|
|
2043
|
+
issuanceDate: TEMPLATE_DATE,
|
|
2044
|
+
name: 'VC 2.0 Auto Boost',
|
|
2045
|
+
credentialSubject: { id: 'did:example:subject' },
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
expectFreshDate(unsigned.validFrom, before);
|
|
2049
|
+
// The VC 2.0 context doesn't define issuanceDate, so DIDKit won't sign with it.
|
|
2050
|
+
expect(unsigned).not.toHaveProperty('issuanceDate');
|
|
2051
|
+
expect(records).toHaveLength(1);
|
|
2052
|
+
});
|
|
2053
|
+
});
|
|
2054
|
+
|
|
1954
2055
|
it('should auto-issue boosts when reconsenting to a contract with auto-boosts', async () => {
|
|
1955
2056
|
await userA.clients.fullAuth.profile.registerSigningAuthority({
|
|
1956
2057
|
endpoint: 'http://localhost:5000/api',
|
package/test/inbox-batch.spec.ts
CHANGED
|
@@ -2,7 +2,13 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
|
|
2
2
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
3
3
|
import { fastifyTRPCOpenApiPlugin } from 'trpc-to-openapi';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
InboxBatchReceipt,
|
|
7
|
+
IssueInboxCredentialBatch,
|
|
8
|
+
UnsignedVC,
|
|
9
|
+
VC,
|
|
10
|
+
VP,
|
|
11
|
+
} from '@learncard/types';
|
|
6
12
|
import { appRouter, createContext } from '../src/app';
|
|
7
13
|
import { getClient, getUser } from './helpers/getClient';
|
|
8
14
|
import { testUnsignedBoost } from './helpers/send';
|
|
@@ -29,6 +35,7 @@ import {
|
|
|
29
35
|
} from '@helpers/inbox-queue.helpers';
|
|
30
36
|
import { fingerprint } from '@helpers/inbox-batch.helpers';
|
|
31
37
|
import * as jobStore from '@accesslayer/inbox-batch/store';
|
|
38
|
+
import { createInboxBatchSchemaInitializer } from '@accesslayer/inbox-batch/schema';
|
|
32
39
|
import * as issuance from '@helpers/inbox.helpers';
|
|
33
40
|
import { SQSClient, SendMessageCommand, SendMessageBatchCommand } from '@aws-sdk/client-sqs';
|
|
34
41
|
|
|
@@ -168,6 +175,36 @@ describe('Universal Inbox batch issuance', () => {
|
|
|
168
175
|
{ batchId }
|
|
169
176
|
);
|
|
170
177
|
|
|
178
|
+
it('initializes an empty batch schema concurrently in independent cold API and worker instances', async () => {
|
|
179
|
+
// Testcontainers owns this database. Recreate the cold condition rather than
|
|
180
|
+
// exercising the process-local cache against already-created schema.
|
|
181
|
+
for (let round = 0; round < 3; round++) {
|
|
182
|
+
const constraints = await neogma.queryRunner.run(
|
|
183
|
+
'SHOW CONSTRAINTS YIELD name WHERE name STARTS WITH "inboxbatch" RETURN name'
|
|
184
|
+
);
|
|
185
|
+
for (const record of constraints.records)
|
|
186
|
+
await neogma.queryRunner.run(`DROP CONSTRAINT ${record.get('name')}`);
|
|
187
|
+
const indexes = await neogma.queryRunner.run(
|
|
188
|
+
'SHOW INDEXES YIELD name WHERE name STARTS WITH "inbox_batch_" RETURN name'
|
|
189
|
+
);
|
|
190
|
+
for (const record of indexes.records)
|
|
191
|
+
await neogma.queryRunner.run(`DROP INDEX ${record.get('name')}`);
|
|
192
|
+
|
|
193
|
+
const processes = Array.from({ length: 4 }, () =>
|
|
194
|
+
createInboxBatchSchemaInitializer(neogma.driver)
|
|
195
|
+
);
|
|
196
|
+
await Promise.all(processes.map(ensure => ensure()));
|
|
197
|
+
const ready = await neogma.queryRunner.run(
|
|
198
|
+
'SHOW CONSTRAINTS YIELD name WHERE name STARTS WITH "inboxbatch" RETURN count(*) AS total'
|
|
199
|
+
);
|
|
200
|
+
expect(ready.records[0].get('total').toNumber()).toBe(4);
|
|
201
|
+
const readyIndexes = await neogma.queryRunner.run(
|
|
202
|
+
'SHOW INDEXES YIELD name WHERE name STARTS WITH "inbox_batch_" RETURN count(*) AS total'
|
|
203
|
+
);
|
|
204
|
+
expect(readyIndexes.records[0].get('total').toNumber()).toBe(6);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
171
208
|
it('accepts a one-item batch without waiting for a slow worker and restricts polling to its issuer', async () => {
|
|
172
209
|
const receipt = await submit({
|
|
173
210
|
items: [{ recipient: email('slow@test.com'), credential: await signed() }],
|
|
@@ -983,6 +1020,157 @@ describe('Universal Inbox batch issuance', () => {
|
|
|
983
1020
|
expect(names).toEqual(['Ada', 'Grace']);
|
|
984
1021
|
});
|
|
985
1022
|
|
|
1023
|
+
it('issues standard per-person Evidence with pure VC2 and Open Badges contexts', async () => {
|
|
1024
|
+
const template = structuredClone(testUnsignedBoost);
|
|
1025
|
+
template['@context'] = [
|
|
1026
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
1027
|
+
'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json',
|
|
1028
|
+
];
|
|
1029
|
+
template.type = ['VerifiableCredential', 'OpenBadgeCredential'];
|
|
1030
|
+
delete template.issuanceDate;
|
|
1031
|
+
template.validFrom = new Date().toISOString();
|
|
1032
|
+
const templateUri = await issuer.clients.fullAuth.boost.createBoost({
|
|
1033
|
+
credential: template,
|
|
1034
|
+
name: 'Standard evidence',
|
|
1035
|
+
category: 'Achievement',
|
|
1036
|
+
});
|
|
1037
|
+
const evidence = [
|
|
1038
|
+
{
|
|
1039
|
+
id: 'https://portfolio.test/work.pdf',
|
|
1040
|
+
type: ['Evidence'],
|
|
1041
|
+
name: 'Work sample',
|
|
1042
|
+
genre: 'document',
|
|
1043
|
+
},
|
|
1044
|
+
];
|
|
1045
|
+
const batch = await issue({
|
|
1046
|
+
configuration: { signingAuthority },
|
|
1047
|
+
items: [
|
|
1048
|
+
{
|
|
1049
|
+
recipient: email('standard-evidence@test.com'),
|
|
1050
|
+
templateUri,
|
|
1051
|
+
configuration: { templateData: { evidence } },
|
|
1052
|
+
},
|
|
1053
|
+
],
|
|
1054
|
+
});
|
|
1055
|
+
expect(batch.summary.failed).toBe(0);
|
|
1056
|
+
const result = batch.results[0];
|
|
1057
|
+
if (!result.success) throw new Error('Expected successful issuance');
|
|
1058
|
+
const credential = JSON.parse(
|
|
1059
|
+
await decryptInboxCredential((await stored(result.issuanceId)).credential)
|
|
1060
|
+
);
|
|
1061
|
+
expect(credential.evidence).toEqual(evidence);
|
|
1062
|
+
expect(credential['@context']).toEqual(template['@context']);
|
|
1063
|
+
expect(credential.type).not.toContain('BoostCredential');
|
|
1064
|
+
const claimed = await claim(result.claimUrl!);
|
|
1065
|
+
expect(claimed[0].proof).toBeDefined();
|
|
1066
|
+
expect(claimed[0].evidence).toEqual(evidence);
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it('materializes numeric credits and awarded dates before queued issuance and signing', async () => {
|
|
1070
|
+
const template = structuredClone(testUnsignedBoost) as unknown as Record<string, unknown>;
|
|
1071
|
+
template['@context'] = [
|
|
1072
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
1073
|
+
'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json',
|
|
1074
|
+
];
|
|
1075
|
+
template.type = ['VerifiableCredential', 'OpenBadgeCredential'];
|
|
1076
|
+
delete template.issuanceDate;
|
|
1077
|
+
template.validFrom = '{{issue_date}}';
|
|
1078
|
+
template.awardedDate = '{{award_date}}';
|
|
1079
|
+
const subject = template.credentialSubject as Record<string, unknown>;
|
|
1080
|
+
subject.creditsEarned = '{{credits}}';
|
|
1081
|
+
subject.result = [
|
|
1082
|
+
{ type: ['Result'], resultDescription: 'urn:uuid:quiz', value: '{{percent}}' },
|
|
1083
|
+
];
|
|
1084
|
+
(subject.achievement as Record<string, unknown>).resultDescription = [
|
|
1085
|
+
{
|
|
1086
|
+
id: 'urn:uuid:quiz',
|
|
1087
|
+
type: ['ResultDescription'],
|
|
1088
|
+
name: 'Quiz',
|
|
1089
|
+
resultType: 'Percent',
|
|
1090
|
+
valueMin: '0',
|
|
1091
|
+
valueMax: '100',
|
|
1092
|
+
},
|
|
1093
|
+
];
|
|
1094
|
+
const templateUri = await issuer.clients.fullAuth.boost.createBoost({
|
|
1095
|
+
credential: template as UnsignedVC,
|
|
1096
|
+
name: 'Mapped spreadsheet',
|
|
1097
|
+
category: 'Achievement',
|
|
1098
|
+
});
|
|
1099
|
+
const batch = await issue({
|
|
1100
|
+
configuration: { signingAuthority },
|
|
1101
|
+
items: ['0', '1.5', '12 hours'].map((credits, index) => ({
|
|
1102
|
+
recipient: email(`mapped-${index}@test.com`),
|
|
1103
|
+
templateUri,
|
|
1104
|
+
configuration: {
|
|
1105
|
+
templateData: { credits, percent: '95', award_date: '2024-04-03T00:00:00Z' },
|
|
1106
|
+
},
|
|
1107
|
+
})),
|
|
1108
|
+
});
|
|
1109
|
+
expect(batch.summary).toMatchObject({ succeeded: 2, failed: 1 });
|
|
1110
|
+
for (const [index, result] of batch.results.entries()) {
|
|
1111
|
+
if (index === 2) {
|
|
1112
|
+
expect(result.success).toBe(false);
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
if (!result.success) throw new Error('Expected successful mapped row');
|
|
1116
|
+
const credential = JSON.parse(
|
|
1117
|
+
await decryptInboxCredential((await stored(result.issuanceId)).credential)
|
|
1118
|
+
);
|
|
1119
|
+
expect(credential.credentialSubject.creditsEarned).toBe(index === 0 ? 0 : 1.5);
|
|
1120
|
+
expect(credential.credentialSubject.result[0].value).toBe('95');
|
|
1121
|
+
expect(credential.awardedDate).toBe('2024-04-03T00:00:00.000Z');
|
|
1122
|
+
expect(credential.validFrom).not.toBe(credential.awardedDate);
|
|
1123
|
+
}
|
|
1124
|
+
const first = batch.results[0];
|
|
1125
|
+
if (!first.success) throw new Error('Expected first result');
|
|
1126
|
+
const claimed = await claim(first.claimUrl!);
|
|
1127
|
+
expect(claimed[0].proof).toBeDefined();
|
|
1128
|
+
expect(claimed[0].credentialSubject).toMatchObject({ creditsEarned: 0 });
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it('still rejects extension EvidenceFile fields without their defining context', async () => {
|
|
1132
|
+
const template = structuredClone(testUnsignedBoost);
|
|
1133
|
+
template['@context'] = [
|
|
1134
|
+
'https://www.w3.org/ns/credentials/v2',
|
|
1135
|
+
'https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json',
|
|
1136
|
+
];
|
|
1137
|
+
template.type = ['VerifiableCredential', 'OpenBadgeCredential'];
|
|
1138
|
+
delete template.issuanceDate;
|
|
1139
|
+
template.validFrom = new Date().toISOString();
|
|
1140
|
+
const templateUri = await issuer.clients.fullAuth.boost.createBoost({
|
|
1141
|
+
credential: template,
|
|
1142
|
+
name: 'Extension evidence',
|
|
1143
|
+
category: 'Achievement',
|
|
1144
|
+
});
|
|
1145
|
+
const batch = await issue({
|
|
1146
|
+
configuration: { signingAuthority },
|
|
1147
|
+
items: [
|
|
1148
|
+
{
|
|
1149
|
+
recipient: email('extension@test.com'),
|
|
1150
|
+
templateUri,
|
|
1151
|
+
configuration: {
|
|
1152
|
+
templateData: {
|
|
1153
|
+
evidence: [
|
|
1154
|
+
{
|
|
1155
|
+
id: 'https://proof.test/file',
|
|
1156
|
+
type: ['EvidenceFile'],
|
|
1157
|
+
fileName: 'file.pdf',
|
|
1158
|
+
},
|
|
1159
|
+
],
|
|
1160
|
+
},
|
|
1161
|
+
},
|
|
1162
|
+
},
|
|
1163
|
+
],
|
|
1164
|
+
});
|
|
1165
|
+
expect(batch.results[0]).toMatchObject({
|
|
1166
|
+
success: false,
|
|
1167
|
+
error: {
|
|
1168
|
+
code: 'BAD_REQUEST',
|
|
1169
|
+
message: expect.stringContaining('Failed to prepare boost credential template'),
|
|
1170
|
+
},
|
|
1171
|
+
});
|
|
1172
|
+
});
|
|
1173
|
+
|
|
986
1174
|
it('returns HTTP 202 then exposes partial success and passes tenant branding to delivery', async () => {
|
|
987
1175
|
const response = await post({
|
|
988
1176
|
items: [
|
package/vitest.config.ts
CHANGED
|
@@ -51,6 +51,7 @@ export default createVitestConfig(brainServicePreset, {
|
|
|
51
51
|
'src/helpers/status-list.helpers.test.ts',
|
|
52
52
|
'src/helpers/boost-storage.helpers.test.ts',
|
|
53
53
|
'src/accesslayer/credential/create.test.ts',
|
|
54
|
+
'src/accesslayer/inbox-batch/schema.test.ts',
|
|
54
55
|
'src/helpers/inbox-encryption.helpers.test.ts',
|
|
55
56
|
'src/helpers/send-inbox-config.helpers.test.ts',
|
|
56
57
|
'src/helpers/contact-method.helpers.test.ts',
|