@openstax/ts-utils 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/cjs/config/envConfig.d.ts +2 -2
  2. package/dist/cjs/config/envConfig.js +13 -6
  3. package/dist/cjs/config/resolveConfigValue.d.ts +1 -1
  4. package/dist/cjs/errors/index.js +4 -4
  5. package/dist/cjs/services/authProvider/browser.js +27 -9
  6. package/dist/cjs/services/authProvider/decryption.js +6 -1
  7. package/dist/cjs/services/authProvider/utils/decryptAndVerify.d.ts +20 -4
  8. package/dist/cjs/services/authProvider/utils/decryptAndVerify.js +74 -62
  9. package/dist/cjs/services/authProvider/utils/embeddedAuthProvider.d.ts +6 -2
  10. package/dist/cjs/services/authProvider/utils/embeddedAuthProvider.js +10 -3
  11. package/dist/cjs/services/documentStore/unversioned/dynamodb.d.ts +1 -0
  12. package/dist/cjs/services/documentStore/unversioned/dynamodb.js +19 -12
  13. package/dist/cjs/services/documentStore/unversioned/file-system.d.ts +1 -0
  14. package/dist/cjs/services/documentStore/unversioned/file-system.js +4 -0
  15. package/dist/cjs/services/exercisesGateway/index.js +2 -1
  16. package/dist/cjs/services/fileServer/index.d.ts +17 -0
  17. package/dist/cjs/services/fileServer/index.js +19 -0
  18. package/dist/cjs/services/fileServer/localFileServer.d.ts +13 -0
  19. package/dist/cjs/services/fileServer/localFileServer.js +23 -0
  20. package/dist/cjs/services/fileServer/s3FileServer.d.ts +16 -0
  21. package/dist/cjs/services/fileServer/s3FileServer.js +25 -0
  22. package/dist/cjs/tsconfig.without-specs.cjs.tsbuildinfo +1 -1
  23. package/dist/esm/config/envConfig.d.ts +2 -2
  24. package/dist/esm/config/envConfig.js +12 -5
  25. package/dist/esm/config/resolveConfigValue.d.ts +1 -1
  26. package/dist/esm/errors/index.js +2 -2
  27. package/dist/esm/services/authProvider/browser.js +27 -9
  28. package/dist/esm/services/authProvider/decryption.js +6 -1
  29. package/dist/esm/services/authProvider/utils/decryptAndVerify.d.ts +20 -4
  30. package/dist/esm/services/authProvider/utils/decryptAndVerify.js +72 -36
  31. package/dist/esm/services/authProvider/utils/embeddedAuthProvider.d.ts +6 -2
  32. package/dist/esm/services/authProvider/utils/embeddedAuthProvider.js +10 -3
  33. package/dist/esm/services/documentStore/unversioned/dynamodb.d.ts +1 -0
  34. package/dist/esm/services/documentStore/unversioned/dynamodb.js +20 -13
  35. package/dist/esm/services/documentStore/unversioned/file-system.d.ts +1 -0
  36. package/dist/esm/services/documentStore/unversioned/file-system.js +4 -0
  37. package/dist/esm/services/exercisesGateway/index.js +2 -1
  38. package/dist/esm/services/fileServer/index.d.ts +17 -0
  39. package/dist/esm/services/fileServer/index.js +13 -0
  40. package/dist/esm/services/fileServer/localFileServer.d.ts +13 -0
  41. package/dist/esm/services/fileServer/localFileServer.js +16 -0
  42. package/dist/esm/services/fileServer/s3FileServer.d.ts +16 -0
  43. package/dist/esm/services/fileServer/s3FileServer.js +21 -0
  44. package/dist/esm/tsconfig.without-specs.esm.tsbuildinfo +1 -1
  45. package/package.json +19 -3
@@ -1,4 +1,4 @@
1
- import { ConfigValueProvider } from '.';
1
+ import type { ConfigValueProvider } from '.';
2
2
  /**
3
3
  * A list of environment variables that were requested at build time. Used by webpack to
4
4
  * capture build-time environment variables values.
@@ -21,4 +21,4 @@ export declare const ENV_BUILD_CONFIGS: string[];
21
21
  *
22
22
  * @example const config = { configValue: envConfig('environment_variable_name') };
23
23
  */
