s3db.js 13.6.0 → 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 (193) hide show
  1. package/README.md +139 -43
  2. package/dist/s3db.cjs +72425 -38970
  3. package/dist/s3db.cjs.map +1 -1
  4. package/dist/s3db.es.js +72177 -38764
  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 +94 -49
  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/opengraph-helper.js +116 -0
  34. package/src/plugins/api/concerns/route-context.js +601 -0
  35. package/src/plugins/api/concerns/state-machine.js +288 -0
  36. package/src/plugins/api/index.js +180 -41
  37. package/src/plugins/api/routes/auth-routes.js +198 -30
  38. package/src/plugins/api/routes/resource-routes.js +19 -4
  39. package/src/plugins/api/server/health-manager.class.js +163 -0
  40. package/src/plugins/api/server/middleware-chain.class.js +310 -0
  41. package/src/plugins/api/server/router.class.js +472 -0
  42. package/src/plugins/api/server.js +280 -1303
  43. package/src/plugins/api/utils/custom-routes.js +17 -5
  44. package/src/plugins/api/utils/guards.js +76 -17
  45. package/src/plugins/api/utils/openapi-generator-cached.class.js +133 -0
  46. package/src/plugins/api/utils/openapi-generator.js +7 -6
  47. package/src/plugins/api/utils/template-engine.js +77 -3
  48. package/src/plugins/audit.plugin.js +30 -8
  49. package/src/plugins/backup.plugin.js +110 -14
  50. package/src/plugins/cache/cache.class.js +22 -5
  51. package/src/plugins/cache/filesystem-cache.class.js +116 -19
  52. package/src/plugins/cache/memory-cache.class.js +211 -57
  53. package/src/plugins/cache/multi-tier-cache.class.js +371 -0
  54. package/src/plugins/cache/partition-aware-filesystem-cache.class.js +168 -47
  55. package/src/plugins/cache/redis-cache.class.js +552 -0
  56. package/src/plugins/cache/s3-cache.class.js +17 -8
  57. package/src/plugins/cache.plugin.js +176 -61
  58. package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +8 -1
  59. package/src/plugins/cloud-inventory/drivers/aws-driver.js +60 -29
  60. package/src/plugins/cloud-inventory/drivers/azure-driver.js +8 -1
  61. package/src/plugins/cloud-inventory/drivers/base-driver.js +16 -2
  62. package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +8 -1
  63. package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +8 -1
  64. package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +8 -1
  65. package/src/plugins/cloud-inventory/drivers/linode-driver.js +8 -1
  66. package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +8 -1
  67. package/src/plugins/cloud-inventory/drivers/vultr-driver.js +8 -1
  68. package/src/plugins/cloud-inventory/index.js +29 -8
  69. package/src/plugins/cloud-inventory/registry.js +64 -42
  70. package/src/plugins/cloud-inventory.plugin.js +240 -138
  71. package/src/plugins/concerns/plugin-dependencies.js +54 -0
  72. package/src/plugins/concerns/resource-names.js +100 -0
  73. package/src/plugins/consumers/index.js +10 -2
  74. package/src/plugins/consumers/sqs-consumer.js +12 -2
  75. package/src/plugins/cookie-farm-suite.plugin.js +278 -0
  76. package/src/plugins/cookie-farm.errors.js +73 -0
  77. package/src/plugins/cookie-farm.plugin.js +869 -0
  78. package/src/plugins/costs.plugin.js +7 -1
  79. package/src/plugins/eventual-consistency/analytics.js +94 -19
  80. package/src/plugins/eventual-consistency/config.js +15 -7
  81. package/src/plugins/eventual-consistency/consolidation.js +29 -11
  82. package/src/plugins/eventual-consistency/garbage-collection.js +3 -1
  83. package/src/plugins/eventual-consistency/helpers.js +39 -14
  84. package/src/plugins/eventual-consistency/install.js +21 -2
  85. package/src/plugins/eventual-consistency/utils.js +32 -10
  86. package/src/plugins/fulltext.plugin.js +38 -11
  87. package/src/plugins/geo.plugin.js +61 -9
  88. package/src/plugins/identity/concerns/config.js +61 -0
  89. package/src/plugins/identity/concerns/mfa-manager.js +15 -2
  90. package/src/plugins/identity/concerns/rate-limit.js +124 -0
  91. package/src/plugins/identity/concerns/resource-schemas.js +9 -1
  92. package/src/plugins/identity/concerns/token-generator.js +29 -4
  93. package/src/plugins/identity/drivers/auth-driver.interface.js +76 -0
  94. package/src/plugins/identity/drivers/client-credentials-driver.js +127 -0
  95. package/src/plugins/identity/drivers/index.js +18 -0
  96. package/src/plugins/identity/drivers/password-driver.js +122 -0
  97. package/src/plugins/identity/email-service.js +17 -2
  98. package/src/plugins/identity/index.js +413 -69
  99. package/src/plugins/identity/oauth2-server.js +413 -30
  100. package/src/plugins/identity/oidc-discovery.js +16 -8
  101. package/src/plugins/identity/rsa-keys.js +115 -35
  102. package/src/plugins/identity/server.js +166 -45
  103. package/src/plugins/identity/session-manager.js +53 -7
  104. package/src/plugins/identity/ui/pages/mfa-verification.js +17 -15
  105. package/src/plugins/identity/ui/routes.js +363 -255
  106. package/src/plugins/importer/index.js +153 -20
  107. package/src/plugins/index.js +9 -2
  108. package/src/plugins/kubernetes-inventory/index.js +6 -0
  109. package/src/plugins/kubernetes-inventory/k8s-driver.js +867 -0
  110. package/src/plugins/kubernetes-inventory/resource-types.js +274 -0
  111. package/src/plugins/kubernetes-inventory.plugin.js +980 -0
  112. package/src/plugins/metrics.plugin.js +64 -16
  113. package/src/plugins/ml/base-model.class.js +25 -15
  114. package/src/plugins/ml/regression-model.class.js +1 -1
  115. package/src/plugins/ml.errors.js +57 -25
  116. package/src/plugins/ml.plugin.js +28 -4
  117. package/src/plugins/namespace.js +210 -0
  118. package/src/plugins/plugin.class.js +180 -8
  119. package/src/plugins/puppeteer/console-monitor.js +729 -0
  120. package/src/plugins/puppeteer/cookie-manager.js +492 -0
  121. package/src/plugins/puppeteer/network-monitor.js +816 -0
  122. package/src/plugins/puppeteer/performance-manager.js +746 -0
  123. package/src/plugins/puppeteer/proxy-manager.js +478 -0
  124. package/src/plugins/puppeteer/stealth-manager.js +556 -0
  125. package/src/plugins/puppeteer.errors.js +81 -0
  126. package/src/plugins/puppeteer.plugin.js +1327 -0
  127. package/src/plugins/queue-consumer.plugin.js +69 -14
  128. package/src/plugins/recon/behaviors/uptime-behavior.js +691 -0
  129. package/src/plugins/recon/concerns/command-runner.js +148 -0
  130. package/src/plugins/recon/concerns/diff-detector.js +372 -0
  131. package/src/plugins/recon/concerns/fingerprint-builder.js +307 -0
  132. package/src/plugins/recon/concerns/process-manager.js +338 -0
  133. package/src/plugins/recon/concerns/report-generator.js +478 -0
  134. package/src/plugins/recon/concerns/security-analyzer.js +571 -0
  135. package/src/plugins/recon/concerns/target-normalizer.js +68 -0
  136. package/src/plugins/recon/config/defaults.js +321 -0
  137. package/src/plugins/recon/config/resources.js +370 -0
  138. package/src/plugins/recon/index.js +778 -0
  139. package/src/plugins/recon/managers/dependency-manager.js +174 -0
  140. package/src/plugins/recon/managers/scheduler-manager.js +179 -0
  141. package/src/plugins/recon/managers/storage-manager.js +745 -0
  142. package/src/plugins/recon/managers/target-manager.js +274 -0
  143. package/src/plugins/recon/stages/asn-stage.js +314 -0
  144. package/src/plugins/recon/stages/certificate-stage.js +84 -0
  145. package/src/plugins/recon/stages/dns-stage.js +107 -0
  146. package/src/plugins/recon/stages/dnsdumpster-stage.js +362 -0
  147. package/src/plugins/recon/stages/fingerprint-stage.js +71 -0
  148. package/src/plugins/recon/stages/google-dorks-stage.js +440 -0
  149. package/src/plugins/recon/stages/http-stage.js +89 -0
  150. package/src/plugins/recon/stages/latency-stage.js +148 -0
  151. package/src/plugins/recon/stages/massdns-stage.js +302 -0
  152. package/src/plugins/recon/stages/osint-stage.js +1373 -0
  153. package/src/plugins/recon/stages/ports-stage.js +169 -0
  154. package/src/plugins/recon/stages/screenshot-stage.js +94 -0
  155. package/src/plugins/recon/stages/secrets-stage.js +514 -0
  156. package/src/plugins/recon/stages/subdomains-stage.js +295 -0
  157. package/src/plugins/recon/stages/tls-audit-stage.js +78 -0
  158. package/src/plugins/recon/stages/vulnerability-stage.js +78 -0
  159. package/src/plugins/recon/stages/web-discovery-stage.js +113 -0
  160. package/src/plugins/recon/stages/whois-stage.js +349 -0
  161. package/src/plugins/recon.plugin.js +75 -0
  162. package/src/plugins/recon.plugin.js.backup +2635 -0
  163. package/src/plugins/relation.errors.js +87 -14
  164. package/src/plugins/replicator.plugin.js +514 -137
  165. package/src/plugins/replicators/base-replicator.class.js +89 -1
  166. package/src/plugins/replicators/bigquery-replicator.class.js +66 -22
  167. package/src/plugins/replicators/dynamodb-replicator.class.js +22 -15
  168. package/src/plugins/replicators/mongodb-replicator.class.js +22 -15
  169. package/src/plugins/replicators/mysql-replicator.class.js +52 -17
  170. package/src/plugins/replicators/planetscale-replicator.class.js +30 -4
  171. package/src/plugins/replicators/postgres-replicator.class.js +62 -27
  172. package/src/plugins/replicators/s3db-replicator.class.js +25 -18
  173. package/src/plugins/replicators/schema-sync.helper.js +3 -3
  174. package/src/plugins/replicators/sqs-replicator.class.js +8 -2
  175. package/src/plugins/replicators/turso-replicator.class.js +23 -3
  176. package/src/plugins/replicators/webhook-replicator.class.js +42 -4
  177. package/src/plugins/s3-queue.plugin.js +464 -65
  178. package/src/plugins/scheduler.plugin.js +20 -6
  179. package/src/plugins/state-machine.plugin.js +40 -9
  180. package/src/plugins/tfstate/README.md +126 -126
  181. package/src/plugins/tfstate/base-driver.js +28 -4
  182. package/src/plugins/tfstate/errors.js +65 -10
  183. package/src/plugins/tfstate/filesystem-driver.js +52 -8
  184. package/src/plugins/tfstate/index.js +163 -90
  185. package/src/plugins/tfstate/s3-driver.js +64 -6
  186. package/src/plugins/ttl.plugin.js +72 -17
  187. package/src/plugins/vector/distances.js +18 -12
  188. package/src/plugins/vector/kmeans.js +26 -4
  189. package/src/resource.class.js +115 -19
  190. package/src/testing/factory.class.js +20 -3
  191. package/src/testing/seeder.class.js +7 -1
  192. package/src/clients/memory-client.md +0 -917
  193. package/src/plugins/cloud-inventory/drivers/mock-drivers.js +0 -449
