s3db.js 11.3.2 → 12.0.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 (82) hide show
  1. package/README.md +102 -8
  2. package/dist/s3db.cjs.js +36664 -15480
  3. package/dist/s3db.cjs.js.map +1 -1
  4. package/dist/s3db.d.ts +57 -0
  5. package/dist/s3db.es.js +36661 -15531
  6. package/dist/s3db.es.js.map +1 -1
  7. package/mcp/entrypoint.js +58 -0
  8. package/mcp/tools/documentation.js +434 -0
  9. package/mcp/tools/index.js +4 -0
  10. package/package.json +27 -6
  11. package/src/behaviors/user-managed.js +13 -6
  12. package/src/client.class.js +41 -46
  13. package/src/concerns/base62.js +85 -0
  14. package/src/concerns/dictionary-encoding.js +294 -0
  15. package/src/concerns/geo-encoding.js +256 -0
  16. package/src/concerns/high-performance-inserter.js +34 -30
  17. package/src/concerns/ip.js +325 -0
  18. package/src/concerns/metadata-encoding.js +345 -66
  19. package/src/concerns/money.js +193 -0
  20. package/src/concerns/partition-queue.js +7 -4
  21. package/src/concerns/plugin-storage.js +39 -19
  22. package/src/database.class.js +76 -74
  23. package/src/errors.js +0 -4
  24. package/src/plugins/api/auth/api-key-auth.js +88 -0
  25. package/src/plugins/api/auth/basic-auth.js +154 -0
  26. package/src/plugins/api/auth/index.js +112 -0
  27. package/src/plugins/api/auth/jwt-auth.js +169 -0
  28. package/src/plugins/api/index.js +539 -0
  29. package/src/plugins/api/middlewares/index.js +15 -0
  30. package/src/plugins/api/middlewares/validator.js +185 -0
  31. package/src/plugins/api/routes/auth-routes.js +241 -0
  32. package/src/plugins/api/routes/resource-routes.js +304 -0
  33. package/src/plugins/api/server.js +350 -0
  34. package/src/plugins/api/utils/error-handler.js +147 -0
  35. package/src/plugins/api/utils/openapi-generator.js +1240 -0
  36. package/src/plugins/api/utils/response-formatter.js +218 -0
  37. package/src/plugins/backup/streaming-exporter.js +132 -0
  38. package/src/plugins/backup.plugin.js +103 -50
  39. package/src/plugins/cache/s3-cache.class.js +95 -47
  40. package/src/plugins/cache.plugin.js +107 -9
  41. package/src/plugins/concerns/plugin-dependencies.js +313 -0
  42. package/src/plugins/concerns/prometheus-formatter.js +255 -0
  43. package/src/plugins/consumers/rabbitmq-consumer.js +4 -0
  44. package/src/plugins/consumers/sqs-consumer.js +4 -0
  45. package/src/plugins/costs.plugin.js +255 -39
  46. package/src/plugins/eventual-consistency/helpers.js +15 -1
  47. package/src/plugins/geo.plugin.js +873 -0
  48. package/src/plugins/importer/index.js +1020 -0
  49. package/src/plugins/index.js +11 -0
  50. package/src/plugins/metrics.plugin.js +163 -4
  51. package/src/plugins/queue-consumer.plugin.js +6 -27
  52. package/src/plugins/relation.errors.js +139 -0
  53. package/src/plugins/relation.plugin.js +1242 -0
  54. package/src/plugins/replicators/bigquery-replicator.class.js +180 -8
  55. package/src/plugins/replicators/dynamodb-replicator.class.js +383 -0
  56. package/src/plugins/replicators/index.js +28 -3
  57. package/src/plugins/replicators/mongodb-replicator.class.js +391 -0
  58. package/src/plugins/replicators/mysql-replicator.class.js +558 -0
  59. package/src/plugins/replicators/planetscale-replicator.class.js +409 -0
  60. package/src/plugins/replicators/postgres-replicator.class.js +182 -7
  61. package/src/plugins/replicators/s3db-replicator.class.js +1 -12
  62. package/src/plugins/replicators/schema-sync.helper.js +601 -0
  63. package/src/plugins/replicators/sqs-replicator.class.js +11 -9
  64. package/src/plugins/replicators/turso-replicator.class.js +416 -0
  65. package/src/plugins/replicators/webhook-replicator.class.js +612 -0
  66. package/src/plugins/state-machine.plugin.js +122 -68
  67. package/src/plugins/tfstate/README.md +745 -0
  68. package/src/plugins/tfstate/base-driver.js +80 -0
  69. package/src/plugins/tfstate/errors.js +112 -0
  70. package/src/plugins/tfstate/filesystem-driver.js +129 -0
  71. package/src/plugins/tfstate/index.js +2660 -0
  72. package/src/plugins/tfstate/s3-driver.js +192 -0
  73. package/src/plugins/ttl.plugin.js +536 -0
  74. package/src/resource.class.js +14 -10
  75. package/src/s3db.d.ts +57 -0
  76. package/src/schema.class.js +366 -32
  77. package/SECURITY.md +0 -76
  78. package/src/partition-drivers/base-partition-driver.js +0 -106
  79. package/src/partition-drivers/index.js +0 -66
  80. package/src/partition-drivers/memory-partition-driver.js +0 -289
  81. package/src/partition-drivers/sqs-partition-driver.js +0 -337
  82. package/src/partition-drivers/sync-partition-driver.js +0 -38
