diff --git a/ui/shared/lti/jquery/__tests__/platform_storage.test.js b/ui/shared/lti/jquery/__tests__/platform_storage.test.js
new file mode 100644
index 00000000000..0e2d0496ed6
--- /dev/null
+++ b/ui/shared/lti/jquery/__tests__/platform_storage.test.js
@@ -0,0 +1,182 @@
+/*
+ * Copyright (C) 2021 - present Instructure, Inc.
+ *
+ * This file is part of Canvas.
+ *
+ * Canvas is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU Affero General Public License as published by the Free
+ * Software Foundation, version 3 of the License.
+ *
+ * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ * details.
+ *
+ * You should have received a copy of the GNU Affero General Public License along
+ * with this program. If not, see .
+ */
+
+import {
+ addToLimit,
+ clearData,
+ clearLimit,
+ getData,
+ getLimit,
+ putData,
+ removeFromLimit
+} from '../platform_storage'
+
+describe('platform_storage', () => {
+ const tool_id = 'tool_id'
+ const key = 'key'
+ const value = 'value'
+
+ beforeEach(() => {
+ clearLimit(tool_id)
+ })
+
+ describe('getLimit', () => {
+ it('defaults counts to 0', () => {
+ const limit = getLimit('tool that does not have limit already')
+ expect(limit.keyCount).toBe(0)
+ expect(limit.charCount).toBe(0)
+ })
+ })
+
+ describe('addToLimit', () => {
+ it('increments key count for tool id', () => {
+ const before = {...getLimit(tool_id)}
+ addToLimit(tool_id, key, value)
+ const after = getLimit(tool_id)
+ expect(after.keyCount).toEqual(before.keyCount + 1)
+ })
+
+ it('adds key and value length to char count for tool', () => {
+ const before = {...getLimit(tool_id)}
+ addToLimit(tool_id, key, value)
+ const after = getLimit(tool_id)
+ expect(after.charCount).toEqual(before.charCount + key.length + value.length)
+ })
+
+ describe('when tool has reached key count limit', () => {
+ beforeEach(() => {
+ getLimit(tool_id).keyCount = 500
+ })
+
+ it('throws a storage_exhaustion error', () => {
+ expect(() => addToLimit(tool_id, key, value)).toThrow('Reached key limit')
+ })
+ })
+
+ describe('when tool has reached char count limit', () => {
+ beforeEach(() => {
+ getLimit(tool_id).charCount = 4096
+ })
+
+ it('throws a storage_exhaustion error', () => {
+ expect(() => addToLimit(tool_id, key, value)).toThrow('Reached byte limit')
+ })
+ })
+ })
+
+ describe('removeFromLimit', () => {
+ beforeEach(() => {
+ addToLimit(tool_id, 'hello', 'world')
+ addToLimit(tool_id, key, value)
+ })
+
+ it('decrements key count for tool id', () => {
+ const before = {...getLimit(tool_id)}
+ removeFromLimit(tool_id, key, value)
+ const after = getLimit(tool_id)
+ expect(after.keyCount).toEqual(before.keyCount - 1)
+ })
+
+ it('removes key and value length from char count for tool', () => {
+ const before = {...getLimit(tool_id)}
+ removeFromLimit(tool_id, key, value)
+ const after = getLimit(tool_id)
+ expect(after.charCount).toEqual(before.charCount - key.length - value.length)
+ })
+
+ describe('when key count goes below 0', () => {
+ beforeEach(() => {
+ removeFromLimit(tool_id, key, value)
+ removeFromLimit(tool_id, key, value)
+ removeFromLimit(tool_id, key, value)
+ })
+
+ it('resets key count to 0', () => {
+ const {keyCount} = getLimit(tool_id)
+ expect(keyCount).toBe(0)
+ })
+ })
+
+ describe('when char count goes below 0', () => {
+ beforeEach(() => {
+ removeFromLimit(tool_id, key, value)
+ removeFromLimit(tool_id, key, value)
+ removeFromLimit(tool_id, key, value)
+ })
+
+ it('resets char count to 0', () => {
+ const {charCount} = getLimit(tool_id)
+ expect(charCount).toBe(0)
+ })
+ })
+ })
+
+ describe('putData', () => {
+ beforeEach(() => {
+ jest.spyOn(window.localStorage, 'setItem')
+ })
+
+ it('namespaces key with tool id', () => {
+ putData(tool_id, key, value)
+ expect(window.localStorage.setItem).toHaveBeenCalledWith(
+ `lti|platform_storage|${tool_id}|${key}`,
+ value
+ )
+ })
+ })
+
+ describe('getData', () => {
+ beforeEach(() => {
+ jest.spyOn(window.localStorage, 'getItem')
+ putData(tool_id, key, value)
+ })
+
+ it('namespaces key with tool id', () => {
+ getData(tool_id, key)
+ expect(window.localStorage.getItem).toHaveBeenCalledWith(
+ `lti|platform_storage|${tool_id}|${key}`
+ )
+ })
+ })
+
+ describe('clearData', () => {
+ beforeEach(() => {
+ jest.spyOn(window.localStorage, 'removeItem')
+ })
+
+ describe('when key does not exist', () => {
+ it('does nothing', () => {
+ expect(window.localStorage.removeItem).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('when key is already stored', () => {
+ beforeEach(() => {
+ jest.spyOn(window.localStorage, 'getItem').mockImplementation(() => value)
+ putData(tool_id, key, value)
+ })
+
+ it('namespaces key with tool id', () => {
+ clearData(tool_id, key)
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith(
+ `lti|platform_storage|${tool_id}|${key}`
+ )
+ })
+ })
+ })
+})
diff --git a/ui/shared/lti/jquery/platform_storage.js b/ui/shared/lti/jquery/platform_storage.js
new file mode 100644
index 00000000000..cb067aa1f80
--- /dev/null
+++ b/ui/shared/lti/jquery/platform_storage.js
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2021 - present Instructure, Inc.
+ *
+ * This file is part of Canvas.
+ *
+ * Canvas is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU Affero General Public License as published by the Free
+ * Software Foundation, version 3 of the License.
+ *
+ * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ * details.
+ *
+ * You should have received a copy of the GNU Affero General Public License along
+ * with this program. If not, see .
+ */
+
+const STORAGE_CHAR_LIMIT = 4096 // IMS minimum storage limit is 4096 bytes so 4096 chars is more than enough
+const STORAGE_KEY_LIMIT = 500
+
+const limits = {}
+
+const createLimit = tool_id => {
+ if (!limits[tool_id]) {
+ limits[tool_id] = {keyCount: 0, charCount: 0}
+ }
+}
+
+const getLimit = tool_id => {
+ createLimit(tool_id)
+ return limits[tool_id]
+}
+
+const clearLimit = tool_id => {
+ delete limits[tool_id]
+}
+
+const addToLimit = (tool_id, key, value) => {
+ createLimit(tool_id)
+ const length = key.length + value.length
+
+ if (limits[tool_id].keyCount >= STORAGE_KEY_LIMIT) {
+ const e = new Error('Reached key limit for tool')
+ e.code = 'storage_exhaustion'
+ throw e
+ }
+
+ if (limits[tool_id].charCount + length > STORAGE_CHAR_LIMIT) {
+ const e = new Error('Reached byte limit for tool')
+ e.code = 'storage_exhaustion'
+ throw e
+ }
+
+ limits[tool_id].keyCount++
+ limits[tool_id].charCount += length
+}
+
+const removeFromLimit = (tool_id, key, value) => {
+ limits[tool_id].keyCount--
+ limits[tool_id].charCount -= key.length + value.length
+
+ if (limits[tool_id].keyCount < 0) {
+ limits[tool_id].keyCount = 0
+ }
+ if (limits[tool_id].charCount < 0) {
+ limits[tool_id].charCount = 0
+ }
+}
+
+const getKey = (tool_id, key) => `lti|platform_storage|${tool_id}|${key}`
+
+const putData = (tool_id, key, value) => {
+ addToLimit(tool_id, key, value)
+ window.localStorage.setItem(getKey(tool_id, key), value)
+}
+
+const getData = (tool_id, key) => window.localStorage.getItem(getKey(tool_id, key))
+
+const clearData = (tool_id, key) => {
+ const value = getData(tool_id, key)
+ if (value) {
+ removeFromLimit(tool_id, key, value)
+ window.localStorage.removeItem(getKey(tool_id, key))
+ }
+}
+
+export {getLimit, clearLimit, addToLimit, removeFromLimit, clearData, getData, putData}