arcway 0.4.5 → 0.4.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -46,6 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@aws-sdk/client-s3": "^3.987.0",
49
+ "@aws-sdk/s3-request-presigner": "3.990.0",
49
50
  "@babel/core": "^7.29.0",
50
51
  "@base-ui/react": "^1.2.0",
51
52
  "@modelcontextprotocol/sdk": "^1.26.0",
@@ -65,6 +65,35 @@ class LocalFileDriver {
65
65
  return false;
66
66
  }
67
67
  }
68
+ async head(namespace, filePath) {
69
+ const resolved = this.resolvePath(namespace, filePath);
70
+ try {
71
+ const stat = await fs.stat(resolved);
72
+ if (!stat.isFile()) return null;
73
+ return {
74
+ bytes: stat.size,
75
+ contentType: null,
76
+ checksum: null,
77
+ etag: null,
78
+ modifiedAt: stat.mtime,
79
+ };
80
+ } catch (err) {
81
+ if (err.code === 'ENOENT') return null;
82
+ throw err;
83
+ }
84
+ }
85
+ async authorizeUpload() {
86
+ throw new Error('Direct uploads require the S3 file driver');
87
+ }
88
+ async authorizeMultipartUpload() {
89
+ throw new Error('Direct uploads require the S3 file driver');
90
+ }
91
+ async completeMultipartUpload() {
92
+ throw new Error('Direct uploads require the S3 file driver');
93
+ }
94
+ async abortMultipartUpload() {
95
+ throw new Error('Direct uploads require the S3 file driver');
96
+ }
68
97
  async walkDir(dir) {
69
98
  const results = [];
70
99
  const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -5,7 +5,12 @@ import {
5
5
  DeleteObjectCommand,
6
6
  ListObjectsV2Command,
7
7
  HeadObjectCommand,
8
+ CreateMultipartUploadCommand,
9
+ UploadPartCommand,
10
+ CompleteMultipartUploadCommand,
11
+ AbortMultipartUploadCommand,
8
12
  } from '@aws-sdk/client-s3';
13
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
9
14
  class S3FileDriver {
10
15
  client;
11
16
  bucket;
@@ -15,6 +20,7 @@ class S3FileDriver {
15
20
  region: config.region ?? 'us-east-1',
16
21
  ...(config.endpoint ? { endpoint: config.endpoint } : {}),
17
22
  ...(config.forcePathStyle ? { forcePathStyle: true } : {}),
23
+ ...(config.credentials ? { credentials: config.credentials } : {}),
18
24
  });
19
25
  }
20
26
  async init() {}
@@ -109,5 +115,119 @@ class S3FileDriver {
109
115
  throw err;
110
116
  }
111
117
  }
