@longsightgroup/qti3-core 0.9.1 → 0.9.2
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/README.md +39 -0
- package/dist/adaptive-turn-materializer.d.ts +14 -0
- package/dist/adaptive-turn-materializer.d.ts.map +1 -0
- package/dist/adaptive-turn-materializer.js +56 -0
- package/dist/adaptive-turn-materializer.js.map +1 -0
- package/dist/adaptive-turn.d.ts +23 -0
- package/dist/adaptive-turn.d.ts.map +1 -0
- package/dist/adaptive-turn.js +60 -0
- package/dist/adaptive-turn.js.map +1 -0
- package/dist/delivery-redaction.d.ts +50 -0
- package/dist/delivery-redaction.d.ts.map +1 -0
- package/dist/delivery-redaction.js +176 -0
- package/dist/delivery-redaction.js.map +1 -0
- package/dist/delivery-security.d.ts +3 -18
- package/dist/delivery-security.d.ts.map +1 -1
- package/dist/delivery-security.js +35 -189
- package/dist/delivery-security.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/server-scoring.d.ts +4 -14
- package/dist/server-scoring.d.ts.map +1 -1
- package/dist/server-scoring.js +19 -157
- package/dist/server-scoring.js.map +1 -1
- package/dist/support.d.ts.map +1 -1
- package/dist/support.js +8 -4
- package/dist/support.js.map +1 -1
- package/dist/trusted-item-session.d.ts +49 -0
- package/dist/trusted-item-session.d.ts.map +1 -0
- package/dist/trusted-item-session.js +260 -0
- package/dist/trusted-item-session.js.map +1 -0
- package/package.json +4 -2
- package/src/adaptive-turn-materializer.ts +85 -0
- package/src/adaptive-turn.ts +109 -0
- package/src/delivery-redaction.ts +268 -0
- package/src/delivery-security.ts +59 -245
- package/src/index.ts +7 -0
- package/src/server-scoring.ts +30 -226
- package/src/support.ts +9 -4
- package/src/trusted-item-session.ts +427 -0
- package/src/interaction-test-fixtures.ts +0 -44
- package/src/serializer-processing.fixtures.ts +0 -469
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type { QtiDiagnostic, QtiSourceLocation } from "./types.js";
|
|
2
|
+
import { descendants, parseXmlTree, type XmlNode } from "./xml.js";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_VALUE_DECLARATION_ELEMENT_NAMES = new Set([
|
|
5
|
+
"response-declaration",
|
|
6
|
+
"outcome-declaration",
|
|
7
|
+
"template-declaration",
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export const FORBIDDEN_DELIVERY_SECRET_ELEMENT_NAMES = new Set([
|
|
11
|
+
"correct-response",
|
|
12
|
+
"mapping",
|
|
13
|
+
"area-mapping",
|
|
14
|
+
"match-table",
|
|
15
|
+
"interpolation-table",
|
|
16
|
+
"response-processing",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export type QtiDeliverySecurityFindingKind =
|
|
20
|
+
| "forbidden-delivery-element"
|
|
21
|
+
| "unsupported-secure-delivery-element"
|
|
22
|
+
| "unsupported-adaptive-response-processing";
|
|
23
|
+
|
|
24
|
+
export interface QtiDeliverySecurityFinding {
|
|
25
|
+
kind: QtiDeliverySecurityFindingKind;
|
|
26
|
+
qtiName: string;
|
|
27
|
+
localName: string;
|
|
28
|
+
message: string;
|
|
29
|
+
source?: QtiSourceLocation | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface QtiDeliverySecurityAnalysis {
|
|
33
|
+
diagnostics: QtiDiagnostic[];
|
|
34
|
+
findings: QtiDeliverySecurityFinding[];
|
|
35
|
+
/** True when this exact XML contains no known answer/scoring/feedback delivery leaks. */
|
|
36
|
+
deliverySafe: boolean;
|
|
37
|
+
/** True when secure-delivery redaction can be attempted for this XML. */
|
|
38
|
+
secureDeliverySupported: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ParsedDeliveryXml {
|
|
42
|
+
root: XmlNode | undefined;
|
|
43
|
+
diagnostics: QtiDiagnostic[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DeliveryRedactionPolicy {
|
|
47
|
+
isForbiddenElement(node: XmlNode, normalizedName: string): boolean;
|
|
48
|
+
unsupportedFinding?(
|
|
49
|
+
node: XmlNode,
|
|
50
|
+
normalizedName: string,
|
|
51
|
+
nodes: readonly XmlNode[],
|
|
52
|
+
): QtiDeliverySecurityFinding | null;
|
|
53
|
+
diagnosticCodeForFinding(kind: QtiDeliverySecurityFindingKind): string;
|
|
54
|
+
forbiddenElementMessage(node: XmlNode): string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RedactedDeliveryXmlResult {
|
|
58
|
+
ok: boolean;
|
|
59
|
+
diagnostics: QtiDiagnostic[];
|
|
60
|
+
analysis: QtiDeliverySecurityAnalysis;
|
|
61
|
+
xml?: string | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseDeliveryXml(xml: string): ParsedDeliveryXml {
|
|
65
|
+
let parsed: ReturnType<typeof parseXmlTree>;
|
|
66
|
+
try {
|
|
67
|
+
parsed = parseXmlTree(xml);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return {
|
|
70
|
+
root: undefined,
|
|
71
|
+
diagnostics: [
|
|
72
|
+
{
|
|
73
|
+
code: "xml.parse",
|
|
74
|
+
severity: "error",
|
|
75
|
+
message: error instanceof Error ? error.message : String(error),
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const diagnostics: QtiDiagnostic[] = parsed.errors.map((error) => ({
|
|
82
|
+
code: "xml.parse",
|
|
83
|
+
severity: "error",
|
|
84
|
+
message: error.message,
|
|
85
|
+
}));
|
|
86
|
+
if (!parsed.root) {
|
|
87
|
+
diagnostics.push({
|
|
88
|
+
code: "xml.empty",
|
|
89
|
+
severity: "error",
|
|
90
|
+
message: "No XML root element was found.",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return { root: parsed.root, diagnostics };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function analyzeDeliveryXml(
|
|
97
|
+
parsed: ParsedDeliveryXml,
|
|
98
|
+
policy: DeliveryRedactionPolicy,
|
|
99
|
+
): QtiDeliverySecurityAnalysis {
|
|
100
|
+
const diagnostics = [...parsed.diagnostics];
|
|
101
|
+
const findings: QtiDeliverySecurityFinding[] = [];
|
|
102
|
+
|
|
103
|
+
if (parsed.root) {
|
|
104
|
+
const nodes = [parsed.root, ...descendants(parsed.root, () => true)];
|
|
105
|
+
for (const node of nodes) {
|
|
106
|
+
const normalizedName = normalizedQtiElementName(node.localName);
|
|
107
|
+
if (policy.isForbiddenElement(node, normalizedName)) {
|
|
108
|
+
findings.push({
|
|
109
|
+
kind: "forbidden-delivery-element",
|
|
110
|
+
qtiName: node.name,
|
|
111
|
+
localName: node.localName,
|
|
112
|
+
message: policy.forbiddenElementMessage(node),
|
|
113
|
+
source: node.source,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const unsupportedFinding = policy.unsupportedFinding?.(node, normalizedName, nodes);
|
|
118
|
+
if (unsupportedFinding) findings.push(unsupportedFinding);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
diagnostics.push(
|
|
123
|
+
...findings.map((finding) =>
|
|
124
|
+
findingToDiagnostic(finding, (kind) => policy.diagnosticCodeForFinding(kind)),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
const parseOk = parsed.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
diagnostics,
|
|
131
|
+
findings,
|
|
132
|
+
deliverySafe:
|
|
133
|
+
parseOk && !findings.some((finding) => finding.kind === "forbidden-delivery-element"),
|
|
134
|
+
secureDeliverySupported:
|
|
135
|
+
parseOk &&
|
|
136
|
+
!findings.some(
|
|
137
|
+
(finding) =>
|
|
138
|
+
finding.kind === "unsupported-secure-delivery-element" ||
|
|
139
|
+
finding.kind === "unsupported-adaptive-response-processing",
|
|
140
|
+
),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function redactDeliveryXml(
|
|
145
|
+
xml: string,
|
|
146
|
+
policy: DeliveryRedactionPolicy,
|
|
147
|
+
): RedactedDeliveryXmlResult {
|
|
148
|
+
const parsed = parseDeliveryXml(xml);
|
|
149
|
+
const analysis = analyzeDeliveryXml(parsed, policy);
|
|
150
|
+
if (!analysis.secureDeliverySupported) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
diagnostics: analysis.diagnostics,
|
|
154
|
+
analysis,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!parsed.root) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
diagnostics: parsed.diagnostics,
|
|
162
|
+
analysis,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const redactionRanges = readRedactionRanges(
|
|
167
|
+
[parsed.root, ...descendants(parsed.root, () => true)],
|
|
168
|
+
(node, normalizedName) => policy.isForbiddenElement(node, normalizedName),
|
|
169
|
+
);
|
|
170
|
+
const redactedXml = removeSourceRanges(xml, redactionRanges);
|
|
171
|
+
const redactedAnalysis = analyzeDeliveryXml(parseDeliveryXml(redactedXml), policy);
|
|
172
|
+
if (!redactedAnalysis.deliverySafe || !redactedAnalysis.secureDeliverySupported) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
diagnostics: [...analysis.diagnostics, ...redactedAnalysis.diagnostics],
|
|
176
|
+
analysis: redactedAnalysis,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
xml: redactedXml,
|
|
183
|
+
diagnostics: redactedAnalysis.diagnostics,
|
|
184
|
+
analysis: redactedAnalysis,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function readRedactionRanges(
|
|
189
|
+
nodes: readonly XmlNode[],
|
|
190
|
+
shouldRedact: (node: XmlNode, normalizedName: string) => boolean,
|
|
191
|
+
): RedactionRange[] {
|
|
192
|
+
const ranges = nodes.flatMap((node) => {
|
|
193
|
+
const normalizedName = normalizedQtiElementName(node.localName);
|
|
194
|
+
if (!shouldRedact(node, normalizedName)) return [];
|
|
195
|
+
return sourceRangeFor(node);
|
|
196
|
+
});
|
|
197
|
+
return mergeSourceRanges(ranges);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function sourceRangeFor(node: XmlNode): RedactionRange[] {
|
|
201
|
+
const endOffset = node.sourceRange.endOffset;
|
|
202
|
+
if (node.sourceRange.startOffset < 0 || endOffset === undefined) return [];
|
|
203
|
+
return [{ startOffset: node.sourceRange.startOffset, endOffset }];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function removeSourceRanges(xml: string, ranges: readonly RedactionRange[]): string {
|
|
207
|
+
let output = "";
|
|
208
|
+
let cursor = 0;
|
|
209
|
+
for (const range of ranges) {
|
|
210
|
+
output += xml.slice(cursor, range.startOffset);
|
|
211
|
+
cursor = range.endOffset;
|
|
212
|
+
}
|
|
213
|
+
return output + xml.slice(cursor);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function normalizedQtiElementName(localName: string): string {
|
|
217
|
+
const lower = localName.toLowerCase();
|
|
218
|
+
return lower.startsWith("qti-") ? lower.slice("qti-".length) : lower;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function parseXmlBoolean(value: string | undefined): boolean | undefined {
|
|
222
|
+
if (value === undefined) return undefined;
|
|
223
|
+
const normalized = value.trim().toLowerCase();
|
|
224
|
+
if (normalized === "true" || normalized === "1") return true;
|
|
225
|
+
if (normalized === "false" || normalized === "0") return false;
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function isDeclarationDefaultValue(node: XmlNode, normalizedName: string): boolean {
|
|
230
|
+
return (
|
|
231
|
+
normalizedName === "default-value" &&
|
|
232
|
+
DEFAULT_VALUE_DECLARATION_ELEMENT_NAMES.has(
|
|
233
|
+
normalizedQtiElementName(node.parent?.localName ?? ""),
|
|
234
|
+
)
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface RedactionRange {
|
|
239
|
+
startOffset: number;
|
|
240
|
+
endOffset: number;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function mergeSourceRanges(ranges: readonly RedactionRange[]): RedactionRange[] {
|
|
244
|
+
const sorted = [...ranges].sort((left, right) => left.startOffset - right.startOffset);
|
|
245
|
+
const merged: RedactionRange[] = [];
|
|
246
|
+
for (const range of sorted) {
|
|
247
|
+
const last = merged.at(-1);
|
|
248
|
+
if (last && range.startOffset <= last.endOffset) {
|
|
249
|
+
last.endOffset = Math.max(last.endOffset, range.endOffset);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
merged.push({ ...range });
|
|
253
|
+
}
|
|
254
|
+
return merged;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function findingToDiagnostic(
|
|
258
|
+
finding: QtiDeliverySecurityFinding,
|
|
259
|
+
diagnosticCodeForFinding: (kind: QtiDeliverySecurityFindingKind) => string,
|
|
260
|
+
): QtiDiagnostic {
|
|
261
|
+
return {
|
|
262
|
+
code: diagnosticCodeForFinding(finding.kind),
|
|
263
|
+
severity: "error",
|
|
264
|
+
message: finding.message,
|
|
265
|
+
path: finding.source?.path,
|
|
266
|
+
source: finding.source,
|
|
267
|
+
};
|
|
268
|
+
}
|
package/src/delivery-security.ts
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
FORBIDDEN_DELIVERY_SECRET_ELEMENT_NAMES,
|
|
3
|
+
analyzeDeliveryXml,
|
|
4
|
+
isDeclarationDefaultValue,
|
|
5
|
+
normalizedQtiElementName,
|
|
6
|
+
parseDeliveryXml,
|
|
7
|
+
parseXmlBoolean,
|
|
8
|
+
redactDeliveryXml,
|
|
9
|
+
type DeliveryRedactionPolicy,
|
|
10
|
+
type QtiDeliverySecurityAnalysis,
|
|
11
|
+
type QtiDeliverySecurityFindingKind,
|
|
12
|
+
} from "./delivery-redaction.js";
|
|
13
|
+
import type { XmlNode } from "./xml.js";
|
|
14
|
+
|
|
15
|
+
export type {
|
|
16
|
+
QtiDeliverySecurityAnalysis,
|
|
17
|
+
QtiDeliverySecurityFinding,
|
|
18
|
+
QtiDeliverySecurityFindingKind,
|
|
19
|
+
} from "./delivery-redaction.js";
|
|
3
20
|
|
|
4
21
|
const FORBIDDEN_DELIVERY_ELEMENT_NAMES = new Set([
|
|
5
|
-
|
|
6
|
-
"mapping",
|
|
7
|
-
"area-mapping",
|
|
8
|
-
"match-table",
|
|
9
|
-
"interpolation-table",
|
|
10
|
-
"response-processing",
|
|
22
|
+
...FORBIDDEN_DELIVERY_SECRET_ELEMENT_NAMES,
|
|
11
23
|
"feedback-inline",
|
|
12
24
|
"feedback-block",
|
|
13
25
|
"modal-feedback",
|
|
@@ -18,222 +30,63 @@ const UNSUPPORTED_SECURE_DELIVERY_ELEMENT_NAMES = new Set([
|
|
|
18
30
|
"set-correct-response",
|
|
19
31
|
]);
|
|
20
32
|
|
|
21
|
-
const DEFAULT_VALUE_DECLARATION_ELEMENT_NAMES = new Set([
|
|
22
|
-
"response-declaration",
|
|
23
|
-
"outcome-declaration",
|
|
24
|
-
"template-declaration",
|
|
25
|
-
]);
|
|
26
|
-
|
|
27
|
-
export type QtiDeliverySecurityFindingKind =
|
|
28
|
-
| "forbidden-delivery-element"
|
|
29
|
-
| "unsupported-secure-delivery-element"
|
|
30
|
-
| "unsupported-adaptive-response-processing";
|
|
31
|
-
|
|
32
|
-
export interface QtiDeliverySecurityFinding {
|
|
33
|
-
kind: QtiDeliverySecurityFindingKind;
|
|
34
|
-
qtiName: string;
|
|
35
|
-
localName: string;
|
|
36
|
-
message: string;
|
|
37
|
-
source?: QtiSourceLocation | undefined;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface QtiDeliverySecurityAnalysis {
|
|
41
|
-
diagnostics: QtiDiagnostic[];
|
|
42
|
-
findings: QtiDeliverySecurityFinding[];
|
|
43
|
-
/** True when this exact XML contains no known answer/scoring/feedback delivery leaks. */
|
|
44
|
-
deliverySafe: boolean;
|
|
45
|
-
/** True when secure-delivery redaction can be attempted for this XML. */
|
|
46
|
-
secureDeliverySupported: boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
33
|
export interface QtiDeliverySafeXmlResult {
|
|
50
34
|
ok: boolean;
|
|
51
|
-
diagnostics:
|
|
35
|
+
diagnostics: QtiDeliverySecurityAnalysis["diagnostics"];
|
|
52
36
|
analysis: QtiDeliverySecurityAnalysis;
|
|
53
37
|
xml?: string | undefined;
|
|
54
38
|
}
|
|
55
39
|
|
|
56
40
|
export function analyzeQtiDeliverySecurity(xml: string): QtiDeliverySecurityAnalysis {
|
|
57
|
-
|
|
58
|
-
return analyzeParsedDeliveryXml(parsed);
|
|
41
|
+
return analyzeDeliveryXml(parseDeliveryXml(xml), staticDeliveryPolicy);
|
|
59
42
|
}
|
|
60
43
|
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
if (UNSUPPORTED_SECURE_DELIVERY_ELEMENT_NAMES.has(normalizedName)) {
|
|
82
|
-
findings.push({
|
|
83
|
-
kind: "unsupported-secure-delivery-element",
|
|
84
|
-
qtiName: node.name,
|
|
85
|
-
localName: node.localName,
|
|
86
|
-
message: `${node.name} is not supported by secure delivery redaction v1.`,
|
|
87
|
-
source: node.source,
|
|
88
|
-
});
|
|
89
|
-
}
|
|
44
|
+
export function buildQtiDeliverySafeXml(xml: string): QtiDeliverySafeXmlResult {
|
|
45
|
+
return redactDeliveryXml(xml, staticDeliveryPolicy);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const staticDeliveryPolicy: DeliveryRedactionPolicy = {
|
|
49
|
+
isForbiddenElement(node, normalizedName) {
|
|
50
|
+
return (
|
|
51
|
+
FORBIDDEN_DELIVERY_ELEMENT_NAMES.has(normalizedName) ||
|
|
52
|
+
isDeclarationDefaultValue(node, normalizedName)
|
|
53
|
+
);
|
|
54
|
+
},
|
|
55
|
+
unsupportedFinding(node, normalizedName, nodes) {
|
|
56
|
+
if (UNSUPPORTED_SECURE_DELIVERY_ELEMENT_NAMES.has(normalizedName)) {
|
|
57
|
+
return {
|
|
58
|
+
kind: "unsupported-secure-delivery-element",
|
|
59
|
+
qtiName: node.name,
|
|
60
|
+
localName: node.localName,
|
|
61
|
+
message: `${node.name} is not supported by secure delivery redaction v1.`,
|
|
62
|
+
source: node.source,
|
|
63
|
+
};
|
|
90
64
|
}
|
|
91
65
|
|
|
92
66
|
if (
|
|
93
|
-
|
|
94
|
-
|
|
67
|
+
node.parent === undefined &&
|
|
68
|
+
parseXmlBoolean(node.attributes.adaptive) === true &&
|
|
69
|
+
nodes.some(
|
|
70
|
+
(candidate) => normalizedQtiElementName(candidate.localName) === "response-processing",
|
|
71
|
+
)
|
|
95
72
|
) {
|
|
96
|
-
|
|
73
|
+
return {
|
|
97
74
|
kind: "unsupported-adaptive-response-processing",
|
|
98
|
-
qtiName:
|
|
99
|
-
localName:
|
|
75
|
+
qtiName: node.name,
|
|
76
|
+
localName: node.localName,
|
|
100
77
|
message:
|
|
101
78
|
"Adaptive response processing requires server-side item materialization before secure delivery.",
|
|
102
|
-
source:
|
|
103
|
-
}
|
|
79
|
+
source: node.source,
|
|
80
|
+
};
|
|
104
81
|
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
diagnostics.push(...findings.map(findingToDiagnostic));
|
|
108
|
-
const parseOk = parsed.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
109
82
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
!findings.some(
|
|
118
|
-
(finding) =>
|
|
119
|
-
finding.kind === "unsupported-secure-delivery-element" ||
|
|
120
|
-
finding.kind === "unsupported-adaptive-response-processing",
|
|
121
|
-
),
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export function buildQtiDeliverySafeXml(xml: string): QtiDeliverySafeXmlResult {
|
|
126
|
-
const parsed = parseDeliveryXml(xml);
|
|
127
|
-
const analysis = analyzeParsedDeliveryXml(parsed);
|
|
128
|
-
if (!analysis.secureDeliverySupported) {
|
|
129
|
-
return {
|
|
130
|
-
ok: false,
|
|
131
|
-
diagnostics: analysis.diagnostics,
|
|
132
|
-
analysis,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (!parsed.root) {
|
|
137
|
-
return {
|
|
138
|
-
ok: false,
|
|
139
|
-
diagnostics: parsed.diagnostics,
|
|
140
|
-
analysis,
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const redactionRanges = readRedactionRanges([
|
|
145
|
-
parsed.root,
|
|
146
|
-
...descendants(parsed.root, () => true),
|
|
147
|
-
]);
|
|
148
|
-
const redactedXml = removeSourceRanges(xml, redactionRanges);
|
|
149
|
-
const redactedAnalysis = analyzeQtiDeliverySecurity(redactedXml);
|
|
150
|
-
if (!redactedAnalysis.deliverySafe) {
|
|
151
|
-
return {
|
|
152
|
-
ok: false,
|
|
153
|
-
diagnostics: [...analysis.diagnostics, ...redactedAnalysis.diagnostics],
|
|
154
|
-
analysis: redactedAnalysis,
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return {
|
|
159
|
-
ok: true,
|
|
160
|
-
xml: redactedXml,
|
|
161
|
-
diagnostics: redactedAnalysis.diagnostics,
|
|
162
|
-
analysis: redactedAnalysis,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function parseDeliveryXml(xml: string): {
|
|
167
|
-
root: XmlNode | undefined;
|
|
168
|
-
diagnostics: QtiDiagnostic[];
|
|
169
|
-
} {
|
|
170
|
-
let parsed: ReturnType<typeof parseXmlTree>;
|
|
171
|
-
try {
|
|
172
|
-
parsed = parseXmlTree(xml);
|
|
173
|
-
} catch (error) {
|
|
174
|
-
return {
|
|
175
|
-
root: undefined,
|
|
176
|
-
diagnostics: [
|
|
177
|
-
{
|
|
178
|
-
code: "xml.parse",
|
|
179
|
-
severity: "error",
|
|
180
|
-
message: error instanceof Error ? error.message : String(error),
|
|
181
|
-
},
|
|
182
|
-
],
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const diagnostics: QtiDiagnostic[] = parsed.errors.map((error) => ({
|
|
187
|
-
code: "xml.parse",
|
|
188
|
-
severity: "error",
|
|
189
|
-
message: error.message,
|
|
190
|
-
}));
|
|
191
|
-
if (!parsed.root) {
|
|
192
|
-
diagnostics.push({
|
|
193
|
-
code: "xml.empty",
|
|
194
|
-
severity: "error",
|
|
195
|
-
message: "No XML root element was found.",
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
return { root: parsed.root, diagnostics };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function normalizedQtiElementName(localName: string): string {
|
|
202
|
-
const lower = localName.toLowerCase();
|
|
203
|
-
return lower.startsWith("qti-") ? lower.slice("qti-".length) : lower;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function parseXmlBoolean(value: string | undefined): boolean | undefined {
|
|
207
|
-
if (value === undefined) return undefined;
|
|
208
|
-
const normalized = value.trim().toLowerCase();
|
|
209
|
-
if (normalized === "true" || normalized === "1") return true;
|
|
210
|
-
if (normalized === "false" || normalized === "0") return false;
|
|
211
|
-
return undefined;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function isForbiddenDeliveryElement(
|
|
215
|
-
node: XmlNode,
|
|
216
|
-
normalizedName = normalizedQtiElementName(node.localName),
|
|
217
|
-
): boolean {
|
|
218
|
-
if (FORBIDDEN_DELIVERY_ELEMENT_NAMES.has(normalizedName)) return true;
|
|
219
|
-
return (
|
|
220
|
-
normalizedName === "default-value" &&
|
|
221
|
-
DEFAULT_VALUE_DECLARATION_ELEMENT_NAMES.has(
|
|
222
|
-
normalizedQtiElementName(node.parent?.localName ?? ""),
|
|
223
|
-
)
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function findingToDiagnostic(finding: QtiDeliverySecurityFinding): QtiDiagnostic {
|
|
228
|
-
const code = diagnosticCodeForFinding(finding.kind);
|
|
229
|
-
return {
|
|
230
|
-
code,
|
|
231
|
-
severity: "error",
|
|
232
|
-
message: finding.message,
|
|
233
|
-
path: finding.source?.path,
|
|
234
|
-
source: finding.source,
|
|
235
|
-
};
|
|
236
|
-
}
|
|
83
|
+
return null;
|
|
84
|
+
},
|
|
85
|
+
diagnosticCodeForFinding,
|
|
86
|
+
forbiddenElementMessage(node: XmlNode) {
|
|
87
|
+
return `${node.name} exposes answer keys, scoring, mapping, feedback, or solution information during delivery.`;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
237
90
|
|
|
238
91
|
function diagnosticCodeForFinding(kind: QtiDeliverySecurityFindingKind): string {
|
|
239
92
|
if (kind === "forbidden-delivery-element") return "delivery.forbiddenElement";
|
|
@@ -242,42 +95,3 @@ function diagnosticCodeForFinding(kind: QtiDeliverySecurityFindingKind): string
|
|
|
242
95
|
}
|
|
243
96
|
return "delivery.unsupportedSecureDelivery";
|
|
244
97
|
}
|
|
245
|
-
|
|
246
|
-
interface RedactionRange {
|
|
247
|
-
startOffset: number;
|
|
248
|
-
endOffset: number;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function readRedactionRanges(nodes: XmlNode[]): RedactionRange[] {
|
|
252
|
-
const ranges = nodes.flatMap((node) => {
|
|
253
|
-
if (!isForbiddenDeliveryElement(node)) return [];
|
|
254
|
-
const endOffset = node.sourceRange.endOffset;
|
|
255
|
-
if (node.sourceRange.startOffset < 0 || endOffset === undefined) return [];
|
|
256
|
-
return [{ startOffset: node.sourceRange.startOffset, endOffset }];
|
|
257
|
-
});
|
|
258
|
-
return mergeSourceRanges(ranges);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function mergeSourceRanges(ranges: RedactionRange[]): RedactionRange[] {
|
|
262
|
-
const sorted = [...ranges].sort((left, right) => left.startOffset - right.startOffset);
|
|
263
|
-
const merged: RedactionRange[] = [];
|
|
264
|
-
for (const range of sorted) {
|
|
265
|
-
const last = merged.at(-1);
|
|
266
|
-
if (last && range.startOffset <= last.endOffset) {
|
|
267
|
-
last.endOffset = Math.max(last.endOffset, range.endOffset);
|
|
268
|
-
continue;
|
|
269
|
-
}
|
|
270
|
-
merged.push({ ...range });
|
|
271
|
-
}
|
|
272
|
-
return merged;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function removeSourceRanges(xml: string, ranges: RedactionRange[]): string {
|
|
276
|
-
let output = "";
|
|
277
|
-
let cursor = 0;
|
|
278
|
-
for (const range of ranges) {
|
|
279
|
-
output += xml.slice(cursor, range.startOffset);
|
|
280
|
-
cursor = range.endOffset;
|
|
281
|
-
}
|
|
282
|
-
return output + xml.slice(cursor);
|
|
283
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export {
|
|
2
|
+
processQtiAdaptiveItemTurn,
|
|
3
|
+
type QtiAdaptiveTurnInput,
|
|
4
|
+
type QtiAdaptiveTurnResponseInput,
|
|
5
|
+
type QtiAdaptiveTurnResult,
|
|
6
|
+
} from "./adaptive-turn.js";
|
|
1
7
|
export {
|
|
2
8
|
createCatalogSupportResolution,
|
|
3
9
|
type QtiCatalogSupportResolution,
|
|
@@ -227,6 +233,7 @@ export type {
|
|
|
227
233
|
QtiResponseProcessing,
|
|
228
234
|
QtiResponseRule,
|
|
229
235
|
QtiScoreResult,
|
|
236
|
+
QtiStylesheet,
|
|
230
237
|
QtiLookupOutcomeValue,
|
|
231
238
|
QtiSetOutcomeValue,
|
|
232
239
|
QtiSupportStatus,
|