quidproquo-webserver 0.1.1 → 0.1.3

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 (43) hide show
  1. package/lib/commonjs/actionProcessor/api/getHttpApiEventAutoRespondActionProcessor.js +20 -0
  2. package/lib/commonjs/config/QPQConfig.d.ts +2 -1
  3. package/lib/commonjs/config/QPQConfig.js +1 -0
  4. package/lib/commonjs/config/settings/api.d.ts +2 -0
  5. package/lib/commonjs/config/settings/api.js +1 -0
  6. package/lib/commonjs/config/settings/fileUploadSettings.d.ts +24 -0
  7. package/lib/commonjs/config/settings/fileUploadSettings.js +14 -0
  8. package/lib/commonjs/config/settings/index.d.ts +1 -0
  9. package/lib/commonjs/config/settings/index.js +1 -0
  10. package/lib/commonjs/config/settings/serviceFunction.d.ts +2 -0
  11. package/lib/commonjs/config/settings/serviceFunction.js +1 -0
  12. package/lib/commonjs/config/settings/websocket.d.ts +2 -0
  13. package/lib/commonjs/config/settings/websocket.js +1 -0
  14. package/lib/commonjs/services/webSocketQueue/logic/webSocket/messageProcessors/askProcessOnAuthenticate.js +27 -20
  15. package/lib/commonjs/types/HTTPEvent.d.ts +12 -0
  16. package/lib/commonjs/types/HTTPEvent.js +9 -0
  17. package/lib/commonjs/utils/httpEventUtils.d.ts +8 -0
  18. package/lib/commonjs/utils/httpEventUtils.js +25 -1
  19. package/lib/commonjs/utils/networkRequestUtils.js +1 -1
  20. package/lib/commonjs/utils/qpqConfigAccessorsUtils.d.ts +10 -1
  21. package/lib/commonjs/utils/qpqConfigAccessorsUtils.js +24 -1
  22. package/lib/esm/actionProcessor/api/getHttpApiEventAutoRespondActionProcessor.js +20 -0
  23. package/lib/esm/config/QPQConfig.d.ts +2 -1
  24. package/lib/esm/config/QPQConfig.js +1 -0
  25. package/lib/esm/config/settings/api.d.ts +2 -0
  26. package/lib/esm/config/settings/api.js +1 -0
  27. package/lib/esm/config/settings/fileUploadSettings.d.ts +24 -0
  28. package/lib/esm/config/settings/fileUploadSettings.js +10 -0
  29. package/lib/esm/config/settings/index.d.ts +1 -0
  30. package/lib/esm/config/settings/index.js +1 -0
  31. package/lib/esm/config/settings/serviceFunction.d.ts +2 -0
  32. package/lib/esm/config/settings/serviceFunction.js +1 -0
  33. package/lib/esm/config/settings/websocket.d.ts +2 -0
  34. package/lib/esm/config/settings/websocket.js +1 -0
  35. package/lib/esm/services/webSocketQueue/logic/webSocket/messageProcessors/askProcessOnAuthenticate.js +31 -24
  36. package/lib/esm/types/HTTPEvent.d.ts +12 -0
  37. package/lib/esm/types/HTTPEvent.js +8 -1
  38. package/lib/esm/utils/httpEventUtils.d.ts +8 -0
  39. package/lib/esm/utils/httpEventUtils.js +22 -0
  40. package/lib/esm/utils/networkRequestUtils.js +1 -1
  41. package/lib/esm/utils/qpqConfigAccessorsUtils.d.ts +10 -1
  42. package/lib/esm/utils/qpqConfigAccessorsUtils.js +24 -0
  43. package/package.json +3 -3
@@ -12,7 +12,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.getHttpApiEventAutoRespondActionProcessor = void 0;
13
13
  const quidproquo_core_1 = require("quidproquo-core");
14
14
  const askValidateRouteAuth_1 = require("../../stories/askValidateRouteAuth");
15
+ const HTTPEvent_1 = require("../../types/HTTPEvent");
15
16
  const headerUtils_1 = require("../../utils/headerUtils");
17
+ const fileUploadErrorHttpStatusMap = {
18
+ [HTTPEvent_1.FileUploadErrorTypeEnum.fileTooLarge]: 413,
19
+ [HTTPEvent_1.FileUploadErrorTypeEnum.tooManyFiles]: 413,
20
+ [HTTPEvent_1.FileUploadErrorTypeEnum.tooManyFields]: 400,
21
+ [HTTPEvent_1.FileUploadErrorTypeEnum.disallowedMimeType]: 415,
22
+ [HTTPEvent_1.FileUploadErrorTypeEnum.malformed]: 400,
23
+ };
16
24
  const getProcessAutoRespond = (qpqConfig) => {
17
25
  const validateAuth = (0, quidproquo_core_1.getProcessCustomImplementation)(qpqConfig, askValidateRouteAuth_1.askValidateRouteAuth, 'API Auth Validation', null, () => new Date().toISOString(), quidproquo_core_1.generateUuid);
18
26
  return (_a, session_1, actionProcessorList_1, logger_1, updateSession_1, dynamicModuleLoader_1) => __awaiter(void 0, [_a, session_1, actionProcessorList_1, logger_1, updateSession_1, dynamicModuleLoader_1], void 0, function* ({ qpqEventRecord, matchResult }, session, actionProcessorList, logger, updateSession, dynamicModuleLoader) {
@@ -41,6 +49,18 @@ const getProcessAutoRespond = (qpqConfig) => {
41
49
  headers: (0, headerUtils_1.getCorsHeaders)(qpqConfig, matchResult.config || {}, qpqEventRecord.headers),
42
50
  });
43
51
  }
52
+ // Reject invalid multipart uploads before the route story runs (after auth, so a 401 wins)
53
+ if (qpqEventRecord.fileUploadError) {
54
+ return (0, quidproquo_core_1.actionResult)({
55
+ status: fileUploadErrorHttpStatusMap[qpqEventRecord.fileUploadError.errorType] || 400,
56
+ isBase64Encoded: false,
57
+ body: JSON.stringify({
58
+ errorType: qpqEventRecord.fileUploadError.errorType,
59
+ errorText: qpqEventRecord.fileUploadError.message,
60
+ }),
61
+ headers: (0, headerUtils_1.getCorsHeaders)(qpqConfig, matchResult.config || {}, qpqEventRecord.headers),
62
+ });
63
+ }
44
64
  return (0, quidproquo_core_1.actionResult)(null);
45
65
  });
46
66
  };
@@ -13,7 +13,8 @@ export declare enum QPQWebServerConfigSettingType {
13
13
  Cache = "@quidproquo-webserver/config/Cache",
14
14
  Certificate = "@quidproquo-webserver/config/Certificate",
15
15
  DomainProxy = "@quidproquo-webserver/config/DomainProxy",
16
- StorageDriveCorsSettings = "@quidproquo-webserver/config/StorageDriveCorsSettings"
16
+ StorageDriveCorsSettings = "@quidproquo-webserver/config/StorageDriveCorsSettings",
17
+ FileUploadSettings = "@quidproquo-webserver/config/FileUploadSettings"
17
18
  }
18
19
  export interface CacheSettings {
19
20
  minTTLInSeconds: number;
@@ -18,4 +18,5 @@ var QPQWebServerConfigSettingType;
18
18
  QPQWebServerConfigSettingType["Certificate"] = "@quidproquo-webserver/config/Certificate";
19
19
  QPQWebServerConfigSettingType["DomainProxy"] = "@quidproquo-webserver/config/DomainProxy";
20
20
  QPQWebServerConfigSettingType["StorageDriveCorsSettings"] = "@quidproquo-webserver/config/StorageDriveCorsSettings";
21
+ QPQWebServerConfigSettingType["FileUploadSettings"] = "@quidproquo-webserver/config/FileUploadSettings";
21
22
  })(QPQWebServerConfigSettingType || (exports.QPQWebServerConfigSettingType = QPQWebServerConfigSettingType = {}));
