@technomoron/api-server-base 2.0.0-beta.19 → 2.0.0-beta.20

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 (73) hide show
  1. package/dist/cjs/api-server-base.cjs +48 -21
  2. package/dist/cjs/auth-api/auth-module.js +16 -30
  3. package/dist/cjs/auth-api/mem-auth-store.js +5 -4
  4. package/dist/cjs/auth-api/sql-auth-store.js +6 -4
  5. package/dist/cjs/auth-api/user-id.d.ts +1 -0
  6. package/dist/cjs/auth-api/user-id.js +7 -0
  7. package/dist/cjs/auth-cookie-options.js +10 -1
  8. package/dist/cjs/oauth/memory.d.ts +6 -0
  9. package/dist/cjs/oauth/memory.js +43 -4
  10. package/dist/cjs/oauth/models.d.ts +1 -0
  11. package/dist/cjs/oauth/models.js +7 -18
  12. package/dist/cjs/oauth/sequelize.d.ts +5 -52
  13. package/dist/cjs/oauth/sequelize.js +34 -93
  14. package/dist/cjs/oauth/types.d.ts +1 -0
  15. package/dist/cjs/passkey/base.d.ts +1 -0
  16. package/dist/cjs/passkey/memory.d.ts +7 -0
  17. package/dist/cjs/passkey/memory.js +47 -5
  18. package/dist/cjs/passkey/models.js +2 -5
  19. package/dist/cjs/passkey/sequelize.d.ts +5 -29
  20. package/dist/cjs/passkey/sequelize.js +48 -191
  21. package/dist/cjs/passkey/service.d.ts +1 -0
  22. package/dist/cjs/passkey/service.js +52 -15
  23. package/dist/cjs/passkey/types.d.ts +1 -0
  24. package/dist/cjs/sequelize-utils.d.ts +5 -0
  25. package/dist/cjs/sequelize-utils.js +40 -0
  26. package/dist/cjs/token/base.js +3 -1
  27. package/dist/cjs/token/memory.d.ts +6 -0
  28. package/dist/cjs/token/memory.js +32 -7
  29. package/dist/cjs/token/sequelize.d.ts +0 -3
  30. package/dist/cjs/token/sequelize.js +50 -81
  31. package/dist/cjs/token/types.d.ts +1 -1
  32. package/dist/cjs/user/base.d.ts +1 -0
  33. package/dist/cjs/user/base.js +11 -4
  34. package/dist/cjs/user/memory.d.ts +2 -0
  35. package/dist/cjs/user/memory.js +8 -2
  36. package/dist/cjs/user/sequelize.js +12 -22
  37. package/dist/esm/api-server-base.js +48 -21
  38. package/dist/esm/auth-api/auth-module.js +16 -30
  39. package/dist/esm/auth-api/mem-auth-store.js +5 -4
  40. package/dist/esm/auth-api/sql-auth-store.js +6 -4
  41. package/dist/esm/auth-api/user-id.d.ts +1 -0
  42. package/dist/esm/auth-api/user-id.js +6 -0
  43. package/dist/esm/auth-cookie-options.js +10 -1
  44. package/dist/esm/oauth/memory.d.ts +6 -0
  45. package/dist/esm/oauth/memory.js +44 -5
  46. package/dist/esm/oauth/models.d.ts +1 -0
  47. package/dist/esm/oauth/models.js +2 -15
  48. package/dist/esm/oauth/sequelize.d.ts +5 -52
  49. package/dist/esm/oauth/sequelize.js +21 -80
  50. package/dist/esm/oauth/types.d.ts +1 -0
  51. package/dist/esm/passkey/base.d.ts +1 -0
  52. package/dist/esm/passkey/memory.d.ts +7 -0
  53. package/dist/esm/passkey/memory.js +47 -5
  54. package/dist/esm/passkey/models.js +1 -4
  55. package/dist/esm/passkey/sequelize.d.ts +5 -29
  56. package/dist/esm/passkey/sequelize.js +47 -190
  57. package/dist/esm/passkey/service.d.ts +1 -0
  58. package/dist/esm/passkey/service.js +52 -15
  59. package/dist/esm/passkey/types.d.ts +1 -0
  60. package/dist/esm/sequelize-utils.d.ts +5 -0
  61. package/dist/esm/sequelize-utils.js +36 -0
  62. package/dist/esm/token/base.js +3 -1
  63. package/dist/esm/token/memory.d.ts +6 -0
  64. package/dist/esm/token/memory.js +32 -7
  65. package/dist/esm/token/sequelize.d.ts +0 -3
  66. package/dist/esm/token/sequelize.js +51 -82
  67. package/dist/esm/token/types.d.ts +1 -1
  68. package/dist/esm/user/base.d.ts +1 -0
  69. package/dist/esm/user/base.js +11 -4
  70. package/dist/esm/user/memory.d.ts +2 -0
  71. package/dist/esm/user/memory.js +8 -2
  72. package/dist/esm/user/sequelize.js +13 -23
  73. package/package.json +5 -5
