@learncard/helpers 1.7.3 → 1.8.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/dist/age-policy.d.ts +9 -0
- package/dist/age-policy.d.ts.map +1 -0
- package/dist/consent-flow-contract-updates.d.ts +29 -0
- package/dist/consent-flow-contract-updates.d.ts.map +1 -0
- package/dist/credential-refresh.d.ts +16 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/helpers.cjs.development.cjs +4953 -3773
- package/dist/helpers.cjs.development.cjs.map +4 -4
- package/dist/helpers.cjs.production.min.cjs +36 -36
- package/dist/helpers.cjs.production.min.cjs.map +4 -4
- package/dist/helpers.esm.js +1171 -59
- package/dist/helpers.esm.js.map +4 -4
- package/dist/index.d.cts +13 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/age-policy.ts +88 -0
- package/src/consent-flow-contract-updates.ts +472 -0
- package/src/credential-refresh.ts +99 -2
- package/src/index.ts +18 -0
package/dist/index.d.cts
CHANGED
|
@@ -16,6 +16,17 @@ export declare const RegExpTransformer: DataTransformer;
|
|
|
16
16
|
* Determines if a credential uses VC 2.0 format by checking the context array
|
|
17
17
|
*/
|
|
18
18
|
export declare const isVC2Format: (credential: unknown) => boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The marker field on the placeholder credential JSON of a "display-only anchor"
|
|
21
|
+
* Boost (one created by a managed/signed-only send that had no explicit credential).
|
|
22
|
+
* `'display' in boost` is NOT a safe way to detect this: ordinary Boosts commonly have
|
|
23
|
+
* their own top-level `display` object too (e.g. the LearnCard app's appearance fields —
|
|
24
|
+
* backgroundImage, backgroundColor, displayType), so that check produces false
|
|
25
|
+
* positives on real credentials.
|
|
26
|
+
*/
|
|
27
|
+
export declare const DISPLAY_ONLY_ANCHOR_MARKER: "displayOnlyAnchor";
|
|
28
|
+
/** Detects a display-only anchor via its dedicated marker (see `DISPLAY_ONLY_ANCHOR_MARKER`). */
|
|
29
|
+
export declare const isDisplayOnlyAnchorTemplate: (credential: unknown) => boolean;
|
|
19
30
|
/** Unwraps a boost credential from a CertifiedBoostCredential, if it is one */
|
|
20
31
|
export declare const unwrapBoostCredential: (vc?: VC | UnsignedVC) => any;
|
|
21
32
|
export * from './images';
|
|
@@ -42,5 +53,7 @@ export * from './credential-format';
|
|
|
42
53
|
*/
|
|
43
54
|
export declare const isAppDidWeb: (did?: string) => boolean;
|
|
44
55
|
export * from './share-content-canonical';
|
|
56
|
+
export * from './consent-flow-contract-updates';
|
|
45
57
|
export * from './credential-materialization';
|
|
58
|
+
export * from './age-policy';
|
|
46
59
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts
CHANGED
|
@@ -16,6 +16,17 @@ export declare const RegExpTransformer: DataTransformer;
|
|
|
16
16
|
* Determines if a credential uses VC 2.0 format by checking the context array
|
|
17
17
|
*/
|
|
18
18
|
export declare const isVC2Format: (credential: unknown) => boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The marker field on the placeholder credential JSON of a "display-only anchor"
|
|
21
|
+
* Boost (one created by a managed/signed-only send that had no explicit credential).
|
|
22
|
+
* `'display' in boost` is NOT a safe way to detect this: ordinary Boosts commonly have
|
|
23
|
+
* their own top-level `display` object too (e.g. the LearnCard app's appearance fields —
|
|
24
|
+
* backgroundImage, backgroundColor, displayType), so that check produces false
|
|
25
|
+
* positives on real credentials.
|
|
26
|
+
*/
|
|
27
|
+
export declare const DISPLAY_ONLY_ANCHOR_MARKER: "displayOnlyAnchor";
|
|
28
|
+
/** Detects a display-only anchor via its dedicated marker (see `DISPLAY_ONLY_ANCHOR_MARKER`). */
|
|
29
|
+
export declare const isDisplayOnlyAnchorTemplate: (credential: unknown) => boolean;
|
|
19
30
|
/** Unwraps a boost credential from a CertifiedBoostCredential, if it is one */
|
|
20
31
|
export declare const unwrapBoostCredential: (vc?: VC | UnsignedVC) => any;
|
|
21
32
|
export * from './images';
|
|
@@ -42,5 +53,7 @@ export * from './credential-format';
|
|
|
42
53
|
*/
|
|
43
54
|
export declare const isAppDidWeb: (did?: string) => boolean;
|
|
44
55
|
export * from './share-content-canonical';
|
|
56
|
+
export * from './consent-flow-contract-updates';
|
|
45
57
|
export * from './credential-materialization';
|
|
58
|
+
export * from './age-policy';
|
|
46
59
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAgB,UAAU,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AACrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD;;;;GAIG;AACH,eAAO,MAAM,KAAK,GAAI,KAAK,MAAM,YAA6B,CAAC;AAE/D,8DAA8D;AAC9D,eAAO,MAAM,WAAW,GAAI,MAAM,OAAO,KAAG,IAAI,IAAI,GAEnD,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,iBAAiB,EAAE,eA8B/B,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,YAAY,OAAO,KAAG,OAQjD,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,qBAAqB,GAAI,KAAK,EAAE,GAAG,UAAU,QAMzD,CAAC;AAGF,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC;AACxB,cAAc,yBAAyB,CAAC;AAGxC,cAAc,aAAa,CAAC;AAG5B,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,OAAO,CAAC;AACtB,cAAc,eAAe,CAAC;AAG9B,cAAc,qBAAqB,CAAC;AAEpC;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,KAAG,OAK1C,CAAC;AAEF,cAAc,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAgB,UAAU,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AACrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD;;;;GAIG;AACH,eAAO,MAAM,KAAK,GAAI,KAAK,MAAM,YAA6B,CAAC;AAE/D,8DAA8D;AAC9D,eAAO,MAAM,WAAW,GAAI,MAAM,OAAO,KAAG,IAAI,IAAI,GAEnD,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,iBAAiB,EAAE,eA8B/B,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,YAAY,OAAO,KAAG,OAQjD,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,EAAG,mBAA4B,CAAC;AAEvE,iGAAiG;AACjG,eAAO,MAAM,2BAA2B,GAAI,YAAY,OAAO,KAAG,OAGc,CAAC;AAEjF,+EAA+E;AAC/E,eAAO,MAAM,qBAAqB,GAAI,KAAK,EAAE,GAAG,UAAU,QAMzD,CAAC;AAGF,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC;AACxB,cAAc,yBAAyB,CAAC;AAGxC,cAAc,aAAa,CAAC;AAG5B,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,OAAO,CAAC;AACtB,cAAc,eAAe,CAAC;AAG9B,cAAc,qBAAqB,CAAC;AAEpC;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,KAAG,OAK1C,CAAC;AAEF,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iCAAiC,CAAC;AAEhD,cAAc,8BAA8B,CAAC;AAC7C,cAAc,cAAc,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/helpers",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Shared helpers for LearnCard packages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -54,8 +54,9 @@
|
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@noble/hashes": "^1.8.0",
|
|
56
56
|
"@sd-jwt/decode": "^0.19.0",
|
|
57
|
-
"@learncard/types": "
|
|
57
|
+
"@learncard/types": "6.0.0",
|
|
58
58
|
"@trpc/server": "11.8.0",
|
|
59
|
+
"moment": "^2.29.4",
|
|
59
60
|
"immer": "^10.0.3",
|
|
60
61
|
"use-immer": "^0.9.0",
|
|
61
62
|
"zod": "4.6.6"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import moment from 'moment';
|
|
2
|
+
|
|
3
|
+
// Canonical GDPR age-of-consent thresholds for EU countries.
|
|
4
|
+
export const GDPR_AGE_LIMIT: Record<string, number> = {
|
|
5
|
+
Germany: 16,
|
|
6
|
+
France: 15,
|
|
7
|
+
Spain: 14,
|
|
8
|
+
Ireland: 13,
|
|
9
|
+
Netherlands: 16,
|
|
10
|
+
Austria: 14,
|
|
11
|
+
Italy: 14,
|
|
12
|
+
Sweden: 13,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_EU_AGE_LIMIT = 16;
|
|
16
|
+
export const GDPR_AGE_LIMIT_BY_CODE: Record<string, number> = {
|
|
17
|
+
DE: 16,
|
|
18
|
+
FR: 15,
|
|
19
|
+
ES: 14,
|
|
20
|
+
IE: 13,
|
|
21
|
+
NL: 16,
|
|
22
|
+
AT: 14,
|
|
23
|
+
IT: 14,
|
|
24
|
+
SE: 13,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const EU_COUNTRY_CODES = [
|
|
28
|
+
'AT',
|
|
29
|
+
'BE',
|
|
30
|
+
'BG',
|
|
31
|
+
'HR',
|
|
32
|
+
'CY',
|
|
33
|
+
'CZ',
|
|
34
|
+
'DK',
|
|
35
|
+
'EE',
|
|
36
|
+
'FI',
|
|
37
|
+
'FR',
|
|
38
|
+
'DE',
|
|
39
|
+
'GR',
|
|
40
|
+
'HU',
|
|
41
|
+
'IE',
|
|
42
|
+
'IT',
|
|
43
|
+
'LV',
|
|
44
|
+
'LT',
|
|
45
|
+
'LU',
|
|
46
|
+
'MT',
|
|
47
|
+
'NL',
|
|
48
|
+
'PL',
|
|
49
|
+
'PT',
|
|
50
|
+
'RO',
|
|
51
|
+
'SK',
|
|
52
|
+
'SI',
|
|
53
|
+
'ES',
|
|
54
|
+
'SE',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** EU countries use the established GDPR threshold; other countries default to 18. */
|
|
58
|
+
export const getMinorAgeThreshold = (countryCode?: string): number => {
|
|
59
|
+
if (countryCode && EU_COUNTRY_CODES.includes(countryCode)) {
|
|
60
|
+
return GDPR_AGE_LIMIT_BY_CODE[countryCode] ?? DEFAULT_EU_AGE_LIMIT;
|
|
61
|
+
}
|
|
62
|
+
return 18;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Returns completed years for a valid ISO date/timestamp, or NaN if invalid/future. */
|
|
66
|
+
export const calculateAgeFromISODate = (dob: string, now: Date = new Date()): number => {
|
|
67
|
+
// Keep the ISO forms accepted by the existing profile/forms parser, including
|
|
68
|
+
// compact, week and ordinal dates. parseZone preserves the DOB's calendar day
|
|
69
|
+
// instead of shifting it with the browser/server timezone.
|
|
70
|
+
const parsed = moment.parseZone(dob, moment.ISO_8601, true);
|
|
71
|
+
if (!parsed.isValid()) return Number.NaN;
|
|
72
|
+
const year = parsed.year();
|
|
73
|
+
const month = parsed.month() + 1;
|
|
74
|
+
const day = parsed.date();
|
|
75
|
+
const birth = new Date(0);
|
|
76
|
+
birth.setUTCFullYear(year, month - 1, day);
|
|
77
|
+
birth.setUTCHours(0, 0, 0, 0);
|
|
78
|
+
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
79
|
+
if (birth > today) return Number.NaN;
|
|
80
|
+
let age = today.getUTCFullYear() - year;
|
|
81
|
+
if (
|
|
82
|
+
today.getUTCMonth() < month - 1 ||
|
|
83
|
+
(today.getUTCMonth() === month - 1 && today.getUTCDate() < day)
|
|
84
|
+
) {
|
|
85
|
+
age--;
|
|
86
|
+
}
|
|
87
|
+
return age;
|
|
88
|
+
};
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConsentFlowContract,
|
|
3
|
+
ConsentFlowContractChangeKind,
|
|
4
|
+
ConsentFlowContractDetailChange,
|
|
5
|
+
ConsentFlowContractDiff,
|
|
6
|
+
ConsentFlowContractScope,
|
|
7
|
+
ConsentFlowContractUpdatePreview,
|
|
8
|
+
} from '@learncard/types';
|
|
9
|
+
|
|
10
|
+
/** Whether a published optional-choice proposal window has not yet expired. */
|
|
11
|
+
export const hasOpenNewOptionsWindow = (
|
|
12
|
+
newOptionsUntil: string | undefined,
|
|
13
|
+
now = Date.now()
|
|
14
|
+
): boolean => Boolean(newOptionsUntil && Date.parse(newOptionsUntil) > now);
|
|
15
|
+
|
|
16
|
+
export type ConsentFlowContractSnapshot = {
|
|
17
|
+
contract: ConsentFlowContract;
|
|
18
|
+
name: string;
|
|
19
|
+
subtitle?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
reasonForAccessing?: string;
|
|
22
|
+
image?: string;
|
|
23
|
+
needsGuardianConsent?: boolean;
|
|
24
|
+
redirectUrl?: string;
|
|
25
|
+
frontDoorBoostUri?: string;
|
|
26
|
+
expiresAt?: string;
|
|
27
|
+
/** Stable URI/id identity is preferred; URI/id-bearing objects are canonicalized. */
|
|
28
|
+
autoboosts?: unknown[];
|
|
29
|
+
writers?: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const scopes = (contract: ConsentFlowContract): ConsentFlowContractScope[] => {
|
|
33
|
+
const result: ConsentFlowContractScope[] = [];
|
|
34
|
+
for (const area of ['read', 'write'] as const) {
|
|
35
|
+
const access = contract[area];
|
|
36
|
+
for (const [key, config] of Object.entries(access.credentials.categories)) {
|
|
37
|
+
result.push({
|
|
38
|
+
area,
|
|
39
|
+
type: 'credential',
|
|
40
|
+
key,
|
|
41
|
+
required: config.required,
|
|
42
|
+
reason: config.reason,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
for (const [key, config] of Object.entries(access.personal)) {
|
|
46
|
+
result.push({
|
|
47
|
+
area,
|
|
48
|
+
type: 'personal',
|
|
49
|
+
key,
|
|
50
|
+
required: config.required,
|
|
51
|
+
reason: config.reason,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result.sort((a, b) => scopeKey(a).localeCompare(scopeKey(b)));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const scopeKey = ({ area, type, key }: ConsentFlowContractScope): string =>
|
|
59
|
+
`${area}:${type}:${key}`;
|
|
60
|
+
|
|
61
|
+
const diffScopes = (
|
|
62
|
+
previous: ConsentFlowContractScope[],
|
|
63
|
+
current: ConsentFlowContractScope[]
|
|
64
|
+
): ConsentFlowContractDiff => {
|
|
65
|
+
const before = new Map(previous.map(scope => [scopeKey(scope), scope]));
|
|
66
|
+
const after = new Map(current.map(scope => [scopeKey(scope), scope]));
|
|
67
|
+
const added: ConsentFlowContractScope[] = [];
|
|
68
|
+
const removed: ConsentFlowContractScope[] = [];
|
|
69
|
+
const changed: ConsentFlowContractScope[] = [];
|
|
70
|
+
for (const [key, scope] of after) {
|
|
71
|
+
const old = before.get(key);
|
|
72
|
+
if (!old) added.push(scope);
|
|
73
|
+
else if (old.required !== scope.required || old.reason !== scope.reason)
|
|
74
|
+
changed.push(scope);
|
|
75
|
+
}
|
|
76
|
+
for (const [key, scope] of before) if (!after.has(key)) removed.push(scope);
|
|
77
|
+
const sort = (list: ConsentFlowContractScope[]) =>
|
|
78
|
+
list.sort((a, b) => scopeKey(a).localeCompare(scopeKey(b)));
|
|
79
|
+
return { added: sort(added), removed: sort(removed), changed: sort(changed) };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const DETAIL_FIELDS = [
|
|
83
|
+
'name',
|
|
84
|
+
'subtitle',
|
|
85
|
+
'description',
|
|
86
|
+
'reasonForAccessing',
|
|
87
|
+
'image',
|
|
88
|
+
'needsGuardianConsent',
|
|
89
|
+
'redirectUrl',
|
|
90
|
+
'frontDoorBoostUri',
|
|
91
|
+
'expiresAt',
|
|
92
|
+
] as const;
|
|
93
|
+
|
|
94
|
+
export const diffContractChange = (
|
|
95
|
+
prev: ConsentFlowContractSnapshot,
|
|
96
|
+
next: ConsentFlowContractSnapshot
|
|
97
|
+
): ConsentFlowContractDiff => {
|
|
98
|
+
const details: ConsentFlowContractDetailChange[] = [];
|
|
99
|
+
for (const field of DETAIL_FIELDS) {
|
|
100
|
+
const before = field === 'needsGuardianConsent' ? !!prev[field] : prev[field];
|
|
101
|
+
const after = field === 'needsGuardianConsent' ? !!next[field] : next[field];
|
|
102
|
+
if (before !== after) details.push({ field, before, after });
|
|
103
|
+
}
|
|
104
|
+
for (const field of ['writers', 'autoboosts'] as const) {
|
|
105
|
+
const before = [...set(prev[field])].sort();
|
|
106
|
+
const after = [...set(next[field])].sort();
|
|
107
|
+
if (
|
|
108
|
+
before.length !== after.length ||
|
|
109
|
+
before.some((value, index) => value !== after[index])
|
|
110
|
+
) {
|
|
111
|
+
details.push({ field, before, after });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
...diffScopes(scopes(prev.contract), scopes(next.contract)),
|
|
116
|
+
...(details.length ? { details } : {}),
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const expiryIsStricter = (previous?: string, next?: string): boolean => {
|
|
121
|
+
if (!next) return false;
|
|
122
|
+
if (!previous) return true;
|
|
123
|
+
const oldTime = Date.parse(previous);
|
|
124
|
+
const newTime = Date.parse(next);
|
|
125
|
+
return Number.isFinite(oldTime) && Number.isFinite(newTime) && newTime < oldTime;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const canonicalIdentity = (value: unknown): string => {
|
|
129
|
+
if (typeof value === 'string') return value;
|
|
130
|
+
if (value && typeof value === 'object') {
|
|
131
|
+
if (Array.isArray(value)) return `[${value.map(canonicalIdentity).join(',')}]`;
|
|
132
|
+
const record = value as Record<string, unknown>;
|
|
133
|
+
const identity = record.uri ?? record.id ?? record.boostUri;
|
|
134
|
+
if (typeof identity === 'string') return identity;
|
|
135
|
+
return `{${Object.keys(record)
|
|
136
|
+
.sort()
|
|
137
|
+
.map(key => `${JSON.stringify(key)}:${canonicalIdentity(record[key])}`)
|
|
138
|
+
.join(',')}}`;
|
|
139
|
+
}
|
|
140
|
+
return JSON.stringify(value) ?? String(value);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const set = (values?: unknown[]) => new Set((values ?? []).map(canonicalIdentity));
|
|
144
|
+
|
|
145
|
+
export const classifyContractChange = (
|
|
146
|
+
prev: ConsentFlowContractSnapshot,
|
|
147
|
+
next: ConsentFlowContractSnapshot
|
|
148
|
+
): ConsentFlowContractChangeKind => {
|
|
149
|
+
const previousScopes = scopes(prev.contract);
|
|
150
|
+
const diff = diffScopes(previousScopes, scopes(next.contract));
|
|
151
|
+
const before = new Map(previousScopes.map(scope => [scopeKey(scope), scope]));
|
|
152
|
+
let requiresConsent =
|
|
153
|
+
next.name !== prev.name ||
|
|
154
|
+
next.reasonForAccessing !== prev.reasonForAccessing ||
|
|
155
|
+
(!prev.needsGuardianConsent && !!next.needsGuardianConsent) ||
|
|
156
|
+
expiryIsStricter(prev.expiresAt, next.expiresAt) ||
|
|
157
|
+
next.writers?.some(writer => !prev.writers?.includes(writer)) === true;
|
|
158
|
+
let expandedOptional = false;
|
|
159
|
+
for (const scope of diff.added) {
|
|
160
|
+
if (scope.required) requiresConsent = true;
|
|
161
|
+
else expandedOptional = true;
|
|
162
|
+
}
|
|
163
|
+
for (const scope of diff.changed) {
|
|
164
|
+
const previous = before.get(scopeKey(scope));
|
|
165
|
+
if (scope.reason !== previous?.reason || (scope.required && !previous?.required))
|
|
166
|
+
requiresConsent = true;
|
|
167
|
+
}
|
|
168
|
+
const oldBoosts = set(prev.autoboosts);
|
|
169
|
+
if ([...set(next.autoboosts)].some(boost => !oldBoosts.has(boost))) requiresConsent = true;
|
|
170
|
+
if (requiresConsent) return 'requires_consent';
|
|
171
|
+
if (expandedOptional) return 'expanded_optional';
|
|
172
|
+
return 'relaxed';
|
|
173
|
+
};
|
|
174
|
+
const CANONICAL_SCOPE_COPY: Record<
|
|
175
|
+
string,
|
|
176
|
+
{ messageKey: string; label: string; description: string }
|
|
177
|
+
> = {
|
|
178
|
+
TRANSCRIPT: {
|
|
179
|
+
messageKey: 'transcripts',
|
|
180
|
+
label: 'Transcripts',
|
|
181
|
+
description: 'Records of completed courses and learning.',
|
|
182
|
+
},
|
|
183
|
+
TRANSCRIPTS: {
|
|
184
|
+
messageKey: 'transcripts',
|
|
185
|
+
label: 'Transcripts',
|
|
186
|
+
description: 'Records of completed courses and learning.',
|
|
187
|
+
},
|
|
188
|
+
CERTIFICATE: {
|
|
189
|
+
messageKey: 'certificates',
|
|
190
|
+
label: 'Certificates',
|
|
191
|
+
description: 'Credentials recognizing qualifications or completed learning.',
|
|
192
|
+
},
|
|
193
|
+
CERTIFICATES: {
|
|
194
|
+
messageKey: 'certificates',
|
|
195
|
+
label: 'Certificates',
|
|
196
|
+
description: 'Credentials recognizing qualifications or completed learning.',
|
|
197
|
+
},
|
|
198
|
+
ACHIEVEMENT: {
|
|
199
|
+
messageKey: 'achievements',
|
|
200
|
+
label: 'Achievements',
|
|
201
|
+
description: 'Milestones earned through effort, skill, or perseverance.',
|
|
202
|
+
},
|
|
203
|
+
ACHIEVEMENTS: {
|
|
204
|
+
messageKey: 'achievements',
|
|
205
|
+
label: 'Achievements',
|
|
206
|
+
description: 'Milestones earned through effort, skill, or perseverance.',
|
|
207
|
+
},
|
|
208
|
+
SKILL: {
|
|
209
|
+
messageKey: 'skills',
|
|
210
|
+
label: 'Skills',
|
|
211
|
+
description: 'Abilities developed through training, practice, or experience.',
|
|
212
|
+
},
|
|
213
|
+
SKILLS: {
|
|
214
|
+
messageKey: 'skills',
|
|
215
|
+
label: 'Skills',
|
|
216
|
+
description: 'Abilities developed through training, practice, or experience.',
|
|
217
|
+
},
|
|
218
|
+
'WORK HISTORY': {
|
|
219
|
+
messageKey: 'workHistory',
|
|
220
|
+
label: 'Work History',
|
|
221
|
+
description: 'Professional roles and activities over time.',
|
|
222
|
+
},
|
|
223
|
+
'LEARNING HISTORY': {
|
|
224
|
+
messageKey: 'learningHistory',
|
|
225
|
+
label: 'Learning History',
|
|
226
|
+
description: 'Structured programs of learning.',
|
|
227
|
+
},
|
|
228
|
+
ID: { messageKey: 'ids', label: 'IDs', description: 'Credentials used to verify identity.' },
|
|
229
|
+
'SOCIAL BADGE': {
|
|
230
|
+
messageKey: 'socialBadges',
|
|
231
|
+
label: 'Social Badges',
|
|
232
|
+
description: 'Digital labels recognizing participation or group membership.',
|
|
233
|
+
},
|
|
234
|
+
'SOCIAL BADGES': {
|
|
235
|
+
messageKey: 'socialBadges',
|
|
236
|
+
label: 'Social Badges',
|
|
237
|
+
description: 'Digital labels recognizing participation or group membership.',
|
|
238
|
+
},
|
|
239
|
+
MEMBERSHIP: {
|
|
240
|
+
messageKey: 'memberships',
|
|
241
|
+
label: 'Memberships',
|
|
242
|
+
description: 'Records of memberships.',
|
|
243
|
+
},
|
|
244
|
+
MEMBERSHIPS: {
|
|
245
|
+
messageKey: 'memberships',
|
|
246
|
+
label: 'Memberships',
|
|
247
|
+
description: 'Records of memberships.',
|
|
248
|
+
},
|
|
249
|
+
ACCOMPLISHMENT: {
|
|
250
|
+
messageKey: 'accomplishments',
|
|
251
|
+
label: 'Accomplishments',
|
|
252
|
+
description: 'Collections of work that showcase skills and experience.',
|
|
253
|
+
},
|
|
254
|
+
ACCOMPLISHMENTS: {
|
|
255
|
+
messageKey: 'accomplishments',
|
|
256
|
+
label: 'Accomplishments',
|
|
257
|
+
description: 'Collections of work that showcase skills and experience.',
|
|
258
|
+
},
|
|
259
|
+
EXPERIENCES: {
|
|
260
|
+
messageKey: 'experiences',
|
|
261
|
+
label: 'Experiences',
|
|
262
|
+
description: 'Knowledge and skills gained through professional roles and activities.',
|
|
263
|
+
},
|
|
264
|
+
ACCOMMODATION: {
|
|
265
|
+
messageKey: 'accommodations',
|
|
266
|
+
label: 'Accommodations',
|
|
267
|
+
description: 'Adjustments made to support accessibility.',
|
|
268
|
+
},
|
|
269
|
+
ACCOMMODATIONS: {
|
|
270
|
+
messageKey: 'accommodations',
|
|
271
|
+
label: 'Accommodations',
|
|
272
|
+
description: 'Adjustments made to support accessibility.',
|
|
273
|
+
},
|
|
274
|
+
FAMILY: {
|
|
275
|
+
messageKey: 'family',
|
|
276
|
+
label: 'Family',
|
|
277
|
+
description: 'Family profiles connected through shared learning journeys.',
|
|
278
|
+
},
|
|
279
|
+
QUALIFICATIONS: {
|
|
280
|
+
messageKey: 'qualifications',
|
|
281
|
+
label: 'Qualifications',
|
|
282
|
+
description: 'Education, training, and professional qualifications.',
|
|
283
|
+
},
|
|
284
|
+
GOALS: {
|
|
285
|
+
messageKey: 'goals',
|
|
286
|
+
label: 'Goals',
|
|
287
|
+
description: 'Learning and career goals you want to achieve.',
|
|
288
|
+
},
|
|
289
|
+
'PROFESSIONAL TITLE': {
|
|
290
|
+
messageKey: 'professionalTitle',
|
|
291
|
+
label: 'Professional title',
|
|
292
|
+
description: 'The title that describes your professional role.',
|
|
293
|
+
},
|
|
294
|
+
'ROLE EXPERIENCE': {
|
|
295
|
+
messageKey: 'roleExperience',
|
|
296
|
+
label: 'Role experience',
|
|
297
|
+
description: 'Experience gained in a particular professional role.',
|
|
298
|
+
},
|
|
299
|
+
'WORK EXPERIENCE': {
|
|
300
|
+
messageKey: 'workExperience',
|
|
301
|
+
label: 'Work experience',
|
|
302
|
+
description: 'Your work activities, responsibilities, and experience.',
|
|
303
|
+
},
|
|
304
|
+
'PAY RATE': {
|
|
305
|
+
messageKey: 'payRate',
|
|
306
|
+
label: 'Pay rate',
|
|
307
|
+
description: 'Your pay amount and payment period.',
|
|
308
|
+
},
|
|
309
|
+
'WORK LIFE BALANCE': {
|
|
310
|
+
messageKey: 'workLifeBalance',
|
|
311
|
+
label: 'Work-life balance',
|
|
312
|
+
description: 'Your preferences for balancing work and personal life.',
|
|
313
|
+
},
|
|
314
|
+
'JOB STABILITY': {
|
|
315
|
+
messageKey: 'jobStability',
|
|
316
|
+
label: 'Job stability',
|
|
317
|
+
description: 'Your preferences for job security and stability.',
|
|
318
|
+
},
|
|
319
|
+
'SELF-ASSIGNED SKILLS': {
|
|
320
|
+
messageKey: 'selfAssignedSkills',
|
|
321
|
+
label: 'Self-assigned skills',
|
|
322
|
+
description: 'Skills you have added to your own profile.',
|
|
323
|
+
},
|
|
324
|
+
'AI-SUMMARY': {
|
|
325
|
+
messageKey: 'aiSummaries',
|
|
326
|
+
label: 'AI summaries',
|
|
327
|
+
description: 'Summaries of your AI-assisted learning sessions.',
|
|
328
|
+
},
|
|
329
|
+
'AI-TOPIC': {
|
|
330
|
+
messageKey: 'aiTopics',
|
|
331
|
+
label: 'AI topics',
|
|
332
|
+
description: 'Topics explored in your AI-assisted learning.',
|
|
333
|
+
},
|
|
334
|
+
'AI-ASSESSMENT': {
|
|
335
|
+
messageKey: 'aiAssessments',
|
|
336
|
+
label: 'AI assessments',
|
|
337
|
+
description: 'Assessments from your AI-assisted learning.',
|
|
338
|
+
},
|
|
339
|
+
'AI INSIGHT': {
|
|
340
|
+
messageKey: 'aiInsights',
|
|
341
|
+
label: 'AI insights',
|
|
342
|
+
description: 'Insights about your learning progress and skills.',
|
|
343
|
+
},
|
|
344
|
+
'LEARNING-PATHWAY': {
|
|
345
|
+
messageKey: 'learningPathways',
|
|
346
|
+
label: 'Learning pathways',
|
|
347
|
+
description: 'Plans and steps toward your learning goals.',
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const PERSONAL_SCOPE_COPY: Record<
|
|
352
|
+
string,
|
|
353
|
+
{ messageKey: string; label: string; description: string }
|
|
354
|
+
> = {
|
|
355
|
+
NAME: { messageKey: 'name', label: 'Name', description: 'Your name.' },
|
|
356
|
+
EMAIL: { messageKey: 'email', label: 'Email', description: 'Your email address.' },
|
|
357
|
+
IMAGE: {
|
|
358
|
+
messageKey: 'profilePhoto',
|
|
359
|
+
label: 'Profile photo',
|
|
360
|
+
description: 'Your profile photo.',
|
|
361
|
+
},
|
|
362
|
+
'PROFILE IMAGE': {
|
|
363
|
+
messageKey: 'profilePhoto',
|
|
364
|
+
label: 'Profile photo',
|
|
365
|
+
description: 'Your profile photo.',
|
|
366
|
+
},
|
|
367
|
+
'PROFILE PHOTO': {
|
|
368
|
+
messageKey: 'profilePhoto',
|
|
369
|
+
label: 'Profile photo',
|
|
370
|
+
description: 'Your profile photo.',
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const scopeCopy = (
|
|
375
|
+
scope: ConsentFlowContractScope,
|
|
376
|
+
translate?: (key: string, values?: Record<string, unknown>) => string
|
|
377
|
+
) => {
|
|
378
|
+
const copy = (scope.type === 'personal' ? PERSONAL_SCOPE_COPY : CANONICAL_SCOPE_COPY)[
|
|
379
|
+
scope.key.toUpperCase()
|
|
380
|
+
];
|
|
381
|
+
if (!copy) return { label: scope.key, description: '' };
|
|
382
|
+
if (!translate) return copy;
|
|
383
|
+
const prefix = `consentUpdate.scope.${copy.messageKey}`;
|
|
384
|
+
return {
|
|
385
|
+
label: translate(`${prefix}.label`),
|
|
386
|
+
description: translate(`${prefix}.description`),
|
|
387
|
+
};
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
export const buildContractUpdatePreview = ({
|
|
391
|
+
name,
|
|
392
|
+
kind,
|
|
393
|
+
diff,
|
|
394
|
+
status,
|
|
395
|
+
translate,
|
|
396
|
+
}: {
|
|
397
|
+
name: string;
|
|
398
|
+
kind: ConsentFlowContractChangeKind;
|
|
399
|
+
diff: ConsentFlowContractDiff;
|
|
400
|
+
status?: string;
|
|
401
|
+
/** Clients localize structured changes; the API retains canonical English copy. */
|
|
402
|
+
translate?: (key: string, values?: Record<string, unknown>) => string;
|
|
403
|
+
}): ConsentFlowContractUpdatePreview => {
|
|
404
|
+
const text = (key: string, fallback: string, values?: Record<string, unknown>) =>
|
|
405
|
+
translate?.(`consentUpdate.${key}`, values) ?? fallback;
|
|
406
|
+
const label = (scope: ConsentFlowContractScope) => scopeCopy(scope, translate).label;
|
|
407
|
+
const addedScopeCount = diff.added.length;
|
|
408
|
+
const onlyAddedScopes = diff.changed.length === 0 && !diff.details?.length;
|
|
409
|
+
let headline: string;
|
|
410
|
+
let subhead: string;
|
|
411
|
+
let primaryLabel: string;
|
|
412
|
+
if (status === 'suspended') {
|
|
413
|
+
headline = text('headline.paused', `${name}'s access is paused`, { name });
|
|
414
|
+
subhead = text('subhead.reconnect', 'Approve the update to reconnect.');
|
|
415
|
+
primaryLabel = text('action.reconnect', 'Reconnect');
|
|
416
|
+
} else if (kind === 'requires_consent' && addedScopeCount === 1 && onlyAddedScopes) {
|
|
417
|
+
const scope = diff.added[0]!;
|
|
418
|
+
const values = { name, label: label(scope) };
|
|
419
|
+
headline =
|
|
420
|
+
scope.area === 'read'
|
|
421
|
+
? text('headline.read', `${name} is asking to see your ${values.label}`, values)
|
|
422
|
+
: text(
|
|
423
|
+
'headline.write',
|
|
424
|
+
`${name} is asking to add ${values.label} to your account`,
|
|
425
|
+
values
|
|
426
|
+
);
|
|
427
|
+
subhead = '';
|
|
428
|
+
primaryLabel = text('action.allow', 'Allow');
|
|
429
|
+
} else if (kind === 'requires_consent' && addedScopeCount > 1 && onlyAddedScopes) {
|
|
430
|
+
headline = text('headline.multiple', `${name} is asking for more access`, { name });
|
|
431
|
+
subhead = text('subhead.newItems', `${addedScopeCount} new items`, {
|
|
432
|
+
count: addedScopeCount,
|
|
433
|
+
});
|
|
434
|
+
primaryLabel = text('action.allowAccess', 'Allow access');
|
|
435
|
+
} else if (kind === 'requires_consent') {
|
|
436
|
+
headline = text('headline.changed', `${name} changed its request`, { name });
|
|
437
|
+
subhead = text('subhead.review', "Review what's different.");
|
|
438
|
+
primaryLabel = text('action.allow', 'Allow');
|
|
439
|
+
} else if (kind === 'expanded_optional') {
|
|
440
|
+
headline = text('headline.optional', `${name} has a new option`, { name });
|
|
441
|
+
subhead = text(
|
|
442
|
+
'subhead.optional',
|
|
443
|
+
'Turn it on if you want. Nothing changes unless you do.'
|
|
444
|
+
);
|
|
445
|
+
primaryLabel = text('action.continue', 'Continue');
|
|
446
|
+
} else {
|
|
447
|
+
const removed = diff.removed[0];
|
|
448
|
+
headline = removed
|
|
449
|
+
? text('headline.removed', `${name} now sees less`, { name })
|
|
450
|
+
: text('headline.changed', `${name} changed its request`, { name });
|
|
451
|
+
subhead = removed
|
|
452
|
+
? text('subhead.removed', `Stopped asking for ${label(removed)}.`, {
|
|
453
|
+
label: label(removed),
|
|
454
|
+
})
|
|
455
|
+
: text('subhead.changed', 'Its request has changed.');
|
|
456
|
+
primaryLabel = text('action.done', 'Done');
|
|
457
|
+
}
|
|
458
|
+
const row = (scope: ConsentFlowContractScope, change: 'added' | 'removed' | 'changed') => {
|
|
459
|
+
const copy = scopeCopy(scope, translate);
|
|
460
|
+
return { scope, change, label: copy.label, subtitle: scope.reason || copy.description };
|
|
461
|
+
};
|
|
462
|
+
return {
|
|
463
|
+
headline,
|
|
464
|
+
subhead,
|
|
465
|
+
rows: [
|
|
466
|
+
...diff.added.map(scope => row(scope, 'added')),
|
|
467
|
+
...diff.removed.map(scope => row(scope, 'removed')),
|
|
468
|
+
...diff.changed.map(scope => row(scope, 'changed')),
|
|
469
|
+
],
|
|
470
|
+
primaryLabel,
|
|
471
|
+
};
|
|
472
|
+
};
|