s3db.js 13.6.1 → 14.0.2

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 (189) hide show
  1. package/README.md +56 -15
  2. package/dist/s3db.cjs +72446 -39022
  3. package/dist/s3db.cjs.map +1 -1
  4. package/dist/s3db.es.js +72172 -38790
  5. package/dist/s3db.es.js.map +1 -1
  6. package/mcp/lib/base-handler.js +157 -0
  7. package/mcp/lib/handlers/connection-handler.js +280 -0
  8. package/mcp/lib/handlers/query-handler.js +533 -0
  9. package/mcp/lib/handlers/resource-handler.js +428 -0
  10. package/mcp/lib/tool-registry.js +336 -0
  11. package/mcp/lib/tools/connection-tools.js +161 -0
  12. package/mcp/lib/tools/query-tools.js +267 -0
  13. package/mcp/lib/tools/resource-tools.js +404 -0
  14. package/package.json +85 -50
  15. package/src/clients/memory-client.class.js +346 -191
  16. package/src/clients/memory-storage.class.js +300 -84
  17. package/src/clients/s3-client.class.js +7 -6
  18. package/src/concerns/geo-encoding.js +19 -2
  19. package/src/concerns/ip.js +59 -9
  20. package/src/concerns/money.js +8 -1
  21. package/src/concerns/password-hashing.js +49 -8
  22. package/src/concerns/plugin-storage.js +186 -18
  23. package/src/concerns/storage-drivers/filesystem-driver.js +284 -0
  24. package/src/database.class.js +139 -29
  25. package/src/errors.js +332 -42
  26. package/src/plugins/api/auth/oidc-auth.js +66 -17
  27. package/src/plugins/api/auth/strategies/base-strategy.class.js +74 -0
  28. package/src/plugins/api/auth/strategies/factory.class.js +63 -0
  29. package/src/plugins/api/auth/strategies/global-strategy.class.js +44 -0
  30. package/src/plugins/api/auth/strategies/path-based-strategy.class.js +83 -0
  31. package/src/plugins/api/auth/strategies/path-rules-strategy.class.js +118 -0
  32. package/src/plugins/api/concerns/failban-manager.js +106 -57
  33. package/src/plugins/api/concerns/route-context.js +601 -0
  34. package/src/plugins/api/index.js +168 -40
  35. package/src/plugins/api/routes/auth-routes.js +198 -30
  36. package/src/plugins/api/routes/resource-routes.js +19 -4
  37. package/src/plugins/api/server/health-manager.class.js +163 -0
  38. package/src/plugins/api/server/middleware-chain.class.js +310 -0
  39. package/src/plugins/api/server/router.class.js +472 -0
  40. package/src/plugins/api/server.js +280 -1303
  41. package/src/plugins/api/utils/custom-routes.js +17 -5
  42. package/src/plugins/api/utils/guards.js +76 -17
  43. package/src/plugins/api/utils/openapi-generator-cached.class.js +133 -0
  44. package/src/plugins/api/utils/openapi-generator.js +7 -6
  45. package/src/plugins/audit.plugin.js +30 -8
  46. package/src/plugins/backup.plugin.js +110 -14
  47. package/src/plugins/cache/cache.class.js +22 -5
  48. package/src/plugins/cache/filesystem-cache.class.js +116 -19
  49. package/src/plugins/cache/memory-cache.class.js +211 -57
  50. package/src/plugins/cache/multi-tier-cache.class.js +371 -0
  51. package/src/plugins/cache/partition-aware-filesystem-cache.class.js +168 -47
  52. package/src/plugins/cache/redis-cache.class.js +552 -0
  53. package/src/plugins/cache/s3-cache.class.js +17 -8
  54. package/src/plugins/cache.plugin.js +176 -61
  55. package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +8 -1
  56. package/src/plugins/cloud-inventory/drivers/aws-driver.js +60 -29
  57. package/src/plugins/cloud-inventory/drivers/azure-driver.js +8 -1
  58. package/src/plugins/cloud-inventory/drivers/base-driver.js +16 -2
  59. package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +8 -1
  60. package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +8 -1
  61. package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +8 -1
  62. package/src/plugins/cloud-inventory/drivers/linode-driver.js +8 -1
  63. package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +8 -1
  64. package/src/plugins/cloud-inventory/drivers/vultr-driver.js +8 -1
  65. package/src/plugins/cloud-inventory/index.js +29 -8
  66. package/src/plugins/cloud-inventory/registry.js +64 -42
  67. package/src/plugins/cloud-inventory.plugin.js +240 -138
  68. package/src/plugins/concerns/plugin-dependencies.js +54 -0
  69. package/src/plugins/concerns/resource-names.js +100 -0
  70. package/src/plugins/consumers/index.js +10 -2
  71. package/src/plugins/consumers/sqs-consumer.js +12 -2
  72. package/src/plugins/cookie-farm-suite.plugin.js +278 -0
  73. package/src/plugins/cookie-farm.errors.js +73 -0
  74. package/src/plugins/cookie-farm.plugin.js +869 -0
  75. package/src/plugins/costs.plugin.js +7 -1
  76. package/src/plugins/eventual-consistency/analytics.js +94 -19
  77. package/src/plugins/eventual-consistency/config.js +15 -7
  78. package/src/plugins/eventual-consistency/consolidation.js +29 -11
  79. package/src/plugins/eventual-consistency/garbage-collection.js +3 -1
  80. package/src/plugins/eventual-consistency/helpers.js +39 -14
  81. package/src/plugins/eventual-consistency/install.js +21 -2
  82. package/src/plugins/eventual-consistency/utils.js +32 -10
  83. package/src/plugins/fulltext.plugin.js +38 -11
  84. package/src/plugins/geo.plugin.js +61 -9
  85. package/src/plugins/identity/concerns/config.js +61 -0
  86. package/src/plugins/identity/concerns/mfa-manager.js +15 -2
  87. package/src/plugins/identity/concerns/rate-limit.js +124 -0
  88. package/src/plugins/identity/concerns/resource-schemas.js +9 -1
  89. package/src/plugins/identity/concerns/token-generator.js +29 -4
  90. package/src/plugins/identity/drivers/auth-driver.interface.js +76 -0
  91. package/src/plugins/identity/drivers/client-credentials-driver.js +127 -0
  92. package/src/plugins/identity/drivers/index.js +18 -0
  93. package/src/plugins/identity/drivers/password-driver.js +122 -0
  94. package/src/plugins/identity/email-service.js +17 -2
  95. package/src/plugins/identity/index.js +413 -69
  96. package/src/plugins/identity/oauth2-server.js +413 -30
  97. package/src/plugins/identity/oidc-discovery.js +16 -8
  98. package/src/plugins/identity/rsa-keys.js +115 -35
  99. package/src/plugins/identity/server.js +166 -45
  100. package/src/plugins/identity/session-manager.js +53 -7
  101. package/src/plugins/identity/ui/pages/mfa-verification.js +17 -15
  102. package/src/plugins/identity/ui/routes.js +363 -255
  103. package/src/plugins/importer/index.js +153 -20
  104. package/src/plugins/index.js +9 -2
  105. package/src/plugins/kubernetes-inventory/index.js +6 -0
  106. package/src/plugins/kubernetes-inventory/k8s-driver.js +867 -0
  107. package/src/plugins/kubernetes-inventory/resource-types.js +274 -0
  108. package/src/plugins/kubernetes-inventory.plugin.js +980 -0
  109. package/src/plugins/metrics.plugin.js +64 -16
  110. package/src/plugins/ml/base-model.class.js +25 -15
  111. package/src/plugins/ml/regression-model.class.js +1 -1
  112. package/src/plugins/ml.errors.js +57 -25
  113. package/src/plugins/ml.plugin.js +28 -4
  114. package/src/plugins/namespace.js +210 -0
  115. package/src/plugins/plugin.class.js +180 -8
  116. package/src/plugins/puppeteer/console-monitor.js +729 -0
  117. package/src/plugins/puppeteer/cookie-manager.js +492 -0
  118. package/src/plugins/puppeteer/network-monitor.js +816 -0
  119. package/src/plugins/puppeteer/performance-manager.js +746 -0
  120. package/src/plugins/puppeteer/proxy-manager.js +478 -0
  121. package/src/plugins/puppeteer/stealth-manager.js +556 -0
  122. package/src/plugins/puppeteer.errors.js +81 -0
  123. package/src/plugins/puppeteer.plugin.js +1327 -0
  124. package/src/plugins/queue-consumer.plugin.js +69 -14
  125. package/src/plugins/recon/behaviors/uptime-behavior.js +691 -0
  126. package/src/plugins/recon/concerns/command-runner.js +148 -0
  127. package/src/plugins/recon/concerns/diff-detector.js +372 -0
  128. package/src/plugins/recon/concerns/fingerprint-builder.js +307 -0
  129. package/src/plugins/recon/concerns/process-manager.js +338 -0
  130. package/src/plugins/recon/concerns/report-generator.js +478 -0
  131. package/src/plugins/recon/concerns/security-analyzer.js +571 -0
  132. package/src/plugins/recon/concerns/target-normalizer.js +68 -0
  133. package/src/plugins/recon/config/defaults.js +321 -0
  134. package/src/plugins/recon/config/resources.js +370 -0
  135. package/src/plugins/recon/index.js +778 -0
  136. package/src/plugins/recon/managers/dependency-manager.js +174 -0
  137. package/src/plugins/recon/managers/scheduler-manager.js +179 -0
  138. package/src/plugins/recon/managers/storage-manager.js +745 -0
  139. package/src/plugins/recon/managers/target-manager.js +274 -0
  140. package/src/plugins/recon/stages/asn-stage.js +314 -0
  141. package/src/plugins/recon/stages/certificate-stage.js +84 -0
  142. package/src/plugins/recon/stages/dns-stage.js +107 -0
  143. package/src/plugins/recon/stages/dnsdumpster-stage.js +362 -0
  144. package/src/plugins/recon/stages/fingerprint-stage.js +71 -0
  145. package/src/plugins/recon/stages/google-dorks-stage.js +440 -0
  146. package/src/plugins/recon/stages/http-stage.js +89 -0
  147. package/src/plugins/recon/stages/latency-stage.js +148 -0
  148. package/src/plugins/recon/stages/massdns-stage.js +302 -0
  149. package/src/plugins/recon/stages/osint-stage.js +1373 -0
  150. package/src/plugins/recon/stages/ports-stage.js +169 -0
  151. package/src/plugins/recon/stages/screenshot-stage.js +94 -0
  152. package/src/plugins/recon/stages/secrets-stage.js +514 -0
  153. package/src/plugins/recon/stages/subdomains-stage.js +295 -0
  154. package/src/plugins/recon/stages/tls-audit-stage.js +78 -0
  155. package/src/plugins/recon/stages/vulnerability-stage.js +78 -0
  156. package/src/plugins/recon/stages/web-discovery-stage.js +113 -0
  157. package/src/plugins/recon/stages/whois-stage.js +349 -0
  158. package/src/plugins/recon.plugin.js +75 -0
  159. package/src/plugins/recon.plugin.js.backup +2635 -0
  160. package/src/plugins/relation.errors.js +87 -14
  161. package/src/plugins/replicator.plugin.js +514 -137
  162. package/src/plugins/replicators/base-replicator.class.js +89 -1
  163. package/src/plugins/replicators/bigquery-replicator.class.js +66 -22
  164. package/src/plugins/replicators/dynamodb-replicator.class.js +22 -15
  165. package/src/plugins/replicators/mongodb-replicator.class.js +22 -15
  166. package/src/plugins/replicators/mysql-replicator.class.js +52 -17
  167. package/src/plugins/replicators/planetscale-replicator.class.js +30 -4
  168. package/src/plugins/replicators/postgres-replicator.class.js +62 -27
  169. package/src/plugins/replicators/s3db-replicator.class.js +25 -18
  170. package/src/plugins/replicators/schema-sync.helper.js +3 -3
  171. package/src/plugins/replicators/sqs-replicator.class.js +8 -2
  172. package/src/plugins/replicators/turso-replicator.class.js +23 -3
  173. package/src/plugins/replicators/webhook-replicator.class.js +42 -4
  174. package/src/plugins/s3-queue.plugin.js +464 -65
  175. package/src/plugins/scheduler.plugin.js +20 -6
  176. package/src/plugins/state-machine.plugin.js +40 -9
  177. package/src/plugins/tfstate/base-driver.js +28 -4
  178. package/src/plugins/tfstate/errors.js +65 -10
  179. package/src/plugins/tfstate/filesystem-driver.js +52 -8
  180. package/src/plugins/tfstate/index.js +163 -90
  181. package/src/plugins/tfstate/s3-driver.js +64 -6
  182. package/src/plugins/ttl.plugin.js +72 -17
  183. package/src/plugins/vector/distances.js +18 -12
  184. package/src/plugins/vector/kmeans.js +26 -4
  185. package/src/resource.class.js +115 -19
  186. package/src/testing/factory.class.js +20 -3
  187. package/src/testing/seeder.class.js +7 -1
  188. package/src/clients/memory-client.md +0 -917
  189. package/src/plugins/cloud-inventory/drivers/mock-drivers.js +0 -449
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import tryFn from './try-fn.js';
13
+ import { ValidationError } from '../errors.js';
13
14
 