@@ -1,21 +1,9 @@
1
1
  import { DataTypes, Model, Op } from 'sequelize';
2
2
  import { normalizeStringUserId } from '../auth-api/user-id.js';
3
- import { DIALECTS_SUPPORTING_UNSIGNED, applyTablePrefix } from '../sequelize-utils.js';
3
+ import { applyTablePrefix, decodeStringArray, encodeStringArray, integerIdType, tableOptions } from '../sequelize-utils.js';
4
4
  import { TokenStore } from './base.js';
5
5
  class TokenModel extends Model {
6
6
  }
7
- function tokenTableOptions(sequelize, tablePrefix) {
8
- const opts = {
9
- sequelize,
10
- tableName: applyTablePrefix(tablePrefix, 'jwttokens'),
11
- timestamps: false
12
- };
13
- if (DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect())) {
14
- opts.charset = 'utf8mb4';
15
- opts.collate = 'utf8mb4_unicode_ci';
16
- }
17
- return opts;
18
- }
19
7
  function initTokenModel(sequelize, options = {}) {
20
8
  const tableName = applyTablePrefix(options.tablePrefix, 'jwttokens');
21
9
  const usePrefixedIndexNames = tableName !== 'jwttokens';
@@ -23,9 +11,7 @@ function initTokenModel(sequelize, options = {}) {
23
11
  const refreshIndexName = usePrefixedIndexNames ? `${tableName}_refresh_unique` : 'jwt_refresh_unique';
24
12
  TokenModel.init({
25
13
  token_id: {
26
- type: DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect())
27
- ? DataTypes.INTEGER.UNSIGNED
28
- : DataTypes.INTEGER,
14
+ type: integerIdType(sequelize),
29
15
  autoIncrement: true,
30
16
  allowNull: false,
31
17
  primaryKey: true
@@ -122,7 +108,7 @@ function initTokenModel(sequelize, options = {}) {
122
108
  defaultValue: '[]'
123
109
  }
124
110
  }, {
125
- ...tokenTableOptions(sequelize, options.tablePrefix),
111
+ ...tableOptions(sequelize, 'jwttokens', options.tablePrefix, { timestamps: false }),
126
112
  indexes: [
127
113
  { name: accessIndexName, unique: true, fields: ['access'] },
128
114
  { name: refreshIndexName, unique: true, fields: ['refresh'] }
@@ -161,43 +147,50 @@ export class SequelizeTokenStore extends TokenStore {
161
147
  const lastSeenAt = normalized.lastSeenAt ?? issuedAt;
162
148
  const sessionCookie = normalized.sessionCookie ?? false;
163
149
  const removalWhere = { user_id: resolvedUserId };
164
- if (normalized.domain !== undefined && record.domain !== undefined) {
150
+ if (record.domain !== undefined) {
165
151
  removalWhere.domain = domain;
166
152
  }
167
- if (normalized.fingerprint !== undefined && record.fingerprint !== undefined) {
153
+ if (record.fingerprint !== undefined) {
168
154
  removalWhere.fingerprint = fingerprint;
169
155
  }
170
156
  if (normalized.clientId) {
171
157
  removalWhere.client_id = normalized.clientId;
172
158
  }
173
- await this.Tokens.destroy({ where: removalWhere });
174
- // Access/refresh columns are unique. Remove stale collisions before insert to avoid
175
- // transient uniqueness failures during retries/rotation edge-cases.
176
- await this.Tokens.destroy({
177
- where: {
178
- [Op.or]: [{ access: normalized.accessToken ?? '' }, { refresh: normalized.refreshToken }]
179
- }
180
- });
181
- await this.Tokens.create({
182
- user_id: resolvedUserId,
183
- real_user_id: resolvedRealUserId,
184
- access: normalized.accessToken ?? '',
185
- refresh: normalized.refreshToken,
186
- expires: normalized.expires,
187
- issued_at: issuedAt,
188
- last_seen_at: lastSeenAt,
189
- domain,
190
- fingerprint,
191
- label,
192
- browser,
193
- device,
194
- ip,
195
- os,
196
- client_id: normalized.clientId ?? null,
197
- scope: this.encodeScope(normalized.scope),
198
- login_type: loginType,
199
- refresh_ttl_seconds: refreshTtlSeconds,
200
- session_cookie: sessionCookie
159
+ const sequelize = this.Tokens.sequelize;
160
+ if (!sequelize) {
161
+ throw new Error('Token model is not bound to a Sequelize instance');
162
+ }
163
+ await sequelize.transaction(async (transaction) => {
164
+ await this.Tokens.destroy({ where: removalWhere, transaction });
165
+ // Access/refresh columns are unique. Remove stale collisions before insert to avoid
166
+ // transient uniqueness failures during retries/rotation edge-cases.
167
+ await this.Tokens.destroy({
168
+ where: {
169
+ [Op.or]: [{ access: normalized.accessToken ?? '' }, { refresh: normalized.refreshToken }]
170
+ },
171
+ transaction
172
+ });
173
+ await this.Tokens.create({
174
+ user_id: resolvedUserId,
175
+ real_user_id: resolvedRealUserId,
176
+ access: normalized.accessToken ?? '',
177
+ refresh: normalized.refreshToken,
178
+ expires: normalized.expires,
179
+ issued_at: issuedAt,
180
+ last_seen_at: lastSeenAt,
181
+ domain,
182
+ fingerprint,
183
+ label,
184
+ browser,
185
+ device,
186
+ ip,
187
+ os,
188
+ client_id: normalized.clientId ?? null,
189
+ scope: this.encodeScope(normalized.scope),
190
+ login_type: loginType,
191
+ refresh_ttl_seconds: refreshTtlSeconds,
192
+ session_cookie: sessionCookie
193
+ }, { transaction });
201
194
  });
202
195
  }
203
196
  async get(query, opts) {
@@ -266,11 +259,11 @@ export class SequelizeTokenStore extends TokenStore {
266
259
  where.client_id = params.clientId;
267
260
  }
268
261
  const updates = {};
269
- if (params.accessToken !== undefined) {
270
- updates.access = params.accessToken ?? null;
262
+ if (params.accessToken !== undefined && params.accessToken !== null) {
263
+ updates.access = params.accessToken;
271
264
  }
272
- if (params.expires !== undefined) {
273
- updates.expires = params.expires ?? null;
265
+ if (params.expires !== undefined && params.expires !== null) {
266
+ updates.expires = params.expires;
274
267
  }
275
268
  if (params.scope !== undefined) {
276
269
  updates.scope = this.encodeScope(params.scope);
@@ -308,11 +301,11 @@ export class SequelizeTokenStore extends TokenStore {
308
301
  if (params.sessionCookie !== undefined) {
309
302
  updates.session_cookie = params.sessionCookie;
310
303
  }
311
- if (params.issuedAt !== undefined) {
312
- updates.issued_at = params.issuedAt ?? null;
304
+ if (params.issuedAt !== undefined && params.issuedAt !== null) {
305
+ updates.issued_at = params.issuedAt;
313
306
  }
314
- if (params.lastSeenAt !== undefined) {
315
- updates.last_seen_at = params.lastSeenAt ?? null;
307
+ if (params.lastSeenAt !== undefined && params.lastSeenAt !== null) {
308
+ updates.last_seen_at = params.lastSeenAt;
316
309
  }
317
310
  if (Object.keys(updates).length === 0) {
318
311
  return false;
@@ -349,41 +342,17 @@ export class SequelizeTokenStore extends TokenStore {
349
342
  }
350
343
  return value;
351
344
  }
352
- encodeStringArray(values) {
353
- return JSON.stringify(values ?? []);
354
- }
355
- decodeStringArray(raw) {
356
- if (!raw) {
357
- return [];
358
- }
359
- try {
360
- const parsed = JSON.parse(raw);
361
- if (Array.isArray(parsed)) {
362
- return parsed.filter((entry) => typeof entry === 'string' && entry.length > 0);
363
- }
364
- }
365
- catch {
366
- // ignore malformed values
367
- }
368
- return raw
369
- .split(/\s+/)
370
- .map((entry) => entry.trim())
371
- .filter((entry) => entry.length > 0);
372
- }
373
345
  encodeScope(scope) {
374
346
  if (!scope || (Array.isArray(scope) && scope.length === 0)) {
375
347
  return '[]';
376
348
  }
377
349
  if (Array.isArray(scope)) {
378
- return this.encodeStringArray(scope);
350
+ return encodeStringArray(scope);
379
351
  }
380
- return this.encodeStringArray(scope.split(/\s+/).filter((entry) => entry.length > 0));
381
- }
382
- decodeScope(raw) {
383
- return this.decodeStringArray(raw);
352
+ return encodeStringArray(scope.split(/\s+/).filter((entry) => entry.length > 0));
384
353
  }
385
354
  toTokenRecord(model) {
386
- const scope = this.decodeScope(model.scope);
355
+ const scope = decodeStringArray(model.scope);
387
356
  const normalized = this.normalizeToken({
388
357
  userId: model.user_id,
389
358
  refreshToken: model.refresh,
@@ -23,7 +23,7 @@ export interface Token {
23
23
  device?: string;
24
24
  ip?: string;
25
25
  os?: string;
26
- scope?: string | string[];
26
+ scope?: string[];
27
27
  loginType?: string;
28
28
  refreshTtlSeconds?: number;
29
29
  sessionCookie?: boolean;
@@ -9,6 +9,7 @@ export declare abstract class UserStore<User, PublicUser> {
9
9
  bcryptRounds?: number;
10
10
  bcryptPepper?: string;
11
11
  });
12
+ private applyPepper;
12
13
  protected hashPassword(plain: string): Promise<string>;
13
14
  verifyPassword(plain: string, hashed: string): Promise<boolean>;
14
15
  protected normalizeUserInput(input: Partial<CreateUserInput>): CreateUserInput;
@@ -1,3 +1,4 @@
1
+ import { createHmac } from 'node:crypto';
1
2
  import bcrypt from 'bcryptjs';
2
3
  export class UserStore {
3
4
  constructor(opts = {}) {
@@ -9,12 +10,18 @@ export class UserStore {
9
10
  this.bcryptPepper =
10
11
  typeof opts.bcryptPepper === 'string' && opts.bcryptPepper.length > 0 ? opts.bcryptPepper : undefined;
11
12
  }
13
+ applyPepper(plain) {
14
+ if (!this.bcryptPepper) {
15
+ return plain;
16
+ }
17
+ return createHmac('sha256', this.bcryptPepper).update(plain).digest('hex');
18
+ }
12
19
  async hashPassword(plain) {
13
- const candidate = this.bcryptPepper ? `${plain}${this.bcryptPepper}` : plain;
20
+ const candidate = this.applyPepper(plain);
14
21
  return bcrypt.hash(candidate, this.bcryptRounds);
15
22
  }
16
23
  async verifyPassword(plain, hashed) {
17
- const candidate = this.bcryptPepper ? `${plain}${this.bcryptPepper}` : plain;
24
+ const candidate = this.applyPepper(plain);
18
25
  return bcrypt.compare(candidate, hashed);
19
26
  }
20
27
  normalizeUserInput(input) {
@@ -29,8 +36,8 @@ export class UserStore {
29
36
  toPublic(user) {
30
37
  const mapped = this.toPublicUser(user);
31
38
  if (mapped && typeof mapped === 'object') {
32
- const { password: _password, ...rest } = mapped;
33
- void _password;
39
+ const rest = { ...mapped };
40
+ delete rest.password;
34
41
  return rest;
35
42
  }
36
43
  return mapped;
@@ -14,12 +14,14 @@ export interface MemoryUserStoreOptions<UserAttributes extends MemoryUserAttribu
14
14
  toPublic?: PublicUserMapper<UserAttributes, PublicUserShape>;
15
15
  userIdFactory?: () => number;
16
16
  startingUserId?: number;
17
+ maxUsers?: number;
17
18
  }
18
19
  export declare class MemoryUserStore<UserAttributes extends MemoryUserAttributes = MemoryUserAttributes, PublicUserShape extends Omit<UserAttributes, 'password'> = Omit<UserAttributes, 'password'>> extends UserStore<UserAttributes, PublicUserShape> {
19
20
  private readonly usersById;
20
21
  private readonly loginToId;
21
22
  private readonly emailToId;
22
23
  private readonly userIdFactory;
24
+ private readonly maxUsers?;
23
25
  private nextUserId;
24
26
  constructor(options?: MemoryUserStoreOptions<UserAttributes, PublicUserShape>);
25
27
  findUser(identifier: AuthIdentifier | string): Promise<UserAttributes | null>;
@@ -3,7 +3,6 @@ import { UserStore } from './base.js';
3
3
  function cloneUser(user) {
4
4
  return { ...user };
5
5
  }
6
- const normalizeUserId = normalizeNumericUserId;
7
6
  export class MemoryUserStore extends UserStore {
8
7
  constructor(options = {}) {
9
8
  super({
@@ -15,6 +14,10 @@ export class MemoryUserStore extends UserStore {
15
14
  this.loginToId = new Map();
16
15
  this.emailToId = new Map();
17
16
  this.nextUserId = Number.isFinite(options.startingUserId) ? Number(options.startingUserId) : 1;
17
+ this.maxUsers =
18
+ typeof options.maxUsers === 'number' && Number.isFinite(options.maxUsers) && options.maxUsers > 0
19
+ ? Math.floor(options.maxUsers)
20
+ : undefined;
18
21
  this.userIdFactory =
19
22
  options.userIdFactory ??
20
23
  (() => {
@@ -49,7 +52,7 @@ export class MemoryUserStore extends UserStore {
49
52
  }
50
53
  async findById(id) {
51
54
  try {
52
- const numeric = normalizeUserId(id);
55
+ const numeric = normalizeNumericUserId(id);
53
56
  const user = this.usersById.get(numeric);
54
57
  return user ? cloneUser(user) : null;
55
58
  }
@@ -67,6 +70,9 @@ export class MemoryUserStore extends UserStore {
67
70
  if (this.usersById.has(userId)) {
68
71
  throw new Error(`User ${userId} already exists`);
69
72
  }
73
+ if (this.maxUsers !== undefined && this.usersById.size >= this.maxUsers) {
74
+ throw new Error('MemoryUserStore maxUsers limit reached');
75
+ }
70
76
  if (this.loginToId.has(normalizedInput.login)) {
71
77
  throw new Error(`User with login ${normalizedInput.login} already exists`);
72
78
  }
@@ -1,22 +1,7 @@
1
- import { DataTypes, Model, Op } from 'sequelize';
1
+ import { DataTypes, Model, Op, UniqueConstraintError } from 'sequelize';
2
2
  import { normalizeNumericUserId } from '../auth-api/user-id.js';
3
- import { DIALECTS_SUPPORTING_UNSIGNED, applyTablePrefix } from '../sequelize-utils.js';
3
+ import { integerIdType, tableOptions } from '../sequelize-utils.js';
4
4
  import { UserStore } from './base.js';
5
- function integerIdType(sequelize) {
6
- return DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect()) ? DataTypes.INTEGER.UNSIGNED : DataTypes.INTEGER;
7
- }
8
- function userTableOptions(sequelize, tablePrefix) {
9
- const opts = {
10
- sequelize,
11
- tableName: applyTablePrefix(tablePrefix, 'users'),
12
- timestamps: false
13
- };
14
- if (DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect())) {
15
- opts.charset = 'utf8mb4';
16
- opts.collate = 'utf8mb4_unicode_ci';
17
- }
18
- return opts;
19
- }
20
5
  export class AuthUserModel extends Model {
21
6
  }
22
7
  export function initAuthUserModel(sequelize, options = {}) {
@@ -43,7 +28,7 @@ export function initAuthUserModel(sequelize, options = {}) {
43
28
  allowNull: false
44
29
  }
45
30
  }, {
46
- ...userTableOptions(sequelize, options.tablePrefix)
31
+ ...tableOptions(sequelize, 'users', options.tablePrefix, { timestamps: false })
47
32
  });
48
33
  return AuthUserModel;
49
34
  }
@@ -110,11 +95,16 @@ export class SequelizeUserStore extends UserStore {
110
95
  if (providedId !== undefined && providedId !== null && Number.isFinite(providedId)) {
111
96
  defaults.user_id = Number(providedId);
112
97
  }
113
- const [model] = await this.Users.findOrCreate({
114
- where: { login: rest.login },
115
- defaults: defaults
116
- });
117
- return this.toUserRecord(model);
98
+ try {
99
+ const model = await this.Users.create(defaults);
100
+ return this.toUserRecord(model);
101
+ }
102
+ catch (error) {
103
+ if (error instanceof UniqueConstraintError) {
104
+ throw new Error(`User with login ${rest.login} or email ${rest.email} already exists`);
105
+ }
106
+ throw error;
107
+ }
118
108
  }
119
109
  async upsertUser(input) {
120
110
  const normalized = this.normalizeUserInput(input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@technomoron/api-server-base",
3
- "version": "2.0.0-beta.19",
3
+ "version": "2.0.0-beta.20",
4
4
  "description": "Api Server Skeleton / Base Class",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.cjs",
@@ -68,11 +68,7 @@
68
68
  },
69
69
  "dependencies": {
70
70
  "@simplewebauthn/server": "^13.2.2",
71
- "@types/cookie-parser": "^1.4.10",
72
- "@types/cors": "^2.8.19",
73
71
  "@types/express": "^5.0.6",
74
- "@types/jsonwebtoken": "^9.0.10",
75
- "@types/multer": "^2.0.0",
76
72
  "bcryptjs": "^3.0.3",
77
73
  "cookie-parser": "^1.4.7",
78
74
  "cors": "^2.8.6",
@@ -81,7 +77,11 @@
81
77
  "multer": "^2.0.2"
82
78
  },
83
79
  "devDependencies": {
80
+ "@types/cookie-parser": "^1.4.10",
81
+ "@types/cors": "^2.8.19",
84
82
  "@types/express-serve-static-core": "^5.1.1",
83
+ "@types/jsonwebtoken": "^9.0.10",
84
+ "@types/multer": "^2.0.0",
85
85
  "@types/supertest": "^6.0.3",
86
86
  "@typescript-eslint/eslint-plugin": "^8.54.0",
87
87
  "@typescript-eslint/parser": "^8.54.0",