Skip to content

Commit dd5bfff

Browse files
[LC-1173] defaultEnabled and autoboost permissions (#753)
* ✨ Add defaultEnabled flag * 🔒️ Do not issue autoboosts when the user has not given write permission for it!
1 parent 4b40c28 commit dd5bfff

File tree

9 files changed

+197
-4
lines changed

9 files changed

+197
-4
lines changed

.changeset/bumpy-suits-fetch.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@learncard/network-brain-service': patch
3+
'@learncard/types': patch
4+
---
5+
6+
Add defaultEnabled flags to contracts as a UI hint

.changeset/hip-pears-dig.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@learncard/network-brain-service': patch
3+
---
4+
5+
Do not issue autoboosts when the user has not given write permission for it!

packages/learn-card-types/src/lcn.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -310,17 +310,17 @@ export const ConsentFlowContractValidator = z.object({
310310
.object({
311311
anonymize: z.boolean().optional(),
312312
credentials: z
313-
.object({ categories: z.record(z.object({ required: z.boolean() })).default({}) })
313+
.object({ categories: z.record(z.object({ required: z.boolean(), defaultEnabled: z.boolean().optional() })).default({}) })
314314
.default({}),
315-
personal: z.record(z.object({ required: z.boolean() })).default({}),
315+
personal: z.record(z.object({ required: z.boolean(), defaultEnabled: z.boolean().optional() })).default({}),
316316
})
317317
.default({}),
318318
write: z
319319
.object({
320320
credentials: z
321-
.object({ categories: z.record(z.object({ required: z.boolean() })).default({}) })
321+
.object({ categories: z.record(z.object({ required: z.boolean(), defaultEnabled: z.boolean().optional() })).default({}) })
322322
.default({}),
323-
personal: z.record(z.object({ required: z.boolean() })).default({}),
323+
personal: z.record(z.object({ required: z.boolean(), defaultEnabled: z.boolean().optional() })).default({}),
324324
})
325325
.default({}),
326326
});

services/learn-card-network/brain-service/src/accesslayer/consentflowcontract/relationships/create.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,16 @@ export const consentToContract = async (
190190

191191
if (terms.deniedWriters?.includes(issuer.profileId)) return;
192192

193+
const boostCategory = boost.target.category;
194+
if (boostCategory) {
195+
const categoryWritePermission =
196+
terms.write?.credentials?.categories?.[boostCategory];
197+
198+
if (!categoryWritePermission) return;
199+
200+
if (categoryWritePermission !== true) return;
201+
}
202+
193203
const contractOwnerSigningAuthority = await getSigningAuthorityForUserByName(
194204
issuer,
195205
signingAuthorityEndpoint,

services/learn-card-network/brain-service/src/accesslayer/consentflowcontract/relationships/update.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,18 @@ export const reconsentTerms = async (
116116
return;
117117
}
118118

119+
const boostCategory = boost.dataValues.category;
120+
if (boostCategory) {
121+
const categoryWritePermission =
122+
terms.write?.credentials?.categories?.[boostCategory];
123+
124+
// Category not found in write permissions - deny autoboost
125+
if (!categoryWritePermission) return;
126+
127+
// Check if write permission is explicitly denied (false) or not granted (undefined)
128+
// Only issue autoboost if write permission is explicitly granted (true)
129+
if (categoryWritePermission !== true) return;
130+
}
119131
// Get boost instance
120132
const boostCredential = JSON.parse(boost.dataValues?.boost) as UnsignedVC | VC;
121133

@@ -309,6 +321,18 @@ export const updateTerms = async (
309321
return;
310322
}
311323

324+
const boostCategory = boost.target.category;
325+
if (boostCategory) {
326+
const categoryWritePermission =
327+
terms.write?.credentials?.categories?.[boostCategory];
328+
329+
if (!categoryWritePermission) return;
330+
331+
// Check if write permission is explicitly denied (false) or not granted (undefined)
332+
// Only issue autoboost if write permission is explicitly granted (true)
333+
if (categoryWritePermission !== true) return;
334+
}
335+
312336
// Get boost instance
313337
const boostCredential = JSON.parse(boost.target.boost) as UnsignedVC | VC;
314338

services/learn-card-network/brain-service/test/consentflow.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,51 @@ describe('Consent Flow Contracts', () => {
277277
// Verifying the image field
278278
expect(contract.image).toEqual(contractData.image);
279279
});
280+
281+
it('should allow setting and retrieving the defaultEnabled field for contract categories', async () => {
282+
const contractWithDefaults: ConsentFlowContract = {
283+
read: {
284+
personal: {
285+
name: { required: false, defaultEnabled: true },
286+
email: { required: true, defaultEnabled: false }
287+
},
288+
credentials: {
289+
categories: {
290+
Achievement: { required: false, defaultEnabled: true },
291+
ID: { required: false, defaultEnabled: false }
292+
},
293+
},
294+
},
295+
write: {
296+
personal: {
297+
name: { required: false, defaultEnabled: true }
298+
},
299+
credentials: {
300+
categories: {
301+
Achievement: { required: true, defaultEnabled: false }
302+
},
303+
},
304+
},
305+
};
306+
307+
const contractUri = await userA.clients.fullAuth.contracts.createConsentFlowContract({
308+
contract: contractWithDefaults,
309+
name: 'Default Enabled Test Contract',
310+
});
311+
312+
// Fetching the created contract
313+
const contract = await userA.clients.fullAuth.contracts.getConsentFlowContract({
314+
uri: contractUri,
315+
});
316+
317+
// Verifying the defaultEnabled fields are preserved
318+
expect(contract.contract.read.personal.name.defaultEnabled).toBe(true);
319+
expect(contract.contract.read.personal.email.defaultEnabled).toBe(false);
320+
expect(contract.contract.read.credentials.categories.Achievement.defaultEnabled).toBe(true);
321+
expect(contract.contract.read.credentials.categories.ID.defaultEnabled).toBe(false);
322+
expect(contract.contract.write.personal.name.defaultEnabled).toBe(true);
323+
expect(contract.contract.write.credentials.categories.Achievement.defaultEnabled).toBe(false);
324+
});
280325
});
281326

282327
describe('getConsentFlowContracts', () => {

tests/e2e/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"scripts": {
66
"test": "echo \"Skipping E2E tests in CI. Run test:e2e if you'd like to actually run this!\"",
77
"test:e2e": "vitest",
8+
"test:run": "vitest run",
89
"logs": "docker compose logs -f"
910
},
1011
"dependencies": {

tests/e2e/tests/consentflow.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
normalFullTerms,
99
normalContract,
1010
normalNoTerms,
11+
contractWithDefaults,
1112
} from './helpers/contract.helpers';
1213
import { VC } from '@learncard/types';
1314

@@ -96,6 +97,53 @@ describe('ConsentFlow E2E Tests', () => {
9697
expect(error).toBeDefined();
9798
}
9899
});
100+
101+
it('should allow creating and retrieving a contract with defaultEnabled fields', async () => {
102+
const contractName = `Default Enabled Contract ${Date.now()}`;
103+
const contractUri = await a.invoke.createContract({
104+
contract: contractWithDefaults,
105+
name: contractName,
106+
description: 'A contract for testing defaultEnabled fields',
107+
});
108+
109+
expect(contractUri).toBeDefined();
110+
expect(typeof contractUri).toBe('string');
111+
112+
// Retrieve the contract and verify defaultEnabled fields are preserved
113+
const contract = await a.invoke.getContract(contractUri);
114+
115+
expect(contract).toBeDefined();
116+
expect(contract.name).toBe(contractName);
117+
expect(contract.description).toBe('A contract for testing defaultEnabled fields');
118+
119+
// Verify read section defaultEnabled fields
120+
expect(contract.contract.read.personal.name?.defaultEnabled).toBe(true);
121+
expect(contract.contract.read.personal.email?.defaultEnabled).toBe(false);
122+
expect(contract.contract.read.personal.phone?.defaultEnabled).toBe(true);
123+
expect(contract.contract.read.credentials.categories.Achievement?.defaultEnabled).toBe(
124+
true
125+
);
126+
expect(contract.contract.read.credentials.categories.ID?.defaultEnabled).toBe(false);
127+
expect(contract.contract.read.credentials.categories.Certificate?.defaultEnabled).toBe(
128+
false
129+
);
130+
131+
// Verify write section defaultEnabled fields
132+
expect(contract.contract.write.personal.name?.defaultEnabled).toBe(true);
133+
expect(contract.contract.write.personal.email?.defaultEnabled).toBe(false);
134+
expect(contract.contract.write.credentials.categories.Achievement?.defaultEnabled).toBe(
135+
false
136+
);
137+
expect(contract.contract.write.credentials.categories.Badge?.defaultEnabled).toBe(true);
138+
139+
// Verify required fields are still preserved alongside defaultEnabled
140+
expect(contract.contract.read.personal.name?.required).toBe(false);
141+
expect(contract.contract.read.personal.email?.required).toBe(true);
142+
expect(contract.contract.read.credentials.categories.Achievement?.required).toBe(false);
143+
expect(contract.contract.read.credentials.categories.Certificate?.required).toBe(true);
144+
expect(contract.contract.write.credentials.categories.Achievement?.required).toBe(true);
145+
expect(contract.contract.write.credentials.categories.Badge?.required).toBe(false);
146+
});
99147
});
100148

