@appweaver/core 1.3.1 → 1.4.1

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 (48) hide show
  1. package/export/export-service.d.ts +4 -3
  2. package/export/export-service.js +39 -22
  3. package/factory/create-model.js +54 -22
  4. package/factory/create-service.js +5 -3
  5. package/package.json +2 -2
  6. package/prisma/client/commonInputTypes.d.ts +0 -50
  7. package/prisma/client/internal/class.js +3 -3
  8. package/prisma/client/models/File.d.ts +15 -28
  9. package/resource/resource-loader.js +6 -0
  10. package/resource/resource-routes.js +2 -2
  11. package/resource/resource-schema.d.ts +38 -12
  12. package/resource/resource-schema.js +63 -15
  13. package/resource/resource-service.d.ts +25 -16
  14. package/resource/resource-service.js +61 -26
  15. package/resource/schemas/resource-sort-schema.js +2 -2
  16. package/resource/utils/cursor-util.d.ts +60 -0
  17. package/resource/utils/cursor-util.js +117 -0
  18. package/resource/utils/index.d.ts +1 -0
  19. package/resource/utils/index.js +1 -0
  20. package/resource/utils/relation-util.d.ts +3 -3
  21. package/resource/utils/relation-util.js +52 -16
  22. package/resource/utils/sort-util.d.ts +11 -0
  23. package/resource/utils/sort-util.js +18 -0
  24. package/security/api-key/api-key-auth.js +4 -1
  25. package/security/auth-service.d.ts +7 -7
  26. package/security/auth-service.js +43 -5
  27. package/security/create-auth-resources.d.ts +2 -1
  28. package/security/oauth2/create-oauth2-plugin.js +3 -34
  29. package/security/oauth2/oauth2-microsoft.js +1 -1
  30. package/security/oauth2/oauth2-util.d.ts +9 -7
  31. package/security/oauth2/oauth2-util.js +17 -10
  32. package/security/resources/api-key/model.js +2 -0
  33. package/security/resources/connected-account/model.js +2 -0
  34. package/security/store/database-security-store.js +2 -1
  35. package/server/swagger.js +49 -1
  36. package/storage/file-service.d.ts +19 -4
  37. package/storage/file-service.js +199 -146
  38. package/storage/resources/file/model.js +4 -2
  39. package/types/auth.d.ts +8 -8
  40. package/types/generated.d.ts +8 -2
  41. package/types/index.d.ts +1 -0
  42. package/types/index.js +1 -0
  43. package/types/storage.d.ts +16 -0
  44. package/types/storage.js +2 -0
  45. package/utils/index.d.ts +1 -0
  46. package/utils/index.js +1 -0
  47. package/utils/model-util.d.ts +12 -0
  48. package/utils/model-util.js +113 -0
@@ -6,6 +6,7 @@ const helper_1 = require("./helper");
6
6
  const oauth2_service_1 = require("./oauth2/oauth2-service");
7
7
  const context_1 = require("../context");
8
8
  const cache_1 = require("../cache");
9
+ const storage_1 = require("../storage");
9
10
  const errors_1 = require("../errors");
10
11
  const AUTH_KEY = 'auth';