24
- export declare const envConfig: (name: string, type?: 'build' | 'runtime', defaultValue?: ConfigValueProvider<string> | undefined) => ConfigValueProvider<string>;
24
+ export declare const envConfig: (name: string, type?: "build" | "runtime" | undefined, defaultValue?: ConfigValueProvider<string> | undefined) => ConfigValueProvider<string>;
@@ -1,4 +1,4 @@
1
- import { resolveConfigValue } from '.';
1
+ import { resolveConfigValue } from './resolveConfigValue';
2
2
  /**
3
3
  * A list of environment variables that were requested at build time. Used by webpack to
4
4
  * capture build-time environment variables values.
@@ -21,7 +21,12 @@ export const ENV_BUILD_CONFIGS = [];
21
21
  *
22
22
  * @example const config = { configValue: envConfig('environment_variable_name') };
23
23
  */
24
- export const envConfig = (name, type = 'build', defaultValue) => {
24
+ export const envConfig = (name, type, defaultValue) => {
25
+ // this doesn't use a default parameter value because of a:
26
+ // "Regular parameters should not come after default parameters."
27
+ // error that occurs when the defaultValue optional default of `undefined`
28
+ // gets optimized out, causing a problem in cloudfront functions.
29
+ type !== null && type !== void 0 ? type : (type = 'build');
25
30
  if (type === 'build') {
26
31
  ENV_BUILD_CONFIGS.push(name);
27
32
  }
@@ -30,8 +35,10 @@ export const envConfig = (name, type = 'build', defaultValue) => {
30
35
  // @ts-ignore - hack to get around the way webpack/define works
31
36
  // - https://github.com/webpack/webpack/issues/14800
32
37
  // - https://github.com/webpack/webpack/issues/5392
33
- const envs = { ...process.env, ...(typeof __PROCESS_ENV !== 'undefined' ? __PROCESS_ENV : {}) };
34
- if (envs[name] === undefined) {
38
+ // also, spread operator not supported in cloudfront functions
39
+ const envs = Object.assign({}, process.env, typeof __PROCESS_ENV !== 'undefined' ? __PROCESS_ENV : {});
40
+ const value = envs[name];
41
+ if (value === undefined) {
35
42
  if (defaultValue === undefined) {
36
43
  throw new Error(`expected to find environment variable with name: ${name}`);
37
44
  }
@@ -40,7 +47,7 @@ export const envConfig = (name, type = 'build', defaultValue) => {
40
47
  }
41
48
  }
42
49
  else {
43
- return envs[name];
50
+ return value;
44
51
  }
45
52
  };
46
53
  };
@@ -1,4 +1,4 @@
1
- import { ConfigValueProvider } from '.';
1
+ import type { ConfigValueProvider } from '.';
2
2
  /**
3
3
  * resolves a config value into a string, to be used inside of things that are provided configurations
4
4
  */
@@ -10,8 +10,8 @@
10
10
  * error instanceof InvalidRequestError
11
11
  *
12
12
  */
13
- const errorIsType = ({ TYPE }) => (e) => e instanceof Error
14
- && e.constructor.TYPE === TYPE;
13
+ const errorIsType = (t) => (e) => e instanceof Error
14
+ && e.constructor.TYPE === t.TYPE;
15
15
  /**
16
16
  * Returns true if the error is defined in this library
17
17
  */
@@ -1,5 +1,7 @@
1
+ import { isEqual } from 'lodash';
1
2
  import { once } from '../..';
2
3
  import { resolveConfigValue } from '../../config';
4
+ import { UnauthorizedError } from '../../errors';
3
5
  import { ifDefined } from '../../guards';
4
6
  import { unsafePayloadValidator } from '../../routing/helpers';
5
7
  import { embeddedAuthProvider, PostMessageTypes } from './utils/embeddedAuthProvider';
@@ -9,14 +11,14 @@ export const browserAuthProvider = ({ window, configSpace }) => (configProvider)
9
11
  const accountsBase = once(() => resolveConfigValue(config.accountsBase));
10
12
  const queryString = window.location.search;
11
13
  const queryKey = 'auth';
12
- const authQuery = new URLSearchParams(queryString).get(queryKey);
14
+ const urlSearchParams = new URLSearchParams(queryString);
15
+ const authQuery = urlSearchParams.get(queryKey);
13
16
  const referrer = window.document.referrer ? new URL(window.document.referrer) : undefined;
14
17
  const isEmbedded = window.parent !== window;
15
18
  const trustedParent = isEmbedded && referrer && referrer.hostname.match(/^(openstax\.org|((.*)(\.openstax\.org|local|localhost)))$/) ? referrer : undefined;
16
- const { embeddedQueryValue, getAuthorizedEmbedUrl } = embeddedAuthProvider(() => getUserData(), { queryKey, window });
17
- let userData = {
18
- token: [null, embeddedQueryValue].includes(authQuery) ? null : authQuery
19
- };
19
+ const { embeddedQueryKey, embeddedQueryValue, getAuthorizedEmbedUrl } = embeddedAuthProvider(() => getUserData(), { authQuery: { key: queryKey, value: authQuery }, window });
20
+ const embeddedQuery = urlSearchParams.get(embeddedQueryKey);
21
+ let userData = { token: authQuery };
20
22
  const getAuthToken = async () => {
21
23
  return (await getUserData()).token;
22
24
  };
@@ -32,6 +34,9 @@ export const browserAuthProvider = ({ window, configSpace }) => (configProvider)
32
34
  if (authQuery) {
33
35
  url.searchParams.set(queryKey, authQuery);
34
36
  }
37
+ if (embeddedQuery) {
38
+ url.searchParams.set(embeddedQueryKey, embeddedQuery);
39
+ }
35
40
  return url.href;
36
41
  };
37
42
  // *note* that this does not actually prevent cookies from being sent on same-origin
@@ -83,10 +88,23 @@ export const browserAuthProvider = ({ window, configSpace }) => (configProvider)
83
88
  throw new Error(`Error response from Accounts ${response.status}: ${message}`);
84
89
  };
