@openstax/ts-utils 1.39.0 → 1.40.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.
@@ -0,0 +1,24 @@
1
+ type PromiseInvoker<T> = () => Promise<T>;
2
+ type UnwrapPromiseInvoker<T> = T extends PromiseInvoker<infer U> ? U : never;
3
+ type UnwrapPromiseInvokers<T extends readonly PromiseInvoker<any>[]> = {
4
+ [K in keyof T]: UnwrapPromiseInvoker<T[K]>;
5
+ };
6
+ /**
7
+ * Executes a tuple of promise invokers with a concurrency limit.
8
+ * Similar to Promise.all but limits the number of in-flight promises.
9
+ *
10
+ * @param invokers Tuple of functions that return promises
11
+ * @param concurrency Maximum number of promises to execute concurrently
12
+ * @returns Promise that resolves with a tuple of results in the same order as the invokers
13
+ * @throws Rejects with the first error encountered (similar to Promise.all behavior)
14
+ *
15
+ * @example
16
+ * const results = await asyncPool([
17
+ * () => fetch('/api/1').then(r => r.json() as Promise<number>),
18
+ * () => fetch('/api/2').then(r => r.text()),
19
+ * () => fetch('/api/3').then(r => r.json() as Promise<boolean>),
20
+ * ], 2);
21
+ * // results has type [number, string, boolean]
22
+ */
23
+ export declare const asyncPool: <T extends readonly PromiseInvoker<any>[]>(invokers: T, concurrency: number) => Promise<UnwrapPromiseInvokers<T>>;
24
+ export {};
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Executes a tuple of promise invokers with a concurrency limit.
3
+ * Similar to Promise.all but limits the number of in-flight promises.
4
+ *
5
+ * @param invokers Tuple of functions that return promises
6
+ * @param concurrency Maximum number of promises to execute concurrently
7
+ * @returns Promise that resolves with a tuple of results in the same order as the invokers
8
+ * @throws Rejects with the first error encountered (similar to Promise.all behavior)
9
+ *
10
+ * @example
11
+ * const results = await asyncPool([
12
+ * () => fetch('/api/1').then(r => r.json() as Promise<number>),
13
+ * () => fetch('/api/2').then(r => r.text()),
14
+ * () => fetch('/api/3').then(r => r.json() as Promise<boolean>),
15
+ * ], 2);
16
+ * // results has type [number, string, boolean]
17
+ */
18
+ export const asyncPool = async (invokers, concurrency) => {
19
+ if (concurrency <= 0) {
20
+ throw new Error('Concurrency must be greater than 0');
21
+ }
22
+ const results = new Array(invokers.length);
23
+ let rejected = false;
24
+ let currentIndex = 0;
25
+ let completedCount = 0;
26
+ return new Promise((resolve, reject) => {
27
+ const executeNext = () => {
28
+ if (completedCount === invokers.length) {
29
+ resolve(results);
30
+ return;
31
+ }
32
+ while (currentIndex < invokers.length && (currentIndex - completedCount) < concurrency) {
33
+ const index = currentIndex++;
34
+ const invoker = invokers[index];
35
+ invoker()
36
+ .then((result) => {
37
+ if (rejected) {
38
+ return;
39
+ }
40
+ results[index] = result;
41
+ completedCount++;
42
+ executeNext();
43
+ })
44
+ .catch((error) => {
45
+ if (!rejected) {
46
+ rejected = true;
47
+ reject(error);
48
+ }
49
+ });
50
+ }
51
+ };
52
+ executeNext();
53
+ });
54
+ };
@@ -1,4 +1,4 @@
1
- export type HashValue = string | number | boolean | null | HashCompoundValue;
1
+ export type HashValue = undefined | string | number | boolean | null | HashCompoundValue;
2
2
  export type HashCompoundValue = Array<HashValue> | {
3
3
  [key: string]: HashValue;
4
4
  };
@@ -5,9 +5,10 @@ import { createHash } from 'crypto';
5
5
  * @example hashValue({someKey: 'someValue'})
6
6
  */
