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.
- package/README.md +139 -43
- package/dist/s3db.cjs +72425 -38970
- package/dist/s3db.cjs.map +1 -1
- package/dist/s3db.es.js +72177 -38764
- package/dist/s3db.es.js.map +1 -1
- package/mcp/lib/base-handler.js +157 -0
- package/mcp/lib/handlers/connection-handler.js +280 -0
- package/mcp/lib/handlers/query-handler.js +533 -0
- package/mcp/lib/handlers/resource-handler.js +428 -0
- package/mcp/lib/tool-registry.js +336 -0
- package/mcp/lib/tools/connection-tools.js +161 -0
- package/mcp/lib/tools/query-tools.js +267 -0
- package/mcp/lib/tools/resource-tools.js +404 -0
- package/package.json +94 -49
- package/src/clients/memory-client.class.js +346 -191
- package/src/clients/memory-storage.class.js +300 -84
- package/src/clients/s3-client.class.js +7 -6
- package/src/concerns/geo-encoding.js +19 -2
- package/src/concerns/ip.js +59 -9
- package/src/concerns/money.js +8 -1
- package/src/concerns/password-hashing.js +49 -8
- package/src/concerns/plugin-storage.js +186 -18
- package/src/concerns/storage-drivers/filesystem-driver.js +284 -0
- package/src/database.class.js +139 -29
- package/src/errors.js +332 -42
- package/src/plugins/api/auth/oidc-auth.js +66 -17
- package/src/plugins/api/auth/strategies/base-strategy.class.js +74 -0
- package/src/plugins/api/auth/strategies/factory.class.js +63 -0
- package/src/plugins/api/auth/strategies/global-strategy.class.js +44 -0
- package/src/plugins/api/auth/strategies/path-based-strategy.class.js +83 -0
- package/src/plugins/api/auth/strategies/path-rules-strategy.class.js +118 -0
- package/src/plugins/api/concerns/failban-manager.js +106 -57
- package/src/plugins/api/concerns/opengraph-helper.js +116 -0
- package/src/plugins/api/concerns/route-context.js +601 -0
- package/src/plugins/api/concerns/state-machine.js +288 -0
- package/src/plugins/api/index.js +180 -41
- package/src/plugins/api/routes/auth-routes.js +198 -30
- package/src/plugins/api/routes/resource-routes.js +19 -4
- package/src/plugins/api/server/health-manager.class.js +163 -0
- package/src/plugins/api/server/middleware-chain.class.js +310 -0
- package/src/plugins/api/server/router.class.js +472 -0
- package/src/plugins/api/server.js +280 -1303
- package/src/plugins/api/utils/custom-routes.js +17 -5
- package/src/plugins/api/utils/guards.js +76 -17
- package/src/plugins/api/utils/openapi-generator-cached.class.js +133 -0
- package/src/plugins/api/utils/openapi-generator.js +7 -6
- package/src/plugins/api/utils/template-engine.js +77 -3
- package/src/plugins/audit.plugin.js +30 -8
- package/src/plugins/backup.plugin.js +110 -14
- package/src/plugins/cache/cache.class.js +22 -5
- package/src/plugins/cache/filesystem-cache.class.js +116 -19
- package/src/plugins/cache/memory-cache.class.js +211 -57
- package/src/plugins/cache/multi-tier-cache.class.js +371 -0
- package/src/plugins/cache/partition-aware-filesystem-cache.class.js +168 -47
- package/src/plugins/cache/redis-cache.class.js +552 -0
- package/src/plugins/cache/s3-cache.class.js +17 -8
- package/src/plugins/cache.plugin.js +176 -61
- package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +8 -1
- package/src/plugins/cloud-inventory/drivers/aws-driver.js +60 -29
- package/src/plugins/cloud-inventory/drivers/azure-driver.js +8 -1
- package/src/plugins/cloud-inventory/drivers/base-driver.js +16 -2
- package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +8 -1
- package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +8 -1
- package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +8 -1
- package/src/plugins/cloud-inventory/drivers/linode-driver.js +8 -1
- package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +8 -1
- package/src/plugins/cloud-inventory/drivers/vultr-driver.js +8 -1
- package/src/plugins/cloud-inventory/index.js +29 -8
- package/src/plugins/cloud-inventory/registry.js +64 -42
- package/src/plugins/cloud-inventory.plugin.js +240 -138
- package/src/plugins/concerns/plugin-dependencies.js +54 -0
- package/src/plugins/concerns/resource-names.js +100 -0
- package/src/plugins/consumers/index.js +10 -2
- package/src/plugins/consumers/sqs-consumer.js +12 -2
- package/src/plugins/cookie-farm-suite.plugin.js +278 -0
- package/src/plugins/cookie-farm.errors.js +73 -0
- package/src/plugins/cookie-farm.plugin.js +869 -0
- package/src/plugins/costs.plugin.js +7 -1
- package/src/plugins/eventual-consistency/analytics.js +94 -19
- package/src/plugins/eventual-consistency/config.js +15 -7
- package/src/plugins/eventual-consistency/consolidation.js +29 -11
- package/src/plugins/eventual-consistency/garbage-collection.js +3 -1
- package/src/plugins/eventual-consistency/helpers.js +39 -14
- package/src/plugins/eventual-consistency/install.js +21 -2
- package/src/plugins/eventual-consistency/utils.js +32 -10
- package/src/plugins/fulltext.plugin.js +38 -11
- package/src/plugins/geo.plugin.js +61 -9
- package/src/plugins/identity/concerns/config.js +61 -0
- package/src/plugins/identity/concerns/mfa-manager.js +15 -2
- package/src/plugins/identity/concerns/rate-limit.js +124 -0
- package/src/plugins/identity/concerns/resource-schemas.js +9 -1
- package/src/plugins/identity/concerns/token-generator.js +29 -4
- package/src/plugins/identity/drivers/auth-driver.interface.js +76 -0
- package/src/plugins/identity/drivers/client-credentials-driver.js +127 -0
- package/src/plugins/identity/drivers/index.js +18 -0
- package/src/plugins/identity/drivers/password-driver.js +122 -0
- package/src/plugins/identity/email-service.js +17 -2
- package/src/plugins/identity/index.js +413 -69
- package/src/plugins/identity/oauth2-server.js +413 -30
- package/src/plugins/identity/oidc-discovery.js +16 -8
- package/src/plugins/identity/rsa-keys.js +115 -35
- package/src/plugins/identity/server.js +166 -45
- package/src/plugins/identity/session-manager.js +53 -7
- package/src/plugins/identity/ui/pages/mfa-verification.js +17 -15
- package/src/plugins/identity/ui/routes.js +363 -255
- package/src/plugins/importer/index.js +153 -20
- package/src/plugins/index.js +9 -2
- package/src/plugins/kubernetes-inventory/index.js +6 -0
- package/src/plugins/kubernetes-inventory/k8s-driver.js +867 -0
- package/src/plugins/kubernetes-inventory/resource-types.js +274 -0
- package/src/plugins/kubernetes-inventory.plugin.js +980 -0
- package/src/plugins/metrics.plugin.js +64 -16
- package/src/plugins/ml/base-model.class.js +25 -15
- package/src/plugins/ml/regression-model.class.js +1 -1
- package/src/plugins/ml.errors.js +57 -25
- package/src/plugins/ml.plugin.js +28 -4
- package/src/plugins/namespace.js +210 -0
- package/src/plugins/plugin.class.js +180 -8
- package/src/plugins/puppeteer/console-monitor.js +729 -0
- package/src/plugins/puppeteer/cookie-manager.js +492 -0
- package/src/plugins/puppeteer/network-monitor.js +816 -0
- package/src/plugins/puppeteer/performance-manager.js +746 -0
- package/src/plugins/puppeteer/proxy-manager.js +478 -0
- package/src/plugins/puppeteer/stealth-manager.js +556 -0
- package/src/plugins/puppeteer.errors.js +81 -0
- package/src/plugins/puppeteer.plugin.js +1327 -0
- package/src/plugins/queue-consumer.plugin.js +69 -14
- package/src/plugins/recon/behaviors/uptime-behavior.js +691 -0
- package/src/plugins/recon/concerns/command-runner.js +148 -0
- package/src/plugins/recon/concerns/diff-detector.js +372 -0
- package/src/plugins/recon/concerns/fingerprint-builder.js +307 -0
- package/src/plugins/recon/concerns/process-manager.js +338 -0
- package/src/plugins/recon/concerns/report-generator.js +478 -0
- package/src/plugins/recon/concerns/security-analyzer.js +571 -0
- package/src/plugins/recon/concerns/target-normalizer.js +68 -0
- package/src/plugins/recon/config/defaults.js +321 -0
- package/src/plugins/recon/config/resources.js +370 -0
- package/src/plugins/recon/index.js +778 -0
- package/src/plugins/recon/managers/dependency-manager.js +174 -0
- package/src/plugins/recon/managers/scheduler-manager.js +179 -0
- package/src/plugins/recon/managers/storage-manager.js +745 -0
- package/src/plugins/recon/managers/target-manager.js +274 -0
- package/src/plugins/recon/stages/asn-stage.js +314 -0
- package/src/plugins/recon/stages/certificate-stage.js +84 -0
- package/src/plugins/recon/stages/dns-stage.js +107 -0
- package/src/plugins/recon/stages/dnsdumpster-stage.js +362 -0
- package/src/plugins/recon/stages/fingerprint-stage.js +71 -0
- package/src/plugins/recon/stages/google-dorks-stage.js +440 -0
- package/src/plugins/recon/stages/http-stage.js +89 -0
- package/src/plugins/recon/stages/latency-stage.js +148 -0
- package/src/plugins/recon/stages/massdns-stage.js +302 -0
- package/src/plugins/recon/stages/osint-stage.js +1373 -0
- package/src/plugins/recon/stages/ports-stage.js +169 -0
- package/src/plugins/recon/stages/screenshot-stage.js +94 -0
- package/src/plugins/recon/stages/secrets-stage.js +514 -0
- package/src/plugins/recon/stages/subdomains-stage.js +295 -0
- package/src/plugins/recon/stages/tls-audit-stage.js +78 -0
- package/src/plugins/recon/stages/vulnerability-stage.js +78 -0
- package/src/plugins/recon/stages/web-discovery-stage.js +113 -0
- package/src/plugins/recon/stages/whois-stage.js +349 -0
- package/src/plugins/recon.plugin.js +75 -0
- package/src/plugins/recon.plugin.js.backup +2635 -0
- package/src/plugins/relation.errors.js +87 -14
- package/src/plugins/replicator.plugin.js +514 -137
- package/src/plugins/replicators/base-replicator.class.js +89 -1
- package/src/plugins/replicators/bigquery-replicator.class.js +66 -22
- package/src/plugins/replicators/dynamodb-replicator.class.js +22 -15
- package/src/plugins/replicators/mongodb-replicator.class.js +22 -15
- package/src/plugins/replicators/mysql-replicator.class.js +52 -17
- package/src/plugins/replicators/planetscale-replicator.class.js +30 -4
- package/src/plugins/replicators/postgres-replicator.class.js +62 -27
- package/src/plugins/replicators/s3db-replicator.class.js +25 -18
- package/src/plugins/replicators/schema-sync.helper.js +3 -3
- package/src/plugins/replicators/sqs-replicator.class.js +8 -2
- package/src/plugins/replicators/turso-replicator.class.js +23 -3
- package/src/plugins/replicators/webhook-replicator.class.js +42 -4
- package/src/plugins/s3-queue.plugin.js +464 -65
- package/src/plugins/scheduler.plugin.js +20 -6
- package/src/plugins/state-machine.plugin.js +40 -9
- package/src/plugins/tfstate/README.md +126 -126
- package/src/plugins/tfstate/base-driver.js +28 -4
- package/src/plugins/tfstate/errors.js +65 -10
- package/src/plugins/tfstate/filesystem-driver.js +52 -8
- package/src/plugins/tfstate/index.js +163 -90
- package/src/plugins/tfstate/s3-driver.js +64 -6
- package/src/plugins/ttl.plugin.js +72 -17
- package/src/plugins/vector/distances.js +18 -12
- package/src/plugins/vector/kmeans.js +26 -4
- package/src/resource.class.js +115 -19
- package/src/testing/factory.class.js +20 -3
- package/src/testing/seeder.class.js +7 -1
- package/src/clients/memory-client.md +0 -917
- 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
|
|
24
|
-
this.metadataLimit = config.metadataLimit
|
|
25
|
-
this.maxObjectSize = config.maxObjectSize
|
|
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
|
|
28
|
-
this.verbose = config.verbose
|
|
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 =
|
|
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
|
|
64
|
-
|
|
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
|
|
72
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
478
|
+
let filteredKeys = prefixFilter
|
|
479
|
+
? sortedKeys.filter(key => key.startsWith(prefixFilter))
|
|
480
|
+
: sortedKeys;
|
|
299
481
|
|
|
300
|
-
|
|
301
|
-
let startIndex = 0;
|
|
482
|
+
let startAfterKey = null;
|
|
302
483
|
if (continuationToken) {
|
|
303
|
-
|
|
484
|
+
startAfterKey = this._decodeContinuationToken(continuationToken);
|
|
485
|
+
} else if (startAfter) {
|
|
486
|
+
startAfterKey = startAfter;
|
|
304
487
|
}
|
|
305
488
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
|
|
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(
|
|
347
|
-
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|