118
+ async head(namespace, filePath) {
119
+ try {
120
+ const response = await this.client.send(
121
+ new HeadObjectCommand({
122
+ Bucket: this.bucket,
123
+ Key: this.key(namespace, filePath),
124
+ ChecksumMode: 'ENABLED',
125
+ }),
126
+ );
127
+ return {
128
+ bytes: response.ContentLength,
129
+ contentType: response.ContentType ?? null,
130
+ checksum: response.ChecksumSHA256 ?? null,
131
+ etag: response.ETag ?? null,
132
+ modifiedAt: response.LastModified ?? null,
133
+ };
134
+ } catch (err) {
135
+ if (
136
+ err.name === 'NotFound' ||
137
+ err.name === 'NoSuchKey' ||
138
+ err.$metadata?.httpStatusCode === 404
139
+ ) {
140
+ return null;
141
+ }
142
+ throw err;
143
+ }
144
+ }
145
+ async authorizeUpload(namespace, filePath, options) {
146
+ const expiresIn = options.expiresIn ?? 900;
147
+ const headers = {
148
+ 'Content-Type': options.contentType,
149
+ 'Content-Length': String(options.bytes),
150
+ ...(options.checksum ? { 'x-amz-checksum-sha256': options.checksum } : {}),
151
+ };
152
+ const command = new PutObjectCommand({
153
+ Bucket: this.bucket,
154
+ Key: this.key(namespace, filePath),
155
+ ContentType: options.contentType,
156
+ ContentLength: options.bytes,
157
+ ...(options.checksum ? { ChecksumSHA256: options.checksum } : {}),
158
+ });
159
+ return {
160
+ method: 'PUT',
161
+ url: await getSignedUrl(this.client, command, { expiresIn }),
162
+ headers,
163
+ expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
164
+ };
165
+ }
166
+ async authorizeMultipartUpload(namespace, filePath, options) {
167
+ const key = this.key(namespace, filePath);
168
+ const expiresIn = options.expiresIn ?? 900;
169
+ const created = await this.client.send(
170
+ new CreateMultipartUploadCommand({
171
+ Bucket: this.bucket,
172
+ Key: key,
173
+ ContentType: options.contentType,
174
+ }),
175
+ );
176
+ if (!created.UploadId) throw new Error('Storage provider did not return an upload ID');
177
+ try {
178
+ const parts = await Promise.all(
179
+ options.parts.map(async (part) => ({
180
+ partNumber: part.partNumber,
181
+ bytes: part.bytes,
182
+ method: 'PUT',
183
+ url: await getSignedUrl(
184
+ this.client,
185
+ new UploadPartCommand({
186
+ Bucket: this.bucket,
187
+ Key: key,
188
+ UploadId: created.UploadId,
189
+ PartNumber: part.partNumber,
190
+ ContentLength: part.bytes,
191
+ }),
192
+ { expiresIn },
193
+ ),
194
+ headers: { 'Content-Length': String(part.bytes) },
195
+ })),
196
+ );
197
+ return {
198
+ uploadId: created.UploadId,
199
+ parts,
200
+ expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
201
+ };
202
+ } catch (error) {
203
+ await this.abortMultipartUpload(namespace, filePath, created.UploadId);
204
+ throw error;
205
+ }
206
+ }
207
+ async completeMultipartUpload(namespace, filePath, uploadId, parts) {
208
+ await this.client.send(
209
+ new CompleteMultipartUploadCommand({
210
+ Bucket: this.bucket,
211
+ Key: this.key(namespace, filePath),
212
+ UploadId: uploadId,
213
+ MultipartUpload: {
214
+ Parts: parts.map((part) => ({ ETag: part.etag, PartNumber: part.partNumber })),
215
+ },
216
+ }),
217
+ );
218
+ }
219
+ async abortMultipartUpload(namespace, filePath, uploadId) {
220
+ try {
221
+ await this.client.send(
222
+ new AbortMultipartUploadCommand({
223
+ Bucket: this.bucket,
224
+ Key: this.key(namespace, filePath),
225
+ UploadId: uploadId,
226
+ }),
227
+ );
228
+ } catch (err) {
229
+ if (err.name !== 'NoSuchUpload' && err.$metadata?.httpStatusCode !== 404) throw err;
230
+ }
231
+ }
112
232
  }
113
233
  export default S3FileDriver;
@@ -44,6 +44,49 @@ class Files {
44
44
  return this._driver.exists(this._namespace, filePath);
45
45
  }
46
46
 
47
+ async head(filePath) {
48
+ return this._driver.head(this._namespace, filePath);
49
+ }
50
+
51
+ async authorizeUpload(filePath, options) {
52
+ validateUploadOptions(options);
53
+ return this._driver.authorizeUpload(this._namespace, filePath, options);
54
+ }
55
+
56
+ async authorizeMultipartUpload(filePath, options) {
57
+ validateUploadOptions(options);
58
+ if (options.checksum !== undefined) {
59
+ throw new Error('Whole-file checksums are not supported for multipart authorization');
60
+ }
61
+ if (!Array.isArray(options.parts) || options.parts.length < 2 || options.parts.length > 10000) {
62
+ throw new Error('Multipart uploads require between 2 and 10000 parts');
63
+ }
64
+ options.parts.forEach((part, index) => {
65
+ if (part.partNumber !== index + 1 || !Number.isSafeInteger(part.bytes) || part.bytes < 1) {
66
+ throw new Error('Multipart upload parts must be sequential and have a positive byte size');
67
+ }
68
+ });
69
+ if (options.parts.reduce((total, part) => total + part.bytes, 0) !== options.bytes) {
70
+ throw new Error('Multipart upload part sizes must equal the total upload byte size');
71
+ }
72
+ return this._driver.authorizeMultipartUpload(this._namespace, filePath, options);
73
+ }
74
+
75
+ async completeUpload(filePath, expected) {
76
+ return verifyMetadata(await this.head(filePath), expected);
77
+ }
78
+
79
+ async completeMultipartUpload(filePath, uploadId, parts, expected) {
80
+ validateMultipartCompletion(uploadId, parts);
81
+ await this._driver.completeMultipartUpload(this._namespace, filePath, uploadId, parts);
82
+ return this.completeUpload(filePath, expected);
83
+ }
84
+
85
+ async abortMultipartUpload(filePath, uploadId) {
86
+ if (typeof uploadId !== 'string' || !uploadId) throw new Error('Upload ID is required');
87
+ await this._driver.abortMultipartUpload(this._namespace, filePath, uploadId);
88
+ }
89
+
47
90
  /** Return a namespaced child that shares the same driver. */