@@ -3,6 +3,7 @@ export interface QPQConfigAdvancedApiSettings extends QPQConfigAdvancedSettings
3
3
  subDomain?: string;
4
4
  cloudflareApiKeySecretName?: string;
5
5
  virtualNetworkName?: string;
6
+ maxConcurrentExecutions?: number;
6
7
  }
7
8
  export interface ApiQPQWebServerConfigSetting extends QPQConfigSetting {
8
9
  apiSubdomain: string;
@@ -11,5 +12,6 @@ export interface ApiQPQWebServerConfigSetting extends QPQConfigSetting {
11
12
  deprecated: boolean;
12
13
  cloudflareApiKeySecretName?: string;
13
14
  virtualNetworkName?: string;
15
+ maxConcurrentExecutions?: number;
14
16
  }
15
17
  export declare const defineApi: (apiName: string, rootDomain: string, options?: QPQConfigAdvancedApiSettings) => ApiQPQWebServerConfigSetting;
@@ -12,6 +12,7 @@ const defineApi = (apiName, rootDomain, options) => {
12
12
  deprecated: (options === null || options === void 0 ? void 0 : options.deprecated) || false,
13
13
  cloudflareApiKeySecretName: options === null || options === void 0 ? void 0 : options.cloudflareApiKeySecretName,
14
14
  virtualNetworkName: options === null || options === void 0 ? void 0 : options.virtualNetworkName,
15
+ maxConcurrentExecutions: options === null || options === void 0 ? void 0 : options.maxConcurrentExecutions,
15
16
  };
16
17
  };
17
18
  exports.defineApi = defineApi;
@@ -0,0 +1,24 @@
1
+ import { QPQConfigSetting } from 'quidproquo-core';
2
+ export interface FileUploadSettings {
3
+ /** Maximum size of any single uploaded file. Uploads with a larger file are rejected with a 413. */
4
+ maxFileSizeBytes: number;
5
+ /** Maximum number of files in a single multipart request. */
6
+ maxFileCount: number;
7
+ /** Maximum number of non-file fields in a single multipart request. */
8
+ maxFieldCount: number;
9
+ /** Maximum size of any single non-file field value. Larger values are truncated by the parser. */
10
+ maxFieldSizeBytes: number;
11
+ /**
12
+ * Content types accepted for uploaded files, e.g. `['image/*', 'application/pdf']`
13
+ * (`type/*` wildcards supported). Omit to accept any content type.
14
+ */
15
+ allowedMimeTypes?: string[];
16
+ }
17
+ export interface FileUploadSettingsQPQWebServerConfigSetting extends QPQConfigSetting {
18
+ fileUploadSettings: Partial<FileUploadSettings>;
19
+ }
20
+ /**
21
+ * Service-wide limits for multipart/form-data file uploads. Sensible defaults
22
+ * apply even when this setting is not declared — declare it only to override them.
23
+ */
24
+ export declare const defineFileUploadSettings: (fileUploadSettings: Partial<FileUploadSettings>) => FileUploadSettingsQPQWebServerConfigSetting;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defineFileUploadSettings = void 0;
4
+ const QPQConfig_1 = require("../QPQConfig");
5
+ /**
6
+ * Service-wide limits for multipart/form-data file uploads. Sensible defaults
7
+ * apply even when this setting is not declared — declare it only to override them.
8
+ */
9
+ const defineFileUploadSettings = (fileUploadSettings) => ({
10
+ configSettingType: QPQConfig_1.QPQWebServerConfigSettingType.FileUploadSettings,
11
+ uniqueKey: 'fileUploadSettings',
12
+ fileUploadSettings,
13
+ });
14
+ exports.defineFileUploadSettings = defineFileUploadSettings;
@@ -8,6 +8,7 @@ export * from './defineAdminSettings';
8
8
  export * from './defineAuthSystem';
9
9
  export * from './dns';
10
10
  export * from './domainProxy';
11
+ export * from './fileUploadSettings';
11
12
  export * from './migration';
12
13
  export * from './openApi';
13
14
  export * from './route';
@@ -24,6 +24,7 @@ __exportStar(require("./defineAdminSettings"), exports);
24
24
  __exportStar(require("./defineAuthSystem"), exports);
25
25
  __exportStar(require("./dns"), exports);
26
26
  __exportStar(require("./domainProxy"), exports);
27
+ __exportStar(require("./fileUploadSettings"), exports);
27
28
  __exportStar(require("./migration"), exports);
28
29
  __exportStar(require("./openApi"), exports);
29
30
  __exportStar(require("./route"), exports);
@@ -2,11 +2,13 @@ import { CrossModuleOwner, QPQConfigAdvancedSettings, QPQConfigSetting, QpqFunct
2
2
  export interface QPQConfigAdvancedServiceFunctionSettings extends QPQConfigAdvancedSettings {
3
3
  functionName?: string;
4
4
  virtualNetworkName?: string;
5
+ maxConcurrentExecutions?: number;
5
6
  owner?: CrossModuleOwner<'functionName'>;
6
7
  }
7
8
  export interface ServiceFunctionQPQWebServerConfigSetting extends QPQConfigSetting {
8
9
  runtime: QpqFunctionRuntime;
9
10
  functionName: string;
10
11
  virtualNetworkName?: string;
12
+ maxConcurrentExecutions?: number;
11
13
  }
12
14
  export declare const defineServiceFunction: (runtime: QpqFunctionRuntime, options?: QPQConfigAdvancedServiceFunctionSettings) => ServiceFunctionQPQWebServerConfigSetting;
@@ -11,6 +11,7 @@ const defineServiceFunction = (runtime, options) => {
11
11
  runtime,
12
12
  functionName: functionName,
13
13
  virtualNetworkName: options === null || options === void 0 ? void 0 : options.virtualNetworkName,
14
+ maxConcurrentExecutions: options === null || options === void 0 ? void 0 : options.maxConcurrentExecutions,
14
15
  owner: quidproquo_core_1.qpqCoreUtils.convertCrossModuleOwnerToGenericResourceNameOverride(options === null || options === void 0 ? void 0 : options.owner),
15
16
  };
16
17
  };
@@ -8,6 +8,7 @@ export interface QPQConfigAdvancedWebSocketSettings extends QPQConfigAdvancedSet
8
8
  onRootDomain?: boolean;
9
9
  apiName?: string;
10
10
  cloudflareApiKeySecretName?: string;
11
+ maxConcurrentExecutions?: number;
11
12
  owner?: CrossModuleOwner<'websocketApiName'>;
12
13
  }
13
14
  export interface WebSocketQPQWebServerConfigSetting extends QPQConfigSetting {
@@ -18,5 +19,6 @@ export interface WebSocketQPQWebServerConfigSetting extends QPQConfigSetting {
18
19
  eventProcessors: QpqWebSocketEventProcessors;
19
20
  deprecated: boolean;
20
21
  cloudflareApiKeySecretName?: string;
22
+ maxConcurrentExecutions?: number;
21
23
  }
22
24
  export declare const defineWebsocket: (apiSubdomain: string, rootDomain: string, eventProcessors: QpqWebSocketEventProcessors, options?: QPQConfigAdvancedWebSocketSettings) => WebSocketQPQWebServerConfigSetting;
@@ -15,6 +15,7 @@ const defineWebsocket = (apiSubdomain, rootDomain, eventProcessors, options) =>
15
15
  apiName: (options === null || options === void 0 ? void 0 : options.apiName) || 'api',
16
16
  deprecated: (options === null || options === void 0 ? void 0 : options.deprecated) || false,
17
17
  cloudflareApiKeySecretName: options === null || options === void 0 ? void 0 : options.cloudflareApiKeySecretName,
18
+ maxConcurrentExecutions: options === null || options === void 0 ? void 0 : options.maxConcurrentExecutions,
18
19
  owner: quidproquo_core_1.qpqCoreUtils.convertCrossModuleOwnerToGenericResourceNameOverride(options === null || options === void 0 ? void 0 : options.owner),
19
20
  };
