@superfunctions/storage-azure 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # @superfunctions/storage-azure
2
+
3
+ Azure Blob Storage adapter for `@superfunctions/storage`.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @superfunctions/storage-azure
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { createAzureStorageAdapter } from '@superfunctions/storage-azure';
15
+
16
+ const connectionStringAdapter = createAzureStorageAdapter({
17
+ containerName: 'my-container',
18
+ connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
19
+ });
20
+
21
+ // Or use account name and key
22
+ const accountKeyAdapter = createAzureStorageAdapter({
23
+ containerName: 'my-container',
24
+ accountName: 'myaccount',
25
+ accountKey: process.env.AZURE_STORAGE_ACCOUNT_KEY,
26
+ });
27
+
28
+ // Or use SAS token
29
+ const sasTokenAdapter = createAzureStorageAdapter({
30
+ containerName: 'my-container',
31
+ accountName: 'myaccount',
32
+ sasToken: process.env.AZURE_STORAGE_SAS_TOKEN,
33
+ });
34
+ ```
35
+
36
+ ## Features
37
+
38
+ - ✅ Signed upload URLs (SAS tokens)
39
+ - ✅ Signed download URLs (SAS tokens)
40
+ - ✅ Multipart uploads (Azure block blobs)
41
+ - ✅ Proxy streaming upload
42
+ - ✅ Proxy streaming download
43
+
44
+ ## Azure Block Blob Semantics
45
+
46
+ Azure Blob Storage uses "block blobs" with "block IDs" instead of S3-style multipart uploads. This adapter maps:
47
+
48
+ - **uploadId**: Azure commitment placeholder (client-generated)
49
+ - **partNumber**: Mapped to block ID (base64-encoded)
50
+ - **etag**: Block ID returned as the ETag for consistency
51
+
52
+ When completing a multipart upload, the adapter commits all blocks to finalize the blob.
53
+
54
+ ## License
55
+
56
+ MIT
@@ -0,0 +1,10 @@
1
+ import type { StorageAdapter } from '@superfunctions/storage';
2
+ export interface AzureStorageConfig {
3
+ containerName: string;
4
+ connectionString?: string;
5
+ accountName?: string;
6
+ accountKey?: string;
7
+ sasToken?: string;
8
+ endpoint?: string;
9
+ }
10
+ export declare function createAzureStorageAdapter(config: AzureStorageConfig): StorageAdapter;
@@ -0,0 +1,306 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { PassThrough } from 'node:stream';
3
+ import { BlobServiceClient, StorageSharedKeyCredential, BlobSASPermissions, generateBlobSASQueryParameters, SASProtocol, } from '@azure/storage-blob';
4
+ import { assertMultipartParts, createNotFoundError, isNotFoundError, } from '@superfunctions/storage/internal/errors';
5
+ function toAzureUploadError(error) {
6
+ return error instanceof Error ? error : new Error(String(error));
7
+ }
8
+ function normalizeSasToken(sasToken) {
9
+ return sasToken.replace(/^\?+/, '');
10
+ }
11
+ function encodeUploadId(key) {
12
+ return Buffer.from(JSON.stringify({
13
+ key,
14
+ sessionId: randomUUID(),
15
+ }), 'utf8').toString('base64');
16
+ }
17
+ function decodeUploadId(uploadId) {
18
+ const decoded = Buffer.from(uploadId, 'base64').toString('utf8');
19
+ try {
20
+ const parsed = JSON.parse(decoded);
21
+ if (typeof parsed.key === 'string' &&
22
+ parsed.key.length > 0 &&
23
+ typeof parsed.sessionId === 'string' &&
24
+ parsed.sessionId.length > 0) {
25
+ return {
26
+ key: parsed.key,
27
+ sessionId: parsed.sessionId,
28
+ };
29
+ }
30
+ }
31
+ catch {
32
+ // Fall through to support legacy uploadIds that were just base64(key).
33
+ }
34
+ if (decoded.length > 0) {
35
+ return {
36
+ key: decoded,
37
+ sessionId: uploadId,
38
+ };
39
+ }
40
+ throw new Error('Invalid Azure multipart uploadId');
41
+ }
42
+ function createContainerClient(config) {
43
+ const { containerName, connectionString, accountName, accountKey, sasToken, endpoint } = config;
44
+ if (connectionString) {
45
+ const blobServiceClient = BlobServiceClient.fromConnectionString(connectionString);
46
+ const containerClient = blobServiceClient.getContainerClient(containerName);
47
+ // Extract account name and credential from connection string for SAS generation
48
+ const accountNameMatch = connectionString.match(/AccountName=([^;]+)/);
49
+ const accountKeyMatch = connectionString.match(/AccountKey=([^;]+)/);
50
+ const extractedAccountName = accountNameMatch?.[1];
51
+ const extractedAccountKey = accountKeyMatch?.[1];
52
+ const credential = extractedAccountName && extractedAccountKey
53
+ ? new StorageSharedKeyCredential(extractedAccountName, extractedAccountKey)
54
+ : undefined;
55
+ return { containerClient, credential, accountName: extractedAccountName };
56
+ }
57
+ if (accountName && accountKey) {
58
+ const credential = new StorageSharedKeyCredential(accountName, accountKey);
59
+ const blobServiceClient = new BlobServiceClient(endpoint || `https://${accountName}.blob.core.windows.net`, credential);
60
+ const containerClient = blobServiceClient.getContainerClient(containerName);
61
+ return { containerClient, credential, accountName };
62
+ }
63
+ if (accountName && sasToken) {
64
+ const blobServiceClient = new BlobServiceClient(`${endpoint || `https://${accountName}.blob.core.windows.net`}?${normalizeSasToken(sasToken)}`);
65
+ const containerClient = blobServiceClient.getContainerClient(containerName);
66
+ return { containerClient, accountName };
67
+ }
68
+ throw new Error('Azure storage config requires either connectionString or (accountName + accountKey) or (accountName + sasToken)');
69
+ }
70
+ function generateBlockId(uploadId, partNumber) {
71
+ return createHash('sha256')
72
+ .update(`${uploadId}:${String(partNumber).padStart(6, '0')}`, 'utf8')
73
+ .digest('base64');
74
+ }
75
+ export function createAzureStorageAdapter(config) {
76
+ const { containerClient, credential, accountName } = createContainerClient(config);
77
+ const supportsSignedUrls = Boolean(credential && accountName);
78
+ const capabilities = {
79
+ signedUploadUrls: supportsSignedUrls,
80
+ signedDownloadUrls: supportsSignedUrls,
81
+ multipart: supportsSignedUrls,
82
+ proxyStreamingUpload: true,
83
+ proxyStreamingDownload: true,
84
+ };
85
+ // Stateless adapter: no activeUploads map
86
+ return {
87
+ name: 'azure',
88
+ capabilities,
89
+ async statObject(input) {
90
+ try {
91
+ const blobClient = containerClient.getBlobClient(input.key);
92
+ const properties = await blobClient.getProperties();
93
+ return {
94
+ key: input.key,
95
+ size: properties.contentLength ?? 0,
96
+ contentType: properties.contentType,
97
+ etag: properties.etag,
98
+ lastModifiedAt: properties.lastModified?.toISOString(),
99
+ };
100
+ }
101
+ catch (err) {
102
+ if (isNotFoundError(err, { statusCode: 404, stringCodes: ['BlobNotFound'] })) {
103
+ throw createNotFoundError();
104
+ }
105
+ throw err;
106
+ }
107
+ },
108
+ async deleteObject(input) {
109
+ const blobClient = containerClient.getBlobClient(input.key);
110
+ await blobClient.deleteIfExists();
111
+ },
112
+ async signUploadUrl(input) {
113
+ if (!credential || !accountName) {
114
+ throw new Error('SAS URL generation requires accountName and credential (accountKey)');
115
+ }
116
+ const blobClient = containerClient.getBlobClient(input.key);
117
+ const blockBlobClient = blobClient.getBlockBlobClient();
118
+ const startsOn = new Date();
119
+ const expiresOn = new Date(startsOn.getTime() + input.expiresInSeconds * 1000);
120
+ const permissions = BlobSASPermissions.parse('w'); // write
121
+ const sasToken = generateBlobSASQueryParameters({
122
+ containerName: config.containerName,
123
+ blobName: input.key,
124
+ permissions,
125
+ startsOn,
126
+ expiresOn,
127
+ protocol: SASProtocol.Https,
128
+ contentType: input.constraints?.contentType,
129
+ }, credential).toString();
130
+ const url = `${blockBlobClient.url}?${sasToken}`;
131
+ const headers = {
132
+ 'x-ms-blob-type': 'BlockBlob',
133
+ };
134
+ if (input.constraints?.contentType) {
135
+ headers['x-ms-blob-content-type'] = input.constraints.contentType;
136
+ }
137
+ return { url, headers: Object.keys(headers).length > 0 ? headers : undefined };
138
+ },
139
+ async signDownloadUrl(input) {
140
+ if (!credential || !accountName) {
141
+ throw new Error('SAS URL generation requires accountName and credential (accountKey)');
142
+ }
143
+ const blobClient = containerClient.getBlobClient(input.key);
144
+ const startsOn = new Date();
145
+ const expiresOn = new Date(startsOn.getTime() + input.expiresInSeconds * 1000);
146
+ const permissions = BlobSASPermissions.parse('r'); // read
147
+ const sasToken = generateBlobSASQueryParameters({
148
+ containerName: config.containerName,
149
+ blobName: input.key,
150
+ permissions,
151
+ startsOn,
152
+ expiresOn,
153
+ protocol: SASProtocol.Https,
154
+ contentType: input.constraints?.responseContentType,
155
+ }, credential).toString();
156
+ const url = `${blobClient.url}?${sasToken}`;
157
+ return { url };
158
+ },
159
+ async createMultipartUpload(input) {
160
+ const uploadId = encodeUploadId(input.key);
161
+ return { uploadId };
162
+ },
163
+ async signMultipartUploadPartUrl(input) {
164
+ if (!credential || !accountName) {
165
+ throw new Error('SAS URL generation requires accountName and credential (accountKey)');
166
+ }
167
+ // Verify uploadId matches key (stateless check)
168
+ const decodedUpload = decodeUploadId(input.uploadId);
169
+ if (decodedUpload.key !== input.key) {
170
+ throw new Error(`Invalid uploadId for key: ${input.key}`);
171
+ }
172
+ const blockBlobClient = containerClient.getBlockBlobClient(input.key);
173
+ const blockId = generateBlockId(input.uploadId, input.partNumber);
174
+ const startsOn = new Date();
175
+ const expiresOn = new Date(startsOn.getTime() + input.expiresInSeconds * 1000);
176
+ const permissions = BlobSASPermissions.parse('w'); // write
177
+ const sasToken = generateBlobSASQueryParameters({
178
+ containerName: config.containerName,
179
+ blobName: input.key,
180
+ permissions,
181
+ startsOn,
182
+ expiresOn,
183
+ protocol: SASProtocol.Https,
184
+ }, credential).toString();
185
+ // The URL must include the blockid query parameter for PutBlock operation
186
+ const url = `${blockBlobClient.url}?comp=block&blockid=${encodeURIComponent(blockId)}&${sasToken}`;
187
+ return { url };
188
+ },
189
+ async completeMultipartUpload(input) {
190
+ assertMultipartParts(input.parts);
191
+ // Verify uploadId
192
+ const decodedUpload = decodeUploadId(input.uploadId);
193
+ if (decodedUpload.key !== input.key) {
194
+ throw new Error(`Invalid uploadId for key: ${input.key}`);
195
+ }
196
+ const blockBlobClient = containerClient.getBlockBlobClient(input.key);
197
+ // In Azure, the etag from each part is actually the blockId we generated
198
+ // Sort parts and commit blocks
199
+ const sortedParts = [...input.parts].sort((a, b) => a.partNumber - b.partNumber);
200
+ const blockList = sortedParts.map((part) => generateBlockId(input.uploadId, part.partNumber));
201
+ await blockBlobClient.commitBlockList(blockList);
202
+ },
203
+ async abortMultipartUpload(input) {
204
+ // Azure automatically garbage-collects uncommitted blocks after 7 days
205
+ // No explicit cleanup needed for stateless tracking
206
+ },
207
+ async openUploadStream(input) {
208
+ const blockBlobClient = containerClient.getBlockBlobClient(input.key);
209
+ const stream = new PassThrough();
210
+ let uploadError;
211
+ stream.on('error', (error) => {
212
+ uploadError = toAzureUploadError(error);
213
+ });
214
+ const uploadPromise = blockBlobClient.uploadStream(stream, 4 * 1024 * 1024, 5, {
215
+ blobHTTPHeaders: {
216
+ blobContentType: input.contentType,
217
+ },
218
+ });
219
+ void uploadPromise.catch((error) => {
220
+ uploadError = toAzureUploadError(error);
221
+ });
222
+ return new WritableStream({
223
+ async write(chunk) {
224
+ if (uploadError) {
225
+ throw uploadError;
226
+ }
227
+ const buf = chunk instanceof Uint8Array ? Buffer.from(chunk) : chunk;
228
+ if (stream.write(buf)) {
229
+ return;
230
+ }
231
+ await new Promise((resolve, reject) => {
232
+ const cleanup = () => {
233
+ stream.off('drain', onDrain);
234
+ stream.off('error', onError);
235
+ };
236
+ const onDrain = () => {
237
+ cleanup();
238
+ resolve();
239
+ };
240
+ const onError = (err) => {
241
+ cleanup();
242
+ reject(err);
243
+ };
244
+ stream.once('drain', onDrain);
245
+ stream.once('error', onError);
246
+ });
247
+ },
248
+ async close() {
249
+ if (uploadError) {
250
+ throw uploadError;
251
+ }
252
+ stream.end();
253
+ await uploadPromise;
254
+ },
255
+ abort(reason) {
256
+ stream.destroy(reason instanceof Error ? reason : new Error(String(reason)));
257
+ void uploadPromise.catch(() => undefined);
258
+ },
259
+ });
260
+ },
261
+ async openDownloadStream(input) {
262
+ const blobClient = containerClient.getBlobClient(input.key);
263
+ try {
264
+ const exists = await blobClient.exists();
265
+ if (!exists) {
266
+ const notFoundError = new Error('Object not found');
267
+ notFoundError.code = 'STORAGE_NOT_FOUND';
268
+ throw notFoundError;
269
+ }
270
+ }
271
+ catch (err) {
272
+ if (err instanceof Error && 'code' in err && err.code === 'STORAGE_NOT_FOUND') {
273
+ throw err;
274
+ }
275
+ throw err;
276
+ }
277
+ const downloadOptions = input.range
278
+ ? { offset: input.range.start, count: input.range.endInclusive - input.range.start + 1 }
279
+ : undefined;
280
+ const response = await blobClient.download(downloadOptions?.offset, downloadOptions?.count);
281
+ if (!response.readableStreamBody) {
282
+ throw new Error('No readable stream in download response');
283
+ }
284
+ const nodeStream = response.readableStreamBody;
285
+ return new ReadableStream({
286
+ start(controller) {
287
+ nodeStream.on('data', (chunk) => {
288
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
289
+ controller.enqueue(new Uint8Array(buffer));
290
+ });
291
+ nodeStream.on('end', () => {
292
+ controller.close();
293
+ });
294
+ nodeStream.on('error', (err) => {
295
+ controller.error(err);
296
+ });
297
+ },
298
+ cancel() {
299
+ if ('destroy' in nodeStream && typeof nodeStream.destroy === 'function') {
300
+ nodeStream.destroy();
301
+ }
302
+ },
303
+ });
304
+ },
305
+ };
306
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,248 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createAzureStorageAdapter } from './adapter.js';
3
+ import { runConformanceTests } from '@superfunctions/storage';
4
+ describe('storage-azure adapter', () => {
5
+ describe('conformance', () => {
6
+ it('should pass conformance tests (method existence)', async () => {
7
+ const adapter = createAzureStorageAdapter({
8
+ containerName: 'test-container',
9
+ accountName: 'testaccount',
10
+ accountKey: 'dGVzdGtleQ==',
11
+ });
12
+ const result = await runConformanceTests(adapter);
13
+ expect(result.ok).toBe(true);
14
+ });
15
+ it('should have correct capabilities', () => {
16
+ const adapter = createAzureStorageAdapter({
17
+ containerName: 'test-container',
18
+ accountName: 'testaccount',
19
+ accountKey: 'dGVzdGtleQ==',
20
+ });
21
+ expect(adapter.capabilities).toEqual({
22
+ signedUploadUrls: true,
23
+ signedDownloadUrls: true,
24
+ multipart: true,
25
+ proxyStreamingUpload: true,
26
+ proxyStreamingDownload: true,
27
+ });
28
+ });
29
+ });
30
+ describe('adapter creation', () => {
31
+ it('should create adapter with connection string', () => {
32
+ const adapter = createAzureStorageAdapter({
33
+ containerName: 'my-container',
34
+ connectionString: 'DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=dGVzdGtleQ==;EndpointSuffix=core.windows.net',
35
+ });
36
+ expect(adapter.name).toBe('azure');
37
+ });
38
+ it('should create adapter with account name and key', () => {
39
+ const adapter = createAzureStorageAdapter({
40
+ containerName: 'my-container',
41
+ accountName: 'testaccount',
42
+ accountKey: 'dGVzdGtleQ==',
43
+ });
44
+ expect(adapter.name).toBe('azure');
45
+ });
46
+ it('should create adapter with account name and SAS token', () => {
47
+ const adapter = createAzureStorageAdapter({
48
+ containerName: 'my-container',
49
+ accountName: 'testaccount',
50
+ sasToken: 'sv=2020-08-04&ss=b&srt=sco&sp=rwdlac&se=2025-01-01T00:00:00Z&st=2024-01-01T00:00:00Z&spr=https&sig=signature',
51
+ });
52
+ expect(adapter.name).toBe('azure');
53
+ });
54
+ it('should create adapter with custom endpoint', () => {
55
+ const adapter = createAzureStorageAdapter({
56
+ containerName: 'my-container',
57
+ accountName: 'testaccount',
58
+ accountKey: 'dGVzdGtleQ==',
59
+ endpoint: 'https://custom-endpoint.blob.core.windows.net',
60
+ });
61
+ expect(adapter.name).toBe('azure');
62
+ });
63
+ it('should throw error with incomplete config', () => {
64
+ expect(() => {
65
+ createAzureStorageAdapter({
66
+ containerName: 'my-container',
67
+ });
68
+ }).toThrow('Azure storage config requires either connectionString or (accountName + accountKey) or (accountName + sasToken)');
69
+ });
70
+ });
71
+ describe('multipart methods', () => {
72
+ it('should have multipart methods (Azure block blob semantics)', () => {
73
+ const adapter = createAzureStorageAdapter({
74
+ containerName: 'test-container',
75
+ accountName: 'testaccount',
76
+ accountKey: 'dGVzdGtleQ==',
77
+ });
78
+ expect(adapter.createMultipartUpload).toBeDefined();
79
+ expect(adapter.signMultipartUploadPartUrl).toBeDefined();
80
+ expect(adapter.completeMultipartUpload).toBeDefined();
81
+ expect(adapter.abortMultipartUpload).toBeDefined();
82
+ });
83
+ it('should generate stateless uploadId (base64 of key)', async () => {
84
+ const adapter = createAzureStorageAdapter({
85
+ containerName: 'test-container',
86
+ accountName: 'testaccount',
87
+ accountKey: 'dGVzdGtleQ==',
88
+ });
89
+ const key = 'test-file.bin';
90
+ const result = await adapter.createMultipartUpload({
91
+ key,
92
+ contentType: 'application/octet-stream',
93
+ });
94
+ const decoded = JSON.parse(Buffer.from(result.uploadId, 'base64').toString('utf8'));
95
+ expect(decoded.key).toBe(key);
96
+ expect(decoded.sessionId).toEqual(expect.any(String));
97
+ });
98
+ it('should generate unique uploadIds per multipart session for the same key', async () => {
99
+ const adapter = createAzureStorageAdapter({
100
+ containerName: 'test-container',
101
+ accountName: 'testaccount',
102
+ accountKey: 'dGVzdGtleQ==',
103
+ });
104
+ const first = await adapter.createMultipartUpload({ key: 'test-file.bin' });
105
+ const second = await adapter.createMultipartUpload({ key: 'test-file.bin' });
106
+ expect(first.uploadId).not.toBe(second.uploadId);
107
+ });
108
+ it('still accepts legacy base64(key) uploadIds even when the key is valid JSON', async () => {
109
+ const adapter = createAzureStorageAdapter({
110
+ containerName: 'test-container',
111
+ accountName: 'testaccount',
112
+ accountKey: 'dGVzdGtleQ==',
113
+ });
114
+ const key = 'null';
115
+ const legacyUploadId = Buffer.from(key).toString('base64');
116
+ const part = await adapter.signMultipartUploadPartUrl({
117
+ key,
118
+ uploadId: legacyUploadId,
119
+ partNumber: 1,
120
+ expiresInSeconds: 900,
121
+ });
122
+ expect(part.url).toContain('blockid=');
123
+ });
124
+ });
125
+ describe('signed URL methods', () => {
126
+ it('should have signed URL methods', () => {
127
+ const adapter = createAzureStorageAdapter({
128
+ containerName: 'test-container',
129
+ accountName: 'testaccount',
130
+ accountKey: 'dGVzdGtleQ==',
131
+ });
132
+ expect(adapter.signUploadUrl).toBeDefined();
133
+ expect(adapter.signDownloadUrl).toBeDefined();
134
+ });
135
+ it('should throw error when generating SAS without credentials', async () => {
136
+ const adapter = createAzureStorageAdapter({
137
+ containerName: 'test-container',
138
+ accountName: 'testaccount',
139
+ sasToken: 'existing-token',
140
+ });
141
+ await expect(async () => {
142
+ await adapter.signUploadUrl({
143
+ key: 'test.txt',
144
+ expiresInSeconds: 900,
145
+ });
146
+ }).rejects.toThrow('SAS URL generation requires accountName and credential (accountKey)');
147
+ });
148
+ it('returns the required blob type header for signed uploads', async () => {
149
+ const adapter = createAzureStorageAdapter({
150
+ containerName: 'test-container',
151
+ accountName: 'testaccount',
152
+ accountKey: 'dGVzdGtleQ==',
153
+ });
154
+ const result = await adapter.signUploadUrl({
155
+ key: 'test.txt',
156
+ expiresInSeconds: 900,
157
+ });
158
+ expect(result.headers).toMatchObject({
159
+ 'x-ms-blob-type': 'BlockBlob',
160
+ });
161
+ });
162
+ it('disables signed-url capabilities when only a SAS token is configured', () => {
163
+ const adapter = createAzureStorageAdapter({
164
+ containerName: 'test-container',
165
+ accountName: 'testaccount',
166
+ sasToken: '?existing-token',
167
+ });
168
+ expect(adapter.capabilities).toMatchObject({
169
+ signedUploadUrls: false,
170
+ signedDownloadUrls: false,
171
+ multipart: false,
172
+ });
173
+ });
174
+ });
175
+ describe('proxy streaming methods', () => {
176
+ it('should have proxy streaming methods', () => {
177
+ const adapter = createAzureStorageAdapter({
178
+ containerName: 'test-container',
179
+ accountName: 'testaccount',
180
+ accountKey: 'dGVzdGtleQ==',
181
+ });
182
+ expect(adapter.openUploadStream).toBeDefined();
183
+ expect(adapter.openDownloadStream).toBeDefined();
184
+ });
185
+ });
186
+ describe('block blob semantics', () => {
187
+ it('should map part numbers to block IDs', async () => {
188
+ const adapter = createAzureStorageAdapter({
189
+ containerName: 'test-container',
190
+ accountName: 'testaccount',
191
+ accountKey: 'dGVzdGtleQ==',
192
+ });
193
+ const key = 'test-multipart.bin';
194
+ const { uploadId } = await adapter.createMultipartUpload({
195
+ key,
196
+ });
197
+ // Sign part 1
198
+ const part1 = await adapter.signMultipartUploadPartUrl({
199
+ key,
200
+ uploadId,
201
+ partNumber: 1,
202
+ expiresInSeconds: 900,
203
+ });
204
+ // URL should contain blockid parameter
205
+ expect(part1.url).toContain('comp=block');
206
+ expect(part1.url).toContain('blockid=');
207
+ });
208
+ it('TV-STORAGE-AZURE-STATELESS-001: should allow cross-instance multipart operations', async () => {
209
+ const config = {
210
+ containerName: 'test-container',
211
+ accountName: 'testaccount',
212
+ accountKey: 'dGVzdGtleQ==',
213
+ };
214
+ const adapterA = createAzureStorageAdapter(config);
215
+ const adapterB = createAzureStorageAdapter(config);
216
+ const key = 'stateless-test.bin';
217
+ // Create with A
218
+ const { uploadId } = await adapterA.createMultipartUpload({
219
+ key,
220
+ contentType: 'application/octet-stream',
221
+ });
222
+ // Sign with B
223
+ const part = await adapterB.signMultipartUploadPartUrl({
224
+ key,
225
+ uploadId,
226
+ partNumber: 1,
227
+ expiresInSeconds: 900,
228
+ });
229
+ expect(part.url).toBeDefined();
230
+ expect(part.url).toContain('blockid=');
231
+ });
232
+ it('should reject invalid uploadId', async () => {
233
+ const adapter = createAzureStorageAdapter({
234
+ containerName: 'test-container',
235
+ accountName: 'testaccount',
236
+ accountKey: 'dGVzdGtleQ==',
237
+ });
238
+ const key = 'test-file.bin';
239
+ const badUploadId = Buffer.from('wrong-key').toString('base64');
240
+ await expect(adapter.signMultipartUploadPartUrl({
241
+ key,
242
+ uploadId: badUploadId,
243
+ partNumber: 1,
244
+ expiresInSeconds: 900,
245
+ })).rejects.toThrow(`Invalid uploadId for key: ${key}`);
246
+ });
247
+ });
248
+ });
@@ -0,0 +1 @@
1
+ export * from './adapter.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './adapter.js';
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@superfunctions/storage-azure",
3
+ "version": "0.1.0",
4
+ "description": "Azure Blob Storage adapter for @superfunctions/storage",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": ["dist"],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "test": "vitest",
18
+ "typecheck": "tsc --noEmit",
19
+ "clean": "rm -rf dist"
20
+ },
21
+ "keywords": ["storage", "azure", "azure-blob-storage", "superfunctions"],
22
+ "author": "21n",
23
+ "license": "MIT",
24
+ "bugs": { "url": "https://github.com/21nCo/super-functions/issues" },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/21nCo/super-functions.git",
28
+ "directory": "packages/storage-azure"
29
+ },
30
+ "publishConfig": { "access": "public" },
31
+ "dependencies": {
32
+ "@azure/storage-blob": "^12.18.0",
33
+ "@superfunctions/storage": "0.1.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.0.0",
37
+ "typescript": "^5.6.0",
38
+ "vitest": "^3.2.4"
39
+ }
40
+ }