+ CATAPULT (cmi5 Advanced Testing Application and Player Underpinning Learning Technologies) is a project funded by ADL to develop tools and resources to support the adoption of cmi5 and xAPI across the Department of Defense (DoD) enterprise. The cornerstone tool within this project is the development and delivery of cmi5 Prototype Software (PTS) including a cmi5 player test suite, a cmi5 player and a cmi5 Content Test Suite (CTS).
+
+ There are no users currently in the service. This form will allow you to setup the first user account. Once the service is initialized it can't be done again without clearing all data and creating a new first user, this means you MUST keep track of the username and password set now.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Initialize First User
+
+
+
+
+
+
+
diff --git a/KST-ASD-BI-101/cts/client/src/components/unauthenticated/signIn.vue b/KST-ASD-BI-101/cts/client/src/components/unauthenticated/signIn.vue
new file mode 100644
index 0000000..ef22419
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/components/unauthenticated/signIn.vue
@@ -0,0 +1,92 @@
+
+
+
+
+ {{ errMsg }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keep me signed in
+
+
+ Sign In
+
+
+
+
+
+
+
diff --git a/KST-ASD-BI-101/cts/client/src/main.js b/KST-ASD-BI-101/cts/client/src/main.js
new file mode 100644
index 0000000..1c9628c
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/main.js
@@ -0,0 +1,60 @@
+/*
+ Copyright 2020 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+import Vue from "vue";
+import BootstrapVue from "bootstrap-vue";
+import Moment from "vue-moment";
+import faker from "faker/locale/en";
+import router from "./router";
+import store from "./store";
+import app from "./components/app.vue";
+
+import "./main.scss";
+
+Vue.use(Moment);
+Vue.use(BootstrapVue);
+
+Vue.config.productionTip = false;
+
+Object.defineProperty(
+ Vue.prototype,
+ "$faker",
+ {
+ get () {
+ return faker;
+ }
+ }
+);
+
+const provision = async () => {
+ //
+ // Try to init the credential to see if there is a cookie already
+ // available, if so, the login screen won't be presented, otherwise
+ // they need to enter their username and password and optionally get
+ // a cookie set, if they don't request a cookie then a refresh of the
+ // page will re-present the login form
+ //
+ await store.dispatch("service/apiAccess/initCredential");
+
+ new Vue(
+ {
+ router,
+ store,
+ render: (h) => h(app)
+ }
+ ).$mount("#app");
+};
+
+provision();
diff --git a/KST-ASD-BI-101/cts/client/src/main.scss b/KST-ASD-BI-101/cts/client/src/main.scss
new file mode 100644
index 0000000..c8fa943
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/main.scss
@@ -0,0 +1,17 @@
+/*
+ Copyright 2020 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+@import "../node_modules/bootstrap/scss/bootstrap";
+@import "../node_modules/bootstrap-vue/src/index.scss";
diff --git a/KST-ASD-BI-101/cts/client/src/router/index.js b/KST-ASD-BI-101/cts/client/src/router/index.js
new file mode 100644
index 0000000..9ba5f6c
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/router/index.js
@@ -0,0 +1,122 @@
+/*
+ Copyright 2020 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+import Vue from "vue";
+import VueRouter from "vue-router";
+
+import notFound from "@/components/notFound";
+import courseList from "@/components/authenticated/course/list";
+import courseDetail from "@/components/authenticated/course/detail";
+import courseNew from "@/components/authenticated/course/new";
+import courseNewUpload from "@/components/authenticated/course/new/upload";
+import courseNewXmlEditor from "@/components/authenticated/course/new/xmlEditor";
+import courseDetailStructure from "@/components/authenticated/course/detail/structure";
+import courseDetailTestList from "@/components/authenticated/course/detail/testList";
+import testNew from "@/components/authenticated/test/new";
+import testDetail from "@/components/authenticated/test/detail";
+import sessionDetail from "@/components/authenticated/session/detail";
+import requirementsList from "@/components/authenticated/requirements/list";
+import admin from "@/components/authenticated/admin";
+import adminAbout from "@/components/authenticated/admin/about";
+import adminUserList from "@/components/authenticated/admin/user/list";
+
+Vue.use(VueRouter);
+
+const idPropToNumber = ({params}) => ({id: Number.parseInt(params.id, 10)}),
+ router = new VueRouter(
+ {
+ routes: [
+ {
+ path: "/course-new",
+ component: courseNew,
+ children: [
+ {
+ path: "upload",
+ component: courseNewUpload
+ },
+ {
+ path: "xml-editor",
+ component: courseNewXmlEditor
+ }
+ ]
+ },
+ {
+ path: "/course/:id",
+ component: courseDetail,
+ props: idPropToNumber,
+ children: [
+ {
+ path: "structure",
+ component: courseDetailStructure,
+ props: idPropToNumber
+ },
+ {
+ path: "",
+ component: courseDetailTestList,
+ props: idPropToNumber
+ }
+ ]
+ },
+ {
+ path: "/test-new/:courseId",
+ component: testNew,
+ props: ({params}) => ({courseId: Number.parseInt(params.courseId, 10)})
+ },
+ {
+ path: "/test/:id",
+ component: testDetail,
+ props: idPropToNumber
+ },
+ {
+ path: "/session/:id",
+ component: sessionDetail,
+ props: idPropToNumber
+ },
+ {
+ path: "/requirements",
+ component: requirementsList
+ },
+ {
+ path: "/admin",
+ component: admin,
+ children: [
+ {
+ path: "user-list/:initPage?",
+ component: adminUserList,
+ props: true
+ },
+ {
+ path: "",
+ component: adminAbout
+ }
+ ]
+ },
+ {
+ path: "/:initPage?",
+ component: courseList,
+ props: true
+ },
+ {
+ path: "*",
+ component: notFound,
+ props: (route) => ({
+ path: route.params.pathMatch
+ })
+ }
+ ]
+ }
+ );
+
+export default router;
diff --git a/KST-ASD-BI-101/cts/client/src/store/alerts.js b/KST-ASD-BI-101/cts/client/src/store/alerts.js
new file mode 100644
index 0000000..0e53971
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/store/alerts.js
@@ -0,0 +1,72 @@
+/*
+ Copyright 2020 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+import Vue from "vue";
+
+const initialState = () => ({
+ cacheContainer: {},
+});
+
+export default {
+ namespaced: true,
+ state: {
+ initialState,
+ ...initialState()
+ },
+ getters: {
+ list: (state) => (kind) => {
+ if (! state.cacheContainer[kind]) {
+ return;
+ }
+
+ return state.cacheContainer[kind];
+ }
+ },
+ mutations: {
+ add (state, {kind, payload}) {
+ if (! state.cacheContainer[kind]) {
+ Vue.set(
+ state.cacheContainer,
+ kind,
+ []
+ );
+ }
+
+ state.cacheContainer[kind].push(
+ {
+ id: Date.now(),
+ variant: "danger",
+ ...payload
+ }
+ );
+ },
+ remove (state, {kind, id}) {
+ const alerts = state.cacheContainer[kind],
+ alertIndex = alerts.findIndex((i) => i.id === id);
+
+ if (alertIndex !== -1) {
+ alerts.splice(alertIndex, 1);
+ }
+ }
+ },
+ actions: {
+ add: ({commit}, {kind, payload}) => {
+ commit("add", {kind, payload});
+ },
+ remove: ({commit}, {kind, id}) => {
+ commit("remove", {kind, id});
+ }
+ }
+};
diff --git a/KST-ASD-BI-101/cts/client/src/store/index.js b/KST-ASD-BI-101/cts/client/src/store/index.js
new file mode 100644
index 0000000..d75ea8d
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/store/index.js
@@ -0,0 +1,47 @@
+/*
+ Copyright 2020 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+import Vue from "vue";
+import Vuex from "vuex";
+import alerts from "./alerts";
+import service from "./service";
+
+Vue.use(Vuex);
+
+export default new Vuex.Store(
+ {
+ namespaced: true,
+ modules: {
+ alerts,
+ service
+ },
+ mutations: {
+ resetState (state) {
+ Object.assign(state.alerts, state.alerts.initialState());
+
+ Object.assign(state.service.sessions, state.service.sessions.initialState());
+ Object.assign(state.service.tests, state.service.tests.initialState());
+ Object.assign(state.service.courses, state.service.courses.initialState());
+
+ // logout shouldn't clear isBootstrapped
+ const apiAccess = state.service.apiAccess.initialState();
+
+ apiAccess.isBootstrapped = state.service.apiAccess.isBootstrapped;
+
+ Object.assign(state.service.apiAccess, apiAccess);
+ }
+ }
+ }
+);
diff --git a/KST-ASD-BI-101/cts/client/src/store/service.js b/KST-ASD-BI-101/cts/client/src/store/service.js
new file mode 100644
index 0000000..c3951de
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/store/service.js
@@ -0,0 +1,49 @@
+/*
+ Copyright 2020 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+import apiAccess from "./service/apiAccess";
+import courses from "./service/courses";
+import tests from "./service/tests";
+import sessions from "./service/sessions";
+import users from "./service/users";
+
+export default {
+ namespaced: true,
+ getters: {
+ baseApiUrl: () => process.env.VUE_APP_API_URL ? process.env.VUE_APP_API_URL : "",
+ makeApiRequest: (state, getters) => (resource, cfg = {}) => {
+ const fetchCfg = {
+ ...cfg
+ };
+
+ if (state.apiAccess.username) {
+ fetchCfg.headers = fetchCfg.headers || {};
+ fetchCfg.headers.Authorization = `Basic ${Buffer.from(`${state.apiAccess.username}:${state.apiAccess.password}`).toString("base64")}`;
+ }
+ else {
+ fetchCfg.credentials = "include";
+ }
+
+ return fetch(`${getters.baseApiUrl}/api/v1/${resource}`, fetchCfg);
+ }
+ },
+ modules: {
+ apiAccess,
+ courses,
+ tests,
+ sessions,
+ users
+ }
+};
diff --git a/KST-ASD-BI-101/cts/client/src/store/service/apiAccess.js b/KST-ASD-BI-101/cts/client/src/store/service/apiAccess.js
new file mode 100644
index 0000000..fdcec3f
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/store/service/apiAccess.js
@@ -0,0 +1,214 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+const initialState = () => ({
+ isBootstrapped: null,
+ loading: false,
+ error: false,
+ errMsg: "",
+ access: false,
+ item: null,
+ username: null,
+ password: null,
+ expiresAt: null
+});
+
+export default {
+ namespaced: true,
+ state: {
+ initialState,
+ ...initialState()
+ },
+ getters: {
+ current: (state) => state.item,
+ isAdmin: (state) => () => {
+ if (state.access && state.item && state.item.roles && state.item.roles.includes("admin")) {
+ return true;
+ }
+
+ return false;
+ }
+ },
+ mutations: {
+ set: (state, {property, value}) => {
+ state[property] = value;
+ }
+ },
+ actions: {
+ initCredential: async ({commit, rootGetters}) => {
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ "login",
+ {
+ method: "GET"
+ }
+ );
+
+ if (! response.ok) {
+ if (response.status === 401) {
+ let body = await response.json();
+
+ if (typeof body.isBootstrapped !== "undefined") {
+ commit("set", {property: "isBootstrapped", value: body.isBootstrapped});
+ }
+
+ return;
+ }
+
+ throw new Error(`Request failed: ${response.status}`);
+ }
+
+ let body = await response.json();
+
+ commit("set", {property: "item", value: body.user});
+ commit("set", {property: "access", value: true});
+ commit("set", {property: "expiresAt", value: body.expiresAt});
+ commit("set", {property: "isBootstrapped", value: true});
+ }
+ catch (ex) {
+ console.log(ex);
+ }
+ },
+
+ storeCredential: async ({commit, rootGetters}, {username, password, storeCookie = false}) => {
+ commit("set", {property: "error", value: false});
+ commit("set", {property: "errMsg", value: ""});
+ commit("set", {property: "loading", value: true});
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ "login",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ username,
+ password,
+ storeCookie
+ })
+ }
+ );
+
+ if (! response.ok) {
+ if (response.status === 401) {
+ throw "Your username and / or password is incorrect. Please try again.";
+ }
+
+ throw new Error(`Request failed: ${response.status}`);
+ }
+
+ let body = await response.json();
+
+ //
+ // if they didn't want to be remembered then we don't get a cookie
+ // in which case we just need to store the username/password and then
+ // make the requests set the basic auth
+ //
+ if (! storeCookie) {
+ commit("set", {property: "username", value: username});
+ commit("set", {property: "password", value: password});
+ }
+
+ commit("set", {property: "item", value: body});
+ commit("set", {property: "access", value: true});
+ commit("set", {property: "expiresAt", value: body.expiresAt});
+ }
+ catch (ex) {
+ commit("set", {property: "error", value: true});
+ commit("set", {property: "errMsg", value: ex});
+ }
+ finally {
+ commit("set", {property: "loading", value: false});
+ }
+ },
+
+ clearCredential: async ({commit, rootGetters}) => {
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ "logout",
+ {
+ method: "GET"
+ }
+ );
+
+ if (! response.ok) {
+ if (response.status === 401) {
+ return;
+ }
+
+ throw new Error(`Request failed: ${response.status}`);
+ }
+ }
+ catch (ex) {
+ console.log(ex);
+ }
+
+ commit("resetState", null, {root: true});
+ },
+
+ clearCredentialTimeout: async ({commit, dispatch}) => {
+ await dispatch("clearCredential");
+ commit("set", {property: "error", value: true});
+ commit("set", {property: "errMsg", value: "Your session has timed out, please sign in again."});
+ },
+
+ bootstrap: async ({commit, rootGetters}, {username, password}) => {
+ commit("set", {property: "error", value: false});
+ commit("set", {property: "errMsg", value: ""});
+ commit("set", {property: "loading", value: true});
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ "bootstrap",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ firstUser: {
+ username,
+ password
+ }
+ })
+ }
+ );
+
+ if (! response.ok) {
+ if (response.status === 409) {
+ throw "Service has already been initialized. Verify potential security issue.";
+ }
+
+ throw new Error(`Request failed: ${response.status}`);
+ }
+
+ //
+ // successful response means the service is now setup, mark it
+ // as initialized which should then force them to login
+ //
+ commit("set", {property: "isBootstrapped", value: true});
+ }
+ catch (ex) {
+ commit("set", {property: "error", value: true});
+ commit("set", {property: "errMsg", value: ex});
+ }
+ finally {
+ commit("set", {property: "loading", value: false});
+ }
+ }
+ }
+};
diff --git a/KST-ASD-BI-101/cts/client/src/store/service/courses.js b/KST-ASD-BI-101/cts/client/src/store/service/courses.js
new file mode 100644
index 0000000..b6f7798
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/store/service/courses.js
@@ -0,0 +1,257 @@
+/*
+ Copyright 2020 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+import Vue from "vue";
+
+const initialState = () => ({
+ detailCache: {},
+ cacheContainer: {},
+ defaultKeyProperties: {}
+ }),
+ wrapItem = (item, {loading = false, loaded = false}) => ({
+ item,
+ loading,
+ loaded,
+ error: false,
+ errMsg: null
+ }),
+ populateItem = (item) => {
+ return item;
+ };
+
+export default {
+ namespaced: true,
+ state: {
+ initialState,
+ ...initialState()
+ },
+ getters: {
+ cacheKey: (state) => () => {
+ const cacheKey = "key";
+
+ if (! state.cacheContainer[cacheKey]) {
+ Vue.set(
+ state.cacheContainer,
+ cacheKey,
+ {
+ loaded: false,
+ loading: false,
+ items: [],
+ currentIndex: null
+ }
+ );
+ }
+
+ return cacheKey;
+ },
+ defaultCacheKey: (state, getters) => getters.cacheKey({props: state.defaultKeyProperties}),
+
+ cache: (state) => ({cacheKey}) => state.cacheContainer[cacheKey],
+ defaultCache: (state, getters) => getters.cache({cacheKey: getters.defaultCacheKey}),
+
+ byId: (state) => ({id}) => {
+ if (! state.detailCache[id]) {
+ Vue.set(
+ state.detailCache,
+ id,
+ wrapItem(
+ populateItem({
+ id
+ }),
+ {
+ loaded: false
+ }
+ )
+ );
+ }
+
+ return state.detailCache[id];
+ }
+ },
+ actions: {
+ alert: ({dispatch}, {content, variant = "danger", kind = "courseList"}) => {
+ dispatch(
+ "alerts/add",
+ {
+ kind,
+ payload: {
+ content,
+ variant
+ }
+ },
+ {root: true}
+ );
+ },
+
+ loadById: async ({getters, rootGetters}, {id, force = false}) => {
+ const fromCache = getters.byId({id});
+
+ if (fromCache.loaded && ! force) {
+ return;
+ }
+
+ fromCache.loading = true;
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](`courses/${id}`);
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${response.status}`);
+ }
+
+ const body = await response.json();
+
+ fromCache.item = populateItem(body);
+ fromCache.loaded = true;
+ }
+ catch (ex) {
+ fromCache.error = true;
+ fromCache.errMsg = `Failed to load from id: ${ex}`;
+ }
+ finally {
+ fromCache.loading = false;
+ }
+ },
+
+ load: async ({dispatch, state, getters, rootGetters}, {props = state.defaultKeyProperties, force = false} = {}) => {
+ const cache = getters.cache({cacheKey: getters.cacheKey({props})}),
+ busyKey = "loading";
+
+ if (cache.loaded || cache.loading) {
+ if (! force) {
+ return;
+ }
+
+ cache.items = cache.items.filter((i) => i.id === null);
+ }
+
+ cache[busyKey] = true;
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"]("courses"),
+ lastNewItemIndex = cache.items.findIndex((i) => i.id !== null);
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${response.status}`);
+ }
+
+ const body = await response.json();
+
+ cache.items.splice(
+ lastNewItemIndex === -1 ? cache.items.length : lastNewItemIndex + 1,
+ 0,
+ ...body.items
+ );
+ cache.loaded = true;
+
+ for (const i of body.items) {
+ if (! state.detailCache[i.id]) {
+ Vue.set(
+ state.detailCache,
+ i.id,
+ wrapItem(i, {loaded: true})
+ );
+ }
+ }
+ }
+ catch (ex) {
+ const content = `Failed to load courses: ${ex}`
+
+ cache.err = true;
+ cache.errMsg = content;
+
+ dispatch("alert", {content});
+ }
+
+ // eslint-disable-next-line require-atomic-updates
+ cache[busyKey] = false;
+ },
+
+ delete: async ({dispatch, getters, rootGetters}, {item}) => {
+ const cache = getters.defaultCache,
+ itemIndex = cache.items.findIndex((i) => i === item);
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ `courses/${item.id}`,
+ {
+ method: "DELETE",
+ mode: "cors"
+ }
+ );
+
+ if (response.ok) {
+ if (itemIndex !== -1) {
+ cache.items.splice(itemIndex, 1);
+
+ if (cache.currentIndex === itemIndex) {
+ cache.currentIndex = null;
+ }
+ }
+ }
+ else {
+ const body = await response.json();
+
+ throw new Error(`${response.status} - ${body.message}`);
+ }
+ }
+ catch (ex) {
+ dispatch("alert", {content: `Failed to delete course (id: ${item.id}): ${ex}`});
+
+ return false;
+ }
+
+ return true;
+ },
+
+ import: async ({dispatch, state, getters, rootGetters}, {body, contentType}) => {
+ const kind = "courseNew";
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ "courses",
+ {
+ method: "POST",
+ mode: "cors",
+ headers: {
+ "Content-Type": contentType
+ },
+ body
+ }
+ );
+
+ let responseBody = await response.json();
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${responseBody.message ? responseBody.message : "no message"} (${response.status})`);
+ }
+
+ responseBody = populateItem(responseBody);
+
+ Vue.set(state.detailCache, responseBody.id, wrapItem(responseBody, {loaded: true}));
+ getters.cache({cacheKey: getters.cacheKey()}).items.unshift(responseBody);
+
+ state.cacheContainer
+
+ return responseBody.id;
+ }
+ catch (ex) {
+ dispatch("alert", {content: `Failed to import course: ${ex}`, kind});
+ }
+
+ return null;
+ }
+ }
+};
diff --git a/KST-ASD-BI-101/cts/client/src/store/service/sessions.js b/KST-ASD-BI-101/cts/client/src/store/service/sessions.js
new file mode 100644
index 0000000..9cb0935
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/store/service/sessions.js
@@ -0,0 +1,184 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+import Vue from "vue";
+import logs from "./sessions/logs";
+
+const initialState = () => ({
+ detailCache: {}
+ }),
+ wrapItem = (item, {loading = false, loaded = false}) => ({
+ item,
+ loading,
+ loaded,
+ error: false,
+ errMsg: null
+ }),
+ populateItem = (item) => {
+ item._listen = false;
+ item._listener = null;
+ item._events = [];
+
+ return item;
+ };
+
+export default {
+ namespaced: true,
+ modules: {
+ logs
+ },
+ state: {
+ initialState,
+ ...initialState()
+ },
+ getters: {
+ byId: (state) => ({id}) => {
+ if (! state.detailCache[id]) {
+ Vue.set(
+ state.detailCache,
+ id,
+ wrapItem(
+ populateItem({
+ id
+ }),
+ {
+ loaded: false
+ }
+ )
+ );
+ }
+
+ return state.detailCache[id];
+ }
+ },
+ actions: {
+ alert: ({dispatch}, {content, variant = "danger", kind = "sessionDetail"}) => {
+ dispatch(
+ "alerts/add",
+ {
+ kind,
+ payload: {
+ content,
+ variant
+ }
+ },
+ {root: true}
+ );
+ },
+
+ loadById: async ({getters, rootGetters}, {id, force = false}) => {
+ const fromCache = getters.byId({id});
+
+ if (fromCache.loaded && ! force) {
+ return;
+ }
+
+ fromCache.loading = true;
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](`sessions/${id}`);
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${response.status}`);
+ }
+
+ let body = await response.json();
+
+ body = populateItem(body);
+
+ fromCache.item = body;
+ fromCache.loaded = true;
+ }
+ catch (ex) {
+ fromCache.error = true;
+ fromCache.errMsg = `Failed to load from id: ${ex}`;
+ }
+ finally {
+ fromCache.loading = false;
+ }
+ },
+
+ create: async ({dispatch, state, rootGetters}, {testId, auIndex, launchCfg = {}, launchMode, caller = "testDetail"}) => {
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ `sessions`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ testId,
+ auIndex,
+ launchMode,
+ contextTemplateAdditions: launchCfg.contextTemplateAdditions,
+ launchParameters: launchCfg.launchParameters,
+ masteryScore: launchCfg.masteryScore,
+ moveOn: launchCfg.moveOn,
+ alternateEntitlementKey: launchCfg.alternateEntitlementKey
+ })
+ }
+ );
+
+ let responseBody = await response.json();
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${responseBody.message ? responseBody.message : "no message"} (${response.status}${responseBody.srcError ? " - " + responseBody.srcError : ""})`);
+ }
+ responseBody = populateItem(responseBody);
+
+ if (launchCfg.launchMethod) {
+ responseBody.launchMethod = launchCfg.launchMethod;
+ }
+
+ Vue.set(state.detailCache, responseBody.id, wrapItem(responseBody, {loaded: true}));
+
+ return responseBody.id;
+ }
+ catch (ex) {
+ dispatch("alert", {content: `Failed to create session: ${ex}`, kind: caller});
+ }
+
+ return null;
+ },
+
+ abandon: async ({dispatch, rootGetters}, {id}) => {
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ `sessions/${id}/abandon`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ }
+ }
+ );
+
+ if (response.status === 204) {
+ return;
+ }
+
+ let responseBody = await response.json();
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${responseBody.message ? responseBody.message : "no message"} (${response.status}${responseBody.srcError ? " - " + responseBody.srcError : ""})`);
+ }
+ }
+ catch (ex) {
+ dispatch("alert", {content: `Failed to abandon session: ${ex}`, kind: "sessionDetail"});
+ }
+ }
+ }
+};
diff --git a/KST-ASD-BI-101/cts/client/src/store/service/sessions/logs.js b/KST-ASD-BI-101/cts/client/src/store/service/sessions/logs.js
new file mode 100644
index 0000000..20241a8
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/store/service/sessions/logs.js
@@ -0,0 +1,159 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+/* globals TextDecoderStream, TransformStream */
+import Vue from "vue";
+import "@stardazed/streams-polyfill";
+
+const initialState = () => ({
+ cacheContainer: {}
+});
+
+export default {
+ namespaced: true,
+ state: {
+ initialState,
+ ...initialState()
+ },
+ getters: {
+ cacheKey: (state) => ({id}) => {
+ const cacheKey = `key-${id}`;
+
+ if (! state.cacheContainer[cacheKey]) {
+ Vue.set(
+ state.cacheContainer,
+ cacheKey,
+ {
+ loaded: false,
+ loading: false,
+ items: [],
+ err: false,
+ errMsg: "",
+ listener: null,
+ listen: false
+ }
+ );
+ }
+
+ return cacheKey;
+ },
+
+ cache: (state) => ({cacheKey}) => state.cacheContainer[cacheKey]
+ },
+ actions: {
+ load: async ({getters, rootGetters}, {props, force = false} = {}) => {
+ const cache = getters.cache({cacheKey: getters.cacheKey(props)}),
+ busyKey = "loading";
+
+ if (cache.loaded || cache.loading) {
+ if (! force) {
+ return;
+ }
+ }
+
+ cache[busyKey] = true;
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](`sessions/${props.id}/logs`);
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${response.status}`);
+ }
+
+ const body = await response.json();
+
+ cache.items = body;
+ cache.loaded = true;
+ }
+ catch (ex) {
+ const content = `Failed to load session logs (${props.id}): ${ex}`
+
+ cache.err = true;
+ cache.errMsg = content;
+ }
+
+ // eslint-disable-next-line require-atomic-updates
+ cache[busyKey] = false;
+ },
+
+ startListener: async ({getters, rootGetters}, props) => {
+ const cacheEntry = getters.cache({cacheKey: getters.cacheKey(props)}),
+ response = await rootGetters["service/makeApiRequest"](`sessions/${props.id}/logs?listen=true`),
+ stream = response.body.pipeThrough(new TextDecoderStream()).pipeThrough(
+ //
+ // this stream takes the text stream as input, splits the text on \n
+ // and then JSON parses the lines, providing each chunk of JSON to
+ // the next handler in the chain
+ //
+ new TransformStream(
+ {
+ start (controller) {
+ controller.buf = "";
+ controller.pos = 0;
+ },
+ transform (chunk, controller) {
+ controller.buf += chunk;
+
+ while (controller.pos < controller.buf.length) {
+ if (controller.buf[controller.pos] === "\n") {
+ const line = controller.buf.substring(0, controller.pos);
+
+ controller.enqueue(JSON.parse(line));
+
+ controller.buf = controller.buf.substring(controller.pos + 1);
+ controller.pos = 0;
+ }
+ else {
+ ++controller.pos;
+ }
+ }
+ }
+ }
+ )
+ ),
+ reader = cacheEntry.listener = stream.getReader();
+
+ cacheEntry.listen = true;
+
+ while (cacheEntry.listen) { // eslint-disable-line no-constant-condition
+ try {
+ const {done, value} = await reader.read();
+
+ cacheEntry.items.unshift(value);
+
+ if (done) {
+ break;
+ }
+ }
+ catch (ex) {
+ break;
+ }
+ }
+ },
+
+ stopListener: async ({getters}, props) => {
+ const cacheEntry = getters.cache({cacheKey: getters.cacheKey(props)});
+
+ if (cacheEntry.listener) {
+ cacheEntry.listen = false;
+
+ await cacheEntry.listener.closed;
+ cacheEntry.listener.releaseLock();
+
+ cacheEntry.listener = null;
+ }
+ }
+ }
+};
diff --git a/KST-ASD-BI-101/cts/client/src/store/service/tests.js b/KST-ASD-BI-101/cts/client/src/store/service/tests.js
new file mode 100644
index 0000000..c97d88e
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/store/service/tests.js
@@ -0,0 +1,461 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+import Vue from "vue";
+import logs from "./tests/logs";
+
+const initialState = () => ({
+ detailCache: {},
+ cacheContainer: {},
+ defaultKeyProperties: {}
+ }),
+ wrapItem = (item, {loading = false, loaded = false}) => ({
+ item,
+ loading,
+ loaded,
+ error: false,
+ errMsg: null
+ }),
+ populateItem = (item) => {
+ item.learnerPrefs = {
+ _etag: null,
+ languagePreference: "",
+ audioPreference: null
+ };
+
+ return item;
+ };
+
+export default {
+ namespaced: true,
+ modules: {
+ logs
+ },
+ state: {
+ initialState,
+ ...initialState()
+ },
+ getters: {
+ cacheKey: (state) => ({courseId}) => {
+ //
+ // there isn't really a technical reason why we should only be able to pull the list
+ // of tests based on a course id, but when written the UI only provided for listing
+ // tests at the course level so this was arbitrarily restricted based on that and the
+ // service back end only has a route for getting tests under the /courses resource
+ //
+ if (typeof courseId === "undefined") {
+ throw new Error("courseId is a required cache key component");
+ }
+
+ const cacheKey = `key-${courseId}`;
+
+ if (! state.cacheContainer[cacheKey]) {
+ Vue.set(
+ state.cacheContainer,
+ cacheKey,
+ {
+ loaded: false,
+ loading: false,
+ items: [],
+ currentIndex: null
+ }
+ );
+ }
+
+ return cacheKey;
+ },
+
+ cache: (state) => ({cacheKey}) => state.cacheContainer[cacheKey],
+
+ byId: (state) => ({id}) => {
+ if (! state.detailCache[id]) {
+ Vue.set(
+ state.detailCache,
+ id,
+ wrapItem(
+ populateItem({
+ id
+ }),
+ {
+ loaded: false
+ }
+ )
+ );
+ }
+
+ return state.detailCache[id];
+ }
+ },
+ actions: {
+ alert: ({dispatch}, {content, variant = "danger", kind = "testList"}) => {
+ dispatch(
+ "alerts/add",
+ {
+ kind,
+ payload: {
+ content,
+ variant
+ }
+ },
+ {root: true}
+ );
+ },
+
+ loadById: async ({getters, rootGetters}, {id, force = false}) => {
+ const fromCache = getters.byId({id});
+
+ if (fromCache.loaded && ! force) {
+ return;
+ }
+
+ fromCache.loading = true;
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](`tests/${id}`);
+
+ let body = await response.json();
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${body.message} (${response.status})${body.srcError ? " (" + body.srcError + ")" : ""}`);
+ }
+
+ body = populateItem(body);
+
+ fromCache.item = body;
+ fromCache.loaded = true;
+ }
+ catch (ex) {
+ fromCache.error = true;
+ fromCache.errMsg = `Failed to load from id: ${ex}`;
+ }
+ finally {
+ fromCache.loading = false;
+ }
+ },
+
+ load: async ({dispatch, state, getters, rootGetters}, {courseId, props = state.defaultKeyProperties, force = false} = {}) => {
+ const cache = getters.cache({cacheKey: getters.cacheKey({courseId, ...props})}),
+ busyKey = "loading";
+
+ if (cache.loaded || cache.loading) {
+ if (! force) {
+ return;
+ }
+
+ cache.items = cache.items.filter((i) => i.id === null);
+ }
+
+ cache[busyKey] = true;
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](`courses/${courseId}/tests`);
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${response.status}`);
+ }
+
+ const body = await response.json(),
+ duplicateCheck = cache.items.map((i) => i.id);
+
+ for (const i of body.items) {
+ if (duplicateCheck.includes(i.id)) {
+ continue;
+ }
+
+ i.pending = null;
+
+ cache.items.push(i);
+ }
+
+ cache.loaded = true;
+
+ for (const i of body.items) {
+ if (! state.detailCache[i.id]) {
+ Vue.set(
+ state.detailCache,
+ i.id,
+ wrapItem(populateItem(i), {loaded: true})
+ );
+ }
+ }
+ }
+ catch (ex) {
+ const content = `Failed to load tests: ${ex}`
+
+ cache.err = true;
+ cache.errMsg = content;
+
+ dispatch("alert", {content});
+ }
+
+ // eslint-disable-next-line require-atomic-updates
+ cache[busyKey] = false;
+ },
+
+ create: async ({dispatch, state, getters, rootGetters}, {courseId, actor}) => {
+ const kind = "testNew";
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ "tests",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ courseId,
+ actor
+ })
+ }
+ );
+
+ let responseBody = await response.json();
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${responseBody.message ? responseBody.message : "no message"} (${response.status}${responseBody.srcError ? " - " + responseBody.srcError : ""})`);
+ }
+ responseBody = populateItem(responseBody);
+
+ Vue.set(state.detailCache, responseBody.id, wrapItem(responseBody, {loaded: true}));
+
+ const courseCache = getters.cache({cacheKey: getters.cacheKey({courseId})});
+
+ courseCache.items.unshift(responseBody);
+
+ return responseBody.id;
+ }
+ catch (ex) {
+ dispatch("alert", {content: `Failed to create test: ${ex}`, kind});
+ }
+
+ return null;
+ },
+
+ waiveAU: async ({dispatch, rootGetters}, {id, auIndex, reason}) => {
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ `tests/${id}/waive-au/${auIndex}`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ reason
+ })
+ }
+ );
+
+ if (response.status === 204) {
+ dispatch("logs/load", {props: {id}, force: true});
+ return;
+ }
+
+ let responseBody = await response.json();
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${responseBody.message ? responseBody.message : "no message"} (${response.status}${responseBody.srcError ? " - " + responseBody.srcError : ""})`);
+ }
+ }
+ catch (ex) {
+ dispatch("alert", {content: `Failed to waive AU: ${ex}`, kind: "testDetail"});
+ }
+ },
+
+ loadLearnerPrefs: async ({getters, dispatch, rootGetters}, {id}) => {
+ try {
+ const test = getters.byId({id}),
+ response = await rootGetters["service/makeApiRequest"](
+ `tests/${id}/learner-prefs`,
+ {
+ method: "GET"
+ }
+ );
+
+ if (response.status === 404) {
+ test.item.learnerPrefs._etag = null;
+ test.item.learnerPrefs.languagePreference = "";
+ test.item.learnerPrefs.audioPreference = null;
+
+ return;
+ }
+
+ let responseBody = await response.json();
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${responseBody.message ? responseBody.message : "no message"} (${response.status}${responseBody.srcError ? " - " + responseBody.srcError : ""})`);
+ }
+
+ test.item.learnerPrefs._etag = response.headers.get("etag") || null;
+ test.item.learnerPrefs.languagePreference = responseBody.languagePreference;
+ test.item.learnerPrefs.audioPreference = responseBody.audioPreference;
+ }
+ catch (ex) {
+ dispatch("alert", {content: `Failed to load learner preferences: ${ex}`, kind: "testDetail"});
+ }
+ },
+
+ saveLearnerPrefs: async ({getters, dispatch, rootGetters}, {id}) => {
+ const test = getters.byId({id});
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ `tests/${id}/learner-prefs`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(test.item.learnerPrefs._etag === null ? {"If-None-Match": "*"} : {"If-Match": test.item.learnerPrefs._etag})
+ },
+ body: JSON.stringify({
+ languagePreference: test.item.learnerPrefs.languagePreference,
+ audioPreference: test.item.learnerPrefs.audioPreference
+ })
+ }
+ );
+
+ if (response.status === 204) {
+ //
+ // after successfully saving the preferences the Etag is out of date, and rather than
+ // trying to calculate it client side for the new value, just fetch the preferences
+ // again to get the new Etag
+ //
+ dispatch("loadLearnerPrefs", {id});
+
+ dispatch("alert", {content: `Agent preferences saved`, kind: "testDetail", variant: "success"});
+
+ return;
+ }
+
+ let responseBody = await response.json();
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${responseBody.message ? responseBody.message : "no message"} (${response.status}${responseBody.srcError ? " - " + responseBody.srcError : ""})`);
+ }
+ }
+ catch (ex) {
+ dispatch("alert", {content: `Failed to save learner preferences: ${ex}`, kind: "testDetail"});
+ }
+ },
+
+ clearLearnerPrefs: async ({getters, dispatch, rootGetters}, {id}) => {
+ const test = getters.byId({id});
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ `tests/${id}/learner-prefs`,
+ {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ "If-Match": test.item.learnerPrefs._etag
+ }
+ }
+ );
+
+ if (response.status === 204) {
+ test.item.learnerPrefs.languagePreference = "";
+ test.item.learnerPrefs.audioPreference = null;
+ test.item.learnerPrefs._etag = null;
+
+ dispatch("alert", {content: `Agent preferences cleared`, kind: "testDetail", variant: "success"});
+
+ return;
+ }
+
+ let responseBody = await response.json();
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${responseBody.message ? responseBody.message : "no message"} (${response.status}${responseBody.srcError ? " - " + responseBody.srcError : ""})`);
+ }
+ }
+ catch (ex) {
+ dispatch("alert", {content: `Failed to clear learner preferences: ${ex}`, kind: "testDetail"});
+ }
+ },
+
+ triggerDownload: async ({getters, dispatch, rootGetters}, {id}) => {
+ await dispatch("loadById", {id, force: true});
+ await dispatch("logs/load", {props: {id}, force: true});
+
+ const test = getters.byId({id}).item,
+ logs = getters["logs/cache"]({cacheKey: getters["logs/cacheKey"]({id})}),
+ code = test.code,
+ sessionCreationItems = logs.items.filter((item) => (item.metadata && item.metadata.resource === "sessions:create")),
+ sessionLoadPromises = [];
+
+ for (const item of sessionCreationItems) {
+ sessionLoadPromises.push(dispatch("service/sessions/loadById", {id: item.metadata.sessionId, force: true}, {root: true}));
+ sessionLoadPromises.push(dispatch("service/sessions/logs/load", {props: {id: item.metadata.sessionId}, force: true}, {root: true}));
+ }
+
+ await Promise.all(sessionLoadPromises);
+
+ const fileContents = {
+ id,
+ code,
+ dateCreated: new Date().toJSON(),
+ metadata: test.metadata
+ },
+
+ //
+ // this is a little unorthodox/odd for a service to be performing but it
+ // makes some sort of sense because this service action can be dispatched
+ // by multiple components, and since the element handling is really outside
+ // of the scope of what Vue should be expected to provide it mostly doesn't
+ // matter where we put it, it'll be odd anywhere else too
+ //
+ element = document.createElement("a");
+
+ fileContents.logs = logs.items.map(
+ (logItem) => {
+ const result = {
+ ...logItem
+ };
+
+ if (logItem.metadata && logItem.metadata.resource === "sessions:create") {
+ result.session = {
+ metadata: rootGetters["service/sessions/byId"]({id: logItem.metadata.sessionId}).item.metadata,
+ // create a new array so that it can be inplace sorted
+ logs: [
+ ...rootGetters["service/sessions/logs/cache"](
+ {
+ cacheKey: rootGetters["service/sessions/logs/cacheKey"]({id: logItem.metadata.sessionId})
+ }
+ ).items
+ ]
+ };
+
+ result.session.logs.sort((a, b) => a.id - b.id);
+ }
+
+ return result;
+ }
+ );
+
+ fileContents.logs.sort((a, b) => a.id - b.id);
+
+ element.setAttribute("href", "data:text/plain;charset=utf-8," + JSON.stringify(fileContents, null, 2)); // eslint-disable-line no-magic-numbers
+ element.setAttribute("download", `catapult-cts-${code}.json`);
+
+ element.style.display = "none";
+ document.body.appendChild(element);
+
+ element.click();
+ document.body.removeChild(element);
+ }
+ }
+};
diff --git a/KST-ASD-BI-101/cts/client/src/store/service/tests/logs.js b/KST-ASD-BI-101/cts/client/src/store/service/tests/logs.js
new file mode 100644
index 0000000..3c87b64
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/store/service/tests/logs.js
@@ -0,0 +1,87 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+import Vue from "vue";
+
+const initialState = () => ({
+ cacheContainer: {}
+});
+
+export default {
+ namespaced: true,
+ state: {
+ initialState,
+ ...initialState()
+ },
+ getters: {
+ cacheKey: (state) => ({id}) => {
+ const cacheKey = `key-${id}`;
+
+ if (! state.cacheContainer[cacheKey]) {
+ Vue.set(
+ state.cacheContainer,
+ cacheKey,
+ {
+ loaded: false,
+ loading: false,
+ items: [],
+ err: false,
+ errMsg: ""
+ }
+ );
+ }
+
+ return cacheKey;
+ },
+
+ cache: (state) => ({cacheKey}) => state.cacheContainer[cacheKey]
+ },
+ actions: {
+ load: async ({getters, rootGetters}, {props, force = false} = {}) => {
+ const cache = getters.cache({cacheKey: getters.cacheKey(props)}),
+ busyKey = "loading";
+
+ if (cache.loaded || cache.loading) {
+ if (! force) {
+ return;
+ }
+ }
+
+ cache[busyKey] = true;
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](`tests/${props.id}/logs`);
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${response.status}`);
+ }
+
+ const body = await response.json();
+
+ cache.items = body;
+ cache.loaded = true;
+ }
+ catch (ex) {
+ const content = `Failed to load session logs (${props.id}): ${ex}`
+
+ cache.err = true;
+ cache.errMsg = content;
+ }
+
+ // eslint-disable-next-line require-atomic-updates
+ cache[busyKey] = false;
+ }
+ }
+};
diff --git a/KST-ASD-BI-101/cts/client/src/store/service/users.js b/KST-ASD-BI-101/cts/client/src/store/service/users.js
new file mode 100644
index 0000000..2cc0006
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/store/service/users.js
@@ -0,0 +1,259 @@
+/*
+ Copyright 2020 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+import Vue from "vue";
+
+const initialState = () => ({
+ detailCache: {},
+ cacheContainer: {},
+ defaultKeyProperties: {}
+ }),
+ wrapItem = (item, {loading = false, loaded = false}) => ({
+ item,
+ loading,
+ loaded,
+ error: false,
+ errMsg: null
+ }),
+ populateItem = (item) => {
+ return item;
+ };
+
+export default {
+ namespaced: true,
+ state: {
+ initialState,
+ ...initialState()
+ },
+ getters: {
+ cacheKey: (state) => () => {
+ const cacheKey = "key";
+
+ if (! state.cacheContainer[cacheKey]) {
+ Vue.set(
+ state.cacheContainer,
+ cacheKey,
+ {
+ loaded: false,
+ loading: false,
+ items: [],
+ currentIndex: null
+ }
+ );
+ }
+
+ return cacheKey;
+ },
+ defaultCacheKey: (state, getters) => getters.cacheKey({props: state.defaultKeyProperties}),
+
+ cache: (state) => ({cacheKey}) => state.cacheContainer[cacheKey],
+ defaultCache: (state, getters) => getters.cache({cacheKey: getters.defaultCacheKey}),
+
+ byId: (state) => ({id}) => {
+ if (! state.detailCache[id]) {
+ Vue.set(
+ state.detailCache,
+ id,
+ wrapItem(
+ populateItem({
+ id
+ }),
+ {
+ loaded: false
+ }
+ )
+ );
+ }
+
+ return state.detailCache[id];
+ }
+ },
+ actions: {
+ alert: ({dispatch}, {content, variant = "danger", kind = "adminUserList"}) => {
+ dispatch(
+ "alerts/add",
+ {
+ kind,
+ payload: {
+ content,
+ variant
+ }
+ },
+ {root: true}
+ );
+ },
+
+ loadById: async ({getters, rootGetters}, {id, force = false}) => {
+ const fromCache = getters.byId({id});
+
+ if (fromCache.loaded && ! force) {
+ return;
+ }
+
+ fromCache.loading = true;
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](`users/${id}`);
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${response.status}`);
+ }
+
+ const body = await response.json();
+
+ fromCache.item = populateItem(body);
+ fromCache.loaded = true;
+ }
+ catch (ex) {
+ fromCache.error = true;
+ fromCache.errMsg = `Failed to load from id: ${ex}`;
+ }
+ finally {
+ fromCache.loading = false;
+ }
+ },
+
+ load: async ({dispatch, state, getters, rootGetters}, {props = state.defaultKeyProperties, force = false} = {}) => {
+ const cache = getters.cache({cacheKey: getters.cacheKey({props})}),
+ busyKey = "loading";
+
+ if (cache.loaded || cache.loading) {
+ if (! force) {
+ return;
+ }
+
+ cache.items = cache.items.filter((i) => i.id === null);
+ }
+
+ cache[busyKey] = true;
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"]("users"),
+ lastNewItemIndex = cache.items.findIndex((i) => i.id !== null);
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${response.status}`);
+ }
+
+ const body = await response.json();
+
+ cache.items.splice(
+ lastNewItemIndex === -1 ? cache.items.length : lastNewItemIndex + 1,
+ 0,
+ ...body.items
+ );
+ cache.loaded = true;
+
+ for (const i of body.items) {
+ if (! state.detailCache[i.id]) {
+ Vue.set(
+ state.detailCache,
+ i.id,
+ wrapItem(i, {loaded: true})
+ );
+ }
+ }
+ }
+ catch (ex) {
+ const content = `Failed to load users: ${ex}`
+
+ cache.err = true;
+ cache.errMsg = content;
+
+ dispatch("alert", {content});
+ }
+
+ // eslint-disable-next-line require-atomic-updates
+ cache[busyKey] = false;
+ },
+
+ delete: async ({dispatch, getters, rootGetters}, {item}) => {
+ const cache = getters.defaultCache,
+ itemIndex = cache.items.findIndex((i) => i === item);
+
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ `users/${item.id}`,
+ {
+ method: "DELETE",
+ mode: "cors"
+ }
+ );
+
+ if (response.ok) {
+ if (itemIndex !== -1) {
+ cache.items.splice(itemIndex, 1);
+
+ if (cache.currentIndex === itemIndex) {
+ cache.currentIndex = null;
+ }
+ }
+ }
+ else {
+ const body = await response.json();
+
+ throw new Error(`${response.status} - ${body.message}`);
+ }
+ }
+ catch (ex) {
+ dispatch("alert", {content: `Failed to delete user (id: ${item.id}): ${ex}`});
+
+ return false;
+ }
+
+ return true;
+ },
+
+ create: async ({dispatch, state, getters, rootGetters}, {username, password, roles = []}) => {
+ try {
+ const response = await rootGetters["service/makeApiRequest"](
+ "users",
+ {
+ method: "POST",
+ mode: "cors",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ username,
+ password,
+ roles
+ })
+ }
+ );
+
+ let responseBody = await response.json();
+
+ if (! response.ok) {
+ throw new Error(`Request failed: ${responseBody.message ? responseBody.message : "no message"} (${response.status})`);
+ }
+
+ responseBody = populateItem(responseBody);
+
+ Vue.set(state.detailCache, responseBody.id, wrapItem(responseBody, {loaded: true}));
+ getters.cache({cacheKey: getters.cacheKey()}).items.unshift(responseBody);
+
+ dispatch("alert", {content: `User created: ${responseBody.id}`, kind: "success"});
+
+ return responseBody.id;
+ }
+ catch (ex) {
+ dispatch("alert", {content: `Failed to create user: ${ex}`});
+ }
+
+ return null;
+ }
+ }
+};
diff --git a/KST-ASD-BI-101/cts/client/src/styles/custom-vars.scss b/KST-ASD-BI-101/cts/client/src/styles/custom-vars.scss
new file mode 100644
index 0000000..0be1206
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/src/styles/custom-vars.scss
@@ -0,0 +1,73 @@
+/*
+ Copyright 2020 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+// These are the closest USWDS "cool gray" colors to our own cool grayscale.
+// Which really aren't all that close (ours are a bit more blue). Oh well.
+$white: #fff;
+$gray-100: #fbfcfd;
+$gray-200: #f1f3f6;
+$gray-300: #dfe1e2;
+$gray-400: #c6cace;
+$gray-500: #a9aeb1;
+$gray-600: #71767a;
+$gray-700: #565c65;
+$gray-800: #2d2e2f;
+$gray-900: #1c1d1f;
+$black: #000;
+
+// The Bootstrap rainbow, using Rustici-ish colors from the USWDS palette.
+// We don't have all of these colors in the Rustici palette (e.g., yellow),
+// so I interpolated them from adjacent colors, did some manual adjustment,
+// and picked the nearest USWDS colors that I could find.
+$blue: #345d96;
+$indigo: #5e519e;
+$purple: #864381;
+$pink: #d72d79;
+$red: #e52207;
+$orange: #fa9441;
+$yellow: #face00;
+$green: #008817;
+$teal: #5abf95;
+$cyan: #5dc0d1;
+
+// Theme colors. We mostly follow Bootstrap's lead here, except for using
+// a slightly different grey for $dark.
+$primary: $blue;
+$secondary: $gray-600;
+$success: $green;
+$info: $cyan;
+$warning: $yellow;
+$danger: $red;
+$light: $gray-100;
+$dark: $gray-800;
+
+// Accessibility tweaks:
+// - According to the Bootstrap docs[1], the colors in the default palette lead
+// to insufficient contrast for WCAG 2.0 guidelines when used against a light
+// background. Since our colors aren't too far off from the defaults, we may
+// have to adjust some of the color scaling values to compensate.
+//
+// - Make sure that links are always underlined[2]. Otherwise, we'd need to
+// ensure that there is a 3:1 contrast between body text and link text, on
+// top of the standard 4.5:1 text contrast requirement. And we'd have to add
+// an underline on hover or mouse focus anyway.
+//
+// [1]: https://getbootstrap.com/docs/4.5/getting-started/accessibility/
+// [2]: https://webaim.org/articles/contrast/#only
+$text-muted: $gray-700;
+$link-decoration: underline;
+
+
diff --git a/KST-ASD-BI-101/cts/client/vue.config.js b/KST-ASD-BI-101/cts/client/vue.config.js
new file mode 100644
index 0000000..be7ee61
--- /dev/null
+++ b/KST-ASD-BI-101/cts/client/vue.config.js
@@ -0,0 +1,26 @@
+module.exports = {
+ publicPath: "./",
+ devServer: {
+ port: 3396
+ },
+ css: {
+ loaderOptions: {
+ scss: {
+ //
+ // make all of our custom variables available in the style
+ // blocks of all of the components without importing in each
+ //
+ additionalData: `@import "~@/styles/custom-vars.scss";`
+ }
+ }
+ },
+ chainWebpack: (config) => {
+ config.plugin("html").tap(
+ (args) => {
+ args[0].title = "Catapult: CTS"
+
+ return args;
+ }
+ );
+ }
+};
diff --git a/KST-ASD-BI-101/cts/docker-compose.client-dev.yml b/KST-ASD-BI-101/cts/docker-compose.client-dev.yml
new file mode 100755
index 0000000..95c77a6
--- /dev/null
+++ b/KST-ASD-BI-101/cts/docker-compose.client-dev.yml
@@ -0,0 +1,21 @@
+# Copyright 2021 Rustici Software
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+version: "3.8"
+services:
+ client:
+ build: client/
+ ports:
+ - 3396:3396
+ volumes:
+ - ./client:/usr/src/app/src:ro
diff --git a/KST-ASD-BI-101/cts/docker-compose.yml b/KST-ASD-BI-101/cts/docker-compose.yml
new file mode 100644
index 0000000..e8f9dd0
--- /dev/null
+++ b/KST-ASD-BI-101/cts/docker-compose.yml
@@ -0,0 +1,154 @@
+# Copyleft 2024 Kusala Tech
+#
+# Licensed under the GNU General Public License v3.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#
+# https://www.gnu.org/licenses/gpl-3.0.en.html
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on
+# an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# Copyright 2020 Rustici Software
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+services:
+ nginx:
+ restart: always
+ build:
+ context: nginx
+ args:
+ HOSTNAME: "${HOSTNAME}"
+ ports:
+ - "80:80"
+ container_name: docker_nginx
+ networks:
+ - public
+
+ # certbot:
+ # container_name: 'docker_certbot'
+ # image: certbot/certbot
+ # volumes:
+ # - ./keys:/var/lib/letsencrypt
+ # - ./nginx/letsencrypt:/data/letsencrypt
+ # - ./certbot/etc:/etc/letsencrypt
+ # - ./certbot/log:/var/log/letsencrypt
+ # depends_on:
+ # - nginx
+ # networks:
+ # - public
+
+ webservice:
+ build: .
+ container_name: docker_cts
+ restart: always # Deprecated but still available old auth method.
+ ports:
+ - 127.0.0.1:3399:3399
+ depends_on:
+ - rdbms
+ volumes:
+ - ./service/index.js:/usr/src/app/index.js:ro
+ - ./service/knexfile.js:/usr/src/app/knexfile.js:ro
+ - ./service/plugins:/usr/src/app/plugins:ro
+ - ./service/lib:/usr/src/app/lib:ro
+ - ./migrations:/usr/src/app/migrations:ro
+ - ./seeds:/usr/src/app/seeds:ro
+ environment:
+ - HOSTNAME
+ - DB_HOST
+ - DB_NAME=catapult_cts
+ - DB_USERNAME
+ - DB_PASSWORD
+ # - DATABASE_USER=catapult
+ # - DATABASE_USER_PASSWORD=quartz
+ # - DATABASE_NAME=catapult_cts
+ - PLAYER_BASE_URL
+ - PLAYER_KEY
+ - PLAYER_SECRET
+ - NODE_TLS_REJECT_UNAUTHORIZED
+ - LRS_ENDPOINT
+ - LRS_USERNAME
+ - LRS_PASSWORD
+ - LRS_XAPI_VERSION
+ - CTS_SESSION_COOKIE_PASSWORD
+ networks:
+ - public
+
+ player:
+ image: adlhub/player
+ container_name: docker_player
+ restart: always
+ depends_on:
+ - rdbms
+ ports:
+ - 127.0.0.1:3398:3398
+ volumes:
+ # - ../migrations:/usr/src/app/migrations:ro
+ # - ../seeds:/usr/src/app/seeds:ro
+ - ../:/usr/src/app/content # FIXME: Less than ideal - fix as we make contributions back to ADLNet
+ environment:
+ - HOSTNAME
+ - DATABASE_USER=${DB_USERNAME}
+ - DATABASE_USER_PASSWORD=${DB_PASSWORD}
+ - DATABASE_NAME=${DB_NAME}
+ - CONTENT_URL=${PLAYER_CONTENT_URL}
+ - LRS_ENDPOINT
+ - LRS_USERNAME
+ - LRS_PASSWORD
+ - LRS_XAPI_VERSION
+ - TOKEN_SECRET=${PLAYER_TOKEN_SECRET}
+ - API_KEY=${PLAYER_KEY}
+ - API_SECRET=${PLAYER_SECRET}
+ - PLAYER_API_ROOT=${PLAYER_ROOT_PATH}
+ - PLAYER_STANDALONE_LAUNCH_URL_BASE
+ networks:
+ - public
+
+ rdbms:
+ image: mysql:8.0.27 # Deprecated but still available old auth method.
+ container_name: mysql
+ restart: always
+ volumes:
+ - catapult-cts-data:/var/lib/mysql
+ - ./init_db.sh:/docker-entrypoint-initdb.d/init_db.sh:ro
+ environment:
+ - MYSQL_RANDOM_ROOT_PASSWORD=yes
+ - DATABASE_USER=${DB_USERNAME}
+ - DATABASE_USER_PASSWORD=${DB_PASSWORD}
+ - DATABASE_NAME=catapult_cts
+ - PLAYER_DATABASE_NAME=catapult_player
+ command: [
+ "mysqld",
+
+ # provide for full UTF-8 support
+ "--character-set-server=utf8mb4",
+ "--collation-server=utf8mb4_unicode_ci",
+
+ # need the following because the mysql.js client lib doesn't yet support
+ # the newer default scheme used in MySQL 8.x
+ "--default-authentication-plugin=mysql_native_password"
+ ]
+ networks:
+ - public
+
+volumes:
+ catapult-cts-data:
+
+networks:
+ public:
+ driver: bridge
diff --git a/KST-ASD-BI-101/cts/entrypoint.sh b/KST-ASD-BI-101/cts/entrypoint.sh
new file mode 100755
index 0000000..b4deb1b
--- /dev/null
+++ b/KST-ASD-BI-101/cts/entrypoint.sh
@@ -0,0 +1,24 @@
+#!/bin/sh
+
+# Copyright 2021 Rustici Software
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+if [ RUN_MIGRATIONS ]; then
+ node node_modules/.bin/knex migrate:latest
+fi
+
+# legacy-watch is used because it improves auto restart in our specific
+# use case for development, mounted volume in container, see "Application
+# isn't restarting" in the docs
+exec nodemon --legacy-watch --watch index.js --watch knexfile.js --watch lib --watch plugins index.js
diff --git a/KST-ASD-BI-101/cts/init-ssl.sh b/KST-ASD-BI-101/cts/init-ssl.sh
new file mode 100755
index 0000000..394fb8e
--- /dev/null
+++ b/KST-ASD-BI-101/cts/init-ssl.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+mkdir -p ./certbot/etc/live/$1
+
+echo "[SSL] Ensured Certbot SSL Directory"
+
+cp ./certbot/local/fullchain.pem ./certbot/etc/live/$1/fullchain.pem
+
+echo "[SSL] Copied temporary SSL Cert to ./certbot/etc/live/$1/fullchain.pem"
+
+cp ./certbot/local/privkey.pem ./certbot/etc/live/$1/privkey.pem
+
+echo "[SSL] Copied temporary SSL Key to ./certbot/etc/live/$1/privkey.pem"
+echo ""
diff --git a/KST-ASD-BI-101/cts/init_db.sh b/KST-ASD-BI-101/cts/init_db.sh
new file mode 100755
index 0000000..68402f1
--- /dev/null
+++ b/KST-ASD-BI-101/cts/init_db.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+# Copyright 2021 Rustici Software
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+echo "Creating Catapult CTS Database: '$DATABASE_NAME', Player Database: '$PLAYER_DATABASE_NAME' and User: '$DATABASE_USER'";
+mysql --user=root --password=$MYSQL_ROOT_PASSWORD << END
+
+CREATE DATABASE IF NOT EXISTS $DATABASE_NAME;
+CREATE DATABASE IF NOT EXISTS $PLAYER_DATABASE_NAME;
+
+CREATE USER '$DATABASE_USER'@'%' IDENTIFIED BY '$DATABASE_USER_PASSWORD';
+GRANT ALL PRIVILEGES ON $DATABASE_NAME.* TO '$DATABASE_USER'@'%';
+GRANT ALL PRIVILEGES ON $PLAYER_DATABASE_NAME.* TO '$DATABASE_USER'@'%';
+FLUSH PRIVILEGES;
+
+END
diff --git a/KST-ASD-BI-101/cts/install-reqs.sh b/KST-ASD-BI-101/cts/install-reqs.sh
new file mode 100755
index 0000000..3ad0184
--- /dev/null
+++ b/KST-ASD-BI-101/cts/install-reqs.sh
@@ -0,0 +1,68 @@
+#!/bin/bash
+
+# Simple script to install the PERLS architecture requirements
+function announce() {
+ echo ""
+ echo "#====================================================#"
+ echo "#"
+ echo "# Installing $1"
+ echo "#"
+ echo "#====================================================#"
+}
+
+# Curl
+#
+announce "Curl"
+
+if ! [ -x "$(command -v curl)" ]; then
+
+ # Curl is easy
+ apt-get install curl
+
+else
+ echo "Skipping, Curl already installed!"
+fi
+
+# Docker
+#
+announce "Docker"
+
+if ! [ -x "$(command -v docker)" ]; then
+
+ # Docker is a bit complicated
+ #
+ # Add the GPG Key
+ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
+
+ # Add the Docker repository to our APT sources
+ add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
+
+ # With those added, update our packages
+ apt-get update
+
+ # Since we're up to date, get docker
+ apt-get install -y docker-ce
+else
+ echo "Skipping, docker already installed!"
+fi
+
+
+# Docker-Compose
+#
+announce "Docker-Compose"
+
+if ! [ -x "$(command -v docker-compose)" ]; then
+
+ # Docker-Compose is also complicated
+ #
+ # Add the GPG Key
+ curl -L https://github.com/docker/compose/releases/download/1.18.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
+
+ # Make sure it's executable
+ chmod +x /usr/local/bin/docker-compose
+
+else
+ echo "Skipping, docker-compose already installed!"
+fi
+
+echo ""
diff --git a/KST-ASD-BI-101/cts/migrations/010-table-tenants.js b/KST-ASD-BI-101/cts/migrations/010-table-tenants.js
new file mode 100644
index 0000000..ea0f641
--- /dev/null
+++ b/KST-ASD-BI-101/cts/migrations/010-table-tenants.js
@@ -0,0 +1,30 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+const tableName = "tenants";
+
+exports.up = async (knex) => {
+ await knex.schema.createTable(
+ tableName,
+ (table) => {
+ table.increments("id");
+ table.timestamp("created_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP"));
+ table.timestamp("updated_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"));
+ table.string("code").notNullable().unique();
+ table.integer("player_tenant_id").notNullable();
+ }
+ );
+};
+exports.down = (knex) => knex.schema.dropTable(tableName);
diff --git a/KST-ASD-BI-101/cts/migrations/020-table-users.js b/KST-ASD-BI-101/cts/migrations/020-table-users.js
new file mode 100644
index 0000000..ec5305e
--- /dev/null
+++ b/KST-ASD-BI-101/cts/migrations/020-table-users.js
@@ -0,0 +1,33 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+const tableName = "users";
+
+exports.up = async (knex) => {
+ await knex.schema.createTable(
+ tableName,
+ (table) => {
+ table.increments("id");
+ table.timestamp("created_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP"));
+ table.timestamp("updated_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"));
+ table.integer("tenant_id").unsigned().notNullable().references("id").inTable("tenants").onUpdate("CASCADE").onDelete("RESTRICT");
+ table.string("username").notNullable().unique();
+ table.string("password").notNullable();
+ table.text("player_api_token");
+ table.json("roles").notNullable();
+ }
+ );
+};
+exports.down = (knex) => knex.schema.dropTable(tableName);
diff --git a/KST-ASD-BI-101/cts/migrations/030-table-courses.js b/KST-ASD-BI-101/cts/migrations/030-table-courses.js
new file mode 100644
index 0000000..f69b242
--- /dev/null
+++ b/KST-ASD-BI-101/cts/migrations/030-table-courses.js
@@ -0,0 +1,29 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+const tableName = "courses";
+
+exports.up = (knex) => knex.schema.createTable(
+ tableName,
+ (table) => {
+ table.increments("id");
+ table.timestamp("created_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP"));
+ table.timestamp("updated_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"));
+ table.integer("tenant_id").unsigned().notNullable().references("id").inTable("tenants").onUpdate("CASCADE").onDelete("RESTRICT");
+ table.integer("player_id").unsigned().notNullable().unique();
+ table.json("metadata").notNullable();
+ }
+);
+exports.down = (knex) => knex.schema.dropTable(tableName);
diff --git a/KST-ASD-BI-101/cts/migrations/040-table-registrations.js b/KST-ASD-BI-101/cts/migrations/040-table-registrations.js
new file mode 100644
index 0000000..b17be3c
--- /dev/null
+++ b/KST-ASD-BI-101/cts/migrations/040-table-registrations.js
@@ -0,0 +1,32 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+const tableName = "registrations";
+
+exports.up = (knex) => knex.schema.createTable(
+ tableName,
+ (table) => {
+ table.increments("id");
+ table.timestamp("created_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP"));
+ table.timestamp("updated_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"));
+ table.integer("tenant_id").unsigned().notNullable().references("id").inTable("tenants").onUpdate("CASCADE").onDelete("RESTRICT");
+ table.integer("player_id").unsigned().notNullable().unique();
+ table.integer("course_id").unsigned().notNullable().references("id").inTable("courses").onUpdate("CASCADE").onDelete("CASCADE");
+ table.string("code").notNullable().unique();
+
+ table.json("metadata").notNullable();
+ }
+);
+exports.down = (knex) => knex.schema.dropTable(tableName);
diff --git a/KST-ASD-BI-101/cts/migrations/045-table-registrations_logs.js b/KST-ASD-BI-101/cts/migrations/045-table-registrations_logs.js
new file mode 100644
index 0000000..6cb8d37
--- /dev/null
+++ b/KST-ASD-BI-101/cts/migrations/045-table-registrations_logs.js
@@ -0,0 +1,30 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+const tableName = "registrations_logs";
+
+exports.up = (knex) => knex.schema.createTable(
+ tableName,
+ (table) => {
+ table.increments("id");
+ table.timestamp("created_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP"));
+ table.timestamp("updated_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"));
+ table.integer("tenant_id").notNullable().unsigned().references("id").inTable("tenants").onUpdate("CASCADE").onDelete("RESTRICT");
+ table.integer("registration_id").notNullable().unsigned().references("id").inTable("registrations").onUpdate("CASCADE").onDelete("CASCADE");
+
+ table.json("metadata").notNullable();
+ }
+);
+exports.down = (knex) => knex.schema.dropTable(tableName);
diff --git a/KST-ASD-BI-101/cts/migrations/050-table-sessions.js b/KST-ASD-BI-101/cts/migrations/050-table-sessions.js
new file mode 100644
index 0000000..5104440
--- /dev/null
+++ b/KST-ASD-BI-101/cts/migrations/050-table-sessions.js
@@ -0,0 +1,36 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+const tableName = "sessions";
+
+exports.up = (knex) => knex.schema.createTable(
+ tableName,
+ (table) => {
+ table.increments("id");
+ table.timestamp("created_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP"));
+ table.timestamp("updated_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"));
+ table.integer("tenant_id").unsigned().notNullable().references("id").inTable("tenants").onUpdate("CASCADE").onDelete("RESTRICT");
+ table.integer("player_id").unsigned().notNullable().unique();
+ table.integer("registration_id").unsigned().notNullable().references("id").inTable("registrations").onUpdate("CASCADE").onDelete("CASCADE");
+ table.integer("au_index").unsigned().notNullable();
+
+ table.text("player_au_launch_url").notNullable();
+ table.text("player_endpoint").notNullable();
+ table.text("player_fetch").notNullable();
+
+ table.json("metadata").notNullable();
+ }
+);
+exports.down = (knex) => knex.schema.dropTable(tableName);
diff --git a/KST-ASD-BI-101/cts/migrations/055-table-sessions_logs.js b/KST-ASD-BI-101/cts/migrations/055-table-sessions_logs.js
new file mode 100644
index 0000000..4930bd2
--- /dev/null
+++ b/KST-ASD-BI-101/cts/migrations/055-table-sessions_logs.js
@@ -0,0 +1,30 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+const tableName = "sessions_logs";
+
+exports.up = (knex) => knex.schema.createTable(
+ tableName,
+ (table) => {
+ table.increments("id");
+ table.timestamp("created_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP"));
+ table.timestamp("updated_at").notNullable().defaultTo(knex.raw("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"));
+ table.integer("tenant_id").notNullable().unsigned().references("id").inTable("tenants").onUpdate("CASCADE").onDelete("RESTRICT");
+ table.integer("session_id").notNullable().unsigned().references("id").inTable("sessions").onUpdate("CASCADE").onDelete("CASCADE");
+
+ table.json("metadata").notNullable();
+ }
+);
+exports.down = (knex) => knex.schema.dropTable(tableName);
diff --git a/KST-ASD-BI-101/cts/nginx/Dockerfile b/KST-ASD-BI-101/cts/nginx/Dockerfile
new file mode 100644
index 0000000..8612ab9
--- /dev/null
+++ b/KST-ASD-BI-101/cts/nginx/Dockerfile
@@ -0,0 +1,15 @@
+FROM nginx:alpine
+
+ARG HOSTNAME
+
+# Move our configuration into place
+#
+COPY default.conf /etc/nginx/nginx.conf
+#COPY default.conf /etc/nginx/conf.d/default.conf
+COPY proxy_headers.conf /etc/nginx/proxy_headers.conf
+
+# Swap our environment variables
+#
+RUN cat /etc/nginx/nginx.conf | envsubst '$HOSTNAME' | tee /tmp/nginx.conf
+RUN mv /tmp/nginx.conf /etc/nginx/nginx.conf
+
diff --git a/KST-ASD-BI-101/cts/nginx/default.conf b/KST-ASD-BI-101/cts/nginx/default.conf
new file mode 100644
index 0000000..3f12b3f
--- /dev/null
+++ b/KST-ASD-BI-101/cts/nginx/default.conf
@@ -0,0 +1,48 @@
+worker_processes 5;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include mime.types;
+ default_type application/octet-stream;
+ sendfile on;
+ keepalive_timeout 65;
+
+ proxy_buffer_size 128k;
+ proxy_buffers 4 256k;
+ proxy_busy_buffers_size 256k;
+
+ client_body_in_file_only clean;
+ client_body_buffer_size 32;
+
+ client_max_body_size 300M;
+
+ server {
+ listen 80;
+ server_name $HOSTNAME;
+
+ client_body_in_file_only clean;
+ client_body_buffer_size 32K;
+
+ client_max_body_size 300M;
+
+ sendfile on;
+
+ send_timeout 300;
+ proxy_connect_timeout 300;
+ proxy_send_timeout 300;
+ proxy_read_timeout 300;
+
+ location / {
+ include proxy_headers.conf;
+ proxy_pass http://webservice:3399;
+ }
+
+ location /player {
+ include proxy_headers.conf;
+ proxy_pass http://player:3398/player;
+ }
+ }
+}
diff --git a/KST-ASD-BI-101/cts/nginx/proxy_headers.conf b/KST-ASD-BI-101/cts/nginx/proxy_headers.conf
new file mode 100644
index 0000000..aba631c
--- /dev/null
+++ b/KST-ASD-BI-101/cts/nginx/proxy_headers.conf
@@ -0,0 +1,8 @@
+proxy_set_header Host $server_name;
+proxy_set_header X-Real-IP $remote_addr;
+proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+proxy_set_header X-Forwarded-Proto https;
+proxy_set_header X-Forwarded-Port 443;
+add_header Front-End-Https on;
+proxy_pass_header Set-Cookie;
+proxy_redirect off;
diff --git a/KST-ASD-BI-101/cts/service/index.js b/KST-ASD-BI-101/cts/service/index.js
new file mode 100644
index 0000000..dd4a448
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/index.js
@@ -0,0 +1,307 @@
+/*
+ Copyright 2020 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+"use strict";
+
+const Hapi = require("@hapi/hapi"),
+ H2o2 = require("@hapi/h2o2"),
+ Inert = require("@hapi/inert"),
+ Vision = require("@hapi/vision"),
+ AuthBasic = require("@hapi/basic"),
+ AuthCookie = require("@hapi/cookie"),
+ Bcrypt = require("bcrypt"),
+ waitPort = require("wait-port"),
+ { AUTH_TTL_SECONDS } = require("./lib/consts"),
+ {
+ PLAYER_BASE_URL: PLAYER_BASE_URL = "http://player:3398",
+ PLAYER_KEY,
+ PLAYER_SECRET,
+ LRS_ENDPOINT,
+ LRS_USERNAME,
+ LRS_PASSWORD
+ } = process.env;
+
+const provision = async () => {
+ const server = Hapi.server(
+ {
+ host: process.argv[3],
+ port: process.argv[2] || 3399,
+ routes: {
+ cors: {
+ credentials: true
+ },
+ response: {
+ emptyStatusCode: 204
+ }
+ }
+ }
+ ),
+ sigHandler = async (signal) => {
+ try {
+ const db = server.app.db;
+
+ await server.stop({timeout: 10000});
+
+ await db.destroy();
+
+ console.log(`Catapult CTS service stopped (${signal})`);
+ process.exit(0);
+ }
+ catch (ex) {
+ console.log(`Catapult CTS service failed to stop gracefully (${signal}): terminating the process`, ex);
+ process.exit(1);
+ }
+ };
+
+ const DB_HOST = (process.env.DB_HOST || "rdbms");
+ await waitPort({host: DB_HOST, port: 3306});
+
+ const db = await require("./lib/db")();
+
+ server.app = {
+ player: {
+ baseUrl: PLAYER_BASE_URL,
+ key: PLAYER_KEY,
+ secret: PLAYER_SECRET
+ },
+ db
+ };
+
+ server.ext(
+ "onPreResponse",
+ (req, h) => {
+ if (req.response.isBoom) {
+ if (req.response.output.statusCode === 500) {
+ req.response.output.payload.srcError = req.response.message;
+ }
+ }
+
+ return h.continue;
+ }
+ );
+
+ await server.register(H2o2);
+ await server.register(Inert);
+ await server.register(AuthBasic);
+ await server.register(AuthCookie);
+
+ await server.register(
+ [
+ Vision,
+ {
+ plugin: require("hapi-swagger"),
+ options: {
+ basePath: "/api/v1",
+ pathPrefixSize: 3,
+ info: {
+ title: "Catapult CTS API"
+ }
+ }
+ }
+ ]
+ );
+
+ server.method(
+ "lrsWreckDefaults",
+ (req) => ({
+ baseUrl: LRS_ENDPOINT.endsWith("/") ? LRS_ENDPOINT : LRS_ENDPOINT + "/",
+ headers: {
+ "X-Experience-API-Version": (process.env.LRS_XAPI_VERSION || "1.0.3"),
+ Authorization: `Basic ${Buffer.from(`${LRS_USERNAME}:${LRS_PASSWORD}`).toString("base64")}`
+ },
+ json: true
+ }),
+ {
+ generateKey: (req) => `${LRS_ENDPOINT}-${LRS_USERNAME}-${LRS_PASSWORD}`,
+ cache: {
+ expiresIn: 60000,
+ generateTimeout: 1000
+ }
+ }
+ );
+
+ server.method(
+ "getCredentials",
+ (user) => {
+ const expiresAt = new Date();
+
+ expiresAt.setSeconds(expiresAt.getSeconds() + AUTH_TTL_SECONDS);
+
+ return {
+ id: user.id,
+ tenantId: user.tenantId,
+ username: user.username,
+ roles: user.roles,
+ expiresAt
+ };
+ },
+ {
+ generateKey: (user) => user.id.toString(),
+ cache: {
+ expiresIn: 60000,
+ generateTimeout: 1000
+ }
+ }
+ );
+ server.method(
+ "basicAuthValidate",
+ async (req, username, password) => {
+ const user = await req.server.app.db.first("*").from("users").queryContext({jsonCols: ["roles"]}).where({username});
+
+ if (! user) {
+ return {isValid: false, credentials: null};
+ }
+
+ if (! await Bcrypt.compare(password, user.password)) {
+ return {isValid: false, credentials: null};
+ }
+
+ return {
+ isValid: true,
+ credentials: await req.server.methods.getCredentials(user)
+ };
+ },
+ {
+ generateKey: (req, username, password) => `${username}-${password}`,
+ cache: {
+ expiresIn: 60000,
+ generateTimeout: 5000
+ }
+ }
+ );
+ server.method(
+ "cookieAuthValidateFunc",
+ async (req, session) => {
+ const user = await req.server.app.db.first("id").from("users").where({id: session.id, username: session.username});
+
+ if (! user) {
+ return {valid: false};
+ }
+
+ return {valid: true};
+ },
+ {
+ generateKey: (req, session) => session.id.toString(),
+ cache: {
+ expiresIn: 60000,
+ generateTimeout: 5000
+ }
+ }
+ );
+ server.method(
+ "playerBearerAuthHeader",
+ async (req) => {
+ const user = await req.server.app.db.first("player_api_token").from("users").where({id: req.auth.credentials.id});
+
+ if (! user) {
+ throw Boom.unauthorized(`Unrecognized user: ${req.auth.credentials.id}`);
+ }
+
+ return `Bearer ${user.playerApiToken}`;
+ }
+ );
+ server.method(
+ "playerBasicAuthHeader",
+ (req) => `Basic ${Buffer.from(`${req.server.app.player.key}:${req.server.app.player.secret}`).toString("base64")}`,
+ {
+ generateKey: (req) => `${req.server.app.player.key}-${req.server.app.player.secret}`,
+ cache: {
+ expiresIn: 60000,
+ generateTimeout: 1000
+ }
+ }
+ );
+
+ server.auth.strategy(
+ "basic",
+ "basic",
+ {
+ validate: async (req, username, password) => await req.server.methods.basicAuthValidate(req, username, password)
+ }
+ );
+ server.auth.strategy(
+ "session",
+ "cookie",
+ {
+ validateFunc: async (req, session) => await req.server.methods.cookieAuthValidateFunc(req, session),
+ cookie: {
+ password: Date.now() + process.env.CTS_SESSION_COOKIE_PASSWORD + Math.ceil(Math.random() * 10000000),
+
+ // switch to use via https
+ isSecure: false,
+
+ ttl: AUTH_TTL_SECONDS * 1000
+ }
+ }
+ );
+
+ await server.register(
+ [
+ require("./plugins/routes/client"),
+ ]
+ );
+ server.route(
+ {
+ method: "GET",
+ path: "/",
+ handler: (req, h) => h.redirect("/client/"),
+ options: {
+ auth: false
+ }
+ }
+ );
+
+ //
+ // order matters here, specifying the default auth setup then applies to
+ // the rest of the routes registered from this point
+ //
+ server.auth.default(
+ {
+ strategies: ["basic", "session"]
+ }
+ );
+ await server.register(
+ [
+ require("./plugins/routes/v1/core"),
+ require("./plugins/routes/v1/mgmt"),
+ require("./plugins/routes/v1/courses"),
+ require("./plugins/routes/v1/tests"),
+ require("./plugins/routes/v1/sessions"),
+ require("./plugins/routes/v1/users")
+ ],
+ {
+ routes: {
+ prefix: "/api/v1"
+ }
+ }
+ );
+
+ await server.start();
+
+ process.on("SIGINT", sigHandler);
+ process.on("SIGTERM", sigHandler);
+
+ console.log("Catapult CTS service running on %s", server.info.uri);
+};
+
+process.on(
+ "unhandledRejection",
+ (err) => {
+ console.log(err);
+ process.exit(1);
+ }
+);
+
+provision();
diff --git a/KST-ASD-BI-101/cts/service/knexfile.js b/KST-ASD-BI-101/cts/service/knexfile.js
new file mode 100644
index 0000000..e90945f
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/knexfile.js
@@ -0,0 +1,101 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+const DB_HOST = (process.env.DB_HOST || "rdbms");
+const DB_NAME = (process.env.DB_NAME || "catapult_player");
+const DB_USERNAME = (process.env.DB_USERNAME || "catapult");
+const DB_PASSWORD = (process.env.DB_PASSWORD || "quartz");
+
+const Hoek = require("@hapi/hoek"),
+ waitPort = require("wait-port"),
+ {
+ // MYSQL_HOST: HOST = "rdbms",
+ MYSQL_HOST_FILE: HOST_FILE,
+ // DATABASE_USER: USER = "catapult",
+ DATABASE_USER_FILE: USER_FILE,
+ // DATABASE_USER_PASSWORD: DB_PASSWORD,
+ DATABASE_USER_PASSWORD_FILE: PASSWORD_FILE,
+ // DATABASE_NAME: DB = "catapult_player",
+ DATABASE_NAME_FILE: DB_FILE,
+ } = process.env;
+
+module.exports = async () => {
+ // const host = HOST_FILE ? fs.readFileSync(HOST_FILE) : HOST,
+ // user = USER_FILE ? fs.readFileSync(USER_FILE) : USER,
+ // password = PASSWORD_FILE ? fs.readFileSync(PASSWORD_FILE) : PASSWORD,
+ // database = DB_FILE ? fs.readFileSync(DB_FILE) : DB;
+ const host = HOST_FILE ? fs.readFileSync(HOST_FILE) : DB_HOST,
+ user = USER_FILE ? fs.readFileSync(USER_FILE) : DB_USERNAME,
+ password = PASSWORD_FILE ? fs.readFileSync(PASSWORD_FILE) : DB_PASSWORD,
+ database = DB_FILE ? fs.readFileSync(DB_FILE) : DB_NAME;
+
+ await waitPort({host, port: 3306});
+
+ return {
+ client: "mysql",
+ connection: {host, user, password, database},
+ postProcessResponse: (result, queryContext) => {
+ if (result && queryContext && queryContext.jsonCols) {
+ if (Array.isArray(result)) {
+ result = result.map(
+ (row) => {
+ for (const k of queryContext.jsonCols) {
+ const parts = k.split(".");
+ let match = row,
+ field = k;
+
+ if (parts.length > 1) {
+ field = parts[parts.length - 1];
+ match = Hoek.reach(row, parts.slice(0, -1).join("."));
+ }
+
+ try {
+ match[field] = JSON.parse(match[field]);
+ }
+ catch (ex) {
+ throw new Error(`Failed to parse JSON in key ('${k}' in '${row}'): ${ex}`);
+ }
+ }
+
+ return row;
+ }
+ );
+ }
+ else {
+ for (const k of queryContext.jsonCols) {
+ const parts = k.split(".");
+
+ let match = result,
+ field = k;
+
+ if (parts.length > 1) {
+ field = parts[parts.length - 1];
+ match = Hoek.reach(result, parts.slice(0, -1).join("."));
+ }
+
+ try {
+ match[field] = JSON.parse(match[field]);
+ }
+ catch (ex) {
+ throw new Error(`Failed to parse JSON in key ('${k}' in '${result}'): ${ex}`);
+ }
+ }
+ }
+ }
+
+ return result;
+ }
+ };
+};
diff --git a/KST-ASD-BI-101/cts/service/lib/consts.js b/KST-ASD-BI-101/cts/service/lib/consts.js
new file mode 100644
index 0000000..de2b937
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/lib/consts.js
@@ -0,0 +1,3 @@
+module.exports = {
+ AUTH_TTL_SECONDS: 8 * 60 * 60
+};
diff --git a/KST-ASD-BI-101/cts/service/lib/db.js b/KST-ASD-BI-101/cts/service/lib/db.js
new file mode 100644
index 0000000..58a0eff
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/lib/db.js
@@ -0,0 +1,24 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+const Knex = require("knex"),
+ KnexStringcase = require('knex-stringcase'),
+ KnexCfg = require("../knexfile");
+
+module.exports = async () => {
+ const knexCfg = await KnexCfg();
+
+ return Knex(KnexStringcase["default"](knexCfg));
+};
diff --git a/KST-ASD-BI-101/cts/service/package-lock.json b/KST-ASD-BI-101/cts/service/package-lock.json
new file mode 100644
index 0000000..85e457a
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/package-lock.json
@@ -0,0 +1,1690 @@
+{
+ "name": "catapult-cts-service",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "catapult-cts-service",
+ "version": "1.0.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@cmi5/requirements": "^1.0.0",
+ "@hapi/basic": "^6.0.0",
+ "@hapi/boom": "^9.1.3",
+ "@hapi/cookie": "^11.0.2",
+ "@hapi/h2o2": "^9.1.0",
+ "@hapi/hapi": "^20.1.5",
+ "@hapi/hoek": "^9.2.0",
+ "@hapi/inert": "^6.0.3",
+ "@hapi/jwt": "^2.0.1",
+ "@hapi/vision": "^6.1.0",
+ "@hapi/wreck": "^17.1.0",
+ "axios": "^1.6.8",
+ "bcrypt": "^5.0.1",
+ "hapi-swagger": "^14.2.1",
+ "iri": "^1.3.0",
+ "joi": "^17.4.1",
+ "knex": "^0.95.7",
+ "knex-stringcase": "^1.4.5",
+ "mysql": "^2.18.1",
+ "uuid": "^8.3.2",
+ "wait-port": "^0.2.9"
+ },
+ "devDependencies": {
+ "detect-secrets": "^1.0.6"
+ }
+ },
+ "node_modules/@cmi5/requirements": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@cmi5/requirements/-/requirements-1.0.0.tgz",
+ "integrity": "sha512-zma/xICz33dNVgpDYFeEhVtX9jsPXw8c8GhX4eZdHLMyfqCowyZN16ESYSv/zQp8orOfWHVPdm2bojv4TYUY/w=="
+ },
+ "node_modules/@hapi/accept": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@hapi/accept/-/accept-5.0.2.tgz",
+ "integrity": "sha512-CmzBx/bXUR8451fnZRuZAJRlzgm0Jgu5dltTX/bszmR2lheb9BpyN47Q1RbaGTsvFzn0PXAEs+lXDKfshccYZw==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/ammo": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@hapi/ammo/-/ammo-5.0.1.tgz",
+ "integrity": "sha512-FbCNwcTbnQP4VYYhLNGZmA76xb2aHg9AMPiy18NZyWMG310P5KdFGyA9v2rm5ujrIny77dEEIkMOwl0Xv+fSSA==",
+ "dependencies": {
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/b64": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-5.0.0.tgz",
+ "integrity": "sha512-ngu0tSEmrezoiIaNGG6rRvKOUkUuDdf4XTPnONHGYfSGRmDqPZX5oJL6HAdKTo1UQHECbdB4OzhWrfgVppjHUw==",
+ "dependencies": {
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/basic": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@hapi/basic/-/basic-6.0.0.tgz",
+ "integrity": "sha512-nWWSXNCq3WptnP3To2c8kfQiRFDUnd9FQOcMS0B85y1x/m12c0hhp+VdmK60BMe44k6WIog1n6g8f9gZOagqBg==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/boom": {
+ "version": "9.1.3",
+ "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.3.tgz",
+ "integrity": "sha512-RlrGyZ603hE/eRTZtTltocRm50HHmrmL3kGOP0SQ9MasazlW1mt/fkv4C5P/6rnpFXjwld/POFX1C8tMZE3ldg==",
+ "dependencies": {
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/bounce": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@hapi/bounce/-/bounce-2.0.0.tgz",
+ "integrity": "sha512-JesW92uyzOOyuzJKjoLHM1ThiOvHPOLDHw01YV8yh5nCso7sDwJho1h0Ad2N+E62bZyz46TG3xhAi/78Gsct6A==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/bourne": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.0.0.tgz",
+ "integrity": "sha512-WEezM1FWztfbzqIUbsDzFRVMxSoLy3HugVcux6KDDtTqzPsLE8NDRHfXvev66aH1i2oOKKar3/XDjbvh/OUBdg=="
+ },
+ "node_modules/@hapi/call": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/@hapi/call/-/call-8.0.1.tgz",
+ "integrity": "sha512-bOff6GTdOnoe5b8oXRV3lwkQSb/LAWylvDMae6RgEWWntd0SHtkYbQukDHKlfaYtVnSAgIavJ0kqszF/AIBb6g==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/catbox": {
+ "version": "11.1.1",
+ "resolved": "https://registry.npmjs.org/@hapi/catbox/-/catbox-11.1.1.tgz",
+ "integrity": "sha512-u/8HvB7dD/6X8hsZIpskSDo4yMKpHxFd7NluoylhGrL6cUfYxdQPnvUp9YU2C6F9hsyBVLGulBd9vBN1ebfXOQ==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/hoek": "9.x.x",
+ "@hapi/podium": "4.x.x",
+ "@hapi/validate": "1.x.x"
+ }
+ },
+ "node_modules/@hapi/catbox-memory": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@hapi/catbox-memory/-/catbox-memory-5.0.1.tgz",
+ "integrity": "sha512-QWw9nOYJq5PlvChLWV8i6hQHJYfvdqiXdvTupJFh0eqLZ64Xir7mKNi96d5/ZMUAqXPursfNDIDxjFgoEDUqeQ==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/catbox-object": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@hapi/catbox-object/-/catbox-object-2.0.0.tgz",
+ "integrity": "sha512-tzTo5q9UVqwqtpNkIz0VNSmJTbaGyD9ZQmw4a91BBWB+YJWYa066KkxOTHGmmWJzjZEhG2CsNYKu34J25pA5aw==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/content": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@hapi/content/-/content-5.0.2.tgz",
+ "integrity": "sha512-mre4dl1ygd4ZyOH3tiYBrOUBzV7Pu/EOs8VLGf58vtOEECWed8Uuw6B4iR9AN/8uQt42tB04qpVaMyoMQh0oMw==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/cookie": {
+ "version": "11.0.2",
+ "resolved": "https://registry.npmjs.org/@hapi/cookie/-/cookie-11.0.2.tgz",
+ "integrity": "sha512-LRpSuHC53urzml83c5eUHSPPt7YtK1CaaPZU9KmnhZlacVVojrWJzOUIcwOADDvCZjDxowCO3zPMaOqzEm9kgg==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/bounce": "2.x.x",
+ "@hapi/hoek": "9.x.x",
+ "@hapi/validate": "1.x.x"
+ }
+ },
+ "node_modules/@hapi/cryptiles": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@hapi/cryptiles/-/cryptiles-5.1.0.tgz",
+ "integrity": "sha512-fo9+d1Ba5/FIoMySfMqPBR/7Pa29J2RsiPrl7bkwo5W5o+AN1dAYQRi4SPrPwwVxVGKjgLOEWrsvt1BonJSfLA==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/@hapi/file": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@hapi/file/-/file-2.0.0.tgz",
+ "integrity": "sha512-WSrlgpvEqgPWkI18kkGELEZfXr0bYLtr16iIN4Krh9sRnzBZN6nnWxHFxtsnP684wueEySBbXPDg/WfA9xJdBQ=="
+ },
+ "node_modules/@hapi/h2o2": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/@hapi/h2o2/-/h2o2-9.1.0.tgz",
+ "integrity": "sha512-B7E58bMhxmpiDI22clxTexoAaVShNBk1Ez6S8SQjQZu5FxxD6Tqa44sXeZQBtWrdJF7ZRbsY60/C8AHLRxagNA==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/hoek": "9.x.x",
+ "@hapi/validate": "1.x.x",
+ "@hapi/wreck": "17.x.x"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/@hapi/hapi": {
+ "version": "20.1.5",
+ "resolved": "https://registry.npmjs.org/@hapi/hapi/-/hapi-20.1.5.tgz",
+ "integrity": "sha512-BhJ5XFR9uWPUBj/z5pPqXSk8OnvQQU/EbQjwpmjZy0ymNEiq7kIhXkAmzXcntbBHta9o7zpW8XMeXnfV4wudXw==",
+ "dependencies": {
+ "@hapi/accept": "^5.0.1",
+ "@hapi/ammo": "^5.0.1",
+ "@hapi/boom": "^9.1.0",
+ "@hapi/bounce": "^2.0.0",
+ "@hapi/call": "^8.0.0",
+ "@hapi/catbox": "^11.1.1",
+ "@hapi/catbox-memory": "^5.0.0",
+ "@hapi/heavy": "^7.0.1",
+ "@hapi/hoek": "^9.0.4",
+ "@hapi/mimos": "^6.0.0",
+ "@hapi/podium": "^4.1.1",
+ "@hapi/shot": "^5.0.5",
+ "@hapi/somever": "^3.0.0",
+ "@hapi/statehood": "^7.0.3",
+ "@hapi/subtext": "^7.0.3",
+ "@hapi/teamwork": "^5.1.0",
+ "@hapi/topo": "^5.0.0",
+ "@hapi/validate": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/@hapi/heavy": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@hapi/heavy/-/heavy-7.0.1.tgz",
+ "integrity": "sha512-vJ/vzRQ13MtRzz6Qd4zRHWS3FaUc/5uivV2TIuExGTM9Qk+7Zzqj0e2G7EpE6KztO9SalTbiIkTh7qFKj/33cA==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/hoek": "9.x.x",
+ "@hapi/validate": "1.x.x"
+ }
+ },
+ "node_modules/@hapi/hoek": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.0.tgz",
+ "integrity": "sha512-sqKVVVOe5ivCaXDWivIJYVSaEgdQK9ul7a4Kity5Iw7u9+wBAPbX1RMSnLLmp7O4Vzj0WOWwMAJsTL00xwaNug=="
+ },
+ "node_modules/@hapi/inert": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@hapi/inert/-/inert-6.0.3.tgz",
+ "integrity": "sha512-Z6Pi0Wsn2pJex5CmBaq+Dky9q40LGzXLUIUFrYpDtReuMkmfy9UuUeYc4064jQ1Xe9uuw7kbwE6Fq6rqKAdjAg==",
+ "dependencies": {
+ "@hapi/ammo": "5.x.x",
+ "@hapi/boom": "9.x.x",
+ "@hapi/bounce": "2.x.x",
+ "@hapi/hoek": "9.x.x",
+ "@hapi/validate": "1.x.x",
+ "lru-cache": "^6.0.0"
+ }
+ },
+ "node_modules/@hapi/iron": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@hapi/iron/-/iron-6.0.0.tgz",
+ "integrity": "sha512-zvGvWDufiTGpTJPG1Y/McN8UqWBu0k/xs/7l++HVU535NLHXsHhy54cfEMdW7EjwKfbBfM9Xy25FmTiobb7Hvw==",
+ "dependencies": {
+ "@hapi/b64": "5.x.x",
+ "@hapi/boom": "9.x.x",
+ "@hapi/bourne": "2.x.x",
+ "@hapi/cryptiles": "5.x.x",
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/jwt": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@hapi/jwt/-/jwt-2.0.1.tgz",
+ "integrity": "sha512-6/nX/yOIk9mvs+r72LFhF177yOB4yVv3e0Nqn7cIx2CU+VruBHxMKkHraARXx6oUAtiwNuyhW+trO5QeGm9ESQ==",
+ "dependencies": {
+ "@hapi/b64": "5.x.x",
+ "@hapi/boom": "9.x.x",
+ "@hapi/bounce": "2.x.x",
+ "@hapi/bourne": "2.x.x",
+ "@hapi/catbox-object": "2.x.x",
+ "@hapi/cryptiles": "5.x.x",
+ "@hapi/hoek": "9.x.x",
+ "@hapi/wreck": "17.x.x",
+ "ecdsa-sig-formatter": "1.x.x",
+ "joi": "^17.2.1"
+ }
+ },
+ "node_modules/@hapi/mimos": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@hapi/mimos/-/mimos-6.0.0.tgz",
+ "integrity": "sha512-Op/67tr1I+JafN3R3XN5DucVSxKRT/Tc+tUszDwENoNpolxeXkhrJ2Czt6B6AAqrespHoivhgZBWYSuANN9QXg==",
+ "dependencies": {
+ "@hapi/hoek": "9.x.x",
+ "mime-db": "1.x.x"
+ }
+ },
+ "node_modules/@hapi/nigel": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@hapi/nigel/-/nigel-4.0.2.tgz",
+ "integrity": "sha512-ht2KoEsDW22BxQOEkLEJaqfpoKPXxi7tvabXy7B/77eFtOyG5ZEstfZwxHQcqAiZhp58Ae5vkhEqI03kawkYNw==",
+ "dependencies": {
+ "@hapi/hoek": "^9.0.4",
+ "@hapi/vise": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/@hapi/pez": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@hapi/pez/-/pez-5.0.3.tgz",
+ "integrity": "sha512-mpikYRJjtrbJgdDHG/H9ySqYqwJ+QU/D7FXsYciS9P7NYBXE2ayKDAy3H0ou6CohOCaxPuTV4SZ0D936+VomHA==",
+ "dependencies": {
+ "@hapi/b64": "5.x.x",
+ "@hapi/boom": "9.x.x",
+ "@hapi/content": "^5.0.2",
+ "@hapi/hoek": "9.x.x",
+ "@hapi/nigel": "4.x.x"
+ }
+ },
+ "node_modules/@hapi/podium": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@hapi/podium/-/podium-4.1.3.tgz",
+ "integrity": "sha512-ljsKGQzLkFqnQxE7qeanvgGj4dejnciErYd30dbrYzUOF/FyS/DOF97qcrT3bhoVwCYmxa6PEMhxfCPlnUcD2g==",
+ "dependencies": {
+ "@hapi/hoek": "9.x.x",
+ "@hapi/teamwork": "5.x.x",
+ "@hapi/validate": "1.x.x"
+ }
+ },
+ "node_modules/@hapi/shot": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/@hapi/shot/-/shot-5.0.5.tgz",
+ "integrity": "sha512-x5AMSZ5+j+Paa8KdfCoKh+klB78otxF+vcJR/IoN91Vo2e5ulXIW6HUsFTCU+4W6P/Etaip9nmdAx2zWDimB2A==",
+ "dependencies": {
+ "@hapi/hoek": "9.x.x",
+ "@hapi/validate": "1.x.x"
+ }
+ },
+ "node_modules/@hapi/somever": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@hapi/somever/-/somever-3.0.1.tgz",
+ "integrity": "sha512-4ZTSN3YAHtgpY/M4GOtHUXgi6uZtG9nEZfNI6QrArhK0XN/RDVgijlb9kOmXwCR5VclDSkBul9FBvhSuKXx9+w==",
+ "dependencies": {
+ "@hapi/bounce": "2.x.x",
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/statehood": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/@hapi/statehood/-/statehood-7.0.3.tgz",
+ "integrity": "sha512-pYB+pyCHkf2Amh67QAXz7e/DN9jcMplIL7Z6N8h0K+ZTy0b404JKPEYkbWHSnDtxLjJB/OtgElxocr2fMH4G7w==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/bounce": "2.x.x",
+ "@hapi/bourne": "2.x.x",
+ "@hapi/cryptiles": "5.x.x",
+ "@hapi/hoek": "9.x.x",
+ "@hapi/iron": "6.x.x",
+ "@hapi/validate": "1.x.x"
+ }
+ },
+ "node_modules/@hapi/subtext": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/@hapi/subtext/-/subtext-7.0.3.tgz",
+ "integrity": "sha512-CekDizZkDGERJ01C0+TzHlKtqdXZxzSWTOaH6THBrbOHnsr3GY+yiMZC+AfNCypfE17RaIakGIAbpL2Tk1z2+A==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/bourne": "2.x.x",
+ "@hapi/content": "^5.0.2",
+ "@hapi/file": "2.x.x",
+ "@hapi/hoek": "9.x.x",
+ "@hapi/pez": "^5.0.1",
+ "@hapi/wreck": "17.x.x"
+ }
+ },
+ "node_modules/@hapi/teamwork": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@hapi/teamwork/-/teamwork-5.1.0.tgz",
+ "integrity": "sha512-llqoQTrAJDTXxG3c4Kz/uzhBS1TsmSBa/XG5SPcVXgmffHE1nFtyLIK0hNJHCB3EuBKT84adzd1hZNY9GJLWtg==",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/@hapi/topo": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.0.0.tgz",
+ "integrity": "sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw==",
+ "dependencies": {
+ "@hapi/hoek": "^9.0.0"
+ }
+ },
+ "node_modules/@hapi/validate": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@hapi/validate/-/validate-1.1.3.tgz",
+ "integrity": "sha512-/XMR0N0wjw0Twzq2pQOzPBZlDzkekGcoCtzO314BpIEsbXdYGthQUbxgkGDf4nhk1+IPDAsXqWjMohRQYO06UA==",
+ "dependencies": {
+ "@hapi/hoek": "^9.0.0",
+ "@hapi/topo": "^5.0.0"
+ }
+ },
+ "node_modules/@hapi/vise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@hapi/vise/-/vise-4.0.0.tgz",
+ "integrity": "sha512-eYyLkuUiFZTer59h+SGy7hUm+qE9p+UemePTHLlIWppEd+wExn3Df5jO04bFQTm7nleF5V8CtuYQYb+VFpZ6Sg==",
+ "dependencies": {
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/vision": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@hapi/vision/-/vision-6.1.0.tgz",
+ "integrity": "sha512-ll0zJ13xDxCYIWvC1aq/8srK0bTXfqZYGT+YoTi/fS42gYYJ3dnvmS35r8T8XXtJ6F6cmya8G2cRlMR/z11LQw==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/bounce": "2.x.x",
+ "@hapi/hoek": "9.x.x",
+ "@hapi/validate": "1.x.x"
+ }
+ },
+ "node_modules/@hapi/wreck": {
+ "version": "17.1.0",
+ "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-17.1.0.tgz",
+ "integrity": "sha512-nx6sFyfqOpJ+EFrHX+XWwJAxs3ju4iHdbB/bwR8yTNZOiYmuhA8eCe7lYPtYmb4j7vyK/SlbaQsmTtUrMvPEBw==",
+ "dependencies": {
+ "@hapi/boom": "9.x.x",
+ "@hapi/bourne": "2.x.x",
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@mapbox/node-pre-gyp": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.3.tgz",
+ "integrity": "sha512-9dTIfQW8HVCxLku5QrJ/ysS/b2MdYngs9+/oPrOTLvp3TrggdANYVW2h8FGJGDf0J7MYfp44W+c90cVJx+ASuA==",
+ "dependencies": {
+ "detect-libc": "^1.0.3",
+ "https-proxy-agent": "^5.0.0",
+ "make-dir": "^3.1.0",
+ "node-fetch": "^2.6.1",
+ "nopt": "^5.0.0",
+ "npmlog": "^4.1.2",
+ "rimraf": "^3.0.2",
+ "semver": "^7.3.4",
+ "tar": "^6.1.0"
+ },
+ "bin": {
+ "node-pre-gyp": "bin/node-pre-gyp"
+ }
+ },
+ "node_modules/@sideway/address": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.2.tgz",
+ "integrity": "sha512-idTz8ibqWFrPU8kMirL0CoPH/A29XOzzAzpyN3zQ4kAWnzmNfFmRaoMNN6VI8ske5M73HZyhIaW4OuSFIdM4oA==",
+ "dependencies": {
+ "@hapi/hoek": "^9.0.0"
+ }
+ },
+ "node_modules/@sideway/formula": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.0.tgz",
+ "integrity": "sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg=="
+ },
+ "node_modules/@sideway/pinpoint": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
+ "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
+ },
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "dependencies": {
+ "color-convert": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
+ },
+ "node_modules/are-we-there-yet": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz",
+ "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==",
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^2.0.6"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+ },
+ "node_modules/axios": {
+ "version": "1.6.8",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz",
+ "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.0",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+ },
+ "node_modules/bcrypt": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.0.1.tgz",
+ "integrity": "sha512-9BTgmrhZM2t1bNuDtrtIMVSmmxZBrJ71n8Wg+YgdjHuIWYF7SjjmCPZFB+/5i/o/PIeRpwVJR3P+NrpIItUjqw==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "@mapbox/node-pre-gyp": "^1.0.0",
+ "node-addon-api": "^3.1.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/bignumber.js": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz",
+ "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/call-me-maybe": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz",
+ "integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms="
+ },
+ "node_modules/chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dependencies": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/code-point-at": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+ "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dependencies": {
+ "color-name": "1.1.3"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
+ },
+ "node_modules/colorette": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz",
+ "integrity": "sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw=="
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+ },
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
+ },
+ "node_modules/core-js": {
+ "version": "2.6.12",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
+ "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
+ "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
+ "hasInstallScript": true
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
+ },
+ "node_modules/debug": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+ "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o="
+ },
+ "node_modules/detect-libc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+ "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
+ "bin": {
+ "detect-libc": "bin/detect-libc.js"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/detect-secrets": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/detect-secrets/-/detect-secrets-1.0.6.tgz",
+ "integrity": "sha512-bAEmXtMJNS/By/TCg9uSW9Sp0V1Z0N+uwlQWFUMbCVri5Yq5rM8gVs+2zzNIjNOy36o5kANZRrMc+22Zf6eRFQ==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.1.0",
+ "which": "^1.3.1"
+ },
+ "bin": {
+ "detect-secrets-launcher": "bin/detect-secrets-launcher.js"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+ "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/esm": {
+ "version": "3.2.25",
+ "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
+ "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.6",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
+ "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+ "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/format-util": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/format-util/-/format-util-1.0.5.tgz",
+ "integrity": "sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg=="
+ },
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
+ },
+ "node_modules/gauge": {
+ "version": "2.7.4",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+ "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
+ "dependencies": {
+ "aproba": "^1.0.3",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.0",
+ "object-assign": "^4.1.0",
+ "signal-exit": "^3.0.0",
+ "string-width": "^1.0.1",
+ "strip-ansi": "^3.0.1",
+ "wide-align": "^1.1.0"
+ }
+ },
+ "node_modules/getopts": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.2.5.tgz",
+ "integrity": "sha512-9jb7AW5p3in+IiJWhQiZmmwkpLaR/ccTWdWQCtZM66HJcHHLegowh4q4tSD7gouUyeNvFWRavfK9GXosQHDpFA=="
+ },
+ "node_modules/glob": {
+ "version": "7.1.6",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
+ "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.0.4",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/handlebars": {
+ "version": "4.7.7",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
+ "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
+ "dependencies": {
+ "minimist": "^1.2.5",
+ "neo-async": "^2.6.0",
+ "source-map": "^0.6.1",
+ "wordwrap": "^1.0.0"
+ },
+ "bin": {
+ "handlebars": "bin/handlebars"
+ },
+ "engines": {
+ "node": ">=0.4.7"
+ },
+ "optionalDependencies": {
+ "uglify-js": "^3.1.4"
+ }
+ },
+ "node_modules/handlebars/node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/hapi-swagger": {
+ "version": "14.2.1",
+ "resolved": "https://registry.npmjs.org/hapi-swagger/-/hapi-swagger-14.2.1.tgz",
+ "integrity": "sha512-K1oN/88Jh4/Q6ZMKxrbQT6bYrFouA3PaE8Kh5F3loNyPm8dezAeGvx8vreeNWCzFLwqHNFjFbZcgBgKnYF1Dwg==",
+ "dependencies": {
+ "@hapi/boom": "^9.1.0",
+ "@hapi/hoek": "^9.0.2",
+ "handlebars": "^4.7.7",
+ "http-status": "^1.0.1",
+ "json-schema-ref-parser": "^6.1.0",
+ "swagger-parser": "4.0.2",
+ "swagger-ui-dist": "^3.47.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "@hapi/hapi": "^20.0.0",
+ "joi": "17.x"
+ }
+ },
+ "node_modules/has": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+ "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+ "dependencies": {
+ "function-bind": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
+ },
+ "node_modules/http-status": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/http-status/-/http-status-1.5.0.tgz",
+ "integrity": "sha512-wcGvY31MpFNHIkUcXHHnvrE4IKYlpvitJw5P/1u892gMBAM46muQ+RH7UN1d+Ntnfx5apnOnVY6vcLmrWHOLwg==",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
+ "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "node_modules/interpret": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
+ "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/iri": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/iri/-/iri-1.3.0.tgz",
+ "integrity": "sha512-1bPyBMoj15VLCxtPDPC7YREka60TTAp7FH+4cU7k/haISq4XyR4/GVMmX+J3TeIOlNYH7twHbpYO9QQyejBZMg=="
+ },
+ "node_modules/is-core-module": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.5.0.tgz",
+ "integrity": "sha512-TXCMSDsEHMEEZ6eCA8rwRDbLu55MRGmrctljsBX/2v1d9/GzqHOxW5c5oPSgrUt2vBFXebu9rGqckXGPWOlYpg==",
+ "dependencies": {
+ "has": "^1.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+ "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
+ "dependencies": {
+ "number-is-nan": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true
+ },
+ "node_modules/joi": {
+ "version": "17.4.1",
+ "resolved": "https://registry.npmjs.org/joi/-/joi-17.4.1.tgz",
+ "integrity": "sha512-gDPOwQ5sr+BUxXuPDGrC1pSNcVR/yGGcTI0aCnjYxZEa3za60K/iCQ+OFIkEHWZGVCUcUlXlFKvMmrlmxrG6UQ==",
+ "dependencies": {
+ "@hapi/hoek": "^9.0.0",
+ "@hapi/topo": "^5.0.0",
+ "@sideway/address": "^4.1.0",
+ "@sideway/formula": "^3.0.0",
+ "@sideway/pinpoint": "^2.0.0"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "3.14.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+ "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-schema-ref-parser": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-6.1.0.tgz",
+ "integrity": "sha512-pXe9H1m6IgIpXmE5JSb8epilNTGsmTb2iPohAXpOdhqGFbQjNeHHsZxU+C8w6T81GZxSPFLeUoqDJmzxx5IGuw==",
+ "deprecated": "Please switch to @apidevtools/json-schema-ref-parser",
+ "dependencies": {
+ "call-me-maybe": "^1.0.1",
+ "js-yaml": "^3.12.1",
+ "ono": "^4.0.11"
+ }
+ },
+ "node_modules/knex": {
+ "version": "0.95.7",
+ "resolved": "https://registry.npmjs.org/knex/-/knex-0.95.7.tgz",
+ "integrity": "sha512-J2X79td0NAcreTyWVmmHHretz5Ox705FHywddjkT3esTtmggphjcfDoaXym18xtsLdjzOvEb53WB/58lqcF14w==",
+ "dependencies": {
+ "colorette": "1.2.1",
+ "commander": "^7.1.0",
+ "debug": "4.3.2",
+ "escalade": "^3.1.1",
+ "esm": "^3.2.25",
+ "getopts": "2.2.5",
+ "interpret": "^2.2.0",
+ "lodash": "^4.17.21",
+ "pg-connection-string": "2.5.0",
+ "rechoir": "^0.7.0",
+ "resolve-from": "^5.0.0",
+ "tarn": "^3.0.1",
+ "tildify": "2.0.0"
+ },
+ "bin": {
+ "knex": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependenciesMeta": {
+ "mysql": {
+ "optional": true
+ },
+ "mysql2": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ },
+ "sqlite3": {
+ "optional": true
+ },
+ "tedious": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/knex-stringcase": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/knex-stringcase/-/knex-stringcase-1.4.5.tgz",
+ "integrity": "sha512-dO8CzBAfxX6HeZ6BP+5KCMn3450MRoz3xsUascogE3OucUIyv/ki/W30YQz15EuAbHr03s9DfgiNlKfAvD+PvQ==",
+ "dependencies": {
+ "stringcase": "^4.3.1"
+ }
+ },
+ "node_modules/knex/node_modules/debug": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+ "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+ },
+ "node_modules/lodash.get": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+ "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
+ },
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA="
+ },
+ "node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+ "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+ "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+ "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
+ },
+ "node_modules/minipass": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz",
+ "integrity": "sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ },
+ "node_modules/mysql": {
+ "version": "2.18.1",
+ "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz",
+ "integrity": "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==",
+ "dependencies": {
+ "bignumber.js": "9.0.0",
+ "readable-stream": "2.3.7",
+ "safe-buffer": "5.1.2",
+ "sqlstring": "2.3.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/neo-async": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
+ },
+ "node_modules/node-addon-api": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.1.0.tgz",
+ "integrity": "sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw=="
+ },
+ "node_modules/node-fetch": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
+ "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==",
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ }
+ },
+ "node_modules/nopt": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+ "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+ "dependencies": {
+ "abbrev": "1"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/npmlog": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
+ "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
+ "dependencies": {
+ "are-we-there-yet": "~1.1.2",
+ "console-control-strings": "~1.1.0",
+ "gauge": "~2.7.3",
+ "set-blocking": "~2.0.0"
+ }
+ },
+ "node_modules/number-is-nan": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+ "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/ono": {
+ "version": "4.0.11",
+ "resolved": "https://registry.npmjs.org/ono/-/ono-4.0.11.tgz",
+ "integrity": "sha512-jQ31cORBFE6td25deYeD80wxKBMj+zBmHTrVxnc6CKhx8gho6ipmWM5zj/oeoqioZ99yqBls9Z/9Nss7J26G2g==",
+ "dependencies": {
+ "format-util": "^1.0.3"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz",
+ "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ=="
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/rechoir": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.0.tgz",
+ "integrity": "sha512-ADsDEH2bvbjltXEP+hTIAmeFekTFK0V2BTxMkok6qILyAJEXV0AFfoWcAq4yfll5VdIMd/RVXq0lR+wQi5ZU3Q==",
+ "dependencies": {
+ "resolve": "^1.9.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.20.0",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
+ "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
+ "dependencies": {
+ "is-core-module": "^2.2.0",
+ "path-parse": "^1.0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+ },
+ "node_modules/semver": {
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
+ "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
+ "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA=="
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
+ },
+ "node_modules/sqlstring": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz",
+ "integrity": "sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A=",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+ "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
+ "dependencies": {
+ "code-point-at": "^1.0.0",
+ "is-fullwidth-code-point": "^1.0.0",
+ "strip-ansi": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stringcase": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/stringcase/-/stringcase-4.3.1.tgz",
+ "integrity": "sha512-Ov7McNX1sFaEX9NWijD1hIOVDDhKdnFzN9tvoa1N8xgrclouhsO4kBPVrTPhjO/zP5mn1Ww03uZ2SThNMXS7zg==",
+ "engines": {
+ "node": ">=8",
+ "npm": ">=5"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+ "dependencies": {
+ "ansi-regex": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/swagger-methods": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/swagger-methods/-/swagger-methods-1.0.8.tgz",
+ "integrity": "sha512-G6baCwuHA+C5jf4FNOrosE4XlmGsdjbOjdBK4yuiDDj/ro9uR4Srj3OR84oQMT8F3qKp00tYNv0YN730oTHPZA==",
+ "deprecated": "This package is no longer being maintained."
+ },
+ "node_modules/swagger-parser": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-4.0.2.tgz",
+ "integrity": "sha512-hKslog8LhsXICJ1sMLsA8b8hQ3oUEX0457aLCFJc4zz6m8drmnCtyjbVqS5HycaKFOKVolJc2wFoe8KDPWfp4g==",
+ "dependencies": {
+ "call-me-maybe": "^1.0.1",
+ "debug": "^3.1.0",
+ "json-schema-ref-parser": "^4.1.0",
+ "ono": "^4.0.3",
+ "swagger-methods": "^1.0.4",
+ "swagger-schema-official": "2.0.0-bab6bed",
+ "z-schema": "^3.19.0"
+ }
+ },
+ "node_modules/swagger-parser/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/swagger-parser/node_modules/json-schema-ref-parser": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-4.1.1.tgz",
+ "integrity": "sha512-lByoCHZ6H2zgb6NtsXIqtzQ+6Ji7iVqnrhWxsXLhF+gXmgu6E8+ErpDxCMR439MUG1nfMjWI2HAoM8l0XgSNhw==",
+ "deprecated": "Please switch to @apidevtools/json-schema-ref-parser",
+ "dependencies": {
+ "call-me-maybe": "^1.0.1",
+ "debug": "^3.1.0",
+ "js-yaml": "^3.10.0",
+ "ono": "^4.0.3"
+ }
+ },
+ "node_modules/swagger-schema-official": {
+ "version": "2.0.0-bab6bed",
+ "resolved": "https://registry.npmjs.org/swagger-schema-official/-/swagger-schema-official-2.0.0-bab6bed.tgz",
+ "integrity": "sha1-cAcEaNbSl3ylI3suUZyn0Gouo/0="
+ },
+ "node_modules/swagger-ui-dist": {
+ "version": "3.51.1",
+ "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-3.51.1.tgz",
+ "integrity": "sha512-df2mEeVgnJp/FcXY3DRh3CsTfvHVTaO6g3FJP/kfwhxfOD1+YTXqBZrOIIsYTPtcRIFBkCAto0NFCxAV4XFRbw=="
+ },
+ "node_modules/tar": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz",
+ "integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==",
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^3.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/tarn": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.1.tgz",
+ "integrity": "sha512-6usSlV9KyHsspvwu2duKH+FMUhqJnAh6J5J/4MITl8s94iSUQTLkJggdiewKv4RyARQccnigV48Z+khiuVZDJw==",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/tildify": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
+ "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/uglify-js": {
+ "version": "3.13.10",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.13.10.tgz",
+ "integrity": "sha512-57H3ACYFXeo1IaZ1w02sfA71wI60MGco/IQFjOqK+WtKoprh7Go2/yvd2HPtoJILO2Or84ncLccI4xoHMTSbGg==",
+ "optional": true,
+ "bin": {
+ "uglifyjs": "bin/uglifyjs"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+ },
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/validator": {
+ "version": "10.11.0",
+ "resolved": "https://registry.npmjs.org/validator/-/validator-10.11.0.tgz",
+ "integrity": "sha512-X/p3UZerAIsbBfN/IwahhYaBbY68EN/UQBWHtsbXGT5bfrH/p4NQzUCG1kF/rtKaNpnJ7jAu6NGTdSNtyNIXMw==",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/wait-port": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-0.2.9.tgz",
+ "integrity": "sha512-hQ/cVKsNqGZ/UbZB/oakOGFqic00YAMM5/PEj3Bt4vKarv2jWIWzDbqlwT94qMs/exAQAsvMOq99sZblV92zxQ==",
+ "dependencies": {
+ "chalk": "^2.4.2",
+ "commander": "^3.0.2",
+ "debug": "^4.1.1"
+ },
+ "bin": {
+ "wait-port": "bin/wait-port.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wait-port/node_modules/commander": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-3.0.2.tgz",
+ "integrity": "sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow=="
+ },
+ "node_modules/which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "which": "bin/which"
+ }
+ },
+ "node_modules/wide-align": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",
+ "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==",
+ "dependencies": {
+ "string-width": "^1.0.2 || 2"
+ }
+ },
+ "node_modules/wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus="
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+ },
+ "node_modules/z-schema": {
+ "version": "3.25.1",
+ "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-3.25.1.tgz",
+ "integrity": "sha512-7tDlwhrBG+oYFdXNOjILSurpfQyuVgkRe3hB2q8TEssamDHB7BbLWYkYO98nTn0FibfdFroFKDjndbgufAgS/Q==",
+ "dependencies": {
+ "core-js": "^2.5.7",
+ "lodash.get": "^4.0.0",
+ "lodash.isequal": "^4.0.0",
+ "validator": "^10.0.0"
+ },
+ "bin": {
+ "z-schema": "bin/z-schema"
+ },
+ "optionalDependencies": {
+ "commander": "^2.7.1"
+ }
+ },
+ "node_modules/z-schema/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "optional": true
+ }
+ }
+}
diff --git a/KST-ASD-BI-101/cts/service/package.json b/KST-ASD-BI-101/cts/service/package.json
new file mode 100644
index 0000000..535bb1e
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "catapult-cts-service",
+ "version": "1.0.0",
+ "description": "Web service to support the Catapult CTS UI",
+ "main": "index.js",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/adlnet/CATAPULT.git"
+ },
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "secrets": "npx detect-secrets **/*.js"
+ },
+ "author": "Rustici Software ",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@cmi5/requirements": "^1.0.0",
+ "@hapi/basic": "^6.0.0",
+ "@hapi/boom": "^9.1.3",
+ "@hapi/cookie": "^11.0.2",
+ "@hapi/h2o2": "^9.1.0",
+ "@hapi/hapi": "^20.1.5",
+ "@hapi/hoek": "^9.2.0",
+ "@hapi/inert": "^6.0.3",
+ "@hapi/jwt": "^2.0.1",
+ "@hapi/vision": "^6.1.0",
+ "@hapi/wreck": "^17.1.0",
+ "axios": "^1.6.8",
+ "bcrypt": "^5.0.1",
+ "hapi-swagger": "^14.2.1",
+ "iri": "^1.3.0",
+ "joi": "^17.4.1",
+ "knex": "^0.95.7",
+ "knex-stringcase": "^1.4.5",
+ "mysql": "^2.18.1",
+ "uuid": "^8.3.2",
+ "wait-port": "^0.2.9"
+ },
+ "devDependencies": {
+ "detect-secrets": "^1.0.6"
+ }
+}
diff --git a/KST-ASD-BI-101/cts/service/plugins/routes/client.js b/KST-ASD-BI-101/cts/service/plugins/routes/client.js
new file mode 100644
index 0000000..db71181
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/plugins/routes/client.js
@@ -0,0 +1,54 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+"use strict";
+
+module.exports = {
+ name: "catapult-cts-api-routes-client",
+ register: (server, options) => {
+ server.route(
+ [
+ //
+ // serve the static web client files
+ //
+ {
+ method: "GET",
+ path: "/client/{param*}",
+ handler: {
+ directory: {
+ path: `${__dirname}/../../client`,
+ listing: true
+ }
+ },
+ options: {
+ auth: false
+ }
+ },
+
+ //
+ // Handle `/` to help web UI users get to `/client/`
+ //
+ {
+ method: "GET",
+ path: "/client",
+ handler: (req, h) => h.redirect("/client/"),
+ options: {
+ auth: false
+ }
+ }
+ ]
+ );
+ }
+};
diff --git a/KST-ASD-BI-101/cts/service/plugins/routes/v1/core.js b/KST-ASD-BI-101/cts/service/plugins/routes/v1/core.js
new file mode 100644
index 0000000..06fc4a8
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/plugins/routes/v1/core.js
@@ -0,0 +1,221 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+"use strict";
+
+const ALLOWED_ORIGIN = (process.env.HOSTNAME || "*");
+
+const Bcrypt = require("bcrypt"),
+ Joi = require("joi"),
+ Boom = require("@hapi/boom"),
+ User = require("./lib/user"),
+ { AUTH_TTL_SECONDS } = require("../../../lib/consts"),
+ getClientSafeUser = (user) => {
+ delete user.password;
+ delete user.playerApiToken;
+ delete user.tenantId;
+
+ return user;
+ };
+
+let alreadyBootstrapped = false;
+
+module.exports = {
+ name: "catapult-cts-api-routes-v1-core",
+ register: (server, options) => {
+ server.route(
+ [
+ //
+ // this route is mainly used to check whether or not a cookie provides for valid
+ // authentication, and in the case it does it will return information about the
+ // user which allows for automatic login in the web UI client
+ //
+ // it also acts as the initial request whenever the UI is loaded so use it to
+ // check to make sure the site has been initialized and that at least one user
+ // exists
+ //
+ {
+ method: "GET",
+ path: "/login",
+ options: {
+ auth: {
+ mode: "try"
+ },
+ tags: ["api"],
+ security: true,
+ cors: {
+ origin: [ALLOWED_ORIGIN]
+ }
+ },
+ handler: async (req, h) => {
+ const db = req.server.app.db,
+ responseBody = {};
+ let responseStatus;
+
+ if (req.auth.isAuthenticated) {
+ responseStatus = 200;
+
+ let user;
+
+ try {
+ user = await db.first("*").from("users").queryContext({jsonCols: ["roles"]}).where({id: req.auth.credentials.id});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed to retrieve user for id ${req.auth.credentials.id}: ${ex}`));
+ }
+
+ responseBody.isBootstrapped = true;
+ responseBody.user = getClientSafeUser(user);
+
+ // Restore whatever the current cookie's expiration is at, for use in managing client side logged in state.
+ if (req.state.sid) {
+ responseBody.user.expiresAt = req.state.sid.expiresAt;
+ }
+ }
+ else {
+ responseStatus = 401;
+
+ //
+ // check to make sure there is at least one user in the users table
+ //
+ const [query] = await db("users").count("id", {as: "count"});
+
+ responseBody.isBootstrapped = query.count > 0;
+ }
+
+ return h.response(responseBody).code(responseStatus);
+ }
+ },
+
+ //
+ // this route allows authenticating by username/password and then optionally
+ // provides a cookie to prevent the need to continue to use basic auth
+ //
+ {
+ method: "POST",
+ path: "/login",
+ options: {
+ auth: false,
+ tags: ["api"],
+ validate: {
+ payload: Joi.object({
+ username: Joi.string().required(),
+ password: Joi.string().required(),
+ storeCookie: Joi.boolean().optional()
+ }).label("Request-Login")
+ },
+ security: true,
+ cors: {
+ origin: [ALLOWED_ORIGIN]
+ }
+ },
+ handler: async (req, h) => {
+ let user;
+
+ try {
+ user = await req.server.app.db.first("*").from("users").queryContext({jsonCols: ["roles"]}).where({username: req.payload.username});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed to retrieve user for username ${req.payload.username}: ${ex}`));
+ }
+
+ if (! user || ! await Bcrypt.compare(req.payload.password, user.password)) {
+ throw Boom.unauthorized();
+ }
+
+ const expiresAt = new Date();
+ expiresAt.setSeconds(expiresAt.getSeconds() + AUTH_TTL_SECONDS)
+ user.expiresAt = expiresAt.toISOString();
+
+ if (req.payload.storeCookie) {
+ req.cookieAuth.set(await req.server.methods.getCredentials(user));
+ }
+
+ return getClientSafeUser(user);
+ }
+ },
+
+ //
+ // this route simply removes any previously stored auth cookie
+ //
+ {
+ method: "GET",
+ path: "/logout",
+ options: {
+ auth: false,
+ tags: ["api"]
+ },
+ handler: async (req, h) => {
+ req.cookieAuth.clear();
+
+ return null;
+ }
+ },
+
+ //
+ // this route is used to establish the first user in the database and can't
+ // be accessed once users exist in the DB, it is intended to make it easy
+ // to establish deployments that are unique to few users
+ //
+ {
+ method: "POST",
+ path: "/bootstrap",
+ options: {
+ tags: ["api"],
+ auth: false,
+ validate: {
+ payload: Joi.object({
+ firstUser: Joi.object({
+ username: Joi.string().required(),
+ password: Joi.string().required()
+ }).required()
+ }).label("Request-Bootstrap")
+ }
+ },
+ handler: async (req, h) => {
+
+ if (!alreadyBootstrapped) {
+
+ //
+ // checking that there aren't any users created yet is effectively
+ // the authorization for this resource
+ //
+ const db = req.server.app.db;
+ const [query] = await db("users").count("id", {as: "count"});
+
+ if (query.count > 0) {
+ alreadyBootstrapped = true;
+ }
+ }
+
+ if (alreadyBootstrapped) {
+ throw Boom.badRequest(`This endpoint cannot be used once the system has been initialized.`);
+ }
+
+ try {
+ // the first user has to be an admin so they can handle other users being created
+ await User.create(req.payload.firstUser.username, req.payload.firstUser.password, ["admin"], {req});
+ }
+ catch (ex) {
+ throw Boom.internal(`Failed to create bootstrap user.`);
+ }
+
+ return null;
+ }
+ }
+ ]
+ );
+ }
+};
diff --git a/KST-ASD-BI-101/cts/service/plugins/routes/v1/courses.js b/KST-ASD-BI-101/cts/service/plugins/routes/v1/courses.js
new file mode 100644
index 0000000..f4e05ab
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/plugins/routes/v1/courses.js
@@ -0,0 +1,260 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+"use strict";
+
+const Boom = require("@hapi/boom"),
+ Wreck = require("@hapi/wreck"),
+ { v4: uuidv4 } = require("uuid");
+
+module.exports = {
+ name: "catapult-cts-api-routes-v1-courses",
+ register: (server, options) => {
+ server.route(
+ [
+ {
+ method: "POST",
+ path: "/courses",
+ options: {
+ tags: ["api"],
+
+ // arbitrarily chosen large number (480 MB)
+ payload: {
+ maxBytes: 1024 * 1024 * 480,
+ },
+ pre: [
+ async (req, h) => {
+ req.headers.authorization = await req.server.methods.playerBearerAuthHeader(req);
+
+ return null;
+ }
+ ]
+ },
+ handler: {
+ proxy: {
+ passThrough: true,
+ xforward: true,
+ acceptEncoding: false,
+
+ mapUri: (req) => ({
+ uri: `${req.server.app.player.baseUrl}/api/v1/course`
+ }),
+
+ onResponse: async (err, res, req, h, settings) => {
+ if (err !== null) {
+ throw Boom.internal(new Error(`Failed proxied import request: ${err}`));
+ }
+
+ let payload;
+ try {
+ payload = await Wreck.read(res, {json: true});
+ }
+ catch (ex) {
+ // clean up the original response
+ res.destroy();
+
+ throw Boom.internal(new Error(`Failed to parse player request response: ${ex}`));
+ }
+
+ // clean up the original response
+ res.destroy();
+
+ if (res.statusCode !== 200) {
+ throw Boom.badRequest(new Error(`Failed request to player import: ${payload.message} (${payload.srcError})`), {statusCode: res.statusCode});
+ }
+
+ const db = req.server.app.db;
+
+ let insertResult;
+ try {
+ insertResult = await db.insert(
+ {
+ tenant_id: req.auth.credentials.tenantId,
+ player_id: payload.id,
+ metadata: JSON.stringify({
+ version: 1,
+ structure: payload.structure,
+ aus: payload.metadata.aus
+ })
+ }
+ ).into("courses");
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed to insert course (${payload.id}): ${ex}`));
+ }
+
+ return db.first("*").from("courses").queryContext({jsonCols: ["metadata"]}).where({tenantId: req.auth.credentials.tenantId, id: insertResult});
+ }
+ }
+ }
+ },
+
+ {
+ method: "GET",
+ path: "/courses",
+ options: {
+ tags: ["api"]
+ },
+ handler: async (req, h) => {
+ const db = req.server.app.db,
+ items = await db
+ .select(
+ "*"
+ )
+ .from("courses")
+ .leftJoin(
+ db
+ .select("course_id", db.max("created_at").as("tested_at"))
+ .from("registrations")
+ .where({"registrations.tenant_id": req.auth.credentials.tenantId})
+ .groupBy("course_id")
+ .as("most_recent_test"),
+ "most_recent_test.course_id",
+ "courses.id"
+ )
+ .leftJoin(
+ "registrations",
+ function () {
+ this.on("registrations.course_id", "most_recent_test.course_id");
+ this.andOn("registrations.created_at", "most_recent_test.tested_at")
+ }
+ )
+ .where({"courses.tenant_id": req.auth.credentials.tenantId})
+ .orderBy("courses.created_at", "desc")
+ .options({nestTables: true})
+ .queryContext({jsonCols: ["courses.metadata", "registrations.metadata"]});
+
+ return {
+ items: items.map(
+ (row) => ({
+ id: row.courses.id,
+ createdAt: row.courses.created_at,
+ metadata: row.courses.metadata,
+ lastTested: row.mostRecentTest.tested_at,
+ testResult: row.registrations.metadata ? row.registrations.metadata.result : "not-started",
+ testId: row.registrations.id
+ })
+ )
+ };
+ }
+ },
+
+ {
+ method: "GET",
+ path: "/courses/{id}",
+ options: {
+ tags: ["api"]
+ },
+ handler: async (req, h) => {
+ const result = await req.server.app.db.first("*").from("courses").queryContext({jsonCols: ["metadata"]}).where({tenantId: req.auth.credentials.tenantId, id: req.params.id});
+
+ if (! result) {
+ return Boom.notFound();
+ }
+
+ return result;
+ }
+ },
+
+ {
+ method: "DELETE",
+ path: "/courses/{id}",
+ options: {
+ tags: ["api"],
+ pre: [
+ async (req, h) => {
+ req.headers.authorization = await req.server.methods.playerBearerAuthHeader(req);
+
+ return null;
+ }
+ ]
+ },
+ handler: {
+ proxy: {
+ passThrough: true,
+ xforward: true,
+
+ mapUri: async (req) => {
+ const result = await req.server.app.db.first("playerId").from("courses").where({tenantId: req.auth.credentials.tenantId, id: req.params.id});
+
+ if (! result) {
+ throw Boom.internal(new Error(`Failed to retrieve player id for course: ${req.params.id}`));
+ }
+
+ return {
+ uri: `${req.server.app.player.baseUrl}/api/v1/course/${result.playerId}`
+ };
+ },
+
+ onResponse: async (err, res, req, h, settings) => {
+ if (err !== null) {
+ // clean up the original response
+ res.destroy();
+
+ throw Boom.internal(new Error(`Failed proxied delete request: ${err}`));
+ }
+
+ if (res.statusCode !== 204) {
+ let payload;
+ try {
+ payload = await Wreck.read(res, {json: true});
+ }
+ catch (ex) {
+ // clean up the original response
+ res.destroy();
+
+ throw Boom.internal(new Error(`Failed to parse player request response (${res.statusCode}): ${ex}`));
+ }
+
+ // clean up the original response
+ res.destroy();
+
+ throw Boom.internal(new Error(`Failed request to player delete: ${payload.message} (${payload.srcError})`), {statusCode: res.statusCode});
+ }
+
+
+ // clean up the original response
+ res.destroy();
+
+ const db = req.server.app.db;
+
+ let deleteResult;
+ try {
+ deleteResult = await db("courses").where({tenantId: req.auth.credentials.tenantId, id: req.params.id}).delete();
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed to delete course from database: ${ex}`));
+ }
+
+ return null;
+ }
+ }
+ }
+ },
+
+ {
+ method: "GET",
+ path: "/courses/{id}/tests",
+ options: {
+ tags: ["api"]
+ },
+ handler: async (req, h) => ({
+ items: await req.server.app.db.select("*").queryContext({jsonCols: ["metadata"]}).from("registrations").where({tenantId: req.auth.credentials.tenantId, courseId: req.params.id})
+ })
+ }
+ ]
+ );
+ }
+};
diff --git a/KST-ASD-BI-101/cts/service/plugins/routes/v1/lib/helpers.js b/KST-ASD-BI-101/cts/service/plugins/routes/v1/lib/helpers.js
new file mode 100644
index 0000000..a8984b9
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/plugins/routes/v1/lib/helpers.js
@@ -0,0 +1,11 @@
+module.exports = {
+ doesLRSResourceEnforceConcurrency: (resourcePath) => {
+ const concurrencyPaths = [
+ "activities/state",
+ "activities/profile",
+ "agents/profile"
+ ];
+
+ return concurrencyPaths.includes(resourcePath);
+ }
+}
\ No newline at end of file
diff --git a/KST-ASD-BI-101/cts/service/plugins/routes/v1/lib/user.js b/KST-ASD-BI-101/cts/service/plugins/routes/v1/lib/user.js
new file mode 100644
index 0000000..9ff8d0d
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/plugins/routes/v1/lib/user.js
@@ -0,0 +1,186 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+"use strict";
+
+const Bcrypt = require("bcrypt"),
+ Boom = require("@hapi/boom"),
+ Wreck = require("@hapi/wreck"),
+ Jwt = require("@hapi/jwt");
+
+module.exports = {
+ create: async (username, password, roles = [], {req}) => {
+ //
+ // need to create a tenant in the player
+ //
+ let createTenantResponse,
+ createTenantResponseBody;
+
+ let auth = await req.server.methods.playerBasicAuthHeader(req);
+ let tenantURL = `${req.server.app.player.baseUrl}/api/v1/tenant`;
+
+ try {
+ createTenantResponse = await Wreck.request(
+ "POST",
+ tenantURL,
+ {
+ headers: {
+ Authorization: auth
+ },
+ payload: {
+ code: username
+ }
+ }
+ );
+ createTenantResponseBody = await Wreck.read(createTenantResponse, {json: true});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed request to create player tenant: ${ex}`));
+ }
+
+ if (createTenantResponse.statusCode !== 200) {
+ throw Boom.internal(new Error(`Failed to create player tenant (${createTenantResponse.statusCode}): ${createTenantResponseBody.message}${createTenantResponseBody.srcError ? " (" + createTenantResponseBody.srcError + ")" : ""}`));
+ }
+
+ const playerTenantId = createTenantResponseBody.id;
+
+ //
+ // with the tenant created get a token for this user to use
+ // to access the player API for that tenant
+ //
+ let createTokenResponse,
+ createTokenResponseBody;
+
+ try {
+ createTokenResponse = await Wreck.request(
+ "POST",
+ `${req.server.app.player.baseUrl}/api/v1/auth`,
+ {
+ headers: {
+ Authorization: await req.server.methods.playerBasicAuthHeader(req)
+ },
+ payload: {
+ tenantId: playerTenantId,
+ audience: `cts-${username}`
+ }
+ }
+ );
+ createTokenResponseBody = await Wreck.read(createTokenResponse, {json: true});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed request to create player tenant: ${ex}`));
+ }
+
+ if (createTokenResponse.statusCode !== 200) {
+ throw Boom.internal(new Error(`Failed to retrieve player token (${createTokenResponse.statusCode}): ${createTokenResponseBody.message}${createTokenResponseBody.srcError ? " (" + createTokenResponseBody.srcError + ")" : ""}`));
+ }
+
+ const playerApiToken = createTokenResponseBody.token;
+
+ let userId,
+ tenantId;
+
+ await req.server.app.db.transaction(
+ async (txn) => {
+ //
+ // create a tenant for this user
+ //
+ try {
+ const insertResult = await txn.insert(
+ {
+ code: `user-${username}`,
+ playerTenantId
+ }
+ ).into("tenants");
+
+ tenantId = insertResult[0];
+ }
+ catch (ex) {
+ throw new Error(`Failed to insert tenant: ${ex}`);
+ }
+
+ //
+ // finally create the user which contains the token needed to access
+ // the player API
+ //
+ try {
+ const insertResult = await txn.insert(
+ {
+ tenantId,
+ username: username,
+ password: await Bcrypt.hash(password, 8),
+ playerApiToken,
+ roles: JSON.stringify([
+ "user",
+ ...roles
+ ])
+ }
+ ).into("users");
+
+ userId = insertResult[0];
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed to insert into users: ${ex}`));
+ }
+ }
+ );
+
+ return {userId, tenantId};
+ },
+
+ delete: async (id, {req}) => {
+ const db = req.server.app.db,
+ user = await db.first("*").from("users").where({id}),
+ token = Jwt.token.decode(user.playerApiToken),
+ playerTenantId = token.decoded.payload.sub;
+
+ let deleteTenantResponse;
+
+ try {
+ deleteTenantResponse = await Wreck.request(
+ "DELETE",
+ `${req.server.app.player.baseUrl}/api/v1/tenant/${playerTenantId}`,
+ {
+ headers: {
+ Authorization: await req.server.methods.playerBasicAuthHeader(req)
+ }
+ }
+ );
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed request to delete player tenant: ${ex}`));
+ }
+
+ if (deleteTenantResponse.statusCode !== 204) {
+ const deleteTenantResponseBody = await Wreck.read(deleteTenantResponse, {json: true});
+
+ throw Boom.internal(new Error(`Failed to delete player tenant (${deleteTenantResponse.statusCode}): ${deleteTenantResponseBody.message}${deleteTenantResponseBody.srcError ? " (" + deleteTenantResponseBody.srcError + ")" : ""}`));
+ }
+
+ try {
+ await db("users").where({id: req.params.id}).delete();
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed to delete user from database: ${ex}`));
+ }
+
+ try {
+ await db("tenants").where({playerTenantId}).delete();
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed to delete tenant from database: ${ex}`));
+ }
+ }
+};
diff --git a/KST-ASD-BI-101/cts/service/plugins/routes/v1/mgmt.js b/KST-ASD-BI-101/cts/service/plugins/routes/v1/mgmt.js
new file mode 100644
index 0000000..95a45b0
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/plugins/routes/v1/mgmt.js
@@ -0,0 +1,44 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+"use strict";
+
+module.exports = {
+ name: "catapult-cts-api-routes-v1-mgmt",
+ register: (server, options) => {
+ server.route(
+ {
+ method: "GET",
+ path: "/ping",
+ options: {
+ tags: ["api"]
+ },
+ handler: (req, h) => ({
+ ok: true
+ })
+ },
+ {
+ method: "GET",
+ path: "/about",
+ options: {
+ tags: ["api"]
+ },
+ handler: (req, h) => ({
+ description: "catapult-cts-service"
+ })
+ }
+ );
+ }
+};
diff --git a/KST-ASD-BI-101/cts/service/plugins/routes/v1/sessions.js b/KST-ASD-BI-101/cts/service/plugins/routes/v1/sessions.js
new file mode 100644
index 0000000..349420f
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/plugins/routes/v1/sessions.js
@@ -0,0 +1,647 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+"use strict";
+
+const stream = require("stream"),
+ Boom = require("@hapi/boom"),
+ Wreck = require("@hapi/wreck"),
+ Hoek = require("@hapi/hoek"),
+ Joi = require("joi"),
+ { v4: uuidv4 } = require("uuid"),
+ Requirements = require("@cmi5/requirements"),
+ sessions = {},
+ getClientSafeSession = (session) => {
+ delete session.playerId;
+ delete session.playerAuLaunchUrl;
+ delete session.playerEndpoint;
+ delete session.playerFetch;
+
+ return session;
+ };
+
+const helpers = require("./lib/helpers");
+
+module.exports = {
+ name: "catapult-cts-api-routes-v1-sessions",
+ register: (server, options) => {
+ server.decorate(
+ "toolkit",
+ "sessionEvent",
+ async (sessionId, tenantId, db, rawData) => {
+ const metadata = {
+ version: 1,
+ ...rawData
+ },
+ log = {
+ tenantId,
+ sessionId,
+ metadata: JSON.stringify(metadata)
+ };
+
+ try {
+ const insertResult = await db.insert(log).into("sessions_logs");
+
+ log.id = insertResult[0];
+ }
+ catch (ex) {
+ console.error(`Failed to write to sessions_logs(${sessionId}): ${ex}`);
+ }
+
+ log.metadata = metadata;
+
+ if (sessions[sessionId]) {
+ sessions[sessionId].write(JSON.stringify(log) + "\n");
+ }
+ }
+ );
+
+ server.route(
+ [
+ //
+ // not proxying this request because have to alter the body based on
+ // converting the CTS course id to the stored Player course id
+ //
+ {
+ method: "POST",
+ path: "/sessions",
+ options: {
+ tags: ["api"],
+ validate: {
+ payload: Joi.object({
+ testId: Joi.number().integer().min(0).required(),
+ auIndex: Joi.number().integer().min(0).required(),
+ alternateEntitlementKey: Joi.string().optional(),
+ launchMode: Joi.any().allow("Normal", "Browse", "Review").optional(),
+ launchMethod: Joi.any().allow("iframe", "newWindow").optional(),
+ contextTemplateAdditions: Joi.object().optional(),
+ launchParameters: Joi.string().optional(),
+ masteryScore: Joi.number().positive().min(0).max(1).optional(),
+ moveOn: Joi.any().allow("Passed", "Completed", "CompletedAndPassed", "CompletedOrPassed", "NotApplicable").optional()
+ }).required().label("Request-PostSession")
+ }
+ },
+ handler: async (req, h) => {
+ const db = req.server.app.db,
+ tenantId = req.auth.credentials.tenantId,
+ testId = req.payload.testId,
+ auIndex = req.payload.auIndex,
+ baseUrl = (req.headers["x-forwarded-proto"] ? `${req.headers["x-forwarded-proto"]}:` : req.url.protocol) + `//${req.info.host}`;
+
+ let queryResult;
+
+ try {
+ queryResult = await db
+ .first("*")
+ .queryContext({jsonCols: ["registrations.metadata", "courses.metadata"]})
+ .from("registrations")
+ .leftJoin("courses", "registrations.course_id", "courses.id")
+ .where({"registrations.tenantId": tenantId, "registrations.id": testId})
+ .options({nestTables: true});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed to retrieve registration for id ${testId}: ${ex}`));
+ }
+
+ if (! queryResult) {
+ throw Boom.notFound(`registration: ${testId}`);
+ }
+
+ let createResponse,
+ createResponseBody;
+
+ try {
+ createResponse = await Wreck.request(
+ "POST",
+ `${req.server.app.player.baseUrl}/api/v1/course/${queryResult.courses.player_id}/launch-url/${auIndex}`,
+ {
+ headers: {
+ Authorization: await req.server.methods.playerBearerAuthHeader(req)
+ },
+ payload: {
+ actor: queryResult.registrations.metadata.actor,
+ reg: queryResult.registrations.code,
+ contextTemplateAdditions: req.payload.contextTemplateAdditions,
+ launchMode: req.payload.launchMode,
+ launchParameters: req.payload.launchParameters,
+ masteryScore: req.payload.masteryScore,
+ moveOn: req.payload.moveOn,
+ alternateEntitlementKey: req.payload.alternateEntitlementKey,
+ returnUrl: `${baseUrl}/api/v1/sessions/__sessionId__/return-url`
+ }
+ }
+ );
+ createResponseBody = await Wreck.read(createResponse, {json: true});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed to request AU launch url from player: ${ex}`));
+ }
+
+ if (createResponse.statusCode !== 200) {
+ throw Boom.internal(new Error(`Failed to retrieve AU launch URL from player (${createResponse.statusCode}): ${createResponseBody.message}${createResponseBody.srcError ? " (" + createResponseBody.srcError + ")" : ""}`));
+ }
+
+ const playerAuLaunchUrl = createResponseBody.url,
+ playerAuLaunchUrlParsed = new URL(playerAuLaunchUrl),
+ playerEndpoint = playerAuLaunchUrlParsed.searchParams.get("endpoint"),
+ playerFetch = playerAuLaunchUrlParsed.searchParams.get("fetch");
+ let sessionId;
+
+ console.log("PLAYER FETCH:", playerFetch);
+
+ try {
+ const sessionInsert = await db.insert(
+ {
+ tenant_id: tenantId,
+ player_id: createResponseBody.id,
+ registration_id: testId,
+ au_index: auIndex,
+ player_au_launch_url: playerAuLaunchUrl,
+ player_endpoint: playerEndpoint,
+ player_fetch: playerFetch,
+ metadata: JSON.stringify(
+ {
+ version: 1,
+ launchMethod: createResponseBody.launchMethod,
+ violatedReqIds: []
+ }
+ )
+ }
+ ).into("sessions");
+
+ sessionId = sessionInsert[0];
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed to insert into sessions: ${ex}`));
+ }
+
+ const auTitle = queryResult.courses.metadata.aus[auIndex].title[0].text;
+
+ await h.registrationEvent(testId, tenantId, db, {kind: "api", resource: "sessions:create", sessionId, auIndex, summary: `Launched AU: ${auTitle}`});
+ await h.sessionEvent(sessionId, tenantId, db, {kind: "api", resource: "create", summary: `AU ${auTitle} session initiated`});
+
+ //
+ // swap endpoint, fetch for proxied versions
+ //
+ playerAuLaunchUrlParsed.searchParams.set("endpoint", `${baseUrl}/api/v1/sessions/${sessionId}/lrs`);
+ playerAuLaunchUrlParsed.searchParams.set("fetch", `${baseUrl}/api/v1/sessions/${sessionId}/fetch`);
+
+ const ctsLaunchUrl = playerAuLaunchUrlParsed.href;
+ const result = await db.first("*").from("sessions").queryContext({jsonCols: ["metadata"]}).where({tenantId, id: sessionId});
+
+ result.launchUrl = ctsLaunchUrl;
+ result.launchMethod = createResponseBody.launchMethod === "OwnWindow" ? "newWindow" : "iframe";
+
+ return getClientSafeSession(result);
+ }
+ },
+
+ {
+ method: "GET",
+ path: "/sessions/{id}",
+ options: {
+ tags: ["api"]
+ },
+ handler: async (req, h) => {
+ const result = await req.server.app.db.first("*").from("sessions").queryContext({jsonCols: ["metadata"]}).where({tenantId: req.auth.credentials.tenantId, id: req.params.id});
+
+ if (! result) {
+ return Boom.notFound();
+ }
+
+ return getClientSafeSession(result);
+ }
+ },
+
+ {
+ method: "GET",
+ path: "/sessions/{id}/return-url",
+ options: {
+ tags: ["api"]
+ },
+ handler: async (req, h) => {
+ const tenantId = req.auth.credentials.tenantId,
+ db = req.server.app.db,
+ result = await db.first("*").from("sessions").queryContext({jsonCols: ["metadata"]}).where({tenantId, id: req.params.id});
+
+ if (! result) {
+ return Boom.notFound();
+ }
+
+ await h.sessionEvent(req.params.id, tenantId, db, {kind: "spec", resource: "return-url", summary: "Return URL loaded"});
+
+ return "Session has ended, use "Close" button to return to test details page.";
+ }
+ },
+
+ {
+ method: "GET",
+ path: "/sessions/{id}/logs",
+ options: {
+ tags: ["api"],
+ validate: {
+ query: Joi.object({
+ listen: Joi.any().optional().description("Switches the response to be a stream that will provide additional logs as they are created")
+ }).optional().label("RequestParams-SessionLogs")
+ }
+ },
+ handler: async (req, h) => {
+ const tenantId = req.auth.credentials.tenantId,
+ db = req.server.app.db,
+ result = await db.first("id").from("sessions").where({tenantId, id: req.params.id});
+
+ if (! result) {
+ return Boom.notFound();
+ }
+
+ const logs = await db.select("*").queryContext({jsonCols: ["metadata"]}).from("sessions_logs").where({tenantId, sessionId: result.id}).orderBy("created_at", "desc");
+
+ if (! req.query.listen) {
+ return logs;
+ }
+
+ const channel = new stream.PassThrough,
+ response = h.response(channel);
+
+ sessions[req.params.id] = channel;
+
+ for (const log of logs) {
+ channel.write(JSON.stringify(log) + "\n");
+ }
+
+ req.raw.req.on(
+ "close",
+ () => {
+ delete sessions[req.params.id];
+ }
+ );
+
+ return response;
+ }
+ },
+
+ {
+ method: "POST",
+ path: "/sessions/{id}/abandon",
+ options: {
+ tags: ["api"]
+ },
+ handler: async (req, h) => {
+ const db = req.server.app.db,
+ sessionId = req.params.id,
+ tenantId = req.auth.credentials.tenantId,
+ result = await db.first("*").from("sessions").where({tenantId, id: sessionId });
+
+ if (! result) {
+ return Boom.notFound();
+ }
+
+ let abandonResponse,
+ abandonResponseBody;
+
+ try {
+ abandonResponse = await Wreck.request(
+ "POST",
+ `${req.server.app.player.baseUrl}/api/v1/session/${result.playerId}/abandon`,
+ {
+ headers: {
+ Authorization: await req.server.methods.playerBearerAuthHeader(req)
+ }
+ }
+ );
+ abandonResponseBody = await Wreck.read(abandonResponse, {json: true});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed request to player to abandon session: ${ex}`));
+ }
+
+ if (abandonResponse.statusCode !== 204) {
+ throw Boom.internal(new Error(`Failed to abandon session in player (${abandonResponse.statusCode}): ${abandonResponseBody.message}${abandonResponseBody.srcError ? " (" + abandonResponseBody.srcError + ")" : ""}`));
+ }
+
+ await h.sessionEvent(sessionId, tenantId, db, {kind: "spec", resource: "abandon", summary: "Session abandoned"});
+
+ return null;
+ }
+ },
+
+ {
+ method: "POST",
+ path: "/sessions/{id}/fetch",
+ options: {
+ // turn off auth because this is effectively an auth request
+ auth: false
+ },
+ handler: async (req, h) => {
+ const db = req.server.app.db;
+
+ try {
+ let session;
+
+ try {
+ session = await db.first("*").from("sessions").queryContext({jsonCols: ["metadata"]}).where({id: req.params.id});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed to select session data: ${ex}`));
+ }
+
+ if (! session) {
+ throw Boom.notFound(`session: ${req.params.id}`);
+ }
+
+ let fetchResponse,
+ fetchResponseBody;
+
+ console.log("POSTING FETCH:", session.playerFetch);
+
+ try {
+ fetchResponse = await Wreck.request(
+ "POST",
+ session.playerFetch
+ );
+ fetchResponseBody = await Wreck.read(fetchResponse, {json: true});
+ }
+ catch (ex) {
+ console.error(ex);
+ throw Boom.internal(new Error(`Failed to request fetch url from player: ${ex}`));
+ }
+
+ await h.sessionEvent(req.params.id, session.tenantId, db, {kind: "spec", resource: "fetch", playerResponseStatusCode: fetchResponse.statusCode, summary: "Fetch URL used"});
+
+ return h.response(fetchResponseBody).code(fetchResponse.statusCode);
+ }
+ catch (ex) {
+ return h.response(
+ {
+ "error-code": "3",
+ "error-text": `General Application Error: ${ex}`
+ }
+ ).code(400);
+ }
+ }
+ },
+
+ // OPTIONS requests don't provide an authorization header, so set this up
+ // as a separate route without auth
+ {
+ method: [
+ "OPTIONS"
+ ],
+ path: "/sessions/{id}/lrs/{resource*}",
+ options: {
+ auth: false,
+
+ //
+ // turn off CORS for this handler because the LRS will provide back the right headers
+ // this just needs to pass them through, enabling CORS for this route means they get
+ // overwritten by the Hapi handling
+ //
+ cors: false
+ },
+ handler: {
+ proxy: {
+ passThrough: true,
+ xforward: true,
+
+ //
+ // map the requested resource (i.e. "statements" or "activities/state") from the
+ // provided LRS endpoint to the resource at the underlying LRS endpoint, while
+ // maintaining any query string parameters
+ //
+ mapUri: (req) => ({
+ uri: `${req.server.app.player.baseUrl}/lrs/${req.params.resource}${req.url.search}`
+ })
+ }
+ }
+ },
+
+ //
+ // proxy the LRS based on the session identifier so that the service
+ // knows what session to log information for
+ //
+ {
+ method: [
+ "GET",
+ "POST",
+ "PUT",
+ "DELETE"
+ ],
+ path: "/sessions/{id}/lrs/{resource*}",
+ options: {
+ //
+ // since this is effectively acting as a proxy this route doesn't use
+ // direct authorization, it instead relies on the player's underlying
+ // handling to handle invalid authorization attempts
+ //
+ auth: false,
+ cors: false
+ },
+ //
+ // not using h2o2 to proxy these resources because there needs to be validation
+ // of the incoming payload which means it needs to be loaded into memory and parsed,
+ // etc. which h2o2 won't do with proxied requests because of the performance overhead
+ // so this code is nearly the same as what the handler for h2o2 does, but with fewer
+ // settings that weren't being used anyways
+ //
+ handler: async (req, h) => {
+ const db = req.server.app.db,
+ id = req.params.id;
+ let session;
+
+ try {
+ session = await db.first("*").from("sessions").where({id}).queryContext({jsonCols: ["metadata"]});
+ }
+ catch (ex) {
+ // console.error(0, "CAN'T SELECT SESSION", ex);
+ throw Boom.internal(new Error(`Failed to select session data: ${ex}`));
+ }
+
+ if (! session) {
+ // console.error(1, "NO SESSION", ex);
+ throw Boom.notFound(`session: ${id}`);
+ }
+
+ const tenantId = session.tenantId;
+ let proxyResponse,
+ rawProxyResponsePayload,
+ response;
+
+ try {
+ //
+ // map the requested resource (i.e. "statements" or "activities/state") from the
+ // provided LRS endpoint to the resource at the underlying LRS endpoint, while
+ // maintaining any query string parameters
+ //
+ const uri = `${req.server.app.player.baseUrl}/lrs/${req.params.resource}${req.url.search}`,
+ protocol = uri.split(":", 1)[0],
+ options = {
+ headers: Hoek.clone(req.headers),
+ payload: req.payload
+ };
+
+ delete options.headers.host;
+ delete options.headers["content-length"];
+
+ if (req.info.remotePort) {
+ options.headers["x-forwarded-for"] = (options.headers["x-forwarded-for"] ? options.headers["x-forwarded-for"] + "," : "") + req.info.remoteAddress;
+ options.headers["x-forwarded-port"] = options.headers["x-forwarded-port"] || req.info.remotePort;
+ options.headers["x-forwarded-proto"] = options.headers["x-forwarded-proto"] || req.server.info.protocol;
+ options.headers["x-forwarded-host"] = options.headers["x-forwarded-host"] || req.info.host;
+ }
+
+ // The cmi5 JS stuff won't know what the configured LRS's xAPI version is
+ // between 1.0.3 and 2.0, so replace that here if it was specified.
+ //
+ let configuredXAPIVersion = process.env.LRS_XAPI_VERSION;
+ if (configuredXAPIVersion != undefined)
+ options.headers["x-experience-api-version"] = configuredXAPIVersion;
+
+ // Concurrency check required or xAPI 2.0
+ //
+ if (req.method == "post" || req.method == "put") {
+
+ let lrsResourcePath = req.params.resource;
+
+ let requiresConcurrency = helpers.doesLRSResourceEnforceConcurrency(lrsResourcePath);
+ let isMissingEtagHeader = options.headers["if-match"] == undefined;
+
+ if (requiresConcurrency && isMissingEtagHeader) {
+ let etagResponse = await Wreck.request("get", uri, { headers: options.headers });
+ if (etagResponse.statusCode < 400) {
+ let etag = etagResponse.headers["etag"];
+ options.headers["if-match"] = etag;
+ }
+
+ etagResponse.destroy();
+ }
+ }
+
+ proxyResponse = await Wreck.request(req.method, uri, options);
+ rawProxyResponsePayload = await Wreck.read(proxyResponse, {gunzip: true});
+
+ let responsePayload = rawProxyResponsePayload;
+
+
+
+ if (req.method === "get" && req.params.resource === "activities/state") {
+ if (req.query.stateId === "LMS.LaunchData") {
+ const parsedPayload = JSON.parse(rawProxyResponsePayload.toString());
+
+ if (typeof parsedPayload.returnURL !== "undefined") {
+ parsedPayload.returnURL = parsedPayload.returnURL.replace("__sessionId__", id);
+ }
+
+ responsePayload = parsedPayload;
+ }
+ }
+
+ response = h.response(responsePayload).passThrough(true);
+
+ response.code(proxyResponse.statusCode);
+ response.message(proxyResponse.statusMessage);
+
+
+ const skipHeaders = {
+ "content-encoding": true,
+ "content-length": true,
+ "transfer-encoding": true
+ };
+ for (const [k, v] of Object.entries(proxyResponse.headers)) {
+ if (! skipHeaders[k.toLowerCase()]) {
+ response.header(k, v);
+ }
+ }
+
+ // clean up the original response
+ proxyResponse.destroy();
+ }
+ catch (ex) {
+ // console.error(2, ex);
+ throw ex;
+ }
+
+ if (proxyResponse.statusCode !== 200 && proxyResponse.statusCode !== 204 && proxyResponse.headers["content-type"].startsWith("application/json")) {
+ const proxyResponsePayloadAsString = rawProxyResponsePayload.toString();
+ let proxyResponsePayload;
+
+ try {
+ proxyResponsePayload = JSON.parse(proxyResponsePayloadAsString);
+ }
+ catch (ex) {
+ // console.error("BAD RESPONSE DATA", ex);
+ console.log(`Failed JSON parse of LRS response error: ${ex} (${proxyResponsePayloadAsString})`);
+ }
+
+ if (proxyResponsePayload && typeof proxyResponsePayload.violatedReqId !== "undefined") {
+ session.metadata.violatedReqIds.push(proxyResponsePayload.violatedReqId);
+
+ try {
+ await db("sessions").update({metadata: JSON.stringify(session.metadata)}).where({id, tenantId});
+ }
+ catch (ex) {
+ // console.error("CAN'T UPDATE SESSION", ex);
+ console.log(`Failed to update session for violated spec requirement (${proxyResponsePayload.violatedReqId}): ${ex}`);
+ }
+
+ await h.sessionEvent(id, tenantId, db, {kind: "lrs", violatedReqId: proxyResponsePayload.violatedReqId, summary: `Spec requirement violated`});
+ }
+ }
+
+ if (req.method === "get") {
+ if (req.params.resource === "activities/state") {
+ if (req.query.stateId === "LMS.LaunchData") {
+ await h.sessionEvent(id, tenantId, db, {kind: "lrs", method: req.method, resource: req.params.resource, summary: "LMS Launch Data retrieved"});
+ }
+ }
+ else if (req.params.resource === "activities") {
+ if (req.query.activityId !== null) {
+ await h.sessionEvent(id, tenantId, db, {kind: "lrs", method: req.method, resource: req.params.resource, summary: "Activity " + req.query.activityId + " retrieved"});
+ }
+ }
+ else if (req.params.resource === "agents/profile") {
+ if (req.query.profileId === "cmi5LearnerPreferences") {
+ await h.sessionEvent(id, tenantId, db, {kind: "lrs", method: req.method, resource: req.params.resource, summary: "Learner Preferences Agent Profile Retrieved"});
+ }
+ }
+ else {
+ await h.sessionEvent(id, tenantId, db, {kind: "lrs", method: req.method, resource: req.params.resource, summary: "Unknown"});
+ }
+ }
+ else if (req.method === "put" || req.method === "post") {
+ if (req.params.resource === "statements") {
+ if (proxyResponse.statusCode === 200 || proxyResponse.statusCode === 204) {
+ let statements = req.payload;
+
+ if (! Array.isArray(statements)) {
+ statements = [statements];
+ }
+
+ for (const st of statements) {
+ await h.sessionEvent(id, tenantId, db, {kind: "lrs", method: req.method, resource: req.params.resource, summary: `Statement recorded: ${st.verb.id}`});
+ }
+ }
+ else {
+ await h.sessionEvent(id, tenantId, db, {kind: "lrs", method: req.method, resource: req.params.resource, summary: `Statement(s) rejected: ${proxyResponse.statusCode}`});
+ }
+ }
+ }
+
+ return response;
+ }
+ }
+ ]
+ );
+ }
+};
diff --git a/KST-ASD-BI-101/cts/service/plugins/routes/v1/tests.js b/KST-ASD-BI-101/cts/service/plugins/routes/v1/tests.js
new file mode 100644
index 0000000..7f08120
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/plugins/routes/v1/tests.js
@@ -0,0 +1,566 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+"use strict";
+
+const Boom = require("@hapi/boom"),
+ Wreck = require("@hapi/wreck"),
+ Hoek = require("@hapi/hoek"),
+ Joi = require("joi"),
+ { v4: uuidv4 } = require("uuid"),
+ iri = require("iri"),
+
+ // sessions is optional because we know calling this from registration creation
+ // will never have any sessions
+ getClientSafeReg = (registration, playerResponseBody, sessionsByAu = []) => {
+ const result = Hoek.clone(registration);
+
+ delete result.playerId;
+
+ result.metadata.actor = playerResponseBody.actor;
+ result.metadata.isSatisfied = playerResponseBody.isSatisfied;
+ result.metadata.moveOn = playerResponseBody.metadata.moveOn;
+ result.metadata.aus = playerResponseBody.aus;
+
+ let pending = 0,
+ notStarted = 0,
+ conformant = 0,
+ nonConformant = 0;
+
+ result.metadata.aus.forEach(
+ (au, index) => {
+ if (sessionsByAu[index] && sessionsByAu[index].some((session) => session.metadata.violatedReqIds.length > 0)) {
+ au.result = "non-conformant";
+ nonConformant += 1;
+ }
+ else if (au.hasBeenAttempted && au.isSatisfied && ! au.isWaived) {
+ au.result = "conformant";
+ conformant += 1;
+ }
+ else if (au.hasBeenAttempted) {
+ au.result = "pending";
+ pending += 1;
+ }
+ else {
+ au.result = "not-started";
+ notStarted += 1;
+ }
+ }
+ );
+
+ if (! pending && ! notStarted && ! nonConformant) {
+ result.metadata.result = "conformant";
+ }
+ else if (nonConformant) {
+ result.metadata.result = "non-conformant";
+ }
+ else if (pending || conformant) {
+ result.metadata.result = "pending";
+ }
+ else {
+ result.metadata.result = "not-started";
+ }
+
+ return result;
+ };
+
+module.exports = {
+ name: "catapult-cts-api-routes-v1-tests",
+ register: (server, options) => {
+ server.decorate(
+ "toolkit",
+ "registrationEvent",
+ async (registrationId, tenantId, db, rawData) => {
+ try {
+ await db.insert(
+ {
+ tenantId,
+ registrationId,
+ metadata: JSON.stringify({
+ version: 1,
+ ...rawData
+ })
+ }
+ ).into("registrations_logs");
+ }
+ catch (ex) {
+ console.log(`Failed to write to registrations_logs (${registrationId}): ${ex}`);
+ }
+ }
+ );
+
+ server.route(
+ [
+ //
+ // not proxying this request because have to alter the body based on
+ // converting the CTS course id to the stored Player course id
+ //
+ {
+ method: "POST",
+ path: "/tests",
+ options: {
+ tags: ["api"],
+ validate: {
+ payload: Joi.object({
+ courseId: Joi.number().min(0).required(),
+ actor: Joi.object({
+ account: Joi.object({
+ name: Joi.string().required(),
+ homePage: Joi.string().required().custom((value, helpers) => {
+ try {
+ new iri.IRI(value).toAbsolute();
+ }
+ catch (ex) {
+ throw new Error("account homepage must be a valid IRI");
+ }
+
+ return value;
+ })
+ }).required(),
+ objectType: Joi.any().allow("Agent").optional(),
+ name: Joi.string().optional()
+ }).required()
+ }).required().label("Request-PostTest")
+ }
+ },
+ handler: async (req, h) => {
+ const db = req.server.app.db,
+ tenantId = req.auth.credentials.tenantId;
+
+ let course;
+
+ try {
+ course = await db.first("*").from("courses").queryContext({jsonCols: ["metadata"]}).where({tenantId, id: req.payload.courseId});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed to retrieve course for id ${req.payload.courseId}: ${ex}`));
+ }
+
+ if (! course) {
+ throw Boom.notFound(`course: ${req.payload.courseId}`);
+ }
+
+ let createResponse,
+ createResponseBody;
+
+ try {
+ createResponse = await Wreck.request(
+ "POST",
+ `${req.server.app.player.baseUrl}/api/v1/registration`,
+ {
+ headers: {
+ Authorization: await req.server.methods.playerBearerAuthHeader(req)
+ },
+ payload: {
+ courseId: course.playerId,
+ actor: req.payload.actor
+ }
+ }
+ );
+ createResponseBody = await Wreck.read(createResponse, {json: true});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed request to create player registration: ${ex}`));
+ }
+
+ if (createResponse.statusCode !== 200) {
+ throw Boom.internal(new Error(`Failed to create player registration (${createResponse.statusCode}): ${createResponseBody.message}${createResponseBody.srcError ? " (" + createResponseBody.srcError + ")" : ""}`));
+ }
+
+ // Create an Agent Profile for this new user.
+ //
+ let lrsWreck = Wreck.defaults(await req.server.methods.lrsWreckDefaults(req));
+ let profileCreationRes = await lrsWreck.request(
+ "POST",
+ "agents/profile?" + new URLSearchParams(
+ {
+ profileId: "cmi5LearnerPreferences",
+ agent: JSON.stringify(req.payload.actor)
+ }
+ ).toString(),
+ {
+ payload: {
+ languagePreference: "en-US,fr-FR,fr-BE",
+ audiePreference: "on"
+ }
+ }
+ )
+ .catch(err => console.error("Failed to Create Agent Profile", profileCreationRes.statusCode, err));
+
+ const txn = await db.transaction();
+ let registrationId;
+
+ try {
+ const insertResult = await txn.insert(
+ {
+ tenantId,
+ playerId: createResponseBody.id,
+ code: createResponseBody.code,
+ courseId: req.payload.courseId,
+ metadata: JSON.stringify({
+ version: 1,
+
+ // storing these locally because they are used to build
+ // the list of tests
+ actor: createResponseBody.actor,
+ result: "not-started"
+ })
+ }
+ ).into("registrations");
+
+ registrationId = insertResult[0];
+ }
+ catch (ex) {
+ await txn.rollback();
+ throw Boom.internal(new Error(`Failed to insert into registrations: ${ex}`));
+ }
+
+ await h.registrationEvent(
+ registrationId,
+ tenantId,
+ txn,
+ {
+ kind: "api",
+ resource: "registration:create",
+ registrationId,
+ registrationCode: createResponseBody.code,
+ summary: `Registration Created`
+ }
+ );
+
+ const result = await txn.first("*").from("registrations").where({id: registrationId, tenantId}).queryContext({jsonCols: ["metadata"]});
+
+ await txn.commit();
+
+ return getClientSafeReg(result, createResponseBody);
+ }
+ },
+
+ {
+ method: "GET",
+ path: "/tests/{id}",
+ options: {
+ tags: ["api"]
+ },
+ handler: async (req, h) => {
+ const db = req.server.app.db,
+ id = req.params.id,
+ tenantId = req.auth.credentials.tenantId,
+ registrationFromSelect = await db.first("*").from("registrations").where({id, tenantId}).queryContext({jsonCols: ["metadata"]});
+
+ if (! registrationFromSelect) {
+ return Boom.notFound();
+ }
+
+ let response,
+ responseBody;
+
+ try {
+ response = await Wreck.request(
+ "GET",
+ `${req.server.app.player.baseUrl}/api/v1/registration/${registrationFromSelect.playerId}`,
+ {
+ headers: {
+ Authorization: await req.server.methods.playerBearerAuthHeader(req)
+ }
+ }
+ );
+ responseBody = await Wreck.read(response, {json: true});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed request to player to get registration details: ${ex}`));
+ }
+
+ if (response.statusCode !== 200) {
+ throw Boom.internal(new Error(`Failed to get player registration details (${response.statusCode}): ${responseBody.message}${responseBody.srcError ? " (" + responseBody.srcError + ")" : ""}`));
+ }
+
+ const sessions = await db.select("id", "au_index", "metadata").from("sessions").where({registrationId: id, tenantId}).queryContext({jsonCols: ["metadata"]}).orderBy("au_index"),
+ sessionsByAu = [];
+
+ for (const session of sessions) {
+ sessionsByAu[session.auIndex] = sessionsByAu[session.auIndex] || [];
+ sessionsByAu[session.auIndex].push(session);
+ }
+
+ const currentCachedResult = registrationFromSelect.metadata.result,
+ registration = getClientSafeReg(registrationFromSelect, responseBody, sessionsByAu);
+
+ //
+ // check to see if the test result determined from the newly updated player
+ // response is different than the one currently cached in the metadata, if it
+ // it then update it
+ //
+ if (registration.metadata.result !== registrationFromSelect.metadata.result) {
+ try {
+ await db("registrations").update({metadata: JSON.stringify(registration.metadata)}).where({id, tenantId});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed to update registration metadata: ${ex}`));
+ }
+ }
+
+ return registration;
+ }
+ },
+
+ {
+ method: "GET",
+ path: "/tests/{id}/logs",
+ options: {
+ tags: ["api"]
+ },
+ handler: async (req, h) => {
+ const tenantId = req.auth.credentials.tenantId,
+ db = req.server.app.db;
+
+ let registrationLogs;
+
+ try {
+ registrationLogs = await db
+ .select("*")
+ .from("registrations_logs")
+ .queryContext({jsonCols: ["metadata"]})
+ .where({tenantId, registrationId: req.params.id})
+ .orderBy("created_at");
+ }
+ catch (ex) {
+ throw Boom.internal(`Failed to select registration logs: ${ex}`);
+ }
+
+ return registrationLogs;
+ }
+ },
+
+ {
+ method: "POST",
+ path: "/tests/{id}/waive-au/{auIndex}",
+ options: {
+ tags: ["api"],
+ payload: {
+ parse: true
+ },
+ validate: {
+ payload: Joi.object({
+ reason: Joi.string().required()
+ }).label("Request-WaiveAU")
+ }
+ },
+ handler: async (req, h) => {
+ const db = req.server.app.db,
+ registrationId = req.params.id,
+ auIndex = req.params.auIndex,
+ tenantId = req.auth.credentials.tenantId,
+ reason = req.payload.reason,
+ result = await db.first("*").from("registrations").where({tenantId, id: registrationId});
+
+ if (! result) {
+ return Boom.notFound();
+ }
+
+ let waiveResponse,
+ waiveResponseBody;
+
+ try {
+ waiveResponse = await Wreck.request(
+ "POST",
+ `${req.server.app.player.baseUrl}/api/v1/registration/${result.playerId}/waive-au/${auIndex}`,
+ {
+ headers: {
+ Authorization: await req.server.methods.playerBearerAuthHeader(req)
+ },
+ payload: {
+ reason
+ }
+ }
+ );
+ waiveResponseBody = await Wreck.read(waiveResponse, {json: true});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed request to player to waive AU: ${ex}`));
+ }
+
+ if (waiveResponse.statusCode !== 204) {
+ throw Boom.internal(new Error(`Failed to waive AU in player registration (${waiveResponse.statusCode}): ${waiveResponseBody.message}${waiveResponseBody.srcError ? " (" + waiveResponseBody.srcError + ")" : ""}`));
+ }
+
+ await h.registrationEvent(registrationId, tenantId, db, {kind: "spec", auIndex, reason, resource: "registration:waive-au", summary: `Waived (${auIndex})`});
+
+ return null;
+ }
+ },
+
+ {
+ method: "GET",
+ path: "/tests/{id}/learner-prefs",
+ options: {
+ tags: ["api"],
+ cors: {
+ additionalExposedHeaders: ["Etag"]
+ }
+ },
+ handler: async (req, h) => {
+ const db = req.server.app.db,
+ registrationId = req.params.id,
+ tenantId = req.auth.credentials.tenantId,
+ registration = await db.first("*").from("registrations").where({tenantId, id: registrationId});
+
+ if (! registration) {
+ return Boom.notFound();
+ }
+
+ let response,
+ responseBody;
+
+ try {
+ response = await Wreck.request(
+ "GET",
+ `${req.server.app.player.baseUrl}/api/v1/registration/${registration.playerId}/learner-prefs`,
+ {
+ headers: {
+ Authorization: await req.server.methods.playerBearerAuthHeader(req)
+ }
+ }
+ );
+ responseBody = await Wreck.read(response, {json: true});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed request to player to get learner preferences: ${ex}`));
+ }
+
+ if (response.statusCode !== 200 && response.statusCode !== 404) {
+ throw Boom.internal(new Error(`Failed to request learner preferences from player (${response.statusCode}): ${responseBody.message}${responseBody.srcError ? " (" + responseBody.srcError + ")" : ""}`));
+ }
+
+ const result = h.response(responseBody);
+
+ result.code(response.statusCode);
+ result.message(response.statusMessage);
+ result.header("Etag", response.headers.etag);
+
+ return result;
+ }
+ },
+
+ {
+ method: "POST",
+ path: "/tests/{id}/learner-prefs",
+ options: {
+ tags: ["api"],
+ cors: {
+ additionalHeaders: ["If-Match"]
+ },
+ payload: {
+ parse: true
+ },
+ validate: {
+ payload: Joi.object({
+ languagePreference: Joi.string().pattern(/^[-A-Za-z0-9]+(?:,[-A-Za-z0-9]+)*$/).required(),
+ audioPreference: Joi.string().allow("on", "off").required()
+ }).label("Request-PostLearnerPrefs")
+ }
+ },
+ handler: async (req, h) => {
+ const db = req.server.app.db,
+ registrationId = req.params.id,
+ tenantId = req.auth.credentials.tenantId,
+ registration = await db.first("*").from("registrations").where({tenantId, id: registrationId});
+
+ if (! registration) {
+ return Boom.notFound();
+ }
+
+ let response,
+ responseBody;
+
+ try {
+ response = await Wreck.request(
+ "POST",
+ `${req.server.app.player.baseUrl}/api/v1/registration/${registration.playerId}/learner-prefs`,
+ {
+ headers: {
+ Authorization: await req.server.methods.playerBearerAuthHeader(req),
+ "Content-Type": "application/json",
+ ...req.headers["if-match"] ? {"If-Match": req.headers["if-match"]} : {},
+ ...req.headers["if-none-match"] ? {"If-None-Match": req.headers["if-none-match"]} : {}
+ },
+ payload: req.payload
+ }
+ );
+ responseBody = await Wreck.read(response, {json: true});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed request to player to save learner preferences: ${ex}`));
+ }
+
+ if (response.statusCode !== 204) {
+ throw Boom.internal(new Error(`Failed to save learner preferences in player (${response.statusCode}): ${responseBody.message}${responseBody.srcError ? " (" + responseBody.srcError + ")" : ""}`));
+ }
+
+ return null;
+ }
+ },
+
+ {
+ method: "DELETE",
+ path: "/tests/{id}/learner-prefs",
+ options: {
+ tags: ["api"],
+ cors: {
+ additionalHeaders: ["If-Match"]
+ }
+ },
+ handler: async (req, h) => {
+ const db = req.server.app.db,
+ registrationId = req.params.id,
+ tenantId = req.auth.credentials.tenantId,
+ registration = await db.first("*").from("registrations").where({tenantId, id: registrationId});
+
+ if (! registration) {
+ return Boom.notFound();
+ }
+
+ let response,
+ responseBody;
+
+ try {
+ response = await Wreck.request(
+ "DELETE",
+ `${req.server.app.player.baseUrl}/api/v1/registration/${registration.playerId}/learner-prefs`,
+ {
+ headers: {
+ Authorization: await req.server.methods.playerBearerAuthHeader(req),
+ ...req.headers["if-match"] ? {"If-Match": req.headers["if-match"]} : {}
+ // ...req.headers["if-none-match"] ? {"If-None-Match": req.headers["if-none-match"]} : {}
+ }
+ }
+ );
+ responseBody = await Wreck.read(response, {json: true});
+ }
+ catch (ex) {
+ throw Boom.internal(new Error(`Failed request to player to delete learner preferences: ${ex}`));
+ }
+
+ if (response.statusCode !== 204) {
+ throw Boom.internal(new Error(`Failed to delete learner preferences in player (${response.statusCode}): ${responseBody.message}${responseBody.srcError ? " (" + responseBody.srcError + ")" : ""}`));
+ }
+
+ return null;
+ }
+ }
+ ]
+ );
+ }
+};
diff --git a/KST-ASD-BI-101/cts/service/plugins/routes/v1/users.js b/KST-ASD-BI-101/cts/service/plugins/routes/v1/users.js
new file mode 100644
index 0000000..0d41f51
--- /dev/null
+++ b/KST-ASD-BI-101/cts/service/plugins/routes/v1/users.js
@@ -0,0 +1,125 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+"use strict";
+
+const Boom = require("@hapi/boom"),
+ Hoek = require("@hapi/hoek"),
+ Joi = require("joi"),
+ User = require("./lib/user"),
+ isAdmin = (req) => {
+ if (req.auth.credentials.roles.includes("admin")) {
+ return;
+ }
+
+ throw Boom.forbidden("Not an admin");
+ },
+ getClientSafeUser = (user) => {
+ const safe = Hoek.clone(user);
+
+ delete safe.tenantId;
+ delete safe.password;
+ delete safe.playerApiToken;
+
+ return safe;
+ };
+
+module.exports = {
+ name: "catapult-cts-api-routes-v1-users",
+ register: (server, options) => {
+ server.route(
+ [
+ {
+ method: "POST",
+ path: "/users",
+ options: {
+ tags: ["api"],
+ validate: {
+ payload: Joi.object({
+ username: Joi.string().required(),
+ password: Joi.string().required(),
+ roles: Joi.array().optional()
+ }).required().label("Request-PostUser")
+ }
+ },
+ handler: async (req, h) => {
+ isAdmin(req);
+
+ const {userId, tenantId} = await User.create(req.payload.username, req.payload.password, req.payload.roles, {req}),
+ result = await req.server.app.db.first("*").from("users").queryContext({jsonCols: ["roles"]}).where({id: userId});
+
+ return getClientSafeUser(result);
+ }
+ },
+
+ {
+ method: "GET",
+ path: "/users",
+ options: {
+ tags: ["api"]
+ },
+ handler: async (req, h) => {
+ isAdmin(req);
+
+ const db = req.server.app.db,
+ users = await db.select( "*").from("users").queryContext({jsonCols: ["roles"]}).orderBy("created_at", "desc");
+
+ return {
+ items: users.map(getClientSafeUser)
+ };
+ }
+ },
+
+ {
+ method: "GET",
+ path: "/users/{id}",
+ options: {
+ tags: ["api"]
+ },
+ handler: async (req, h) => {
+ isAdmin(req);
+
+ const result = await req.server.app.db.first("*").from("users").queryContext({jsonCols: ["roles"]}).where({id: req.params.id});
+
+ if (! result) {
+ return Boom.notFound();
+ }
+
+ return getClientSafeUser(result);
+ }
+ },
+
+ {
+ method: "DELETE",
+ path: "/users/{id}",
+ options: {
+ tags: ["api"]
+ },
+ handler: async (req, h) => {
+ isAdmin(req);
+
+ if (Number.parseInt(req.params.id) === req.auth.credentials.id) {
+ throw Boom.forbidden("Can't delete self");
+ }
+
+ await User.delete(req.params.id, {req});
+
+ return null;
+ }
+ }
+ ]
+ );
+ }
+};
diff --git a/KST-ASD-BI-101/docker-compose.yaml b/KST-ASD-BI-101/docker-compose.yaml
new file mode 100644
index 0000000..2bbc954
--- /dev/null
+++ b/KST-ASD-BI-101/docker-compose.yaml
@@ -0,0 +1,82 @@
+# Copyleft 2024 Kusala Tech
+#
+# Licensed under the GNU General Public License v3.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#
+# https://www.gnu.org/licenses/gpl-3.0.en.html
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on
+# an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# Copyright 2021 Rustici Software
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+services:
+ webservice:
+ image: adlhub/player
+ ports:
+ - ${HOST_PORT}:3398
+ depends_on:
+ - rdbms
+ volumes:
+ - ./migrations:/usr/src/app/migrations:ro
+ - ./seeds:/usr/src/app/seeds:ro
+ - ./:/usr/src/app/var/content
+ environment:
+ - CONTENT_URL
+ - API_KEY
+ - API_SECRET
+ - TOKEN_SECRET
+ - LRS_ENDPOINT
+ - LRS_USERNAME
+ - LRS_PASSWORD
+ - LRS_XAPI_VERSIONs
+ - DB_HOST
+ - DB_NAME
+ - DB_USERNAME
+ - DB_PASSWOD
+ # - DATABASE_USER=catapult
+ # - DATABASE_USER_PASSWORD=quartz
+ # - DATABASE_NAME=catapult_player
+ - FIRST_TENANT_NAME
+ - PLAYER_API_ROOT=${PLAYER_ROOT_PATH}
+ - PLAYER_STANDALONE_LAUNCH_URL_BASE
+ - HOST_PORT
+ rdbms:
+ image: mysql:8.0.27 # Deprecated but still available old auth method.
+ volumes:
+ - catapult-player-data:/var/lib/mysql
+ environment:
+ - MYSQL_RANDOM_ROOT_PASSWORD=yes
+ - MYSQL_USER=catapult
+ - MYSQL_PASSWORD=quartz
+ - MYSQL_DATABASE=catapult_player
+ command: [
+ "mysqld",
+ # provide for full UTF-8 support
+ "--character-set-server=utf8mb4",
+ "--collation-server=utf8mb4_unicode_ci",
+
+ # need the following because the mysql.js client lib doesn't yet support
+ # the newer default scheme used in MySQL 8.x
+ "--default-authentication-plugin=mysql_native_password"
+ ]
+
+volumes:
+ catapult-player-data:
\ No newline at end of file
diff --git a/KST-ASD-BI-101/img/cycle1.jpg b/KST-ASD-BI-101/img/cycle1.jpg
new file mode 100644
index 0000000..409e1fc
Binary files /dev/null and b/KST-ASD-BI-101/img/cycle1.jpg differ
diff --git a/KST-ASD-BI-101/img/earth_timescale.png b/KST-ASD-BI-101/img/earth_timescale.png
new file mode 100644
index 0000000..d607583
Binary files /dev/null and b/KST-ASD-BI-101/img/earth_timescale.png differ
diff --git a/KST-ASD-BI-101/img/fault_types.svg b/KST-ASD-BI-101/img/fault_types.svg
new file mode 100644
index 0000000..7041365
--- /dev/null
+++ b/KST-ASD-BI-101/img/fault_types.svg
@@ -0,0 +1,1590 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+]>
+
diff --git a/KST-ASD-BI-101/img/jordens_inre-numbers.svg b/KST-ASD-BI-101/img/jordens_inre-numbers.svg
new file mode 100644
index 0000000..ff8a24a
--- /dev/null
+++ b/KST-ASD-BI-101/img/jordens_inre-numbers.svg
@@ -0,0 +1,551 @@
+
+
+
diff --git a/KST-ASD-BI-101/img/quartz1.jpg b/KST-ASD-BI-101/img/quartz1.jpg
new file mode 100644
index 0000000..ee618c3
Binary files /dev/null and b/KST-ASD-BI-101/img/quartz1.jpg differ
diff --git a/KST-ASD-BI-101/img/strata1.jpg b/KST-ASD-BI-101/img/strata1.jpg
new file mode 100644
index 0000000..eea96a0
Binary files /dev/null and b/KST-ASD-BI-101/img/strata1.jpg differ
diff --git a/KST-ASD-BI-101/img/utahstrat.jpg b/KST-ASD-BI-101/img/utahstrat.jpg
new file mode 100644
index 0000000..0fbe4fd
Binary files /dev/null and b/KST-ASD-BI-101/img/utahstrat.jpg differ
diff --git a/KST-ASD-BI-101/index.html b/KST-ASD-BI-101/index.html
new file mode 100644
index 0000000..329aec1
--- /dev/null
+++ b/KST-ASD-BI-101/index.html
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
+
+ Applied Software Development for Buddhist Innovation
+
+
+
+
+
+ Read-only mode, no data will be saved.
+
+
+
+
Applied Software Development for Buddhist Innovation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ☰
+
+
+
+
⚠
+
+
+
+
+
+
Your final score is:
+
You achieved a passing score.
+
You did not achieve a passing score.
+
+
+ Licensed under the GNU General Public License v3.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+
+ https://www.gnu.org/licenses/gpl-3.0.en.html
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on
+ an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+
+
+
Would you like to resume from your bookmark?
+
+
+
+
+
+
+
+
+
One or more errors occurred. Most recent error:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/KST-ASD-BI-101/js/cmi5.min.js b/KST-ASD-BI-101/js/cmi5.min.js
new file mode 100644
index 0000000..1720475
--- /dev/null
+++ b/KST-ASD-BI-101/js/cmi5.min.js
@@ -0,0 +1,21 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+/*
+ https://www.npmjs.com/package/@rusticisoftware/cmi5
+ 3.1.0
+ */
+!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Cmi5=e():t.Cmi5=e()}(self,(function(){return(()=>{"use strict";var t={d:(e,n)=>{for(var r in n)t.o(n,r)&&!t.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:n[r]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)},e={};t.d(e,{default:()=>v});const n="3.1.0",r="@rusticisoftware/cmi5",i="1.0.3",o="cmi5LearnerPreferences",a="Normal",s={id:"https://w3id.org/xapi/cmi5/context/categories/cmi5"},c={id:"https://w3id.org/xapi/cmi5/context/categories/moveon"},l={id:`http://id.tincanapi.com/activity/software/${r}/3.1.0`,definition:{name:{und:`${r} (3.1.0)`},description:{en:"A JavaScript library implementing the cmi5 specification for AUs during runtime."},type:"http://id.tincanapi.com/activitytype/source"}},h="https://w3id.org/xapi/cmi5/context/extensions/masteryscore",u="http://adlnet.gov/expapi/verbs/initialized",d="http://adlnet.gov/expapi/verbs/terminated",f="http://adlnet.gov/expapi/verbs/completed",m="http://adlnet.gov/expapi/verbs/passed",p="http://adlnet.gov/expapi/verbs/failed",w={[u]:{en:"initialized"},[d]:{en:"terminated"},[f]:{en:"completed"},[m]:{en:"passed"},[p]:{en:"failed"}},g=["endpoint","fetch","actor","activityId","registration"];function v(t){if(this.log("constructor",t),void 0!==t){const e=new URL(t).searchParams;this.log("params");for(let t=0;t{v.DEBUG=!0},v.disableDebug=()=>{v.DEBUG=!1},v.uuidv4=()=>([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,(t=>(t^crypto.getRandomValues(new Uint8Array(1))[0]&15>>t/4).toString(16))),v.convertISO8601DurationToMilliseconds=t=>{const e=t.indexOf("-")>=0,n=t.indexOf("T"),r=t.indexOf("S");let i,o,a=t.indexOf("H"),s=t.indexOf("M");if(-1===n||-1!==s&&s{const e=parseInt(t,10);let n="PT",r=Math.round(e/10);r<0&&(n="-"+n,r*=-1);const i=parseInt(r/36e4,10),o=parseInt(r%36e4/6e3,10);return i>0&&(n+=i+"H"),o>0&&(n+=o+"M"),n+=r%36e4%6e3/100+"S",n},v.prototype={_fetch:null,_endpoint:null,_actor:null,_registration:null,_activityId:null,_auth:null,_fetchContent:null,_lmsLaunchData:null,_contextTemplate:null,_learnerPrefs:null,_isActive:!1,_initialized:null,_passed:null,_failed:null,_completed:null,_terminated:null,_durationStart:null,_progress:null,_includeSourceActivity:!0,start:async function(t={},e){this.log("start");try{await this.postFetch(),void 0!==t.postFetch&&await t.postFetch.apply(this),await this.loadLMSLaunchData(),void 0!==t.launchData&&await t.launchData.apply(this),await this.loadLearnerPrefs(),void 0!==t.learnerPrefs&&await t.learnerPrefs.apply(this),await this.initialize(e),void 0!==t.initializeStatement&&await t.initializeStatement.apply(this)}catch(t){throw new Error(`Failed to start AU: ${t}`)}},postFetch:async function(){if(this.log("postFetch"),null===this._fetch)throw new Error("Can't POST to fetch URL without setFetch");let t;try{t=await fetch(this._fetch,{mode:"cors",method:"POST"})}catch(t){throw new Error(`Failed to make fetch request: ${t}`)}if(!t.ok)throw new Error(`Post fetch response returned error: ${t.status}`);if(200===t.status){const e=await t.json();if(void 0!==e["auth-token"])return this._fetchContent=e,void this.setAuth(`Basic ${e["auth-token"]}`);throw new Error(`Post fetch response indicated LMS error: ${e["error-code"]}`)}throw new Error(`Post fetch response status code unexpected: ${t.status}`)},loadLMSLaunchData:async function(){if(this.log("loadLMSLaunchData"),null===this._fetchContent)throw new Error("Can't retrieve LMS Launch Data without successful postFetch");let t;try{t=await fetch(`${this._endpoint}/activities/state?`+new URLSearchParams({stateId:"LMS.LaunchData",activityId:this._activityId,agent:JSON.stringify(this._actor),registration:this._registration}),{mode:"cors",method:"get",headers:{"X-Experience-API-Version":i,Authorization:this._auth}})}catch(t){throw new Error(`Failed to GET LMS launch data: ${t}`)}this._lmsLaunchData=await t.json(),this._contextTemplate=JSON.stringify(this._lmsLaunchData.contextTemplate)},loadLearnerPrefs:async function(){if(this.log("loadLearnerPrefs"),null===this._lmsLaunchData)throw new Error("Can't retrieve Learner Preferences without successful loadLMSLaunchData");let t;try{t=await fetch(`${this._endpoint}/agents/profile?`+new URLSearchParams({profileId:o,agent:JSON.stringify(this._actor)}),{mode:"cors",method:"get",headers:{"X-Experience-API-Version":i,Authorization:this._auth}})}catch(t){throw new Error(`Failed request to retrieve learner preferences: ${t}`)}if(200!==t.status){if(404===t.status)return this.log("Learner Preferences request returned not found (expected)"),void(this._learnerPrefs={contents:{}});throw new Error("Failed to get learner preferences: unrecognized response status")}this._learnerPrefs={contents:await t.json(),etag:t.headers.ETag}},saveLearnerPrefs:async function(){if(this.log("saveLearnerPrefs"),null===this._learnerPrefs)throw new Error("Can't save Learner Preferences without first loading them");let t;try{t=await fetch(`${this._endpoint}/agents/profile?`+new URLSearchParams({profileId:o,agent:JSON.stringify(this._actor)}),{mode:"cors",method:"put",headers:{"X-Experience-API-Version":i,Authorization:this._auth,"Content-Type":"application/json",...this._learnerPrefs.etag?{"If-Match":this._learnerPrefs.etag}:{"If-None-Match":"*"}},body:JSON.stringify(this._learnerPrefs.contents)})}catch(t){throw new Error(`Failed request to save learner preferences: ${t}`)}if(403===t.status)return this.log("Save of learner preferences denied by LMS"),void(this._learnerPrefs.saveDisallowed=!0);if(204!==t.status)throw new Error(`Failed to save learner preferences: ${t.status}`);this._learnerPrefs.etag=t.headers.ETag},initialize:async function(t={}){if(this.log("initialize"),null===this._lmsLaunchData)throw new Error("Failed to initialize: can't send initialized statement without successful loadLMSLaunchData");if(null===this._learnerPrefs)throw new Error("Failed to initialize: can't send initialized statement without successful loadLearnerPrefs");if(this._initialized)throw new Error("Failed to initialize: AU already initialized");const e=this.initializedStatement();this._appendProvidedProperties(e,t);try{await this.sendStatement(e)}catch(t){throw new Error(`Failed to initialize: exception sending initialized statement (${t})`)}this._initialized=!0,this._isActive=!0,this._durationStart=(new Date).getTime()},terminate:async function(t={}){if(this.log("terminate"),!this._initialized)throw new Error("AU not initialized");if(this._terminated)throw new Error("AU already terminated");const e=this.terminatedStatement();this._appendProvidedProperties(e,t);try{await this.sendStatement(e)}catch(t){throw new Error(`Failed to terminate: exception sending terminated statement (${t})`)}this._terminated=!0,this._isActive=!1},completed:async function(t={}){if(this.log("completed"),!this.isActive())throw new Error("AU not active");if(this.getLaunchMode()!==a)throw new Error("AU not in Normal launch mode");if(this._completed)throw new Error("AU already completed");const e=this.completedStatement();this._appendProvidedProperties(e,t);try{await this.sendStatement(e)}catch(t){throw new Error(`Failed to send completed statement: ${t}`)}this.setProgress(null),this._completed=!0},passed:async function(t){if(this.log("passed"),!this.isActive())throw new Error("AU not active");if(this.getLaunchMode()!==a)throw new Error("AU not in Normal launch mode");if(null!==this._passed)throw new Error("AU already passed");let e;try{e=this.passedStatement(t)}catch(t){throw new Error(`Failed to create passed statement: ${t}`)}try{await this.sendStatement(e)}catch(t){throw new Error(`Failed to send passed statement: ${t}`)}this._passed=!0},failed:async function(t){if(this.log("failed"),!this.isActive())throw new Error("AU not active");if(this.getLaunchMode()!==a)throw new Error("AU not in Normal launch mode");if(null!==this._failed||null!==this._passed)throw new Error("AU already passed/failed");let e;try{e=this.failedStatement(t)}catch(t){throw new Error(`Failed to create failed statement: ${t}`)}try{await this.sendStatement(e)}catch(t){throw new Error(`Failed to send failed statement: ${t}`)}this._failed=!0},isActive:function(){return this.log("isActive"),this._isActive},log:function(){v.DEBUG&&"undefined"!=typeof console&&console.log&&(arguments[0]="cmi5.js:"+arguments[0],console.log.apply(console,arguments))},includeSourceActivity:function(t){this._includeSourceActivity=!!t},getLaunchMode:function(){if(this.log("getLaunchMode"),null===this._lmsLaunchData)throw new Error("Can't determine launchMode until LMS LaunchData has been loaded");return this._lmsLaunchData.launchMode},getLaunchParameters:function(){if(this.log("getLaunchParameters"),null===this._lmsLaunchData)throw new Error("Can't determine LaunchParameters until LMS LaunchData has been loaded");let t=null;return void 0!==this._lmsLaunchData.launchParameters&&(t=this._lmsLaunchData.launchParameters),t},getSessionId:function(){if(this.log("getSessionId"),null===this._lmsLaunchData)throw new Error("Can't determine session id until LMS LaunchData has been loaded");return this._lmsLaunchData.contextTemplate.extensions["https://w3id.org/xapi/cmi5/context/extensions/sessionid"]},getMoveOn:function(){if(this.log("getMoveOn"),null===this._lmsLaunchData)throw new Error("Can't determine moveOn until LMS LaunchData has been loaded");return this._lmsLaunchData.moveOn},getMasteryScore:function(){if(this.log("getMasteryScore"),null===this._lmsLaunchData)throw new Error("Can't determine masteryScore until LMS LaunchData has been loaded");let t=null;return void 0!==this._lmsLaunchData.masteryScore&&(t=this._lmsLaunchData.masteryScore),t},getReturnURL:function(){if(this.log("getReturnURL"),null===this._lmsLaunchData)throw new Error("Can't determine returnURL until LMS LaunchData has been loaded");let t=null;return void 0!==this._lmsLaunchData.returnURL&&(t=this._lmsLaunchData.returnURL),t},getEntitlementKey:function(){if(this.log("getEntitlementKey"),null===this._lmsLaunchData)throw new Error("Can't determine entitlementKey until LMS LaunchData has been loaded");let t=null;return void 0!==this._lmsLaunchData.entitlementKey&&(void 0!==this._lmsLaunchData.entitlementKey.alternate?t=this._lmsLaunchData.entitlementKey.alternate:void 0!==this._lmsLaunchData.entitlementKey.courseStructure&&(t=this._lmsLaunchData.entitlementKey.courseStructure)),t},getLanguagePreference:function(){if(this.log("getLanguagePreference"),null===this._learnerPrefs)throw new Error("Can't determine language preference until learner preferences have been loaded");let t=null;return void 0!==this._learnerPrefs.contents.languagePreference&&(t=this._learnerPrefs.contents.languagePreference),t},setLanguagePreference:function(t){if(this.log("setLanguagePreference"),null===this._learnerPrefs)throw new Error("Can't set language preference until learner preferences have been loaded");""===t&&(t=null),this._learnerPrefs.contents.languagePreference=t},getAudioPreference:function(){if(this.log("getAudioPreference"),null===this._learnerPrefs)throw new Error("Can't determine audio preference until learner preferences have been loaded");let t=null;return void 0!==this._learnerPrefs.contents.audioPreference&&(t=this._learnerPrefs.contents.audioPreference),t},setAudioPreference:function(t){if(this.log("setAudioPreference"),null===this._learnerPrefs)throw new Error("Can't set audio preference until learner preferences have been loaded");if("on"!==t&&"off"!==t&&null!==t)throw new Error(`Unrecognized value for audio preference: ${t}`);this._learnerPrefs.contents.audioPreference=t},getDuration:function(){return this.log("getDuration"),(new Date).getTime()-this._durationStart},setProgress:function(t){if(this.log("setProgress: ",t),null!==t){if(!Number.isInteger(t))throw new Error(`Invalid progress measure (not an integer): ${t}`);if(t<0||t>100)throw new Error(`Invalid progress measure must be greater than or equal to 0 and less than or equal to 100: ${t}`)}this._progress=t},getProgress:function(){return this.log("getProgress"),this._progress},setEndpoint:function(t){this.log("setEndpoint: ",t),this._endpoint=t},getEndpoint:function(){return this._endpoint},setAuth:function(t){this.log("setAuth: ",t),this._auth=t},getAuth:function(){return this._auth},setFetch:function(t){this.log("setFetch: ",t),this._fetch=t},getFetch:function(){return this._fetch},setActor:function(t){if(this.log("setActor",t),"string"==typeof t)try{t=JSON.parse(t)}catch(t){throw new Error(`Invalid actor: failed to parse string as JSON (${t})`)}if(void 0===t.account)throw new Error("Invalid actor: account is missing");if(void 0===t.account.name)throw new Error("Invalid actor: account name is missing");if(""===t.account.name)throw new Error("Invalid actor: account name is empty");if(void 0===t.account.homePage)throw new Error("Invalid actor: account homePage is missing");if(""===t.account.homePage)throw new Error("Invalid actor: account homePage is empty");this._actor=t},getActor:function(){return this._actor},setActivityId:function(t){if(this.log("setActivityId",t),void 0===t)throw new Error("Invalid activityId: argument missing");if(""===t)throw new Error("Invalid activityId: empty string");this._activityId=t},getActivityId:function(){return this._activityId},setRegistration:function(t){if(this.log("setRegistration",t),null===t)throw new Error("Invalid registration: null");if(""===t)throw new Error("Invalid registration: empty");this._registration=t},getRegistration:function(){return this._registration},validateScore:function(t){if(null==t)throw new Error(`cannot validate score (score not provided): ${t}`);if(void 0!==t.min&&!Number.isInteger(t.min))throw new Error("score.min is not an integer");if(void 0!==t.max&&!Number.isInteger(t.max))throw new Error("score.max is not an integer");if(void 0!==t.scaled){if(!/^(-|\+)?[01]+(\.[0-9]+)?$/.test(t.scaled))throw new Error(`scaled score not a recognized number: ${t.scaled}`);if(t.scaled<0)throw new Error("scaled score must be greater than or equal to 0");if(t.scaled>1)throw new Error("scaled score must be less than or equal to 1")}if(void 0!==t.raw){if(!Number.isInteger(t.raw))throw new Error("score.raw is not an integer");if(void 0===t.min)throw new Error("minimum score must be provided when including a raw score");if(void 0===t.max)throw new Error("maximum score must be provided when including a raw score");if(t.rawt.max)throw new Error("raw score must be less than or equal to maximum score")}return!0},prepareStatement:function(t){const e={id:v.uuidv4(),timestamp:(new Date).toISOString(),actor:this._actor,verb:{id:t},object:{id:this._activityId},context:this._prepareContext()},n=this.getProgress();return void 0!==w[t]&&(e.verb.display=w[t]),t!==f&&null!==n&&(e.result={extensions:{"https://w3id.org/xapi/cmi5/result/extensions/progress":n}}),e},sendStatement:async function(t){let e;this.log("sendStatement",t),void 0===t.id&&(t.id=v.uuidv4());try{e=await fetch(`${this._endpoint}/statements?`+new URLSearchParams({statementId:t.id}),{mode:"cors",method:"put",headers:{"X-Experience-API-Version":i,Authorization:this._auth,"Content-Type":"application/json"},body:JSON.stringify(t)})}catch(t){throw new Error(`Failed request to send statement: ${t}`)}if(204!==e.status)throw new Error(`Failed to send statement: status code ${e.status}`)},sendStatements:async function(t){let e;this.log("sendStatements",t),t.forEach((t=>{void 0===t.id&&(t.id=v.uuidv4())}));try{e=await fetch(`${this._endpoint}/statements`,{mode:"cors",method:"post",headers:{"X-Experience-API-Version":i,Authorization:this._auth,"Content-Type":"application/json"},body:JSON.stringify(t)})}catch(t){throw new Error(`Failed request to send statements: ${t}`)}if(204!==e.status)throw new Error(`Failed to send statements: status code ${e.status}`)},initializedStatement:function(){return this.log("initializedStatement"),this._prepareStatement(u)},terminatedStatement:function(){this.log("terminatedStatement");const t=this._prepareStatement(d);return t.result=t.result||{},t.result.duration=v.convertMillisecondsToISO8601Duration(this.getDuration()),t},passedStatement:function(t){this.log("passedStatement");const e=this._prepareStatement(m);if(e.result=e.result||{},e.result.success=!0,e.result.duration=v.convertMillisecondsToISO8601Duration(this.getDuration()),t){try{this.validateScore(t)}catch(t){throw new Error(`Invalid score: ${t}`)}const n=this.getMasteryScore();if(null!==n&&void 0!==t.scaled){if(t.scaled=n)throw new Error(`Invalid score: scaled score exceeds mastery score (${t.scaled} >= ${n})`);e.context.extensions=e.context.extensions||{},e.context.extensions[h]=n}e.result.score=t}return e.context.contextActivities.category.push(c),e},completedStatement:function(){this.log("completedStatement");const t=this._prepareStatement(f);return t.result=t.result||{},t.result.completion=!0,t.result.duration=v.convertMillisecondsToISO8601Duration(this.getDuration()),t.context.contextActivities.category.push(c),t},_prepareContext:function(){const t=JSON.parse(this._contextTemplate);return t.registration=this._registration,this._includeSourceActivity&&(t.contextActivities=t.contextActivities||{},t.contextActivities.other=t.contextActivities.other||[],t.contextActivities.other.push(l)),t},_prepareStatement:function(t){const e=this.prepareStatement(t);return e.context.contextActivities=e.context.contextActivities||{},e.context.contextActivities.category=e.context.contextActivities.category||[],e.context.contextActivities.category.push(s),e},_appendProvidedProperties:function(t,e){if(void 0!==e.context&&void 0!==e.context.extensions)for(const n in e.context.extensions)e.context.extensions.hasOwnProperty(n)&&(t.context.extensions[n]=e.context.extensions[n]);if(void 0!==e.result&&(t.result=t.result||{},t.result.extensions=t.result.extensions||{},void 0!==e.result.extensions))for(const n in e.result.extensions)e.result.extensions.hasOwnProperty(n)&&(t.result.extensions[n]=e.result.extensions[n]);void 0!==e.object&&void 0!==e.object.definition&&(t.object.definition=t.object.definition||{},void 0!==e.object.definition.type&&(t.object.definition.type=e.object.definition.type))}},e.default})()}));
+//# sourceMappingURL=cmi5.min.js.map
diff --git a/KST-ASD-BI-101/js/course.js b/KST-ASD-BI-101/js/course.js
new file mode 100644
index 0000000..7e734cf
--- /dev/null
+++ b/KST-ASD-BI-101/js/course.js
@@ -0,0 +1,755 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+/**
+ * Initialize a course player for display, navigation and learning capture behavior.
+ *
+ * @param {Object} opts Course configuration parameters.
+ * @param {Array} opts.pageList An array of page names that will be made available via navigation.
+ * @param {string} opts.pageListQueryParam A comma-delimited list of page names that will be made available via navigation.
+ * @param {Object} opts.trackingPlugin The learning standard plugin prototype containing methods for communicating with a learning standard.
+ * @param {boolean} opts.navigateOnLaunch When true, automatically take the learner to the first navigable content page at startup.
+ * @param {boolean} opts.navigationInjectIntoContent When true, add navigation markup as needed to content areas.
+ * @param {string} opts.completionBehavior Indicates what learner actions cause the course to trigger a learning completion.
+ * @param {boolean} opts.enableBookmarking When true, allow course to load and save bookmarks.
+ * @param {boolean} opts.trackInteractions When true, allow learning standard storage of interaction data.
+ */
+function Course(opts) {
+ // Make sure we know how to clean up correctly, even if we're being exited from a redirect or window closing event.
+ window.addEventListener("beforeunload", event => {
+ this.exit({isUnloading: true});
+ });
+
+ this.pageList = [];
+ this.lastBookmark = "";
+ if (opts.pageList) {
+ this.pageList = opts.pageList;
+ } else if (opts.pageListQueryParam) {
+ this.pageList = opts.pageListQueryParam.split(",");
+ } else {
+ let tempPages = document.getElementsByClassName("contentpage");
+ this.pageList = [];
+ for (let i = 0; i < tempPages.length; i++) {
+ this.pageList.push(tempPages[i].id.substr(4));
+ }
+ }
+ this.pageViewedState = Array(this.pageList.length).fill(false);
+
+ this.trackingPlugin = null;
+ if (opts.trackingPlugin) {
+ this.trackingPlugin = opts.trackingPlugin;
+ } else {
+ alert("A tracking plugin is required, unable to proceed. Course cannot operate, no results will be recorded.");
+ return;
+ }
+
+ this.navigateOnLaunch = false;
+ if (opts.hasOwnProperty("navigateOnLaunch")) {
+ this.navigateOnLaunch = opts.navigateOnLaunch;
+ }
+
+ this.navigationInjectIntoContent = false;
+ if (opts.hasOwnProperty("navigationInjectIntoContent")) {
+ this.navigationInjectIntoContent = opts.navigationInjectIntoContent;
+ }
+ this.numQuestions = 0;
+
+ this.completionBehavior = "launch";
+ if (opts.completionBehavior) {
+ this.completionBehavior = opts.completionBehavior;
+ }
+
+ this.enableBookmarking = false;
+ if (opts.hasOwnProperty("enableBookmarking")) {
+ this.enableBookmarking = opts.enableBookmarking;
+ }
+
+ this.trackInteractions = true;
+ if (opts.hasOwnProperty("trackInteractions")) {
+ this.trackInteractions = opts.trackInteractions;
+ }
+
+ this.currentPageIdx = 0;
+ this.exitAttempted = false;
+ this.passingScaledScore = 0.8;
+ this.videoTrackingData = {};
+ // If true, don't save interactions while saving the passed/completed, delay until explicit flush.
+ this.trackingDelayInteractionSave = true;
+ this.renderNavTree();
+
+ this.trackingPlugin.initialize(this.postInit.bind(this), this.trackingActivityUpdate.bind(this));
+}
+
+/**
+ * Upon the learning standard plugin indicating that the course has been correctly initialized, control is returned
+ * to the course here to prepare for user engagement. Bookmarking and launch completion both occur at this point.
+ */
+Course.prototype.postInit = function () {
+ if (!this.trackingPlugin.canSave()) {
+ document.body.classList.add("info-bar-visible");
+ }
+
+ if (this.enableBookmarking) {
+ this.trackingPlugin.getBookmark().then(bookmark => {
+ this.lastBookmark = bookmark;
+ this.askBookmark();
+ }).catch(() => {
+ if (this.navigateOnLaunch) {
+ this.gotoPage(0);
+ }
+ });
+ } else if (this.navigateOnLaunch) {
+ this.gotoPage(0);
+ }
+
+ if (this.completionBehavior === "launch") {
+ this.pass();
+ }
+}
+
+/**
+ * If bookmarking is available and a bookmark exists, present the learner with a request to resume.
+ * Otherwise, navigate the learner into the learning experience.
+ */
+Course.prototype.askBookmark = function () {
+ if (this.lastBookmark) {
+ document.querySelector("#bookmark-overlay .restart-button").addEventListener("click", _ => {
+ this.ignoreBookmark();
+ });
+ document.querySelector("#bookmark-overlay .resume-button").addEventListener("click", _ => {
+ this.followBookmark();
+ });
+ document.getElementById("bookmark-overlay").classList.add("full-overlay-visible");
+ } else if (this.navigateOnLaunch) {
+ this.gotoPage(0);
+ }
+}
+
+/**
+ * Navigate the learner to the course page represented by the bookmark from the tracking plugin.
+ */
+Course.prototype.followBookmark = function () {
+ this.gotoPage(this.pageList.indexOf(this.lastBookmark));
+ this._resolveBookmark();
+}
+
+/**
+ * Perform no further bookmark actions, and start the course normally.
+ */
+Course.prototype.ignoreBookmark = function () {
+ if (this.navigateOnLaunch) {
+ this.gotoPage(0);
+ }
+ this._resolveBookmark();
+}
+
+Course.prototype._resolveBookmark = function () {
+ document.getElementById("bookmark-overlay").classList.remove("full-overlay-visible");
+}
+
+/**
+ * Helper method for generating (weak) v4 UUIDs. Only for use to establish non-cryptographic identifiers.
+ */
+Course.prototype.testUUID = function () {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
+ const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
+ return v.toString(16);
+ });
+}
+
+/**
+ * Whenever the tracking plugin has a change in transmitted data, this callback will be executed and any
+ * course-specific UI updates can occur.
+ *
+ * @param result Any successful tracking behaviors.
+ * @param error Any unsuccessful tracking behaviors.
+ * @param activeStatementCount The number of outstanding tracking behaviors yet to resolve.
+ */
+Course.prototype.trackingActivityUpdate = function (result, error, activeStatementCount) {
+ document.querySelectorAll(".exit-button").forEach(el => {
+ if (activeStatementCount > 0) {
+ el.disabled = "disabled";
+ } else {
+ el.disabled = "";
+ }
+ });
+}
+
+/**
+ * As part of preparing a page for learner engagement, sets up all useful page-specific event behaviors.
+ */
+Course.prototype.addPerPageListeners = function () {
+ // Waiting to make sure DOM has time to be correctly attached, even if all elements are not yet rendered.
+ setTimeout(() => {
+ // For every video on the page, bind a series of listeners and interaction trackers.
+ let videos = document.getElementsByTagName("video");
+ for (let i = 0; i < videos.length; i++) {
+ let vid = videos[i];
+
+ (videoClosure => {
+ let vidName = videoClosure.dataset.name,
+ vidObject = videoClosure.dataset.objectid,
+ session = this.testUUID();
+
+ this.videoTrackingData[vidObject] = {
+ playedSegments: [],
+ lastSeen: -1,
+ initializedTime: new Date(),
+ finished: false,
+ session: session
+ };
+
+ this.trackingPlugin.videoInitialize({
+ name: vidName,
+ objectId: vidObject,
+ currentTime: 0
+ }).catch(e => {
+ this.trackingError("Unable to save video initialized statement: " + String(e));
+ });
+
+ videoClosure.parentNode.querySelectorAll(".video-start-overlay").forEach(overlay => {
+ overlay.addEventListener("click", () => {
+ overlay.classList.add("hidden");
+ videoClosure.play();
+ });
+ });
+
+ videoClosure.addEventListener("click", () => {
+ if (!this.videoTrackingData[vidObject].finished) {
+ if (videoClosure.paused) {
+ videoClosure.play();
+ } else {
+ videoClosure.pause();
+ }
+ }
+ });
+
+ videoClosure.addEventListener("play", e => {
+ this.videoTrackingData[vidObject].lastSeen = e.target.currentTime;
+ this.trackingPlugin.videoPlay({
+ name: vidName,
+ objectId: vidObject,
+ currentTime: e.target.currentTime,
+ videoLength: e.target.duration,
+ session: session
+ }).catch(e => {
+ this.trackingError("Unable to save video played statement: " + String(e));
+ });
+ });
+
+ videoClosure.addEventListener("pause", e => {
+ if (e.target.currentTime !== e.target.duration) {
+ this.videoTrackingData[vidObject].playedSegments.push([
+ this.videoTrackingData[vidObject].lastSeen, e.target.currentTime]);
+ this.trackingPlugin.videoPause({
+ name: vidName,
+ objectId: vidObject,
+ currentTime: e.target.currentTime,
+ playedSegments: this.videoTrackingData[vidObject].playedSegments,
+ progress: +(e.target.currentTime / e.target.duration).toFixed(3),
+ videoLength: e.target.duration,
+ session: session
+ }).catch(e => {
+ this.trackingError("Unable to save video paused statement: " + String(e));
+ });
+ }
+ });
+
+ videoClosure.addEventListener("ended", e => {
+ this.videoTrackingData[vidObject].playedSegments.push([
+ this.videoTrackingData[vidObject].lastSeen, e.target.currentTime]);
+ videoClosure.parentNode.querySelectorAll(".video-complete-overlay").forEach(overlay => {
+ overlay.classList.remove("hidden");
+ });
+ this.trackingPlugin.videoCompleted({
+ name: vidName,
+ objectId: vidObject,
+ currentTime: e.target.currentTime,
+ progress: 1,
+ playedSegments: this.videoTrackingData[vidObject].playedSegments,
+ completion: true,
+ duration: "PT" + e.target.currentTime + "S",
+ completionThreshold: 1.0,
+ videoLength: e.target.duration,
+ session: session
+ }).catch(e => {
+ this.trackingError("Unable to save video completed statement: " + String(e));
+ });
+ });
+ })(vid);
+ }
+ }, 1);
+}
+
+/**
+ * Learner has failed the course material, perform appropriate tracking behaviors.
+ *
+ * @param {Object=} scoreObj The scored results of the learner's efforts, if relevant.
+ * @param {number} scoreObj.scaled The learner's scaled score, between -1.0 and 1.0.
+ * @param {number} scoreObj.raw The learner's raw score, between `min` and `max`.
+ * @param {number} scoreObj.min The lowest possible raw score that can be achieved in this course.
+ * @param {number} scoreObj.max The highest possible raw score that can be achieved in this course.
+ */
+Course.prototype.fail = function (scoreObj) {
+ this.trackingPlugin.fail(scoreObj).then(() => {
+ this.trackingPlugin.flushBatch();
+ }).catch(e => {
+ this.trackingError("Unable to save failed statement: " + String(e));
+ });
+}
+
+/**
+ * Learner has successfully completed the course material, perform appropriate tracking behaviors.
+ *
+ * @param {Object=} scoreObj The scored results of the learner's efforts, if relevant.
+ * @param {number} scoreObj.scaled The learner's scaled score, between -1.0 and 1.0.
+ * @param {number} scoreObj.raw The learner's raw score, between `min` and `max`.
+ * @param {number} scoreObj.min The lowest possible raw score that can be achieved in this course.
+ * @param {number} scoreObj.max The highest possible raw score that can be achieved in this course.
+ */
+Course.prototype.pass = function (scoreObj) {
+ this.trackingPlugin.passAndComplete(scoreObj).then(() => {
+ this.trackingPlugin.flushBatch();
+ }).catch(e => {
+ this.trackingError("Unable to save passed/completed statement: " + String(e));
+ });
+}
+
+/**
+ * Given a page name (not position in the page list), return the page's title.
+ *
+ * @param {string} pageName Page name (the id value without the "page" prefix).
+ * @returns {string} Page title.
+ */
+Course.prototype.getPageTitle = function (pageName) {
+ let el = document.getElementById("page" + pageName);
+ if (!el) {
+ return "";
+ }
+ return el.dataset.title;
+}
+
+/**
+ * Build the navigation menu from the existing list of pages.
+ */
+Course.prototype.renderNavTree = function () {
+ let s = "";
+ for (let i = 0; i < this.pageList.length; i++) {
+ let title = this.getPageTitle(this.pageList[i]);
+ s += '
' + title + '
';
+ }
+ document.getElementById("navmenuitems").innerHTML = s;
+}
+
+/**
+ * Do all the setup to inject the new page into the content DOM area, bind the appropriate events and do learning
+ * standards tracking.
+ *
+ * @param {number} pageIdx The current position in the page list to navigate to.
+ * @private
+ */
+Course.prototype._navigatePage = function (pageIdx) {
+ this.swapPageContents(pageIdx);
+ this.addPerPageListeners();
+
+ let lis = document.getElementById("navmenu").getElementsByTagName("li");
+ for (let i = 0; i < lis.length; i++) {
+ lis[i].classList.remove("current");
+ }
+ lis[pageIdx].classList.add("current");
+
+ this.numQuestions = document.getElementById("page" + this.pageList[pageIdx]).dataset.questions;
+
+ // If we're using completion behavior of "visited last page", then perform any standard-specific completion actions.
+ if (this.completionBehavior === "last") {
+ if (pageIdx >= this.pageList.length - 1) {
+ this.pass();
+ }
+ }
+
+ // Set the bookmark
+ if (this.enableBookmarking) {
+ this.trackingPlugin.setBookmark(this.pageList[pageIdx]);
+ }
+
+ // Control navigation visibility for views that require it.
+ if (document.getElementById("next")) {
+ if (pageIdx >= this.pageList.length - 1) {
+ document.getElementById("next").classList.add("navdisabled");
+ document.getElementById("next").disabled = "disabled";
+ } else {
+ document.getElementById("next").classList.remove("navdisabled");
+ document.getElementById("next").disabled = undefined;
+ }
+ }
+ if (document.getElementById("prev")) {
+ if (pageIdx <= 0) {
+ document.getElementById("prev").classList.add("navdisabled");
+ document.getElementById("prev").disabled = "disabled";
+ } else {
+ document.getElementById("prev").classList.remove("navdisabled");
+ document.getElementById("prev").disabled = undefined;
+ }
+ }
+}
+
+/**
+ * Inject a page into the content area DOM.
+ *
+ * @param {number} pageIdx The current position in the page list to navigate to.
+ */
+Course.prototype.swapPageContents = function (pageIdx) {
+ let pageName = this.pageList[pageIdx];
+ let page = document.getElementById("page" + pageName);
+ let s = "";
+ if (this.navigationInjectIntoContent) {
+ if (pageIdx > 0) {
+ s += '
↑ Previous Section
';
+ }
+ }
+ s += page.innerHTML;
+ if (this.navigationInjectIntoContent) {
+ if (pageIdx < this.pageList.length - 1) {
+ s += '';
+ }
+ }
+ document.getElementById("content").innerHTML = s;
+}
+
+/**
+ * Calculate the learner's current navigated progress through the course. This is not related to scoring or status.
+ *
+ * @returns {number} A progress measure value from 0 to 100.
+ */
+Course.prototype.getCourseProgress = function () {
+ return Math.round((this.pageViewedState.filter(x => x === true).length / this.pageViewedState.length) * 100);
+}
+
+/**
+ * Perform post-navigation actions before presenting the current page to the learner.
+ * @private
+ */
+Course.prototype._postNavigate = function () {
+ window.scrollTo(0, 0);
+ let content = document.getElementById("content");
+ content.scrollTo(0, 0);
+ content.classList.remove("changePage");
+
+ this.pageViewedState[this.currentPageIdx] = true;
+ if (document.getElementById("footer-progress-value")) {
+ document.getElementById("footer-progress-value").innerHTML = String(this.getCourseProgress());
+ }
+ document.querySelectorAll(".navmenuitem")[this.currentPageIdx].classList.add("seen");
+ this.trackingPlugin.experienced(
+ this.currentPageIdx,
+ this.getPageTitle(this.pageList[this.currentPageIdx]),
+ this.getCourseProgress()
+ ).catch(e => {
+ this.trackingError("Unable to save experienced statement: " + String(e));
+ });
+}
+
+/**
+ * Navigate directly to the specified page index in the page list.
+ *
+ * @param {number} pageIdx The page index in the page list.
+ */
+Course.prototype.gotoPage = function (pageIdx) {
+ this.currentPageIdx = pageIdx;
+ this._navigatePage(this.currentPageIdx);
+ this._postNavigate();
+}
+
+/**
+ * Navigate to the previous available page.
+ */
+Course.prototype.previousPage = function () {
+ this.currentPageIdx--;
+ this._navigatePage(this.currentPageIdx);
+ this._postNavigate();
+}
+
+/**
+ * Navigate to the next available page.
+ */
+Course.prototype.nextPage = function () {
+ this.currentPageIdx++;
+ this._navigatePage(this.currentPageIdx);
+ this._postNavigate();
+}
+
+/**
+ * With the current page's form, determine the success or failure of the interactions contained in it.
+ * Perform any related tracking behaviors surrounding interactions and completions.
+ */
+Course.prototype.submitAnswers = function () {
+ let correct = 0;
+ const pageName = this.pageList[this.currentPageIdx];
+ const page = document.getElementById("page" + pageName);
+
+ let interactionTrackingData = [];
+ for (let i = 0; i < this.numQuestions; i++) {
+ let statusAndData = this.checkAndPrepare(page.dataset.quizId, "q" + i);
+ if (statusAndData.data) {
+ interactionTrackingData.push(statusAndData.data);
+ }
+ if (statusAndData.isCorrect) {
+ correct += 1;
+ }
+ }
+ const scaledScore = correct / this.numQuestions;
+ const rawScore = Math.floor(scaledScore * 100);
+ const scoreObj = {
+ scaled: scaledScore,
+ raw: rawScore,
+ min: 0,
+ max: 100
+ };
+
+ let actualPassingScaledScore = this.passingScaledScore;
+ let tmpPassingScaledScore = this.trackingPlugin.getOverridePassingScaledScore();
+ if (!Number.isNaN(tmpPassingScaledScore)) {
+ actualPassingScaledScore = tmpPassingScaledScore;
+ }
+
+ document.getElementById("score-panel-score").innerHTML = "" + Math.round(rawScore * 100.) / 100.;
+ document.getElementById("score-panel").classList.add("full-overlay-visible");
+ if (scoreObj["scaled"] >= actualPassingScaledScore) {
+ document.getElementById("score-passed").classList.add("visible");
+ this.pass(scoreObj);
+ } else {
+ document.getElementById("score-failed").classList.add("visible");
+ this.fail(scoreObj);
+ }
+
+ this.trackingPlugin.captureInteractions(interactionTrackingData, {queue: this.trackingDelayInteractionSave});
+}
+
+/**
+ * Given a single question/interaction on a page form, determine if it was answered correctly, and perform
+ * tracking behaviors around it if appropriate.
+ * @param {string} testId A unique identifier for the current page's test element, for tracking.
+ * @param {string} name The identifier of the current question.
+ * @returns {Object} isCorrect: Whether the question was answered correctly.
+ * interactionData: Any useful data about the interaction, null if none.
+ */
+Course.prototype.checkAndPrepare = function (testId, name) {
+ let nodes = document.getElementsByName(name);
+ let success = false;
+ let interactionData = null;
+
+ if (nodes.length > 0) {
+ if (nodes[0].type === "text") {
+ let userAnswer = nodes[0].value;
+ let correctAnswer = nodes[0].dataset.answer;
+ if (userAnswer === correctAnswer) {
+ success = true;
+ }
+
+ interactionData = {
+ testId: testId,
+ interactionType: "fill-in",
+ interactionId: name,
+ userAnswers: [userAnswer],
+ correctAnswers: [correctAnswer],
+ name: document.getElementById(name + "-question").innerHTML,
+ description: "",
+ success: success
+ };
+ } else if (nodes[0].type === "radio") {
+ let failedMatch = false;
+ let userAnswers = [];
+ let correctAnswers = [];
+ let choices = [];
+ for (let i = 0; i < nodes.length; i++) {
+ choices.push({
+ id: nodes[i].value,
+ description: {
+ "en-US": nodes[i].nextSibling.innerHTML
+ }
+ })
+ if ((nodes[i].dataset.correct === "yes" && !nodes[i].checked) ||
+ (nodes[i].dataset.correct !== "yes" && nodes[i].checked)) {
+ failedMatch = true;
+ }
+ if (nodes[i].checked) {
+ userAnswers.push(nodes[i].value);
+ }
+ if (nodes[i].dataset.correct === "yes") {
+ correctAnswers.push(nodes[i].value);
+ }
+ }
+ if (!failedMatch) {
+ success = true;
+ }
+ interactionData = {
+ testId: testId,
+ interactionType: "choice",
+ interactionId: name,
+ userAnswers: userAnswers,
+ correctAnswers: correctAnswers,
+ choices: choices,
+ name: document.getElementById(name + "-question").innerHTML,
+ description: "",
+ success: success
+ };
+ } else if (nodes[0].tagName.toLowerCase() === "select") {
+ let failedMatch = false;
+ let userAnswers = [];
+ let correctAnswers = [];
+ let choices = [];
+ for (let option of nodes[0].options) {
+ choices.push({
+ id: option.value,
+ description: {
+ "en-US": option.innerHTML
+ }
+ })
+ if ((option.dataset.correct === "yes" && !option.selected) ||
+ (option.dataset.correct !== "yes" && option.selected)) {
+ failedMatch = true;
+ }
+ if (option.selected) {
+ userAnswers.push(option.value);
+ }
+ if (option.dataset.correct === "yes") {
+ correctAnswers.push(option.value);
+ }
+ }
+ if (!failedMatch) {
+ success = true;
+ }
+
+ interactionData = {
+ testId: testId,
+ interactionType: "choice",
+ interactionId: name,
+ userAnswers: userAnswers,
+ correctAnswers: correctAnswers,
+ choices: choices,
+ name: document.getElementById(name + "-question").innerHTML,
+ description: "",
+ success: success
+ };
+ }
+ }
+ return {isCorrect: success, data: interactionData};
+}
+
+/**
+ * Fill in all answers on the current page's form correctly.
+ */
+Course.prototype.autopass = function () {
+ for (let i = 0; i < this.numQuestions; i++) {
+ let nm = "q" + i;
+ let nodes = document.getElementsByName(nm);
+ if (nodes.length > 0) {
+ if (nodes[0].type === "text") {
+ nodes[0].value = nodes[0].dataset.answer;
+ } else if (nodes[0].type === "radio") {
+ for (let node of nodes) {
+ node.checked = node.dataset.correct === "yes";
+ }
+ } else if (nodes[0].tagName.toLowerCase() === "select") {
+ for (let option of nodes[0].options) {
+ option.selected = option.dataset.correct === "yes";
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Fill in all answers on the current page's form incorrectly.
+ */
+Course.prototype.autofail = function () {
+ for (let questionIdx = 0; questionIdx < this.numQuestions; questionIdx++) {
+ let nm = "q" + questionIdx;
+ let nodes = document.getElementsByName(nm);
+ if (nodes.length > 0) {
+ if (nodes[0].type === "text") {
+ nodes[0].value = Math.floor(Math.random() * 199);
+ } else if (nodes[0].type === "radio") {
+ for (let node of nodes) {
+ if (node.dataset.correct !== "yes") {
+ node.checked = true;
+ break;
+ } else {
+ node.checked = false;
+ }
+ }
+ } else if (nodes[0].tagName.toLowerCase() === "select") {
+ for (let option of nodes[0].options) {
+ option.selected = option.dataset.correct !== "yes";
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Hide the error overlay.
+ */
+Course.prototype.hideErrorPanel = function () {
+ document.getElementById("error-message-overlay").classList.remove("full-overlay-visible");
+}
+
+/**
+ * Show the error overlay.
+ */
+Course.prototype.showErrorPanel = function () {
+ document.getElementById("error-message-overlay").classList.add("full-overlay-visible");
+}
+
+/**
+ * If an error occurs while performing tracking behaviors, record it and make it available on the error panel.
+ *
+ * @param {string} errorMsg The text of the error retrieved from a tracking call that errored.
+ */
+Course.prototype.trackingError = function (errorMsg) {
+ if (document.getElementById("warning-icon")) {
+ document.getElementById("warning-icon").classList.add("warning-icon-visible");
+ }
+ document.getElementById("error-content").innerHTML = errorMsg;
+}
+
+/**
+ * Exit the course, performing any necessary exit tracking behaviors.
+ */
+Course.prototype.exit = function (opts) {
+ if (!opts) {
+ opts = {};
+ }
+ if (!opts.hasOwnProperty("isUnloading")) {
+ opts.isUnloading = false;
+ }
+
+ if (this.trackingPlugin) {
+ // If we've already done the exit procedure and the window is now unloading, don't do it again.
+ if (!this.exitAttempted || (this.exitAttempted && !opts.isUnloading)) {
+ const lastExitAttempted = this.exitAttempted;
+ this.exitAttempted = true;
+ this.trackingPlugin.exit(lastExitAttempted);
+ }
+ } else {
+ if (window.opener) {
+ try {
+ window.close();
+ } catch (e) {
+ }
+ }
+ }
+}
diff --git a/KST-ASD-BI-101/js/course_cmi5.js b/KST-ASD-BI-101/js/course_cmi5.js
new file mode 100644
index 0000000..aa39dd3
--- /dev/null
+++ b/KST-ASD-BI-101/js/course_cmi5.js
@@ -0,0 +1,693 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+const
+ XAPI_VERSION = "1.0.3",
+ SUSPEND_DATA_KEY = "suspendData",
+ VERB_ANSWERED = "http://adlnet.gov/expapi/verbs/answered",
+ VERB_EXPERIENCED_OBJ = {
+ id: "http://adlnet.gov/expapi/verbs/experienced",
+ display: {
+ "en-US": "experienced"
+ }
+ },
+ VERB_VIDEO_COMPLETED_OBJ = {
+ "id": "http://adlnet.gov/expapi/verbs/completed",
+ "display": {
+ "en-US": "completed"
+ }
+ },
+ VERB_VIDEO_INITIALIZED_OBJ = {
+ "id": "http://adlnet.gov/expapi/verbs/initialized",
+ "display": {
+ "en-US": "initialized"
+ }
+ },
+ VERB_VIDEO_PAUSED_OBJ = {
+ "id": "https://w3id.org/xapi/video/verbs/paused",
+ "display": {
+ "en-US": "paused"
+ }
+ },
+ VERB_VIDEO_PLAYED_OBJ = {
+ "id": "https://w3id.org/xapi/video/verbs/played",
+ "display": {
+ "en-US": "played"
+ }
+ };
+
+/**
+ * Prepare the cmi5 tracking plugin. This does not begin a cmi5 session.
+ * @constructor
+ */
+function CourseCmi5Plugin() {
+ this.cmi5 = null;
+ this.passingScore = Number.NaN;
+ this.activeStatements = 0;
+ this.callbackOnStatementSend = null;
+ this.launchMode = "";
+ this.statementBatch = [];
+}
+
+/**
+ * Begin a cmi5 session and send a learner "initialized" statement.
+ *
+ * @param {Function} callbackOnInit Callback to call once initialization is complete.
+ * @param {Function} callbackOnStatementSend Callback to call whenever any xAPI or cmi5 statement is sent.
+ */
+CourseCmi5Plugin.prototype.initialize = function (callbackOnInit, callbackOnStatementSend) {
+ // Cmi5.enableDebug();
+
+ this.callbackOnStatementSend = callbackOnStatementSend;
+ this.cmi5 = new Cmi5(document.location.href);
+ if (!this.cmi5.getEndpoint()) {
+ this.cmi5 = null;
+ } else {
+ this.cmi5.start({
+ launchData: err => {
+ if (err) {
+ console.log("error occurred fetching launchData", err);
+ alert("Unable to retrieve launch data, reason: " + err);
+ }
+
+ this.launchMode = this.cmi5.getLaunchMode();
+ let masteryScore = this.cmi5.getMasteryScore();
+ if (masteryScore !== null) {
+ this.passingScore = parseFloat(masteryScore);
+ }
+ },
+ initializeStatement: err => {
+ if (err) {
+ console.log("error occurred sending initialized statement", err);
+ alert("Unable to initialize, reason: " + err);
+ } else {
+ callbackOnInit();
+ }
+ }
+ });
+ }
+}
+
+/**
+ * If we're running in a mode where saving anything meaningful is allowed (more than initialized/terminated), return true.
+ *
+ * @returns {boolean} true if we can save most learner information, false if we can only initialize/terminate.
+ */
+CourseCmi5Plugin.prototype.canSave = function () {
+ return this._shouldSendStatement();
+}
+
+/**
+ * Get the xAPI endpoint for the current session.
+ *
+ * @return {string} URL representing the xAPI root to send xAPI data to.
+ */
+CourseCmi5Plugin.prototype.getEndpoint = function () {
+ let endpoint = this.cmi5.getEndpoint();
+ if (endpoint[endpoint.length - 1] !== "/") {
+ endpoint = endpoint + "/";
+ }
+ return endpoint;
+}
+
+/**
+ * Retrieve the activity state for this learner and registration at the given stateId.
+ *
+ * @param {string} stateId The xAPI Activity State "stateId" value for the API call.
+ * @return {Promise} The promise returned by the fetch call representing the xAPI request.
+ */
+CourseCmi5Plugin.prototype.getActivityState = function (stateId) {
+ return fetch(
+ this.getEndpoint() + "activities/state?" + new URLSearchParams(
+ {
+ stateId: stateId,
+ activityId: this.cmi5.getActivityId(),
+ agent: JSON.stringify(this.cmi5.getActor()),
+ registration: this.cmi5.getRegistration()
+ }
+ ),
+ {
+ mode: "cors",
+ method: "get",
+ headers: {
+ "X-Experience-API-Version": XAPI_VERSION,
+ Authorization: this.cmi5.getAuth()
+ }
+ }
+ ).then(response => {
+ if (response.status === 200) {
+ return response.json();
+ } else {
+ return Promise.resolve("");
+ }
+ }).catch(ex => {
+ throw new Error(`Failed to GET activity state: ${ex}`);
+ });
+}
+
+/**
+ * Store the data provided as an activity state for this learner and registration at the given stateId.
+ *
+ * @param {string} stateId The xAPI Activity State "stateId" value for the API call.
+ * @param data The data to store in this Activity State.
+ * @return {Promise} The promise returned by the fetch call representing the xAPI request.
+ */
+CourseCmi5Plugin.prototype.setActivityState = function (stateId, data) {
+ return fetch(
+ this.getEndpoint() + "activities/state?" + new URLSearchParams(
+ {
+ stateId: stateId,
+ activityId: this.cmi5.getActivityId(),
+ agent: JSON.stringify(this.cmi5.getActor()),
+ registration: this.cmi5.getRegistration()
+ }
+ ),
+ {
+ mode: "cors",
+ method: "put",
+ headers: {
+ "X-Experience-API-Version": XAPI_VERSION,
+ Authorization: this.cmi5.getAuth(),
+ Accept: "application/json",
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify(data)
+ }
+ ).then(response => {
+ if (response.status === 200) {
+ return response.json();
+ }
+ return Promise.resolve("");
+ }).catch(ex => {
+ throw new Error(`Failed to GET activity state: ${ex}`);
+ });
+}
+
+/**
+ * Get the learner's bookmark if one exists.
+ *
+ * @return {Promise} The learner's bookmark, or an empty string if no bookmark exists.
+ */
+CourseCmi5Plugin.prototype.getBookmark = function () {
+ return this.getActivityState(SUSPEND_DATA_KEY).then(bookmarkObj => {
+ if (bookmarkObj && bookmarkObj["bookmark"]) {
+ return Promise.resolve(bookmarkObj["bookmark"]);
+ } else {
+ return Promise.resolve("");
+ }
+ });
+}
+
+/**
+ * Save the learner's bookmark.
+ *
+ * @param {string} bookmark The learner's bookmark data.
+ * @return {Promise} The promise returned by the fetch call representing the xAPI request.
+ */
+CourseCmi5Plugin.prototype.setBookmark = function (bookmark) {
+ return this.setActivityState(SUSPEND_DATA_KEY, {"bookmark": bookmark});
+}
+
+/**
+ * Retrieve the AU's mastery score.
+
+ * @return {number} The AU mastery score, or Number.NaN if one was not provided.
+ */
+CourseCmi5Plugin.prototype.getOverridePassingScaledScore = function () {
+ return this.passingScore;
+}
+
+/**
+ * Learner has failed the course material, send a cmi5 defined statement indicating failure.
+ *
+ * @param {Object=} userScoreObj The scored results of the learner's efforts, if relevant.
+ * @param {number} userScoreObj.scaled The learner's scaled score, between -1.0 and 1.0.
+ * @param {number} userScoreObj.raw The learner's raw score, between `min` and `max`.
+ * @param {number} userScoreObj.min The lowest possible raw score that can be achieved in this course.
+ * @param {number} userScoreObj.max The highest possible raw score that can be achieved in this course.
+ */
+CourseCmi5Plugin.prototype.fail = function (userScoreObj) {
+ if (!this.cmi5) {
+ return Promise.resolve(null);
+ }
+
+ if (this._shouldSendStatement()) {
+ return this.cmi5.failed(userScoreObj);
+ }
+ return Promise.resolve(null);
+}
+
+/**
+ * Learner has passed the course material, send two cmi5 defined statements indicating passed and completed.
+ *
+ * @param {Object=} userScoreObj The scored results of the learner's efforts, if relevant.
+ * @param {number} userScoreObj.scaled The learner's scaled score, between -1.0 and 1.0.
+ * @param {number} userScoreObj.raw The learner's raw score, between `min` and `max`.
+ * @param {number} userScoreObj.min The lowest possible raw score that can be achieved in this course.
+ * @param {number} userScoreObj.max The highest possible raw score that can be achieved in this course.
+ */
+CourseCmi5Plugin.prototype.passAndComplete = function (userScoreObj) {
+ if (!this.cmi5) {
+ return Promise.resolve(null);
+ }
+
+ if (this._shouldSendStatement()) {
+ return this._sendStatementViaLibFunction(() => {
+ return this.cmi5.passed(userScoreObj).then(() => this.cmi5.completed());
+ })
+ }
+ return Promise.resolve(null);
+}
+
+/**
+ * Handle cmi5-specific exit redirect rules here around the return URL, if available.
+ * @private
+ */
+CourseCmi5Plugin.prototype._exitRedirect = function () {
+ if (this.cmi5 && this.cmi5.getReturnURL()) {
+ document.location.href = this.cmi5.getReturnURL();
+ }
+}
+
+/**
+ * Attempt to navigate the browser or close the current window relative to whatever way the course has been launched.
+ * @private
+ */
+CourseCmi5Plugin.prototype._exit = function () {
+ if (window.opener) {
+ try {
+ window.close();
+ } catch (e) {
+ this._exitRedirect();
+ }
+ } else {
+ this._exitRedirect();
+ }
+}
+
+/**
+ * Perform any tracking behaviors necessary to exit the course safely, and then navigate away from the course.
+ *
+ * @param {string} alreadyAttempted true if we've tried to exit before, to avoid doing tracking behaviors that should
+ * not be repeated.
+ */
+CourseCmi5Plugin.prototype.exit = function (alreadyAttempted) {
+ if (this.cmi5 && !alreadyAttempted) {
+ this.cmi5.terminate().finally(() => {
+ this._exit();
+ });
+ } else {
+ this._exit();
+ }
+}
+
+/**
+ * Generate an xAPI "experienced" statement, tagged with the current page information.
+ *
+ * @param {string} pageId The current page ID name the learner experienced.
+ * @param {string} name The current page name the learner experienced.
+ * @param {number} overallProgress Value from 0-100 representing percentage of the course viewed.
+ * @return {Promise} The promise from the statement network call.
+ */
+CourseCmi5Plugin.prototype.experienced = function (pageId, name, overallProgress) {
+ if (!this.cmi5) {
+ return Promise.resolve(null);
+ }
+
+ let stmt = this.cmi5.prepareStatement(VERB_EXPERIENCED_OBJ.id);
+ stmt.verb.display = VERB_EXPERIENCED_OBJ.display;
+ stmt.object = {
+ objectType: "Activity",
+ id: this.cmi5.getActivityId() + "/slide/" + pageId,
+ definition: {
+ name: {"en-US": name},
+ }
+ };
+ // If we can, also save the progress value, ignore values that are out of range.
+ if (!Number.isNaN(overallProgress) && overallProgress > 0 && overallProgress < 100) {
+ if (!stmt.result) {
+ stmt.result = {};
+ }
+ if (!stmt.result.extensions) {
+ stmt.result.extensions = {};
+ }
+ stmt.result.extensions["https://w3id.org/xapi/cmi5/result/extensions/progress"] = Math.round(overallProgress);
+ }
+ return this.sendStatement(stmt);
+}
+
+/**
+ * Generate an xAPI "answered" statement that represents the details of the learner's interaction, including success.
+ *
+ * @param {Array} interactionList A list of interaction details.
+ * @param {Object} opts Configuration around the storage and sending of the interaction.
+ * @return {Promise} The promise from the statement network call.
+ */
+CourseCmi5Plugin.prototype.captureInteractions = function (interactionList, opts) {
+ if (!this.cmi5) {
+ return Promise.resolve(null);
+ }
+
+ if (!opts) {
+ opts = {}
+ }
+ if (!opts.hasOwnProperty("queue")) {
+ opts.queue = false;
+ }
+
+ let stmts = [];
+ interactionList.forEach(interactionObj => {
+ let stmt = this.cmi5.prepareStatement(VERB_ANSWERED);
+ stmt.result = {
+ response: interactionObj.userAnswers.join("[,]")
+ }
+ if (typeof interactionObj.success !== "undefined") {
+ stmt.result.success = !!interactionObj.success;
+ }
+ stmt.object = {
+ objectType: "Activity",
+ id: this.cmi5.getActivityId() + "/test/" + interactionObj.testId + "/question/" + interactionObj.interactionId,
+ definition: {
+ type: "http://adlnet.gov/expapi/activities/cmi.interaction",
+ interactionType: interactionObj.interactionType,
+ name: {"en-US": interactionObj.name},
+ correctResponsesPattern: [interactionObj.correctAnswers.join("[,]")]
+ }
+ };
+ if (interactionObj.description) {
+ stmt.object.definition.description = {"en-US": interactionObj.description};
+ }
+ if (interactionObj.choices) {
+ stmt.object.definition.choices = interactionObj.choices;
+ }
+ stmts.push(stmt);
+ });
+ return this.sendStatements(stmts, opts);
+}
+
+/**
+ * Build a cmi5 statement that matches the xAPI video profile.
+ *
+ * @param {Object} videoObj The learner details about the video element interaction.
+ * @param {Object} verb The video profile verb action the learner performed with the video.
+ * @return {Object} An xAPI video profile statement.
+ * @private
+ */
+CourseCmi5Plugin.prototype._videoMakeStatement = function (videoObj, verb) {
+ let stmt = this.cmi5.prepareStatement(verb.id);
+ if (verb.display) {
+ stmt.verb.display = verb.display;
+ }
+ stmt.object = {
+ "definition": {
+ "type": "https://w3id.org/xapi/video/activity-type/video",
+ "name": {
+ "en-US": videoObj.name
+ }
+ },
+ "id": videoObj.objectId,
+ "objectType": "Activity"
+ };
+ stmt.result = stmt.result || {};
+ stmt.result.extensions = stmt.result.extensions || {};
+ stmt.context.contextActivities = stmt.context.contextActivities || {};
+ stmt.context.contextActivities.category = stmt.context.contextActivities.category || [];
+ stmt.context.contextActivities.category.push({"id": "https://w3id.org/xapi/video"});
+ stmt.context.extensions = stmt.context.extensions || {};
+ stmt.context.extensions["https://w3id.org/xapi/video/extensions/session-id"] = videoObj.session;
+ stmt.context.extensions["https://w3id.org/xapi/video/extensions/length"] = videoObj.videoLength;
+
+ if (videoObj.hasOwnProperty("currentTime")) {
+ stmt["result"]["extensions"]["https://w3id.org/xapi/video/extensions/time"] = videoObj.currentTime;
+ }
+ if (videoObj.hasOwnProperty("progress")) {
+ stmt["result"]["extensions"]["https://w3id.org/xapi/video/extensions/progress"] = videoObj.progress;
+ }
+ if (videoObj.hasOwnProperty("completion")) {
+ stmt["result"]["completion"] = videoObj.completion;
+ }
+ if (videoObj.hasOwnProperty("duration")) {
+ stmt["result"]["duration"] = videoObj.duration;
+ }
+ if (videoObj.hasOwnProperty("playedSegments")) {
+ stmt["result"]["extensions"]["https://w3id.org/xapi/video/extensions/played-segments"] =
+ videoObj.playedSegments.map(segment => segment[0] + "[.]" + segment[1]).join("[,]");
+ }
+ if (videoObj.hasOwnProperty("completionThreshold")) {
+ stmt["context"]["extensions"]["https://w3id.org/xapi/video/extensions/completion-threshold"] = videoObj.completionThreshold;
+ }
+ return stmt;
+}
+
+/**
+ * Mark the current video being watched as completed.
+ *
+ * @param {Object} videoObj The learner details about the video element interaction.
+ * @return {Promise} The promise from the statement network call.
+ */
+CourseCmi5Plugin.prototype.videoCompleted = function (videoObj) {
+ if (!this.cmi5) {
+ return Promise.resolve(null);
+ }
+
+ let stmt = this._videoMakeStatement(videoObj, VERB_VIDEO_COMPLETED_OBJ);
+ return this.sendStatement(stmt);
+}
+
+/**
+ * Initialize the current video watching session.
+ *
+ * @param {Object} videoObj The learner details about the video element interaction.
+ * @return {Promise} The promise from the statement network call.
+ */
+CourseCmi5Plugin.prototype.videoInitialize = function (videoObj) {
+ if (!this.cmi5) {
+ return Promise.resolve(null);
+ }
+
+ // A video that's been loaded must be marked as initialized for the (video) session, exactly once.
+ let stmt = this._videoMakeStatement(videoObj, VERB_VIDEO_INITIALIZED_OBJ);
+ return this.sendStatement(stmt);
+}
+
+/**
+ * Mark the current video being watched as paused.
+ *
+ * @param {Object} videoObj The learner details about the video element interaction.
+ * @return {Promise} The promise from the statement network call.
+ */
+CourseCmi5Plugin.prototype.videoPause = function (videoObj) {
+ if (!this.cmi5) {
+ return Promise.resolve(null);
+ }
+
+ let stmt = this._videoMakeStatement(videoObj, VERB_VIDEO_PAUSED_OBJ);
+ return this.sendStatement(stmt);
+}
+
+/**
+ * Mark the current video being watched as played.
+ *
+ * @param {Object} videoObj The learner details about the video element interaction.
+ * @return {Promise} The promise from the statement network call.
+ */
+CourseCmi5Plugin.prototype.videoPlay = function (videoObj) {
+ if (!this.cmi5) {
+ return Promise.resolve(null);
+ }
+
+ let stmt = this._videoMakeStatement(videoObj, VERB_VIDEO_PLAYED_OBJ);
+ return this.sendStatement(stmt);
+}
+
+/**
+ * Answers if the current state of the course should save cmi5 statements to a backend in the general case.
+ *
+ * @param {Object=} opts
+ * @return {boolean} true if the tracking plugin should save statements, false otherwise.
+ * @private
+ */
+CourseCmi5Plugin.prototype._shouldSendStatement = function (opts) {
+ if (!opts) {
+ opts = {};
+ }
+ if (!opts.forceSend) {
+ opts.forceSend = false;
+ }
+ return this.launchMode.toLowerCase() === "normal" || opts.forceSend;
+}
+
+/**
+ * If there are buffered statements waiting to be sent, begin sending them.
+ */
+CourseCmi5Plugin.prototype.flushBatch = function () {
+ return this._sendStatements(this.statementBatch);
+}
+
+/**
+ * Buffer a cmi5 statement to be sent in the future.
+ *
+ * @param {Object} statement A cmi5 statement to save.
+ * @param {Object=} opts
+ * @return {Promise} Does nothing, provided for matching interface with sendStatement.
+ */
+CourseCmi5Plugin.prototype.batchStatement = function (statement, opts) {
+ this.statementBatch.push(statement);
+ return Promise.resolve(null);
+}
+
+/**
+ * Buffer cmi5 statements to be sent in the future.
+ *
+ * @param {Array} statements cmi5 statements to save.
+ * @param {Object=} opts
+ * @return {Promise} Does nothing, provided for matching interface with sendStatement.
+ */
+CourseCmi5Plugin.prototype.batchStatements = function (statements, opts) {
+ this.statementBatch = this.statementBatch.concat(statements);
+ return Promise.resolve(null);
+}
+
+/**
+ * Send a cmi5 statement to the LRS.
+ *
+ * @param {Object} statement A cmi5 statement to save.
+ * @param {Object=} opts Configuration around the sending parameters of a cmi5 statement.
+ * @return {Promise} The network call attempting to save the cmi5 statement.
+ */
+CourseCmi5Plugin.prototype.sendStatement = function (statement, opts) {
+ if (!opts) {
+ opts = {};
+ }
+ if (!opts.forceSend) {
+ opts.forceSend = false;
+ }
+ if (!opts.hasOwnProperty("queue")) {
+ opts.queue = false;
+ }
+ // forceSend sends the statement even if we're in browse/review mode. Normally should only be used
+ // for initialized/terminated statements.
+ if (this._shouldSendStatement(opts)) {
+ if (opts.queue) {
+ return this.batchStatement(statement, opts);
+ } else {
+ return this._sendStatement(statement);
+ }
+ }
+ return Promise.resolve(null);
+}
+
+/**
+ * Send a list of cmi5 statements to the LRS.
+ *
+ * @param {Array} statements A cmi5 statement to save.
+ * @param {Object=} opts Configuration around the sending parameters of a cmi5 statement.
+ * @return {Promise} The network call attempting to save the cmi5 statement.
+ */
+CourseCmi5Plugin.prototype.sendStatements = function (statements, opts) {
+ if (!opts) {
+ opts = {};
+ }
+ if (!opts.forceSend) {
+ opts.forceSend = false;
+ }
+ if (!opts.hasOwnProperty("queue")) {
+ opts.queue = false;
+ }
+ // forceSend sends the statement even if we're in browse/review mode. Normally should only be used
+ // for initialized/terminated statements.
+ if (this._shouldSendStatement(opts)) {
+ if (opts.queue) {
+ return this.batchStatements(statements, opts);
+ } else {
+ return this._sendStatements(statements);
+ }
+ }
+ return Promise.resolve(null);
+}
+
+/**
+ * Statement tracker sending statements and waiting for statement network calls to end, responsible for updating the
+ * main course adapter whenever there's a state change.
+ *
+ * @param {Object} statement A cmi5 statement to save.
+ * @return {Promise} The network call attempting to save the cmi5 statement.
+ * @private
+ */
+CourseCmi5Plugin.prototype._sendStatement = function (statement) {
+ this.activeStatements += 1;
+ this.callbackOnStatementSend(null, null, this.activeStatements);
+ return this.cmi5.sendStatement(statement).then(result => {
+ this.activeStatements -= 1;
+ if (this.callbackOnStatementSend) {
+ this.callbackOnStatementSend(result, null, this.activeStatements);
+ }
+ }).catch(error => {
+ this.activeStatements -= 1;
+ if (this.callbackOnStatementSend) {
+ this.callbackOnStatementSend(null, error, this.activeStatements);
+ }
+ })
+}
+
+/**
+ * Statement tracker sending statements and waiting for statement network calls to end, responsible for updating the
+ * main course adapter whenever there's a state change.
+ *
+ * @param {Array} statements A list of cmi5 statements to save.
+ * @return {Promise} The network call attempting to save the cmi5 statement.
+ * @private
+ */
+CourseCmi5Plugin.prototype._sendStatements = function (statements) {
+ this.activeStatements += 1;
+ this.callbackOnStatementSend(null, null, this.activeStatements);
+ return this.cmi5.sendStatements(statements).then(result => {
+ this.activeStatements -= 1;
+ if (this.callbackOnStatementSend) {
+ this.callbackOnStatementSend(result, null, this.activeStatements);
+ }
+ }).catch(error => {
+ this.activeStatements -= 1;
+ if (this.callbackOnStatementSend) {
+ this.callbackOnStatementSend(null, error, this.activeStatements);
+ }
+ })
+}
+
+/**
+ * Some statements in the cmi5 library used by this tracking plugin are themselves function calls that manage their
+ * own sending state. Use these functions instead of directly sending statements here, but still manage the same
+ * callbacks and communications as done in `_sendStatement`.
+ *
+ * @param {Function} statementFn A function call that should trigger a statement save.
+ * @return {Promise} The network call attempting to save the cmi5 statement.
+ * @private
+ */
+CourseCmi5Plugin.prototype._sendStatementViaLibFunction = function (statementFn) {
+ this.activeStatements += 1;
+ this.callbackOnStatementSend(null, null, this.activeStatements);
+ return statementFn().then(result => {
+ this.activeStatements -= 1;
+ if (this.callbackOnStatementSend) {
+ this.callbackOnStatementSend(result, null, this.activeStatements);
+ }
+ }).catch(error => {
+ this.activeStatements -= 1;
+ if (this.callbackOnStatementSend) {
+ this.callbackOnStatementSend(null, error, this.activeStatements);
+ }
+ })
+}
diff --git a/KST-ASD-BI-101/style/base.css b/KST-ASD-BI-101/style/base.css
new file mode 100644
index 0000000..f89a3af
--- /dev/null
+++ b/KST-ASD-BI-101/style/base.css
@@ -0,0 +1,436 @@
+/*
+ Copyright 2021 Rustici Software
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+html, body {
+ padding: 0;
+ margin: 0;
+ border: 0;
+ font-family: Arial, sans-serif;
+ font-size: 16px;
+ line-height: 1.5;
+}
+
+main {
+ position: relative;
+}
+
+.navigation {
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 225px;
+ background-color: #f6f6f6;
+ box-shadow: inset -2rem 0 2rem -2rem rgb(0 0 0 / 40%);
+ overflow-y: auto;
+ user-select: none;
+}
+
+.navcorner {
+ background-size: cover;
+ background-position: 50% 50%;
+ background-repeat: no-repeat;
+ width: 100%;
+ height: 150px;
+
+ color: white;
+ text-align: center;
+ font-size: 24px;
+ font-weight: bold;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.navtitle {
+ text-shadow: 3px 3px 6px #000000;
+}
+
+.navtoggle {
+ display: none;
+ border: 1px solid black;
+ border-radius: 3px;
+ background-color: lightgray;
+ position: fixed;
+ top: 5px;
+ left: 10px;
+ z-index: 110;
+ cursor: pointer;
+}
+
+.navmenu ul {
+ list-style: none;
+ padding: 0;
+}
+
+.navmenu li {
+ margin: 0;
+ padding: 1em;
+}
+
+.navmenu li.current {
+ border-left: 5px solid #ddb0b0 !important;
+ background-color: #ffd0d0 !important;
+ box-shadow: inset -2rem 0 2rem -2rem rgb(0 0 0 / 40%);
+}
+
+.navmenu li.seen {
+ border-left: 5px solid #99ff99;
+}
+
+.navmenu li:hover {
+ background-color: #e6e6e6;
+ box-shadow: inset -2rem 0 2rem -2rem rgb(0 0 0 / 40%);
+}
+
+.navexit {
+ position: absolute;
+ top: 5px;
+ right: 10px;
+}
+
+.footer-progress {
+ font-size: small;
+ color: #856404;
+ background-color: #fff3cd;
+ border: 1px solid #ffeeba;
+ text-align: center;
+ padding: 10px;
+}
+
+.content {
+ padding-left: 225px;
+}
+
+@media (max-width: 620px) {
+ .navtoggle {
+ display: block;
+ }
+
+ .navigation {
+ display: none;
+ }
+
+ .navigation.visible-narrow {
+ display: block;
+ }
+
+ .content {
+ padding-left: 0;
+ }
+}
+
+.homebar {
+ background-color: #eee;
+ text-align: center;
+ font-size: 16px;
+ font-weight: bold;
+ padding: 1em;
+ cursor: pointer;
+}
+
+.footerbar {
+ background-color: #eee;
+ text-align: center;
+ font-size: 16px;
+ font-weight: bold;
+ padding: 1em;
+ cursor: pointer;
+}
+
+.title {
+ font-size: 24px;
+ font-weight: bold;
+ padding: 2em 0 2em 4em;
+}
+
+.titleunderbar {
+ background-color: #8b4513;
+ margin-top: 2em;
+ height: 5px;
+ width: 200px;
+}
+
+.body-content {
+ margin: 2em 6em;
+}
+
+.body-content2col {
+ margin: 2em 6em;
+ columns: 2;
+ column-gap: 3em;
+}
+
+.bodyimg {
+ position: relative;
+ max-height: 60rem;
+ min-height: 15rem;
+ width: 100%;
+ overflow: hidden;
+ background-repeat: no-repeat;
+ background-position: 50%;
+ background-size: cover;
+}
+
+.bodyimg img {
+ opacity: 0;
+ max-height: 100%;
+ max-width: 100%;
+}
+
+.body-video {
+ margin: 2em 6em;
+}
+
+@media (max-width: 620px) {
+ .title {
+ padding-left: 35px;
+ }
+
+ .body-content {
+ margin: 2em 2em;
+ }
+
+ .body-content2col {
+ margin: 2em 2em;
+ }
+
+ .body-video {
+ margin: 2em 2em;
+ }
+}
+
+.body-video video {
+ width: 100%;
+}
+
+.changePage {
+ -webkit-animation: flash 0.5s ease 1;
+ -moz-animation: flash 0.5s ease 1;
+ -ms-animation: flash 0.5s ease 1;
+ -o-animation: flash 0.5s ease 1;
+ animation: flash 0.5s ease 1;
+}
+
+@keyframes flash {
+ 100% {
+ opacity: 0;
+ }
+}
+
+input, select {
+ vertical-align: middle;
+ padding: .375rem .75rem;
+ font-size: 1rem;
+ line-height: 1.5;
+ color: #495057;
+ background-color: #fff;
+ background-clip: padding-box;
+ border: 1px solid #ced4da;
+ border-radius: .25rem;
+ transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
+}
+
+select {
+ min-width: 10rem;
+}
+
+select:not([size]):not([multiple]) {
+ height: calc(2.25rem + 2px);
+}
+
+input:focus, select:focus {
+ color: #495057;
+ background-color: #fff;
+ border-color: #80bdff;
+ outline: 0;
+ box-shadow: 0 0 0 0.2rem rgb(0 123 255 / 25%);
+}
+
+.full-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: none;
+ background-color: #00000077;
+ justify-content: center;
+ align-items: center;
+}
+
+.full-overlay.full-overlay-visible {
+ display: flex;
+}
+
+.full-overlay .message-content {
+ width: 500px;
+ height: 200px;
+ border: 3px solid black;
+ background-color: white;
+ justify-content: center;
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.quiz .title-contents {
+ display: flex;
+}
+
+.quiz .title-contents-heading {
+ flex: 1;
+}
+
+.quiz .title-contents-buttons {
+ text-align: right;
+}
+
+.quiz label {
+ display: block;
+}
+
+.quiz .radiochoices label {
+ display: inline;
+ padding-top: 0.25em;
+}
+
+.quiz .radiochoices .radiochoice {
+ display: flex;
+ align-items: center;
+}
+
+.quiz .radiochoices input[type=radio] {
+ margin: 0 0.5em 0.02em 0;
+}
+
+.text-attribution {
+ color: gray;
+}
+
+.video-start-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #00000077;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.video-start-overlay .msg {
+ border: 4px solid #ffd0d0;
+ border-radius: 5px;
+ padding: 10px;
+ background-color: white;
+ font-weight: bold;
+}
+
+.video-complete-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #777777;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: default;
+}
+
+.video-complete-overlay .msg {
+ border: 4px solid #ffd0d0;
+ border-radius: 5px;
+ padding: 10px;
+ background-color: white;
+ font-weight: bold;
+}
+
+.video-start-overlay.hidden, .video-complete-overlay.hidden {
+ display: none;
+}
+
+.video-attribution {
+ color: gray;
+ font-size: small;
+ text-align: center;
+ padding-bottom: 0.25em;
+}
+
+.info-bar {
+ display: none;
+ position: fixed;
+ right: 0;
+ left: 0;
+ white-space: nowrap;
+ height: 25px;
+ z-index: 99;
+ color: #856404;
+ background-color: #fff3cd;
+ border: 1px solid #ffeeba;
+ text-align: center;
+}
+
+body.info-bar-visible .info-bar {
+ display: block;
+}
+
+body.info-bar-visible .navigation {
+ top: 27px;
+}
+
+body.info-bar-visible .content {
+ padding-top: 27px;
+}
+
+.score-passed, .score-failed {
+ display: none;
+}
+
+.score-passed.visible, .score-failed.visible {
+ display: block;
+}
+
+.warning-floater {
+ position: absolute;
+ top: 5px;
+ right: 5px;
+ z-index: 102;
+}
+
+.warning-icon {
+ display: none;
+ cursor: pointer;
+}
+
+.warning-icon.warning-icon-visible {
+ display: block;
+}
+
+.warning-icon-container {
+ background-color: pink;
+ border: 1px solid red;
+ border-radius: 5px;
+ padding: 4px;
+ color: red;
+ font-weight: bold;
+}
diff --git a/KST-ASD-BI-101/video/mountains_31175.mp4 b/KST-ASD-BI-101/video/mountains_31175.mp4
new file mode 100644
index 0000000..4d2ceee
Binary files /dev/null and b/KST-ASD-BI-101/video/mountains_31175.mp4 differ