20
21
  };
@@ -14,28 +14,35 @@ function isWebSocketAuthenticateMessage(event) {
14
14
  }
15
15
  function* askProcessOnAuthenticate(connectionId, accessToken) {
16
16
  const connection = yield* data_1.webSocketConnectionData.askGetById(connectionId);
17
- if (connection) {
18
- const apiName = yield* (0, context_1.askWebsocketReadApiNameOrThrow)();
19
- const userDirectoryName = yield* (0, quidproquo_core_1.askConfigGetGlobal)((0, config_1.getWebSocketQueueGlobalConfigKeyForUserDirectoryName)(apiName));
20
- if (userDirectoryName) {
21
- const result = yield* (0, quidproquo_core_1.askCatch)((0, quidproquo_core_1.askUserDirectorySetAccessToken)(userDirectoryName, accessToken));
22
- if (!result.success) {
23
- yield* (0, askSendMessage_1.askSendMessage)(connectionId, {
24
- type: types_1.WebSocketQueueServerMessageEventType.Unauthenticated,
25
- });
26
- return;
27
- }
28
- const decodedAccessToken = result.result;
29
- yield* data_1.webSocketConnectionData.askUpsert(Object.assign(Object.assign({}, connection), { userId: decodedAccessToken.userId, accessToken }));
17
+ // No connection record (e.g. the connect event hasn't been processed yet)
18
+ // tell the client rather than dropping the request silently, so it can
19
+ // distinguish "not authenticated" from "no reply".
20
+ if (!connection) {
21
+ yield* (0, askSendMessage_1.askSendMessage)(connectionId, {
22
+ type: types_1.WebSocketQueueServerMessageEventType.Unauthenticated,
23
+ });
24
+ return;
25
+ }
26
+ const apiName = yield* (0, context_1.askWebsocketReadApiNameOrThrow)();
27
+ const userDirectoryName = yield* (0, quidproquo_core_1.askConfigGetGlobal)((0, config_1.getWebSocketQueueGlobalConfigKeyForUserDirectoryName)(apiName));
28
+ if (userDirectoryName) {
29
+ const result = yield* (0, quidproquo_core_1.askCatch)((0, quidproquo_core_1.askUserDirectorySetAccessToken)(userDirectoryName, accessToken));
30
+ if (!result.success) {
30
31
  yield* (0, askSendMessage_1.askSendMessage)(connectionId, {
31
- type: types_1.WebSocketQueueServerMessageEventType.Authenticated,
32
+ type: types_1.WebSocketQueueServerMessageEventType.Unauthenticated,
32
33
  });
33
- // Send a websocket message to the event buss WITHOUT an access token
34
- const webSocketQueueClientEventMessageAuthenticate = {
35
- type: types_1.WebSocketQueueClientMessageEventType.Authenticate,
36
- payload: {},
37
- };
38
- yield* (0, askBroadcastUnknownMessage_1.askBroadcastUnknownMessage)(webSocketQueueClientEventMessageAuthenticate);
34
+ return;
39
35
  }
36
+ const decodedAccessToken = result.result;
37
+ yield* data_1.webSocketConnectionData.askUpsert(Object.assign(Object.assign({}, connection), { userId: decodedAccessToken.userId, accessToken }));
38
+ yield* (0, askSendMessage_1.askSendMessage)(connectionId, {
39
+ type: types_1.WebSocketQueueServerMessageEventType.Authenticated,
40
+ });
41
+ // Send a websocket message to the event buss WITHOUT an access token
42
+ const webSocketQueueClientEventMessageAuthenticate = {
43
+ type: types_1.WebSocketQueueClientMessageEventType.Authenticate,
44
+ payload: {},
45
+ };
46
+ yield* (0, askBroadcastUnknownMessage_1.askBroadcastUnknownMessage)(webSocketQueueClientEventMessageAuthenticate);
40
47
  }
41
48
  }
@@ -5,6 +5,17 @@ export interface HttpEventHeaders {
5
5
  export interface HttpEventRouteParams {
6
6
  [key: string]: string;
7
7
  }
8
+ export declare enum FileUploadErrorTypeEnum {
9
+ fileTooLarge = "fileTooLarge",
10
+ tooManyFiles = "tooManyFiles",
11
+ tooManyFields = "tooManyFields",
12
+ disallowedMimeType = "disallowedMimeType",
13
+ malformed = "malformed"
14
+ }
15
+ export interface HTTPEventFileUploadError {
16
+ errorType: FileUploadErrorTypeEnum;
17
+ message: string;
18
+ }
8
19
  export interface HTTPEvent {
9
20
  path: string;
10
21
  query: {
@@ -17,6 +28,7 @@ export interface HTTPEvent {
17
28
  sourceIp: string;
18
29
  isBase64Encoded: boolean;
19
30
  files?: QPQBinaryData[];
31
+ fileUploadError?: HTTPEventFileUploadError;
20
32
  }
21
33
  export interface HTTPEventResponse {
22
34
  status: number;
@@ -1,5 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FileUploadErrorTypeEnum = void 0;
4
+ var FileUploadErrorTypeEnum;
5
+ (function (FileUploadErrorTypeEnum) {
6
+ FileUploadErrorTypeEnum["fileTooLarge"] = "fileTooLarge";
7
+ FileUploadErrorTypeEnum["tooManyFiles"] = "tooManyFiles";
8
+ FileUploadErrorTypeEnum["tooManyFields"] = "tooManyFields";
9
+ FileUploadErrorTypeEnum["disallowedMimeType"] = "disallowedMimeType";
10
+ FileUploadErrorTypeEnum["malformed"] = "malformed";
11
+ })(FileUploadErrorTypeEnum || (exports.FileUploadErrorTypeEnum = FileUploadErrorTypeEnum = {}));
3
12
  // type ParseParamType<S extends string> = S extends `${infer ParamName}:number`
4
13
  // ? { name: ParamName; type: number }
5
14
  // : S extends `${infer ParamName}:int`
@@ -4,6 +4,14 @@ import { HTTPEvent, HTTPEventResponse } from '../types/HTTPEvent';
4
4
  export declare const rawFromJsonEventRequest: (httpJsonEvent: HTTPEvent) => string | undefined;
5
5
  export declare const fromJsonEventRequest: <T>(httpJsonEvent: HTTPEvent) => T;
6
6
  export declare function askFromJsonEventRequest<T>(httpJsonEvent: HTTPEvent): AskResponse<T>;
7
+ /**
8
+ * Like `askFromJsonEventRequest`, but the parsed body is run through an app-supplied
9
+ * validator before it is returned - so the `T` is actually checked, not just cast.
10
+ * The validator throws (or returns the typed value); a validation throw becomes an
11
+ * `Invalid` (422) response. Any schema library fits, e.g. zod: `(data) => schema.parse(data)`.
12
+ */
13
+ export declare function askFromValidJsonEventRequest<T>(httpJsonEvent: HTTPEvent, validate: (data: unknown) => T): AskResponse<T>;
14
+ export declare const readUriQueryParamFromEvent: (event: HTTPEvent, paramName: string) => string | undefined;
7
15
  export declare const toJsonEventResponse: (item: any, status?: number) => HTTPEventResponse;
8
16
  export declare const toHtmlResponse: (html: string, status?: number) => HTTPEventResponse;
9
17
  export declare const toTextResponse: (text: string, status?: number) => HTTPEventResponse;
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.toCdnResponse = exports.toMovedTemporarilyRedirectResponse = exports.toMovedPermanentlyRedirectResponse = exports.toTextResponse = exports.toHtmlResponse = exports.toJsonEventResponse = exports.fromJsonEventRequest = exports.rawFromJsonEventRequest = void 0;
3
+ exports.toCdnResponse = exports.toMovedTemporarilyRedirectResponse = exports.toMovedPermanentlyRedirectResponse = exports.toTextResponse = exports.toHtmlResponse = exports.toJsonEventResponse = exports.readUriQueryParamFromEvent = exports.fromJsonEventRequest = exports.rawFromJsonEventRequest = void 0;
4
4
  exports.askFromJsonEventRequest = askFromJsonEventRequest;
5
+ exports.askFromValidJsonEventRequest = askFromValidJsonEventRequest;
5
6
  const quidproquo_core_1 = require("quidproquo-core");
6
7
  const rawFromJsonEventRequest = (httpJsonEvent) => {
7
8
  const json = httpJsonEvent.isBase64Encoded && httpJsonEvent.body ? Buffer.from(httpJsonEvent.body, 'base64').toString() : httpJsonEvent.body;
@@ -39,6 +40,29 @@ function* askFromJsonEventRequest(httpJsonEvent) {
39
40
  return yield* (0, quidproquo_core_1.askThrowError)(quidproquo_core_1.ErrorTypeEnum.BadRequest, 'Unable to parse incoming json from HTTPEvent.');
40
41
  }
41
42
  }
43
+ /**
44
+ * Like `askFromJsonEventRequest`, but the parsed body is run through an app-supplied
45
+ * validator before it is returned - so the `T` is actually checked, not just cast.
46
+ * The validator throws (or returns the typed value); a validation throw becomes an
47
+ * `Invalid` (422) response. Any schema library fits, e.g. zod: `(data) => schema.parse(data)`.
48
+ */
49
+ function* askFromValidJsonEventRequest(httpJsonEvent, validate) {
50
+ const parsedBody = yield* askFromJsonEventRequest(httpJsonEvent);
51
+ try {
52
+ return validate(parsedBody);
53
+ }
54
+ catch (error) {
55
+ return yield* (0, quidproquo_core_1.askThrowError)(quidproquo_core_1.ErrorTypeEnum.Invalid, error instanceof Error ? error.message : 'Invalid request body.');
56
+ }
57
+ }
58
+ const readUriQueryParamFromEvent = (event, paramName) => {
59
+ const rawValue = event.query[paramName];
60
+ if (!rawValue) {
61
+ return undefined;
62
+ }
63
+ return Array.isArray(rawValue) ? rawValue[0] : rawValue;
64
+ };
65
+ exports.readUriQueryParamFromEvent = readUriQueryParamFromEvent;
42
66
  const toJsonEventResponse = (item, status = 200) => {
43
67
  return {
44
68
  status,
@@ -22,7 +22,7 @@ const isAbsoluteUrl = (url) => /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url);
22
22
  // Join basePath + url like axios' combineURLs (path concatenation), NOT URL
23
23
  // resolution. `new URL('/v1/x', 'http://host/api/svc')` drops the base path
24
24
  // because a leading-slash url is root-absolute; we want '.../api/svc/v1/x'.
25
- const combineUrls = (basePath, url) => url ? `${basePath.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}` : basePath;
25
+ const combineUrls = (basePath, url) => (url ? `${basePath.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}` : basePath);
26
26
  const buildRequestUrl = (payload) => {
27
27
  const fullUrl = payload.basePath && !isAbsoluteUrl(payload.url) ? combineUrls(payload.basePath, payload.url) : payload.url;
28
28
  const url = new URL(fullUrl);
@@ -1,5 +1,5 @@
1
1
  import { QPQConfig, QpqFunctionRuntime } from 'quidproquo-core';
2
- import { ApiKeyQPQWebServerConfigSetting, CacheQPQWebServerConfigSetting, CertificateQPQWebServerConfigSetting, DefaultRouteOptionsQPQWebServerConfigSetting, DnsQPQWebServerConfigSetting, DomainProxyQPQWebServerConfigSetting, OpenApiQPQWebServerConfigSetting, RouteQPQWebServerConfigSetting, SeoQPQWebServerConfigSetting, ServiceFunctionQPQWebServerConfigSetting, SubdomainRedirectQPQWebServerConfigSetting, WebSocketQPQWebServerConfigSetting } from '../config';
2
+ import { ApiKeyQPQWebServerConfigSetting, CacheQPQWebServerConfigSetting, CertificateQPQWebServerConfigSetting, DefaultRouteOptionsQPQWebServerConfigSetting, DnsQPQWebServerConfigSetting, DomainProxyQPQWebServerConfigSetting, FileUploadSettings, OpenApiQPQWebServerConfigSetting, RouteQPQWebServerConfigSetting, SeoQPQWebServerConfigSetting, ServiceFunctionQPQWebServerConfigSetting, SubdomainRedirectQPQWebServerConfigSetting, WebSocketQPQWebServerConfigSetting } from '../config';
3
3
  import { ApiQPQWebServerConfigSetting, WebEntryQPQWebServerConfigSetting } from '../config';
4
4
  export declare const getAllRoutes: (qpqConfig: QPQConfig) => RouteQPQWebServerConfigSetting[];
5
5
  export declare const getAllRoutesForApi: (apiName: string, qpqConfig: QPQConfig) => RouteQPQWebServerConfigSetting[];
@@ -40,6 +40,15 @@ export declare const resolveServiceScopedCorsAllowedOrigins: (qpqConfig: QPQConf
40
40
  * `defineStorageDriveCorsSettings` wins; otherwise the service-scoped default.
41
41
  */
42
42
  export declare const getStorageDriveCorsAllowedOrigins: (qpqConfig: QPQConfig, storageDriveName: string) => string[];
43
+ /**
44
+ * Whether any web entry in this service serves its assets from the named storage
45
+ * drive (`storageDrive.sourceStorageDrive`) — i.e. the drive's bucket is a
46
+ * CloudFront origin. Drives with no web entry consumer need no CloudFront access
47
+ * to their bucket at all.
48
+ */
49
+ export declare const isStorageDriveWebEntryOrigin: (qpqConfig: QPQConfig, storageDriveName: string) => boolean;
50
+ export declare const defaultFileUploadSettings: FileUploadSettings;
51
+ export declare const getFileUploadSettings: (qpqConfig: QPQConfig) => FileUploadSettings;
43
52
  export declare const resolveApexDomainNameFromDomainConfig: (qpqConfig: QPQConfig, rootDomain: string, onRootDomain: boolean) => string;
44
53
  export declare const constructServiceDomainName: (rootDomain: string, environment: string, service: string, feature?: string) => string;
45
54
  export declare const constructEnvironmentDomainName: (environment: string, domain: string) => string;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getOwnedWebsocketSettings = exports.getWebsocketSettings = exports.resolveDomainRoot = exports.getDomainRoot = exports.getDefaultRouteSettings = exports.constructEnvironmentDomainName = exports.constructServiceDomainName = exports.resolveApexDomainNameFromDomainConfig = exports.getStorageDriveCorsAllowedOrigins = exports.resolveServiceScopedCorsAllowedOrigins = exports.getServiceDomainName = exports.getBaseDomainName = exports.getEnvironmentDomainName = exports.getCacheConfigByName = exports.getAllOwnedCertifcateConfigs = exports.getAllOwnedCacheConfigs = exports.getDomainProxyConfigs = exports.getWebEntryConfigs = exports.getDnsConfigs = exports.getApiConfigs = exports.getSubdomainRedirects = exports.getWebsocketEntryByApiName = exports.getWebEntry = exports.getDomainName = exports.getAllSrcEntries = exports.getAllWebsocketSrcEntries = exports.getAllOpenApiSpecs = exports.getOwnedServiceFunctions = exports.getAllServiceFunctions = exports.getAllSeo = exports.getAllApiKeyConfigs = exports.getAllRoutesForApi = exports.getAllRoutes = void 0;
3
+ exports.getOwnedWebsocketSettings = exports.getWebsocketSettings = exports.resolveDomainRoot = exports.getDomainRoot = exports.getDefaultRouteSettings = exports.constructEnvironmentDomainName = exports.constructServiceDomainName = exports.resolveApexDomainNameFromDomainConfig = exports.getFileUploadSettings = exports.defaultFileUploadSettings = exports.isStorageDriveWebEntryOrigin = exports.getStorageDriveCorsAllowedOrigins = exports.resolveServiceScopedCorsAllowedOrigins = exports.getServiceDomainName = exports.getBaseDomainName = exports.getEnvironmentDomainName = exports.getCacheConfigByName = exports.getAllOwnedCertifcateConfigs = exports.getAllOwnedCacheConfigs = exports.getDomainProxyConfigs = exports.getWebEntryConfigs = exports.getDnsConfigs = exports.getApiConfigs = exports.getSubdomainRedirects = exports.getWebsocketEntryByApiName = exports.getWebEntry = exports.getDomainName = exports.getAllSrcEntries = exports.getAllWebsocketSrcEntries = exports.getAllOpenApiSpecs = exports.getOwnedServiceFunctions = exports.getAllServiceFunctions = exports.getAllSeo = exports.getAllApiKeyConfigs = exports.getAllRoutesForApi = exports.getAllRoutes = void 0;
4
4
  const quidproquo_core_1 = require("quidproquo-core");
5
5
  const config_1 = require("../config");
6
6
  const getAllRoutes = (qpqConfig) => {
@@ -175,6 +175,29 @@ const getStorageDriveCorsAllowedOrigins = (qpqConfig, storageDriveName) => {
175
175
  return (0, exports.resolveServiceScopedCorsAllowedOrigins)(qpqConfig, corsSetting === null || corsSetting === void 0 ? void 0 : corsSetting.allowedOrigins);
176
176
  };
177
177
  exports.getStorageDriveCorsAllowedOrigins = getStorageDriveCorsAllowedOrigins;
178
+ /**
179
+ * Whether any web entry in this service serves its assets from the named storage
180
+ * drive (`storageDrive.sourceStorageDrive`) — i.e. the drive's bucket is a
181
+ * CloudFront origin. Drives with no web entry consumer need no CloudFront access
182
+ * to their bucket at all.
183
+ */
184
+ const isStorageDriveWebEntryOrigin = (qpqConfig, storageDriveName) => {
185
+ return (0, exports.getWebEntryConfigs)(qpqConfig).some((webEntry) => webEntry.storageDrive.sourceStorageDrive === storageDriveName);
186
+ };
187
+ exports.isStorageDriveWebEntryOrigin = isStorageDriveWebEntryOrigin;
188
+ // API Gateway caps request payloads at 10MB, so the default per-file ceiling matches it;
189
+ // the other defaults exist to bound parser memory rather than to be hit in practice.
190
+ exports.defaultFileUploadSettings = {
191
+ maxFileSizeBytes: 10 * 1024 * 1024,
192
+ maxFileCount: 10,
193
+ maxFieldCount: 100,
194
+ maxFieldSizeBytes: 1024 * 1024,
195
+ };
196
+ const getFileUploadSettings = (qpqConfig) => {
197
+ const setting = quidproquo_core_1.qpqCoreUtils.getConfigSetting(qpqConfig, config_1.QPQWebServerConfigSettingType.FileUploadSettings);
198
+ return Object.assign(Object.assign({}, exports.defaultFileUploadSettings), Object.fromEntries(Object.entries((setting === null || setting === void 0 ? void 0 : setting.fileUploadSettings) || {}).filter(([, value]) => (0, quidproquo_core_1.isDefined)(value))));
199
+ };
200
+ exports.getFileUploadSettings = getFileUploadSettings;
178
201
  const resolveApexDomainNameFromDomainConfig = (qpqConfig, rootDomain, onRootDomain) => {
179
202
  const feature = quidproquo_core_1.qpqCoreUtils.getApplicationModuleFeature(qpqConfig);
180
203
  const environment = quidproquo_core_1.qpqCoreUtils.getApplicationModuleEnvironment(qpqConfig);
@@ -1,6 +1,14 @@
1
1
  import { actionResult, EventActionType, generateUuid, getProcessCustomImplementation, } from 'quidproquo-core';
2
2
  import { askValidateRouteAuth } from '../../stories/askValidateRouteAuth';
3
+ import { FileUploadErrorTypeEnum } from '../../types/HTTPEvent';
3
4
  import { getCorsHeaders } from '../../utils/headerUtils';
5
+ const fileUploadErrorHttpStatusMap = {
6
+ [FileUploadErrorTypeEnum.fileTooLarge]: 413,
7
+ [FileUploadErrorTypeEnum.tooManyFiles]: 413,
8
+ [FileUploadErrorTypeEnum.tooManyFields]: 400,
9
+ [FileUploadErrorTypeEnum.disallowedMimeType]: 415,
10
+ [FileUploadErrorTypeEnum.malformed]: 400,
11
+ };
4
12
  const getProcessAutoRespond = (qpqConfig) => {
5
13
  const validateAuth = getProcessCustomImplementation(qpqConfig, askValidateRouteAuth, 'API Auth Validation', null, () => new Date().toISOString(), generateUuid);
6
14
  return async ({ qpqEventRecord, matchResult }, session, actionProcessorList, logger, updateSession, dynamicModuleLoader) => {
@@ -28,6 +36,18 @@ const getProcessAutoRespond = (qpqConfig) => {
28
36
  headers: getCorsHeaders(qpqConfig, matchResult.config || {}, qpqEventRecord.headers),
29
37
  });
30
38
  }
39
+ // Reject invalid multipart uploads before the route story runs (after auth, so a 401 wins)
40
+ if (qpqEventRecord.fileUploadError) {
41
+ return actionResult({
42
+ status: fileUploadErrorHttpStatusMap[qpqEventRecord.fileUploadError.errorType] || 400,
43
+ isBase64Encoded: false,
44
+ body: JSON.stringify({
45
+ errorType: qpqEventRecord.fileUploadError.errorType,
46
+ errorText: qpqEventRecord.fileUploadError.message,
47
+ }),
48
+ headers: getCorsHeaders(qpqConfig, matchResult.config || {}, qpqEventRecord.headers),
49
+ });
50
+ }
31
51
  return actionResult(null);
32
52
  };
33
53
  };
@@ -13,7 +13,8 @@ export declare enum QPQWebServerConfigSettingType {
13
13
  Cache = "@quidproquo-webserver/config/Cache",
14
14
  Certificate = "@quidproquo-webserver/config/Certificate",
15
15
  DomainProxy = "@quidproquo-webserver/config/DomainProxy",
16
- StorageDriveCorsSettings = "@quidproquo-webserver/config/StorageDriveCorsSettings"
16
+ StorageDriveCorsSettings = "@quidproquo-webserver/config/StorageDriveCorsSettings",
17
+ FileUploadSettings = "@quidproquo-webserver/config/FileUploadSettings"
17
18
  }
18
19
  export interface CacheSettings {
19
20
  minTTLInSeconds: number;
@@ -15,4 +15,5 @@ export var QPQWebServerConfigSettingType;
15
15
  QPQWebServerConfigSettingType["Certificate"] = "@quidproquo-webserver/config/Certificate";
16
16
  QPQWebServerConfigSettingType["DomainProxy"] = "@quidproquo-webserver/config/DomainProxy";
17
17
  QPQWebServerConfigSettingType["StorageDriveCorsSettings"] = "@quidproquo-webserver/config/StorageDriveCorsSettings";
18
+ QPQWebServerConfigSettingType["FileUploadSettings"] = "@quidproquo-webserver/config/FileUploadSettings";
18
19
  })(QPQWebServerConfigSettingType || (QPQWebServerConfigSettingType = {}));
@@ -3,6 +3,7 @@ export interface QPQConfigAdvancedApiSettings extends QPQConfigAdvancedSettings
3
3
  subDomain?: string;
4
4
  cloudflareApiKeySecretName?: string;
5
5
  virtualNetworkName?: string;
6
+ maxConcurrentExecutions?: number;
6
7
  }
7
8
  export interface ApiQPQWebServerConfigSetting extends QPQConfigSetting {
8
9
  apiSubdomain: string;
@@ -11,5 +12,6 @@ export interface ApiQPQWebServerConfigSetting extends QPQConfigSetting {
11
12
  deprecated: boolean;
12
13
  cloudflareApiKeySecretName?: string;
13
14
  virtualNetworkName?: string;
15
+ maxConcurrentExecutions?: number;
14
16
  }
15
17
  export declare const defineApi: (apiName: string, rootDomain: string, options?: QPQConfigAdvancedApiSettings) => ApiQPQWebServerConfigSetting;
@@ -9,5 +9,6 @@ export const defineApi = (apiName, rootDomain, options) => {
9
9
  deprecated: options?.deprecated || false,
10
10
  cloudflareApiKeySecretName: options?.cloudflareApiKeySecretName,
11
11
  virtualNetworkName: options?.virtualNetworkName,
12
+ maxConcurrentExecutions: options?.maxConcurrentExecutions,
12
13
  };
13
14
  };
@@ -0,0 +1,24 @@
1
+ import { QPQConfigSetting } from 'quidproquo-core';
2
+ export interface FileUploadSettings {
3
+ /** Maximum size of any single uploaded file. Uploads with a larger file are rejected with a 413. */
4
+ maxFileSizeBytes: number;
5
+ /** Maximum number of files in a single multipart request. */
6
+ maxFileCount: number;
7
+ /** Maximum number of non-file fields in a single multipart request. */
8
+ maxFieldCount: number;
9
+ /** Maximum size of any single non-file field value. Larger values are truncated by the parser. */
10
+ maxFieldSizeBytes: number;
11
+ /**
12
+ * Content types accepted for uploaded files, e.g. `['image/*', 'application/pdf']`
13
+ * (`type/*` wildcards supported). Omit to accept any content type.
14
+ */
15
+ allowedMimeTypes?: string[];
16
+ }
17
+ export interface FileUploadSettingsQPQWebServerConfigSetting extends QPQConfigSetting {
18
+ fileUploadSettings: Partial<FileUploadSettings>;
19
+ }
20
+ /**
21
+ * Service-wide limits for multipart/form-data file uploads. Sensible defaults
22
+ * apply even when this setting is not declared — declare it only to override them.
23
+ */
24
+ export declare const defineFileUploadSettings: (fileUploadSettings: Partial<FileUploadSettings>) => FileUploadSettingsQPQWebServerConfigSetting;
@@ -0,0 +1,10 @@
1
+ import { QPQWebServerConfigSettingType } from '../QPQConfig';
2
+ /**
3
+ * Service-wide limits for multipart/form-data file uploads. Sensible defaults
4
+ * apply even when this setting is not declared — declare it only to override them.
5
+ */
6
+ export const defineFileUploadSettings = (fileUploadSettings) => ({
7
+ configSettingType: QPQWebServerConfigSettingType.FileUploadSettings,
8
+ uniqueKey: 'fileUploadSettings',
9
+ fileUploadSettings,
10
+ });
@@ -8,6 +8,7 @@ export * from './defineAdminSettings';
8
8
  export * from './defineAuthSystem';
9
9
  export * from './dns';
10
10
  export * from './domainProxy';
11
+ export * from './fileUploadSettings';
11
12
  export * from './migration';
12
13
  export * from './openApi';
13
14
  export * from './route';
@@ -8,6 +8,7 @@ export * from './defineAdminSettings';
8
8
  export * from './defineAuthSystem';
9
9
  export * from './dns';
10
10
  export * from './domainProxy';
11
+ export * from './fileUploadSettings';
11
12
  export * from './migration';
12
13
  export * from './openApi';
13
14
  export * from './route';
@@ -2,11 +2,13 @@ import { CrossModuleOwner, QPQConfigAdvancedSettings, QPQConfigSetting, QpqFunct
2
2
  export interface QPQConfigAdvancedServiceFunctionSettings extends QPQConfigAdvancedSettings {
3
3
  functionName?: string;
4
4
  virtualNetworkName?: string;
5
+ maxConcurrentExecutions?: number;
5
6
  owner?: CrossModuleOwner<'functionName'>;
6
7
  }
7
8
  export interface ServiceFunctionQPQWebServerConfigSetting extends QPQConfigSetting {
8
9
  runtime: QpqFunctionRuntime;
9
10
  functionName: string;
10
11
  virtualNetworkName?: string;
12
+ maxConcurrentExecutions?: number;
11
13
  }
12
14
  export declare const defineServiceFunction: (runtime: QpqFunctionRuntime, options?: QPQConfigAdvancedServiceFunctionSettings) => ServiceFunctionQPQWebServerConfigSetting;
@@ -8,6 +8,7 @@ export const defineServiceFunction = (runtime, options) => {
8
8
  runtime,
9
9
  functionName: functionName,
10
10
  virtualNetworkName: options?.virtualNetworkName,
11
+ maxConcurrentExecutions: options?.maxConcurrentExecutions,
11
12
  owner: qpqCoreUtils.convertCrossModuleOwnerToGenericResourceNameOverride(options?.owner),
12
13
  };
13
14
  };
@@ -8,6 +8,7 @@ export interface QPQConfigAdvancedWebSocketSettings extends QPQConfigAdvancedSet
8
8
  onRootDomain?: boolean;
9
9
  apiName?: string;
10
10
  cloudflareApiKeySecretName?: string;
11
+ maxConcurrentExecutions?: number;
11
12
  owner?: CrossModuleOwner<'websocketApiName'>;
12
13
  }
13
14
  export interface WebSocketQPQWebServerConfigSetting extends QPQConfigSetting {
@@ -18,5 +19,6 @@ export interface WebSocketQPQWebServerConfigSetting extends QPQConfigSetting {
18
19
  eventProcessors: QpqWebSocketEventProcessors;
19
20
  deprecated: boolean;
20
21
  cloudflareApiKeySecretName?: string;
22
+ maxConcurrentExecutions?: number;
21
23
  }
22
24
  export declare const defineWebsocket: (apiSubdomain: string, rootDomain: string, eventProcessors: QpqWebSocketEventProcessors, options?: QPQConfigAdvancedWebSocketSettings) => WebSocketQPQWebServerConfigSetting;
@@ -12,6 +12,7 @@ export const defineWebsocket = (apiSubdomain, rootDomain, eventProcessors, optio
12
12
  apiName: options?.apiName || 'api',
13
13
  deprecated: options?.deprecated || false,
14
14
  cloudflareApiKeySecretName: options?.cloudflareApiKeySecretName,
15
+ maxConcurrentExecutions: options?.maxConcurrentExecutions,
15
16
  owner: qpqCoreUtils.convertCrossModuleOwnerToGenericResourceNameOverride(options?.owner),
16
17
  };
17
18
  };
@@ -10,32 +10,39 @@ export function isWebSocketAuthenticateMessage(event) {
10
10
  }
11
11
  export function* askProcessOnAuthenticate(connectionId, accessToken) {
12
12
  const connection = yield* webSocketConnectionData.askGetById(connectionId);
13
- if (connection) {
14
- const apiName = yield* askWebsocketReadApiNameOrThrow();
15
- const userDirectoryName = yield* askConfigGetGlobal(getWebSocketQueueGlobalConfigKeyForUserDirectoryName(apiName));
16
- if (userDirectoryName) {
17
- const result = yield* askCatch(askUserDirectorySetAccessToken(userDirectoryName, accessToken));
18
- if (!result.success) {
19
- yield* askSendMessage(connectionId, {
20
- type: WebSocketQueueServerMessageEventType.Unauthenticated,
21
- });
22
- return;
23
- }
24
- const decodedAccessToken = result.result;
25
- yield* webSocketConnectionData.askUpsert({
26
- ...connection,
27
- userId: decodedAccessToken.userId,
28
- accessToken,
29
- });
13
+ // No connection record (e.g. the connect event hasn't been processed yet)
14
+ // tell the client rather than dropping the request silently, so it can
15
+ // distinguish "not authenticated" from "no reply".
16
+ if (!connection) {
17
+ yield* askSendMessage(connectionId, {
18
+ type: WebSocketQueueServerMessageEventType.Unauthenticated,
19
+ });
20
+ return;
21
+ }
22
+ const apiName = yield* askWebsocketReadApiNameOrThrow();
23
+ const userDirectoryName = yield* askConfigGetGlobal(getWebSocketQueueGlobalConfigKeyForUserDirectoryName(apiName));
24
+ if (userDirectoryName) {
25
+ const result = yield* askCatch(askUserDirectorySetAccessToken(userDirectoryName, accessToken));
26
+ if (!result.success) {
30
27
  yield* askSendMessage(connectionId, {
31
- type: WebSocketQueueServerMessageEventType.Authenticated,
28
+ type: WebSocketQueueServerMessageEventType.Unauthenticated,
32
29
  });
33
- // Send a websocket message to the event buss WITHOUT an access token
34
- const webSocketQueueClientEventMessageAuthenticate = {
35
- type: WebSocketQueueClientMessageEventType.Authenticate,
36
- payload: {},
37
- };
38
- yield* askBroadcastUnknownMessage(webSocketQueueClientEventMessageAuthenticate);
30
+ return;
39
31
  }
32
+ const decodedAccessToken = result.result;
33
+ yield* webSocketConnectionData.askUpsert({
34
+ ...connection,
35
+ userId: decodedAccessToken.userId,
36
+ accessToken,
37
+ });
38
+ yield* askSendMessage(connectionId, {
39
+ type: WebSocketQueueServerMessageEventType.Authenticated,
40
+ });
41
+ // Send a websocket message to the event buss WITHOUT an access token
42
+ const webSocketQueueClientEventMessageAuthenticate = {
43
+ type: WebSocketQueueClientMessageEventType.Authenticate,
44
+ payload: {},
45
+ };
46
+ yield* askBroadcastUnknownMessage(webSocketQueueClientEventMessageAuthenticate);
40
47
  }
41
48
  }
@@ -5,6 +5,17 @@ export interface HttpEventHeaders {
5
5
  export interface HttpEventRouteParams {
6
6
  [key: string]: string;
7
7
  }
8
+ export declare enum FileUploadErrorTypeEnum {
9
+ fileTooLarge = "fileTooLarge",
10
+ tooManyFiles = "tooManyFiles",
11
+ tooManyFields = "tooManyFields",
12
+ disallowedMimeType = "disallowedMimeType",
13
+ malformed = "malformed"
14
+ }
15
+ export interface HTTPEventFileUploadError {
16
+ errorType: FileUploadErrorTypeEnum;
17
+ message: string;
18
+ }
8
19
  export interface HTTPEvent {
9
20
  path: string;
10
21
  query: {
@@ -17,6 +28,7 @@ export interface HTTPEvent {
17
28
  sourceIp: string;
18
29
  isBase64Encoded: boolean;
19
30
  files?: QPQBinaryData[];
31
+ fileUploadError?: HTTPEventFileUploadError;
20
32
  }
21
33
  export interface HTTPEventResponse {
22
34
  status: number;
@@ -1,4 +1,11 @@
1
- export {};
1
+ export var FileUploadErrorTypeEnum;
2
+ (function (FileUploadErrorTypeEnum) {
3
+ FileUploadErrorTypeEnum["fileTooLarge"] = "fileTooLarge";
4
+ FileUploadErrorTypeEnum["tooManyFiles"] = "tooManyFiles";
5
+ FileUploadErrorTypeEnum["tooManyFields"] = "tooManyFields";
6
+ FileUploadErrorTypeEnum["disallowedMimeType"] = "disallowedMimeType";
7
+ FileUploadErrorTypeEnum["malformed"] = "malformed";
8
+ })(FileUploadErrorTypeEnum || (FileUploadErrorTypeEnum = {}));
2
9
  // type ParseParamType<S extends string> = S extends `${infer ParamName}:number`
3
10
  // ? { name: ParamName; type: number }
4
11
  // : S extends `${infer ParamName}:int`
@@ -4,6 +4,14 @@ import { HTTPEvent, HTTPEventResponse } from '../types/HTTPEvent';
4
4
  export declare const rawFromJsonEventRequest: (httpJsonEvent: HTTPEvent) => string | undefined;
5
5
  export declare const fromJsonEventRequest: <T>(httpJsonEvent: HTTPEvent) => T;
6
6
  export declare function askFromJsonEventRequest<T>(httpJsonEvent: HTTPEvent): AskResponse<T>;
7
+ /**
8
+ * Like `askFromJsonEventRequest`, but the parsed body is run through an app-supplied
9
+ * validator before it is returned - so the `T` is actually checked, not just cast.
10
+ * The validator throws (or returns the typed value); a validation throw becomes an
11
+ * `Invalid` (422) response. Any schema library fits, e.g. zod: `(data) => schema.parse(data)`.
12
+ */
13
+ export declare function askFromValidJsonEventRequest<T>(httpJsonEvent: HTTPEvent, validate: (data: unknown) => T): AskResponse<T>;
14
+ export declare const readUriQueryParamFromEvent: (event: HTTPEvent, paramName: string) => string | undefined;
7
15
  export declare const toJsonEventResponse: (item: any, status?: number) => HTTPEventResponse;
8
16
  export declare const toHtmlResponse: (html: string, status?: number) => HTTPEventResponse;
9
17
  export declare const toTextResponse: (text: string, status?: number) => HTTPEventResponse;
@@ -33,6 +33,28 @@ export function* askFromJsonEventRequest(httpJsonEvent) {
33
33
  return yield* askThrowError(ErrorTypeEnum.BadRequest, 'Unable to parse incoming json from HTTPEvent.');
34
34
  }
35
35
  }
36
+ /**
37
+ * Like `askFromJsonEventRequest`, but the parsed body is run through an app-supplied
38
+ * validator before it is returned - so the `T` is actually checked, not just cast.
39
+ * The validator throws (or returns the typed value); a validation throw becomes an
40
+ * `Invalid` (422) response. Any schema library fits, e.g. zod: `(data) => schema.parse(data)`.
41
+ */
42
+ export function* askFromValidJsonEventRequest(httpJsonEvent, validate) {
43
+ const parsedBody = yield* askFromJsonEventRequest(httpJsonEvent);
44
+ try {
45
+ return validate(parsedBody);
46
+ }
47
+ catch (error) {
48
+ return yield* askThrowError(ErrorTypeEnum.Invalid, error instanceof Error ? error.message : 'Invalid request body.');
49
+ }
50
+ }
51
+ export const readUriQueryParamFromEvent = (event, paramName) => {
52
+ const rawValue = event.query[paramName];
53
+ if (!rawValue) {
54
+ return undefined;
55
+ }
56
+ return Array.isArray(rawValue) ? rawValue[0] : rawValue;
57
+ };
36
58
  export const toJsonEventResponse = (item, status = 200) => {
37
59
  return {
38
60
  status,
@@ -10,7 +10,7 @@ const isAbsoluteUrl = (url) => /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url);
10
10
  // Join basePath + url like axios' combineURLs (path concatenation), NOT URL
11
11
  // resolution. `new URL('/v1/x', 'http://host/api/svc')` drops the base path
12
12
  // because a leading-slash url is root-absolute; we want '.../api/svc/v1/x'.
13
- const combineUrls = (basePath, url) => url ? `${basePath.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}` : basePath;
13
+ const combineUrls = (basePath, url) => (url ? `${basePath.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}` : basePath);
14
14
  const buildRequestUrl = (payload) => {
15
15
  const fullUrl = payload.basePath && !isAbsoluteUrl(payload.url) ? combineUrls(payload.basePath, payload.url) : payload.url;
16
16
  const url = new URL(fullUrl);
@@ -1,5 +1,5 @@
1
1
  import { QPQConfig, QpqFunctionRuntime } from 'quidproquo-core';
2
- import { ApiKeyQPQWebServerConfigSetting, CacheQPQWebServerConfigSetting, CertificateQPQWebServerConfigSetting, DefaultRouteOptionsQPQWebServerConfigSetting, DnsQPQWebServerConfigSetting, DomainProxyQPQWebServerConfigSetting, OpenApiQPQWebServerConfigSetting, RouteQPQWebServerConfigSetting, SeoQPQWebServerConfigSetting, ServiceFunctionQPQWebServerConfigSetting, SubdomainRedirectQPQWebServerConfigSetting, WebSocketQPQWebServerConfigSetting } from '../config';
2
+ import { ApiKeyQPQWebServerConfigSetting, CacheQPQWebServerConfigSetting, CertificateQPQWebServerConfigSetting, DefaultRouteOptionsQPQWebServerConfigSetting, DnsQPQWebServerConfigSetting, DomainProxyQPQWebServerConfigSetting, FileUploadSettings, OpenApiQPQWebServerConfigSetting, RouteQPQWebServerConfigSetting, SeoQPQWebServerConfigSetting, ServiceFunctionQPQWebServerConfigSetting, SubdomainRedirectQPQWebServerConfigSetting, WebSocketQPQWebServerConfigSetting } from '../config';
3
3
  import { ApiQPQWebServerConfigSetting, WebEntryQPQWebServerConfigSetting } from '../config';
4
4
  export declare const getAllRoutes: (qpqConfig: QPQConfig) => RouteQPQWebServerConfigSetting[];
5
5
  export declare const getAllRoutesForApi: (apiName: string, qpqConfig: QPQConfig) => RouteQPQWebServerConfigSetting[];
@@ -40,6 +40,15 @@ export declare const resolveServiceScopedCorsAllowedOrigins: (qpqConfig: QPQConf
40
40
  * `defineStorageDriveCorsSettings` wins; otherwise the service-scoped default.
41
41
  */
42
42
  export declare const getStorageDriveCorsAllowedOrigins: (qpqConfig: QPQConfig, storageDriveName: string) => string[];
43
+ /**
44
+ * Whether any web entry in this service serves its assets from the named storage
45
+ * drive (`storageDrive.sourceStorageDrive`) — i.e. the drive's bucket is a
46
+ * CloudFront origin. Drives with no web entry consumer need no CloudFront access
47
+ * to their bucket at all.
48
+ */
49
+ export declare const isStorageDriveWebEntryOrigin: (qpqConfig: QPQConfig, storageDriveName: string) => boolean;
50
+ export declare const defaultFileUploadSettings: FileUploadSettings;
51
+ export declare const getFileUploadSettings: (qpqConfig: QPQConfig) => FileUploadSettings;
43
52
  export declare const resolveApexDomainNameFromDomainConfig: (qpqConfig: QPQConfig, rootDomain: string, onRootDomain: boolean) => string;
44
53
  export declare const constructServiceDomainName: (rootDomain: string, environment: string, service: string, feature?: string) => string;
45
54
  export declare const constructEnvironmentDomainName: (environment: string, domain: string) => string;
@@ -147,6 +147,30 @@ export const getStorageDriveCorsAllowedOrigins = (qpqConfig, storageDriveName) =
147
147
  .find((setting) => setting.storageDriveName === storageDriveName);
148
148
  return resolveServiceScopedCorsAllowedOrigins(qpqConfig, corsSetting?.allowedOrigins);
149
149
  };
150
+ /**
151
+ * Whether any web entry in this service serves its assets from the named storage
152
+ * drive (`storageDrive.sourceStorageDrive`) — i.e. the drive's bucket is a
153
+ * CloudFront origin. Drives with no web entry consumer need no CloudFront access
154
+ * to their bucket at all.
155
+ */
156
+ export const isStorageDriveWebEntryOrigin = (qpqConfig, storageDriveName) => {
157
+ return getWebEntryConfigs(qpqConfig).some((webEntry) => webEntry.storageDrive.sourceStorageDrive === storageDriveName);
158
+ };
159
+ // API Gateway caps request payloads at 10MB, so the default per-file ceiling matches it;
160
+ // the other defaults exist to bound parser memory rather than to be hit in practice.
161
+ export const defaultFileUploadSettings = {
162
+ maxFileSizeBytes: 10 * 1024 * 1024,
163
+ maxFileCount: 10,
164
+ maxFieldCount: 100,
165
+ maxFieldSizeBytes: 1024 * 1024,
166
+ };
167
+ export const getFileUploadSettings = (qpqConfig) => {
168
+ const setting = qpqCoreUtils.getConfigSetting(qpqConfig, QPQWebServerConfigSettingType.FileUploadSettings);
169
+ return {
170
+ ...defaultFileUploadSettings,
171
+ ...Object.fromEntries(Object.entries(setting?.fileUploadSettings || {}).filter(([, value]) => isDefined(value))),
172
+ };
173
+ };
150
174
  export const resolveApexDomainNameFromDomainConfig = (qpqConfig, rootDomain, onRootDomain) => {
151
175
  const feature = qpqCoreUtils.getApplicationModuleFeature(qpqConfig);
152
176
  const environment = qpqCoreUtils.getApplicationModuleEnvironment(qpqConfig);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quidproquo-webserver",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "",
5
5
  "main": "./lib/commonjs/index.js",
6
6
  "module": "./lib/esm/index.js",
@@ -33,10 +33,10 @@
33
33
  "homepage": "https://github.com/joe-coady/quidproquo#readme",
34
34
  "dependencies": {
35
35
  "@anthropic-ai/sdk": "^0.19.1",
36
- "quidproquo-core": "0.1.1"
36
+ "quidproquo-core": "0.1.3"
37
37
  },
38
38
  "devDependencies": {
39
- "quidproquo-tsconfig": "0.1.1",
39
+ "quidproquo-tsconfig": "0.1.3",
40
40
  "typescript": "^5.8.2"
41
41
  }
42
42
  }