48
91
  withNamespace(namespace) {
49
92
  const child = Object.create(Files.prototype);
@@ -54,4 +97,60 @@ class Files {
54
97
  }
55
98
  }
56
99
 
100
+ function validateUploadOptions(options) {
101
+ if (!options || typeof options !== 'object') throw new Error('Upload options are required');
102
+ if (
103
+ typeof options.contentType !== 'string' ||
104
+ !options.contentType ||
105
+ /[\r\n]/.test(options.contentType)
106
+ ) {
107
+ throw new Error('A valid upload content type is required');
108
+ }
109
+ if (!Number.isSafeInteger(options.bytes) || options.bytes < 1) {
110
+ throw new Error('Upload byte size must be a positive safe integer');
111
+ }
112
+ if (
113
+ options.expiresIn !== undefined &&
114
+ (!Number.isSafeInteger(options.expiresIn) ||
115
+ options.expiresIn < 1 ||
116
+ options.expiresIn > 604800)
117
+ ) {
118
+ throw new Error('Upload expiry must be between 1 and 604800 seconds');
119
+ }
120
+ if (options.checksum !== undefined && !/^[A-Za-z0-9+/]{43}=$/.test(options.checksum)) {
121
+ throw new Error('Upload checksum must be a base64 SHA-256 digest');
122
+ }
123
+ }
124
+
125
+ function validateMultipartCompletion(uploadId, parts) {
126
+ if (typeof uploadId !== 'string' || !uploadId) throw new Error('Upload ID is required');
127
+ if (!Array.isArray(parts) || parts.length < 2 || parts.length > 10000) {
128
+ throw new Error('Multipart completion requires between 2 and 10000 parts');
129
+ }
130
+ parts.forEach((part, index) => {
131
+ if (
132
+ part.partNumber !== index + 1 ||
133
+ typeof part.etag !== 'string' ||
134
+ !part.etag ||
135
+ /[\r\n]/.test(part.etag)
136
+ ) {
137
+ throw new Error('Multipart completion parts must be sequential and include an ETag');
138
+ }
139
+ });
140
+ }
141
+
142
+ function verifyMetadata(metadata, expected = {}) {
143
+ if (!metadata) throw new Error('Uploaded object not found');
144
+ if (expected.bytes !== undefined && metadata.bytes !== expected.bytes) {
145
+ throw new Error('Uploaded object size does not match');
146
+ }
147
+ if (expected.contentType !== undefined && metadata.contentType !== expected.contentType) {
148
+ throw new Error('Uploaded object content type does not match');
149
+ }
150
+ if (expected.checksum !== undefined && metadata.checksum !== expected.checksum) {
151
+ throw new Error('Uploaded object checksum does not match');
152
+ }
153
+ return metadata;
154
+ }
155
+
57
156
  export default Files;
@@ -1,13 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { customAlphabet, nanoid } from 'nanoid';
2
+ import { customAlphabet } from 'nanoid';
3
3
 
4
+ const ALPHANUMERIC_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
4
5
  const LOWERCASE_BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
6
+ const alphanumericNanoId = customAlphabet(ALPHANUMERIC_ALPHABET);
5
7
  const lowercaseBase32 = customAlphabet(LOWERCASE_BASE32_ALPHABET);
6
8
  function generateUUID() {
7
9
  return randomUUID();
8
10
  }
9
11
  function generateNanoId(size) {
10
- return nanoid(size);
12
+ return alphanumericNanoId(size);
11
13
  }
12
14
  function generateBase32Id(size = 16) {
13
15
  return lowercaseBase32(size);