@appweaver/core 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/export/export-service.d.ts +4 -3
  2. package/export/export-service.js +33 -22
  3. package/factory/create-model.js +36 -21
  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 +1 -1
  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/storage/file-service.d.ts +19 -4
  36. package/storage/file-service.js +199 -146
  37. package/storage/resources/file/model.js +4 -2
  38. package/types/auth.d.ts +8 -8
  39. package/types/generated.d.ts +8 -2
  40. package/types/index.d.ts +1 -0
  41. package/types/index.js +1 -0
  42. package/types/storage.d.ts +16 -0
  43. package/types/storage.js +2 -0
  44. package/utils/index.d.ts +1 -0
  45. package/utils/index.js +1 -0
  46. package/utils/model-util.d.ts +12 -0
  47. package/utils/model-util.js +113 -0
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.queryFingerprint = queryFingerprint;
4
+ exports.encodeCursor = encodeCursor;
5
+ exports.pageCursors = pageCursors;
6
+ exports.decodeCursor = decodeCursor;
7
+ const common_1 = require("@appweaver/common");
8
+ const errors_1 = require("../../errors");
9
+ /**
10
+ * Builds the fingerprint identifying the query a cursor belongs to. A cursor
11
+ * only yields the intended records while the resource, the query, and the order
12
+ * stay the same, so it carries the fingerprint and is rejected on mismatch.
13
+ *
14
+ * @param {string} resourceName - The name of the queried model.
15
+ * @param {Object} query - The mapped database query the cursor was issued for.
16
+ * @param {Object[]} orderBy - The mapped order entries the cursor was issued for.
17
+ * @return {string} The fingerprint of the query.
18
+ */
19
+ function queryFingerprint(resourceName, query, orderBy) {
20
+ const serialized = stableStringify([resourceName, query ?? {}, orderBy]);
21
+ return (0, common_1.makeHash)(serialized, 'sha256', 'base64url').slice(0, 16);
22
+ }
23
+ /**
24
+ * Encodes the cursor of a page adjacent to a query result. The direction belongs
25
+ * to the cursor rather than to the request, so a caller cannot pair one with a
26
+ * direction it was not issued for.
27
+ *
28
+ * @param {ResourceId} id - The primary key of the record the page continues from.
29
+ * @param {string} fingerprint - The fingerprint of the query, as built by
30
+ * {@link queryFingerprint}.
31
+ * @param {boolean} [backward] - Whether the cursor pages towards the preceding
32
+ * records.
33
+ * @return {string} The encoded cursor.
34
+ */
35
+ function encodeCursor(id, fingerprint, backward = false) {
36
+ const payload = { i: id, f: fingerprint };
37
+ if (backward) {
38
+ payload.b = true;
39
+ }
40
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
41
+ }
42
+ /**
43
+ * Builds the cursors of the pages adjacent to a returned page, each addressing
44
+ * the record of this page it continues from. A page that has no neighbour in a
45
+ * direction, and an empty page, yield null rather than an absent cursor.
46
+ *
47
+ * @param {Object[]} resources - The records of the returned page, in the order
48
+ * they were queried in.
49
+ * @param {string} fingerprint - The fingerprint of the query the page belongs to.
50
+ * @param {boolean} hasNext - Whether a page follows the returned one.
51
+ * @param {boolean} hasPrev - Whether a page precedes the returned one.
52
+ * @return {PageCursors} The cursors of the existing adjacent pages.
53
+ */
54
+ function pageCursors(resources, fingerprint, hasNext, hasPrev) {
55
+ // A queried record always carries its primary key, which a model type need
56
+ // not declare
57
+ const first = resources[0];
58
+ const last = resources[resources.length - 1];
59
+ if (!first || !last) {
60
+ return { nextCursor: null, prevCursor: null };
61
+ }
62
+ return {
63
+ nextCursor: hasNext ? encodeCursor(last.id, fingerprint) : null,
64
+ prevCursor: hasPrev ? encodeCursor(first.id, fingerprint, true) : null
65
+ };
66
+ }
67
+ /**
68
+ * Decodes the cursor a query request carries, resolving the record the page
69
+ * continues from and the direction it runs in.
70
+ *
71
+ * @param {string} [cursor] - The cursor of the request, which may be the null
72
+ * one of a page that does not exist.
73
+ * @param {string} fingerprint - The fingerprint of the query the cursor is used
74
+ * on, as built by {@link queryFingerprint}.
75
+ * @return {DecodedCursor | undefined} The decoded cursor, or `undefined` when the
76
+ * request carries none.
77
+ * @throws {HttpError} 400 if the cursor is malformed, or was issued for another
78
+ * resource, filter, or sort order.
79
+ */
80
+ function decodeCursor(cursor, fingerprint) {
81
+ if (!cursor) {
82
+ return undefined;
83
+ }
84
+ let payload;
85
+ try {
86
+ payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
87
+ }
88
+ catch (e) {
89
+ throw new errors_1.HttpError('Invalid pagination cursor', 400, e);
90
+ }
91
+ if (!(0, common_1.isPlainObject)(payload) || payload.i === undefined) {
92
+ throw new errors_1.HttpError('Invalid pagination cursor', 400);
93
+ }
94
+ if (payload.f !== fingerprint) {
95
+ throw new errors_1.HttpError('Pagination cursor does not match the filter and sort of this query', 400);
96
+ }
97
+ return { id: payload.i, backward: payload.b === true };
98
+ }
99
+ /**
100
+ * Serializes a value with its object keys in a stable order, so two equal
101
+ * queries fingerprint identically whatever order their properties arrived in.
102
+ *
103
+ * @param {any} value - The value to serialize.
104
+ * @return {string} The serialized value.
105
+ */
106
+ function stableStringify(value) {
107
+ if ((0, common_1.isArray)(value)) {
108
+ return `[${value.map(stableStringify).join(',')}]`;
109
+ }
110
+ if ((0, common_1.isPlainObject)(value)) {
111
+ const entries = Object.keys(value)
112
+ .sort()
113
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
114
+ return `{${entries.join(',')}}`;
115
+ }
116
+ return JSON.stringify(value) ?? 'null';
117
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './aggregate-util';
2
+ export * from './cursor-util';
2
3
  export * from './filter-util';
3
4
  export * from './relation-util';
4
5
  export * from './sort-util';
@@ -15,6 +15,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./aggregate-util"), exports);
18
+ __exportStar(require("./cursor-util"), exports);
18
19
  __exportStar(require("./filter-util"), exports);
19
20
  __exportStar(require("./relation-util"), exports);
20
21
  __exportStar(require("./sort-util"), exports);
@@ -1,4 +1,4 @@
1
- import { ActionType } from '@appweaver/common';
1
+ import { ActionType, ResourceId } from '@appweaver/common';
2
2
  /** The nested write actions a single relation field can be mapped to. */
3
3
  export type RelationActions = Record<string, Partial<{
4
4
  connect: any;
@@ -74,11 +74,11 @@ export declare function missingRelationFields(resourceName: string | undefined,
74
74
  * user. Returns undefined when the model does not audit the `createdById` field or when no user is authenticated.
75
75
  *
76
76
  * @param {string} resourceName - The name of the model the audit relation is built for.
77
- * @return {{connect: {id: number}}|undefined} The connect action pointing at the id of the currently authenticated
77
+ * @return {{connect: {id: ResourceId}}|undefined} The connect action pointing at the id of the currently authenticated
78
78
  * user, or undefined if the model does not audit the `createdById` field or no user is authenticated.
79
79
  */
80
80
  export declare function createdByConnect(resourceName: string): {
81
81
  connect: {
82
- id: number;
82
+ id: ResourceId;
83
83
  };
84
84
  } | undefined;
@@ -276,7 +276,7 @@ function missingRelationFields(resourceName, data) {
276
276
  * user. Returns undefined when the model does not audit the `createdById` field or when no user is authenticated.
277
277
  *
278
278
  * @param {string} resourceName - The name of the model the audit relation is built for.
279
- * @return {{connect: {id: number}}|undefined} The connect action pointing at the id of the currently authenticated
279
+ * @return {{connect: {id: ResourceId}}|undefined} The connect action pointing at the id of the currently authenticated
280
280
  * user, or undefined if the model does not audit the `createdById` field or no user is authenticated.
281
281
  */
282
282
  function createdByConnect(resourceName) {
@@ -18,3 +18,14 @@ import { ActionType, QuerySort } from '@appweaver/common';
18
18
  * direction, or targets a relation that the action does not include in its response.
19
19
  */
20
20
  export declare function mapSortValues(sort: QuerySort, resourceName: string, action?: ActionType): any[];
21
+ /**
22
+ * Maps a sort input the same way {@link mapSortValues} does, and terminates it with the primary key when no entry
23
+ * orders by it. Without a total order, records sharing every sort value can be skipped or repeated across pages.
24
+ *
25
+ * @param {QuerySort} sort - The sort input to map, as a comma-separated field list or a sort object.
26
+ * @param {string} resourceName - The name of the model the sort is applied on.
27
+ * @param {ActionType} [action] - The action the sort is applied on. Defaults to the query action.
28
+ * @return {Object[]} The `orderBy` entries, ending in a unique one.
29
+ * @throws {HttpError} 400 under the same conditions as {@link mapSortValues}.
30
+ */
31
+ export declare function mapStableSortValues(sort: QuerySort, resourceName: string, action?: ActionType): any[];
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.mapSortValues = mapSortValues;
4
+ exports.mapStableSortValues = mapStableSortValues;
4
5
  const common_1 = require("@appweaver/common");
5
6
  const context_1 = require("../../context");
6
7
  const errors_1 = require("../../errors");
@@ -46,6 +47,23 @@ function mapSortValues(sort, resourceName, action = 'query') {
46
47
  }
47
48
  return orderBy;
48
49
  }
50
+ /**
51
+ * Maps a sort input the same way {@link mapSortValues} does, and terminates it with the primary key when no entry
52
+ * orders by it. Without a total order, records sharing every sort value can be skipped or repeated across pages.
53
+ *
54
+ * @param {QuerySort} sort - The sort input to map, as a comma-separated field list or a sort object.
55
+ * @param {string} resourceName - The name of the model the sort is applied on.
56
+ * @param {ActionType} [action] - The action the sort is applied on. Defaults to the query action.
57
+ * @return {Object[]} The `orderBy` entries, ending in a unique one.
58
+ * @throws {HttpError} 400 under the same conditions as {@link mapSortValues}.
59
+ */
60
+ function mapStableSortValues(sort, resourceName, action = 'query') {
61
+ const orderBy = mapSortValues(sort, resourceName, action);
62
+ if (!orderBy.some((entry) => 'id' in entry)) {
63
+ orderBy.push({ id: 'asc' });
64
+ }
65
+ return orderBy;
66
+ }
49
67
  /**
50
68
  * Flattens a sort input into the list of its field paths and directions, keeping the order the fields were declared
51
69
  * in. String inputs are split on commas, with a `-` or `+` prefix selecting the direction, and object inputs are
@@ -26,7 +26,9 @@ exports.apiKeyAuth = (0, fastify_plugin_1.default)(async (server) => {
26
26
  // Use configured delimiter to split an API key and separate ID from the
27
27
  // rest of the key value
28
28
  const apiKeyParts = sanitizedApiKey.split(common_1.config.SECURITY_API_KEY_DELIMITER);
29
- const apiKeyId = parseInt(apiKeyParts.shift() ?? '', 10);
29
+ // The id prefix is read back in the primary key type of the ApiKey model,
30
+ // which can be configured as a string like on any other model
31
+ const apiKeyId = (0, common_1.toResourceId)(apiKeyParts.shift() ?? '', (0, context_1.injectModel)('ApiKey', false)?.config?.id);
30
32
  const apiKeyValue = apiKeyParts.join(common_1.config.SECURITY_API_KEY_DELIMITER);
31
33
  const cacheKey = cacheService.buildCacheKey({
32
34
  baseKey: `apikey:${apiKeyId}`,
@@ -35,6 +37,7 @@ exports.apiKeyAuth = (0, fastify_plugin_1.default)(async (server) => {
35
37
  let apiKey = await cacheService.getCachedValue(cacheKey);
36
38
  if (!apiKey) {
37
39
  try {
40
+ // The generated client types the id after the configured primary key
38
41
  apiKey = await db
39
42
  .client()
40
43
  .apiKey.findFirst({ where: { id: apiKeyId } });
@@ -1,14 +1,14 @@
1
- import { AuthScope, AuthSource, AuthUser, RouteConfig } from '@appweaver/common';
1
+ import { AuthScope, AuthSource, AuthUser, ResourceId, RouteConfig } from '@appweaver/common';
2
2
  import { AuthTokens, JwtPayload, UserAdditionalData } from '../types';
3
3
  export declare class AuthService {
4
4
  /**
5
5
  * Finds an authenticated user by their unique identifier.
6
6
  *
7
- * @param {number} id - The unique identifier of the authenticated user to find.
7
+ * @param {ResourceId} id - The unique identifier of the authenticated user to find.
8
8
  * @return {Promise<AuthUser | null>} A promise that resolves to the authenticated user object if found, otherwise
9
9
  * null.
10
10
  */
11
- findById(id: number): Promise<AuthUser | null>;
11
+ findById(id: ResourceId): Promise<AuthUser | null>;
12
12
  /**
13
13
  * Retrieves a user by their username.
14
14
  *
@@ -19,12 +19,12 @@ export declare class AuthService {
19
19
  /**
20
20
  * Updates an authenticated user's information in the system.
21
21
  *
22
- * @param {number} id - The unique identifier of the authenticated user to be updated.
22
+ * @param {ResourceId} id - The unique identifier of the authenticated user to be updated.
23
23
  * @param {Partial<AuthUser> & { password?: string }} data - The partial user data to update, optionally including a
24
24
  * password.
25
25
  * @return {Promise<AuthUser>} A promise that resolves to the updated authenticated user object.
26
26
  */
27
- updateAuthUser(id: number, data: Partial<AuthUser> & {
27
+ updateAuthUser(id: ResourceId, data: Partial<AuthUser> & {
28
28
  password?: string;
29
29
  }): Promise<AuthUser>;
30
30
  /**
@@ -112,9 +112,9 @@ export declare class AuthService {
112
112
  /**
113
113
  * Logs out a user by updating their authentication information with a logout timestamp.
114
114
  *
115
- * @param {number} id - The unique identifier of the user to be logged out.
115
+ * @param {ResourceId} id - The unique identifier of the user to be logged out.
116
116
  * @return {Promise<boolean>} A promise that resolves to a boolean indicating whether the logout operation was
117
117
  * successful.
118
118
  */
119
- logout(id: number): Promise<boolean>;
119
+ logout(id: ResourceId): Promise<boolean>;
120
120
  }
@@ -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;
@@ -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
  }