14
15
  /**
15
16
  * Validate IPv4 address format
@@ -54,7 +55,12 @@ export function isValidIPv6(ip) {
54
55
  */
55
56
  export function encodeIPv4(ip) {
56
57
  if (!isValidIPv4(ip)) {
57
- throw new Error(`Invalid IPv4 address: ${ip}`);
58
+ throw new ValidationError('Invalid IPv4 address', {
59
+ field: 'ip',
60
+ value: ip,
61
+ retriable: false,
62
+ suggestion: 'Provide a valid IPv4 address (e.g., "192.168.0.1").'
63
+ });
58
64
  }
59
65
 
60
66
  const octets = ip.split('.').map(octet => parseInt(octet, 10));
@@ -70,21 +76,38 @@ export function encodeIPv4(ip) {
70
76
  */
71
77
  export function decodeIPv4(encoded) {
72
78
  if (typeof encoded !== 'string') {
73
- throw new Error('Encoded IPv4 must be a string');
79
+ throw new ValidationError('Encoded IPv4 must be a string', {
80
+ field: 'encoded',
81
+ retriable: false,
82
+ suggestion: 'Pass the base64-encoded IPv4 string returned by encodeIPv4().'
83
+ });
74
84
  }
75
85
 
76
86
  const [ok, err, result] = tryFn(() => {
77
87
  const buffer = Buffer.from(encoded, 'base64');
78
88
 
79
89
  if (buffer.length !== 4) {
80
- throw new Error(`Invalid encoded IPv4 length: ${buffer.length} (expected 4)`);
90
+ throw new ValidationError('Invalid encoded IPv4 length', {
91
+ field: 'encoded',
92
+ value: encoded,
93
+ retriable: false,
94
+ suggestion: 'Ensure the encoded IPv4 string was produced by encodeIPv4().'
95
+ });
81
96
  }
82
97
 
83
98
  return Array.from(buffer).join('.');
84
99
  });
85
100
 
86
101
  if (!ok) {
87
- throw new Error(`Failed to decode IPv4: ${err.message}`);
102
+ if (err instanceof ValidationError) {
103
+ throw err;
104
+ }
105
+ throw new ValidationError('Failed to decode IPv4', {
106
+ field: 'encoded',
107
+ retriable: false,
108
+ suggestion: 'Confirm the value is a base64-encoded IPv4 string generated by encodeIPv4().',
109
+ original: err
110
+ });
88
111
  }
89
112
 
90
113
  return result;
@@ -97,7 +120,12 @@ export function decodeIPv4(encoded) {
97
120
  */
98
121
  export function expandIPv6(ip) {
99
122
  if (!isValidIPv6(ip)) {
100
- throw new Error(`Invalid IPv6 address: ${ip}`);
123
+ throw new ValidationError('Invalid IPv6 address', {
124
+ field: 'ip',
125
+ value: ip,
126
+ retriable: false,
127
+ suggestion: 'Provide a valid IPv6 address (e.g., "2001:db8::1").'
128
+ });
101
129
  }
102
130
 
103
131
  // Handle :: expansion
@@ -196,7 +224,12 @@ export function compressIPv6(ip) {
196
224
  */
197
225
  export function encodeIPv6(ip) {
198
226
  if (!isValidIPv6(ip)) {
199
- throw new Error(`Invalid IPv6 address: ${ip}`);
227
+ throw new ValidationError('Invalid IPv6 address', {
228
+ field: 'ip',
229
+ value: ip,
230
+ retriable: false,
231
+ suggestion: 'Provide a valid IPv6 address (e.g., "2001:db8::1").'
232
+ });
200
233
  }
201
234
 
202
235
  // Always encode for consistency (like IPv4)
@@ -229,7 +262,11 @@ export function encodeIPv6(ip) {
229
262
  */
230
263
  export function decodeIPv6(encoded, compress = true) {
231
264
  if (typeof encoded !== 'string') {
232
- throw new Error('Encoded IPv6 must be a string');
265
+ throw new ValidationError('Encoded IPv6 must be a string', {
266
+ field: 'encoded',
267
+ retriable: false,
268
+ suggestion: 'Pass the base64-encoded IPv6 string returned by encodeIPv6().'
269
+ });
233
270
  }
234
271
 
235
272
  // SMART DETECTION: Check if this is unencoded IPv6
@@ -245,7 +282,12 @@ export function decodeIPv6(encoded, compress = true) {
245
282
  const buffer = Buffer.from(encoded, 'base64');
246
283
 
247
284
  if (buffer.length !== 16) {
248
- throw new Error(`Invalid encoded IPv6 length: ${buffer.length} (expected 16)`);
285
+ throw new ValidationError('Invalid encoded IPv6 length', {
286
+ field: 'encoded',
287
+ value: encoded,
288
+ retriable: false,
289
+ suggestion: 'Ensure the encoded IPv6 string was produced by encodeIPv6().'
290
+ });
249
291
  }
250
292
 
251
293
  const groups = [];
@@ -260,7 +302,15 @@ export function decodeIPv6(encoded, compress = true) {
260
302
  });
261
303
 
262
304
  if (!ok) {
263
- throw new Error(`Failed to decode IPv6: ${err.message}`);
305
+ if (err instanceof ValidationError) {
306
+ throw err;
307
+ }
308
+ throw new ValidationError('Failed to decode IPv6', {
309
+ field: 'encoded',
310
+ retriable: false,
311
+ suggestion: 'Confirm the value is a base64-encoded IPv6 string generated by encodeIPv6().',
312
+ original: err
313
+ });
264
314
  }
265
315
 
266
316
  return result;
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { encode, decode } from './base62.js';
19
+ import { ValidationError } from '../errors.js';
19
20
 
20
21
  /**
21
22
  * Currency decimal places (number of decimals in smallest unit)
@@ -100,7 +101,13 @@ export function encodeMoney(value, currency = 'USD') {
100
101
 
101
102
  // Money cannot be negative (validation should happen at schema level)
102
103
  if (value < 0) {
103
- throw new Error(`Money value cannot be negative: ${value}`);
104
+ throw new ValidationError('Money value cannot be negative', {
105
+ field: 'value',
106
+ value,
107
+ statusCode: 400,
108
+ retriable: false,
109
+ suggestion: 'Provide a non-negative monetary value or store debts in a separate field.'
110
+ });
104
111
  }
105
112
 
106
113
  const decimals = getCurrencyDecimals(currency);
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import bcrypt from 'bcrypt';
10
+ import { ValidationError } from '../errors.js';
10
11
 
11
12
  /**
12
13
  * Hash a password using bcrypt (synchronous)
@@ -16,11 +17,21 @@ import bcrypt from 'bcrypt';
16
17
  */
17
18
  export function hashPasswordSync(password, rounds = 10) {
18
19
  if (!password || typeof password !== 'string') {
19
- throw new Error('Password must be a non-empty string');
20
+ throw new ValidationError('Password must be a non-empty string', {
21
+ field: 'password',
22
+ statusCode: 400,
23
+ retriable: false,
24
+ suggestion: 'Provide a non-empty string before calling hashPasswordSync().'
25
+ });
20
26
  }
21
27
 
22
28
  if (rounds < 4 || rounds > 31) {
23
- throw new Error('Bcrypt rounds must be between 4 and 31');
29
+ throw new ValidationError('Bcrypt rounds must be between 4 and 31', {
30
+ field: 'rounds',
31
+ statusCode: 400,
32
+ retriable: false,
33
+ suggestion: 'Configure bcrypt rounds between 4 and 31 (inclusive).'
34
+ });
24
35
  }
25
36
 
26
37
  return bcrypt.hashSync(password, rounds);
@@ -34,11 +45,21 @@ export function hashPasswordSync(password, rounds = 10) {
34
45
  */
35
46
  export async function hashPassword(password, rounds = 10) {
36
47
  if (!password || typeof password !== 'string') {
37
- throw new Error('Password must be a non-empty string');
48
+ throw new ValidationError('Password must be a non-empty string', {
49
+ field: 'password',
50
+ statusCode: 400,
51
+ retriable: false,
52
+ suggestion: 'Provide a non-empty string before calling hashPassword().'
53
+ });
38
54
  }
39
55
 
40
56
  if (rounds < 4 || rounds > 31) {
41
- throw new Error('Bcrypt rounds must be between 4 and 31');
57
+ throw new ValidationError('Bcrypt rounds must be between 4 and 31', {
58
+ field: 'rounds',
59
+ statusCode: 400,
60
+ retriable: false,
61
+ suggestion: 'Configure bcrypt rounds between 4 and 31 (inclusive).'
62
+ });
42
63
  }
43
64
 
44
65
  return await bcrypt.hash(password, rounds);
@@ -82,18 +103,33 @@ export async function verifyPassword(plaintext, hash) {
82
103
  */
83
104
  export function compactHash(bcryptHash) {
84
105
  if (!bcryptHash || typeof bcryptHash !== 'string') {
85
- throw new Error('Invalid bcrypt hash');
106
+ throw new ValidationError('Invalid bcrypt hash', {
107
+ field: 'bcryptHash',
108
+ statusCode: 400,
109
+ retriable: false,
110
+ suggestion: 'Provide a valid bcrypt hash generated by hashPassword().'
111
+ });
86
112
  }
87
113
 
88
114
  // Bcrypt format: $2a$10$ or $2b$10$ or $2y$10$
89
115
  if (!bcryptHash.startsWith('$2')) {
90
- throw new Error('Not a valid bcrypt hash');
116
+ throw new ValidationError('Not a valid bcrypt hash', {
117
+ field: 'bcryptHash',
118
+ statusCode: 400,
119
+ retriable: false,
120
+ suggestion: 'Ensure the hash starts with "$2" and was produced by bcrypt.'
121
+ });
91
122
  }
92
123
 
93
124
  // Remove prefix (e.g., "$2b$10$")
94
125
  const parts = bcryptHash.split('$');
95
126
  if (parts.length !== 4) {
96
- throw new Error('Invalid bcrypt hash format');
127
+ throw new ValidationError('Invalid bcrypt hash format', {
128
+ field: 'bcryptHash',
129
+ statusCode: 400,
130
+ retriable: false,
131
+ suggestion: 'Provide a complete bcrypt hash (e.g., "$2b$10$...").'
132
+ });
97
133
  }
98
134
 
99
135
  // Return just the salt+hash part (last element after split)
@@ -109,7 +145,12 @@ export function compactHash(bcryptHash) {
109
145
  */
110
146
  export function expandHash(compactHash, rounds = 10) {
111
147
  if (!compactHash || typeof compactHash !== 'string') {
112
- throw new Error('Invalid compacted hash');
148
+ throw new ValidationError('Invalid compacted hash', {
149
+ field: 'compactHash',
150
+ statusCode: 400,
151
+ retriable: false,
152
+ suggestion: 'Provide a compacted hash returned from compactHash().'
153
+ });
113
154
  }
114
155
 
115
156
  // If it's already a full hash, return as-is
@@ -31,6 +31,7 @@
31
31
  import { metadataEncode, metadataDecode } from './metadata-encoding.js';
32
32
  import { calculateEffectiveLimit, calculateUTF8Bytes } from './calculator.js';
33
33
  import { tryFn } from './try-fn.js';
34
+ import { idGenerator } from './id.js';
34
35
  import { PluginStorageError, MetadataLimitError, BehaviorError } from '../errors.js';
35
36
 
36
37
  const S3_METADATA_LIMIT = 2047; // AWS S3 metadata limit in bytes
@@ -89,10 +90,16 @@ export class PluginStorage {
89
90
  * @param {number} options.ttl - Time-to-live in seconds (optional)
90
91
  * @param {string} options.behavior - 'body-overflow' | 'body-only' | 'enforce-limits'
91
92
  * @param {string} options.contentType - Content type (default: application/json)
92
- * @returns {Promise<void>}
93
+ * @returns {Promise<Object>} Underlying client response (includes ETag when available)
93
94
  */
94
95
  async set(key, data, options = {}) {
95
- const { ttl, behavior = 'body-overflow', contentType = 'application/json' } = options;
96
+ const {
97
+ ttl,
98
+ behavior = 'body-overflow',
99
+ contentType = 'application/json',
100
+ ifMatch,
101
+ ifNoneMatch
102
+ } = options;
96
103
 
97
104
  // Clone data to avoid mutating original
98
105
  const dataToSave = { ...data };
@@ -117,8 +124,15 @@ export class PluginStorage {
117
124
  putParams.body = JSON.stringify(body);
118
125
  }
119
126
 
127
+ if (ifMatch !== undefined) {
128
+ putParams.ifMatch = ifMatch;
129
+ }
130
+ if (ifNoneMatch !== undefined) {
131
+ putParams.ifNoneMatch = ifNoneMatch;
132
+ }
133
+
120
134
  // Save to S3
121
- const [ok, err] = await tryFn(() => this.client.putObject(putParams));
135
+ const [ok, err, response] = await tryFn(() => this.client.putObject(putParams));
122
136
 
123
137
  if (!ok) {
124
138
  throw new PluginStorageError(`Failed to save plugin data`, {
@@ -131,6 +145,8 @@ export class PluginStorage {
131
145
  suggestion: 'Check S3 permissions and key format'
132
146
  });
133
147
  }
148
+
149
+ return response;
134
150
  }
135
151
 
136
152
  /**
@@ -165,7 +181,17 @@ export class PluginStorage {
165
181
 
166
182
  if (!ok) {
167
183
  // If not found, return null
168
- if (err.name === 'NoSuchKey' || err.Code === 'NoSuchKey') {
184
+ // Check multiple ways the error might indicate "not found":
185
+ // 1. error.name is 'NoSuchKey' (standard S3)
186
+ // 2. error.code is 'NoSuchKey' (ResourceError with code property)
187
+ // 3. error.Code is 'NoSuchKey' (AWS SDK format)
188
+ // 4. statusCode is 404
189
+ if (
190
+ err.name === 'NoSuchKey' ||
191
+ err.code === 'NoSuchKey' ||
192
+ err.Code === 'NoSuchKey' ||
193
+ err.statusCode === 404
194
+ ) {
169
195
  return null;
170
196
  }
171
197
  throw new PluginStorageError(`Failed to retrieve plugin data`, {
@@ -625,40 +651,172 @@ export class PluginStorage {
625
651
  * @returns {Promise<Object|null>} Lock object or null if couldn't acquire
626
652
  */
627
653
  async acquireLock(lockName, options = {}) {
628
- const { ttl = 30, timeout = 0, workerId = 'unknown' } = options;
654
+ const {
655
+ ttl = 30,
656
+ timeout = 0,
657
+ workerId = 'unknown',
658
+ retryDelay = 100,
659
+ maxRetryDelay = 1000
660
+ } = options;
629
661
  const key = this.getPluginKey(null, 'locks', lockName);
662
+ const token = idGenerator();
630
663
 
631
664
  const startTime = Date.now();
665
+ let attempt = 0;
632
666
 
633
667
  while (true) {
634
- // Try to acquire
635
- const existing = await this.get(key);
636
- if (!existing) {
637
- await this.set(key, { workerId, acquiredAt: Date.now() }, { ttl });
638
- return { key, workerId };
668
+ const payload = {
669
+ workerId,
670
+ token,
671
+ acquiredAt: Date.now()
672
+ };
673
+
674
+ const [ok, err, putResponse] = await tryFn(() => this.set(key, payload, {
675
+ ttl,
676
+ behavior: 'body-only',
677
+ ifNoneMatch: '*'
678
+ }));
679
+
680
+ if (ok) {
681
+ return {
682
+ name: lockName,
683
+ key,
684
+ token,
685
+ workerId,
686
+ expiresAt: Date.now() + ttl * 1000,
687
+ etag: putResponse?.ETag || null
688
+ };
689
+ }
690
+
691
+ const originalError = err?.original || err;
692
+ const errorCode = originalError?.code || originalError?.Code || originalError?.name;
693
+ const statusCode = originalError?.statusCode || originalError?.$metadata?.httpStatusCode;
694
+ const isPreconditionFailure = errorCode === 'PreconditionFailed' || statusCode === 412;
695
+
696
+ if (!isPreconditionFailure) {
697
+ throw err;
698
+ }
699
+
700
+ // Check timeout (0 means don't wait, undefined means wait indefinitely)
701
+ if (timeout !== undefined && Date.now() - startTime >= timeout) {
702
+ return null;
639
703
  }
640
704
 
641
- // Check timeout
642
- if (Date.now() - startTime >= timeout) {
643
- return null; // Could not acquire
705
+ // Remove expired locks (get deletes expired entries automatically)
706
+ const current = await this.get(key);
707
+ if (!current) {
708
+ continue; // Lock expired - retry immediately
644
709
  }
645
710
 
646
- // Wait and retry (100ms intervals)
647
- await new Promise(resolve => setTimeout(resolve, 100));
711
+ attempt += 1;
712
+ const delay = this._computeBackoff(attempt, retryDelay, maxRetryDelay);
713
+ await this._sleep(delay);
648
714
  }
649
715
  }
650
716
 
651
717
  /**
652
718
  * Release a distributed lock
653
719
  *
654
- * @param {string} lockName - Lock identifier
720
+ * @param {Object|string} lock - Lock object returned by acquireLock or lock name
721
+ * @param {string} [token] - Lock token (required when passing lock name)
655
722
  * @returns {Promise<void>}
656
723
  */
657
- async releaseLock(lockName) {
658
- const key = this.getPluginKey(null, 'locks', lockName);
724
+ async releaseLock(lock, token) {
725
+ if (!lock) return;
726
+
727
+ let lockName;
728
+ let key;
729
+ let expectedToken = token;
730
+
731
+ if (typeof lock === 'object') {
732
+ lockName = lock.name || lock.lockName;
733
+ key = lock.key || (lockName ? this.getPluginKey(null, 'locks', lockName) : null);
734
+ expectedToken = lock.token ?? token;
735
+ if (!expectedToken && lock.token !== undefined) {
736
+ throw new PluginStorageError('Lock token missing on lock object', {
737
+ pluginSlug: this.pluginSlug,
738
+ operation: 'releaseLock',
739
+ lockName
740
+ });
741
+ }
742
+ } else if (typeof lock === 'string') {
743
+ lockName = lock;
744
+ key = this.getPluginKey(null, 'locks', lockName);
745
+ expectedToken = token;
746
+ if (!expectedToken) {
747
+ throw new PluginStorageError('releaseLock(lockName) now requires the lock token', {
748
+ pluginSlug: this.pluginSlug,
749
+ operation: 'releaseLock',
750
+ lockName,
751
+ suggestion: 'Pass the original lock object or provide the token explicitly'
752
+ });
753
+ }
754
+ } else {
755
+ throw new PluginStorageError('releaseLock expects a lock object or lock name', {
756
+ pluginSlug: this.pluginSlug,
757
+ operation: 'releaseLock'
758
+ });
759
+ }
760
+
761
+ if (!key) {
762
+ throw new PluginStorageError('Invalid lock key', {
763
+ pluginSlug: this.pluginSlug,
764
+ operation: 'releaseLock'
765
+ });
766
+ }
767
+
768
+ const current = await this.get(key);
769
+ if (!current) {
770
+ return;
771
+ }
772
+
773
+ if (current.token !== undefined) {
774
+ if (!expectedToken) {
775
+ throw new PluginStorageError('releaseLock detected a stored token but none was provided', {
776
+ pluginSlug: this.pluginSlug,
777
+ operation: 'releaseLock',
778
+ lockName,
779
+ suggestion: 'Always release using the lock object returned by acquireLock'
780
+ });
781
+ }
782
+ if (current.token !== expectedToken) {
783
+ return;
784
+ }
785
+ }
786
+
659
787
  await this.delete(key);
660
788
  }
661
789
 
790
+ /**
791
+ * Acquire a lock, execute a callback, and release automatically.
792
+ *
793
+ * @param {string} lockName - Lock identifier
794
+ * @param {Object} options - Options forwarded to acquireLock
795
+ * @param {Function} callback - Async function to execute while holding the lock
796
+ * @returns {Promise<*>} Callback result, or null when lock not acquired
797
+ */
798
+ async withLock(lockName, options, callback) {
799
+ if (typeof callback !== 'function') {
800
+ throw new PluginStorageError('withLock requires a callback function', {
801
+ pluginSlug: this.pluginSlug,
802
+ operation: 'withLock',
803
+ lockName,
804
+ suggestion: 'Pass an async function as the third argument'
805
+ });
806
+ }
807
+
808
+ const lock = await this.acquireLock(lockName, options);
809
+ if (!lock) {
810
+ return null;
811
+ }
812
+
813
+ try {
814
+ return await callback(lock);
815
+ } finally {
816
+ await tryFn(() => this.releaseLock(lock));
817
+ }
818
+ }
819
+
662
820
  /**
663
821
  * Check if a lock is currently held
664
822
  *
@@ -671,6 +829,16 @@ export class PluginStorage {
671
829
  return lock !== null;
672
830
  }
673
831
 
832
+ _computeBackoff(attempt, baseDelay, maxDelay) {
833
+ const exponential = Math.min(baseDelay * Math.pow(2, Math.max(attempt - 1, 0)), maxDelay);
834
+ const jitter = Math.floor(Math.random() * Math.max(baseDelay / 2, 1));
835
+ return exponential + jitter;
836
+ }
837
+
838
+ _sleep(ms) {
839
+ return new Promise((resolve) => setTimeout(resolve, ms));
840
+ }
841
+
674
842
  /**
675
843
  * Increment a counter value
676
844
  *