@modulus-learning/lms-canvas 0.0.0-stage → 0.11.1

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.
@@ -0,0 +1,45 @@
1
+ import { CLAIM_CUSTOM, CLAIM_ROLES, LMS_CONTRACT_VERSION, } from '@modulus-learning/lti';
2
+ import { HttpLtiAgsClient } from './ags-client.js';
3
+ import { CANVAS_CUSTOM_LAUNCH_FIELDS } from './deep-link-fields.js';
4
+ import { QuotaGovernor } from './quota-governor.js';
5
+ import { canvasRegistrationConfig, canvasRegistrationEndpoints } from './registration.js';
6
+ import { isInstructor } from './roles.js';
7
+ import { inspectCanvasTerm } from './term.js';
8
+ /**
9
+ * The Canvas LMS adapter. Everything Canvas-specific that core relies on —
10
+ * role interpretation, term metadata, the assignment lock-date fallback, the
11
+ * deep-link substitution variables, registration defaults and developer-key
12
+ * configuration, the AGS transport and Canvas quota pacing — is reached
13
+ * through this object.
14
+ */
15
+ export const canvasAdapter = () => ({
16
+ id: 'canvas',
17
+ contractVersion: LMS_CONTRACT_VERSION,
18
+ capabilities: { deepLinking: true, termMetadata: true, registrationConfig: true },
19
+ interpretLaunch: (launch) => ({
20
+ isInstructor: isInstructor(launch[CLAIM_ROLES]),
21
+ term: inspectCanvasTerm(launch[CLAIM_CUSTOM]),
22
+ cutoffFallback: launch[CLAIM_CUSTOM]['Canvas.assignment.lockAt.iso8601'],
23
+ }),
24
+ deepLinkCustomFields: () => ({ ...CANVAS_CUSTOM_LAUNCH_FIELDS }),
25
+ registrationEndpoints: canvasRegistrationEndpoints,
26
+ registrationConfig: canvasRegistrationConfig,
27
+ createScoreClient: ({ registration, accessTokens, logger, settings }) => {
28
+ // One governor per registration-bound client: no pacing state is shared
29
+ // between registrations.
30
+ const governor = new QuotaGovernor(Math.max(1, settings.maxConcurrentSubmissions), settings.quotaReserveRequests, settings.quotaWindowMs, settings.quotaRampIntervalMs);
31
+ return {
32
+ client: new HttpLtiAgsClient({
33
+ logger,
34
+ registration,
35
+ accessTokens,
36
+ requestTimeoutSeconds: settings.requestTimeoutSeconds,
37
+ }),
38
+ scheduling: {
39
+ target: () => governor.target(),
40
+ record: (signal) => governor.record(signal),
41
+ reset: () => governor.reset(),
42
+ },
43
+ };
44
+ },
45
+ });
@@ -0,0 +1,26 @@
1
+ import type { AccessTokenSource, LtiAgsClient, LtiLogger, LtiRegistration, PublishScoreInput, PublishScoreOutcome } from '@modulus-learning/lti';
2
+ export type FetchFn = typeof fetch;
3
+ /**
4
+ * HTTP client for {@link LtiAgsClient}, targeting Canvas's AGS score endpoint.
5
+ * Owns access-token acquisition (and invalidation on a token-level rejection),
6
+ * the AGS Score payload, the rate-limit header read, and response
7
+ * classification -- everything transport/protocol, so core's submitter is left
8
+ * with pure queue/lease orchestration.
9
+ */
10
+ export declare class HttpLtiAgsClient implements LtiAgsClient {
11
+ readonly issuer: string;
12
+ private logger;
13
+ private platform;
14
+ private accessTokenManager;
15
+ private requestTimeoutSeconds;
16
+ private fetchFn;
17
+ constructor(deps: {
18
+ logger: LtiLogger;
19
+ registration: LtiRegistration;
20
+ accessTokens: AccessTokenSource;
21
+ requestTimeoutSeconds: number;
22
+ fetchFn?: FetchFn;
23
+ });
24
+ publishScore(input: PublishScoreInput): Promise<PublishScoreOutcome>;
25
+ }
26
+ //# sourceMappingURL=ags-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ags-client.d.ts","sourceRoot":"","sources":["../src/ags-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EACjB,YAAY,EACZ,SAAS,EACT,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACpB,MAAM,uBAAuB,CAAA;AAO9B,MAAM,MAAM,OAAO,GAAG,OAAO,KAAK,CAAA;AAElC;;;;;;GAMG;AACH,qBAAa,gBAAiB,YAAW,YAAY;IACnD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IAEvB,OAAO,CAAC,MAAM,CAAW;IACzB,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,kBAAkB,CAAmB;IAC7C,OAAO,CAAC,qBAAqB,CAAQ;IACrC,OAAO,CAAC,OAAO,CAAS;IAExB,YAAY,IAAI,EAAE;QAChB,MAAM,EAAE,SAAS,CAAA;QACjB,YAAY,EAAE,eAAe,CAAA;QAC7B,YAAY,EAAE,iBAAiB,CAAA;QAC/B,qBAAqB,EAAE,MAAM,CAAA;QAC7B,OAAO,CAAC,EAAE,OAAO,CAAA;KAClB,EAOA;IAEK,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAmEzE;CACF"}
@@ -0,0 +1,99 @@
1
+ import { classifyScoreSubmissionResponse } from './error-classifier.js';
2
+ /**
3
+ * HTTP client for {@link LtiAgsClient}, targeting Canvas's AGS score endpoint.
4
+ * Owns access-token acquisition (and invalidation on a token-level rejection),
5
+ * the AGS Score payload, the rate-limit header read, and response
6
+ * classification -- everything transport/protocol, so core's submitter is left
7
+ * with pure queue/lease orchestration.
8
+ */
9
+ export class HttpLtiAgsClient {
10
+ issuer;
11
+ logger;
12
+ platform;
13
+ accessTokenManager;
14
+ requestTimeoutSeconds;
15
+ fetchFn;
16
+ constructor(deps) {
17
+ this.logger = deps.logger;
18
+ this.platform = deps.registration;
19
+ this.issuer = deps.registration.issuer;
20
+ this.accessTokenManager = deps.accessTokens;
21
+ this.requestTimeoutSeconds = deps.requestTimeoutSeconds;
22
+ this.fetchFn = deps.fetchFn ?? fetch;
23
+ }
24
+ async publishScore(input) {
25
+ this.logger.debug({
26
+ issuer: this.issuer,
27
+ progress: input.scoreGiven,
28
+ }, 'submitting score');
29
+ const accessTokenResult = await this.accessTokenManager.getAccessToken(this.platform);
30
+ if (!accessTokenResult.ok) {
31
+ return {
32
+ result: {
33
+ ok: false,
34
+ category: accessTokenResult.category,
35
+ description: accessTokenResult.message,
36
+ status: accessTokenResult.status_code,
37
+ },
38
+ };
39
+ }
40
+ const { accessToken } = accessTokenResult;
41
+ const headers = new Headers();
42
+ headers.append('Authorization', `Bearer ${accessToken.token}`);
43
+ headers.append('Content-Type', 'application/x-www-form-urlencoded');
44
+ const body = new URLSearchParams({
45
+ userId: input.ltiUserId,
46
+ activityProgress: input.scoreGiven < 1 ? 'Submitted' : 'Completed',
47
+ gradingProgress: 'FullyGraded',
48
+ timestamp: new Date().toISOString(),
49
+ scoreGiven: input.scoreGiven.toString(),
50
+ scoreMaximum: '1',
51
+ });
52
+ const response = await this.fetchFn(`${input.lineitemUrl}/scores`, {
53
+ method: 'POST',
54
+ headers,
55
+ body,
56
+ signal: AbortSignal.timeout(this.requestTimeoutSeconds * 1000),
57
+ }).catch((err) => {
58
+ // TODO: Should we handle TimeoutError (from the AbortSignal) differently?
59
+ this.logger.warn({ err, issuer: this.issuer }, 'network error in LTI score submission');
60
+ return null;
61
+ });
62
+ if (response == null) {
63
+ return { result: { ok: false, category: 'transient', description: 'network error' } };
64
+ }
65
+ const reading = parseRateLimit(response.headers);
66
+ const status = response.status;
67
+ const getText = () => response.text().catch((err) => {
68
+ this.logger.warn({ err, status, issuer: this.issuer }, 'error reading LTI score submission response body');
69
+ return '';
70
+ });
71
+ const result = await classifyScoreSubmissionResponse(status, getText);
72
+ if (!result.ok && result.category === 'platform_token') {
73
+ this.accessTokenManager.invalidateAccessToken(this.platform, accessToken);
74
+ }
75
+ return { result, reading };
76
+ }
77
+ }
78
+ /**
79
+ * Parse Canvas's rate-limit quota headers from a response. Returns `undefined`
80
+ * when `X-Rate-Limit-Remaining` is absent or unparseable (some low-level errors
81
+ * and 5xx responses omit them). Header lookup is case-insensitive.
82
+ */
83
+ function parseRateLimit(headers, at = Date.now()) {
84
+ const remainingRaw = headers.get('X-Rate-Limit-Remaining');
85
+ if (remainingRaw == null) {
86
+ return undefined;
87
+ }
88
+ const remaining = Number.parseFloat(remainingRaw);
89
+ if (Number.isNaN(remaining)) {
90
+ return undefined;
91
+ }
92
+ const costRaw = headers.get('X-Request-Cost');
93
+ const cost = costRaw == null ? undefined : Number.parseFloat(costRaw);
94
+ return {
95
+ remaining,
96
+ cost: cost != null && !Number.isNaN(cost) ? cost : undefined,
97
+ at,
98
+ };
99
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The Canvas substitution variables a Modulus deep-link content item asks
3
+ * Canvas to expand into every later launch's custom claims. Existing resource
4
+ * links keep the set they were created with, so removing a field affects only
5
+ * links created afterwards.
6
+ */
7
+ export declare const CANVAS_CUSTOM_LAUNCH_FIELDS: Record<string, string>;
8
+ //# sourceMappingURL=deep-link-fields.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deep-link-fields.d.ts","sourceRoot":"","sources":["../src/deep-link-fields.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,eAAO,MAAM,2BAA2B,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAwE9D,CAAA"}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The Canvas substitution variables a Modulus deep-link content item asks
3
+ * Canvas to expand into every later launch's custom claims. Existing resource
4
+ * links keep the set they were created with, so removing a field affects only
5
+ * links created afterwards.
6
+ */
7
+ // For now, every field that might eventually be useful is listed here;
8
+ // once things settle down, we can remove the ones that aren't being used,
9
+ // and rename the ones we are.
10
+ export const CANVAS_CUSTOM_LAUNCH_FIELDS = {
11
+ 'User.id': '$User.id',
12
+ 'User.username': '$User.username',
13
+ // Canvas doesn't include the next two
14
+ 'ActualUser.id': '$ActualUser.id',
15
+ 'ActualUser.username': '$ActualUser.username',
16
+ 'Context.id': '$Context.id',
17
+ 'Context.title': '$Context.title',
18
+ // Canvas doesn't include the next two
19
+ // 'Context.type': '$Context.type',
20
+ // 'Context.label': '$Context.label',
21
+ 'ResourceLink.id': '$ResourceLink.id',
22
+ 'ResourceLink.title': '$ResourceLink.title',
23
+ 'ResourceLink.description': '$ResourceLink.description',
24
+ 'ResourceLink.available.startDateTime': '$ResourceLink.available.startDateTime',
25
+ 'ResourceLink.available.endDateTime': '$ResourceLink.available.endDateTime',
26
+ 'ResourceLink.submission.startDateTime': '$ResourceLink.submission.startDateTime',
27
+ 'ResourceLink.submission.endDateTime': '$ResourceLink.submission.endDateTime',
28
+ // Canvas doesn't include the next four
29
+ // 'ResourceLink.available.user.startDateTime': '$ResourceLink.available.user.startDateTime',
30
+ // 'ResourceLink.available.user.endDateTime': '$ResourceLink.available.user.endDateTime',
31
+ // 'ResourceLink.submission.user.startDateTime': '$ResourceLink.submission.user.startDateTime',
32
+ // 'ResourceLink.submission.user.endDateTime': '$ResourceLink.submission.user.endDateTime',
33
+ // Canvas-specific properties
34
+ 'Message.locale': '$Message.locale',
35
+ 'Canvas.account.id': '$Canvas.account.id',
36
+ 'Canvas.account.name': '$Canvas.account.name',
37
+ 'Canvas.user.id': '$Canvas.user.id',
38
+ 'Canvas.user.loginId': '$Canvas.user.loginId',
39
+ 'Canvas.masqueradingUser.id': '$Canvas.masqueradingUser.id',
40
+ 'Canvas.masqueradingUser.userId': '$Canvas.masqueradingUser.userId',
41
+ 'Canvas.course.id': '$Canvas.course.id',
42
+ 'Canvas.course.name': '$Canvas.course.name',
43
+ 'Canvas.course.startAt': '$Canvas.course.startAt',
44
+ 'Canvas.course.endAt': '$Canvas.course.endAt',
45
+ 'Canvas.course.gradePassbackSetting': '$Canvas.course.gradePassbackSetting',
46
+ 'Canvas.term.id': '$Canvas.term.id',
47
+ 'Canvas.term.name': '$Canvas.term.name',
48
+ 'Canvas.term.startAt': '$Canvas.term.startAt',
49
+ 'Canvas.term.endAt': '$Canvas.term.endAt',
50
+ 'Canvas.enrollment.enrollmentState': '$Canvas.enrollment.enrollmentState',
51
+ 'Canvas.module.id': '$Canvas.module.id',
52
+ 'Canvas.moduleItem.id': '$Canvas.moduleItem.id',
53
+ 'Canvas.assignment.id': '$Canvas.assignment.id',
54
+ 'Canvas.assignment.title': '$Canvas.assignment.title',
55
+ 'Canvas.assignment.description': '$Canvas.assignment.description',
56
+ 'Canvas.assignment.published': '$Canvas.assignment.published',
57
+ 'Canvas.assignment.pointsPossible': '$Canvas.assignment.pointsPossible',
58
+ 'Canvas.assignment.unlockAt.iso8601': '$Canvas.assignment.unlockAt.iso8601',
59
+ 'Canvas.assignment.lockAt.iso8601': '$Canvas.assignment.lockAt.iso8601',
60
+ 'Canvas.assignment.dueAt.iso8601': '$Canvas.assignment.dueAt.iso8601',
61
+ 'Canvas.assignment.allowedAttempts': '$Canvas.assignment.allowedAttempts',
62
+ 'Canvas.assignment.submission.studentAttempts': '$Canvas.assignment.submission.studentAttempts',
63
+ 'com.instructure.User.student_view': '$com.instructure.User.student_view',
64
+ 'com.instructure.Assignment.lti.id': '$com.instructure.Assignment.lti.id',
65
+ 'com.instructure.Assignment.description': '$com.instructure.Assignment.description',
66
+ 'com.instructure.Submission.id': '$com.instructure.Submission.id',
67
+ 'com.instructure.Context.globalId': '$com.instructure.Context.globalId',
68
+ 'com.instructure.Context.uuid': '$com.instructure.Context.uuid',
69
+ 'com.instructure.contextLabel': '$com.instructure.contextLabel',
70
+ 'vnd.instructure.Course.uuid': '$vnd.instructure.Course.uuid',
71
+ 'vnd.instructure.User.uuid': '$vnd.instructure.User.uuid',
72
+ 'vnd.instructure.User.current_uuid': '$vnd.instructure.User.current_uuid',
73
+ 'LineItem.resultValue.max': '$LineItem.resultValue.max',
74
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Classifier for error responses from Canvas LMS's AGS score-submission
3
+ * endpoint (POST /api/lti/courses/:course_id/line_items/:line_item_id/scores).
4
+ *
5
+ * Categories (see `SubmissionErrorCategory` in ./types.ts):
6
+ * platform_config — the developer key / registration itself is broken (scopes
7
+ * not granted, key inactive at the account level); every AGS
8
+ * submission against this key keeps failing until fixed at
9
+ * the platform level.
10
+ * platform_token — the access token is bad (rejected, insufficient scope,
11
+ * unknown/inactive key); a token-level auth problem.
12
+ * lineitem_dead — the specific line item, course, user, or assignment is
13
+ * the problem (deleted, concluded, not bound to this key);
14
+ * the key is otherwise healthy. Don't resubmit this line-item.
15
+ * malformed — the request is structurally wrong; almost always a coding
16
+ * bug in the tool. For now, just back off and retry.
17
+ * rate_limit — 403 quota exhausted or operator-blocked; back off and retry.
18
+ * superseded — Canvas already has a newer score for this line item; the
19
+ * write is stale and can be dropped.
20
+ * transient — 5xx / gateway / temporary; safe to retry with backoff.
21
+ * unknown — unrecognized response; default handling.
22
+ *
23
+ * The response body is not read here directly; the caller supplies a `getText`
24
+ * accessor so this function stays independent of the Response object.
25
+ */
26
+ import type { SubmissionResult } from '@modulus-learning/lti';
27
+ export declare function classifyScoreSubmissionResponse(status: number, getText: () => Promise<string>): Promise<SubmissionResult>;
28
+ //# sourceMappingURL=error-classifier.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-classifier.d.ts","sourceRoot":"","sources":["../src/error-classifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAA2B,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAwTtF,wBAAsB,+BAA+B,CACnD,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,GAC7B,OAAO,CAAC,gBAAgB,CAAC,CAuC3B"}
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Classifier for error responses from Canvas LMS's AGS score-submission
3
+ * endpoint (POST /api/lti/courses/:course_id/line_items/:line_item_id/scores).
4
+ *
5
+ * Categories (see `SubmissionErrorCategory` in ./types.ts):
6
+ * platform_config — the developer key / registration itself is broken (scopes
7
+ * not granted, key inactive at the account level); every AGS
8
+ * submission against this key keeps failing until fixed at
9
+ * the platform level.
10
+ * platform_token — the access token is bad (rejected, insufficient scope,
11
+ * unknown/inactive key); a token-level auth problem.
12
+ * lineitem_dead — the specific line item, course, user, or assignment is
13
+ * the problem (deleted, concluded, not bound to this key);
14
+ * the key is otherwise healthy. Don't resubmit this line-item.
15
+ * malformed — the request is structurally wrong; almost always a coding
16
+ * bug in the tool. For now, just back off and retry.
17
+ * rate_limit — 403 quota exhausted or operator-blocked; back off and retry.
18
+ * superseded — Canvas already has a newer score for this line item; the
19
+ * write is stale and can be dropped.
20
+ * transient — 5xx / gateway / temporary; safe to retry with backoff.
21
+ * unknown — unrecognized response; default handling.
22
+ *
23
+ * The response body is not read here directly; the caller supplies a `getText`
24
+ * accessor so this function stays independent of the Response object.
25
+ */
26
+ const has = (...needles) => (msg) => {
27
+ const lower = msg.toLowerCase();
28
+ return needles.some((n) => lower.includes(n.toLowerCase()));
29
+ };
30
+ // Order matters: more-specific message matchers must come before generic
31
+ // ones for the same status code (e.g. "Invalid access token signature"
32
+ // before the catch-all "Invalid access token").
33
+ const RULES = [
34
+ // ---------- 401 (auth / authorization) ----------
35
+ {
36
+ status: 401,
37
+ match: has('Missing access token'),
38
+ category: 'malformed',
39
+ description: 'no access token sent on request',
40
+ },
41
+ {
42
+ status: 401,
43
+ match: has('Invalid access token format'),
44
+ category: 'platform_token',
45
+ description: 'access token is not a well-formed JWT',
46
+ },
47
+ {
48
+ status: 401,
49
+ match: has('Invalid access token signature'),
50
+ category: 'platform_token',
51
+ description: 'JWT signature did not verify against the registered JWKs',
52
+ },
53
+ {
54
+ status: 401,
55
+ match: has('Access token signature algorithm not allowed'),
56
+ category: 'platform_token',
57
+ description: 'JWT signing algorithm not permitted by Canvas',
58
+ },
59
+ {
60
+ status: 401,
61
+ match: has('Access token contains invalid claims'),
62
+ category: 'platform_token',
63
+ description: 'JWT claims rejected (audience, expiry, iss, sub, etc.)',
64
+ },
65
+ {
66
+ status: 401,
67
+ match: has('Expired access token'),
68
+ category: 'platform_token',
69
+ description: 'access token expired; obtain a fresh token and retry',
70
+ },
71
+ {
72
+ status: 401,
73
+ match: has('Revoked access token'),
74
+ category: 'platform_token',
75
+ description: 'access token has been revoked',
76
+ },
77
+ {
78
+ status: 401,
79
+ match: has('Insufficient scopes on access token'),
80
+ category: 'platform_token',
81
+ description: 'access token does not carry the AGS score scope',
82
+ },
83
+ {
84
+ status: 401,
85
+ match: has('Insufficient permissions'),
86
+ category: 'platform_config',
87
+ description: 'developer key is not registered with the AGS score scope',
88
+ },
89
+ {
90
+ status: 401,
91
+ match: has('Unknown or inactive Developer Key'),
92
+ category: 'platform_token',
93
+ description: 'developer key is missing, deleted, or inactive',
94
+ },
95
+ {
96
+ status: 401,
97
+ match: has('Invalid Developer Key'),
98
+ category: 'lineitem_dead',
99
+ description: "developer key not enabled in this course's account chain, or registration deactivated",
100
+ },
101
+ {
102
+ status: 401,
103
+ match: has('Access Token not linked to a Tool associated with this Context'),
104
+ category: 'lineitem_dead',
105
+ description: 'no active deployment of this key reachable from this course',
106
+ },
107
+ {
108
+ status: 401,
109
+ match: has('Invalid access token'),
110
+ category: 'platform_token',
111
+ description: 'access token rejected by Canvas',
112
+ },
113
+ // ---------- 403 (rate limit / blocklist) ----------
114
+ {
115
+ status: 403,
116
+ category: 'rate_limit',
117
+ description: 'rate-limit bucket exhausted or operator-blocked; backoff and retry',
118
+ },
119
+ // ---------- 404 (context / resource gone) ----------
120
+ {
121
+ status: 404,
122
+ match: has('Context is deleted or not found'),
123
+ category: 'lineitem_dead',
124
+ description: 'course context missing or deleted',
125
+ },
126
+ {
127
+ status: 404,
128
+ match: has('The specified resource does not exist'),
129
+ category: 'lineitem_dead',
130
+ description: 'line item, assignment, or course not found',
131
+ },
132
+ {
133
+ status: 404,
134
+ category: 'lineitem_dead',
135
+ description: 'resource not found',
136
+ },
137
+ // ---------- 412 (precondition) ----------
138
+ {
139
+ status: 412,
140
+ match: has('The specified LTI link ID is not associated with the line item'),
141
+ category: 'malformed',
142
+ description: 'resourceLinkId does not match the line item',
143
+ },
144
+ {
145
+ status: 412,
146
+ match: has('Tool does not have permission to view line_item'),
147
+ category: 'lineitem_dead',
148
+ description: 'line item not associated with developer key',
149
+ },
150
+ {
151
+ status: 412,
152
+ category: 'malformed',
153
+ description: 'precondition failed',
154
+ },
155
+ // ---------- 413 (payload) ----------
156
+ {
157
+ status: 413,
158
+ category: 'malformed',
159
+ description: 'request payload too large',
160
+ },
161
+ // ---------- 400 (validation / bad request) ----------
162
+ {
163
+ status: 400,
164
+ match: has('is missing'),
165
+ category: 'malformed',
166
+ description: 'required parameter missing',
167
+ },
168
+ {
169
+ status: 400,
170
+ match: has('before last updated timestamp'),
171
+ category: 'superseded',
172
+ description: 'stale timestamp — Canvas already has a newer score for this line item',
173
+ },
174
+ {
175
+ status: 400,
176
+ match: has('in the future'),
177
+ category: 'malformed',
178
+ description: 'submitted_at is more than a minute in the future',
179
+ },
180
+ {
181
+ status: 400,
182
+ match: has('not a valid timestamp'),
183
+ category: 'malformed',
184
+ description: 'timestamp could not be parsed',
185
+ },
186
+ {
187
+ status: 400,
188
+ match: has('uploading to file service failed'),
189
+ category: 'malformed',
190
+ description: 'Canvas file service rejected the upload as a bad request',
191
+ },
192
+ {
193
+ status: 400,
194
+ category: 'malformed',
195
+ description: 'bad request',
196
+ },
197
+ // ---------- 422 (unprocessable entity) ----------
198
+ {
199
+ status: 422,
200
+ match: has('This course has concluded'),
201
+ category: 'lineitem_dead',
202
+ description: 'course concluded (for this student or globally)',
203
+ },
204
+ {
205
+ status: 422,
206
+ match: has('User not found in course or is not a student'),
207
+ category: 'lineitem_dead',
208
+ description: 'user is not (or no longer) a student in this course',
209
+ },
210
+ {
211
+ status: 422,
212
+ match: has('This assignment is still unpublished'),
213
+ category: 'lineitem_dead',
214
+ description: 'assignment is unpublished',
215
+ },
216
+ {
217
+ status: 422,
218
+ match: has('Resource link id points to Tool not associated with this Context'),
219
+ category: 'lineitem_dead',
220
+ description: 'resourceLinkId points to a tool that no longer matches this context',
221
+ },
222
+ {
223
+ status: 422,
224
+ match: has('cannot be zero if line item'),
225
+ category: 'malformed',
226
+ description: "scoreMaximum is zero but the line item's max is not",
227
+ },
228
+ {
229
+ status: 422,
230
+ match: has('ScoreMaximum'),
231
+ category: 'malformed',
232
+ description: 'invalid scoreMaximum (missing or negative)',
233
+ },
234
+ {
235
+ status: 422,
236
+ match: has('ScoreGiven must be greater than or equal to 0'),
237
+ category: 'malformed',
238
+ description: 'scoreGiven is negative',
239
+ },
240
+ {
241
+ status: 422,
242
+ match: has("Content items must be provided with submission type 'online_upload'"),
243
+ category: 'malformed',
244
+ description: 'online_upload submission type requires content_items',
245
+ },
246
+ {
247
+ status: 422,
248
+ match: has('maximum number of allowed attempts has been reached'),
249
+ category: 'lineitem_dead',
250
+ description: 'user has no submission attempts remaining',
251
+ },
252
+ {
253
+ status: 422,
254
+ match: has('Student must be enrolled in the course as a student'),
255
+ category: 'lineitem_dead',
256
+ description: "user's enrollment does not allow grading",
257
+ },
258
+ {
259
+ status: 422,
260
+ match: has('Cannot grade this submission at this time'),
261
+ category: 'lineitem_dead',
262
+ description: 'submission not gradeable (e.g. moderated grading state)',
263
+ },
264
+ {
265
+ status: 422,
266
+ match: has('Must provide a valid sub assignment tag'),
267
+ category: 'lineitem_dead',
268
+ description: 'checkpointed discussion sub-assignment cannot accept AGS scores',
269
+ },
270
+ {
271
+ status: 422,
272
+ category: 'unknown',
273
+ description: 'unprocessable entity (uncategorized validation or grading error)',
274
+ },
275
+ ];
276
+ // function parseBody(text: string): unknown {
277
+ // try {
278
+ // return JSON.parse(text)
279
+ // } catch {
280
+ // return text
281
+ // }
282
+ // }
283
+ // function extractMessage(body: unknown): string {
284
+ // if (body == null) return ''
285
+ // if (typeof body === 'string') return body
286
+ // if (typeof body !== 'object') return ''
287
+ // const errors = (body as Record<string, unknown>).errors
288
+ // if (errors == null) return ''
289
+ // // Shape A: { errors: { type, message } }
290
+ // if (!Array.isArray(errors) && typeof errors === 'object') {
291
+ // const e = errors as Record<string, unknown>
292
+ // if (typeof e.message === 'string') return e.message
293
+ // // Shape C: { errors: { field: [{ message }] } }
294
+ // for (const key of Object.keys(e)) {
295
+ // const val = e[key]
296
+ // if (Array.isArray(val) && val.length > 0) {
297
+ // const first = val[0] as Record<string, unknown> | undefined
298
+ // if (first && typeof first.message === 'string') {
299
+ // return `${key}: ${first.message}`
300
+ // }
301
+ // }
302
+ // }
303
+ // }
304
+ // // Shape B: { errors: [{ message }] }
305
+ // if (Array.isArray(errors) && errors.length > 0) {
306
+ // const first = errors[0] as Record<string, unknown> | undefined
307
+ // if (first && typeof first.message === 'string') return first.message
308
+ // }
309
+ // return ''
310
+ // }
311
+ export async function classifyScoreSubmissionResponse(status, getText) {
312
+ if (status >= 200 && status < 300) {
313
+ return {
314
+ ok: true,
315
+ };
316
+ }
317
+ const text = await getText();
318
+ if (status >= 500) {
319
+ return {
320
+ ok: false,
321
+ category: 'transient',
322
+ description: 'server error',
323
+ status,
324
+ text,
325
+ };
326
+ }
327
+ for (const rule of RULES) {
328
+ // A rule with no matcher is the catch-all for its status code.
329
+ if (rule.status === status && (rule.match == null || rule.match(text))) {
330
+ return {
331
+ ok: false,
332
+ category: rule.category,
333
+ description: rule.description,
334
+ status,
335
+ text,
336
+ };
337
+ }
338
+ }
339
+ return {
340
+ ok: false,
341
+ category: 'unknown',
342
+ description: 'unrecognized error',
343
+ status,
344
+ text,
345
+ };
346
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@modulus-learning/lms-canvas` — the Canvas LMS adapter for Modulus. Pass
3
+ * `canvasAdapter()` to the host's server configuration.
4
+ */
5
+ export { canvasAdapter } from './adapter.js';
6
+ export { type FetchFn, HttpLtiAgsClient } from './ags-client.js';
7
+ export { CANVAS_CUSTOM_LAUNCH_FIELDS } from './deep-link-fields.js';
8
+ export { classifyScoreSubmissionResponse } from './error-classifier.js';
9
+ export { QuotaGovernor } from './quota-governor.js';
10
+ export { canvasRegistrationConfig, canvasRegistrationEndpoints } from './registration.js';
11
+ export { INSTRUCTOR_LTI_ROLES, isInstructor } from './roles.js';
12
+ export { inspectCanvasTerm, normalizeCanvasTerm } from './term.js';
13
+ export type { RateLimitReading } from './types.js';
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAC5C,OAAO,EAAE,KAAK,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAChE,OAAO,EAAE,2BAA2B,EAAE,MAAM,uBAAuB,CAAA;AACnE,OAAO,EAAE,+BAA+B,EAAE,MAAM,uBAAuB,CAAA;AACvE,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACnD,OAAO,EAAE,wBAAwB,EAAE,2BAA2B,EAAE,MAAM,mBAAmB,CAAA;AACzF,OAAO,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAC/D,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAA;AAClE,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA"}