@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.
- package/LICENSE +661 -0
- package/dist/adapter.d.ts +10 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +45 -0
- package/dist/ags-client.d.ts +26 -0
- package/dist/ags-client.d.ts.map +1 -0
- package/dist/ags-client.js +99 -0
- package/dist/deep-link-fields.d.ts +8 -0
- package/dist/deep-link-fields.d.ts.map +1 -0
- package/dist/deep-link-fields.js +74 -0
- package/dist/error-classifier.d.ts +28 -0
- package/dist/error-classifier.d.ts.map +1 -0
- package/dist/error-classifier.js +346 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/quota-governor.d.ts +47 -0
- package/dist/quota-governor.d.ts.map +1 -0
- package/dist/quota-governor.js +101 -0
- package/dist/registration.d.ts +8 -0
- package/dist/registration.d.ts.map +1 -0
- package/dist/registration.js +103 -0
- package/dist/roles.d.ts +3 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +26 -0
- package/dist/term.d.ts +11 -0
- package/dist/term.d.ts.map +1 -0
- package/dist/term.js +54 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +49 -3
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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 { 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';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { RateLimitReading } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Per-platform concurrency governor driven by Canvas's rate-limit quota headers.
|
|
4
|
+
*
|
|
5
|
+
* It maps observed quota into an allowed concurrency in `[1, maxConcurrency]`:
|
|
6
|
+
* roughly "how many requests fit in the remaining quota, minus a reserve". The
|
|
7
|
+
* quota bucket is shared per developer key, and each `remaining` reading already
|
|
8
|
+
* reflects the *global* bucket level, so reacting to it implicitly responds to
|
|
9
|
+
* pressure from other processes too (though independent governors can still
|
|
10
|
+
* collectively over-target — cross-process coordination is out of scope).
|
|
11
|
+
*
|
|
12
|
+
* Design points:
|
|
13
|
+
* - **Cold start at 1** and ramp up as ample quota is observed.
|
|
14
|
+
* - **Conservative aggregation**: target off the *minimum* `remaining` and the
|
|
15
|
+
* *maximum* `cost` seen in a recent window, since concurrent readings race.
|
|
16
|
+
* - **Fast down, slow up**: drop to the computed target immediately when quota
|
|
17
|
+
* tightens, but raise the effective target by at most +1 per ramp interval.
|
|
18
|
+
* - **Reset on rate-limit**: a 403 returns the governor to cold start so it
|
|
19
|
+
* re-earns concurrency conservatively.
|
|
20
|
+
*
|
|
21
|
+
* Pure and synchronous — unit-testable by passing explicit `now` values.
|
|
22
|
+
*/
|
|
23
|
+
export declare class QuotaGovernor {
|
|
24
|
+
private readonly maxConcurrency;
|
|
25
|
+
private readonly reserveRequests;
|
|
26
|
+
private readonly windowMs;
|
|
27
|
+
private readonly rampIntervalMs;
|
|
28
|
+
private window;
|
|
29
|
+
private currentTarget;
|
|
30
|
+
private lastRaiseAt;
|
|
31
|
+
constructor(maxConcurrency: number, reserveRequests: number, windowMs: number, rampIntervalMs: number, now?: number);
|
|
32
|
+
/** Record a quota reading from a submission response. */
|
|
33
|
+
record(reading: RateLimitReading): void;
|
|
34
|
+
/** Return the governor to cold start (concurrency 1, no history). */
|
|
35
|
+
reset(now?: number): void;
|
|
36
|
+
/** The currently allowed concurrency, in `[1, maxConcurrency]`. */
|
|
37
|
+
target(now?: number): number;
|
|
38
|
+
private reconcile;
|
|
39
|
+
private prune;
|
|
40
|
+
/**
|
|
41
|
+
* The concurrency the current readings support, or `undefined` if we can't yet
|
|
42
|
+
* tell (no readings, or no cost observed). Uses the worst recent `remaining`
|
|
43
|
+
* and the priciest recent `cost` for safety.
|
|
44
|
+
*/
|
|
45
|
+
private computedTarget;
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=quota-governor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quota-governor.d.ts","sourceRoot":"","sources":["../src/quota-governor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAElD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,aAAa;IAMtB,OAAO,CAAC,QAAQ,CAAC,cAAc;IAC/B,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,cAAc;IARjC,OAAO,CAAC,MAAM,CAAyB;IACvC,OAAO,CAAC,aAAa,CAAI;IACzB,OAAO,CAAC,WAAW,CAAQ;IAE3B,YACmB,cAAc,EAAE,MAAM,EACtB,eAAe,EAAE,MAAM,EACvB,QAAQ,EAAE,MAAM,EAChB,cAAc,EAAE,MAAM,EACvC,GAAG,GAAE,MAAmB,EAGzB;IAED,yDAAyD;IACzD,MAAM,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAGtC;IAED,qEAAqE;IACrE,KAAK,CAAC,GAAG,GAAE,MAAmB,GAAG,IAAI,CAIpC;IAED,mEAAmE;IACnE,MAAM,CAAC,GAAG,GAAE,MAAmB,GAAG,MAAM,CAGvC;IAED,OAAO,CAAC,SAAS;IAmBjB,OAAO,CAAC,KAAK;IAOb;;;;OAIG;IACH,OAAO,CAAC,cAAc;CAuBvB"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-platform concurrency governor driven by Canvas's rate-limit quota headers.
|
|
3
|
+
*
|
|
4
|
+
* It maps observed quota into an allowed concurrency in `[1, maxConcurrency]`:
|
|
5
|
+
* roughly "how many requests fit in the remaining quota, minus a reserve". The
|
|
6
|
+
* quota bucket is shared per developer key, and each `remaining` reading already
|
|
7
|
+
* reflects the *global* bucket level, so reacting to it implicitly responds to
|
|
8
|
+
* pressure from other processes too (though independent governors can still
|
|
9
|
+
* collectively over-target — cross-process coordination is out of scope).
|
|
10
|
+
*
|
|
11
|
+
* Design points:
|
|
12
|
+
* - **Cold start at 1** and ramp up as ample quota is observed.
|
|
13
|
+
* - **Conservative aggregation**: target off the *minimum* `remaining` and the
|
|
14
|
+
* *maximum* `cost` seen in a recent window, since concurrent readings race.
|
|
15
|
+
* - **Fast down, slow up**: drop to the computed target immediately when quota
|
|
16
|
+
* tightens, but raise the effective target by at most +1 per ramp interval.
|
|
17
|
+
* - **Reset on rate-limit**: a 403 returns the governor to cold start so it
|
|
18
|
+
* re-earns concurrency conservatively.
|
|
19
|
+
*
|
|
20
|
+
* Pure and synchronous — unit-testable by passing explicit `now` values.
|
|
21
|
+
*/
|
|
22
|
+
export class QuotaGovernor {
|
|
23
|
+
maxConcurrency;
|
|
24
|
+
reserveRequests;
|
|
25
|
+
windowMs;
|
|
26
|
+
rampIntervalMs;
|
|
27
|
+
window = [];
|
|
28
|
+
currentTarget = 1;
|
|
29
|
+
lastRaiseAt;
|
|
30
|
+
constructor(maxConcurrency, reserveRequests, windowMs, rampIntervalMs, now = Date.now()) {
|
|
31
|
+
this.maxConcurrency = maxConcurrency;
|
|
32
|
+
this.reserveRequests = reserveRequests;
|
|
33
|
+
this.windowMs = windowMs;
|
|
34
|
+
this.rampIntervalMs = rampIntervalMs;
|
|
35
|
+
this.lastRaiseAt = now;
|
|
36
|
+
}
|
|
37
|
+
/** Record a quota reading from a submission response. */
|
|
38
|
+
record(reading) {
|
|
39
|
+
this.window.push(reading);
|
|
40
|
+
this.reconcile(reading.at);
|
|
41
|
+
}
|
|
42
|
+
/** Return the governor to cold start (concurrency 1, no history). */
|
|
43
|
+
reset(now = Date.now()) {
|
|
44
|
+
this.window = [];
|
|
45
|
+
this.currentTarget = 1;
|
|
46
|
+
this.lastRaiseAt = now;
|
|
47
|
+
}
|
|
48
|
+
/** The currently allowed concurrency, in `[1, maxConcurrency]`. */
|
|
49
|
+
target(now = Date.now()) {
|
|
50
|
+
this.reconcile(now);
|
|
51
|
+
return this.currentTarget;
|
|
52
|
+
}
|
|
53
|
+
reconcile(now) {
|
|
54
|
+
this.prune(now);
|
|
55
|
+
const computed = this.computedTarget();
|
|
56
|
+
if (computed == null) {
|
|
57
|
+
// No usable readings (cold start, or window aged out): hold steady.
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (computed < this.currentTarget) {
|
|
61
|
+
// Fast down: tighten immediately.
|
|
62
|
+
this.currentTarget = computed;
|
|
63
|
+
}
|
|
64
|
+
else if (computed > this.currentTarget && now - this.lastRaiseAt >= this.rampIntervalMs) {
|
|
65
|
+
// Slow up: at most +1 per ramp interval.
|
|
66
|
+
this.currentTarget += 1;
|
|
67
|
+
this.lastRaiseAt = now;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
prune(now) {
|
|
71
|
+
// Readings can arrive slightly out of order under concurrency, so filter the
|
|
72
|
+
// whole (small) window rather than assuming the head is oldest.
|
|
73
|
+
const cutoff = now - this.windowMs;
|
|
74
|
+
this.window = this.window.filter((reading) => reading.at >= cutoff);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The concurrency the current readings support, or `undefined` if we can't yet
|
|
78
|
+
* tell (no readings, or no cost observed). Uses the worst recent `remaining`
|
|
79
|
+
* and the priciest recent `cost` for safety.
|
|
80
|
+
*/
|
|
81
|
+
computedTarget() {
|
|
82
|
+
if (this.window.length === 0) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
let minRemaining = Number.POSITIVE_INFINITY;
|
|
86
|
+
let maxCost = 0;
|
|
87
|
+
for (const reading of this.window) {
|
|
88
|
+
if (reading.remaining < minRemaining) {
|
|
89
|
+
minRemaining = reading.remaining;
|
|
90
|
+
}
|
|
91
|
+
if (reading.cost != null && reading.cost > maxCost) {
|
|
92
|
+
maxCost = reading.cost;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (maxCost <= 0) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const capacity = Math.floor(minRemaining / maxCost) - this.reserveRequests;
|
|
99
|
+
return Math.min(this.maxConcurrency, Math.max(1, capacity));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RegistrationConfigInput, RegistrationEndpoints } from '@modulus-learning/lti';
|
|
2
|
+
/**
|
|
3
|
+
* Canvas registration: the standard Canvas endpoints for an issuer, and the
|
|
4
|
+
* developer-key configuration an administrator pastes into Canvas.
|
|
5
|
+
*/
|
|
6
|
+
export declare const canvasRegistrationEndpoints: (issuer: string) => RegistrationEndpoints;
|
|
7
|
+
export declare const canvasRegistrationConfig: (input: RegistrationConfigInput) => string;
|
|
8
|
+
//# sourceMappingURL=registration.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registration.d.ts","sourceRoot":"","sources":["../src/registration.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAA;AAE3F;;;GAGG;AAEH,eAAO,MAAM,2BAA2B,WAAY,MAAM,KAAG,qBAK3D,CAAA;AAEF,eAAO,MAAM,wBAAwB,UAAW,uBAAuB,KAAG,MAmGzE,CAAA"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas registration: the standard Canvas endpoints for an issuer, and the
|
|
3
|
+
* developer-key configuration an administrator pastes into Canvas.
|
|
4
|
+
*/
|
|
5
|
+
export const canvasRegistrationEndpoints = (issuer) => ({
|
|
6
|
+
authorization_endpoint: new URL('/api/lti/authorize_redirect', issuer).href,
|
|
7
|
+
authorization_server: new URL('/login/oauth2/token', issuer).href,
|
|
8
|
+
token_endpoint: new URL('/login/oauth2/token', issuer).href,
|
|
9
|
+
jwks_uri: new URL('/api/lti/security/jwks', issuer).href,
|
|
10
|
+
});
|
|
11
|
+
export const canvasRegistrationConfig = (input) => {
|
|
12
|
+
const { title = 'Modulus', description = 'Open Educational Learning', tool_id = 'modulus', platform = 'canvas.instructure.com', } = input;
|
|
13
|
+
const baseUrl = new URL(input.baseUrl);
|
|
14
|
+
const config = {
|
|
15
|
+
title,
|
|
16
|
+
description,
|
|
17
|
+
target_link_uri: new URL('/routes/lti/launch', baseUrl).toString(),
|
|
18
|
+
oidc_initiation_url: new URL('/routes/lti/login', baseUrl).toString(),
|
|
19
|
+
public_jwk: input.publicJwk,
|
|
20
|
+
custom_fields: {},
|
|
21
|
+
scopes: [
|
|
22
|
+
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
|
|
23
|
+
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
|
|
24
|
+
'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly',
|
|
25
|
+
'https://purl.imsglobal.org/spec/lti-ags/scope/score',
|
|
26
|
+
// 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly',
|
|
27
|
+
// 'https://canvas.instructure.com/lti/account_lookup/scope/show',
|
|
28
|
+
// 'https://canvas.instructure.com/lti-ags/progress/scope/show',
|
|
29
|
+
// 'https://canvas.instructure.com/lti/page_content/show',
|
|
30
|
+
],
|
|
31
|
+
extensions: [
|
|
32
|
+
{
|
|
33
|
+
domain: input.host,
|
|
34
|
+
tool_id,
|
|
35
|
+
privacy_level: 'public',
|
|
36
|
+
platform,
|
|
37
|
+
settings: {
|
|
38
|
+
placements: [
|
|
39
|
+
{
|
|
40
|
+
text: 'Create a Modulus Assignment',
|
|
41
|
+
placement: 'assignment_selection',
|
|
42
|
+
message_type: 'LtiDeepLinkingRequest',
|
|
43
|
+
custom_fields: {
|
|
44
|
+
context_id: '$Context.id',
|
|
45
|
+
context_title: '$Context.title',
|
|
46
|
+
canvas_course_id: '$Canvas.course.id',
|
|
47
|
+
canvas_course_name: '$Canvas.course.name',
|
|
48
|
+
modulus_launch_type: 'deep-link',
|
|
49
|
+
modulus_deep_link_context: 'assignment',
|
|
50
|
+
com_instructure_context_uuid: '$com.instructure.Context.uuid',
|
|
51
|
+
com_instructure_context_label: '$com.instructure.contextLabel',
|
|
52
|
+
com_instructure_assignment_lti_id: '$com.instructure.Assignment.lti.id',
|
|
53
|
+
},
|
|
54
|
+
target_link_uri: new URL('/lti/deep-link', baseUrl).toString(),
|
|
55
|
+
},
|
|
56
|
+
// {
|
|
57
|
+
// text: 'Link to a Modulus activity',
|
|
58
|
+
// placement: 'link_selection',
|
|
59
|
+
// message_type: 'LtiDeepLinkingRequest',
|
|
60
|
+
// custom_fields: {
|
|
61
|
+
// context_id: '$Context.id',
|
|
62
|
+
// context_title: '$Context.title',
|
|
63
|
+
// canvas_course_id: '$Canvas.course.id',
|
|
64
|
+
// canvas_course_name: '$Canvas.course.name',
|
|
65
|
+
// modulus_launch_type: 'deep-link',
|
|
66
|
+
// modulus_deep_link_context: 'standalone',
|
|
67
|
+
// com_instructure_context_label: '$com.instructure.contextLabel',
|
|
68
|
+
// },
|
|
69
|
+
// target_link_uri: new URL('/lti/deep-link', baseUrl).toString(),
|
|
70
|
+
// },
|
|
71
|
+
// {
|
|
72
|
+
// text: 'Link to a Modulus activity',
|
|
73
|
+
// placement: 'editor_button',
|
|
74
|
+
// message_type: 'LtiDeepLinkingRequest',
|
|
75
|
+
// custom_fields: {
|
|
76
|
+
// context_id: '$Context.id',
|
|
77
|
+
// context_title: '$Context.title',
|
|
78
|
+
// canvas_course_id: '$Canvas.course.id',
|
|
79
|
+
// canvas_course_name: '$Canvas.course.name',
|
|
80
|
+
// modulus_launch_type: 'deep-link',
|
|
81
|
+
// modulus_deep_link_context: 'standalone',
|
|
82
|
+
// com_instructure_context_label: '$com.instructure.contextLabel',
|
|
83
|
+
// },
|
|
84
|
+
// target_link_uri: new URL('/lti/deep-link', baseUrl).toString(),
|
|
85
|
+
// },
|
|
86
|
+
{
|
|
87
|
+
text: 'Modulus Dashboard',
|
|
88
|
+
placement: 'course_navigation',
|
|
89
|
+
message_type: 'LtiResourceLinkRequest',
|
|
90
|
+
windowTarget: '_blank',
|
|
91
|
+
custom_fields: {
|
|
92
|
+
modulus_launch_type: 'view-dashboard',
|
|
93
|
+
},
|
|
94
|
+
target_link_uri: new URL('/dashboard', baseUrl).toString(),
|
|
95
|
+
required_permissions: 'manage_students',
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
return JSON.stringify(config, null, 2);
|
|
103
|
+
};
|
package/dist/roles.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"roles.d.ts","sourceRoot":"","sources":["../src/roles.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,oBAAoB,UAkBhC,CAAA;AAED,eAAO,MAAM,YAAY,aAAc,MAAM,EAAE,KAAG,OAQjD,CAAA"}
|
package/dist/roles.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// LTI roles that should indicate the user is an instructor for our purposes.
|
|
2
|
+
// The LTI spec defines a number of roles without specifying their meaning, so
|
|
3
|
+
// for now this list just includes roles that Canvas appears to use. See:
|
|
4
|
+
// https://www.imsglobal.org/spec/lti/v1p3/#role-vocabularies
|
|
5
|
+
// https://developerdocs.instructure.com/services/canvas/external-tools/file.canvas_roles
|
|
6
|
+
export const INSTRUCTOR_LTI_ROLES = [
|
|
7
|
+
// A system / site admin.
|
|
8
|
+
'http://purl.imsglobal.org/vocab/lis/v2/system/person#SysAdmin',
|
|
9
|
+
// Not used by Canvas, but maybe appropriate for other platforms?
|
|
10
|
+
//'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator',
|
|
11
|
+
// An instructor within the overal institution / organization.
|
|
12
|
+
'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor',
|
|
13
|
+
// An administrator within the overall institution / organization.
|
|
14
|
+
'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator',
|
|
15
|
+
// An instructor within the context (course / assignment) the user launched from.
|
|
16
|
+
'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor',
|
|
17
|
+
// A content developer / designer within the context the user launched from.
|
|
18
|
+
'http://purl.imsglobal.org/vocab/lis/v2/membership#ContentDeveloper',
|
|
19
|
+
];
|
|
20
|
+
export const isInstructor = (ltiRoles) => {
|
|
21
|
+
const ltiRoleSet = new Set(ltiRoles);
|
|
22
|
+
// TODO: Should we also look for the TestUser role? In Canvas, it indicates
|
|
23
|
+
// that a Canvas instructor / admin is using the 'view as student' feature.
|
|
24
|
+
// const isTestUser = ltiRoleSet.has('http://purl.imsglobal.org/vocab/lis/v2/system/person#TestUser')
|
|
25
|
+
return INSTRUCTOR_LTI_ROLES.some((role) => ltiRoleSet.has(role));
|
|
26
|
+
};
|
package/dist/term.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CLAIM_CUSTOM, NormalizedTerm, PlatformMessage, TermInspection } from '@modulus-learning/lti';
|
|
2
|
+
/**
|
|
3
|
+
* Canvas term metadata: the `Canvas.term.*` substitution variables a Modulus
|
|
4
|
+
* resource link requests (see `deep-link-fields.ts`), inspected with a quality
|
|
5
|
+
* per field so a fallback can say why it happened.
|
|
6
|
+
*/
|
|
7
|
+
type CustomFields = PlatformMessage[typeof CLAIM_CUSTOM];
|
|
8
|
+
export declare const inspectCanvasTerm: (custom: CustomFields) => TermInspection;
|
|
9
|
+
export declare const normalizeCanvasTerm: (custom: CustomFields) => NormalizedTerm | undefined;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=term.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"term.d.ts","sourceRoot":"","sources":["../src/term.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,YAAY,EAEZ,cAAc,EACd,eAAe,EACf,cAAc,EACf,MAAM,uBAAuB,CAAA;AAG9B;;;;GAIG;AAEH,KAAK,YAAY,GAAG,eAAe,CAAC,OAAO,YAAY,CAAC,CAAA;AAyCxD,eAAO,MAAM,iBAAiB,WAAY,YAAY,KAAG,cAyBxD,CAAA;AAED,eAAO,MAAM,mBAAmB,WAAY,YAAY,KAAG,cAAc,GAAG,SAC5C,CAAA"}
|
package/dist/term.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const canvasTermDateSchema = z.iso.datetime({ offset: true });
|
|
3
|
+
const inspectCanvasString = (custom, key) => {
|
|
4
|
+
const value = custom[key];
|
|
5
|
+
if (value == null) {
|
|
6
|
+
return { quality: 'missing' };
|
|
7
|
+
}
|
|
8
|
+
if (typeof value !== 'string') {
|
|
9
|
+
return { quality: 'malformed' };
|
|
10
|
+
}
|
|
11
|
+
const normalized = value.trim();
|
|
12
|
+
if (normalized.length === 0) {
|
|
13
|
+
return { quality: 'empty' };
|
|
14
|
+
}
|
|
15
|
+
if (normalized === `$${key}`) {
|
|
16
|
+
return { quality: 'unexpanded' };
|
|
17
|
+
}
|
|
18
|
+
return { value: normalized, quality: 'usable' };
|
|
19
|
+
};
|
|
20
|
+
const inspectCanvasDate = (custom, key) => {
|
|
21
|
+
const inspected = inspectCanvasString(custom, key);
|
|
22
|
+
if (inspected.value == null) {
|
|
23
|
+
return { quality: inspected.quality };
|
|
24
|
+
}
|
|
25
|
+
if (!canvasTermDateSchema.safeParse(inspected.value).success) {
|
|
26
|
+
return { quality: 'malformed' };
|
|
27
|
+
}
|
|
28
|
+
return { value: new Date(inspected.value), quality: 'usable' };
|
|
29
|
+
};
|
|
30
|
+
export const inspectCanvasTerm = (custom) => {
|
|
31
|
+
const externalId = inspectCanvasString(custom, 'Canvas.term.id');
|
|
32
|
+
const name = inspectCanvasString(custom, 'Canvas.term.name');
|
|
33
|
+
const startsAt = inspectCanvasDate(custom, 'Canvas.term.startAt');
|
|
34
|
+
const endsAt = inspectCanvasDate(custom, 'Canvas.term.endAt');
|
|
35
|
+
const quality = {
|
|
36
|
+
term_id: externalId.quality,
|
|
37
|
+
name: name.quality,
|
|
38
|
+
starts_at: startsAt.quality,
|
|
39
|
+
ends_at: endsAt.quality,
|
|
40
|
+
};
|
|
41
|
+
if (externalId.value == null) {
|
|
42
|
+
return { quality };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
term: {
|
|
46
|
+
external_id: externalId.value,
|
|
47
|
+
name: name.value,
|
|
48
|
+
starts_at: startsAt.value,
|
|
49
|
+
ends_at: endsAt.value,
|
|
50
|
+
},
|
|
51
|
+
quality,
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
export const normalizeCanvasTerm = (custom) => inspectCanvasTerm(custom).term;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reading of Canvas's rate-limit quota headers, captured from a submission
|
|
3
|
+
* response. `remaining` is `X-Rate-Limit-Remaining`; `cost` is `X-Request-Cost`
|
|
4
|
+
* (absent on some responses). `at` is when it was observed (ms epoch). Feeds
|
|
5
|
+
* the `QuotaGovernor`; core passes it through as an opaque scheduling signal.
|
|
6
|
+
*/
|
|
7
|
+
export type RateLimitReading = {
|
|
8
|
+
remaining: number;
|
|
9
|
+
cost?: number;
|
|
10
|
+
at: number;
|
|
11
|
+
};
|
|
12
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,EAAE,EAAE,MAAM,CAAA;CACX,CAAA"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modulus-learning/lms-canvas",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.11.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "The Canvas LMS adapter for Modulus",
|
|
6
|
+
"license": "AGPL-3.0-only",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"homepage": "https://github.com/Modulus-Learning/app.modulus-learning.org",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/Modulus-Learning/app.modulus-learning.org/issues"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/Modulus-Learning/app.modulus-learning.org.git",
|
|
15
|
+
"directory": "packages/lms-canvas"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": "^22.18.0 || ^24.11.0 || >=26.0.0"
|
|
19
|
+
},
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modulus-learning/lti": "0.11.1",
|
|
33
|
+
"zod": "^4.6.7"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@biomejs/biome": "^2.6.4",
|
|
37
|
+
"@types/node": "^26.6.6",
|
|
38
|
+
"tsx": "^4.23.15",
|
|
39
|
+
"typescript": "^7.0.2"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"clean": "rm -rf node_modules dist .turbo",
|
|
46
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
47
|
+
"dev": "tsc -p tsconfig.build.json --watch",
|
|
48
|
+
"lint": "biome check --write --diagnostic-level=error",
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"test": "LOG_LEVEL=off tsx --test $(find src -name \"*.test.ts\")"
|
|
51
|
+
}
|
|
6
52
|
}
|
package/README.md
DELETED