7
7
  export const hashValue = (value) => {
8
+ var _a;
8
9
  // hack for sorting keys https://stackoverflow.com/a/53593328/14809536
9
10
  const allKeys = new Set();
10
11
  JSON.stringify(value, (k, v) => (allKeys.add(k), v));
11
- const strValue = JSON.stringify(value, Array.from(allKeys).sort());
12
+ const strValue = (_a = JSON.stringify(value, Array.from(allKeys).sort())) !== null && _a !== void 0 ? _a : 'undefined';
12
13
  return createHash('sha1').update(strValue).digest('hex');
13
14
  };
@@ -187,6 +187,22 @@ type RequestResponder<Sa, Ri, Ro> = {
187
187
  logger: Logger;
188
188
  }) => RoF): (request: Ri) => RoF;
189
189
  };
190
+ export type BatchItem<Ro> = {
191
+ id: string;
192
+ makeRequest: () => undefined | Ro;
193
+ };
194
+ export type BatchHandler<Sa, Ri, Ro> = (services: CompatibleServices<Sa>) => ({
195
+ isBatchRequest: (request: Ri) => boolean;
196
+ extractBatchRequests: (request: Ri) => {
197
+ id: string;
198
+ request: Ri;
199
+ }[];
200
+ responseMiddleware?: (app: Sa) => (response: Ro | undefined, request: {
201
+ request: Ri;
202
+ logger: Logger;
203
+ }) => Ro;
204
+ composeBatchResponse: (items: BatchItem<Ro>[]) => Ro;
205
+ });
190
206
  /**
191
207
  * A factory factory for creating request responders (functions that take a request and return a
192
208
  * response -- these functions let us implement Lambda `handler` functions).
@@ -217,12 +233,13 @@ type RequestResponder<Sa, Ri, Ro> = {
217
233
  * );
218
234
  * ```
219
235
  */
