@timeback/sdk 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/adapters/react/hooks/types.d.ts +15 -0
- package/dist/client/adapters/react/hooks/types.d.ts.map +1 -0
- package/dist/client/adapters/react/hooks/useTimebackVerification.d.ts +18 -0
- package/dist/client/adapters/react/hooks/useTimebackVerification.d.ts.map +1 -0
- package/dist/client/adapters/react/index.d.ts +2 -0
- package/dist/client/adapters/react/index.d.ts.map +1 -1
- package/dist/client/adapters/react/index.js +139 -9
- package/dist/client/auth/bearer.d.ts +17 -0
- package/dist/client/auth/bearer.d.ts.map +1 -0
- package/dist/client/auth/index.d.ts +3 -0
- package/dist/client/auth/index.d.ts.map +1 -0
- package/dist/client/auth/types.d.ts +39 -0
- package/dist/client/auth/types.d.ts.map +1 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/lib/fetch.d.ts +19 -0
- package/dist/client/lib/fetch.d.ts.map +1 -0
- package/dist/client/namespaces/user.d.ts +25 -2
- package/dist/client/namespaces/user.d.ts.map +1 -1
- package/dist/client/timeback-client.class.d.ts +15 -0
- package/dist/client/timeback-client.class.d.ts.map +1 -1
- package/dist/client/timeback-client.d.ts +3 -0
- package/dist/client/timeback-client.d.ts.map +1 -1
- package/dist/client.d.ts +2 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +69 -6
- package/dist/edge.js +85291 -169
- package/dist/identity.js +85186 -74
- package/dist/index.js +773 -493
- package/dist/server/adapters/express.d.ts.map +1 -1
- package/dist/server/adapters/express.js +169 -193
- package/dist/server/adapters/native.d.ts.map +1 -1
- package/dist/server/adapters/native.js +32 -1
- package/dist/server/adapters/nextjs.js +32 -1
- package/dist/server/adapters/nuxt.d.ts.map +1 -1
- package/dist/server/adapters/nuxt.js +173 -193
- package/dist/server/adapters/solid-start.d.ts.map +1 -1
- package/dist/server/adapters/solid-start.js +173 -193
- package/dist/server/adapters/svelte-kit.d.ts.map +1 -1
- package/dist/server/adapters/svelte-kit.js +37 -1
- package/dist/server/adapters/tanstack-start.d.ts.map +1 -1
- package/dist/server/adapters/tanstack-start.js +168 -193
- package/dist/server/adapters/utils.d.ts +1 -1
- package/dist/server/adapters/utils.d.ts.map +1 -1
- package/dist/server/{lib/build-activity-events.d.ts → handlers/activity/caliper.d.ts} +29 -4
- package/dist/server/handlers/activity/caliper.d.ts.map +1 -0
- package/dist/server/handlers/activity/gradebook.d.ts +56 -0
- package/dist/server/handlers/activity/gradebook.d.ts.map +1 -0
- package/dist/server/handlers/activity/handler.d.ts +15 -0
- package/dist/server/handlers/activity/handler.d.ts.map +1 -0
- package/dist/server/handlers/activity/index.d.ts +9 -0
- package/dist/server/handlers/activity/index.d.ts.map +1 -0
- package/dist/server/handlers/activity/resolve.d.ts +39 -0
- package/dist/server/handlers/activity/resolve.d.ts.map +1 -0
- package/dist/server/handlers/activity/schema.d.ts +52 -0
- package/dist/server/handlers/activity/schema.d.ts.map +1 -0
- package/dist/server/handlers/activity/types.d.ts +52 -0
- package/dist/server/handlers/activity/types.d.ts.map +1 -0
- package/dist/server/handlers/identity/handler.d.ts +14 -0
- package/dist/server/handlers/identity/handler.d.ts.map +1 -0
- package/dist/server/handlers/identity/index.d.ts +8 -0
- package/dist/server/handlers/identity/index.d.ts.map +1 -0
- package/dist/server/handlers/identity/oidc.d.ts +43 -0
- package/dist/server/handlers/identity/oidc.d.ts.map +1 -0
- package/dist/server/handlers/identity/types.d.ts +24 -0
- package/dist/server/handlers/identity/types.d.ts.map +1 -0
- package/dist/server/handlers/identity-only/handler.d.ts +15 -0
- package/dist/server/handlers/identity-only/handler.d.ts.map +1 -0
- package/dist/server/handlers/identity-only/index.d.ts +8 -0
- package/dist/server/handlers/identity-only/index.d.ts.map +1 -0
- package/dist/server/handlers/identity-only/oidc.d.ts +26 -0
- package/dist/server/handlers/identity-only/oidc.d.ts.map +1 -0
- package/dist/server/handlers/identity-only/types.d.ts +19 -0
- package/dist/server/handlers/identity-only/types.d.ts.map +1 -0
- package/dist/server/handlers/index.d.ts +5 -2
- package/dist/server/handlers/index.d.ts.map +1 -1
- package/dist/server/{lib/build-user-profile.d.ts → handlers/user/enrollments.d.ts} +7 -2
- package/dist/server/handlers/user/enrollments.d.ts.map +1 -0
- package/dist/server/handlers/user/handler.d.ts +17 -0
- package/dist/server/handlers/user/handler.d.ts.map +1 -0
- package/dist/server/handlers/user/index.d.ts +10 -0
- package/dist/server/handlers/user/index.d.ts.map +1 -0
- package/dist/server/handlers/user/profile.d.ts +22 -0
- package/dist/server/handlers/user/profile.d.ts.map +1 -0
- package/dist/server/handlers/user/types.d.ts +35 -0
- package/dist/server/handlers/user/types.d.ts.map +1 -0
- package/dist/server/handlers/user/verify.d.ts +25 -0
- package/dist/server/handlers/user/verify.d.ts.map +1 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/lib/index.d.ts +4 -5
- package/dist/server/lib/index.d.ts.map +1 -1
- package/dist/server/lib/resolve.d.ts +4 -42
- package/dist/server/lib/resolve.d.ts.map +1 -1
- package/dist/server/lib/sso.d.ts +86 -0
- package/dist/server/lib/sso.d.ts.map +1 -0
- package/dist/server/lib/utils.d.ts +32 -1
- package/dist/server/lib/utils.d.ts.map +1 -1
- package/dist/server/timeback-identity.d.ts.map +1 -1
- package/dist/server/timeback.d.ts.map +1 -1
- package/dist/server/types.d.ts +16 -9
- package/dist/server/types.d.ts.map +1 -1
- package/dist/shared/constants.d.ts +1 -0
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/types.d.ts +15 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/package.json +6 -2
- package/dist/server/handlers/activity.d.ts +0 -25
- package/dist/server/handlers/activity.d.ts.map +0 -1
- package/dist/server/handlers/identity-full.d.ts +0 -28
- package/dist/server/handlers/identity-full.d.ts.map +0 -1
- package/dist/server/handlers/identity-only.d.ts +0 -22
- package/dist/server/handlers/identity-only.d.ts.map +0 -1
- package/dist/server/handlers/user.d.ts +0 -31
- package/dist/server/handlers/user.d.ts.map +0 -1
- package/dist/server/lib/build-activity-events.d.ts.map +0 -1
- package/dist/server/lib/build-user-profile.d.ts.map +0 -1
package/dist/index.js
CHANGED
|
@@ -15297,14 +15297,20 @@ var TimebackConfig = exports_external.object({
|
|
|
15297
15297
|
message: "Duplicate courseCode found; each must be unique",
|
|
15298
15298
|
path: ["courses"]
|
|
15299
15299
|
}).refine((config2) => {
|
|
15300
|
-
return config2.courses.every((c) =>
|
|
15301
|
-
|
|
15302
|
-
|
|
15303
|
-
|
|
15304
|
-
|
|
15305
|
-
|
|
15300
|
+
return config2.courses.every((c) => {
|
|
15301
|
+
if (c.sensor !== undefined || config2.sensor !== undefined) {
|
|
15302
|
+
return true;
|
|
15303
|
+
}
|
|
15304
|
+
const launchUrls = [
|
|
15305
|
+
c.launchUrl,
|
|
15306
|
+
config2.launchUrl,
|
|
15307
|
+
c.overrides?.staging?.launchUrl,
|
|
15308
|
+
c.overrides?.production?.launchUrl
|
|
15309
|
+
].filter(Boolean);
|
|
15310
|
+
return launchUrls.length > 0;
|
|
15311
|
+
});
|
|
15306
15312
|
}, {
|
|
15307
|
-
message: "Each course must have an effective
|
|
15313
|
+
message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
|
|
15308
15314
|
path: ["courses"]
|
|
15309
15315
|
});
|
|
15310
15316
|
var EdubridgeDateString = exports_external.union([IsoDateString, IsoDateTimeString]);
|
|
@@ -31700,14 +31706,20 @@ var TimebackConfig2 = exports_external2.object({
|
|
|
31700
31706
|
message: "Duplicate courseCode found; each must be unique",
|
|
31701
31707
|
path: ["courses"]
|
|
31702
31708
|
}).refine((config22) => {
|
|
31703
|
-
return config22.courses.every((c) =>
|
|
31704
|
-
|
|
31705
|
-
|
|
31706
|
-
|
|
31707
|
-
|
|
31708
|
-
|
|
31709
|
+
return config22.courses.every((c) => {
|
|
31710
|
+
if (c.sensor !== undefined || config22.sensor !== undefined) {
|
|
31711
|
+
return true;
|
|
31712
|
+
}
|
|
31713
|
+
const launchUrls = [
|
|
31714
|
+
c.launchUrl,
|
|
31715
|
+
config22.launchUrl,
|
|
31716
|
+
c.overrides?.staging?.launchUrl,
|
|
31717
|
+
c.overrides?.production?.launchUrl
|
|
31718
|
+
].filter(Boolean);
|
|
31719
|
+
return launchUrls.length > 0;
|
|
31720
|
+
});
|
|
31709
31721
|
}, {
|
|
31710
|
-
message: "Each course must have an effective
|
|
31722
|
+
message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
|
|
31711
31723
|
path: ["courses"]
|
|
31712
31724
|
});
|
|
31713
31725
|
var EdubridgeDateString2 = exports_external2.union([IsoDateString2, IsoDateTimeString2]);
|
|
@@ -49133,14 +49145,20 @@ var TimebackConfig3 = exports_external3.object({
|
|
|
49133
49145
|
message: "Duplicate courseCode found; each must be unique",
|
|
49134
49146
|
path: ["courses"]
|
|
49135
49147
|
}).refine((config22) => {
|
|
49136
|
-
return config22.courses.every((c) =>
|
|
49137
|
-
|
|
49138
|
-
|
|
49139
|
-
|
|
49140
|
-
|
|
49141
|
-
|
|
49148
|
+
return config22.courses.every((c) => {
|
|
49149
|
+
if (c.sensor !== undefined || config22.sensor !== undefined) {
|
|
49150
|
+
return true;
|
|
49151
|
+
}
|
|
49152
|
+
const launchUrls = [
|
|
49153
|
+
c.launchUrl,
|
|
49154
|
+
config22.launchUrl,
|
|
49155
|
+
c.overrides?.staging?.launchUrl,
|
|
49156
|
+
c.overrides?.production?.launchUrl
|
|
49157
|
+
].filter(Boolean);
|
|
49158
|
+
return launchUrls.length > 0;
|
|
49159
|
+
});
|
|
49142
49160
|
}, {
|
|
49143
|
-
message: "Each course must have an effective
|
|
49161
|
+
message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
|
|
49144
49162
|
path: ["courses"]
|
|
49145
49163
|
});
|
|
49146
49164
|
var EdubridgeDateString3 = exports_external3.union([IsoDateString3, IsoDateTimeString3]);
|
|
@@ -66765,14 +66783,20 @@ var TimebackConfig4 = exports_external4.object({
|
|
|
66765
66783
|
message: "Duplicate courseCode found; each must be unique",
|
|
66766
66784
|
path: ["courses"]
|
|
66767
66785
|
}).refine((config22) => {
|
|
66768
|
-
return config22.courses.every((c) =>
|
|
66769
|
-
|
|
66770
|
-
|
|
66771
|
-
|
|
66772
|
-
|
|
66773
|
-
|
|
66786
|
+
return config22.courses.every((c) => {
|
|
66787
|
+
if (c.sensor !== undefined || config22.sensor !== undefined) {
|
|
66788
|
+
return true;
|
|
66789
|
+
}
|
|
66790
|
+
const launchUrls = [
|
|
66791
|
+
c.launchUrl,
|
|
66792
|
+
config22.launchUrl,
|
|
66793
|
+
c.overrides?.staging?.launchUrl,
|
|
66794
|
+
c.overrides?.production?.launchUrl
|
|
66795
|
+
].filter(Boolean);
|
|
66796
|
+
return launchUrls.length > 0;
|
|
66797
|
+
});
|
|
66774
66798
|
}, {
|
|
66775
|
-
message: "Each course must have an effective
|
|
66799
|
+
message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
|
|
66776
66800
|
path: ["courses"]
|
|
66777
66801
|
});
|
|
66778
66802
|
var EdubridgeDateString4 = exports_external4.union([IsoDateString4, IsoDateTimeString4]);
|
|
@@ -83427,14 +83451,20 @@ var TimebackConfig5 = exports_external5.object({
|
|
|
83427
83451
|
message: "Duplicate courseCode found; each must be unique",
|
|
83428
83452
|
path: ["courses"]
|
|
83429
83453
|
}).refine((config22) => {
|
|
83430
|
-
return config22.courses.every((c) =>
|
|
83431
|
-
|
|
83432
|
-
|
|
83433
|
-
|
|
83434
|
-
|
|
83435
|
-
|
|
83454
|
+
return config22.courses.every((c) => {
|
|
83455
|
+
if (c.sensor !== undefined || config22.sensor !== undefined) {
|
|
83456
|
+
return true;
|
|
83457
|
+
}
|
|
83458
|
+
const launchUrls = [
|
|
83459
|
+
c.launchUrl,
|
|
83460
|
+
config22.launchUrl,
|
|
83461
|
+
c.overrides?.staging?.launchUrl,
|
|
83462
|
+
c.overrides?.production?.launchUrl
|
|
83463
|
+
].filter(Boolean);
|
|
83464
|
+
return launchUrls.length > 0;
|
|
83465
|
+
});
|
|
83436
83466
|
}, {
|
|
83437
|
-
message: "Each course must have an effective
|
|
83467
|
+
message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
|
|
83438
83468
|
path: ["courses"]
|
|
83439
83469
|
});
|
|
83440
83470
|
var EdubridgeDateString5 = exports_external5.union([IsoDateString5, IsoDateTimeString5]);
|
|
@@ -85556,14 +85586,20 @@ var TimebackConfig6 = z9.object({
|
|
|
85556
85586
|
message: "Duplicate courseCode found; each must be unique",
|
|
85557
85587
|
path: ["courses"]
|
|
85558
85588
|
}).refine((config6) => {
|
|
85559
|
-
return config6.courses.every((c) =>
|
|
85560
|
-
|
|
85561
|
-
|
|
85562
|
-
|
|
85563
|
-
|
|
85564
|
-
|
|
85589
|
+
return config6.courses.every((c) => {
|
|
85590
|
+
if (c.sensor !== undefined || config6.sensor !== undefined) {
|
|
85591
|
+
return true;
|
|
85592
|
+
}
|
|
85593
|
+
const launchUrls = [
|
|
85594
|
+
c.launchUrl,
|
|
85595
|
+
config6.launchUrl,
|
|
85596
|
+
c.overrides?.staging?.launchUrl,
|
|
85597
|
+
c.overrides?.production?.launchUrl
|
|
85598
|
+
].filter(Boolean);
|
|
85599
|
+
return launchUrls.length > 0;
|
|
85600
|
+
});
|
|
85565
85601
|
}, {
|
|
85566
|
-
message: "Each course must have an effective
|
|
85602
|
+
message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
|
|
85567
85603
|
path: ["courses"]
|
|
85568
85604
|
});
|
|
85569
85605
|
// ../types/src/zod/edubridge.ts
|
|
@@ -86666,19 +86702,6 @@ ${err instanceof Error ? err.message : String(err)}`
|
|
|
86666
86702
|
};
|
|
86667
86703
|
}
|
|
86668
86704
|
}
|
|
86669
|
-
// src/shared/constants.ts
|
|
86670
|
-
var ROUTES = {
|
|
86671
|
-
ACTIVITY: "/activity",
|
|
86672
|
-
IDENTITY: {
|
|
86673
|
-
SIGNIN: "/identity/signin",
|
|
86674
|
-
SIGNOUT: "/identity/signout",
|
|
86675
|
-
CALLBACK: "/identity/callback"
|
|
86676
|
-
},
|
|
86677
|
-
USER: {
|
|
86678
|
-
ME: "/user/me"
|
|
86679
|
-
}
|
|
86680
|
-
};
|
|
86681
|
-
|
|
86682
86705
|
// ../internal/logger/src/debug.ts
|
|
86683
86706
|
var patterns7 = null;
|
|
86684
86707
|
var debugAll7 = false;
|
|
@@ -87096,12 +87119,32 @@ async function getUserInfo(params) {
|
|
|
87096
87119
|
return response.json();
|
|
87097
87120
|
}
|
|
87098
87121
|
// src/server/lib/utils.ts
|
|
87122
|
+
function safeIdSegment(value) {
|
|
87123
|
+
return encodeURIComponent(value).replace(/%/g, "_");
|
|
87124
|
+
}
|
|
87125
|
+
function hashSuffix64Base36(value) {
|
|
87126
|
+
let hash6 = 0xcbf29ce484222325n;
|
|
87127
|
+
const prime = 0x100000001b3n;
|
|
87128
|
+
const mod64 = 0xffffffffffffffffn;
|
|
87129
|
+
for (let i = 0;i < value.length; i++) {
|
|
87130
|
+
hash6 ^= BigInt(value.charCodeAt(i));
|
|
87131
|
+
hash6 = hash6 * prime & mod64;
|
|
87132
|
+
}
|
|
87133
|
+
const base36 = hash6.toString(36);
|
|
87134
|
+
return base36.length > 12 ? base36.slice(-12) : base36;
|
|
87135
|
+
}
|
|
87099
87136
|
function mapEnvForApi(env2) {
|
|
87100
87137
|
if (env2 === "local" || env2 === "staging") {
|
|
87101
87138
|
return "staging";
|
|
87102
87139
|
}
|
|
87103
87140
|
return "production";
|
|
87104
87141
|
}
|
|
87142
|
+
function normalizeEnv(env2) {
|
|
87143
|
+
if (env2 === "production" || env2 === "local" || env2 === "staging") {
|
|
87144
|
+
return env2;
|
|
87145
|
+
}
|
|
87146
|
+
return "staging";
|
|
87147
|
+
}
|
|
87105
87148
|
function jsonResponse(data, status = 200, headers) {
|
|
87106
87149
|
const responseHeaders = new Headers(headers);
|
|
87107
87150
|
responseHeaders.set("Content-Type", "application/json");
|
|
@@ -87236,164 +87279,21 @@ async function lookupTimebackIdByEmail(params) {
|
|
|
87236
87279
|
throw new TimebackUserResolutionError(`Failed to lookup Timeback user: ${message}`, "timeback_user_lookup_failed");
|
|
87237
87280
|
}
|
|
87238
87281
|
}
|
|
87239
|
-
|
|
87240
|
-
|
|
87241
|
-
|
|
87242
|
-
|
|
87243
|
-
|
|
87244
|
-
|
|
87245
|
-
|
|
87246
|
-
|
|
87247
|
-
|
|
87248
|
-
|
|
87249
|
-
|
|
87250
|
-
get selectorDescription() {
|
|
87251
|
-
if ("grade" in this.selector) {
|
|
87252
|
-
return `${this.selector.subject} grade ${this.selector.grade}`;
|
|
87253
|
-
}
|
|
87254
|
-
return `code "${this.selector.code}"`;
|
|
87255
|
-
}
|
|
87256
|
-
}
|
|
87257
|
-
function resolveActivityCourse(courses, courseRef) {
|
|
87258
|
-
let matches;
|
|
87259
|
-
if ("grade" in courseRef) {
|
|
87260
|
-
matches = courses.filter((c) => c.subject === courseRef.subject && c.grade === courseRef.grade);
|
|
87261
|
-
} else {
|
|
87262
|
-
matches = courses.filter((c) => c.courseCode === courseRef.code);
|
|
87263
|
-
}
|
|
87264
|
-
if (matches.length === 0) {
|
|
87265
|
-
throw new ActivityCourseResolutionError("unknown_course", courseRef);
|
|
87266
|
-
}
|
|
87267
|
-
if (matches.length > 1) {
|
|
87268
|
-
throw new ActivityCourseResolutionError("ambiguous_course", courseRef, matches.length);
|
|
87269
|
-
}
|
|
87270
|
-
return matches[0];
|
|
87271
|
-
}
|
|
87272
|
-
// src/server/lib/build-activity-events.ts
|
|
87273
|
-
class MissingSyncedCourseIdError extends Error {
|
|
87274
|
-
course;
|
|
87275
|
-
env;
|
|
87276
|
-
constructor(course, env2) {
|
|
87277
|
-
const identifier = course.grade === undefined ? course.courseCode ?? course.subject : `${course.subject} grade ${course.grade}`;
|
|
87278
|
-
super(`Course "${identifier}" is missing a synced ID for ${env2}. Run \`timeback sync\` first.`);
|
|
87279
|
-
this.name = "MissingSyncedCourseIdError";
|
|
87280
|
-
this.course = course;
|
|
87281
|
-
this.env = env2;
|
|
87282
|
-
}
|
|
87283
|
-
}
|
|
87284
|
-
function buildCourseId(course, apiEnv) {
|
|
87285
|
-
const courseId = course.ids?.[apiEnv];
|
|
87286
|
-
if (!courseId) {
|
|
87287
|
-
throw new MissingSyncedCourseIdError(course, apiEnv);
|
|
87288
|
-
}
|
|
87289
|
-
return courseId;
|
|
87290
|
-
}
|
|
87291
|
-
function buildCourseName(course) {
|
|
87292
|
-
if (course.courseCode) {
|
|
87293
|
-
return course.courseCode;
|
|
87294
|
-
}
|
|
87295
|
-
if (course.grade !== undefined) {
|
|
87296
|
-
return `${course.subject} G${String(course.grade)}`;
|
|
87282
|
+
// src/shared/constants.ts
|
|
87283
|
+
var ROUTES = {
|
|
87284
|
+
ACTIVITY: "/activity",
|
|
87285
|
+
IDENTITY: {
|
|
87286
|
+
SIGNIN: "/identity/signin",
|
|
87287
|
+
SIGNOUT: "/identity/signout",
|
|
87288
|
+
CALLBACK: "/identity/callback"
|
|
87289
|
+
},
|
|
87290
|
+
USER: {
|
|
87291
|
+
ME: "/user/me",
|
|
87292
|
+
VERIFY: "/user/verify"
|
|
87297
87293
|
}
|
|
87298
|
-
|
|
87299
|
-
}
|
|
87294
|
+
};
|
|
87300
87295
|
|
|
87301
|
-
|
|
87302
|
-
sensor;
|
|
87303
|
-
constructor(sensor) {
|
|
87304
|
-
super(`Invalid sensor URL "${sensor}". Sensor must be a valid absolute URL (e.g., "https://sensor.example.com") to support slug-based activity IDs.`);
|
|
87305
|
-
this.name = "InvalidSensorUrlError";
|
|
87306
|
-
this.sensor = sensor;
|
|
87307
|
-
}
|
|
87308
|
-
}
|
|
87309
|
-
function buildCanonicalActivityUrl(sensor, selector, slug) {
|
|
87310
|
-
let base;
|
|
87311
|
-
try {
|
|
87312
|
-
base = new URL(sensor);
|
|
87313
|
-
} catch {
|
|
87314
|
-
throw new InvalidSensorUrlError(sensor);
|
|
87315
|
-
}
|
|
87316
|
-
const pathSegment = "grade" in selector ? `${selector.subject}/g${String(selector.grade)}` : selector.code;
|
|
87317
|
-
const basePath = base.pathname.replace(/\/+$/, "");
|
|
87318
|
-
base.pathname = `${basePath}/activities/${pathSegment}/${encodeURIComponent(slug)}`;
|
|
87319
|
-
return base.toString();
|
|
87320
|
-
}
|
|
87321
|
-
function buildActivityContext(payload, course, appName, apiEnv, sensor) {
|
|
87322
|
-
return {
|
|
87323
|
-
id: buildCanonicalActivityUrl(sensor, payload.course, payload.id),
|
|
87324
|
-
type: "TimebackActivityContext",
|
|
87325
|
-
subject: course.subject,
|
|
87326
|
-
app: { name: appName },
|
|
87327
|
-
activity: { name: payload.name },
|
|
87328
|
-
course: {
|
|
87329
|
-
id: buildCourseId(course, apiEnv),
|
|
87330
|
-
name: buildCourseName(course)
|
|
87331
|
-
}
|
|
87332
|
-
};
|
|
87333
|
-
}
|
|
87334
|
-
function buildActivityMetrics(metrics) {
|
|
87335
|
-
const result = [];
|
|
87336
|
-
if (metrics.totalQuestions !== undefined) {
|
|
87337
|
-
result.push({ type: "totalQuestions", value: metrics.totalQuestions });
|
|
87338
|
-
}
|
|
87339
|
-
if (metrics.correctQuestions !== undefined) {
|
|
87340
|
-
result.push({ type: "correctQuestions", value: metrics.correctQuestions });
|
|
87341
|
-
}
|
|
87342
|
-
if (metrics.xpEarned !== undefined) {
|
|
87343
|
-
result.push({ type: "xpEarned", value: metrics.xpEarned });
|
|
87344
|
-
}
|
|
87345
|
-
if (metrics.masteredUnits !== undefined) {
|
|
87346
|
-
result.push({ type: "masteredUnits", value: metrics.masteredUnits });
|
|
87347
|
-
}
|
|
87348
|
-
return result;
|
|
87349
|
-
}
|
|
87350
|
-
function buildTimeSpentMetrics(elapsedMs, pausedMs) {
|
|
87351
|
-
const result = [{ type: "active", value: Math.max(0, elapsedMs) / 1000 }];
|
|
87352
|
-
if (pausedMs > 0) {
|
|
87353
|
-
result.push({ type: "inactive", value: Math.max(0, pausedMs) / 1000 });
|
|
87354
|
-
}
|
|
87355
|
-
return result;
|
|
87356
|
-
}
|
|
87357
|
-
async function sendCaliperEnvelope(client, sensor, activityEvent, timeSpentEvent) {
|
|
87358
|
-
await client.caliper.events.send(sensor, [activityEvent, timeSpentEvent]);
|
|
87359
|
-
}
|
|
87360
|
-
// src/server/lib/build-user-profile.ts
|
|
87361
|
-
function buildCourseLookup(courses, apiEnv) {
|
|
87362
|
-
const courseById = new Map;
|
|
87363
|
-
for (const course of courses) {
|
|
87364
|
-
const courseId = course.ids?.[apiEnv];
|
|
87365
|
-
if (courseId) {
|
|
87366
|
-
courseById.set(courseId, course);
|
|
87367
|
-
}
|
|
87368
|
-
}
|
|
87369
|
-
return courseById;
|
|
87370
|
-
}
|
|
87371
|
-
function mapEnrollmentsToCourses(enrollments, courseById) {
|
|
87372
|
-
return enrollments.map((enrollment) => {
|
|
87373
|
-
const configuredCourse = courseById.get(enrollment.course.id);
|
|
87374
|
-
return {
|
|
87375
|
-
id: enrollment.course.id,
|
|
87376
|
-
code: configuredCourse?.courseCode ?? enrollment.course.id,
|
|
87377
|
-
name: enrollment.course.title
|
|
87378
|
-
};
|
|
87379
|
-
});
|
|
87380
|
-
}
|
|
87381
|
-
function pickGoalsFromEnrollments(enrollments) {
|
|
87382
|
-
return enrollments.map((enrollment) => enrollment.metadata?.goals).find(Boolean);
|
|
87383
|
-
}
|
|
87384
|
-
function getUtcDayRange(date6) {
|
|
87385
|
-
const start = new Date(Date.UTC(date6.getUTCFullYear(), date6.getUTCMonth(), date6.getUTCDate()));
|
|
87386
|
-
const end = new Date(Date.UTC(date6.getUTCFullYear(), date6.getUTCMonth(), date6.getUTCDate(), 23, 59, 59, 999));
|
|
87387
|
-
return { start, end };
|
|
87388
|
-
}
|
|
87389
|
-
function sumXp(facts) {
|
|
87390
|
-
return Object.values(facts).reduce((dateTotal, subjects) => {
|
|
87391
|
-
return dateTotal + Object.values(subjects).reduce((subjectTotal, metrics) => {
|
|
87392
|
-
return subjectTotal + (metrics.activityMetrics?.xpEarned ?? 0);
|
|
87393
|
-
}, 0);
|
|
87394
|
-
}, 0);
|
|
87395
|
-
}
|
|
87396
|
-
// src/server/handlers/identity-full.ts
|
|
87296
|
+
// src/server/lib/sso.ts
|
|
87397
87297
|
function buildErrorContext(error57, errorCode, state, req) {
|
|
87398
87298
|
return {
|
|
87399
87299
|
error: error57,
|
|
@@ -87412,123 +87312,59 @@ function tryDecodeState(stateParam) {
|
|
|
87412
87312
|
return;
|
|
87413
87313
|
}
|
|
87414
87314
|
}
|
|
87415
|
-
function
|
|
87315
|
+
function handleIdpError(errorParam, url6, state, req, onCallbackError) {
|
|
87416
87316
|
const errorDesc = url6.searchParams.get("error_description");
|
|
87417
87317
|
ssoLog.error("IdP returned error", { error: errorParam, description: errorDesc });
|
|
87418
87318
|
const error57 = new Error(errorDesc ?? errorParam);
|
|
87419
|
-
if (
|
|
87420
|
-
return
|
|
87319
|
+
if (onCallbackError) {
|
|
87320
|
+
return onCallbackError(buildErrorContext(error57, errorParam, state, req));
|
|
87421
87321
|
}
|
|
87422
87322
|
return jsonResponse({ error: errorParam }, 400);
|
|
87423
87323
|
}
|
|
87424
|
-
function handleMissingCode(state, req,
|
|
87324
|
+
function handleMissingCode(state, req, onCallbackError) {
|
|
87425
87325
|
ssoLog.error("Missing authorization code in callback");
|
|
87426
87326
|
const error57 = new Error("Missing authorization code");
|
|
87427
|
-
if (
|
|
87428
|
-
return
|
|
87327
|
+
if (onCallbackError) {
|
|
87328
|
+
return onCallbackError(buildErrorContext(error57, "missing_code", state, req));
|
|
87429
87329
|
}
|
|
87430
87330
|
return jsonResponse({ error: "Missing authorization code" }, 400);
|
|
87431
87331
|
}
|
|
87432
|
-
async function
|
|
87433
|
-
|
|
87434
|
-
|
|
87435
|
-
return jsonResponse({ error: "SSO not configured" }, 400);
|
|
87436
|
-
}
|
|
87437
|
-
const issuer = identity.issuer ?? getIssuer(env2);
|
|
87332
|
+
async function initiateSignIn(params) {
|
|
87333
|
+
const { req, env: env2, clientId, buildState } = params;
|
|
87334
|
+
const issuer = params.issuer ?? getIssuer(env2);
|
|
87438
87335
|
const url6 = new URL(req.url);
|
|
87439
|
-
let redirectUri =
|
|
87336
|
+
let redirectUri = params.redirectUri;
|
|
87440
87337
|
if (!redirectUri) {
|
|
87441
87338
|
const basePath = url6.pathname.replace(ROUTES.IDENTITY.SIGNIN, "");
|
|
87442
87339
|
redirectUri = `${url6.origin}${basePath}${ROUTES.IDENTITY.CALLBACK}`;
|
|
87443
87340
|
}
|
|
87444
|
-
ssoLog.debug("SSO sign-in initiated", { env: env2, issuer, clientId
|
|
87445
|
-
const stateData =
|
|
87341
|
+
ssoLog.debug("SSO sign-in initiated", { env: env2, issuer, clientId, redirectUri });
|
|
87342
|
+
const stateData = buildState ? buildState({ req, url: url6 }) : {};
|
|
87446
87343
|
const state = encodeBase64Url(stateData);
|
|
87447
87344
|
const authUrl = await buildAuthorizationUrl({
|
|
87448
87345
|
issuer,
|
|
87449
|
-
clientId
|
|
87346
|
+
clientId,
|
|
87450
87347
|
redirectUri,
|
|
87451
87348
|
state
|
|
87452
87349
|
});
|
|
87453
87350
|
return redirectResponse(authUrl);
|
|
87454
87351
|
}
|
|
87455
|
-
|
|
87456
|
-
try {
|
|
87457
|
-
const issuer = identity.issuer ?? getIssuer(env2);
|
|
87458
|
-
let redirectUri = identity.redirectUri;
|
|
87459
|
-
if (!redirectUri) {
|
|
87460
|
-
const basePath = url6.pathname.replace(ROUTES.IDENTITY.CALLBACK, "");
|
|
87461
|
-
redirectUri = `${url6.origin}${basePath}${ROUTES.IDENTITY.CALLBACK}`;
|
|
87462
|
-
}
|
|
87463
|
-
ssoLog.debug("Exchanging auth code for tokens", { issuer, clientId: identity.clientId });
|
|
87464
|
-
const tokens = await exchangeCodeForTokens({
|
|
87465
|
-
issuer,
|
|
87466
|
-
clientId: identity.clientId,
|
|
87467
|
-
clientSecret: identity.clientSecret,
|
|
87468
|
-
code,
|
|
87469
|
-
redirectUri
|
|
87470
|
-
});
|
|
87471
|
-
const userInfo = await getUserInfo({ issuer, accessToken: tokens.access_token });
|
|
87472
|
-
const identities = typeof userInfo.identities === "string" ? JSON.parse(userInfo.identities) : userInfo.identities;
|
|
87473
|
-
ssoLog.debug("SSO completed, resolving Timeback user", {
|
|
87474
|
-
user: { ...userInfo, identities }
|
|
87475
|
-
});
|
|
87476
|
-
const authUser = await resolveTimebackUserByEmail({
|
|
87477
|
-
env: env2,
|
|
87478
|
-
apiCredentials: api.credentials,
|
|
87479
|
-
userInfo,
|
|
87480
|
-
client: api.getClient()
|
|
87481
|
-
});
|
|
87482
|
-
ssoLog.debug("Timeback user resolved", { timebackId: authUser.id });
|
|
87483
|
-
const ctx = {
|
|
87484
|
-
user: authUser,
|
|
87485
|
-
idp: { tokens, userInfo },
|
|
87486
|
-
state,
|
|
87487
|
-
req,
|
|
87488
|
-
redirect: redirectResponse,
|
|
87489
|
-
json: jsonResponse
|
|
87490
|
-
};
|
|
87491
|
-
return identity.onCallbackSuccess(ctx);
|
|
87492
|
-
} catch (err) {
|
|
87493
|
-
const error57 = err instanceof Error ? err : new Error("Unknown error");
|
|
87494
|
-
const errorCode = err instanceof TimebackUserResolutionError ? err.code : "token_exchange_failed";
|
|
87495
|
-
ssoLog.error("SSO callback failed", { error: error57.message, errorCode });
|
|
87496
|
-
if (identity.onCallbackError) {
|
|
87497
|
-
return identity.onCallbackError(buildErrorContext(error57, errorCode, state, req));
|
|
87498
|
-
}
|
|
87499
|
-
return jsonResponse({ error: error57.message }, 500);
|
|
87500
|
-
}
|
|
87501
|
-
}
|
|
87502
|
-
async function handleCallback(req, env2, identity, api) {
|
|
87503
|
-
if (identity.mode !== "sso") {
|
|
87504
|
-
ssoLog.warn("SSO not configured");
|
|
87505
|
-
return jsonResponse({ error: "SSO not configured" }, 400);
|
|
87506
|
-
}
|
|
87352
|
+
function parseCallback(req) {
|
|
87507
87353
|
const url6 = new URL(req.url);
|
|
87508
87354
|
const code = url6.searchParams.get("code");
|
|
87509
87355
|
const errorParam = url6.searchParams.get("error");
|
|
87510
87356
|
const stateParam = url6.searchParams.get("state");
|
|
87511
87357
|
ssoLog.debug("Received callback from IdP", { hasCode: !!code, error: errorParam });
|
|
87512
87358
|
const state = stateParam ? tryDecodeState(stateParam) : undefined;
|
|
87513
|
-
|
|
87514
|
-
return await handleCallbackError(errorParam, url6, state, req, identity);
|
|
87515
|
-
}
|
|
87516
|
-
if (!code) {
|
|
87517
|
-
return await handleMissingCode(state, req, identity);
|
|
87518
|
-
}
|
|
87519
|
-
return await completeCallback(code, url6, state, req, env2, identity, api);
|
|
87359
|
+
return { url: url6, code, errorParam, state };
|
|
87520
87360
|
}
|
|
87521
|
-
function
|
|
87522
|
-
|
|
87523
|
-
|
|
87524
|
-
|
|
87525
|
-
|
|
87526
|
-
|
|
87527
|
-
};
|
|
87361
|
+
function computeRedirectUri(url6, configuredRedirectUri) {
|
|
87362
|
+
if (configuredRedirectUri) {
|
|
87363
|
+
return configuredRedirectUri;
|
|
87364
|
+
}
|
|
87365
|
+
const basePath = url6.pathname.replace(ROUTES.IDENTITY.CALLBACK, "");
|
|
87366
|
+
return `${url6.origin}${basePath}${ROUTES.IDENTITY.CALLBACK}`;
|
|
87528
87367
|
}
|
|
87529
|
-
// src/server/handlers/activity.ts
|
|
87530
|
-
import * as z16 from "zod";
|
|
87531
|
-
|
|
87532
87368
|
// ../clients/caliper/dist/index.js
|
|
87533
87369
|
var __defProp7 = Object.defineProperty;
|
|
87534
87370
|
var __export = (target, all) => {
|
|
@@ -102809,14 +102645,20 @@ var TimebackConfig8 = exports_external6.object({
|
|
|
102809
102645
|
message: "Duplicate courseCode found; each must be unique",
|
|
102810
102646
|
path: ["courses"]
|
|
102811
102647
|
}).refine((config22) => {
|
|
102812
|
-
return config22.courses.every((c) =>
|
|
102813
|
-
|
|
102814
|
-
|
|
102815
|
-
|
|
102816
|
-
|
|
102817
|
-
|
|
102648
|
+
return config22.courses.every((c) => {
|
|
102649
|
+
if (c.sensor !== undefined || config22.sensor !== undefined) {
|
|
102650
|
+
return true;
|
|
102651
|
+
}
|
|
102652
|
+
const launchUrls = [
|
|
102653
|
+
c.launchUrl,
|
|
102654
|
+
config22.launchUrl,
|
|
102655
|
+
c.overrides?.staging?.launchUrl,
|
|
102656
|
+
c.overrides?.production?.launchUrl
|
|
102657
|
+
].filter(Boolean);
|
|
102658
|
+
return launchUrls.length > 0;
|
|
102659
|
+
});
|
|
102818
102660
|
}, {
|
|
102819
|
-
message: "Each course must have an effective
|
|
102661
|
+
message: "Each course must have an effective sensor. Either set `sensor` explicitly (top-level or per-course), or provide a `launchUrl` so sensor can be derived from its origin.",
|
|
102820
102662
|
path: ["courses"]
|
|
102821
102663
|
});
|
|
102822
102664
|
var EdubridgeDateString8 = exports_external6.union([IsoDateString8, IsoDateTimeString8]);
|
|
@@ -104099,8 +103941,337 @@ function createCaliperClient2(registry22 = DEFAULT_PROVIDER_REGISTRY7) {
|
|
|
104099
103941
|
}
|
|
104100
103942
|
var CaliperClient2 = createCaliperClient2();
|
|
104101
103943
|
|
|
104102
|
-
// src/server/handlers/activity.ts
|
|
104103
|
-
|
|
103944
|
+
// src/server/handlers/activity/caliper.ts
|
|
103945
|
+
class MissingSyncedCourseIdError extends Error {
|
|
103946
|
+
course;
|
|
103947
|
+
env;
|
|
103948
|
+
constructor(course, env2) {
|
|
103949
|
+
const identifier = course.grade === undefined ? course.courseCode ?? course.subject : `${course.subject} grade ${course.grade}`;
|
|
103950
|
+
super(`Course "${identifier}" is missing a synced ID for ${env2}. Run \`timeback resources push\` first.`);
|
|
103951
|
+
this.name = "MissingSyncedCourseIdError";
|
|
103952
|
+
this.course = course;
|
|
103953
|
+
this.env = env2;
|
|
103954
|
+
}
|
|
103955
|
+
}
|
|
103956
|
+
|
|
103957
|
+
class InvalidSensorUrlError extends Error {
|
|
103958
|
+
sensor;
|
|
103959
|
+
constructor(sensor) {
|
|
103960
|
+
super(`Invalid sensor URL "${sensor}". Sensor must be a valid absolute URL (e.g., "https://sensor.example.com") to support slug-based activity IDs.`);
|
|
103961
|
+
this.name = "InvalidSensorUrlError";
|
|
103962
|
+
this.sensor = sensor;
|
|
103963
|
+
}
|
|
103964
|
+
}
|
|
103965
|
+
function buildCourseId(course, apiEnv) {
|
|
103966
|
+
const courseId = course.ids?.[apiEnv];
|
|
103967
|
+
if (!courseId) {
|
|
103968
|
+
throw new MissingSyncedCourseIdError(course, apiEnv);
|
|
103969
|
+
}
|
|
103970
|
+
return courseId;
|
|
103971
|
+
}
|
|
103972
|
+
function buildCourseName(course) {
|
|
103973
|
+
if (course.courseCode) {
|
|
103974
|
+
return course.courseCode;
|
|
103975
|
+
}
|
|
103976
|
+
if (course.grade !== undefined) {
|
|
103977
|
+
return `${course.subject} G${String(course.grade)}`;
|
|
103978
|
+
}
|
|
103979
|
+
return course.subject;
|
|
103980
|
+
}
|
|
103981
|
+
function buildCanonicalActivityUrl(sensor, selector, slug) {
|
|
103982
|
+
let base;
|
|
103983
|
+
try {
|
|
103984
|
+
base = new URL(sensor);
|
|
103985
|
+
} catch {
|
|
103986
|
+
throw new InvalidSensorUrlError(sensor);
|
|
103987
|
+
}
|
|
103988
|
+
const pathSegment = "grade" in selector ? `${selector.subject}/g${String(selector.grade)}` : selector.code;
|
|
103989
|
+
const basePath = base.pathname.replace(/\/+$/, "");
|
|
103990
|
+
base.pathname = `${basePath}/activities/${pathSegment}/${encodeURIComponent(slug)}`;
|
|
103991
|
+
return base.toString();
|
|
103992
|
+
}
|
|
103993
|
+
function buildActivityContext(payload, course, appName, apiEnv, sensor) {
|
|
103994
|
+
return {
|
|
103995
|
+
id: buildCanonicalActivityUrl(sensor, payload.course, payload.id),
|
|
103996
|
+
type: "TimebackActivityContext",
|
|
103997
|
+
subject: course.subject,
|
|
103998
|
+
app: { name: appName },
|
|
103999
|
+
activity: { name: payload.name },
|
|
104000
|
+
course: {
|
|
104001
|
+
id: buildCourseId(course, apiEnv),
|
|
104002
|
+
name: buildCourseName(course)
|
|
104003
|
+
},
|
|
104004
|
+
process: false
|
|
104005
|
+
};
|
|
104006
|
+
}
|
|
104007
|
+
function buildActivityMetrics(metrics) {
|
|
104008
|
+
const result = [];
|
|
104009
|
+
if (metrics.totalQuestions !== undefined) {
|
|
104010
|
+
result.push({ type: "totalQuestions", value: metrics.totalQuestions });
|
|
104011
|
+
}
|
|
104012
|
+
if (metrics.correctQuestions !== undefined) {
|
|
104013
|
+
result.push({ type: "correctQuestions", value: metrics.correctQuestions });
|
|
104014
|
+
}
|
|
104015
|
+
if (metrics.xpEarned !== undefined) {
|
|
104016
|
+
result.push({ type: "xpEarned", value: metrics.xpEarned });
|
|
104017
|
+
}
|
|
104018
|
+
if (metrics.masteredUnits !== undefined) {
|
|
104019
|
+
result.push({ type: "masteredUnits", value: metrics.masteredUnits });
|
|
104020
|
+
}
|
|
104021
|
+
return result;
|
|
104022
|
+
}
|
|
104023
|
+
function buildTimeSpentMetrics(elapsedMs, pausedMs) {
|
|
104024
|
+
const result = [{ type: "active", value: Math.max(0, elapsedMs) / 1000 }];
|
|
104025
|
+
if (pausedMs > 0) {
|
|
104026
|
+
result.push({ type: "inactive", value: Math.max(0, pausedMs) / 1000 });
|
|
104027
|
+
}
|
|
104028
|
+
return result;
|
|
104029
|
+
}
|
|
104030
|
+
function buildActivityEvents(params) {
|
|
104031
|
+
const { sensor, timebackId, email: email8, payload, course, appName, apiEnv } = params;
|
|
104032
|
+
const actor = {
|
|
104033
|
+
id: `urn:timeback:user:${timebackId}`,
|
|
104034
|
+
type: "TimebackUser",
|
|
104035
|
+
email: email8
|
|
104036
|
+
};
|
|
104037
|
+
const object7 = buildActivityContext(payload, course, appName, apiEnv, sensor);
|
|
104038
|
+
const metrics = buildActivityMetrics(payload.metrics);
|
|
104039
|
+
const timeSpentMetrics = buildTimeSpentMetrics(payload.elapsedMs, payload.pausedMs);
|
|
104040
|
+
const activityEvent = createActivityEvent2({
|
|
104041
|
+
actor,
|
|
104042
|
+
object: object7,
|
|
104043
|
+
metrics,
|
|
104044
|
+
eventTime: payload.endedAt,
|
|
104045
|
+
attempt: payload.attemptNumber,
|
|
104046
|
+
generatedExtensions: payload.pctCompleteApp === undefined ? undefined : { pctCompleteApp: payload.pctCompleteApp }
|
|
104047
|
+
});
|
|
104048
|
+
const timeSpentEvent = createTimeSpentEvent2({
|
|
104049
|
+
actor,
|
|
104050
|
+
object: object7,
|
|
104051
|
+
metrics: timeSpentMetrics,
|
|
104052
|
+
eventTime: payload.endedAt
|
|
104053
|
+
});
|
|
104054
|
+
return {
|
|
104055
|
+
sensor,
|
|
104056
|
+
actor,
|
|
104057
|
+
object: object7,
|
|
104058
|
+
events: [activityEvent, timeSpentEvent],
|
|
104059
|
+
payload,
|
|
104060
|
+
course,
|
|
104061
|
+
appName,
|
|
104062
|
+
apiEnv,
|
|
104063
|
+
email: email8,
|
|
104064
|
+
timebackId
|
|
104065
|
+
};
|
|
104066
|
+
}
|
|
104067
|
+
async function sendCaliperEnvelope(client, sensor, activityEvent, timeSpentEvent) {
|
|
104068
|
+
await client.caliper.events.send(sensor, [activityEvent, timeSpentEvent]);
|
|
104069
|
+
}
|
|
104070
|
+
|
|
104071
|
+
// src/server/handlers/activity/gradebook.ts
|
|
104072
|
+
var log11 = createScopedLogger3("handlers:activity:gradebook");
|
|
104073
|
+
function buildAssessmentResultPayload(params) {
|
|
104074
|
+
const { resultId, lineItemId, timebackId, attempt, write } = params;
|
|
104075
|
+
return {
|
|
104076
|
+
sourcedId: resultId,
|
|
104077
|
+
status: "active",
|
|
104078
|
+
assessmentLineItem: { sourcedId: lineItemId },
|
|
104079
|
+
student: { sourcedId: timebackId },
|
|
104080
|
+
score: write.score,
|
|
104081
|
+
scoreDate: write.endedAt,
|
|
104082
|
+
scoreStatus: "fully graded",
|
|
104083
|
+
inProgress: "false",
|
|
104084
|
+
metadata: {
|
|
104085
|
+
totalQuestions: write.metrics.totalQuestions,
|
|
104086
|
+
correctQuestions: write.metrics.correctQuestions,
|
|
104087
|
+
accuracy: write.score,
|
|
104088
|
+
xpEarned: write.metrics.xpEarned,
|
|
104089
|
+
masteredUnits: write.metrics.masteredUnits,
|
|
104090
|
+
attempt,
|
|
104091
|
+
endedAt: write.endedAt,
|
|
104092
|
+
pctCompleteApp: write.pctCompleteApp,
|
|
104093
|
+
appName: write.appName,
|
|
104094
|
+
lastUpdated: new Date().toISOString()
|
|
104095
|
+
}
|
|
104096
|
+
};
|
|
104097
|
+
}
|
|
104098
|
+
function buildAttemptResultId(lineItemId, timebackId, attempt, endedAt) {
|
|
104099
|
+
return `${lineItemId}:${safeIdSegment(timebackId)}:attempt-${attempt}:e-${hashSuffix64Base36(endedAt)}`;
|
|
104100
|
+
}
|
|
104101
|
+
async function ensureAssessmentLineItemExists(params) {
|
|
104102
|
+
const { client, lineItemId, activityName, courseId, appName } = params;
|
|
104103
|
+
try {
|
|
104104
|
+
await client.oneroster.assessmentLineItems(lineItemId).get();
|
|
104105
|
+
return;
|
|
104106
|
+
} catch {}
|
|
104107
|
+
try {
|
|
104108
|
+
await client.oneroster.assessmentLineItems.create({
|
|
104109
|
+
sourcedId: lineItemId,
|
|
104110
|
+
title: activityName,
|
|
104111
|
+
status: "active",
|
|
104112
|
+
course: { sourcedId: courseId },
|
|
104113
|
+
resultValueMin: 0,
|
|
104114
|
+
resultValueMax: 100,
|
|
104115
|
+
metadata: {
|
|
104116
|
+
createdBy: "timeback-sdk",
|
|
104117
|
+
appName
|
|
104118
|
+
}
|
|
104119
|
+
});
|
|
104120
|
+
} catch (err) {
|
|
104121
|
+
try {
|
|
104122
|
+
await client.oneroster.assessmentLineItems(lineItemId).get();
|
|
104123
|
+
} catch {
|
|
104124
|
+
throw err;
|
|
104125
|
+
}
|
|
104126
|
+
}
|
|
104127
|
+
log11.debug("Created assessment line item", { lineItemId });
|
|
104128
|
+
}
|
|
104129
|
+
function findRetryResult(existingResults, endedAt) {
|
|
104130
|
+
return existingResults.find((result) => {
|
|
104131
|
+
const metadata = result.metadata;
|
|
104132
|
+
return result.scoreDate === endedAt || metadata?.endedAt === endedAt;
|
|
104133
|
+
});
|
|
104134
|
+
}
|
|
104135
|
+
function computeMaxAttempt(existingResults) {
|
|
104136
|
+
let maxAttempt = 0;
|
|
104137
|
+
for (const result of existingResults) {
|
|
104138
|
+
const metadata = result.metadata;
|
|
104139
|
+
const attempt = metadata?.attempt;
|
|
104140
|
+
if (typeof attempt === "number" && attempt > maxAttempt) {
|
|
104141
|
+
maxAttempt = attempt;
|
|
104142
|
+
}
|
|
104143
|
+
}
|
|
104144
|
+
return maxAttempt;
|
|
104145
|
+
}
|
|
104146
|
+
function resolveAttemptFromResult(result) {
|
|
104147
|
+
const metadata = result.metadata;
|
|
104148
|
+
return typeof metadata?.attempt === "number" && metadata.attempt >= 1 ? metadata.attempt : 1;
|
|
104149
|
+
}
|
|
104150
|
+
async function writeAssessmentResultWithAttemptReservation(params) {
|
|
104151
|
+
const { client, lineItemId, timebackId, write } = params;
|
|
104152
|
+
const existingResults = await client.oneroster.assessmentResults.listAll({
|
|
104153
|
+
where: {
|
|
104154
|
+
status: "active",
|
|
104155
|
+
"assessmentLineItem.sourcedId": lineItemId,
|
|
104156
|
+
"student.sourcedId": timebackId
|
|
104157
|
+
}
|
|
104158
|
+
});
|
|
104159
|
+
const retryResult = findRetryResult(existingResults, write.endedAt);
|
|
104160
|
+
if (retryResult) {
|
|
104161
|
+
const attempt2 = resolveAttemptFromResult(retryResult);
|
|
104162
|
+
const resultId2 = retryResult.sourcedId || buildAttemptResultId(lineItemId, timebackId, attempt2, write.endedAt);
|
|
104163
|
+
await client.oneroster.assessmentResults.update(resultId2, buildAssessmentResultPayload({
|
|
104164
|
+
resultId: resultId2,
|
|
104165
|
+
lineItemId,
|
|
104166
|
+
timebackId,
|
|
104167
|
+
attempt: attempt2,
|
|
104168
|
+
write
|
|
104169
|
+
}));
|
|
104170
|
+
log11.debug("Wrote gradebook entry (retry)", {
|
|
104171
|
+
lineItemId,
|
|
104172
|
+
resultId: resultId2,
|
|
104173
|
+
score: write.score,
|
|
104174
|
+
attempt: attempt2
|
|
104175
|
+
});
|
|
104176
|
+
return;
|
|
104177
|
+
}
|
|
104178
|
+
const attempt = computeMaxAttempt(existingResults) + 1;
|
|
104179
|
+
const resultId = buildAttemptResultId(lineItemId, timebackId, attempt, write.endedAt);
|
|
104180
|
+
await client.oneroster.assessmentResults.update(resultId, buildAssessmentResultPayload({
|
|
104181
|
+
resultId,
|
|
104182
|
+
lineItemId,
|
|
104183
|
+
timebackId,
|
|
104184
|
+
attempt,
|
|
104185
|
+
write
|
|
104186
|
+
}));
|
|
104187
|
+
log11.debug("Wrote gradebook entry (new attempt)", {
|
|
104188
|
+
lineItemId,
|
|
104189
|
+
resultId,
|
|
104190
|
+
score: write.score,
|
|
104191
|
+
attempt
|
|
104192
|
+
});
|
|
104193
|
+
}
|
|
104194
|
+
async function writeGradebookEntry(params) {
|
|
104195
|
+
const { client, courseId, activityId, activityName, timebackId, payload, appName } = params;
|
|
104196
|
+
const { metrics, endedAt, pctCompleteApp } = payload;
|
|
104197
|
+
if (metrics.totalQuestions === undefined || metrics.correctQuestions === undefined) {
|
|
104198
|
+
log11.debug("Skipping gradebook write: missing totalQuestions or correctQuestions", {
|
|
104199
|
+
activityId
|
|
104200
|
+
});
|
|
104201
|
+
return;
|
|
104202
|
+
}
|
|
104203
|
+
if (metrics.totalQuestions <= 0) {
|
|
104204
|
+
log11.debug("Skipping gradebook write: totalQuestions must be positive", {
|
|
104205
|
+
activityId,
|
|
104206
|
+
totalQuestions: metrics.totalQuestions
|
|
104207
|
+
});
|
|
104208
|
+
return;
|
|
104209
|
+
}
|
|
104210
|
+
const rawScore = metrics.correctQuestions / metrics.totalQuestions * 100;
|
|
104211
|
+
const score = Math.round(Math.min(100, Math.max(0, rawScore)));
|
|
104212
|
+
const lineItemId = `${safeIdSegment(courseId)}-${safeIdSegment(activityId)}-assessment`;
|
|
104213
|
+
try {
|
|
104214
|
+
await ensureAssessmentLineItemExists({
|
|
104215
|
+
client,
|
|
104216
|
+
lineItemId,
|
|
104217
|
+
activityName,
|
|
104218
|
+
courseId,
|
|
104219
|
+
appName
|
|
104220
|
+
});
|
|
104221
|
+
await writeAssessmentResultWithAttemptReservation({
|
|
104222
|
+
client,
|
|
104223
|
+
lineItemId,
|
|
104224
|
+
timebackId,
|
|
104225
|
+
write: { score, endedAt, metrics, pctCompleteApp, appName }
|
|
104226
|
+
});
|
|
104227
|
+
} catch (err) {
|
|
104228
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
104229
|
+
log11.warn("Failed to write gradebook entry", {
|
|
104230
|
+
lineItemId,
|
|
104231
|
+
error: message
|
|
104232
|
+
});
|
|
104233
|
+
}
|
|
104234
|
+
}
|
|
104235
|
+
|
|
104236
|
+
// src/server/handlers/activity/schema.ts
|
|
104237
|
+
import * as z16 from "zod";
|
|
104238
|
+
|
|
104239
|
+
// src/server/handlers/activity/resolve.ts
|
|
104240
|
+
class ActivityCourseResolutionError extends Error {
|
|
104241
|
+
code;
|
|
104242
|
+
selector;
|
|
104243
|
+
count;
|
|
104244
|
+
constructor(code, selector, count) {
|
|
104245
|
+
super(code);
|
|
104246
|
+
this.code = code;
|
|
104247
|
+
this.selector = selector;
|
|
104248
|
+
this.count = count;
|
|
104249
|
+
}
|
|
104250
|
+
get selectorDescription() {
|
|
104251
|
+
if ("grade" in this.selector) {
|
|
104252
|
+
return `${this.selector.subject} grade ${this.selector.grade}`;
|
|
104253
|
+
}
|
|
104254
|
+
return `code "${this.selector.code}"`;
|
|
104255
|
+
}
|
|
104256
|
+
}
|
|
104257
|
+
function resolveActivityCourse(courses, courseRef) {
|
|
104258
|
+
let matches;
|
|
104259
|
+
if ("grade" in courseRef) {
|
|
104260
|
+
matches = courses.filter((c) => c.subject === courseRef.subject && c.grade === courseRef.grade);
|
|
104261
|
+
} else {
|
|
104262
|
+
matches = courses.filter((c) => c.courseCode === courseRef.code);
|
|
104263
|
+
}
|
|
104264
|
+
if (matches.length === 0) {
|
|
104265
|
+
throw new ActivityCourseResolutionError("unknown_course", courseRef);
|
|
104266
|
+
}
|
|
104267
|
+
if (matches.length > 1) {
|
|
104268
|
+
throw new ActivityCourseResolutionError("ambiguous_course", courseRef, matches.length);
|
|
104269
|
+
}
|
|
104270
|
+
return matches[0];
|
|
104271
|
+
}
|
|
104272
|
+
|
|
104273
|
+
// src/server/handlers/activity/schema.ts
|
|
104274
|
+
var log12 = createScopedLogger3("handlers:activity:schema");
|
|
104104
104275
|
var activityMetricsSchema = z16.object({
|
|
104105
104276
|
totalQuestions: z16.number().int().nonnegative().optional(),
|
|
104106
104277
|
correctQuestions: z16.number().int().nonnegative().optional(),
|
|
@@ -104152,13 +104323,13 @@ function validateActivityRequest(body, appConfig, env2) {
|
|
|
104152
104323
|
if (err instanceof ActivityCourseResolutionError) {
|
|
104153
104324
|
const selectorDesc = formatCourseSelector(payload.course);
|
|
104154
104325
|
if (err.code === "unknown_course") {
|
|
104155
|
-
|
|
104326
|
+
log12.warn("Unknown course selector", { selector: payload.course });
|
|
104156
104327
|
return {
|
|
104157
104328
|
ok: false,
|
|
104158
104329
|
response: jsonResponse({ success: false, error: `Unknown course: ${selectorDesc}` }, 400)
|
|
104159
104330
|
};
|
|
104160
104331
|
}
|
|
104161
|
-
|
|
104332
|
+
log12.error("Ambiguous course selector", { selector: payload.course });
|
|
104162
104333
|
return {
|
|
104163
104334
|
ok: false,
|
|
104164
104335
|
response: jsonResponse({ success: false, error: "Ambiguous course selector in timeback.config.json" }, 500)
|
|
@@ -104170,7 +104341,7 @@ function validateActivityRequest(body, appConfig, env2) {
|
|
|
104170
104341
|
const sensor = course.overrides?.[envForOverrides]?.sensor ?? course.sensor ?? appConfig.sensor;
|
|
104171
104342
|
if (!sensor) {
|
|
104172
104343
|
const selectorDesc = formatCourseSelector(payload.course);
|
|
104173
|
-
|
|
104344
|
+
log12.error("Missing sensor for course", { selector: payload.course });
|
|
104174
104345
|
return {
|
|
104175
104346
|
ok: false,
|
|
104176
104347
|
response: jsonResponse({
|
|
@@ -104181,6 +104352,9 @@ function validateActivityRequest(body, appConfig, env2) {
|
|
|
104181
104352
|
}
|
|
104182
104353
|
return { ok: true, payload, course, sensor };
|
|
104183
104354
|
}
|
|
104355
|
+
|
|
104356
|
+
// src/server/handlers/activity/handler.ts
|
|
104357
|
+
var log13 = createScopedLogger3("handlers:activity");
|
|
104184
104358
|
async function getActivityUserInfo(identity, req) {
|
|
104185
104359
|
if (identity.mode === "custom") {
|
|
104186
104360
|
const email8 = await identity.getEmail(req);
|
|
@@ -104192,55 +104366,18 @@ async function getActivityUserInfo(identity, req) {
|
|
|
104192
104366
|
function resolveTimebackId(userInfo, client) {
|
|
104193
104367
|
return userInfo.timebackId ?? lookupTimebackIdByEmail({ email: userInfo.email, client });
|
|
104194
104368
|
}
|
|
104195
|
-
function buildActivityEvents(params) {
|
|
104196
|
-
const { sensor, timebackId, email: email8, payload, course, appName, apiEnv } = params;
|
|
104197
|
-
const actor = {
|
|
104198
|
-
id: `urn:timeback:user:${timebackId}`,
|
|
104199
|
-
type: "TimebackUser",
|
|
104200
|
-
email: email8
|
|
104201
|
-
};
|
|
104202
|
-
const object7 = buildActivityContext(payload, course, appName, apiEnv, sensor);
|
|
104203
|
-
const metrics = buildActivityMetrics(payload.metrics);
|
|
104204
|
-
const timeSpentMetrics = buildTimeSpentMetrics(payload.elapsedMs, payload.pausedMs);
|
|
104205
|
-
const activityEvent = createActivityEvent2({
|
|
104206
|
-
actor,
|
|
104207
|
-
object: object7,
|
|
104208
|
-
metrics,
|
|
104209
|
-
eventTime: payload.endedAt,
|
|
104210
|
-
attempt: payload.attemptNumber,
|
|
104211
|
-
generatedExtensions: payload.pctCompleteApp === undefined ? undefined : { pctCompleteApp: payload.pctCompleteApp }
|
|
104212
|
-
});
|
|
104213
|
-
const timeSpentEvent = createTimeSpentEvent2({
|
|
104214
|
-
actor,
|
|
104215
|
-
object: object7,
|
|
104216
|
-
metrics: timeSpentMetrics,
|
|
104217
|
-
eventTime: payload.endedAt
|
|
104218
|
-
});
|
|
104219
|
-
return {
|
|
104220
|
-
sensor,
|
|
104221
|
-
actor,
|
|
104222
|
-
object: object7,
|
|
104223
|
-
events: [activityEvent, timeSpentEvent],
|
|
104224
|
-
payload,
|
|
104225
|
-
course,
|
|
104226
|
-
appName,
|
|
104227
|
-
apiEnv,
|
|
104228
|
-
email: email8,
|
|
104229
|
-
timebackId
|
|
104230
|
-
};
|
|
104231
|
-
}
|
|
104232
104369
|
function mapErrorToResponse(err, context) {
|
|
104233
104370
|
if (err instanceof TimebackUserResolutionError) {
|
|
104234
|
-
|
|
104371
|
+
log13.warn("Failed to resolve Timeback user", { code: err.code });
|
|
104235
104372
|
return jsonResponse({ success: false, error: "Unable to resolve Timeback identity" }, resolveStatusForUserResolutionError(err));
|
|
104236
104373
|
}
|
|
104237
104374
|
if (err instanceof MissingSyncedCourseIdError) {
|
|
104238
104375
|
const syncErr = err;
|
|
104239
|
-
|
|
104376
|
+
log13.error("Course not synced", { course: syncErr.course, env: syncErr.env });
|
|
104240
104377
|
return jsonResponse({ success: false, error: syncErr.message }, 500);
|
|
104241
104378
|
}
|
|
104242
104379
|
const message = err instanceof Error ? err.message : "Unknown error";
|
|
104243
|
-
|
|
104380
|
+
log13.error("Failed to submit activity", { ...context, error: message });
|
|
104244
104381
|
return jsonResponse({ success: false, error: message }, 502);
|
|
104245
104382
|
}
|
|
104246
104383
|
function createActivityHandler(config7) {
|
|
@@ -104286,8 +104423,20 @@ function createActivityHandler(config7) {
|
|
|
104286
104423
|
events: effective.events
|
|
104287
104424
|
});
|
|
104288
104425
|
}
|
|
104426
|
+
const courseId = effective.object.course?.id;
|
|
104427
|
+
if (courseId) {
|
|
104428
|
+
await writeGradebookEntry({
|
|
104429
|
+
client,
|
|
104430
|
+
courseId,
|
|
104431
|
+
activityId: effective.payload.id,
|
|
104432
|
+
activityName: effective.payload.name,
|
|
104433
|
+
timebackId: effective.timebackId,
|
|
104434
|
+
payload: effective.payload,
|
|
104435
|
+
appName: effective.appName
|
|
104436
|
+
});
|
|
104437
|
+
}
|
|
104289
104438
|
await sendCaliperEnvelope(client, effective.sensor, effective.events[0], effective.events[1]);
|
|
104290
|
-
|
|
104439
|
+
log13.debug("Submitted activity", {
|
|
104291
104440
|
courseSelector: payload.course,
|
|
104292
104441
|
activityId: payload.id
|
|
104293
104442
|
});
|
|
@@ -104302,27 +104451,191 @@ function createActivityHandler(config7) {
|
|
|
104302
104451
|
}
|
|
104303
104452
|
};
|
|
104304
104453
|
}
|
|
104305
|
-
// src/server/handlers/
|
|
104306
|
-
|
|
104454
|
+
// src/server/handlers/identity/oidc.ts
|
|
104455
|
+
async function handleSignIn(req, env2, identity) {
|
|
104456
|
+
if (identity.mode !== "sso") {
|
|
104457
|
+
ssoLog.warn("SSO not configured");
|
|
104458
|
+
return jsonResponse({ error: "SSO not configured" }, 400);
|
|
104459
|
+
}
|
|
104460
|
+
return await initiateSignIn({
|
|
104461
|
+
req,
|
|
104462
|
+
env: env2,
|
|
104463
|
+
clientId: identity.clientId,
|
|
104464
|
+
issuer: identity.issuer,
|
|
104465
|
+
redirectUri: identity.redirectUri,
|
|
104466
|
+
buildState: identity.buildState
|
|
104467
|
+
});
|
|
104468
|
+
}
|
|
104469
|
+
async function completeCallback(code, url7, state, req, env2, identity, api) {
|
|
104470
|
+
try {
|
|
104471
|
+
const issuer = identity.issuer ?? getIssuer(env2);
|
|
104472
|
+
const redirectUri = computeRedirectUri(url7, identity.redirectUri);
|
|
104473
|
+
ssoLog.debug("Exchanging auth code for tokens", { issuer, clientId: identity.clientId });
|
|
104474
|
+
const tokens = await exchangeCodeForTokens({
|
|
104475
|
+
issuer,
|
|
104476
|
+
clientId: identity.clientId,
|
|
104477
|
+
clientSecret: identity.clientSecret,
|
|
104478
|
+
code,
|
|
104479
|
+
redirectUri
|
|
104480
|
+
});
|
|
104481
|
+
const userInfo = await getUserInfo({ issuer, accessToken: tokens.access_token });
|
|
104482
|
+
const identities = typeof userInfo.identities === "string" ? JSON.parse(userInfo.identities) : userInfo.identities;
|
|
104483
|
+
ssoLog.debug("SSO completed, resolving Timeback user", {
|
|
104484
|
+
user: { ...userInfo, identities }
|
|
104485
|
+
});
|
|
104486
|
+
const authUser = await resolveTimebackUserByEmail({
|
|
104487
|
+
env: env2,
|
|
104488
|
+
apiCredentials: api.credentials,
|
|
104489
|
+
userInfo,
|
|
104490
|
+
client: api.getClient()
|
|
104491
|
+
});
|
|
104492
|
+
ssoLog.debug("Timeback user resolved", { timebackId: authUser.id });
|
|
104493
|
+
const ctx = {
|
|
104494
|
+
user: authUser,
|
|
104495
|
+
idp: { tokens, userInfo },
|
|
104496
|
+
state,
|
|
104497
|
+
req,
|
|
104498
|
+
redirect: redirectResponse,
|
|
104499
|
+
json: jsonResponse
|
|
104500
|
+
};
|
|
104501
|
+
return identity.onCallbackSuccess(ctx);
|
|
104502
|
+
} catch (err) {
|
|
104503
|
+
const error59 = err instanceof Error ? err : new Error("Unknown error");
|
|
104504
|
+
const errorCode = err instanceof TimebackUserResolutionError ? err.code : "token_exchange_failed";
|
|
104505
|
+
ssoLog.error("SSO callback failed", { error: error59.message, errorCode });
|
|
104506
|
+
if (identity.onCallbackError) {
|
|
104507
|
+
return identity.onCallbackError(buildErrorContext(error59, errorCode, state, req));
|
|
104508
|
+
}
|
|
104509
|
+
return jsonResponse({ error: error59.message }, 500);
|
|
104510
|
+
}
|
|
104511
|
+
}
|
|
104512
|
+
async function handleCallback(req, env2, identity, api) {
|
|
104513
|
+
if (identity.mode !== "sso") {
|
|
104514
|
+
ssoLog.warn("SSO not configured");
|
|
104515
|
+
return jsonResponse({ error: "SSO not configured" }, 400);
|
|
104516
|
+
}
|
|
104517
|
+
const { url: url7, code, errorParam, state } = parseCallback(req);
|
|
104518
|
+
if (errorParam) {
|
|
104519
|
+
return handleIdpError(errorParam, url7, state, req, identity.onCallbackError);
|
|
104520
|
+
}
|
|
104521
|
+
if (!code) {
|
|
104522
|
+
return handleMissingCode(state, req, identity.onCallbackError);
|
|
104523
|
+
}
|
|
104524
|
+
return await completeCallback(code, url7, state, req, env2, identity, api);
|
|
104525
|
+
}
|
|
104526
|
+
|
|
104527
|
+
// src/server/handlers/identity/handler.ts
|
|
104528
|
+
function createIdentityHandlers(params) {
|
|
104529
|
+
const { env: env2, identity, api } = params;
|
|
104530
|
+
return {
|
|
104531
|
+
signIn: (req) => handleSignIn(req, env2, identity),
|
|
104532
|
+
callback: (req) => handleCallback(req, env2, identity, api),
|
|
104533
|
+
signOut: () => redirectResponse("/")
|
|
104534
|
+
};
|
|
104535
|
+
}
|
|
104536
|
+
// src/server/handlers/user/enrollments.ts
|
|
104537
|
+
function buildCourseLookup(courses, apiEnv) {
|
|
104538
|
+
const courseById = new Map;
|
|
104539
|
+
for (const course of courses) {
|
|
104540
|
+
const courseId = course.ids?.[apiEnv];
|
|
104541
|
+
if (courseId) {
|
|
104542
|
+
courseById.set(courseId, course);
|
|
104543
|
+
}
|
|
104544
|
+
}
|
|
104545
|
+
return courseById;
|
|
104546
|
+
}
|
|
104547
|
+
function mapEnrollmentsToCourses(enrollments, courseById) {
|
|
104548
|
+
return enrollments.map((enrollment) => {
|
|
104549
|
+
const configuredCourse = courseById.get(enrollment.course.id);
|
|
104550
|
+
return {
|
|
104551
|
+
id: enrollment.course.id,
|
|
104552
|
+
code: configuredCourse?.courseCode ?? enrollment.course.id,
|
|
104553
|
+
name: enrollment.course.title
|
|
104554
|
+
};
|
|
104555
|
+
});
|
|
104556
|
+
}
|
|
104557
|
+
function pickGoalsFromEnrollments(enrollments) {
|
|
104558
|
+
return enrollments.map((enrollment) => enrollment.metadata?.goals).find(Boolean);
|
|
104559
|
+
}
|
|
104560
|
+
function getUtcDayRange(date10) {
|
|
104561
|
+
const start = new Date(Date.UTC(date10.getUTCFullYear(), date10.getUTCMonth(), date10.getUTCDate()));
|
|
104562
|
+
const end = new Date(Date.UTC(date10.getUTCFullYear(), date10.getUTCMonth(), date10.getUTCDate(), 23, 59, 59, 999));
|
|
104563
|
+
return { start, end };
|
|
104564
|
+
}
|
|
104565
|
+
function sumXp(facts) {
|
|
104566
|
+
return Object.values(facts).reduce((dateTotal, subjects) => {
|
|
104567
|
+
return dateTotal + Object.values(subjects).reduce((subjectTotal, metrics) => {
|
|
104568
|
+
return subjectTotal + (metrics.activityMetrics?.xpEarned ?? 0);
|
|
104569
|
+
}, 0);
|
|
104570
|
+
}, 0);
|
|
104571
|
+
}
|
|
104572
|
+
|
|
104573
|
+
// src/server/handlers/user/profile.ts
|
|
104574
|
+
async function buildUserProfile(client, user, appConfig, apiEnv) {
|
|
104575
|
+
const enrollments = await client.edubridge.enrollments.list({
|
|
104576
|
+
userId: user.id
|
|
104577
|
+
});
|
|
104578
|
+
const courseById = buildCourseLookup(appConfig.courses, apiEnv);
|
|
104579
|
+
const courses = mapEnrollmentsToCourses(enrollments, courseById);
|
|
104580
|
+
const goals = pickGoalsFromEnrollments(enrollments);
|
|
104581
|
+
const { start: todayStart, end: todayEnd } = getUtcDayRange(new Date);
|
|
104582
|
+
const [todayFacts, allFacts] = await Promise.all([
|
|
104583
|
+
client.edubridge.analytics.getActivity({
|
|
104584
|
+
studentId: user.id,
|
|
104585
|
+
startDate: todayStart.toISOString(),
|
|
104586
|
+
endDate: todayEnd.toISOString()
|
|
104587
|
+
}),
|
|
104588
|
+
client.edubridge.analytics.getActivity({
|
|
104589
|
+
studentId: user.id,
|
|
104590
|
+
startDate: "2000-01-01",
|
|
104591
|
+
endDate: todayEnd.toISOString()
|
|
104592
|
+
})
|
|
104593
|
+
]);
|
|
104594
|
+
return {
|
|
104595
|
+
id: user.id,
|
|
104596
|
+
email: user.email,
|
|
104597
|
+
name: user.name,
|
|
104598
|
+
school: user.school,
|
|
104599
|
+
grade: user.grade,
|
|
104600
|
+
courses: courses.length ? courses : undefined,
|
|
104601
|
+
goals,
|
|
104602
|
+
xp: {
|
|
104603
|
+
today: sumXp(todayFacts),
|
|
104604
|
+
all: sumXp(allFacts)
|
|
104605
|
+
}
|
|
104606
|
+
};
|
|
104607
|
+
}
|
|
104608
|
+
|
|
104609
|
+
// src/server/handlers/user/handler.ts
|
|
104610
|
+
var log14 = createScopedLogger3("handlers:user");
|
|
104611
|
+
async function getUserIdentity(identity, req) {
|
|
104612
|
+
if (identity.mode === "custom") {
|
|
104613
|
+
const email8 = await identity.getEmail(req);
|
|
104614
|
+
if (!email8)
|
|
104615
|
+
return;
|
|
104616
|
+
return { sub: email8, email: email8 };
|
|
104617
|
+
}
|
|
104618
|
+
const user = await identity.getUser(req);
|
|
104619
|
+
if (!user)
|
|
104620
|
+
return;
|
|
104621
|
+
return { sub: user.id, email: user.email };
|
|
104622
|
+
}
|
|
104623
|
+
function mapResolutionErrorToResponse(error59) {
|
|
104624
|
+
log14.warn("Timeback user resolution failed", { code: error59.code });
|
|
104625
|
+
if (error59.code === "timeback_user_ambiguous") {
|
|
104626
|
+
return jsonResponse({ error: "Timeback user resolution ambiguous" }, 409);
|
|
104627
|
+
}
|
|
104628
|
+
if (error59.code === "timeback_user_not_found") {
|
|
104629
|
+
return jsonResponse({ error: "Timeback user not found" }, 404);
|
|
104630
|
+
}
|
|
104631
|
+
return jsonResponse({ error: "User resolution failed" }, 500);
|
|
104632
|
+
}
|
|
104307
104633
|
function createUserHandler(config7) {
|
|
104308
104634
|
return async (req) => {
|
|
104309
104635
|
try {
|
|
104310
|
-
|
|
104311
|
-
|
|
104312
|
-
|
|
104313
|
-
const email8 = await config7.identity.getEmail(req);
|
|
104314
|
-
if (!email8) {
|
|
104315
|
-
return jsonResponse({ error: "Unauthorized" }, 401);
|
|
104316
|
-
}
|
|
104317
|
-
userSub = email8;
|
|
104318
|
-
userEmail = email8;
|
|
104319
|
-
} else {
|
|
104320
|
-
const user = await config7.identity.getUser(req);
|
|
104321
|
-
if (!user) {
|
|
104322
|
-
return jsonResponse({ error: "Unauthorized" }, 401);
|
|
104323
|
-
}
|
|
104324
|
-
userSub = user.id;
|
|
104325
|
-
userEmail = user.email;
|
|
104636
|
+
const userIdentity = await getUserIdentity(config7.identity, req);
|
|
104637
|
+
if (!userIdentity) {
|
|
104638
|
+
return jsonResponse({ error: "Unauthorized" }, 401);
|
|
104326
104639
|
}
|
|
104327
104640
|
const apiEnv = mapEnvForApi(config7.env);
|
|
104328
104641
|
const client = new TimebackClient({
|
|
@@ -104336,65 +104649,85 @@ function createUserHandler(config7) {
|
|
|
104336
104649
|
const resolved = await resolveTimebackUserByEmail({
|
|
104337
104650
|
env: config7.env,
|
|
104338
104651
|
apiCredentials: config7.api,
|
|
104339
|
-
userInfo:
|
|
104340
|
-
sub: userSub,
|
|
104341
|
-
email: userEmail
|
|
104342
|
-
},
|
|
104652
|
+
userInfo: userIdentity,
|
|
104343
104653
|
client
|
|
104344
104654
|
});
|
|
104345
|
-
const
|
|
104346
|
-
userId: resolved.id
|
|
104347
|
-
});
|
|
104348
|
-
const courseById = buildCourseLookup(config7.appConfig.courses, apiEnv);
|
|
104349
|
-
const courses = mapEnrollmentsToCourses(enrollments, courseById);
|
|
104350
|
-
const goals = pickGoalsFromEnrollments(enrollments);
|
|
104351
|
-
const { start: todayStart, end: todayEnd } = getUtcDayRange(new Date);
|
|
104352
|
-
const [todayFacts, allFacts] = await Promise.all([
|
|
104353
|
-
client.edubridge.analytics.getActivity({
|
|
104354
|
-
studentId: resolved.id,
|
|
104355
|
-
startDate: todayStart.toISOString(),
|
|
104356
|
-
endDate: todayEnd.toISOString()
|
|
104357
|
-
}),
|
|
104358
|
-
client.edubridge.analytics.getActivity({
|
|
104359
|
-
studentId: resolved.id,
|
|
104360
|
-
startDate: "2000-01-01",
|
|
104361
|
-
endDate: todayEnd.toISOString()
|
|
104362
|
-
})
|
|
104363
|
-
]);
|
|
104364
|
-
const profile = {
|
|
104365
|
-
id: resolved.id,
|
|
104366
|
-
email: resolved.email,
|
|
104367
|
-
name: resolved.name,
|
|
104368
|
-
school: resolved.school,
|
|
104369
|
-
grade: resolved.grade,
|
|
104370
|
-
courses: courses.length ? courses : undefined,
|
|
104371
|
-
goals,
|
|
104372
|
-
xp: {
|
|
104373
|
-
today: sumXp(todayFacts),
|
|
104374
|
-
all: sumXp(allFacts)
|
|
104375
|
-
}
|
|
104376
|
-
};
|
|
104655
|
+
const profile = await buildUserProfile(client, resolved, config7.appConfig, apiEnv);
|
|
104377
104656
|
return jsonResponse(profile);
|
|
104378
104657
|
} catch (error59) {
|
|
104379
104658
|
if (error59 instanceof TimebackUserResolutionError) {
|
|
104380
|
-
|
|
104659
|
+
return mapResolutionErrorToResponse(error59);
|
|
104660
|
+
}
|
|
104661
|
+
const message = error59 instanceof Error ? error59.message : "Unknown error";
|
|
104662
|
+
log14.error("Failed to build user profile", { error: message });
|
|
104663
|
+
return jsonResponse({ error: message }, 502);
|
|
104664
|
+
} finally {
|
|
104665
|
+
client.close();
|
|
104666
|
+
}
|
|
104667
|
+
} catch (error59) {
|
|
104668
|
+
const message = error59 instanceof Error ? error59.message : "Unknown error";
|
|
104669
|
+
log14.error("Unhandled error in user handler", { error: message });
|
|
104670
|
+
return jsonResponse({ error: message }, 500);
|
|
104671
|
+
}
|
|
104672
|
+
};
|
|
104673
|
+
}
|
|
104674
|
+
// src/server/handlers/user/verify.ts
|
|
104675
|
+
var log15 = createScopedLogger3("handlers:user:verify");
|
|
104676
|
+
async function getUserIdentity2(identity, req) {
|
|
104677
|
+
if (identity.mode === "custom") {
|
|
104678
|
+
const email8 = await identity.getEmail(req);
|
|
104679
|
+
if (!email8)
|
|
104680
|
+
return;
|
|
104681
|
+
return { sub: email8, email: email8 };
|
|
104682
|
+
}
|
|
104683
|
+
const user = await identity.getUser(req);
|
|
104684
|
+
if (!user)
|
|
104685
|
+
return;
|
|
104686
|
+
return { sub: user.id, email: user.email };
|
|
104687
|
+
}
|
|
104688
|
+
function createUserVerifyHandler(config7) {
|
|
104689
|
+
return async (req) => {
|
|
104690
|
+
try {
|
|
104691
|
+
const userIdentity = await getUserIdentity2(config7.identity, req);
|
|
104692
|
+
if (!userIdentity) {
|
|
104693
|
+
return jsonResponse({ verified: false, error: "Unauthorized" }, 401);
|
|
104694
|
+
}
|
|
104695
|
+
const client = new TimebackClient({
|
|
104696
|
+
env: mapEnvForApi(config7.env),
|
|
104697
|
+
auth: {
|
|
104698
|
+
clientId: config7.api.clientId,
|
|
104699
|
+
clientSecret: config7.api.clientSecret
|
|
104700
|
+
}
|
|
104701
|
+
});
|
|
104702
|
+
try {
|
|
104703
|
+
const resolved = await resolveTimebackUserByEmail({
|
|
104704
|
+
env: config7.env,
|
|
104705
|
+
apiCredentials: config7.api,
|
|
104706
|
+
userInfo: userIdentity,
|
|
104707
|
+
client
|
|
104708
|
+
});
|
|
104709
|
+
return jsonResponse({ verified: true, timebackId: resolved.id });
|
|
104710
|
+
} catch (error59) {
|
|
104711
|
+
if (error59 instanceof TimebackUserResolutionError) {
|
|
104712
|
+
log15.warn("Timeback user resolution failed", { code: error59.code });
|
|
104381
104713
|
if (error59.code === "timeback_user_ambiguous") {
|
|
104382
|
-
return jsonResponse({ error: "Timeback user resolution ambiguous" }, 409);
|
|
104714
|
+
return jsonResponse({ verified: false, error: "Timeback user resolution ambiguous" }, 409);
|
|
104383
104715
|
}
|
|
104384
104716
|
if (error59.code === "timeback_user_not_found") {
|
|
104385
|
-
return jsonResponse({
|
|
104717
|
+
return jsonResponse({ verified: false });
|
|
104386
104718
|
}
|
|
104719
|
+
return jsonResponse({ verified: false, error: error59.message }, 502);
|
|
104387
104720
|
}
|
|
104388
104721
|
const message = error59 instanceof Error ? error59.message : "Unknown error";
|
|
104389
|
-
|
|
104390
|
-
return jsonResponse({ error: message }, 502);
|
|
104722
|
+
log15.error("Failed to verify user", { error: message });
|
|
104723
|
+
return jsonResponse({ verified: false, error: message }, 502);
|
|
104391
104724
|
} finally {
|
|
104392
104725
|
client.close();
|
|
104393
104726
|
}
|
|
104394
104727
|
} catch (error59) {
|
|
104395
104728
|
const message = error59 instanceof Error ? error59.message : "Unknown error";
|
|
104396
|
-
|
|
104397
|
-
return jsonResponse({ error: message }, 500);
|
|
104729
|
+
log15.error("Unhandled error in verify handler", { error: message });
|
|
104730
|
+
return jsonResponse({ verified: false, error: message }, 500);
|
|
104398
104731
|
}
|
|
104399
104732
|
};
|
|
104400
104733
|
}
|
|
@@ -104409,6 +104742,7 @@ function toAppCourses(courses) {
|
|
|
104409
104742
|
});
|
|
104410
104743
|
}
|
|
104411
104744
|
async function createTimeback(config7) {
|
|
104745
|
+
const env2 = normalizeEnv(config7.env);
|
|
104412
104746
|
const configResult = await loadConfig({ configPath: config7.configPath });
|
|
104413
104747
|
if (!configResult.success) {
|
|
104414
104748
|
throw new Error(`Failed to load timeback config: ${configResult.error}`);
|
|
@@ -104418,7 +104752,7 @@ async function createTimeback(config7) {
|
|
|
104418
104752
|
const getApiClient = () => {
|
|
104419
104753
|
if (!apiClient) {
|
|
104420
104754
|
apiClient = new TimebackClient({
|
|
104421
|
-
env: mapEnvForApi(
|
|
104755
|
+
env: mapEnvForApi(env2),
|
|
104422
104756
|
auth: {
|
|
104423
104757
|
clientId: config7.api.clientId,
|
|
104424
104758
|
clientSecret: config7.api.clientSecret
|
|
@@ -104428,7 +104762,7 @@ async function createTimeback(config7) {
|
|
|
104428
104762
|
return apiClient;
|
|
104429
104763
|
};
|
|
104430
104764
|
const activity = createActivityHandler({
|
|
104431
|
-
env:
|
|
104765
|
+
env: env2,
|
|
104432
104766
|
identity: config7.identity,
|
|
104433
104767
|
appConfig: {
|
|
104434
104768
|
name: appConfig.name,
|
|
@@ -104439,7 +104773,7 @@ async function createTimeback(config7) {
|
|
|
104439
104773
|
hooks: config7.hooks
|
|
104440
104774
|
});
|
|
104441
104775
|
const identity = createIdentityHandlers({
|
|
104442
|
-
env:
|
|
104776
|
+
env: env2,
|
|
104443
104777
|
identity: config7.identity,
|
|
104444
104778
|
api: {
|
|
104445
104779
|
credentials: config7.api,
|
|
@@ -104447,7 +104781,7 @@ async function createTimeback(config7) {
|
|
|
104447
104781
|
}
|
|
104448
104782
|
});
|
|
104449
104783
|
const user = createUserHandler({
|
|
104450
|
-
env:
|
|
104784
|
+
env: env2,
|
|
104451
104785
|
identity: config7.identity,
|
|
104452
104786
|
api: config7.api,
|
|
104453
104787
|
appConfig: {
|
|
@@ -104456,13 +104790,19 @@ async function createTimeback(config7) {
|
|
|
104456
104790
|
courses: toAppCourses(appConfig.courses)
|
|
104457
104791
|
}
|
|
104458
104792
|
});
|
|
104793
|
+
const userVerify = createUserVerifyHandler({
|
|
104794
|
+
env: env2,
|
|
104795
|
+
identity: config7.identity,
|
|
104796
|
+
api: config7.api
|
|
104797
|
+
});
|
|
104459
104798
|
const instance = {
|
|
104460
104799
|
config: config7,
|
|
104461
104800
|
handle: {
|
|
104462
104801
|
activity,
|
|
104463
104802
|
identity,
|
|
104464
104803
|
user: {
|
|
104465
|
-
me: user
|
|
104804
|
+
me: user,
|
|
104805
|
+
verify: userVerify
|
|
104466
104806
|
}
|
|
104467
104807
|
},
|
|
104468
104808
|
get api() {
|
|
@@ -104471,74 +104811,21 @@ async function createTimeback(config7) {
|
|
|
104471
104811
|
};
|
|
104472
104812
|
return instance;
|
|
104473
104813
|
}
|
|
104474
|
-
// src/server/handlers/identity-only.ts
|
|
104475
|
-
function buildErrorContext2(error59, errorCode, state, req) {
|
|
104476
|
-
return {
|
|
104477
|
-
error: error59,
|
|
104478
|
-
errorCode,
|
|
104479
|
-
state,
|
|
104480
|
-
req,
|
|
104481
|
-
redirect: redirectResponse,
|
|
104482
|
-
json: jsonResponse
|
|
104483
|
-
};
|
|
104484
|
-
}
|
|
104485
|
-
function tryDecodeState2(stateParam) {
|
|
104486
|
-
try {
|
|
104487
|
-
return decodeBase64Url(stateParam);
|
|
104488
|
-
} catch {
|
|
104489
|
-
ssoLog.warn("Failed to decode state");
|
|
104490
|
-
return;
|
|
104491
|
-
}
|
|
104492
|
-
}
|
|
104493
|
-
function handleCallbackError2(errorParam, url7, state, req, identity) {
|
|
104494
|
-
const errorDesc = url7.searchParams.get("error_description");
|
|
104495
|
-
ssoLog.error("IdP returned error", { error: errorParam, description: errorDesc });
|
|
104496
|
-
const error59 = new Error(errorDesc ?? errorParam);
|
|
104497
|
-
if (identity.onCallbackError) {
|
|
104498
|
-
return identity.onCallbackError(buildErrorContext2(error59, errorParam, state, req));
|
|
104499
|
-
}
|
|
104500
|
-
return jsonResponse({ error: errorParam }, 400);
|
|
104501
|
-
}
|
|
104502
|
-
function handleMissingCode2(state, req, identity) {
|
|
104503
|
-
ssoLog.error("Missing authorization code in callback");
|
|
104504
|
-
const error59 = new Error("Missing authorization code");
|
|
104505
|
-
if (identity.onCallbackError) {
|
|
104506
|
-
return identity.onCallbackError(buildErrorContext2(error59, "missing_code", state, req));
|
|
104507
|
-
}
|
|
104508
|
-
return jsonResponse({ error: "Missing authorization code" }, 400);
|
|
104509
|
-
}
|
|
104814
|
+
// src/server/handlers/identity-only/oidc.ts
|
|
104510
104815
|
async function handleSignIn2(req, env2, identity) {
|
|
104511
|
-
|
|
104512
|
-
|
|
104513
|
-
let redirectUri = identity.redirectUri;
|
|
104514
|
-
if (!redirectUri) {
|
|
104515
|
-
const basePath = url7.pathname.replace(ROUTES.IDENTITY.SIGNIN, "");
|
|
104516
|
-
redirectUri = `${url7.origin}${basePath}${ROUTES.IDENTITY.CALLBACK}`;
|
|
104517
|
-
}
|
|
104518
|
-
ssoLog.debug("SSO sign-in initiated (identity-only)", {
|
|
104816
|
+
return await initiateSignIn({
|
|
104817
|
+
req,
|
|
104519
104818
|
env: env2,
|
|
104520
|
-
issuer,
|
|
104521
|
-
clientId: identity.clientId,
|
|
104522
|
-
redirectUri
|
|
104523
|
-
});
|
|
104524
|
-
const stateData = identity.buildState ? identity.buildState({ req, url: url7 }) : {};
|
|
104525
|
-
const state = encodeBase64Url(stateData);
|
|
104526
|
-
const authUrl = await buildAuthorizationUrl({
|
|
104527
|
-
issuer,
|
|
104528
104819
|
clientId: identity.clientId,
|
|
104529
|
-
|
|
104530
|
-
|
|
104820
|
+
issuer: identity.issuer,
|
|
104821
|
+
redirectUri: identity.redirectUri,
|
|
104822
|
+
buildState: identity.buildState
|
|
104531
104823
|
});
|
|
104532
|
-
return redirectResponse(authUrl);
|
|
104533
104824
|
}
|
|
104534
|
-
async function
|
|
104825
|
+
async function completeCallback2(code, url7, state, req, env2, identity) {
|
|
104535
104826
|
try {
|
|
104536
104827
|
const issuer = identity.issuer ?? getIssuer(env2);
|
|
104537
|
-
|
|
104538
|
-
if (!redirectUri) {
|
|
104539
|
-
const basePath = url7.pathname.replace(ROUTES.IDENTITY.CALLBACK, "");
|
|
104540
|
-
redirectUri = `${url7.origin}${basePath}${ROUTES.IDENTITY.CALLBACK}`;
|
|
104541
|
-
}
|
|
104828
|
+
const redirectUri = computeRedirectUri(url7, identity.redirectUri);
|
|
104542
104829
|
ssoLog.debug("Exchanging auth code for tokens (identity-only)", {
|
|
104543
104830
|
issuer,
|
|
104544
104831
|
clientId: identity.clientId
|
|
@@ -104566,29 +104853,23 @@ async function completeCallbackIdentityOnly(code, url7, state, req, env2, identi
|
|
|
104566
104853
|
const error59 = err instanceof Error ? err : new Error("Unknown error");
|
|
104567
104854
|
ssoLog.error("Token exchange failed (identity-only)", { error: error59.message });
|
|
104568
104855
|
if (identity.onCallbackError) {
|
|
104569
|
-
return identity.onCallbackError(
|
|
104856
|
+
return identity.onCallbackError(buildErrorContext(error59, undefined, state, req));
|
|
104570
104857
|
}
|
|
104571
104858
|
return jsonResponse({ error: error59.message }, 500);
|
|
104572
104859
|
}
|
|
104573
104860
|
}
|
|
104574
|
-
async function
|
|
104575
|
-
const url7 =
|
|
104576
|
-
const code = url7.searchParams.get("code");
|
|
104577
|
-
const errorParam = url7.searchParams.get("error");
|
|
104578
|
-
const stateParam = url7.searchParams.get("state");
|
|
104579
|
-
ssoLog.debug("Received callback from IdP (identity-only)", {
|
|
104580
|
-
hasCode: !!code,
|
|
104581
|
-
error: errorParam
|
|
104582
|
-
});
|
|
104583
|
-
const state = stateParam ? tryDecodeState2(stateParam) : undefined;
|
|
104861
|
+
async function handleCallback2(req, env2, identity) {
|
|
104862
|
+
const { url: url7, code, errorParam, state } = parseCallback(req);
|
|
104584
104863
|
if (errorParam) {
|
|
104585
|
-
return
|
|
104864
|
+
return handleIdpError(errorParam, url7, state, req, identity.onCallbackError);
|
|
104586
104865
|
}
|
|
104587
104866
|
if (!code) {
|
|
104588
|
-
return
|
|
104867
|
+
return handleMissingCode(state, req, identity.onCallbackError);
|
|
104589
104868
|
}
|
|
104590
|
-
return await
|
|
104869
|
+
return await completeCallback2(code, url7, state, req, env2, identity);
|
|
104591
104870
|
}
|
|
104871
|
+
|
|
104872
|
+
// src/server/handlers/identity-only/handler.ts
|
|
104592
104873
|
function createIdentityOnlyHandlers(params) {
|
|
104593
104874
|
const { env: env2, identity } = params;
|
|
104594
104875
|
if (identity.mode !== "sso") {
|
|
@@ -104596,15 +104877,14 @@ function createIdentityOnlyHandlers(params) {
|
|
|
104596
104877
|
}
|
|
104597
104878
|
return {
|
|
104598
104879
|
signIn: (req) => handleSignIn2(req, env2, identity),
|
|
104599
|
-
callback: (req) =>
|
|
104880
|
+
callback: (req) => handleCallback2(req, env2, identity),
|
|
104600
104881
|
signOut: () => redirectResponse("/")
|
|
104601
104882
|
};
|
|
104602
104883
|
}
|
|
104603
|
-
|
|
104604
104884
|
// src/server/timeback-identity.ts
|
|
104605
104885
|
function createTimebackIdentity(config7) {
|
|
104606
104886
|
const identity = createIdentityOnlyHandlers({
|
|
104607
|
-
env: config7.env,
|
|
104887
|
+
env: normalizeEnv(config7.env),
|
|
104608
104888
|
identity: config7.identity
|
|
104609
104889
|
});
|
|
104610
104890
|
return {
|