11
12
  class AuthService {
@@ -21,7 +22,7 @@ class AuthService {
21
22
  /**
22
23
  * Finds an authenticated user by their unique identifier.
23
24
  *
24
- * @param {number} id - The unique identifier of the authenticated user to find.
25
+ * @param {ResourceId} id - The unique identifier of the authenticated user to find.
25
26
  * @return {Promise<AuthUser | null>} A promise that resolves to the authenticated user object if found, otherwise
26
27
  * null.
27
28
  */
@@ -80,7 +81,7 @@ class AuthService {
80
81
  /**
81
82
  * Updates an authenticated user's information in the system.
82
83
  *
83
- * @param {number} id - The unique identifier of the authenticated user to be updated.
84
+ * @param {ResourceId} id - The unique identifier of the authenticated user to be updated.
84
85
  * @param {Partial<AuthUser> & { password?: string }} data - The partial user data to update, optionally including a
85
86
  * password.
86
87
  * @return {Promise<AuthUser>} A promise that resolves to the updated authenticated user object.
@@ -106,10 +107,11 @@ class AuthService {
106
107
  * @throws {HttpError} Throws an error if the registration process fails.
107
108
  */
108
109
  async registerAuthUser(source, email, password, data) {
110
+ let authUser;
109
111
  try {
110
112
  const serviceConfig = this._authUserService[common_1.CONFIG];
111
- const registrationData = serviceConfig.registrationData(source, email, password, data);
112
- return this._authUserService.create({
113
+ const registrationData = await serviceConfig.registrationData(source, email, password, data);
114
+ authUser = await this._authUserService.create({
113
115
  ...registrationData,
114
116
  verifiedEmail: source !== common_1.AuthSource.Password
115
117
  });
@@ -117,6 +119,42 @@ class AuthService {
117
119
  catch (e) {
118
120
  throw new errors_1.HttpError('Auth user registration error', 500, e);
119
121
  }
122
+ await this.saveRegistrationFiles(authUser, source, data);
123
+ return authUser;
124
+ }
125
+ /**
126
+ * Saves the files the `registrationFiles` service configuration selects for a newly registered user, each one to the
127
+ * file field of the auth model it is keyed by. The files are stored only after the user exists, since a file record
128
+ * is linked to the resource that owns it. Storing a file is best-effort, so a rejected file, such as an avatar the
129
+ * provider served in an unsupported format, is logged and never fails the registration.
130
+ *
131
+ * @internal
132
+ */
133
+ async saveRegistrationFiles(authUser, source, data) {
134
+ const serviceConfig = this._authUserService[common_1.CONFIG];
135
+ if (!serviceConfig.registrationFiles) {
136
+ return;
137
+ }
138
+ let files;
139
+ try {
140
+ files = (await serviceConfig.registrationFiles(source, data)) ?? {};
141
+ }
142
+ catch (e) {
143
+ common_1.logger.error({ id: authUser.id, err: e }, 'Registration files selection error');
144
+ return;
145
+ }
146
+ const fileService = (0, context_1.inject)(storage_1.FileService);
147
+ for (const [field, file] of Object.entries(files)) {
148
+ if (!file) {
149
+ continue;
150
+ }
151
+ try {
152
+ await fileService.saveBuffer(field, file, authUser, this._authUserService.client);
153
+ }
154
+ catch (e) {
155
+ common_1.logger.error({ id: authUser.id, field, err: e }, 'Registration file save error');
156
+ }
157
+ }
120
158
  }
121
159
  /**
122
160
  * Changes the password for the authenticated user.
@@ -292,7 +330,7 @@ class AuthService {
292
330
  /**
293
331
  * Logs out a user by updating their authentication information with a logout timestamp.
294
332
  *
295
- * @param {number} id - The unique identifier of the user to be logged out.
333
+ * @param {ResourceId} id - The unique identifier of the user to be logged out.
296
334
  * @return {Promise<boolean>} A promise that resolves to a boolean indicating whether the logout operation was
297
335
  * successful.
298
336
  */
@@ -1,7 +1,8 @@
1
1
  import { Ctor, IResourceService, ResourceModel, ResourceModelConfig, ResourceServiceConfig } from '@appweaver/common';
2
- import { CheckOAuth2UserFn, RegistrationDataFn } from '../types';
2
+ import { CheckOAuth2UserFn, RegistrationDataFn, RegistrationFilesFn } from '../types';
3
3
  export declare function createAuthModel(config: ResourceModelConfig): ResourceModel;
4
4
  export declare function createAuthService<T = any, C = any, U = any>(config: ResourceServiceConfig<T, C, U> & {
5
5
  registrationData?: RegistrationDataFn<T>;
6
+ registrationFiles?: RegistrationFilesFn;
6
7
  checkOAuth2User?: CheckOAuth2UserFn;
7
8
  }): Ctor<IResourceService<T, T, C, U>>;
@@ -13,6 +13,7 @@ const auth_service_1 = require("../auth-service");
13
13
  const oauth2_service_1 = require("./oauth2-service");
14
14
  const helper_1 = require("../helper");
15
15
  const oauth2_schema_1 = require("./oauth2-schema");
16
+ const oauth2_util_1 = require("./oauth2-util");
16
17
  function createOAuth2Plugin(authSource, oAuth2Config) {
17
18
  const name = authSource.replace('oauth2', '');
18
19
  const upperName = name.toUpperCase();
@@ -100,7 +101,8 @@ function createOAuth2Plugin(authSource, oAuth2Config) {
100
101
  }
101
102
  authUser = await authService.registerAuthUser(authSource, userInfo.email, undefined, {
102
103
  ...(0, common_1.pickProperties)(userInfo, ['firstName', 'lastName', 'avatarUrl']),
103
- avatarFile: userInfo.avatarFile ?? (await fetchAvatarFile(userInfo))
104
+ avatarFile: userInfo.avatarFile ??
105
+ (await (0, oauth2_util_1.fetchAvatarFile)(userInfo.avatarUrl, userInfo.id))
104
106
  });
105
107
  }
106
108
  const stateData = request.oauth2State;
@@ -137,36 +139,3 @@ function createOAuth2Plugin(authSource, oAuth2Config) {
137
139
  });
138
140
  });
139
141
  }
140
- /**
141
- * Downloads the user's avatar image from the OAuth2 provider so it can be passed to the `registrationData` callback.
142
- * Fetching is best-effort: any failure is logged and `undefined` is returned so the registration flow is not blocked.
143
- *
144
- * @param {UserInfo} userInfo - The user info extracted from the OAuth2 provider, including the optional avatar URL.
145
- * @return {Promise<AvatarFile | undefined>} A promise resolving to the downloaded avatar file, or `undefined` when
146
- * avatar fetching is disabled, no avatar URL is available, or the download fails.
147
- */
148
- async function fetchAvatarFile(userInfo) {
149
- if (!common_1.config.SECURITY_OAUTH2_FETCH_AVATAR_ENABLED || !userInfo.avatarUrl) {
150
- return undefined;
151
- }
152
- try {
153
- const resp = await fetch(userInfo.avatarUrl, { method: 'GET' });
154
- if (!resp.ok) {
155
- common_1.logger.debug({ url: userInfo.avatarUrl, status: resp.status }, 'OAuth2 avatar fetch failed');
156
- return undefined;
157
- }
158
- const mimeType = resp.headers.get('content-type') ?? 'image/jpeg';
159
- const data = Buffer.from(await resp.arrayBuffer());
160
- const extension = mimeType.split('/')[1]?.split(';')[0] ?? 'jpg';
161
- return {
162
- name: `avatar-${userInfo.id}.${extension}`,
163
- mimeType,
164
- size: data.length,
165
- data
166
- };
167
- }
168
- catch (e) {
169
- common_1.logger.debug({ url: userInfo.avatarUrl, err: e }, 'OAuth2 avatar fetch error');
170
- return undefined;
171
- }
172
- }
@@ -31,6 +31,6 @@ async function fetchMicrosoftUser(accessToken) {
31
31
  firstName: data.givenName ?? firstName,
32
32
  lastName: data.surname ?? lastName,
33
33
  // Microsoft Graph serves the photo as authenticated binary content, so it has to be downloaded here.
34
- avatarFile: await (0, oauth2_util_1.fetchAuthenticatedAvatar)(`${userInfoUrl}/photo/$value`, accessToken, data.id)
34
+ avatarFile: await (0, oauth2_util_1.fetchAvatarFile)(`${userInfoUrl}/photo/$value`, data.id, accessToken)
35
35
  };
36
36
  }
@@ -6,20 +6,22 @@ import { AvatarFile } from '../../types';
6
6
  * @param {string} url - The user info endpoint to call.
7
7
  * @param {string} accessToken - The access token obtained from the authorization code flow.
8
8
  * @param {Record<string, string>} [headers] - Extra request headers required by the provider.
9
- * @return {Promise<T>} A promise resolving to the parsed response body.
9
+ * @return {Promise<Object>} A promise resolving to the parsed response body.
10
10
  * @throws {HttpError} If the provider responds with a non-2xx status.
11
11
  */
12
12
  export declare function fetchUserInfo<T>(providerName: string, url: string, accessToken: string, headers?: Record<string, string>): Promise<T>;
13
13
  /**
14
- * Downloads an avatar image that is only reachable with the provider's access token, so it cannot be resolved later
15
- * from a plain URL. Fetching is best-effort and returns `undefined` on any failure.
14
+ * Downloads the user's avatar image so it can be passed to the `registrationData` and `registrationFiles` callbacks as
15
+ * `avatarFile`. Fetching is opt-in through `SECURITY_OAUTH2_FETCH_AVATAR_ENABLED` and best-effort: every reason the
16
+ * image does not arrive is logged and `undefined` is returned, so the registration flow is never blocked by it.
16
17
  *
17
- * @param {string} url - The avatar endpoint to call.
18
- * @param {string} accessToken - The access token obtained from the authorization code flow.
18
+ * @param {string | undefined} url - The avatar endpoint to call.
19
19
  * @param {string} id - The provider's user identifier, used to name the file.
20
- * @return {Promise<AvatarFile | undefined>} A promise resolving to the downloaded avatar, or `undefined`.
20
+ * @param {string} [accessToken] - Access token, for providers whose avatar endpoint requires authentication.
21
+ * @return {Promise<AvatarFile | undefined>} A promise resolving to the downloaded avatar, or `undefined` when
22
+ * fetching is disabled, no avatar URL is available, or the download fails.
21
23
  */
22
- export declare function fetchAuthenticatedAvatar(url: string, accessToken: string, id: string): Promise<AvatarFile | undefined>;
24
+ export declare function fetchAvatarFile(url: string | undefined, id: string, accessToken?: string): Promise<AvatarFile | undefined>;
23
25
  /**
24
26
  * Splits a provider's single display name field into a first and last name. Everything after the first whitespace
25
27
  * separated word becomes the last name, so multipart surnames are kept intact.
@@ -34,7 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.fetchUserInfo = fetchUserInfo;
37
- exports.fetchAuthenticatedAvatar = fetchAuthenticatedAvatar;
37
+ exports.fetchAvatarFile = fetchAvatarFile;
38
38
  exports.splitFullName = splitFullName;
39
39
  exports.requireEmail = requireEmail;
40
40
  exports.decodeJwtPayload = decodeJwtPayload;
@@ -51,7 +51,7 @@ const APPLE_TOKEN_AUDIENCE = 'https://appleid.apple.com';
51
51
  * @param {string} url - The user info endpoint to call.
52
52
  * @param {string} accessToken - The access token obtained from the authorization code flow.
53
53
  * @param {Record<string, string>} [headers] - Extra request headers required by the provider.
54
- * @return {Promise<T>} A promise resolving to the parsed response body.
54
+ * @return {Promise<Object>} A promise resolving to the parsed response body.
55
55
  * @throws {HttpError} If the provider responds with a non-2xx status.
56
56
  */
57
57
  async function fetchUserInfo(providerName, url, accessToken, headers = {}) {
@@ -65,24 +65,30 @@ async function fetchUserInfo(providerName, url, accessToken, headers = {}) {
65
65
  return resp.json();
66
66
  }
67
67
  /**
68
- * Downloads an avatar image that is only reachable with the provider's access token, so it cannot be resolved later
69
- * from a plain URL. Fetching is best-effort and returns `undefined` on any failure.
68
+ * Downloads the user's avatar image so it can be passed to the `registrationData` and `registrationFiles` callbacks as
69
+ * `avatarFile`. Fetching is opt-in through `SECURITY_OAUTH2_FETCH_AVATAR_ENABLED` and best-effort: every reason the
70
+ * image does not arrive is logged and `undefined` is returned, so the registration flow is never blocked by it.
70
71
  *
71
- * @param {string} url - The avatar endpoint to call.
72
- * @param {string} accessToken - The access token obtained from the authorization code flow.
72
+ * @param {string | undefined} url - The avatar endpoint to call.
73
73
  * @param {string} id - The provider's user identifier, used to name the file.
74
- * @return {Promise<AvatarFile | undefined>} A promise resolving to the downloaded avatar, or `undefined`.
74
+ * @param {string} [accessToken] - Access token, for providers whose avatar endpoint requires authentication.
75
+ * @return {Promise<AvatarFile | undefined>} A promise resolving to the downloaded avatar, or `undefined` when
76
+ * fetching is disabled, no avatar URL is available, or the download fails.
75
77
  */
76
- async function fetchAuthenticatedAvatar(url, accessToken, id) {
78
+ async function fetchAvatarFile(url, id, accessToken) {
77
79
  if (!common_1.config.SECURITY_OAUTH2_FETCH_AVATAR_ENABLED) {
78
80
  return undefined;
79
81
  }
82
+ if (!url) {
83
+ return undefined;
84
+ }
80
85
  try {
81
86
  const resp = await fetch(url, {
82
87
  method: 'GET',
83
- headers: { authorization: `Bearer ${accessToken}` }
88
+ headers: accessToken ? { authorization: `Bearer ${accessToken}` } : {}
84
89
  });
85
90
  if (!resp.ok) {
91
+ common_1.logger.error({ url, status: resp.status }, 'OAuth2 avatar fetch failed');
86
92
  return undefined;
87
93
  }
88
94
  const mimeType = resp.headers.get('content-type') ?? 'image/jpeg';
@@ -95,7 +101,8 @@ async function fetchAuthenticatedAvatar(url, accessToken, id) {
95
101
  data
96
102
  };
97
103
  }
98
- catch {
104
+ catch (e) {
105
+ common_1.logger.error({ url, err: e }, 'OAuth2 avatar fetch error');
99
106
  return undefined;
100
107
  }
101
108
  }
@@ -44,6 +44,8 @@ exports.default = shouldCreateModel
44
44
  type: 'oneToMany',
45
45
  mappedBy: 'apiKeys',
46
46
  owner: true,
47
+ // API keys are meaningless without their user
48
+ onDelete: 'cascade',
47
49
  input: {
48
50
  type: 'none'
49
51
  },
@@ -37,6 +37,8 @@ exports.default = shouldCreateModel
37
37
  type: 'oneToMany',
38
38
  mappedBy: 'connectedAccounts',
39
39
  owner: true,
40
+ // Connected accounts are meaningless without their user
41
+ onDelete: 'cascade',
40
42
  input: {
41
43
  type: 'none'
42
44
  },
@@ -45,7 +45,8 @@ class DatabaseSecurityStore extends common_1.SecurityStore {
45
45
  }
46
46
  /** @internal */
47
47
  async removeOneTimeToken(id) {
48
- await this._db.client().oneTimeToken.delete({ where: { id } });
48
+ // The generated client types the id after the configured primary key
49
+ await this._db.client().oneTimeToken.delete({ where: { id: id } });
49
50
  }
50
51
  }
51
52
  exports.DatabaseSecurityStore = DatabaseSecurityStore;
package/server/swagger.js CHANGED
@@ -11,7 +11,7 @@ const context_1 = require("../context");
11
11
  exports.default = (0, fastify_plugin_1.default)((server) => {
12
12
  server.register(swagger_1.default, {
13
13
  hideUntagged: common_1.config.SWAGGER_HIDE_UNTAGGED,
14
- transformObject: (document) => addConfig(normalizeUnionTypes(pruneUnusedSchemas(document))),
14
+ transformObject: (document) => addConfig(normalizeUnionTypes(pruneUnusedSchemas(inlineNullableRefs(document)))),
15
15
  openapi: {
16
16
  info: {
17
17
  title: common_1.config.APP_NAME,
@@ -64,6 +64,54 @@ exports.default = (0, fastify_plugin_1.default)((server) => {
64
64
  });
65
65
  }
66
66
  });
67
+ /**
68
+ * Replaces every reference to a nullable model variant with the union it stands
69
+ * for. Each model registers a `<Model>SingleNullable` schema the response
70
+ * serializer needs as a name of its own, since it cannot compile an inline
71
+ * union that cycles back to a model it is already writing. The document says
72
+ * the same thing without that indirection, leaving the variants unreferenced
73
+ * for {@link pruneUnusedSchemas} to drop.
74
+ *
75
+ * @param {Object} document The transform argument of the Swagger plugin,
76
+ * wrapping the OpenAPI document in its `openapiObject` property.
77
+ * @returns {Object} The same argument, with every reference to a nullable model
78
+ * variant replaced in place.
79
+ */
80
+ function inlineNullableRefs(document) {
81
+ const schemas = document.openapiObject?.components?.schemas ?? {};
82
+ // The schemas are registered under generated names, so the variants are
83
+ // recognized by the title carrying the name they were declared with
84
+ const variants = new Map();
85
+ for (const [name, schema] of Object.entries(schemas)) {
86
+ if (/SingleNullable$/.test(schema?.title ?? '') && (0, common_1.isArray)(schema?.anyOf)) {
87
+ variants.set(`#/components/schemas/${name}`, schema.anyOf);
88
+ }
89
+ }
90
+ if (variants.size === 0) {
91
+ return document;
92
+ }
93
+ const visit = (node) => {
94
+ if (!node || typeof node !== 'object') {
95
+ return;
96
+ }
97
+ if ((0, common_1.isArray)(node)) {
98
+ for (const item of node)
99
+ visit(item);
100
+ return;
101
+ }
102
+ for (const value of Object.values(node)) {
103
+ visit(value);
104
+ }
105
+ const union = variants.get(node.$ref);
106
+ if (union) {
107
+ delete node.$ref;
108
+ node.anyOf = structuredClone(union);
109
+ }
110
+ };
111
+ visit(document.openapiObject?.paths);
112
+ visit(schemas);
113
+ return document;
114
+ }
67
115
  /**
68
116
  * Rewrites the JSON Schema type lists of the document into the equivalent
69
117
  * `anyOf` unions. The query filter schemas declare their plain values as a
@@ -1,6 +1,6 @@
1
1
  import { Multipart, MultipartFile } from '@fastify/multipart';
2
- import { ContentStream, Resource, ResourceClient } from '@appweaver/common';
3
- import { File } from '../types';
2
+ import { ContentStream, Resource, ResourceClient, ResourceId } from '@appweaver/common';
3
+ import { File, FileBuffer } from '../types';
4
4
  export type FileStream = {
5
5
  content: ContentStream;
6
6
  fileName: string;
@@ -41,6 +41,21 @@ export declare class FileService {
41
41
  * @throws {HttpError} Throws an error if file validation, storage, or resource association fails.
42
42
  */
43
43
  saveFile(data: MultipartFile, resource: Resource, client: ResourceClient): Promise<File>;
44
+ /**
45
+ * Saves an in-memory file to storage and associates it with a file field of a specific resource. Behaves exactly like
46
+ * {@link saveFile}, except the content is taken from a buffer instead of a multipart upload, which makes it usable
47
+ * outside a file upload request, for an avatar downloaded from an OAuth2 provider or an image the application
48
+ * generates itself.
49
+ *
50
+ * @param {string} fieldName The file field of the resource the file is saved to.
51
+ * @param {FileBuffer} file The file content, along with its original name and media type.
52
+ * @param {Resource} resource The resource object with which the file is being associated.
53
+ * @param {ResourceClient} client The database client responsible for handling the resource.
54
+ * @return {Promise<File>} A promise that resolves to the saved file object or rejects with an error if the operation
55
+ * fails.
56
+ * @throws {HttpError} Throws an error if file validation, storage, or resource association fails.
57
+ */
58
+ saveBuffer(fieldName: string, file: FileBuffer, resource: Resource, client: ResourceClient): Promise<File>;
44
59
  /**
45
60
  * Saves files received as an asynchronous iterable of multipart data.
46
61
  *
@@ -85,8 +100,8 @@ export declare class FileService {
85
100
  * setting (or set to `'keep'`) are left untouched.
86
101
  *
87
102
  * @param {string} resourceName - The resource model name.
88
- * @param {number} resourceId - The ID of the deleted resource.
103
+ * @param {ResourceId} id - The ID of the deleted resource.
89
104
  * @return {Promise<File[]>} A promise that resolves to the list of successfully deleted files.
90
105
  */
91
- deleteResourceFiles(resourceName: string, resourceId: number): Promise<File[]>;
106
+ deleteResourceFiles(resourceName: string, id: ResourceId): Promise<File[]>;
92
107
  }