101149
describe('Contract Consent Flow', () => {
@@ -471,6 +519,31 @@ describe('ConsentFlow E2E Tests', () => {
471519
const hasWriteAction = transactions.records.some(tx => tx.action === 'write');
472520
expect(hasWriteAction).toBe(true);
473521
});
522+
523+
it('should NOT auto-issue boosts when write permission is denied in consent terms', async () => {
524+
// Create a contract with autoboost for Achievement category
525+
const contractUri = await a.invoke.createContract({
526+
contract: normalContract, // Allows Achievement category
527+
name: 'Security Test Contract',
528+
description: 'Testing autoboost permission enforcement',
529+
autoboosts: [{ boostUri, signingAuthority }], // Use existing boostUri and signingAuthority
530+
});
531+
532+
// User B consents but DENIES write permission for Achievement category
533+
const termsUri = await b.invoke.consentToContract(contractUri, {
534+
terms: normalNoTerms, // This denies ALL write permissions: Achievement: false, ID: false
535+
});
536+
537+
expect(termsUri).toBeDefined();
538+
539+
// Wait a moment for any autoboost processing
540+
await new Promise(resolve => setTimeout(resolve, 1000));
541+
542+
// Check if any credentials were issued (there should be NONE)
543+
const credentials = await b.invoke.getCredentialsForContract(termsUri);
544+
545+
expect(credentials.records).toHaveLength(0);
546+
});
474547
});
475548

476549
describe('Auto-Boosts with Denied Writers', () => {

tests/e2e/tests/helpers/contract.helpers.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,32 @@ export const promiscuousTerms: ConsentFlowTerms = {
237237
},
238238
},
239239
};
240+
241+
export const contractWithDefaults: ConsentFlowContract = {
242+
read: {
243+
personal: {
244+
name: { required: false, defaultEnabled: true },
245+
email: { required: true, defaultEnabled: false },
246+
phone: { required: false, defaultEnabled: true }
247+
},
248+
credentials: {
249+
categories: {
250+
Achievement: { required: false, defaultEnabled: true },
251+
ID: { required: false, defaultEnabled: false },
252+
Certificate: { required: true, defaultEnabled: false }
253+
},
254+
},
255+
},
256+
write: {
257+
personal: {
258+
name: { required: false, defaultEnabled: true },
259+
email: { required: false, defaultEnabled: false }
260+
},
261+
credentials: {
262+
categories: {
263+
Achievement: { required: true, defaultEnabled: false },
264+
Badge: { required: false, defaultEnabled: true }
265+
},
266+
},
267+
},
268+
};

0 commit comments

Comments
 (0)