220
- export declare const makeGetRequestResponder: <Sa, Ru, Ri, Ro>() => ({ routes, pathExtractor, routeMatcher, errorHandler, logExtractor }: {
236
+ export declare const makeGetRequestResponder: <Sa, Ru, Ri, Ro>() => ({ routes, pathExtractor, routeMatcher, errorHandler, logExtractor, batch }: {
221
237
  routes: () => AnySpecificRoute<Ru, Sa, Ri, Ro>[];
222
238
  pathExtractor: RequestPathExtractor<Ri>;
223
239
  logExtractor?: RequestLogExtractor<Ri>;
224
240
  routeMatcher?: RequestRouteMatcher<Ri, AnySpecificRoute<Ru, Sa, Ri, Ro>>;
225
241
  errorHandler?: (e: Error, logger: Logger) => Ro;
242
+ batch?: BatchHandler<Sa, Ri, Ro>;
226
243
  }) => RequestResponder<Sa, Ri, Ro>;
227
244
  /** HTTP Headers */
228
245
  export type HttpHeaders = {
@@ -150,11 +150,13 @@ const bindRoute = (services, appBinder, pathExtractor, matcher) => (route) => {
150
150
  * );
151
151
  * ```
152
152
  */
153
- export const makeGetRequestResponder = () => ({ routes, pathExtractor, routeMatcher, errorHandler, logExtractor }) => (services, responseMiddleware) => {
153
+ export const makeGetRequestResponder = () => ({ routes, pathExtractor, routeMatcher, errorHandler, logExtractor, batch }) => (services, responseMiddleware) => {
154
154
  const appBinderImpl = (app, middleware) => middleware(app, appBinder);
155
155
  const appBinder = memoize(appBinderImpl);
156
156
  const boundRoutes = routes().map(bindRoute(services, appBinder, pathExtractor, routeMatcher));
157
157
  const boundResponseMiddleware = responseMiddleware ? responseMiddleware(services) : undefined;
158
+ const boundBatch = batch ? batch(services) : undefined;
159
+ const boundBatchResponseMiddleware = (boundBatch === null || boundBatch === void 0 ? void 0 : boundBatch.responseMiddleware) ? boundBatch.responseMiddleware(services) : undefined;
158
160
  const appLogger = services.logger || createConsoleLogger();
159
161
  // *note* this opaque promise guard is less generic than i hoped so
160
162
  // i'm leaving it here instead of the guards file.
@@ -164,42 +166,58 @@ export const makeGetRequestResponder = () => ({ routes, pathExtractor, routeMatc
164
166
  // promise or a non-promise value and the promise figures it out, but those
165
167
  // types are getting complicated quickly here.
166
168
  const isPromise = (thing) => thing instanceof Promise;
167
- return (request) => {
168
- const logger = appLogger.createSubContext();
169
- if (logExtractor) {
170
- logger.setContext(logExtractor(request));
171
- }
172
- logger.log('begin request');
169
+ const requestResponder = (request, logger) => {
170
+ const boundErrorHandler = errorHandler ? (e) => errorHandler(e, logger) : undefined;
173
171
  try {
174
172
  const route = mapFind(boundRoutes, (route) => route(request, logger));
175
173
  if (route) {
176
174
  logger.log(`route matched ${route.name}`);
177
- const result = boundResponseMiddleware ?
178
- boundResponseMiddleware(route.executor(), { request, logger }) : route.executor();
179
- if (isPromise(result) && errorHandler) {
180
- const errorHandlerWithMiddleware = (e) => boundResponseMiddleware ?
181
- boundResponseMiddleware(errorHandler(e, logger), { request, logger }) : errorHandler(e, logger);
182
- return result.catch(errorHandlerWithMiddleware);
175
+ const result = route.executor();
176
+ if (isPromise(result) && boundErrorHandler) {
177
+ return result.catch(boundErrorHandler);
183
178
  }
184
179
  else {
185
180
  return result;
186
181
  }
187
182
  }
188
- else if (boundResponseMiddleware) {
189
- logger.log('no route matched, returning 404');
190
- return boundResponseMiddleware(undefined, { request, logger });
191
- }
192
183
  }
193
184
  catch (e) {
194
- if (errorHandler && e instanceof Error) {
195
- return boundResponseMiddleware
196
- ? boundResponseMiddleware(errorHandler(e, logger), { request, logger })
197
- : errorHandler(e, logger);
185
+ if (boundErrorHandler && e instanceof Error) {
186
+ return boundErrorHandler(e);
198
187
  }
199
188
  throw e;
200
189
  }
201
190
  return undefined;
202
191
  };
192
+ return (request) => {
193
+ const logger = appLogger.createSubContext();
194
+ if (logExtractor)
195
+ logger.setContext(logExtractor(request));
196
+ logger.log('begin request');
197
+ let response = undefined;
198
+ if (boundBatch === null || boundBatch === void 0 ? void 0 : boundBatch.isBatchRequest(request)) {
199
+ const batchRequests = boundBatch.extractBatchRequests(request);
200
+ const batchResponses = [];
201
+ for (const batchRequest of batchRequests) {
202
+ const subRequestLogger = logger.createSubContext();
203
+ subRequestLogger.setContext({ batchRequestId: batchRequest.id });
204
+ if (logExtractor)
205
+ subRequestLogger.setContext(logExtractor(batchRequest.request));
206
+ batchResponses.push({
207
+ id: batchRequest.id,
208
+ makeRequest: () => {
209
+ const batchItemResponse = requestResponder(batchRequest.request, subRequestLogger);
210
+ return boundBatchResponseMiddleware ? boundBatchResponseMiddleware(batchItemResponse, { request: batchRequest.request, logger: subRequestLogger }) : batchItemResponse;
211
+ }
212
+ });
213
+ }
214
+ response = boundBatch.composeBatchResponse(batchResponses);
215
+ }
216
+ else {
217
+ response = requestResponder(request, logger);
218
+ }
219
+ return boundResponseMiddleware ? boundResponseMiddleware(response, { request, logger }) : response;
220
+ };
203
221
  };
204
222
  /**
205
223
  * Returns a JSON response. Handles serializing the data to JSON and setting the content-type header.
@@ -0,0 +1,13 @@
1
+ import { APIGatewayProxyEventV2 } from 'aws-lambda';
2
+ import { Logger } from '../services/logger';
3
+ import { ApiResponse, BatchHandler } from '.';
4
+ type Ro = Promise<ApiResponse<number, any>>;
5
+ export declare const lambdaBatchRequestHandler: <Sa, Ri extends APIGatewayProxyEventV2>(config: {
6
+ batchPath: string;
7
+ concurrency: number;
8
+ responseMiddleware?: (app: Sa) => (response: Ro | undefined, request: {
9
+ request: Ri;
10
+ logger: Logger;
11
+ }) => Ro;
12
+ }) => BatchHandler<Sa, Ri, Ro>;
13
+ export {};
@@ -0,0 +1,67 @@
1
+ import { z } from 'zod';
2
+ import { asyncPool } from '../misc/asyncPool';
3
+ import { getRequestBody } from './helpers';
4
+ import { apiJsonResponse } from '.';
5
+ const BatchItemSchema = z.object({
6
+ id: z.string(),
7
+ path: z.string(),
8
+ method: z.string(),
9
+ queryParams: z.record(z.string(), z.string()).optional(),
10
+ body: z.string().optional(),
11
+ headers: z.record(z.string(), z.string()).optional(),
12
+ });
13
+ const BatchRequestSchema = z.object({
14
+ requests: z.array(BatchItemSchema),
15
+ });
16
+ export const lambdaBatchRequestHandler = (config) => {
17
+ const { batchPath, concurrency, responseMiddleware } = config;
18
+ return () => ({
19
+ responseMiddleware,
20
+ isBatchRequest: (request) => {
21
+ return request.requestContext.http.path === batchPath;
22
+ },
23
+ extractBatchRequests: (request) => {
24
+ // Parse and validate payload
25
+ const payload = getRequestBody(request);
26
+ const parsed = BatchRequestSchema.parse(payload);
27
+ // Create modified requests for each batch item
28
+ return parsed.requests.map(item => {
29
+ var _a;
30
+ return ({
31
+ id: item.id,
32
+ request: {
33
+ ...request,
34
+ requestContext: {
35
+ ...request.requestContext,
36
+ http: {
37
+ ...request.requestContext.http,
38
+ path: item.path,
39
+ method: item.method,
40
+ }
41
+ },
42
+ headers: { ...request.headers, ...item.headers },
43
+ body: (_a = item.body) !== null && _a !== void 0 ? _a : request.body,
44
+ rawQueryString: item.queryParams
45
+ ? new URLSearchParams(item.queryParams).toString()
46
+ : request.rawQueryString,
47
+ }
48
+ });
49
+ });
50
+ },
51
+ composeBatchResponse: async (items) => {
52
+ const results = await asyncPool(items.map(({ id, makeRequest }) => async () => {
53
+ const response = await makeRequest();
54
+ return { id, response };
55
+ }), concurrency);
56
+ const data = {
57
+ results: results.map(({ id, response }) => ({
58
+ id,
59
+ statusCode: (response === null || response === void 0 ? void 0 : response.statusCode) || 500,
60
+ body: (response === null || response === void 0 ? void 0 : response.body) || '',
61
+ headers: (response === null || response === void 0 ? void 0 : response.headers) || {}
62
+ }))
63
+ };
64
+ return apiJsonResponse(200, data);
65
+ }
66
+ });
67
+ };
@@ -24,10 +24,12 @@ export const subrequestAuthProvider = (initializer) => (configProvider) => {
24
24
  return undefined;
25
25
  }
26
26
  const user = await loadUserData(initializer.fetch, await accountsBase(), resolvedCookieName, token);
27
- if (user) {
27
+ // this returns `{"error_id":null}` when the token is invalid
28
+ if (user.uuid) {
28
29
  logger.setContext({ user: user.uuid });
30
+ return user;
29
31
  }
30
- return user;
32
+ return undefined;
31
33
  };
32
34
  const getUser = async () => {
33
35
  if (!user) {
@@ -1,3 +1,3 @@
1
1
  import { ApiUser } from '..';
2
2
  import { GenericFetch } from '../../../fetch';
3
- export declare const loadUserData: (fetch: GenericFetch, accountsBase: string, cookieName: string, token: string) => Promise<ApiUser | undefined>;
3
+ export declare const loadUserData: (fetch: GenericFetch, accountsBase: string, cookieName: string, token: string) => Promise<ApiUser>;
@@ -2,6 +2,7 @@
2
2
  import { createHash } from 'crypto';
3
3
  import { createVerifier, httpbis } from 'http-message-signatures';
4
4
  import { SigningKeyNotFoundError } from 'jwks-rsa';
5
+ import { ByteSequence, parseDictionary, parseItem, Token } from 'structured-headers';
5
6
  import { assertString } from '../../assertions';
6
7
  import { resolveConfigValue } from '../../config';
7
8
  import { InvalidRequestError } from '../../errors';
@@ -28,9 +29,25 @@ export const createHttpMessageVerifier = ({ configSpace, fetcher }) => (configPr
28
29
  // but we do not bother matching the Signature-Agent names with the Signature names
29
30
  // as that would be awkward with the packages we use
30
31
  const signatureAgentString = (_a = headers['signature-agent']) !== null && _a !== void 0 ? _a : '';
31
- const signatureAgentMatches = [...signatureAgentString.matchAll(/([^=]+)="([^"]+)"/g)];
32
- const signatureAgents = signatureAgentMatches.length == 0 ?
33
- [signatureAgentString] : [...new Set(signatureAgentMatches.map((match) => match[2]))];
32
+ let rawValues;
33
+ // Try parsing as RFC 8941 Item first (e.g., "https://url" or bare token)
34
+ try {
35
+ rawValues = [parseItem(signatureAgentString)];
36
+ }
37
+ catch {
38
+ // If item parsing fails, try parsing as a Dictionary (e.g., sig="https://url")
39
+ rawValues = Array.from(parseDictionary(signatureAgentString).values());
40
+ }
41
+ // Convert values to strings (handle Token, string, and reject others) and deduplicate
42
+ const signatureAgents = [...new Set(rawValues.map(([bareItem]) => {
43
+ if (bareItem instanceof Token) {
44
+ return bareItem.toString();
45
+ }
46
+ return assertString(bareItem, new InvalidRequestError('Signature-Agent values must be strings or tokens'));
47
+ }))];
48
+ if (signatureAgents.length === 0) {
49
+ throw new InvalidRequestError('Signature-Agent header is required');
50
+ }
34
51
  const keys = (await Promise.all(signatureAgents.map(async (signatureAgent) => {
35
52
  if (!await signatureAgentVerifier(signatureAgent)) {
36
53
  throw new InvalidRequestError('Signature-Agent verification failed');
@@ -75,14 +92,37 @@ export const createHttpMessageVerifier = ({ configSpace, fetcher }) => (configPr
75
92
  // For example, if a GET request uses configOverride to make content-digest not required
76
93
  if (!headers['content-digest'])
77
94
  return true;
78
- const match = headers['content-digest'].match(/^(sha-256|sha-512)=:([^:]+):/);
79
- if (!match)
95
+ let contentDigest;
96
+ try {
97
+ contentDigest = parseDictionary(headers['content-digest']);
98
+ }
99
+ catch {
80
100
  throw new InvalidRequestError('Unsupported Content-Digest header format');
81
- const contentDigestAlg = match[1];
82
- const contentDigestHash = match[2];
83
- const calculatedContentDigestHash = createHash(contentDigestAlg).update(assertString(body)).digest('base64');
84
- if (calculatedContentDigestHash !== contentDigestHash.replace(/:/g, '')) {
85
- throw new InvalidRequestError('Calculated Content-Digest value did not match header');
101
+ }
102
+ if (contentDigest.size === 0) {
103
+ throw new InvalidRequestError('Content-Digest header is required');
104
+ }
105
+ // Map Content-Digest algorithm names to Node's crypto algorithm names
106
+ const algorithmMap = {
107
+ 'sha-256': 'sha256',
108
+ 'sha-512': 'sha512',
109
+ };
110
+ for (const [algorithm, value] of contentDigest.entries()) {
111
+ const [bareItem] = value;
112
+ // Convert ByteSequence to string, or assert it's already a string
113
+ const hashValue = bareItem instanceof ByteSequence
114
+ ? bareItem.toBase64()
115
+ : assertString(bareItem, new InvalidRequestError('Content-Digest values must be strings'));
116
+ const cryptoAlgorithm = algorithmMap[algorithm];
117
+ if (!cryptoAlgorithm) {
118
+ throw new InvalidRequestError(`Unsupported Content-Digest algorithm: ${algorithm}`);
119
+ }
120
+ // Calculate the hash
121
+ const calculatedHash = createHash(cryptoAlgorithm).update(assertString(body, new InvalidRequestError('Request body is required when Content-Digest is present'))).digest('base64');
122
+ // Compare with the provided hash
123
+ if (calculatedHash !== hashValue) {
124
+ throw new InvalidRequestError(`Calculated Content-Digest value did not match header for algorithm ${algorithm}`);
125
+ }
86
126
  }
87
127
  return true;
88
128
  },