@learncard/network-brain-service 4.0.3 → 4.1.0
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/.env.example +14 -0
- package/CHANGELOG.md +76 -0
- package/config/config.dev.json +5 -0
- package/config/config.example.json +5 -0
- package/config/config.production.json +5 -0
- package/config/config.scouts.dev.json +5 -0
- package/config/config.scouts.production.json +5 -0
- package/dist/accesslayer/campaign/audience.d.ts +7 -0
- package/dist/accesslayer/campaign/audience.d.ts.map +1 -0
- package/dist/accesslayer/campaign/create.d.ts +3 -0
- package/dist/accesslayer/campaign/create.d.ts.map +1 -0
- package/dist/accesslayer/campaign/delete.d.ts +2 -0
- package/dist/accesslayer/campaign/delete.d.ts.map +1 -0
- package/dist/accesslayer/campaign/read.d.ts +6 -0
- package/dist/accesslayer/campaign/read.d.ts.map +1 -0
- package/dist/accesslayer/campaign/shared.d.ts +16 -0
- package/dist/accesslayer/campaign/shared.d.ts.map +1 -0
- package/dist/accesslayer/campaign/state.d.ts +4 -0
- package/dist/accesslayer/campaign/state.d.ts.map +1 -0
- package/dist/accesslayer/campaign/update.d.ts +3 -0
- package/dist/accesslayer/campaign/update.d.ts.map +1 -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/credential-share-previews.d.ts +5 -0
- package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
- 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/accesslayer/profile/delete.d.ts.map +1 -1
- package/dist/accesslayer/skill/read.d.ts +12 -2
- package/dist/accesslayer/skill/read.d.ts.map +1 -1
- package/dist/accesslayer/skill/update.d.ts +11 -1
- package/dist/accesslayer/skill/update.d.ts.map +1 -1
- package/dist/app.d.ts +4 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +2 -1
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/config/environment.d.ts +268 -178
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/skillEmbedding.d.ts +70 -0
- package/dist/config/skillEmbedding.d.ts.map +1 -0
- package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
- package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
- package/dist/config/stageConfig.d.ts +20 -0
- package/dist/config/stageConfig.d.ts.map +1 -1
- package/dist/constants/campaigns.d.ts +3 -0
- package/dist/constants/campaigns.d.ts.map +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
- package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
- package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
- package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
- package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
- package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
- package/dist/helpers/boost.helpers.d.ts +1 -0
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
- package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
- package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
- package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/Campaign.d.ts +10 -0
- package/dist/models/Campaign.d.ts.map +1 -0
- package/dist/models/CredentialSharePreview.d.ts +14 -0
- package/dist/models/CredentialSharePreview.d.ts.map +1 -0
- package/dist/models/Integration.d.ts +1 -1
- package/dist/models/Profile.d.ts.map +1 -1
- package/dist/models/campaign-constraints.d.ts +2 -0
- package/dist/models/campaign-constraints.d.ts.map +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/app-store.d.ts +1 -1
- package/dist/routes/boosts.d.ts +24 -4
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/campaigns.d.ts +1411 -0
- package/dist/routes/campaigns.d.ts.map +1 -0
- package/dist/routes/contracts.d.ts +19 -6
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-share-previews.d.ts +39 -0
- package/dist/routes/credential-share-previews.d.ts.map +1 -0
- package/dist/routes/credentials.d.ts +4 -1
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +5 -5
- package/dist/routes/index.d.ts +4 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/integrations.d.ts +3 -3
- package/dist/routes/profile-manager.d.ts +4 -0
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts +44 -0
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/skill-frameworks.d.ts +1 -0
- package/dist/routes/skill-frameworks.d.ts.map +1 -1
- package/dist/routes/skills-semantic-search.test.d.ts +2 -0
- package/dist/routes/skills-semantic-search.test.d.ts.map +1 -0
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/test.d.ts +3 -1
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/seed/seedSkillFrameworks.d.ts +1 -0
- package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
- package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
- package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
- package/dist/types/app-manifest.d.ts +2 -2
- package/dist/types/campaign.d.ts +19 -0
- package/dist/types/campaign.d.ts.map +1 -0
- package/dist/types/consentflowcontract.d.ts +1 -1
- package/dist/types/integration.d.ts +3 -3
- package/dist/types/profile.d.ts +9 -0
- package/dist/types/profile.d.ts.map +1 -1
- package/dist/types/skill.d.ts.map +1 -1
- package/esbuildPlugins.cjs +9 -6
- package/package.json +20 -17
- package/project.json +14 -0
- package/scripts/skill-embeddings-backfill.ts +161 -0
- package/serverless-local.yml +4 -0
- package/serverless.function-env.cjs +3 -0
- package/serverless.local.yml +4 -0
- package/serverless.yml +12 -1
- package/src/accesslayer/campaign/README.md +174 -0
- package/src/accesslayer/campaign/audience.ts +152 -0
- package/src/accesslayer/campaign/create.ts +47 -0
- package/src/accesslayer/campaign/delete.ts +18 -0
- package/src/accesslayer/campaign/read.ts +125 -0
- package/src/accesslayer/campaign/shared.ts +92 -0
- package/src/accesslayer/campaign/state.ts +80 -0
- package/src/accesslayer/campaign/update.ts +50 -0
- package/src/accesslayer/consentflowcontract/relationships/create.ts +3 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
- package/src/accesslayer/credential-share-previews.ts +76 -0
- 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/accesslayer/profile/delete.ts +6 -0
- package/src/accesslayer/skill/read.ts +56 -16
- package/src/accesslayer/skill/update.ts +43 -1
- package/src/app.ts +9 -0
- package/src/config/environment.test.ts +208 -0
- package/src/config/environment.ts +166 -11
- package/src/config/skillEmbedding.neo4j.test.ts +138 -0
- package/src/config/skillEmbedding.ts +202 -0
- package/src/constants/campaigns.ts +9 -0
- package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
- package/src/helpers/bedrock-embedding.helpers.ts +243 -0
- package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
- package/src/helpers/boost-storage.helpers.test.ts +155 -1
- package/src/helpers/boost.helpers.ts +52 -10
- package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
- package/src/helpers/skill-embedding.helpers.ts +611 -123
- package/src/models/Campaign.ts +32 -0
- package/src/models/CredentialSharePreview.ts +30 -0
- package/src/models/Profile.ts +1 -0
- package/src/models/campaign-constraints.ts +22 -0
- package/src/models/index.ts +29 -1
- package/src/routes/campaigns.ts +179 -0
- package/src/routes/credential-share-previews.ts +74 -0
- package/src/routes/profiles.ts +7 -0
- package/src/routes/skills-semantic-search.test.ts +168 -0
- package/src/routes/skills.ts +42 -6
- package/src/routes/workflows.ts +8 -1
- package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
- package/src/seed/seedSkillFrameworks.test.ts +6 -0
- package/src/seed/seedSkillFrameworks.ts +83 -18
- package/src/types/campaign.ts +17 -0
- package/src/types/skill.ts +6 -0
- package/test/campaigns.spec.ts +789 -0
- package/test/consentflow.spec.ts +101 -0
- package/test/credential-share-previews.spec.ts +97 -0
- package/test/inbox-batch.spec.ts +189 -1
- package/test/verification/campaigns.ts +49 -0
- package/vitest.campaigns.config.ts +21 -0
- package/vitest.config.ts +11 -0
- package/vitest.integration.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',
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
|
|
4
|
+
const owner = { profileId: 'owner-1', did: 'did:key:owner', displayName: 'Owner' };
|
|
5
|
+
const storage = vi.hoisted(() => ({
|
|
6
|
+
create: vi.fn(
|
|
7
|
+
async (ownerId: string, preview: { id?: string }): Promise<string | null> =>
|
|
8
|
+
preview.id ?? '5b58ff0c-bb55-4f25-a2a5-b4de3778c649'
|
|
9
|
+
),
|
|
10
|
+
get: vi.fn(async (_id: string, _shareUri: string) => null as unknown),
|
|
11
|
+
delete: vi.fn(async (_ownerId: string, _id: string) => false),
|
|
12
|
+
}));
|
|
13
|
+
const sentry = vi.hoisted(() => ({
|
|
14
|
+
trpcMiddleware: vi.fn(() => async (options: { next: () => unknown }) => options.next()),
|
|
15
|
+
setUser: vi.fn(),
|
|
16
|
+
configureScope: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('@accesslayer/credential-share-previews', () => ({
|
|
19
|
+
createCredentialSharePreview: storage.create,
|
|
20
|
+
getCredentialSharePreview: storage.get,
|
|
21
|
+
deleteCredentialSharePreview: storage.delete,
|
|
22
|
+
}));
|
|
23
|
+
vi.mock('@accesslayer/profile/read', () => ({ getProfileByDid: vi.fn(async () => owner) }));
|
|
24
|
+
vi.mock('@sentry/serverless', () => ({
|
|
25
|
+
Handlers: { trpcMiddleware: sentry.trpcMiddleware },
|
|
26
|
+
setUser: sentry.setUser,
|
|
27
|
+
configureScope: sentry.configureScope,
|
|
28
|
+
}));
|
|
29
|
+
import { credentialSharePreviewsRouter } from '../src/routes/credential-share-previews';
|
|
30
|
+
|
|
31
|
+
const caller = (user?: { did: string; isChallengeValid: boolean }) =>
|
|
32
|
+
credentialSharePreviewsRouter.createCaller({
|
|
33
|
+
domain: 'network.example.com',
|
|
34
|
+
tenant: { id: 'default' },
|
|
35
|
+
...(user ? { user } : {}),
|
|
36
|
+
} as never);
|
|
37
|
+
const authenticated = { did: owner.did, isChallengeValid: true };
|
|
38
|
+
const id = '5b58ff0c-bb55-4f25-a2a5-b4de3778c649';
|
|
39
|
+
const shareUri = 'urn:encrypted:opaque';
|
|
40
|
+
|
|
41
|
+
describe('credential share previews route', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
storage.get.mockResolvedValue(null);
|
|
45
|
+
storage.delete.mockResolvedValue(false);
|
|
46
|
+
storage.create.mockImplementation(async (_owner, preview) => preview.id ?? id);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('requires authentication and rejects private fields in published metadata', async () => {
|
|
50
|
+
const preview = {
|
|
51
|
+
name: 'Badge',
|
|
52
|
+
issuer: 'Issuer',
|
|
53
|
+
image: 'https://cdn.example/badge.png',
|
|
54
|
+
shareUri,
|
|
55
|
+
};
|
|
56
|
+
await expect(caller().publish(preview)).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
57
|
+
await expect(
|
|
58
|
+
caller(authenticated).publish({ ...preview, credential: { id: 'private' } } as never)
|
|
59
|
+
).rejects.toBeInstanceOf(TRPCError);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects unauthenticated deletion', async () => {
|
|
63
|
+
await expect(caller().delete({ id })).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('reports missing refreshes with the machine-readable recovery code', async () => {
|
|
67
|
+
storage.create.mockResolvedValueOnce(null);
|
|
68
|
+
await expect(
|
|
69
|
+
caller(authenticated).publish({ id, name: 'Badge', issuer: 'Issuer', shareUri })
|
|
70
|
+
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('fails closed if storage returns non-public fields', async () => {
|
|
74
|
+
storage.get.mockResolvedValueOnce({
|
|
75
|
+
name: 'Badge',
|
|
76
|
+
issuer: 'Issuer',
|
|
77
|
+
seed: 'must-never-be-returned',
|
|
78
|
+
shareUri,
|
|
79
|
+
ownerProfileId: owner.profileId,
|
|
80
|
+
createdAt: new Date(0).toISOString(),
|
|
81
|
+
updatedAt: new Date(0).toISOString(),
|
|
82
|
+
});
|
|
83
|
+
await expect(caller().get({ id, shareUri })).rejects.toMatchObject({
|
|
84
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('requires both public lookup keys and reports missing previews', async () => {
|
|
89
|
+
await expect(caller().get({ id } as never)).rejects.toBeInstanceOf(TRPCError);
|
|
90
|
+
await expect(caller().get({ id: 'not-a-uuid', shareUri })).rejects.toBeInstanceOf(
|
|
91
|
+
TRPCError
|
|
92
|
+
);
|
|
93
|
+
await expect(caller().get({ id, shareUri })).rejects.toMatchObject({
|
|
94
|
+
code: 'NOT_FOUND',
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
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: [
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real signed SDK requests against an owned Linux verification stack.
|
|
3
|
+
* Positive admin/data coverage lives in campaigns.spec.ts (disposable Neo4j): the
|
|
4
|
+
* default stack intentionally grants no campaign administration to test actors.
|
|
5
|
+
*
|
|
6
|
+
* bun run --cwd apps/learn-card-app lc verify script \
|
|
7
|
+
* services/learn-card-network/brain-service/test/verification/campaigns.ts --dir <owned-dir>
|
|
8
|
+
*/
|
|
9
|
+
import type { VerificationScript } from '../../../../../apps/learn-card-app/scripts/verification/script';
|
|
10
|
+
|
|
11
|
+
const script: VerificationScript = async v => {
|
|
12
|
+
const learner = await v.sdk(v.actors.learner);
|
|
13
|
+
const other = await v.sdk(v.actors.recipient);
|
|
14
|
+
const campaignId = 'verification-denied-campaign';
|
|
15
|
+
|
|
16
|
+
await v.step(null, 'campaign-sdk-authorization', async () => {
|
|
17
|
+
v.expect(await learner.invoke.campaignsIsAdmin()).toBe(false);
|
|
18
|
+
await v
|
|
19
|
+
.expect(
|
|
20
|
+
learner.invoke.campaignsCreate({
|
|
21
|
+
id: campaignId,
|
|
22
|
+
name: 'Not authorized',
|
|
23
|
+
channels: ['inApp'],
|
|
24
|
+
message: { title: 'Denied' },
|
|
25
|
+
})
|
|
26
|
+
)
|
|
27
|
+
.rejects.toThrow(/Campaign administrator access required/);
|
|
28
|
+
await v
|
|
29
|
+
.expect(other.invoke.campaignsList())
|
|
30
|
+
.rejects.toThrow(/Campaign administrator access required/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await v.step(null, 'campaign-sdk-user-isolation', async () => {
|
|
34
|
+
v.expect(await learner.invoke.getMyCampaigns()).toEqual([]);
|
|
35
|
+
v.expect(await other.invoke.getMyCampaigns()).toEqual([]);
|
|
36
|
+
v.expect(await learner.invoke.recordCampaignEvent(campaignId, 'shown')).toBe(false);
|
|
37
|
+
await v
|
|
38
|
+
.expect(learner.invoke.enrollInCampaign(campaignId))
|
|
39
|
+
.rejects.toThrow(/Campaign not found/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
authorization: 'passed',
|
|
44
|
+
userIsolation: 'passed',
|
|
45
|
+
adminDataCoverage: 'separate Neo4j integration suite',
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default script;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
import { createVitestConfig, serviceIntegrationPreset } from '../../../vitest.shared';
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
|
|
7
|
+
export default createVitestConfig(serviceIntegrationPreset, {
|
|
8
|
+
test: {
|
|
9
|
+
globalSetup: './test-setup.ts',
|
|
10
|
+
include: ['test/campaigns.spec.ts'],
|
|
11
|
+
alias: { '@instance': require.resolve('./test/helpers/mock-instance.ts') },
|
|
12
|
+
env: {
|
|
13
|
+
IS_E2E_TEST: 'true',
|
|
14
|
+
DOMAIN_NAME: 'localhost%3A3000',
|
|
15
|
+
SEED: 'a'.repeat(64),
|
|
16
|
+
CAMPAIGN_ADMIN_PROFILE_IDS: 'campaign-admin',
|
|
17
|
+
TRACE_CONSOLE: 'false',
|
|
18
|
+
NEO4J_SKIP_INDICES: 'true',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|
package/vitest.config.ts
CHANGED
|
@@ -16,6 +16,7 @@ export default createVitestConfig(brainServicePreset, {
|
|
|
16
16
|
NEO4J_SKIP_INDICES: 'true',
|
|
17
17
|
},
|
|
18
18
|
include: [
|
|
19
|
+
'test/credential-share-previews.spec.ts',
|
|
19
20
|
'test/share-link-*.unit.spec.ts',
|
|
20
21
|
'test/share-content-client.*.spec.ts',
|
|
21
22
|
'test/uri-helpers.spec.ts',
|
|
@@ -51,11 +52,21 @@ export default createVitestConfig(brainServicePreset, {
|
|
|
51
52
|
'src/helpers/status-list.helpers.test.ts',
|
|
52
53
|
'src/helpers/boost-storage.helpers.test.ts',
|
|
53
54
|
'src/accesslayer/credential/create.test.ts',
|
|
55
|
+
'src/accesslayer/inbox-batch/schema.test.ts',
|
|
54
56
|
'src/helpers/inbox-encryption.helpers.test.ts',
|
|
55
57
|
'src/helpers/send-inbox-config.helpers.test.ts',
|
|
56
58
|
'src/helpers/contact-method.helpers.test.ts',
|
|
57
59
|
'src/services/delivery/adapters/postmark.adapter.test.ts',
|
|
58
60
|
'src/helpers/inbox-maintenance.helpers.test.ts',
|
|
61
|
+
'src/helpers/bedrock-embedding.helpers.test.ts',
|
|
62
|
+
'src/helpers/skill-embedding.helpers.test.ts',
|
|
63
|
+
'src/seed/seedSkillFrameworks.embeddings.test.ts',
|
|
64
|
+
// Opt-in, local-only Neo4j check of the embedding compare-and-set; skipped unless
|
|
65
|
+
// SKILL_EMBEDDING_TEST_NEO4J_URI points at localhost.
|
|
66
|
+
'src/config/skillEmbedding.neo4j.test.ts',
|
|
67
|
+
'src/routes/skills-semantic-search.test.ts',
|
|
68
|
+
// Opt-in, paid, real-AWS smoke test; skipped unless SKILL_EMBEDDING_BEDROCK_SMOKE=true.
|
|
69
|
+
'src/helpers/bedrock-embedding.smoke.test.ts',
|
|
59
70
|
],
|
|
60
71
|
},
|
|
61
72
|
});
|
|
@@ -19,6 +19,7 @@ export default createVitestConfig(serviceIntegrationPreset, {
|
|
|
19
19
|
CREDENTIAL_REFRESH_ENABLED: 'true',
|
|
20
20
|
LOGIN_PROVIDER_DID: 'did:key:z6Mko9uYxDPk2BetRRziLz1xHN8nR5zQWdNjytKNDPcygHJP',
|
|
21
21
|
APP_STORE_ADMIN_PROFILE_IDS: 'app-store-admin',
|
|
22
|
+
CAMPAIGN_ADMIN_PROFILE_IDS: 'campaign-admin',
|
|
22
23
|
TRACE_CONSOLE: 'false',
|
|
23
24
|
},
|
|
24
25
|
},
|