@@ -8,7 +8,9 @@
8
8
  import { createHash } from 'crypto';
9
9
  import { writeFile, readFile } from 'fs/promises';
10
10
  import { Readable } from 'stream';
11
+
11
12
  import tryFn from '../concerns/try-fn.js';
13
+ import { MetadataLimitError, ResourceError, ValidationError } from '../errors.js';
12
14
 
13
15
  export class MemoryStorage {
14
16
  constructor(config = {}) {
@@ -20,26 +22,119 @@ export class MemoryStorage {
20
22
 
21
23
  // Configuration
22
24
  this.bucket = config.bucket || 's3db';
23
- this.enforceLimits = config.enforceLimits || false;
24
- this.metadataLimit = config.metadataLimit || 2048; // 2KB like S3
25
- this.maxObjectSize = config.maxObjectSize || 5 * 1024 * 1024 * 1024; // 5GB
25
+ this.enforceLimits = Boolean(config.enforceLimits);
26
+ this.metadataLimit = config.metadataLimit ?? 2048; // 2KB like S3
27
+ this.maxObjectSize = config.maxObjectSize ?? 5 * 1024 * 1024 * 1024; // 5GB
26
28
  this.persistPath = config.persistPath;
27
- this.autoPersist = config.autoPersist || false;
28
- this.verbose = config.verbose || false;
29
+ this.autoPersist = Boolean(config.autoPersist);
30
+ this.verbose = Boolean(config.verbose);
29
31
  }
30
32
 
31
33
  /**
32
34
  * Generate ETag (MD5 hash) for object body
33
35
  */
34
36
  _generateETag(body) {
35
- const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body || '');
37
+ const buffer = this._toBuffer(body);
36
38
  return createHash('md5').update(buffer).digest('hex');
37
39
  }
38
40
 
41
+ /**
42
+ * Convert arbitrary body input to Buffer without triggering coverage-opaque branches
43
+ */
44
+ _toBuffer(body) {
45
+ if (Buffer.isBuffer(body)) {
46
+ return body;
47
+ }
48
+
49
+ if (body === undefined || body === null) {
50
+ return Buffer.alloc(0);
51
+ }
52
+
53
+ return Buffer.from(body);
54
+ }
55
+
56
+ /**
57
+ * Ensure ETag matches AWS quoting
58
+ */
59
+ _formatEtag(etag) {
60
+ return `"${etag}"`;
61
+ }
62
+
63
+ /**
64
+ * Normalize ETag header value into array of hashes (quotes removed)
65
+ */
66
+ _normalizeEtagHeader(headerValue) {
67
+ if (headerValue === undefined || headerValue === null) {
68
+ return [];
69
+ }
70
+
71
+ return String(headerValue)
72
+ .split(',')
73
+ .map(value => value.trim())
74
+ .filter(Boolean)
75
+ .map(value => value.replace(/^W\//i, '').replace(/^['"]|['"]$/g, ''));
76
+ }
77
+
78
+ /**
79
+ * Encode continuation token (base64) to mimic AWS opaque tokens
80
+ */
81
+ _encodeContinuationToken(key) {
82
+ return Buffer.from(String(key), 'utf8').toString('base64');
83
+ }
84
+
85
+ /**
86
+ * Decode continuation token, throwing ValidationError on malformed input
87
+ */
88
+ _decodeContinuationToken(token) {
89
+ try {
90
+ const normalized = String(token).trim();
91
+ const decoded = Buffer.from(normalized, 'base64').toString('utf8');
92
+ const reencoded = Buffer.from(decoded, 'utf8').toString('base64').replace(/=+$/, '');
93
+ const normalizedNoPad = normalized.replace(/=+$/, '');
94
+
95
+ if (!decoded || reencoded !== normalizedNoPad) {
96
+ throw new Error('Invalid continuation token format');
97
+ }
98
+
99
+ return decoded;
100
+ } catch (error) {
101
+ throw new ValidationError('Invalid continuation token', {
102
+ field: 'ContinuationToken',
103
+ retriable: false,
104
+ suggestion: 'Use the NextContinuationToken returned by a previous ListObjectsV2 response.',
105
+ original: error
106
+ });
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Identify common prefix grouping when delimiter is provided
112
+ */
113
+ _extractCommonPrefix(prefix, delimiter, key) {
114
+ /* c8 ignore next -- guard clause */
115
+ if (!delimiter) return null;
116
+
117
+ const hasPrefix = Boolean(prefix);
118
+ if (hasPrefix && !key.startsWith(prefix)) {
119
+ return null;
120
+ }
121
+
122
+ const remainder = hasPrefix ? key.slice(prefix.length) : key;
123
+ const index = remainder.indexOf(delimiter);
124
+
125
+ if (index === -1) {
126
+ return null;
127
+ }
128
+
129
+ const baseLength = hasPrefix ? prefix.length : 0;
130
+ return key.slice(0, baseLength + index + delimiter.length);
131
+ }
132
+
39
133
  /**
40
134
  * Calculate metadata size in bytes
41
135
  */
42
136
  _calculateMetadataSize(metadata) {
137
+ /* c8 ignore next -- guard clause */
43
138
  if (!metadata) return 0;
44
139
 
45
140
  let size = 0;
@@ -54,55 +149,101 @@ export class MemoryStorage {
54
149
  /**
55
150
  * Validate limits if enforceLimits is enabled
56
151
  */
152
+ /* c8 ignore start */
57
153
  _validateLimits(body, metadata) {
154
+ /* c8 ignore next -- limits opt-in */
58
155
  if (!this.enforceLimits) return;
59
156
 
60
157
  // Check metadata size
61
158
  const metadataSize = this._calculateMetadataSize(metadata);
62
159
  if (metadataSize > this.metadataLimit) {
63
- throw new Error(
64
- `Metadata size (${metadataSize} bytes) exceeds limit of ${this.metadataLimit} bytes`
65
- );
160
+ throw new MetadataLimitError('Metadata limit exceeded in memory storage', {
161
+ bucket: this.bucket,
162
+ totalSize: metadataSize,
163
+ effectiveLimit: this.metadataLimit,
164
+ operation: 'put',
165
+ retriable: false,
166
+ suggestion: 'Reduce metadata size or disable enforceLimits in MemoryClient configuration.'
167
+ });
66
168
  }
67
169
 
68
170
  // Check object size
69
171
  const bodySize = Buffer.isBuffer(body) ? body.length : Buffer.byteLength(body || '', 'utf8');
70
172
  if (bodySize > this.maxObjectSize) {
71
- throw new Error(
72
- `Object size (${bodySize} bytes) exceeds limit of ${this.maxObjectSize} bytes`
73
- );
173
+ throw new ResourceError('Object size exceeds in-memory limit', {
174
+ bucket: this.bucket,
175
+ operation: 'put',
176
+ size: bodySize,
177
+ maxObjectSize: this.maxObjectSize,
178
+ statusCode: 413,
179
+ retriable: false,
180
+ suggestion: 'Store smaller objects or increase maxObjectSize when instantiating MemoryClient.'
181
+ });
74
182
  }
75
183
  }
184
+ /* c8 ignore end */
76
185
 
77
186
  /**
78
187
  * Store an object
79
188
  */
80
- async put(key, { body, metadata, contentType, contentEncoding, contentLength, ifMatch }) {
189
+ async put(key, { body, metadata, contentType, contentEncoding, contentLength, ifMatch, ifNoneMatch }) {
81
190
  // Validate limits
82
191
  this._validateLimits(body, metadata);
83
192
 
84
193
  // Check ifMatch (conditional put)
194
+ const existing = this.objects.get(key);
85
195
  if (ifMatch !== undefined) {
86
- const existing = this.objects.get(key);
87
- if (existing && existing.etag !== ifMatch) {
88
- throw new Error(`Precondition failed: ETag mismatch for key "${key}"`);
196
+ const expectedEtags = this._normalizeEtagHeader(ifMatch);
197
+ const currentEtag = existing ? existing.etag : null;
198
+ const matches = expectedEtags.length > 0 && currentEtag ? expectedEtags.includes(currentEtag) : false;
199
+
200
+ if (!existing || !matches) {
201
+ throw new ResourceError(`Precondition failed: ETag mismatch for key "${key}"`, {
202
+ bucket: this.bucket,
203
+ key,
204
+ code: 'PreconditionFailed',
205
+ statusCode: 412,
206
+ retriable: false,
207
+ suggestion: 'Fetch the latest object and retry with the current ETag in options.ifMatch.'
208
+ });
89
209
  }
90
210
  }
91
211
 
92
- const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body || '');
212
+ if (ifNoneMatch !== undefined) {
213
+ const normalized = this._normalizeEtagHeader(ifNoneMatch);
214
+ const targetValue = existing ? existing.etag : null;
215
+ const shouldFail =
216
+ (ifNoneMatch === '*' && Boolean(existing)) ||
217
+ (normalized.length > 0 && existing && normalized.includes(targetValue));
218
+
219
+ if (shouldFail) {
220
+ throw new ResourceError(`Precondition failed: object already exists for key "${key}"`, {
221
+ bucket: this.bucket,
222
+ key,
223
+ code: 'PreconditionFailed',
224
+ statusCode: 412,
225
+ retriable: false,
226
+ suggestion: 'Use ifNoneMatch: "*" only when the object should not exist or remove the conditional header.'
227
+ });
228
+ }
229
+ }
230
+
231
+ const buffer = this._toBuffer(body);
93
232
  const etag = this._generateETag(buffer);
94
233
  const lastModified = new Date().toISOString();
95
234
  const size = buffer.length;
96
235
 
97
236
  const objectData = {
98
237
  body: buffer,
99
- metadata: metadata || {},
238
+ /* c8 ignore next */
239
+ metadata: metadata ? { ...metadata } : {},
100
240
  contentType: contentType || 'application/octet-stream',
101
241
  etag,
102
242
  lastModified,
103
243
  size,
104
244
  contentEncoding,
105
- contentLength: contentLength || size
245
+ /* c8 ignore next */
246
+ contentLength: typeof contentLength === 'number' ? contentLength : size
106
247
  };
107
248
 
108
249
  this.objects.set(key, objectData);
@@ -112,12 +253,13 @@ export class MemoryStorage {
112
253
  }
113
254
 
114
255
  // Auto-persist if enabled
256
+ /* c8 ignore next -- persistence optional */
115
257
  if (this.autoPersist && this.persistPath) {
116
258
  await this.saveToDisk();
117
259
  }
118
260
 
119
261
  return {
120
- ETag: etag,
262
+ ETag: this._formatEtag(etag),
121
263
  VersionId: null, // Memory storage doesn't support versioning
122
264
  ServerSideEncryption: null,
123
265
  Location: `/${this.bucket}/${key}`
@@ -131,14 +273,16 @@ export class MemoryStorage {
131
273
  const obj = this.objects.get(key);
132
274
 
133
275
  if (!obj) {
134
- const error = new Error(`Object not found: ${key}`);
276
+ const error = new ResourceError(`Object not found: ${key}`, {
277
+ bucket: this.bucket,
278
+ key,
279
+ code: 'NoSuchKey',
280
+ statusCode: 404,
281
+ retriable: false,
282
+ suggestion: 'Ensure the key exists before attempting to read it.'
283
+ });
284
+ // Set error name to 'NoSuchKey' for S3 compatibility
135
285
  error.name = 'NoSuchKey';
136
- error.$metadata = {
137
- httpStatusCode: 404,
138
- requestId: 'memory-' + Date.now(),
139
- attempts: 1,
140
- totalRetryDelay: 0
141
- };
142
286
  throw error;
143
287
  }
144
288
 
@@ -149,12 +293,36 @@ export class MemoryStorage {
149
293
  // Convert Buffer to Readable stream (same as real S3 Client)
150
294
  const bodyStream = Readable.from(obj.body);
151
295
 
296
+ // Add AWS SDK compatible transformToString() method
297
+ // This mimics the AWS SDK's SdkStreamMixin behavior
298
+ bodyStream.transformToString = async (encoding = 'utf-8') => {
299
+ const chunks = [];
300
+ for await (const chunk of bodyStream) {
301
+ chunks.push(chunk);
302
+ }
303
+ return Buffer.concat(chunks).toString(encoding);
304
+ };
305
+
306
+ // Add AWS SDK compatible transformToByteArray() method
307
+ bodyStream.transformToByteArray = async () => {
308
+ const chunks = [];
309
+ for await (const chunk of bodyStream) {
310
+ chunks.push(chunk);
311
+ }
312
+ return new Uint8Array(Buffer.concat(chunks));
313
+ };
314
+
315
+ // Add AWS SDK compatible transformToWebStream() method
316
+ bodyStream.transformToWebStream = () => {
317
+ return Readable.toWeb(bodyStream);
318
+ };
319
+
152
320
  return {
153
321
  Body: bodyStream,
154
322
  Metadata: { ...obj.metadata },
155
323
  ContentType: obj.contentType,
156
324
  ContentLength: obj.size,
157
- ETag: obj.etag,
325
+ ETag: this._formatEtag(obj.etag),
158
326
  LastModified: new Date(obj.lastModified),
159
327
  ContentEncoding: obj.contentEncoding
160
328
  };
@@ -167,14 +335,16 @@ export class MemoryStorage {
167
335
  const obj = this.objects.get(key);
168
336
 
169
337
  if (!obj) {
170
- const error = new Error(`Object not found: ${key}`);
338
+ const error = new ResourceError(`Object not found: ${key}`, {
339
+ bucket: this.bucket,
340
+ key,
341
+ code: 'NoSuchKey',
342
+ statusCode: 404,
343
+ retriable: false,
344
+ suggestion: 'Ensure the key exists before attempting to read it.'
345
+ });
346
+ // Set error name to 'NoSuchKey' for S3 compatibility
171
347
  error.name = 'NoSuchKey';
172
- error.$metadata = {
173
- httpStatusCode: 404,
174
- requestId: 'memory-' + Date.now(),
175
- attempts: 1,
176
- totalRetryDelay: 0
177
- };
178
348
  throw error;
179
349
  }
180
350
 
@@ -186,7 +356,7 @@ export class MemoryStorage {
186
356
  Metadata: { ...obj.metadata },
187
357
  ContentType: obj.contentType,
188
358
  ContentLength: obj.size,
189
- ETag: obj.etag,
359
+ ETag: this._formatEtag(obj.etag),
190
360
  LastModified: new Date(obj.lastModified),
191
361
  ContentEncoding: obj.contentEncoding
192
362
  };
@@ -199,9 +369,14 @@ export class MemoryStorage {
199
369
  const source = this.objects.get(from);
200
370
 
201
371
  if (!source) {
202
- const error = new Error(`Source object not found: ${from}`);
203
- error.name = 'NoSuchKey';
204
- throw error;
372
+ throw new ResourceError(`Source object not found: ${from}`, {
373
+ bucket: this.bucket,
374
+ key: from,
375
+ code: 'NoSuchKey',
376
+ statusCode: 404,
377
+ retriable: false,
378
+ suggestion: 'Copy requires an existing source object. Verify the source key before retrying.'
379
+ });
205
380
  }
206
381
 
207
382
  // Determine final metadata based on directive
@@ -213,7 +388,7 @@ export class MemoryStorage {
213
388
  }
214
389
 
215
390
  // Copy the object
216
- const result = await this.put(to, {
391
+ await this.put(to, {
217
392
  body: source.body,
218
393
  metadata: finalMetadata,
219
394
  contentType: contentType || source.contentType,
@@ -224,7 +399,16 @@ export class MemoryStorage {
224
399
  console.log(`[MemoryStorage] COPY ${from} → ${to}`);
225
400
  }
226
401
 
227
- return result;
402
+ const destination = this.objects.get(to);
403
+ return {
404
+ CopyObjectResult: {
405
+ ETag: this._formatEtag(destination.etag),
406
+ LastModified: new Date(destination.lastModified).toISOString()
407
+ },
408
+ BucketKeyEnabled: false,
409
+ VersionId: null,
410
+ ServerSideEncryption: null
411
+ };
228
412
  }
229
413
 
230
414
  /**
@@ -246,6 +430,7 @@ export class MemoryStorage {
246
430
  }
247
431
 
248
432
  // Auto-persist if enabled
433
+ /* c8 ignore next -- persistence optional */
249
434
  if (this.autoPersist && this.persistPath) {
250
435
  await this.saveToDisk();
251
436
  }
@@ -286,70 +471,75 @@ export class MemoryStorage {
286
471
  /**
287
472
  * List objects with prefix/delimiter support
288
473
  */
289
- async list({ prefix = '', delimiter = null, maxKeys = 1000, continuationToken = null }) {
290
- const allKeys = Array.from(this.objects.keys());
291
-
292
- // Filter by prefix
293
- let filteredKeys = prefix
294
- ? allKeys.filter(key => key.startsWith(prefix))
295
- : allKeys;
474
+ async list({ prefix = '', delimiter = null, maxKeys = 1000, continuationToken = null, startAfter = null }) {
475
+ const sortedKeys = Array.from(this.objects.keys()).sort();
476
+ const prefixFilter = prefix || '';
296
477
 
297
- // Sort keys
298
- filteredKeys.sort();
478
+ let filteredKeys = prefixFilter
479
+ ? sortedKeys.filter(key => key.startsWith(prefixFilter))
480
+ : sortedKeys;
299
481
 
300
- // Handle continuation token (simple offset-based pagination)
301
- let startIndex = 0;
482
+ let startAfterKey = null;
302
483
  if (continuationToken) {
303
- startIndex = parseInt(continuationToken) || 0;
484
+ startAfterKey = this._decodeContinuationToken(continuationToken);
485
+ } else if (startAfter) {
486
+ startAfterKey = startAfter;
304
487
  }
305
488
 
306
- // Apply pagination
307
- const paginatedKeys = filteredKeys.slice(startIndex, startIndex + maxKeys);
308
- const isTruncated = startIndex + maxKeys < filteredKeys.length;
309
- const nextContinuationToken = isTruncated ? String(startIndex + maxKeys) : null;
489
+ if (startAfterKey) {
490
+ filteredKeys = filteredKeys.filter(key => key > startAfterKey);
491
+ }
310
492
 
311
- // Group by common prefixes if delimiter is set
312
- const commonPrefixes = new Set();
313
493
  const contents = [];
494
+ const commonPrefixes = new Set();
495
+ let processed = 0;
496
+ let lastKeyInPage = null;
497
+
498
+ for (const key of filteredKeys) {
499
+ if (processed >= maxKeys) {
500
+ break;
501
+ }
314
502
 
315
- for (const key of paginatedKeys) {
316
- if (delimiter && prefix) {
317
- // Find the next delimiter after prefix
318
- const suffix = key.substring(prefix.length);
319
- const delimiterIndex = suffix.indexOf(delimiter);
320
-
321
- if (delimiterIndex !== -1) {
322
- // This key has a delimiter - add to common prefixes
323
- const commonPrefix = prefix + suffix.substring(0, delimiterIndex + 1);
324
- commonPrefixes.add(commonPrefix);
325
- continue;
503
+ const prefixEntry = delimiter ? this._extractCommonPrefix(prefixFilter, delimiter, key) : null;
504
+ if (prefixEntry) {
505
+ if (!commonPrefixes.has(prefixEntry)) {
506
+ commonPrefixes.add(prefixEntry);
326
507
  }
508
+ continue;
327
509
  }
328
510
 
329
- // Add to contents
330
511
  const obj = this.objects.get(key);
331
512
  contents.push({
332
513
  Key: key,
333
514
  Size: obj.size,
334
515
  LastModified: new Date(obj.lastModified),
335
- ETag: obj.etag,
516
+ ETag: this._formatEtag(obj.etag),
336
517
  StorageClass: 'STANDARD'
337
518
  });
519
+ processed++;
520
+ lastKeyInPage = key;
338
521
  }
339
522
 
523
+ const hasMoreKeys = filteredKeys.length > contents.length;
524
+ const nextContinuationToken = hasMoreKeys && lastKeyInPage
525
+ ? this._encodeContinuationToken(lastKeyInPage)
526
+ : null;
527
+
340
528
  if (this.verbose) {
341
- console.log(`[MemoryStorage] LIST prefix="${prefix}" (${contents.length} objects, ${commonPrefixes.size} prefixes)`);
529
+ console.log(`[MemoryStorage] LIST prefix="${prefix}" (${contents.length} objects, ${commonPrefixes.size} prefixes, truncated=${Boolean(nextContinuationToken)})`);
342
530
  }
343
531
 
344
532
  return {
345
533
  Contents: contents,
346
- CommonPrefixes: Array.from(commonPrefixes).map(prefix => ({ Prefix: prefix })),
347
- IsTruncated: isTruncated,
534
+ CommonPrefixes: Array.from(commonPrefixes).map(commonPrefix => ({ Prefix: commonPrefix })),
535
+ IsTruncated: Boolean(nextContinuationToken),
536
+ ContinuationToken: continuationToken || undefined,
348
537
  NextContinuationToken: nextContinuationToken,
349
- KeyCount: contents.length + commonPrefixes.size,
538
+ KeyCount: contents.length,
350
539
  MaxKeys: maxKeys,
351
- Prefix: prefix,
352
- Delimiter: delimiter
540
+ Prefix: prefix || undefined,
541
+ Delimiter: delimiter || undefined,
542
+ StartAfter: startAfter || undefined
353
543
  };
354
544
  }
355
545
 
@@ -385,7 +575,11 @@ export class MemoryStorage {
385
575
  */
386
576
  restore(snapshot) {
387
577
  if (!snapshot || !snapshot.objects) {
388
- throw new Error('Invalid snapshot format');
578
+ throw new ValidationError('Invalid snapshot format', {
579
+ field: 'snapshot',
580
+ retriable: false,
581
+ suggestion: 'Provide the snapshot returned by MemoryStorage.snapshot() before calling restore().'
582
+ });
389
583
  }
390
584
 
391
585
  this.objects.clear();
@@ -414,7 +608,11 @@ export class MemoryStorage {
414
608
  async saveToDisk(customPath) {
415
609
  const path = customPath || this.persistPath;
416
610
  if (!path) {
417
- throw new Error('No persist path configured');
611
+ throw new ValidationError('No persist path configured', {
612
+ field: 'persistPath',
613
+ retriable: false,
614
+ suggestion: 'Provide a persistPath when creating MemoryClient or pass a custom path to saveToDisk().'
615
+ });
418
616
  }
419
617
 
420
618
  const snapshot = this.snapshot();
@@ -423,7 +621,14 @@ export class MemoryStorage {
423
621
  const [ok, err] = await tryFn(() => writeFile(path, json, 'utf-8'));
424
622
 
425
623
  if (!ok) {
426
- throw new Error(`Failed to save to disk: ${err.message}`);
624
+ throw new ResourceError(`Failed to save to disk: ${err.message}`, {
625
+ bucket: this.bucket,
626
+ operation: 'saveToDisk',
627
+ statusCode: 500,
628
+ retriable: false,
629
+ suggestion: 'Check filesystem permissions and available disk space, then retry.',
630
+ original: err
631
+ });
427
632
  }
428
633
 
429
634
  if (this.verbose) {
@@ -439,13 +644,24 @@ export class MemoryStorage {
439
644
  async loadFromDisk(customPath) {
440
645
  const path = customPath || this.persistPath;
441
646
  if (!path) {
442
- throw new Error('No persist path configured');
647
+ throw new ValidationError('No persist path configured', {
648
+ field: 'persistPath',
649
+ retriable: false,
650
+ suggestion: 'Provide a persistPath when creating MemoryClient or pass a custom path to loadFromDisk().'
651
+ });
443
652
  }
444
653
 
445
654
  const [ok, err, json] = await tryFn(() => readFile(path, 'utf-8'));
446
655
 
447
656
  if (!ok) {
448
- throw new Error(`Failed to load from disk: ${err.message}`);
657
+ throw new ResourceError(`Failed to load from disk: ${err.message}`, {
658
+ bucket: this.bucket,
659
+ operation: 'loadFromDisk',
660
+ statusCode: 500,
661
+ retriable: false,
662
+ suggestion: 'Verify the file exists and is readable, then retry.',
663
+ original: err
664
+ });
449
665
  }
450
666
 
451
667
  const snapshot = JSON.parse(json);
@@ -496,7 +712,7 @@ export class MemoryStorage {
496
712
  clear() {
497
713
  this.objects.clear();
498
714
  if (this.verbose) {
499
- console.log(`[MemoryStorage] Cleared all objects`);
715
+ console.log('[MemoryStorage] Cleared all objects');
500
716
  }
501
717
  }
502
718
  }
@@ -116,7 +116,7 @@ export class S3Client extends EventEmitter {
116
116
  return response;
117
117
  }
118
118
 
119
- async putObject({ key, metadata, contentType, body, contentEncoding, contentLength, ifMatch }) {
119
+ async putObject({ key, metadata, contentType, body, contentEncoding, contentLength, ifMatch, ifNoneMatch }) {
120
120
  const keyPrefix = typeof this.config.keyPrefix === 'string' ? this.config.keyPrefix : '';
121
121
  const fullKey = keyPrefix ? path.join(keyPrefix, key) : key;
122
122
 
@@ -140,10 +140,11 @@ export class S3Client extends EventEmitter {
140
140
  Body: body || Buffer.alloc(0),
141
141
  };
142
142
 
143
- if (contentType !== undefined) options.ContentType = contentType
144
- if (contentEncoding !== undefined) options.ContentEncoding = contentEncoding
145
- if (contentLength !== undefined) options.ContentLength = contentLength
146
- if (ifMatch !== undefined) options.IfMatch = ifMatch
143
+ if (contentType !== undefined) options.ContentType = contentType;
144
+ if (contentEncoding !== undefined) options.ContentEncoding = contentEncoding;
145
+ if (contentLength !== undefined) options.ContentLength = contentLength;
146
+ if (ifMatch !== undefined) options.IfMatch = ifMatch;
147
+ if (ifNoneMatch !== undefined) options.IfNoneMatch = ifNoneMatch;
147
148
 
148
149
  const [ok, err, response] = await tryFn(() => this.sendCommand(new PutObjectCommand(options)));
149
150
  this.emit('cl:PutObject', err || response, { key, metadata, contentType, body, contentEncoding, contentLength });
@@ -591,4 +592,4 @@ export class S3Client extends EventEmitter {
591
592
  }
592
593
 
593
594
  // Default export for backward compatibility
594
- export default S3Client;
595
+ export default S3Client;
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { encode, decode } from './base62.js';
22
+ import { ValidationError } from '../errors.js';
22
23
 
23
24
  /**
24
25
  * Encode latitude with normalized range
@@ -41,7 +42,15 @@ export function encodeGeoLat(lat, precision = 6) {
41
42
 
42
43
  // Validate range
43
44
  if (lat < -90 || lat > 90) {
44
- throw new Error(`Latitude out of range [-90, 90]: ${lat}`);
45
+ throw new ValidationError('Latitude out of range', {
46
+ field: 'lat',
47
+ value: lat,
48
+ min: -90,
49
+ max: 90,
50
+ statusCode: 400,
51
+ retriable: false,
52
+ suggestion: 'Provide a latitude between -90 and +90 degrees.'
53
+ });
45
54
  }
46
55
 
47
56
  // Normalize: -90 to +90 → 0 to 180
@@ -100,7 +109,15 @@ export function encodeGeoLon(lon, precision = 6) {
100
109
 
101
110
  // Validate range
102
111
  if (lon < -180 || lon > 180) {
103
- throw new Error(`Longitude out of range [-180, 180]: ${lon}`);
112
+ throw new ValidationError('Longitude out of range', {
113
+ field: 'lon',
114
+ value: lon,
115
+ min: -180,
116
+ max: 180,
117
+ statusCode: 400,
118
+ retriable: false,
119
+ suggestion: 'Provide a longitude between -180 and +180 degrees.'
120
+ });
104
121
  }
105
122
 
106
123
  // Normalize: -180 to +180 → 0 to 360