@@ -88,11 +88,8 @@ export class Database extends EventEmitter {
88
88
  if (typeof process !== 'undefined') {
89
89
  process.on('exit', async () => {
90
90
  if (this.isConnected()) {
91
- try {
92
- await this.disconnect();
93
- } catch (err) {
94
- // Silently ignore errors on exit
95
- }
91
+ // Silently ignore errors on exit
92
+ await tryFn(() => this.disconnect());
96
93
  }
97
94
  });
98
95
  }
@@ -107,26 +104,28 @@ export class Database extends EventEmitter {
107
104
  let healingLog = [];
108
105
 
109
106
  if (await this.client.exists(`s3db.json`)) {
110
- try {
107
+ const [ok, error] = await tryFn(async () => {
111
108
  const request = await this.client.getObject(`s3db.json`);
112
109
  const rawContent = await streamToString(request?.Body);
113
-
110
+
114
111
  // Try to parse JSON
115
- try {
116
- metadata = JSON.parse(rawContent);
117
- } catch (parseError) {
112
+ const [parseOk, parseError, parsedData] = tryFn(() => JSON.parse(rawContent));
113
+
114
+ if (!parseOk) {
118
115
  healingLog.push('JSON parsing failed - attempting recovery');
119
116
  needsHealing = true;
120
-
117
+
121
118
  // Attempt to fix common JSON issues
122
119
  metadata = await this._attemptJsonRecovery(rawContent, healingLog);
123
-
120
+
124
121
  if (!metadata) {
125
122
  // Create backup and start fresh
126
123
  await this._createCorruptedBackup(rawContent);
127
124
  healingLog.push('Created backup of corrupted file - starting with blank metadata');
128
125
  metadata = this.blankMetadataStructure();
129
126
  }
127
+ } else {
128
+ metadata = parsedData;
130
129
  }
131
130
 
132
131
  // Validate and heal metadata structure
@@ -135,8 +134,9 @@ export class Database extends EventEmitter {
135
134
  metadata = healedMetadata;
136
135
  needsHealing = true;
137
136
  }
137
+ });
138
138
 
139
- } catch (error) {
139
+ if (!ok) {
140
140
  healingLog.push(`Critical error reading s3db.json: ${error.message}`);
141
141
  await this._createCorruptedBackup();
142
142
  metadata = this.blankMetadataStructure();
@@ -159,7 +159,7 @@ export class Database extends EventEmitter {
159
159
 
160
160
  // Create resources from saved metadata using current version
161
161
  for (const [name, resourceMetadata] of Object.entries(metadata.resources || {})) {
162
- const currentVersion = resourceMetadata.currentVersion || 'v0';
162
+ const currentVersion = resourceMetadata.currentVersion || 'v1';
163
163
  const versionData = resourceMetadata.versions?.[currentVersion];
164
164
 
165
165
  if (versionData) {
@@ -240,7 +240,7 @@ export class Database extends EventEmitter {
240
240
  });
241
241
  } else {
242
242
  // Get current version hash from saved metadata
243
- const currentVersion = savedResource.currentVersion || 'v0';
243
+ const currentVersion = savedResource.currentVersion || 'v1';
244
244
  const versionData = savedResource.versions?.[currentVersion];
245
245
  const savedHash = versionData?.hash;
246
246
 
@@ -260,7 +260,7 @@ export class Database extends EventEmitter {
260
260
  // Check for deleted resources
261
261
  for (const [name, savedResource] of Object.entries(savedMetadata.resources || {})) {
262
262
  if (!this.resources[name]) {
263
- const currentVersion = savedResource.currentVersion || 'v0';
263
+ const currentVersion = savedResource.currentVersion || 'v1';
264
264
  const versionData = savedResource.versions?.[currentVersion];
265
265
  changes.push({
266
266
  type: 'deleted',
@@ -312,8 +312,8 @@ export class Database extends EventEmitter {
312
312
  .filter(v => v.startsWith('v'))
313
313
  .map(v => parseInt(v.substring(1)))
314
314
  .filter(n => !isNaN(n));
315
-
316
- const maxVersion = versionNumbers.length > 0 ? Math.max(...versionNumbers) : -1;
315
+
316
+ const maxVersion = versionNumbers.length > 0 ? Math.max(...versionNumbers) : 0;
317
317
  return `v${maxVersion + 1}`;
318
318
  }
319
319
 
@@ -325,24 +325,25 @@ export class Database extends EventEmitter {
325
325
  */
326
326
  _serializeHooks(hooks) {
327
327
  if (!hooks || typeof hooks !== 'object') return hooks;
328
-
328
+
329
329
  const serialized = {};
330
330
  for (const [event, hookArray] of Object.entries(hooks)) {
331
331
  if (Array.isArray(hookArray)) {
332
332
  serialized[event] = hookArray.map(hook => {
333
333
  if (typeof hook === 'function') {
334
- try {
335
- return {
336
- __s3db_serialized_function: true,
337
- code: hook.toString(),
338
- name: hook.name || 'anonymous'
339
- };
340
- } catch (err) {
334
+ const [ok, err, data] = tryFn(() => ({
335
+ __s3db_serialized_function: true,
336
+ code: hook.toString(),
337
+ name: hook.name || 'anonymous'
338
+ }));
339
+
340
+ if (!ok) {
341
341
  if (this.verbose) {
342
342
  console.warn(`Failed to serialize hook for event '${event}':`, err.message);
343
343
  }
344
344
  return null;
345
345
  }
346
+ return data;
346
347
  }
347
348
  return hook;
348
349
  });
@@ -361,24 +362,25 @@ export class Database extends EventEmitter {
361
362
  */
362
363
  _deserializeHooks(serializedHooks) {
363
364
  if (!serializedHooks || typeof serializedHooks !== 'object') return serializedHooks;
364
-
365
+
365
366
  const deserialized = {};
366
367
  for (const [event, hookArray] of Object.entries(serializedHooks)) {
367
368
  if (Array.isArray(hookArray)) {
368
369
  deserialized[event] = hookArray.map(hook => {
369
370
  if (hook && typeof hook === 'object' && hook.__s3db_serialized_function) {
370
- try {
371
+ const [ok, err, fn] = tryFn(() => {
371
372
  // Use Function constructor instead of eval for better security
372
- const fn = new Function('return ' + hook.code)();
373
- if (typeof fn === 'function') {
374
- return fn;
375
- }
376
- } catch (err) {
373
+ const func = new Function('return ' + hook.code)();
374
+ return typeof func === 'function' ? func : null;
375
+ });
376
+
377
+ if (!ok || fn === null) {
377
378
  if (this.verbose) {
378
- console.warn(`Failed to deserialize hook '${hook.name}' for event '${event}':`, err.message);
379
+ console.warn(`Failed to deserialize hook '${hook.name}' for event '${event}':`, err?.message || 'Invalid function');
379
380
  }
381
+ return null;
380
382
  }
381
- return null;
383
+ return fn;
382
384
  }
383
385
  return hook;
384
386
  }).filter(hook => hook !== null); // Remove failed deserializations
@@ -498,7 +500,7 @@ export class Database extends EventEmitter {
498
500
 
499
501
  // Check if resource exists in saved metadata
500
502
  const existingResource = this.savedMetadata?.resources?.[name];
501
- const currentVersion = existingResource?.currentVersion || 'v0';
503
+ const currentVersion = existingResource?.currentVersion || 'v1';
502
504
  const existingVersionData = existingResource?.versions?.[currentVersion];
503
505
 
504
506
  let version, isNewVersion;
@@ -626,14 +628,16 @@ export class Database extends EventEmitter {
626
628
  ];
627
629
 
628
630
  for (const [index, fix] of fixes.entries()) {
629
- try {
631
+ const [ok, err, parsed] = tryFn(() => {
630
632
  const fixedContent = fix();
631
- const parsed = JSON.parse(fixedContent);
633
+ return JSON.parse(fixedContent);
634
+ });
635
+
636
+ if (ok) {
632
637
  healingLog.push(`JSON recovery successful using fix #${index + 1}`);
633
638
  return parsed;
634
- } catch (error) {
635
- // Try next fix
636
639
  }
640
+ // Try next fix
637
641
  }
638
642
 
639
643
  healingLog.push('All JSON recovery attempts failed');
@@ -723,7 +727,7 @@ export class Database extends EventEmitter {
723
727
 
724
728
  // Ensure currentVersion exists
725
729
  if (!healed.currentVersion) {
726
- healed.currentVersion = 'v0';
730
+ healed.currentVersion = 'v1';
727
731
  healingLog.push(`Resource ${name}: added missing currentVersion`);
728
732
  changed = true;
729
733
  }
@@ -822,17 +826,16 @@ export class Database extends EventEmitter {
822
826
  * Create backup of corrupted file
823
827
  */
824
828
  async _createCorruptedBackup(content = null) {
825
- try {
829
+ const [ok, err] = await tryFn(async () => {
826
830
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
827
831
  const backupKey = `s3db.json.corrupted.${timestamp}.backup`;
828
-
832
+
829
833
  if (!content) {
830
- try {
834
+ const [readOk, readErr, readData] = await tryFn(async () => {
831
835
  const request = await this.client.getObject(`s3db.json`);
832
- content = await streamToString(request?.Body);
833
- } catch (error) {
834
- content = 'Unable to read corrupted file content';
835
- }
836
+ return await streamToString(request?.Body);
837
+ });
838
+ content = readOk ? readData : 'Unable to read corrupted file content';
836
839
  }
837
840
 
838
841
  await this.client.putObject({
@@ -844,10 +847,10 @@ export class Database extends EventEmitter {
844
847
  if (this.verbose) {
845
848
  console.warn(`S3DB: Created backup of corrupted s3db.json as ${backupKey}`);
846
849
  }
847
- } catch (error) {
848
- if (this.verbose) {
849
- console.warn(`S3DB: Failed to create backup: ${error.message}`);
850
- }
850
+ });
851
+
852
+ if (!ok && this.verbose) {
853
+ console.warn(`S3DB: Failed to create backup: ${err.message}`);
851
854
  }
852
855
  }
853
856
 
@@ -855,7 +858,7 @@ export class Database extends EventEmitter {
855
858
  * Upload healed metadata with logging
856
859
  */
857
860
  async _uploadHealedMetadata(metadata, healingLog) {
858
- try {
861
+ const [ok, err] = await tryFn(async () => {
859
862
  if (this.verbose && healingLog.length > 0) {
860
863
  console.warn('S3DB Self-Healing Operations:');
861
864
  healingLog.forEach(log => console.warn(` - ${log}`));
@@ -875,11 +878,13 @@ export class Database extends EventEmitter {
875
878
  if (this.verbose) {
876
879
  console.warn('S3DB: Successfully uploaded healed metadata');
877
880
  }
878
- } catch (error) {
881
+ });
882
+
883
+ if (!ok) {
879
884
  if (this.verbose) {
880
- console.error(`S3DB: Failed to upload healed metadata: ${error.message}`);
885
+ console.error(`S3DB: Failed to upload healed metadata: ${err.message}`);
881
886
  }
882
- throw error;
887
+ throw err;
883
888
  }
884
889
  }
885
890
 
@@ -979,7 +984,7 @@ export class Database extends EventEmitter {
979
984
  // Only upload metadata if hash actually changed
980
985
  const newHash = this.generateDefinitionHash(existingResource.export(), existingResource.behavior);
981
986
  const existingMetadata = this.savedMetadata?.resources?.[name];
982
- const currentVersion = existingMetadata?.currentVersion || 'v0';
987
+ const currentVersion = existingMetadata?.currentVersion || 'v1';
983
988
  const existingVersionData = existingMetadata?.versions?.[currentVersion];
984
989
  if (!existingVersionData || existingVersionData.hash !== newHash) {
985
990
  await this.uploadMetadataFile();
@@ -988,7 +993,7 @@ export class Database extends EventEmitter {
988
993
  return existingResource;
989
994
  }
990
995
  const existingMetadata = this.savedMetadata?.resources?.[name];
991
- const version = existingMetadata?.currentVersion || 'v0';
996
+ const version = existingMetadata?.currentVersion || 'v1';
992
997
  const resource = new Resource({
993
998
  name,
994
999
  client: this.client,
@@ -1011,6 +1016,7 @@ export class Database extends EventEmitter {
1011
1016
  idGenerator: config.idGenerator,
1012
1017
  idSize: config.idSize,
1013
1018
  asyncEvents: config.asyncEvents,
1019
+ asyncPartitions: config.asyncPartitions !== undefined ? config.asyncPartitions : true,
1014
1020
  events: config.events || {},
1015
1021
  createdBy: config.createdBy || 'user'
1016
1022
  });
@@ -1073,7 +1079,8 @@ export class Database extends EventEmitter {
1073
1079
  }
1074
1080
 
1075
1081
  async disconnect() {
1076
- try {
1082
+ // Silently ignore all errors during disconnect
1083
+ await tryFn(async () => {
1077
1084
  // 1. Remove all listeners from all plugins
1078
1085
  if (this.pluginList && this.pluginList.length > 0) {
1079
1086
  for (const plugin of this.pluginList) {
@@ -1083,13 +1090,12 @@ export class Database extends EventEmitter {
1083
1090
  }
1084
1091
  // Also stop plugins if they have a stop method
1085
1092
  const stopProms = this.pluginList.map(async (plugin) => {
1086
- try {
1093
+ // Silently ignore errors on exit
1094
+ await tryFn(async () => {
1087
1095
  if (plugin && typeof plugin.stop === 'function') {
1088
1096
  await plugin.stop();
1089
1097
  }
1090
- } catch (err) {
1091
- // Silently ignore errors on exit
1092
- }
1098
+ });
1093
1099
  });
1094
1100
  await Promise.all(stopProms);
1095
1101
  }
@@ -1097,7 +1103,8 @@ export class Database extends EventEmitter {
1097
1103
  // 2. Remove all listeners from all resources
1098
1104
  if (this.resources && Object.keys(this.resources).length > 0) {
1099
1105
  for (const [name, resource] of Object.entries(this.resources)) {
1100
- try {
1106
+ // Silently ignore errors on exit
1107
+ await tryFn(() => {
1101
1108
  if (resource && typeof resource.removeAllListeners === 'function') {
1102
1109
  resource.removeAllListeners();
1103
1110
  }
@@ -1110,9 +1117,7 @@ export class Database extends EventEmitter {
1110
1117
  if (resource.observers && Array.isArray(resource.observers)) {
1111
1118
  resource.observers = [];
1112
1119
  }
1113
- } catch (err) {
1114
- // Silently ignore errors on exit
1115
- }
1120
+ });
1116
1121
  }
1117
1122
  // Instead of reassigning, clear in place
1118
1123
  Object.keys(this.resources).forEach(k => delete this.resources[k]);
@@ -1132,9 +1137,7 @@ export class Database extends EventEmitter {
1132
1137
  this.pluginList = [];
1133
1138
 
1134
1139
  this.emit('disconnected', new Date());
1135
- } catch (err) {
1136
- // Silently ignore errors on exit
1137
- }
1140
+ });
1138
1141
  }
1139
1142
 
1140
1143
  /**
@@ -1249,12 +1252,11 @@ export class Database extends EventEmitter {
1249
1252
  */
1250
1253
  async _executeHooks(event, context = {}) {
1251
1254
  if (!this._hooks || !this._hooks.has(event)) return;
1252
-
1255
+
1253
1256
  const hooks = this._hooks.get(event);
1254
1257
  for (const hook of hooks) {
1255
- try {
1256
- await hook({ database: this, ...context });
1257
- } catch (error) {
1258
+ const [ok, error] = await tryFn(() => hook({ database: this, ...context }));
1259
+ if (!ok) {
1258
1260
  // Emit error but don't stop hook execution
1259
1261
  this.emit('hookError', { event, error, context });
1260
1262
  }
package/src/errors.js CHANGED
@@ -498,8 +498,6 @@ Solution:
498
498
  ${queueSize >= maxQueueSize
499
499
  ? 'Wait for queue to drain or increase maxQueueSize'
500
500
  : 'Check driver configuration and permissions'}
501
-
502
- Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/README.md#partition-drivers
503
501
  `.trim();
504
502
  } else if (!description) {
505
503
  description = `
@@ -509,8 +507,6 @@ Driver: ${driver}
509
507
  Operation: ${operation}
510
508
 
511
509
  Check driver configuration and permissions.
512
-
513
- Docs: https://github.com/forattini-dev/s3db.js/blob/main/docs/README.md#partition-drivers
514
510
  `.trim();
515
511
  }
516
512
 
@@ -0,0 +1,88 @@
1
+ /**
2
+ * API Key Authentication - Simple API key authentication middleware
3
+ *
4
+ * Provides authentication using static API keys in headers
5
+ */
6
+
7
+ import { unauthorized } from '../utils/response-formatter.js';
8
+
9
+ /**
10
+ * Generate random API key
11
+ * @param {number} length - Key length (default: 32)
12
+ * @returns {string} Random API key
13
+ */
14
+ export function generateApiKey(length = 32) {
15
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
16
+ let apiKey = '';
17
+
18
+ for (let i = 0; i < length; i++) {
19
+ apiKey += chars.charAt(Math.floor(Math.random() * chars.length));
20
+ }
21
+
22
+ return apiKey;
23
+ }
24
+
25
+ /**
26
+ * Create API Key authentication middleware
27
+ * @param {Object} options - API Key options
28
+ * @param {string} options.headerName - Header name for API key (default: 'X-API-Key')
29
+ * @param {Object} options.usersResource - Users resource for key validation
30
+ * @param {boolean} options.optional - If true, allows requests without auth
31
+ * @returns {Function} Hono middleware
32
+ */
33
+ export function apiKeyAuth(options = {}) {
34
+ const {
35
+ headerName = 'X-API-Key',
36
+ usersResource,
37
+ optional = false
38
+ } = options;
39
+
40
+ if (!usersResource) {
41
+ throw new Error('usersResource is required for API key authentication');
42
+ }
43
+
44
+ return async (c, next) => {
45
+ const apiKey = c.req.header(headerName);
46
+
47
+ if (!apiKey) {
48
+ if (optional) {
49
+ return await next();
50
+ }
51
+
52
+ const response = unauthorized(`Missing ${headerName} header`);
53
+ return c.json(response, response._status);
54
+ }
55
+
56
+ // Query users by API key
57
+ try {
58
+ const users = await usersResource.query({ apiKey });
59
+
60
+ if (!users || users.length === 0) {
61
+ const response = unauthorized('Invalid API key');
62
+ return c.json(response, response._status);
63
+ }
64
+
65
+ const user = users[0];
66
+
67
+ if (!user.active) {
68
+ const response = unauthorized('User account is inactive');
69
+ return c.json(response, response._status);
70
+ }
71
+
72
+ // Store user in context
73
+ c.set('user', user);
74
+ c.set('authMethod', 'apiKey');
75
+
76
+ await next();
77
+ } catch (err) {
78
+ console.error('[API Key Auth] Error validating key:', err);
79
+ const response = unauthorized('Authentication error');
80
+ return c.json(response, response._status);
81
+ }
82
+ };
83
+ }
84
+
85
+ export default {
86
+ generateApiKey,
87
+ apiKeyAuth
88
+ };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Basic Authentication - HTTP Basic Auth middleware
3
+ *
4
+ * Provides authentication using username:password in Authorization header
5
+ */
6
+
7
+ import { unauthorized } from '../utils/response-formatter.js';
8
+ import { decrypt } from '../../../concerns/crypto.js';
9
+ import tryFn from '../../../concerns/try-fn.js';
10
+
11
+ /**
12
+ * Parse Basic Auth header
13
+ * @param {string} authHeader - Authorization header value
14
+ * @returns {Object|null} { username, password } or null if invalid
15
+ */
16
+ export function parseBasicAuth(authHeader) {
17
+ if (!authHeader) {
18
+ return null;
19
+ }
20
+
21
+ const match = authHeader.match(/^Basic\s+(.+)$/i);
22
+ if (!match) {
23
+ return null;
24
+ }
25
+
26
+ try {
27
+ const decoded = Buffer.from(match[1], 'base64').toString('utf-8');
28
+ const [username, ...passwordParts] = decoded.split(':');
29
+ const password = passwordParts.join(':'); // Handle passwords with colons
30
+
31
+ if (!username || !password) {
32
+ return null;
33
+ }
34
+
35
+ return { username, password };
36
+ } catch (err) {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Verify password against stored hash
43
+ * @param {string} inputPassword - Plain text password from request
44
+ * @param {string} storedPassword - Encrypted password from database
45
+ * @param {string} passphrase - Encryption passphrase
46
+ * @returns {Promise<boolean>} True if password matches
47
+ */
48
+ async function verifyPassword(inputPassword, storedPassword, passphrase) {
49
+ try {
50
+ // Decrypt stored password
51
+ const [ok, err, decrypted] = await tryFn(() =>
52
+ decrypt(storedPassword, passphrase)
53
+ );
54
+
55
+ if (!ok) {
56
+ return false;
57
+ }
58
+
59
+ // Compare
60
+ return decrypted === inputPassword;
61
+ } catch (err) {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Create Basic Auth middleware
68
+ * @param {Object} options - Basic Auth options
69
+ * @param {string} options.realm - Authentication realm (default: 'API Access')
70
+ * @param {Object} options.usersResource - Users resource for credential validation
71
+ * @param {string} options.passphrase - Passphrase for password decryption
72
+ * @param {boolean} options.optional - If true, allows requests without auth
73
+ * @returns {Function} Hono middleware
74
+ */
75
+ export function basicAuth(options = {}) {
76
+ const {
77
+ realm = 'API Access',
78
+ usersResource,
79
+ passphrase = 'secret',
80
+ optional = false
81
+ } = options;
82
+
83
+ if (!usersResource) {
84
+ throw new Error('usersResource is required for Basic authentication');
85
+ }
86
+
87
+ return async (c, next) => {
88
+ const authHeader = c.req.header('authorization');
89
+
90
+ if (!authHeader) {
91
+ if (optional) {
92
+ return await next();
93
+ }
94
+
95
+ c.header('WWW-Authenticate', `Basic realm="${realm}"`);
96
+ const response = unauthorized('Basic authentication required');
97
+ return c.json(response, response._status);
98
+ }
99
+
100
+ const credentials = parseBasicAuth(authHeader);
101
+
102
+ if (!credentials) {
103
+ c.header('WWW-Authenticate', `Basic realm="${realm}"`);
104
+ const response = unauthorized('Invalid Basic authentication format');
105
+ return c.json(response, response._status);
106
+ }
107
+
108
+ const { username, password } = credentials;
109
+
110
+ // Query user by username
111
+ try {
112
+ const users = await usersResource.query({ username });
113
+
114
+ if (!users || users.length === 0) {
115
+ c.header('WWW-Authenticate', `Basic realm="${realm}"`);
116
+ const response = unauthorized('Invalid credentials');
117
+ return c.json(response, response._status);
118
+ }
119
+
120
+ const user = users[0];
121
+
122
+ if (!user.active) {
123
+ c.header('WWW-Authenticate', `Basic realm="${realm}"`);
124
+ const response = unauthorized('User account is inactive');
125
+ return c.json(response, response._status);
126
+ }
127
+
128
+ // Verify password
129
+ const isValid = await verifyPassword(password, user.password, passphrase);
130
+
131
+ if (!isValid) {
132
+ c.header('WWW-Authenticate', `Basic realm="${realm}"`);
133
+ const response = unauthorized('Invalid credentials');
134
+ return c.json(response, response._status);
135
+ }
136
+
137
+ // Store user in context
138
+ c.set('user', user);
139
+ c.set('authMethod', 'basic');
140
+
141
+ await next();
142
+ } catch (err) {
143
+ console.error('[Basic Auth] Error validating credentials:', err);
144
+ c.header('WWW-Authenticate', `Basic realm="${realm}"`);
145
+ const response = unauthorized('Authentication error');
146
+ return c.json(response, response._status);
147
+ }
148
+ };
149
+ }
150
+
151
+ export default {
152
+ parseBasicAuth,
153
+ basicAuth
154
+ };