85
90
  const getUserData = once(async () => {
86
- userData = authQuery === embeddedQueryValue
87
- ? await getParentWindowUser()
88
- : await getFetchUser();
89
- return userData;
91
+ // For backwards compatibility
92
+ if (authQuery === 'embedded') {
93
+ return getParentWindowUser();
94
+ }
95
+ // getFetchUser() will throw here if authQuery is not set
96
+ if (embeddedQuery !== embeddedQueryValue) {
97
+ return getFetchUser();
98
+ }
99
+ const userDataFromParentWindow = await getParentWindowUser();
100
+ if (!authQuery) {
101
+ return userDataFromParentWindow;
102
+ }
103
+ const userDataFromAuthQuery = await getFetchUser();
104
+ if (isEqual(userDataFromAuthQuery, userDataFromParentWindow)) {
105
+ return userDataFromAuthQuery;
106
+ }
107
+ throw new UnauthorizedError();
90
108
  });
91
109
  const getUser = async () => {
92
110
  return (await getUserData()).user;
@@ -1,4 +1,5 @@
1
1
  import { resolveConfigValue } from '../../config/resolveConfigValue';
2
+ import { SessionExpiredError } from '../../errors';
2
3
  import { ifDefined } from '../../guards';
3
4
  import { once } from '../../misc/helpers';
4
5
  import { decryptAndVerify } from './utils/decryptAndVerify';
@@ -22,7 +23,11 @@ export const decryptionAuthProvider = (initializer) => (configProvider) => {
22
23
  if (!token) {
23
24
  return undefined;
24
25
  }
25
- return decryptAndVerify(token, await encryptionPrivateKey(), await signaturePublicKey());
26
+ const result = decryptAndVerify(token, await encryptionPrivateKey(), await signaturePublicKey());
27
+ if ('error' in result && result.error == 'expired token') {
28
+ throw new SessionExpiredError();
29
+ }
30
+ return 'user' in result ? result.user : undefined;
26
31
  };
27
32
  return {
28
33
  getAuthorizedFetchConfig,
@@ -1,11 +1,27 @@
1
- import { User } from '..';
1
+ /// <reference types="node" />
2
+ import type { User } from '..';
3
+ export declare const decryptJwe: (jwe: string, encryptionPrivateKey: Buffer | string) => string | undefined;
4
+ declare type MaybeAccountsSSOToken = {
5
+ iss?: string;
6
+ sub?: User | string;
7
+ aud?: string;
8
+ exp?: number;
9
+ nbf?: number;
10
+ iat?: number;
11
+ jti?: string;
12
+ };
13
+ export declare const verifyJws: (jws: string, signaturePublicKey: Buffer | string) => MaybeAccountsSSOToken | undefined;
2
14
  /**
3
15
  * Decrypts and verifies a SSO cookie.
4
16
  *
5
17
  * @param token the encrypted token
6
18
  * @param encryptionPrivateKey the private key used to encrypt the token
7
19
  * @param signaturePublicKey the public key used to verify the decrypted token
8
- * @throws SessionExpiredError if the token is expired
9
- * @returns User (success) or undefined (failure)
20
+ * @returns {user: User} (success) or {error: string} (failure)
10
21
  */
11
- export declare const decryptAndVerify: (token: string, encryptionPrivateKey: string, signaturePublicKey: string) => User | undefined;
22
+ export declare const decryptAndVerify: (token: string, encryptionPrivateKey: string, signaturePublicKey: string) => {
23
+ user: User;
24
+ } | {
25
+ error: string;
26
+ };
27
+ export {};
@@ -1,22 +1,55 @@
1
- import * as crypto from 'crypto';
2
- import { TextEncoder } from 'util';
3
- import jwt from 'jsonwebtoken';
4
- import { SessionExpiredError } from '../../../errors';
1
+ import { createDecipheriv, verify } from 'crypto';
5
2
  import { isPlainObject } from '../../../guards';
6
- const decrypt = (input, key) => {
7
- const splitInput = input.split('.');
8
- const aad = new TextEncoder().encode(splitInput[0]);
9
- const iv = Buffer.from(splitInput[2], 'base64');
10
- const cipherText = Buffer.from(splitInput[3], 'base64');
11
- const tag = Buffer.from(splitInput[4], 'base64');
12
- const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(key), iv, { authTagLength: 16 });
3
+ export const decryptJwe = (jwe, encryptionPrivateKey) => {
4
+ const jweParts = jwe.split('.', 6);
5
+ if (jweParts.length !== 5 || jweParts[1]) {
6
+ return undefined;
7
+ } // Invalid/unsupported JWE
8
+ const header = JSON.parse(Buffer.from(jweParts[0], 'base64url').toString());
9
+ if (header.alg !== 'dir' || header.enc !== 'A256GCM') {
10
+ // Unsupported signature/encryption algorithm
11
+ return undefined;
12
+ }
13
+ const aad = Buffer.from(jweParts[0]);
14
+ const iv = Buffer.from(jweParts[2], 'base64url');
15
+ const cipherText = Buffer.from(jweParts[3], 'base64url');
16
+ const authTag = Buffer.from(jweParts[4], 'base64url');
17
+ // Verify token signature and decrypt
18
+ const decipher = createDecipheriv('aes-256-gcm', encryptionPrivateKey, iv, { authTagLength: 16 });
13
19
  decipher.setAAD(aad, { plaintextLength: cipherText.length });
14
- decipher.setAuthTag(tag);
15
- const result = Buffer.concat([
16
- decipher.update(cipherText),
17
- decipher.final(),
18
- ]);
19
- return result.toString('utf-8');
20
+ try {
21
+ decipher.setAuthTag(authTag);
22
+ return `${decipher.update(cipherText)}${decipher.final()}`;
23
+ }
24
+ catch (error) {
25
+ // Invalid cipherText or authTag
26
+ return undefined;
27
+ }
28
+ };
29
+ const issuer = 'OpenStax Accounts';
30
+ const audience = 'OpenStax';
31
+ const clockTolerance = 300; // 5 minutes
32
+ export const verifyJws = (jws, signaturePublicKey) => {
33
+ const jwsParts = jws.split('.', 4);
34
+ if (jwsParts.length !== 3) {
35
+ return undefined;
36
+ } // Invalid JWS
37
+ const header = JSON.parse(Buffer.from(jwsParts[0], 'base64url').toString());
38
+ if (header.alg !== 'RS256' || header.typ !== 'JWT') {
39
+ return undefined;
40
+ } // Unsupported JWS
41
+ const signedContent = Buffer.from(`${jwsParts[0]}.${jwsParts[1]}`);
42
+ const signature = Buffer.from(jwsParts[2], 'base64url');
43
+ if (!verify('RSA-SHA256', signedContent, signaturePublicKey, signature)) {
44
+ return undefined;
45
+ }
46
+ const payload = Buffer.from(jwsParts[1], 'base64url').toString();
47
+ try {
48
+ return JSON.parse(payload);
49
+ }
50
+ catch (error) {
51
+ return undefined;
52
+ }
20
53
  };
21
54
  /**
22
55
  * Decrypts and verifies a SSO cookie.
@@ -24,26 +57,29 @@ const decrypt = (input, key) => {
24
57
  * @param token the encrypted token
25
58
  * @param encryptionPrivateKey the private key used to encrypt the token
26
59
  * @param signaturePublicKey the public key used to verify the decrypted token
27
- * @throws SessionExpiredError if the token is expired
28
- * @returns User (success) or undefined (failure)
60
+ * @returns {user: User} (success) or {error: string} (failure)
29
61
  */
30
62
  export const decryptAndVerify = (token, encryptionPrivateKey, signaturePublicKey) => {
31
- try {
32
- // Decrypt SSO cookie
33
- const plaintext = decrypt(token, encryptionPrivateKey);
34
- const payload = jwt.verify(plaintext, signaturePublicKey, {
35
- clockTolerance: 300 // 5 minutes
36
- });
37
- if (!isPlainObject(payload) || !isPlainObject(payload.sub) || !payload.sub.uuid) {
38
- return undefined;
39
- }
40
- // TS is confused because the library types the `sub` as a string
41
- return payload.sub;
42
- }
43
- catch (err) {
44
- if (err instanceof jwt.TokenExpiredError) {
45
- throw new SessionExpiredError();
46
- }
47
- return undefined;
63
+ const timestamp = Math.floor(Date.now() / 1000);
64
+ const jws = decryptJwe(token, encryptionPrivateKey);
65
+ if (!jws) {
66
+ return { error: 'invalid token' };
67
+ }
68
+ const payload = verifyJws(jws, signaturePublicKey);
69
+ // Ensure payload contains all the claims we expect
70
+ // Normally "sub" would be a string but Accounts uses an object for it instead
71
+ if (!isPlainObject(payload) ||
72
+ !isPlainObject(payload.sub) || !payload.sub.uuid ||
73
+ payload.iss !== issuer ||
74
+ payload.aud !== audience ||
75
+ !payload.exp ||
76
+ !payload.nbf || payload.nbf > timestamp + clockTolerance ||
77
+ !payload.iat || payload.iat > timestamp + clockTolerance ||
78
+ !payload.jti) {
79
+ return { error: 'invalid token' };
80
+ }
81
+ if (payload.exp < timestamp - clockTolerance) {
82
+ return { error: 'expired token' };
48
83
  }
84
+ return { user: payload.sub };
49
85
  };
@@ -9,10 +9,14 @@ export declare enum PostMessageTypes {
9
9
  ReceiveUser = "receive-user",
10
10
  RequestUser = "request-user"
11
11
  }
12
- export declare const embeddedAuthProvider: (getUserData: UserDataLoader, { queryKey, window }: {
13
- queryKey?: string | undefined;
12
+ export declare const embeddedAuthProvider: (getUserData: UserDataLoader, { authQuery, window }: {
13
+ authQuery?: {
14
+ key: string;
15
+ value: string | null;
16
+ } | undefined;
14
17
  window: Window;
15
18
  }) => {
19
+ embeddedQueryKey: string;
16
20
  embeddedQueryValue: string;
17
21
  getAuthorizedEmbedUrl: (urlString: string, extraParams?: {
18
22
  [key: string]: string;
@@ -4,9 +4,10 @@ export var PostMessageTypes;
4
4
  PostMessageTypes["ReceiveUser"] = "receive-user";
5
5
  PostMessageTypes["RequestUser"] = "request-user";
6
6
  })(PostMessageTypes || (PostMessageTypes = {}));
7
- export const embeddedAuthProvider = (getUserData, { queryKey = 'auth', window }) => {
7
+ export const embeddedAuthProvider = (getUserData, { authQuery, window }) => {
8
8
  const trustedEmbeds = new Set();
9
- const embeddedQueryValue = 'embedded';
9
+ const embeddedQueryKey = 'embedded';
10
+ const embeddedQueryValue = 'true';
10
11
  const messageHandler = event => {
11
12
  if (event.data.type === PostMessageTypes.RequestUser && trustedEmbeds.has(event.origin)) {
12
13
  getUserData().then(data => {
@@ -19,10 +20,16 @@ export const embeddedAuthProvider = (getUserData, { queryKey = 'auth', window })
19
20
  const url = new URL(urlString);
20
21
  trustedEmbeds.add(url.origin);
21
22
  const params = queryString.parse(url.search);
22
- url.search = queryString.stringify({ ...params, ...extraParams, [queryKey]: embeddedQueryValue });
23
+ url.search = queryString.stringify({
24
+ ...params,
25
+ ...extraParams,
26
+ ...(authQuery && authQuery.value ? { [authQuery.key]: authQuery.value } : {}),
27
+ [embeddedQueryKey]: embeddedQueryValue
28
+ });
23
29
  return url.href;
24
30
  };
25
31
  return {
32
+ embeddedQueryKey,
26
33
  embeddedQueryValue,
27
34
  getAuthorizedEmbedUrl,
28
35
  unmount: () => {
@@ -7,6 +7,7 @@ export declare const dynamoUnversionedDocumentStore: <C extends string = "dynamo
7
7
  tableName: import("../../../config").ConfigValueProvider<string>;
8
8
  }; }) => <K extends keyof T>(_: {}, hashKey: K) => {
9
9
  loadAllDocumentsTheBadWay: () => Promise<T[]>;
10
+ batchGetItem: (ids: T[K][]) => Promise<T[]>;
10
11
  getItem: (id: T[K]) => Promise<T | undefined>;
11
12
  putItem: (item: T) => Promise<T>;
12
13
  };
@@ -1,4 +1,4 @@
1
- import { DynamoDB, PutItemCommand, QueryCommand, ScanCommand } from '@aws-sdk/client-dynamodb';
1
+ import { BatchGetItemCommand, DynamoDB, GetItemCommand, PutItemCommand, ScanCommand } from '@aws-sdk/client-dynamodb';
2
2
  import { once } from '../../..';
3
3
  import { resolveConfigValue } from '../../../config';
4
4
  import { ifDefined } from '../../../guards';
@@ -10,10 +10,10 @@ export const dynamoUnversionedDocumentStore = (initializer) => () => (configProv
10
10
  return {
11
11
  loadAllDocumentsTheBadWay: async () => {
12
12
  const loadAllResults = async (ExclusiveStartKey) => {
13
- var _a;
13
+ var _a, _b;
14
14
  const cmd = new ScanCommand({ TableName: await tableName(), ExclusiveStartKey });
15
15
  const result = await dynamodb().send(cmd);
16
- const resultItems = ((_a = result.Items) === null || _a === void 0 ? void 0 : _a.map(decodeDynamoDocument)) || [];
16
+ const resultItems = (_b = (_a = result.Items) === null || _a === void 0 ? void 0 : _a.map((item) => decodeDynamoDocument(item))) !== null && _b !== void 0 ? _b : [];
17
17
  if (result.LastEvaluatedKey) {
18
18
  return [...resultItems, ...await loadAllResults(result.LastEvaluatedKey)];
19
19
  }
@@ -21,19 +21,26 @@ export const dynamoUnversionedDocumentStore = (initializer) => () => (configProv
21
21
  };
22
22
  return loadAllResults();
23
23
  },
24
+ batchGetItem: async (ids) => {
25
+ const table = await tableName();
26
+ const key = hashKey.toString();
27
+ const getBatches = async (requestItems) => {
28
+ const cmd = new BatchGetItemCommand({
29
+ RequestItems: requestItems !== null && requestItems !== void 0 ? requestItems : { [table]: { Keys: ids.map((id) => ({ [key]: encodeDynamoAttribute(id) })) } },
30
+ });
31
+ const response = await dynamodb().send(cmd);
32
+ const currentResponses = response.Responses ?
33
+ response.Responses[table].map(response => decodeDynamoDocument(response)) : [];
34
+ return currentResponses.concat(response.UnprocessedKeys ? await getBatches(response.UnprocessedKeys) : []);
35
+ };
36
+ return getBatches();
37
+ },
24
38
  getItem: async (id) => {
25
- const cmd = new QueryCommand({
39
+ const cmd = new GetItemCommand({
40
+ Key: { [hashKey.toString()]: encodeDynamoAttribute(id) },
26
41
  TableName: await tableName(),
27
- KeyConditionExpression: '#hk = :hkv',
28
- ExpressionAttributeNames: { '#hk': hashKey.toString() },
29
- ExpressionAttributeValues: { ':hkv': encodeDynamoAttribute(id) },
30
- ScanIndexForward: false,
31
- Limit: 1
32
- });
33
- return dynamodb().send(cmd).then(result => {
34
- var _a;
35
- return (_a = result.Items) === null || _a === void 0 ? void 0 : _a.map(decodeDynamoDocument)[0];
36
42
  });
43
+ return dynamodb().send(cmd).then(result => result.Item ? decodeDynamoDocument(result.Item) : undefined);
37
44
  },
38
45
  /* saves a new version of a document with the given data */
39
46
  putItem: async (item) => {
@@ -9,6 +9,7 @@ export declare const fileSystemUnversionedDocumentStore: <C extends string = "fi
9
9
  tableName: import("../../../config").ConfigValueProvider<string>;
10
10
  }; }) => <K extends keyof T>(_: {}, hashKey: K) => {
11
11
  loadAllDocumentsTheBadWay: () => Promise<T[]>;
12
+ batchGetItem: (ids: T[K][]) => Promise<Exclude<Awaited<T>, undefined>[]>;
12
13
  getItem: (id: T[K]) => Promise<T | undefined>;
13
14
  putItem: (item: T) => Promise<T>;
14
15
  };
@@ -39,6 +39,10 @@ export const fileSystemUnversionedDocumentStore = (initializer) => () => (config
39
39
  Promise.all(files.map((file) => load(file)))
40
40
  .then((allData) => resolve(allData.filter(isDefined)), (err) => reject(err))));
41
41
  },
42
+ batchGetItem: async (ids) => {
43
+ const items = await Promise.all(ids.map((id) => load(hashFilename(id))));
44
+ return items.filter(isDefined);
45
+ },
42
46
  getItem: (id) => load(hashFilename(id)),
43
47
  putItem: async (item) => {
44
48
  const path = await filePath(hashFilename(item[hashKey]));
@@ -25,7 +25,8 @@ export const exercisesGateway = (initializer) => (configProvider) => {
25
25
  question.collaborator_solutions = [
26
26
  { solution_type: 'detailed', images: [], content_html: defaultHint }
27
27
  ];
28
- for (const [index, answer] of question.answers.entries()) {
28
+ for (let index = 0; index < question.answers.length; index++) {
29
+ const answer = question.answers[index];
29
30
  answer.correctness = defaultCorrectIndex === index ? '1.0' : '0.0';
30
31
  answer.feedback_html = defaultCorrectIndex === index ? 'This is the good one!' : defaultHint;
31
32
  }
@@ -0,0 +1,17 @@
1
+ /// <reference types="node" />
2
+ export declare type FileValue = {
3
+ dataType: 'file';
4
+ mimeType: string;
5
+ path: string;
6
+ label: string;
7
+ };
8
+ export declare type FolderValue = {
9
+ dataType: 'folder';
10
+ files: FileValue[];
11
+ };
12
+ export declare const isFileValue: (thing: any) => thing is FileValue;
13
+ export declare const isFolderValue: (thing: any) => thing is FolderValue;
14
+ export interface FileServerAdapter {
15
+ getFileContent: (source: FileValue) => Promise<Buffer>;
16
+ }
17
+ export declare const isFileOrFolder: (thing: any) => thing is FileValue | FolderValue;
@@ -0,0 +1,13 @@
1
+ import { isPlainObject } from '../../guards';
2
+ export const isFileValue = (thing) => isPlainObject(thing)
3
+ && Object.keys(thing).every(key => ['dataType', 'path', 'label', 'mimeType'].includes(key))
4
+ && thing.dataType === 'file'
5
+ && typeof thing.mimeType === 'string'
6
+ && typeof thing.path === 'string'
7
+ && typeof thing.label === 'string';
8
+ export const isFolderValue = (thing) => isPlainObject(thing)
9
+ && Object.keys(thing).every(key => ['dataType', 'files'].includes(key))
10
+ && thing.dataType === 'folder'
11
+ && thing.files instanceof Array
12
+ && thing.files.every(isFileValue);
13
+ export const isFileOrFolder = (thing) => isFileValue(thing) || isFolderValue(thing);
@@ -0,0 +1,13 @@
1
+ import { ConfigProviderForConfig } from '../../config';
2
+ import { FileServerAdapter } from '.';
3
+ export declare type Config = {
4
+ storagePrefix: string;
5
+ };
6
+ interface Initializer<C> {
7
+ dataDir: string;
8
+ configSpace?: C;
9
+ }
10
+ export declare const localFileServer: <C extends string = "local">(initializer: Initializer<C>) => (configProvider: { [key in C]: {
11
+ storagePrefix: import("../../config").ConfigValueProvider<string>;
12
+ }; }) => FileServerAdapter;
13
+ export {};
@@ -0,0 +1,16 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { resolveConfigValue } from '../../config';
4
+ import { ifDefined } from '../../guards';
5
+ export const localFileServer = (initializer) => (configProvider) => {
6
+ const config = configProvider[ifDefined(initializer.configSpace, 'local')];
7
+ const storagePrefix = resolveConfigValue(config.storagePrefix);
8
+ const fileDir = storagePrefix.then((prefix) => path.join(initializer.dataDir, prefix));
9
+ const getFileContent = async (source) => {
10
+ const filePath = path.join(await fileDir, source.path);
11
+ return fs.promises.readFile(filePath);
12
+ };
13
+ return {
14
+ getFileContent,
15
+ };
16
+ };
@@ -0,0 +1,16 @@
1
+ import { S3Client } from '@aws-sdk/client-s3';
2
+ import { ConfigProviderForConfig } from '../../config';
3
+ import { FileServerAdapter } from '.';
4
+ export declare type Config = {
5
+ bucketName: string;
6
+ bucketRegion: string;
7
+ };
8
+ interface Initializer<C> {
9
+ configSpace?: C;
10
+ s3Client?: typeof S3Client;
11
+ }
12
+ export declare const s3FileServer: <C extends string = "deployed">(initializer: Initializer<C>) => (configProvider: { [key in C]: {
13
+ bucketName: import("../../config").ConfigValueProvider<string>;
14
+ bucketRegion: import("../../config").ConfigValueProvider<string>;
15
+ }; }) => FileServerAdapter;
16
+ export {};
@@ -0,0 +1,21 @@
1
+ import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
2
+ import { once } from '../..';
3
+ import { assertDefined } from '../../assertions';
4
+ import { resolveConfigValue } from '../../config';
5
+ import { ifDefined } from '../../guards';
6
+ export const s3FileServer = (initializer) => (configProvider) => {
7
+ const config = configProvider[ifDefined(initializer.configSpace, 'deployed')];
8
+ const bucketName = once(() => resolveConfigValue(config.bucketName));
9
+ const bucketRegion = once(() => resolveConfigValue(config.bucketRegion));
10
+ const client = ifDefined(initializer.s3Client, S3Client);
11
+ const s3Service = once(async () => new client({ apiVersion: '2012-08-10', region: await bucketRegion() }));
12
+ const getFileContent = async (source) => {
13
+ const bucket = await bucketName();
14
+ const command = new GetObjectCommand({ Bucket: bucket, Key: source.path });
15
+ const response = await (await s3Service()).send(command);
16
+ return Buffer.from(await assertDefined(response.Body, new Error('Invalid Response from s3')).transformToByteArray());
17
+ };
18
+ return {
19
+ getFileContent,
20
+ };
21
+ };