brightspace-d2l-mcp 1.0.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/LICENSE +24 -0
- package/README.md +269 -0
- package/build/api/cache.js +49 -0
- package/build/api/client.js +345 -0
- package/build/api/errors.js +48 -0
- package/build/api/index.js +12 -0
- package/build/api/paginate.js +70 -0
- package/build/api/rate-limiter.js +52 -0
- package/build/api/retry.js +39 -0
- package/build/api/types.js +11 -0
- package/build/api/version-discovery.js +43 -0
- package/build/auth/authenticator.js +146 -0
- package/build/auth/browser.js +82 -0
- package/build/auth/index.js +6 -0
- package/build/auth/login.js +227 -0
- package/build/auth/session-store.js +46 -0
- package/build/auth/token-manager.js +150 -0
- package/build/auth/token-mint.js +75 -0
- package/build/cli/clients.js +344 -0
- package/build/cli/main.js +162 -0
- package/build/cli/school.js +74 -0
- package/build/cli/setup.js +189 -0
- package/build/cli/ui.js +39 -0
- package/build/index.js +13 -0
- package/build/server.js +175 -0
- package/build/tools/download-file.js +167 -0
- package/build/tools/get-announcements.js +116 -0
- package/build/tools/get-assignment-files.js +159 -0
- package/build/tools/get-assignments.js +343 -0
- package/build/tools/get-classlist-emails.js +38 -0
- package/build/tools/get-course-content.js +175 -0
- package/build/tools/get-discussions.js +192 -0
- package/build/tools/get-my-courses.js +42 -0
- package/build/tools/get-my-grades.js +93 -0
- package/build/tools/get-roster.js +99 -0
- package/build/tools/get-syllabus.js +154 -0
- package/build/tools/get-todo.js +61 -0
- package/build/tools/get-upcoming-due-dates.js +138 -0
- package/build/tools/index.js +18 -0
- package/build/tools/schemas.js +91 -0
- package/build/tools/submission-status.js +71 -0
- package/build/tools/tool-helpers.js +62 -0
- package/build/types/index.js +2 -0
- package/build/utils/atomic-write.js +66 -0
- package/build/utils/config.js +60 -0
- package/build/utils/course-filter.js +24 -0
- package/build/utils/deep-links.js +13 -0
- package/build/utils/download-errors.js +14 -0
- package/build/utils/download-helpers.js +73 -0
- package/build/utils/errors.js +31 -0
- package/build/utils/file-validator.js +121 -0
- package/build/utils/html-converter.js +20 -0
- package/build/utils/logger.js +69 -0
- package/build/utils/paths.js +15 -0
- package/build/utils/pdf-extractor.js +18 -0
- package/build/utils/version.js +18 -0
- package/build/utils/zip-extract.js +139 -0
- package/package.json +104 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { DEFAULT_CACHE_TTLS } from "./types.js";
|
|
2
|
+
import { TTLCache } from "./cache.js";
|
|
3
|
+
import { TokenBucket } from "./rate-limiter.js";
|
|
4
|
+
import { discoverVersions } from "./version-discovery.js";
|
|
5
|
+
import { ApiError, RateLimitError, NetworkError } from "./errors.js";
|
|
6
|
+
import { withRetry, isRetryableFailure, retryAfterMsFrom } from "./retry.js";
|
|
7
|
+
import { log } from "../utils/logger.js";
|
|
8
|
+
import { LOGIN_COMMAND } from "../utils/paths.js";
|
|
9
|
+
import { packageVersion } from "../utils/version.js";
|
|
10
|
+
function isExpiredSessionRedirect(body, baseUrl) {
|
|
11
|
+
const expiredTarget = (target) => {
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(target.replace(/&/gi, "&").replace(/\\\//g, "/"), baseUrl);
|
|
14
|
+
return url.origin === new URL(baseUrl).origin && url.pathname === "/d2l/login" &&
|
|
15
|
+
url.searchParams.get("sessionExpired") === "1";
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
for (const script of body.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi)) {
|
|
22
|
+
const redirect = /(?:(?:window|document)\s*\.\s*)?location\s*(?:\.\s*(?:replace|assign)\s*\(\s*|(?:\.\s*href\s*)?=\s*)(["'])(.*?)\1/g;
|
|
23
|
+
for (const match of script[1].matchAll(redirect)) {
|
|
24
|
+
if (expiredTarget(match[2]))
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
for (const meta of body.matchAll(/<meta\b[^>]*>/gi)) {
|
|
29
|
+
const attributes = new Map([...meta[0].matchAll(/([\w-]+)\s*=\s*(["'])(.*?)\2/g)].map(match => [match[1].toLowerCase(), match[3]]));
|
|
30
|
+
if (attributes.get("http-equiv")?.toLowerCase() !== "refresh")
|
|
31
|
+
continue;
|
|
32
|
+
const target = attributes.get("content")?.match(/^\s*\d+(?:\.\d+)?\s*;\s*url\s*=\s*(.*?)\s*$/i)?.[1];
|
|
33
|
+
if (target && expiredTarget(target))
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const LP_VERSION = "{lp}";
|
|
39
|
+
const LE_VERSION = "{le}";
|
|
40
|
+
export class D2LApiClient {
|
|
41
|
+
baseUrl;
|
|
42
|
+
tokenManager;
|
|
43
|
+
cache;
|
|
44
|
+
rateLimiter;
|
|
45
|
+
cacheTTLs;
|
|
46
|
+
timeoutMs;
|
|
47
|
+
onAuthExpired;
|
|
48
|
+
retryConfig;
|
|
49
|
+
versions = null;
|
|
50
|
+
versionsInFlight = null;
|
|
51
|
+
constructor(options) {
|
|
52
|
+
// HTTPS-only enforcement, on a parsed URL rather than a string prefix so
|
|
53
|
+
// a malformed base cannot slip through as "not http".
|
|
54
|
+
let parsedBase;
|
|
55
|
+
try {
|
|
56
|
+
parsedBase = new URL(options.baseUrl);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error(`Invalid D2L base URL: ${options.baseUrl}`);
|
|
60
|
+
}
|
|
61
|
+
if (parsedBase.protocol !== "https:") {
|
|
62
|
+
throw new Error("HTTPS is required for D2L API client. HTTP URLs are not allowed for security reasons.");
|
|
63
|
+
}
|
|
64
|
+
// Strip trailing slash from baseUrl
|
|
65
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
66
|
+
this.tokenManager = options.tokenManager;
|
|
67
|
+
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
68
|
+
this.onAuthExpired = options.onAuthExpired;
|
|
69
|
+
this.retryConfig = options.retry ?? {};
|
|
70
|
+
// Merge user-provided TTLs with defaults
|
|
71
|
+
this.cacheTTLs = { ...DEFAULT_CACHE_TTLS, ...options.cacheTTLs };
|
|
72
|
+
// Initialize cache and rate limiter
|
|
73
|
+
this.cache = new TTLCache();
|
|
74
|
+
const rateLimitConfig = options.rateLimitConfig ?? {
|
|
75
|
+
capacity: 10,
|
|
76
|
+
refillRate: 3,
|
|
77
|
+
};
|
|
78
|
+
this.rateLimiter = new TokenBucket(rateLimitConfig.capacity, rateLimitConfig.refillRate);
|
|
79
|
+
log("DEBUG", `D2LApiClient initialized for ${this.baseUrl}`);
|
|
80
|
+
}
|
|
81
|
+
async ensureVersions() {
|
|
82
|
+
if (this.versions)
|
|
83
|
+
return this.versions;
|
|
84
|
+
if (!this.versionsInFlight) {
|
|
85
|
+
const flow = discoverVersions(this.baseUrl, this.timeoutMs)
|
|
86
|
+
.then(versions => {
|
|
87
|
+
this.versions = versions;
|
|
88
|
+
log("INFO", `D2L API versions discovered: LP ${versions.lp}, LE ${versions.le}`);
|
|
89
|
+
return versions;
|
|
90
|
+
})
|
|
91
|
+
.finally(() => {
|
|
92
|
+
if (this.versionsInFlight === flow)
|
|
93
|
+
this.versionsInFlight = null;
|
|
94
|
+
});
|
|
95
|
+
this.versionsInFlight = flow;
|
|
96
|
+
}
|
|
97
|
+
return this.versionsInFlight;
|
|
98
|
+
}
|
|
99
|
+
async initialize() {
|
|
100
|
+
await this.ensureVersions();
|
|
101
|
+
}
|
|
102
|
+
get apiVersions() {
|
|
103
|
+
if (!this.versions) {
|
|
104
|
+
throw new Error("API versions have not been discovered yet. They are fetched on the first request.");
|
|
105
|
+
}
|
|
106
|
+
return this.versions;
|
|
107
|
+
}
|
|
108
|
+
async resolvePath(path) {
|
|
109
|
+
if (!path.includes(LP_VERSION) && !path.includes(LE_VERSION))
|
|
110
|
+
return path;
|
|
111
|
+
const { lp, le } = await this.ensureVersions();
|
|
112
|
+
return path.split(LP_VERSION).join(lp).split(LE_VERSION).join(le);
|
|
113
|
+
}
|
|
114
|
+
async get(path, options) {
|
|
115
|
+
// Checked before the path is resolved, and keyed by the path as the caller
|
|
116
|
+
// wrote it, so a cached read needs neither version discovery nor a token.
|
|
117
|
+
if (options?.ttl && this.cache.has(path)) {
|
|
118
|
+
log("DEBUG", `Cache hit: ${path}`);
|
|
119
|
+
return this.cache.get(path);
|
|
120
|
+
}
|
|
121
|
+
const resolved = await this.resolvePath(path);
|
|
122
|
+
const data = await this.withAuthentication(resolved, token => this.makeRequest(resolved, token));
|
|
123
|
+
if (options?.ttl) {
|
|
124
|
+
this.cache.set(path, data, options.ttl);
|
|
125
|
+
log("DEBUG", `Cached response for ${path} (TTL: ${options.ttl}ms)`);
|
|
126
|
+
}
|
|
127
|
+
return data;
|
|
128
|
+
}
|
|
129
|
+
async getRaw(path) {
|
|
130
|
+
const resolved = await this.resolvePath(path);
|
|
131
|
+
return this.withAuthentication(resolved, token => this.makeRawRequest(resolved, token));
|
|
132
|
+
}
|
|
133
|
+
async withAuthentication(path, request) {
|
|
134
|
+
let token = await this.tokenManager.getToken();
|
|
135
|
+
let authenticated = false;
|
|
136
|
+
if (!token) {
|
|
137
|
+
token = await this.tryAutoReauth(path);
|
|
138
|
+
authenticated = true;
|
|
139
|
+
}
|
|
140
|
+
const send = (current) => this.retrying(() => this.throttled(() => request(current)));
|
|
141
|
+
try {
|
|
142
|
+
return await send(token);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (!(error instanceof ApiError) || error.status !== 401)
|
|
146
|
+
throw error;
|
|
147
|
+
if (authenticated)
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
const fresh = await this.tokenManager.getToken(token.accessToken);
|
|
151
|
+
if (fresh && fresh.accessToken !== token.accessToken) {
|
|
152
|
+
try {
|
|
153
|
+
return await send(fresh);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (!(error instanceof ApiError) || error.status !== 401)
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const loggedIn = await this.tryAutoReauth(path, fresh?.accessToken ?? token.accessToken);
|
|
161
|
+
return send(loggedIn);
|
|
162
|
+
}
|
|
163
|
+
async throttled(fn) {
|
|
164
|
+
await this.rateLimiter.consume();
|
|
165
|
+
return fn();
|
|
166
|
+
}
|
|
167
|
+
retrying(fn) {
|
|
168
|
+
return withRetry(fn, {
|
|
169
|
+
...this.retryConfig,
|
|
170
|
+
shouldRetry: isRetryableFailure,
|
|
171
|
+
retryAfterMs: retryAfterMsFrom,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async tryAutoReauth(path, rejectedAccessToken) {
|
|
175
|
+
if (this.onAuthExpired) {
|
|
176
|
+
log("INFO", "Attempting auto-reauthentication...");
|
|
177
|
+
const success = await this.onAuthExpired();
|
|
178
|
+
if (success) {
|
|
179
|
+
const freshToken = await this.tokenManager.getToken(rejectedAccessToken);
|
|
180
|
+
if (freshToken) {
|
|
181
|
+
log("INFO", "Auto-reauthentication succeeded, retrying request");
|
|
182
|
+
return freshToken;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
log("WARN", "Auto-reauthentication did not produce a valid token");
|
|
186
|
+
}
|
|
187
|
+
throw new ApiError(401, path, `Session expired. Please re-authenticate via ${LOGIN_COMMAND}.`);
|
|
188
|
+
}
|
|
189
|
+
async makeRequest(path, token) {
|
|
190
|
+
const url = `${this.baseUrl}${path}`;
|
|
191
|
+
const headers = this.buildAuthHeaders(token);
|
|
192
|
+
try {
|
|
193
|
+
log("DEBUG", `Requesting GET ${path}`);
|
|
194
|
+
const response = await fetch(url, {
|
|
195
|
+
method: "GET",
|
|
196
|
+
headers,
|
|
197
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
198
|
+
});
|
|
199
|
+
// Preserve cookie material: a 401 only rejects this access token.
|
|
200
|
+
if (response.status === 401) {
|
|
201
|
+
throw new ApiError(401, path, "Brightspace rejected the access token.");
|
|
202
|
+
}
|
|
203
|
+
// Handle 429 rate limiting
|
|
204
|
+
if (response.status === 429) {
|
|
205
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
206
|
+
const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : undefined;
|
|
207
|
+
throw new RateLimitError(path, retryAfterSeconds);
|
|
208
|
+
}
|
|
209
|
+
// Handle 403 (common for past-semester courses)
|
|
210
|
+
if (response.status === 403) {
|
|
211
|
+
const responseText = await response.text();
|
|
212
|
+
throw new ApiError(403, path, responseText);
|
|
213
|
+
}
|
|
214
|
+
// Handle other non-OK responses
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
const responseText = await response.text();
|
|
217
|
+
throw new ApiError(response.status, path, responseText);
|
|
218
|
+
}
|
|
219
|
+
const responseBody = await response.text();
|
|
220
|
+
let data;
|
|
221
|
+
try {
|
|
222
|
+
data = JSON.parse(responseBody);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Only now consider the stub: a real payload that merely mentions the
|
|
226
|
+
// marker still parses, so it can never be misread as a dead session.
|
|
227
|
+
if (isExpiredSessionRedirect(responseBody, this.baseUrl)) {
|
|
228
|
+
log("DEBUG", "Response carried the session-expired stub, treating it as a 401");
|
|
229
|
+
throw new ApiError(401, path, `Session expired. Please re-authenticate via ${LOGIN_COMMAND}.`);
|
|
230
|
+
}
|
|
231
|
+
throw new ApiError(response.status, path, `Expected JSON from ${path} but the body did not parse`);
|
|
232
|
+
}
|
|
233
|
+
return data;
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
// Re-throw our own errors
|
|
237
|
+
if (error instanceof ApiError ||
|
|
238
|
+
error instanceof RateLimitError ||
|
|
239
|
+
error instanceof NetworkError) {
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
// Wrap network/fetch errors
|
|
243
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
244
|
+
throw new NetworkError(`Request to ${path} failed: ${message}`, error instanceof Error ? error : undefined);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async makeRawRequest(path, token) {
|
|
248
|
+
const url = `${this.baseUrl}${path}`;
|
|
249
|
+
const headers = this.buildAuthHeaders(token);
|
|
250
|
+
try {
|
|
251
|
+
log("DEBUG", `Requesting GET ${path} (raw)`);
|
|
252
|
+
const response = await fetch(url, {
|
|
253
|
+
method: "GET",
|
|
254
|
+
headers,
|
|
255
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
256
|
+
});
|
|
257
|
+
// Preserve cookie material for the shared HTTP refresh path.
|
|
258
|
+
if (response.status === 401) {
|
|
259
|
+
throw new ApiError(401, path, "Brightspace rejected the access token.");
|
|
260
|
+
}
|
|
261
|
+
// Handle 429 rate limiting
|
|
262
|
+
if (response.status === 429) {
|
|
263
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
264
|
+
const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : undefined;
|
|
265
|
+
throw new RateLimitError(path, retryAfterSeconds);
|
|
266
|
+
}
|
|
267
|
+
// Handle 403 (common for past-semester courses or no access)
|
|
268
|
+
if (response.status === 403) {
|
|
269
|
+
const responseText = await response.text();
|
|
270
|
+
throw new ApiError(403, path, responseText);
|
|
271
|
+
}
|
|
272
|
+
// Handle 404 (file not found)
|
|
273
|
+
if (response.status === 404) {
|
|
274
|
+
throw new ApiError(404, path, "File not found");
|
|
275
|
+
}
|
|
276
|
+
// Handle other non-OK responses
|
|
277
|
+
if (!response.ok) {
|
|
278
|
+
const responseText = await response.text();
|
|
279
|
+
throw new ApiError(response.status, path, responseText);
|
|
280
|
+
}
|
|
281
|
+
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
|
|
282
|
+
if (contentType.startsWith("text/html")) {
|
|
283
|
+
const body = await response.text();
|
|
284
|
+
if (isExpiredSessionRedirect(body, this.baseUrl)) {
|
|
285
|
+
log("DEBUG", "File download answered with the session-expired stub, treating it as a 401");
|
|
286
|
+
throw new ApiError(401, path, `Session expired. Please re-authenticate via ${LOGIN_COMMAND}.`);
|
|
287
|
+
}
|
|
288
|
+
// A legitimate HTML page: hand back an equivalent response with the
|
|
289
|
+
// body we already consumed.
|
|
290
|
+
return new Response(body, {
|
|
291
|
+
status: response.status,
|
|
292
|
+
statusText: response.statusText,
|
|
293
|
+
headers: response.headers,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
// Return raw response for caller to process
|
|
297
|
+
return response;
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
// Re-throw our own errors
|
|
301
|
+
if (error instanceof ApiError ||
|
|
302
|
+
error instanceof RateLimitError ||
|
|
303
|
+
error instanceof NetworkError) {
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
// Wrap network/fetch errors
|
|
307
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
308
|
+
throw new NetworkError(`Request to ${path} failed: ${message}`, error instanceof Error ? error : undefined);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
buildAuthHeaders(token) {
|
|
312
|
+
const headers = {
|
|
313
|
+
"User-Agent": `brightspace-d2l-mcp/${packageVersion()}`,
|
|
314
|
+
};
|
|
315
|
+
// Auto-detect cookie vs Bearer auth based on "cookie:" prefix
|
|
316
|
+
if (token.accessToken.startsWith("cookie:")) {
|
|
317
|
+
// Cookie-based auth: strip prefix and set Cookie header
|
|
318
|
+
headers["Cookie"] = token.accessToken.substring(7);
|
|
319
|
+
log("DEBUG", "Using cookie-based authentication");
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
// Bearer token auth
|
|
323
|
+
headers["Authorization"] = `Bearer ${token.accessToken}`;
|
|
324
|
+
log("DEBUG", "Using Bearer token authentication");
|
|
325
|
+
}
|
|
326
|
+
return headers;
|
|
327
|
+
}
|
|
328
|
+
lp(path) {
|
|
329
|
+
return `/d2l/api/lp/${LP_VERSION}${path}`;
|
|
330
|
+
}
|
|
331
|
+
le(orgUnitId, path) {
|
|
332
|
+
return `/d2l/api/le/${LE_VERSION}/${orgUnitId}${path}`;
|
|
333
|
+
}
|
|
334
|
+
leGlobal(path) {
|
|
335
|
+
return `/d2l/api/le/${LE_VERSION}${path}`;
|
|
336
|
+
}
|
|
337
|
+
clearCache() {
|
|
338
|
+
this.cache.clear();
|
|
339
|
+
log("DEBUG", "Cache cleared");
|
|
340
|
+
}
|
|
341
|
+
get cacheSize() {
|
|
342
|
+
return this.cache.size;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AuthError } from "../utils/errors.js";
|
|
2
|
+
// Base class for all HTTP API errors
|
|
3
|
+
export class ApiError extends AuthError {
|
|
4
|
+
status;
|
|
5
|
+
endpoint;
|
|
6
|
+
responseBody;
|
|
7
|
+
constructor(status, endpoint, message, responseBody, cause) {
|
|
8
|
+
super(`[PBMCP-2001] API error (${status}) at ${endpoint}: ${message}`, cause);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.endpoint = endpoint;
|
|
11
|
+
this.responseBody = responseBody;
|
|
12
|
+
this.name = "ApiError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// Generic HTTP error (non-401/429) - semantic alias for ApiError
|
|
16
|
+
export class HttpError extends ApiError {
|
|
17
|
+
constructor(status, endpoint, message, responseBody, cause) {
|
|
18
|
+
super(status, endpoint, message, responseBody, cause);
|
|
19
|
+
this.name = "HttpError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Rate limit error (429 Too Many Requests)
|
|
23
|
+
export class RateLimitError extends ApiError {
|
|
24
|
+
retryAfter;
|
|
25
|
+
constructor(endpoint, retryAfter) {
|
|
26
|
+
const message = retryAfter
|
|
27
|
+
? `[PBMCP-2002] Rate limited, retry after ${retryAfter}s`
|
|
28
|
+
: "[PBMCP-2002] Rate limited";
|
|
29
|
+
super(429, endpoint, message);
|
|
30
|
+
this.retryAfter = retryAfter;
|
|
31
|
+
this.name = "RateLimitError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Network-level error (no HTTP status code)
|
|
35
|
+
// For fetch failures, timeouts, DNS errors
|
|
36
|
+
export class NetworkError extends AuthError {
|
|
37
|
+
constructor(message, cause) {
|
|
38
|
+
super(`[PBMCP-2003] Network error: ${message}`, cause);
|
|
39
|
+
this.name = "NetworkError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export class TokenRefreshError extends NetworkError {
|
|
43
|
+
constructor(detail, cause) {
|
|
44
|
+
super(`Token refresh is temporarily unavailable: ${detail}. Your saved session was retained. Try again later.`, cause);
|
|
45
|
+
this.name = "TokenRefreshError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// D2L API client and infrastructure - Phase 2 public exports
|
|
2
|
+
// Main client
|
|
3
|
+
export { D2LApiClient } from "./client.js";
|
|
4
|
+
// Version discovery
|
|
5
|
+
export { discoverVersions } from "./version-discovery.js";
|
|
6
|
+
// Cache and rate limiting
|
|
7
|
+
export { TTLCache } from "./cache.js";
|
|
8
|
+
export { TokenBucket } from "./rate-limiter.js";
|
|
9
|
+
// Errors
|
|
10
|
+
export { ApiError, RateLimitError, NetworkError } from "./errors.js";
|
|
11
|
+
export { DEFAULT_CACHE_TTLS } from "./types.js";
|
|
12
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { log } from "../utils/logger.js";
|
|
2
|
+
const MAX_PAGES = 200;
|
|
3
|
+
function withBookmark(firstPath, bookmark) {
|
|
4
|
+
const separator = firstPath.includes("?") ? "&" : "?";
|
|
5
|
+
return `${firstPath}${separator}bookmark=${encodeURIComponent(bookmark)}`;
|
|
6
|
+
}
|
|
7
|
+
export async function fetchAllItems(apiClient, firstPath, options) {
|
|
8
|
+
const items = [];
|
|
9
|
+
const seen = new Set();
|
|
10
|
+
let path = firstPath;
|
|
11
|
+
let pages = 0;
|
|
12
|
+
while (path !== null) {
|
|
13
|
+
if (pages >= MAX_PAGES) {
|
|
14
|
+
log("WARN", `Pagination stopped at the ${MAX_PAGES} page ceiling`, { firstPath });
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
const response = await apiClient.get(path, options);
|
|
18
|
+
pages += 1;
|
|
19
|
+
items.push(...(response?.Items ?? []));
|
|
20
|
+
const paging = response?.PagingInfo;
|
|
21
|
+
const bookmark = paging?.Bookmark;
|
|
22
|
+
if (!paging?.HasMoreItems || !bookmark)
|
|
23
|
+
break;
|
|
24
|
+
if (seen.has(bookmark)) {
|
|
25
|
+
log("WARN", "Pagination stopped: the server repeated a bookmark", { firstPath });
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
seen.add(bookmark);
|
|
29
|
+
path = withBookmark(firstPath, bookmark);
|
|
30
|
+
}
|
|
31
|
+
return items;
|
|
32
|
+
}
|
|
33
|
+
export async function fetchAllObjects(apiClient, firstPath, options) {
|
|
34
|
+
const objects = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
let path = firstPath;
|
|
37
|
+
let pages = 0;
|
|
38
|
+
while (path !== null) {
|
|
39
|
+
if (pages >= MAX_PAGES) {
|
|
40
|
+
log("WARN", `Pagination stopped at the ${MAX_PAGES} page ceiling`, { firstPath });
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
const response = await apiClient.get(path, options);
|
|
44
|
+
pages += 1;
|
|
45
|
+
objects.push(...(response?.Objects ?? []));
|
|
46
|
+
const next = response?.Next;
|
|
47
|
+
if (!next)
|
|
48
|
+
break;
|
|
49
|
+
if (seen.has(next)) {
|
|
50
|
+
log("WARN", "Pagination stopped: the server repeated a next page", { firstPath });
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
seen.add(next);
|
|
54
|
+
path = nextPath(firstPath, next);
|
|
55
|
+
}
|
|
56
|
+
return objects;
|
|
57
|
+
}
|
|
58
|
+
function nextPath(firstPath, next) {
|
|
59
|
+
if (/^https?:\/\//i.test(next)) {
|
|
60
|
+
const url = new URL(next);
|
|
61
|
+
return `${url.pathname}${url.search}`;
|
|
62
|
+
}
|
|
63
|
+
// A Next that begins with a slash is already a path, not a bookmark.
|
|
64
|
+
// Appending it as one would ask for a page that does not exist.
|
|
65
|
+
if (next.startsWith("/")) {
|
|
66
|
+
return next;
|
|
67
|
+
}
|
|
68
|
+
return withBookmark(firstPath, next);
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=paginate.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Token bucket rate limiter - allows bursts up to capacity
|
|
2
|
+
// Conservative defaults: capacity 10, refill 3/sec
|
|
3
|
+
export class TokenBucket {
|
|
4
|
+
tokens;
|
|
5
|
+
lastRefill;
|
|
6
|
+
capacity;
|
|
7
|
+
refillRate; // tokens per second
|
|
8
|
+
constructor(capacity, refillRate) {
|
|
9
|
+
this.capacity = capacity;
|
|
10
|
+
this.refillRate = refillRate;
|
|
11
|
+
this.tokens = capacity; // Start with full bucket
|
|
12
|
+
this.lastRefill = Date.now();
|
|
13
|
+
}
|
|
14
|
+
refill() {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
const elapsedMs = now - this.lastRefill;
|
|
17
|
+
const elapsedSeconds = elapsedMs / 1000;
|
|
18
|
+
// Add tokens based on elapsed time
|
|
19
|
+
const tokensToAdd = elapsedSeconds * this.refillRate;
|
|
20
|
+
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
|
|
21
|
+
this.lastRefill = now;
|
|
22
|
+
}
|
|
23
|
+
async consume(count = 1) {
|
|
24
|
+
this.refill();
|
|
25
|
+
if (this.tokens >= count) {
|
|
26
|
+
// Enough tokens available - consume immediately
|
|
27
|
+
this.tokens -= count;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Not enough tokens - calculate wait time
|
|
31
|
+
const tokensNeeded = count - this.tokens;
|
|
32
|
+
const waitTimeMs = (tokensNeeded / this.refillRate) * 1000;
|
|
33
|
+
// Wait for tokens to refill
|
|
34
|
+
await new Promise((resolve) => setTimeout(resolve, waitTimeMs));
|
|
35
|
+
// Refill and consume
|
|
36
|
+
this.refill();
|
|
37
|
+
this.tokens -= count;
|
|
38
|
+
}
|
|
39
|
+
tryConsume(count = 1) {
|
|
40
|
+
this.refill();
|
|
41
|
+
if (this.tokens >= count) {
|
|
42
|
+
this.tokens -= count;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
get availableTokens() {
|
|
48
|
+
this.refill();
|
|
49
|
+
return this.tokens;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=rate-limiter.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ApiError, NetworkError, RateLimitError } from "./errors.js";
|
|
2
|
+
const JITTER_FRACTION = 0.3;
|
|
3
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
export async function withRetry(fn, options) {
|
|
5
|
+
const { maxAttempts = 3, initialMs = 250, maxMs = 5000, sleep = defaultSleep, jitter = Math.random, shouldRetry, retryAfterMs, } = options;
|
|
6
|
+
for (let attempt = 1;; attempt++) {
|
|
7
|
+
try {
|
|
8
|
+
return await fn();
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (attempt >= maxAttempts || !shouldRetry(error))
|
|
12
|
+
throw error;
|
|
13
|
+
const requested = retryAfterMs?.(error);
|
|
14
|
+
let delay;
|
|
15
|
+
if (requested !== undefined) {
|
|
16
|
+
delay = requested;
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
const base = Math.min(initialMs * 2 ** (attempt - 1), maxMs);
|
|
20
|
+
delay = Math.round(base + base * JITTER_FRACTION * jitter());
|
|
21
|
+
}
|
|
22
|
+
await sleep(delay);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function isRetryableFailure(error) {
|
|
27
|
+
if (error instanceof RateLimitError)
|
|
28
|
+
return true;
|
|
29
|
+
if (error instanceof ApiError)
|
|
30
|
+
return error.status >= 500 && error.status <= 599;
|
|
31
|
+
return error instanceof NetworkError;
|
|
32
|
+
}
|
|
33
|
+
export function retryAfterMsFrom(error) {
|
|
34
|
+
if (error instanceof RateLimitError && typeof error.retryAfter === "number" && error.retryAfter > 0) {
|
|
35
|
+
return error.retryAfter * 1000;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=retry.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Default TTL values per user decision
|
|
2
|
+
export const DEFAULT_CACHE_TTLS = {
|
|
3
|
+
enrollments: 3_600_000,
|
|
4
|
+
courseContent: 1_800_000,
|
|
5
|
+
announcements: 300_000,
|
|
6
|
+
grades: 120_000,
|
|
7
|
+
assignments: 600_000,
|
|
8
|
+
roster: 3_600_000,
|
|
9
|
+
profile: 3_600_000,
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { NetworkError } from "./errors.js";
|
|
2
|
+
import { log } from "../utils/logger.js";
|
|
3
|
+
export async function discoverVersions(baseUrl, timeoutMs = 15000) {
|
|
4
|
+
const url = `${baseUrl}/d2l/api/versions/`;
|
|
5
|
+
try {
|
|
6
|
+
log("DEBUG", `Discovering API versions from ${url}`);
|
|
7
|
+
const response = await fetch(url, {
|
|
8
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
9
|
+
headers: {
|
|
10
|
+
// Browser-like User-Agent for version discovery
|
|
11
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
throw new NetworkError(`Version discovery failed with status ${response.status}`);
|
|
16
|
+
}
|
|
17
|
+
const versions = await response.json();
|
|
18
|
+
// Find LP (Learning Platform) and LE (Learning Environment) versions
|
|
19
|
+
const lpEntry = versions.find(v => v.ProductCode === "lp");
|
|
20
|
+
const leEntry = versions.find(v => v.ProductCode === "le");
|
|
21
|
+
if (!lpEntry) {
|
|
22
|
+
throw new NetworkError("LP (Learning Platform) version not found in /d2l/api/versions/ response");
|
|
23
|
+
}
|
|
24
|
+
if (!leEntry) {
|
|
25
|
+
throw new NetworkError("LE (Learning Environment) version not found in /d2l/api/versions/ response");
|
|
26
|
+
}
|
|
27
|
+
const result = {
|
|
28
|
+
lp: lpEntry.LatestVersion,
|
|
29
|
+
le: leEntry.LatestVersion,
|
|
30
|
+
};
|
|
31
|
+
log("INFO", `Discovered API versions: LP ${result.lp}, LE ${result.le}`);
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error instanceof NetworkError) {
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
// Wrap other errors (timeout, network failures, JSON parse errors)
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
40
|
+
throw new NetworkError(`Failed to discover API versions: ${message}`, error instanceof Error ? error : undefined);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=version-discovery.js.map
|