@progalaxyelabs/stonescriptphp-files 2.0.0 → 3.0.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/package.json +1 -1
- package/src/authorization.js +124 -0
- package/src/azure-storage.js +76 -24
- package/src/index.js +22 -5
- package/src/routes/delete.js +10 -2
- package/src/routes/download.js +10 -2
- package/src/routes/list.js +7 -0
- package/src/routes/upload.js +9 -1
- package/HLD.md +0 -170
- package/project-info.yaml +0 -119
package/package.json
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional authorization middleware for stonescriptphp-files.
|
|
3
|
+
*
|
|
4
|
+
* When AUTHORIZATION_URL is configured, calls the platform API to check
|
|
5
|
+
* if the current user is allowed to perform the file operation.
|
|
6
|
+
* The API acts as an authorization oracle — it knows about business entities
|
|
7
|
+
* and role hierarchies. The files service stays generic.
|
|
8
|
+
*
|
|
9
|
+
* When AUTHORIZATION_URL is NOT set, this is a no-op pass-through.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create authorization middleware
|
|
14
|
+
* @param {string|null} authorizationUrl - URL to call for auth checks (e.g., "http://api:9100/files/authorize")
|
|
15
|
+
* @param {number} timeout - Request timeout in ms (default: 3000)
|
|
16
|
+
* @returns {Function} Express middleware
|
|
17
|
+
*/
|
|
18
|
+
export function createAuthorizationMiddleware(authorizationUrl, timeout = 3000) {
|
|
19
|
+
if (!authorizationUrl) {
|
|
20
|
+
// No authorization configured — pass through (backwards compat)
|
|
21
|
+
return (req, res, next) => {
|
|
22
|
+
req.fileScope = 'user';
|
|
23
|
+
next();
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log(`Authorization middleware enabled: ${authorizationUrl}`);
|
|
28
|
+
|
|
29
|
+
return async (req, res, next) => {
|
|
30
|
+
try {
|
|
31
|
+
// Determine action from HTTP method + path
|
|
32
|
+
let action;
|
|
33
|
+
if (req.method === 'POST' && req.path === '/upload') {
|
|
34
|
+
action = 'upload';
|
|
35
|
+
} else if (req.method === 'GET' && req.path.startsWith('/files/')) {
|
|
36
|
+
action = 'download';
|
|
37
|
+
} else if (req.method === 'DELETE' && req.path.startsWith('/files/')) {
|
|
38
|
+
action = 'delete';
|
|
39
|
+
} else {
|
|
40
|
+
// Not a file operation route (e.g., /health, /list) — pass through
|
|
41
|
+
req.fileScope = 'user';
|
|
42
|
+
return next();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Build authorization request body
|
|
46
|
+
const body = { action };
|
|
47
|
+
|
|
48
|
+
if ((action === 'download' || action === 'delete') && req.params.id) {
|
|
49
|
+
body.file_id = req.params.id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (action === 'upload') {
|
|
53
|
+
body.resource_type = req.body?.resource_type || null;
|
|
54
|
+
body.resource_id = req.body?.resource_id ? parseInt(req.body.resource_id) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Forward the user's JWT token
|
|
58
|
+
const authHeader = req.headers.authorization;
|
|
59
|
+
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
62
|
+
|
|
63
|
+
const response = await fetch(authorizationUrl, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
...(authHeader ? { 'Authorization': authHeader } : {})
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify(body),
|
|
70
|
+
signal: controller.signal
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
clearTimeout(timeoutId);
|
|
74
|
+
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
const errorRaw = await response.json().catch(() => ({}));
|
|
77
|
+
const errorBody = errorRaw.data || errorRaw;
|
|
78
|
+
|
|
79
|
+
if (response.status === 403) {
|
|
80
|
+
console.warn(`Authorization denied: user=${req.user?.id} action=${action} reason=${errorBody.reason || errorRaw.message || 'unknown'}`);
|
|
81
|
+
return res.status(403).json({
|
|
82
|
+
error: 'Forbidden',
|
|
83
|
+
message: errorBody.reason || errorRaw.message || 'Access denied'
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.error(`Authorization service error: status=${response.status} body=${JSON.stringify(errorBody)}`);
|
|
88
|
+
return res.status(503).json({
|
|
89
|
+
error: 'Service Unavailable',
|
|
90
|
+
message: 'Authorization service returned an error'
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const raw = await response.json();
|
|
95
|
+
// Unwrap StoneScriptPHP {status, message, data} wrapper if present
|
|
96
|
+
const authResult = raw.data || raw;
|
|
97
|
+
|
|
98
|
+
if (!authResult.allowed) {
|
|
99
|
+
console.warn(`Authorization denied: user=${req.user?.id} action=${action} reason=${authResult.reason || 'unknown'}`);
|
|
100
|
+
return res.status(403).json({
|
|
101
|
+
error: 'Forbidden',
|
|
102
|
+
message: authResult.reason || 'Access denied'
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Set scope on request for route handlers
|
|
107
|
+
req.fileScope = authResult.scope || 'user';
|
|
108
|
+
next();
|
|
109
|
+
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error.name === 'AbortError') {
|
|
112
|
+
console.error(`Authorization timeout: url=${authorizationUrl} timeout=${timeout}ms`);
|
|
113
|
+
} else {
|
|
114
|
+
console.error(`Authorization error: ${error.message}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Fail closed — deny access when authorization service is unavailable
|
|
118
|
+
return res.status(503).json({
|
|
119
|
+
error: 'Service Unavailable',
|
|
120
|
+
message: 'Authorization service unavailable'
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
package/src/azure-storage.js
CHANGED
|
@@ -7,20 +7,22 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
7
7
|
*/
|
|
8
8
|
export class AzureStorageClient {
|
|
9
9
|
constructor(connectionString, containerName = 'platform-files') {
|
|
10
|
-
if (!connectionString) {
|
|
11
|
-
throw new Error('Azure Storage connection string is required');
|
|
12
|
-
}
|
|
13
|
-
|
|
14
10
|
this.connectionString = connectionString;
|
|
15
11
|
this.containerName = containerName;
|
|
16
12
|
this.blobServiceClient = null;
|
|
17
13
|
this.containerClient = null;
|
|
14
|
+
this.isConfigured = !!connectionString;
|
|
18
15
|
}
|
|
19
16
|
|
|
20
17
|
/**
|
|
21
18
|
* Initialize Azure Blob Storage client
|
|
22
19
|
*/
|
|
23
20
|
async initialize() {
|
|
21
|
+
if (!this.isConfigured) {
|
|
22
|
+
console.warn('⚠️ AZURE_STORAGE_CONNECTION_STRING not set — file uploads will fail');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
24
26
|
try {
|
|
25
27
|
// Create BlobServiceClient
|
|
26
28
|
this.blobServiceClient = BlobServiceClient.fromConnectionString(this.connectionString);
|
|
@@ -28,10 +30,8 @@ export class AzureStorageClient {
|
|
|
28
30
|
// Get container client
|
|
29
31
|
this.containerClient = this.blobServiceClient.getContainerClient(this.containerName);
|
|
30
32
|
|
|
31
|
-
// Create container if it doesn't exist
|
|
32
|
-
await this.containerClient.createIfNotExists(
|
|
33
|
-
access: 'private' // CRITICAL: No public access
|
|
34
|
-
});
|
|
33
|
+
// Create container if it doesn't exist (no access property = private, no public access)
|
|
34
|
+
await this.containerClient.createIfNotExists();
|
|
35
35
|
|
|
36
36
|
console.log(`Azure Blob Storage initialized: container=${this.containerName}`);
|
|
37
37
|
} catch (error) {
|
|
@@ -47,17 +47,29 @@ export class AzureStorageClient {
|
|
|
47
47
|
* @param {Buffer} fileBuffer - File content
|
|
48
48
|
* @param {string} originalFilename - Original filename
|
|
49
49
|
* @param {string} contentType - MIME type
|
|
50
|
+
* @param {string} scope - Storage scope: 'user' (default) or 'tenant'
|
|
50
51
|
* @returns {Object} File metadata
|
|
51
52
|
*/
|
|
52
|
-
async uploadFile(tenantId, userId, fileBuffer, originalFilename, contentType) {
|
|
53
|
+
async uploadFile(tenantId, userId, fileBuffer, originalFilename, contentType, scope = 'user') {
|
|
54
|
+
if (!this.isConfigured) {
|
|
55
|
+
const error = new Error('Storage not configured');
|
|
56
|
+
error.statusCode = 503;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
|
|
53
60
|
if (!this.containerClient) {
|
|
54
61
|
throw new Error('Azure Storage not initialized');
|
|
55
62
|
}
|
|
56
63
|
|
|
57
|
-
// Generate unique blob name with
|
|
64
|
+
// Generate unique blob name with scope-based prefix
|
|
58
65
|
const fileId = uuidv4();
|
|
59
66
|
const extension = originalFilename.split('.').pop();
|
|
60
|
-
|
|
67
|
+
let prefix;
|
|
68
|
+
if (scope === 'tenant') {
|
|
69
|
+
prefix = tenantId ? `${tenantId}/shared/` : 'shared/';
|
|
70
|
+
} else {
|
|
71
|
+
prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
|
|
72
|
+
}
|
|
61
73
|
const blobName = `${prefix}${fileId}.${extension}`;
|
|
62
74
|
|
|
63
75
|
const blockBlobClient = this.containerClient.getBlockBlobClient(blobName);
|
|
@@ -73,7 +85,8 @@ export class AzureStorageClient {
|
|
|
73
85
|
original_filename: originalFilename,
|
|
74
86
|
content_type: contentType,
|
|
75
87
|
uploaded_at: new Date().toISOString(),
|
|
76
|
-
file_id: fileId
|
|
88
|
+
file_id: fileId,
|
|
89
|
+
scope: scope
|
|
77
90
|
}
|
|
78
91
|
});
|
|
79
92
|
|
|
@@ -94,15 +107,28 @@ export class AzureStorageClient {
|
|
|
94
107
|
* @param {string} fileId - File ID (UUID)
|
|
95
108
|
* @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
|
|
96
109
|
* @param {string} userId - User ID (for authorization)
|
|
110
|
+
* @param {string} scope - Storage scope: 'user' (default) or 'tenant'
|
|
97
111
|
* @returns {Object} { stream, metadata }
|
|
98
112
|
*/
|
|
99
|
-
async downloadFile(fileId, tenantId, userId) {
|
|
113
|
+
async downloadFile(fileId, tenantId, userId, scope = 'user') {
|
|
114
|
+
if (!this.isConfigured) {
|
|
115
|
+
const error = new Error('Storage not configured');
|
|
116
|
+
error.statusCode = 503;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
|
|
100
120
|
if (!this.containerClient) {
|
|
101
121
|
throw new Error('Azure Storage not initialized');
|
|
102
122
|
}
|
|
103
123
|
|
|
104
|
-
//
|
|
105
|
-
|
|
124
|
+
// Build prefix based on scope
|
|
125
|
+
let prefix;
|
|
126
|
+
if (scope === 'tenant') {
|
|
127
|
+
prefix = tenantId ? `${tenantId}/shared/` : 'shared/';
|
|
128
|
+
} else {
|
|
129
|
+
prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
|
|
130
|
+
}
|
|
131
|
+
|
|
106
132
|
const blobs = this.containerClient.listBlobsFlat({
|
|
107
133
|
prefix,
|
|
108
134
|
includeMetadata: true
|
|
@@ -112,8 +138,9 @@ export class AzureStorageClient {
|
|
|
112
138
|
if (blob.metadata && blob.metadata.file_id === fileId) {
|
|
113
139
|
const blockBlobClient = this.containerClient.getBlockBlobClient(blob.name);
|
|
114
140
|
|
|
115
|
-
// Verify ownership
|
|
116
|
-
|
|
141
|
+
// Verify ownership for user-scoped files only
|
|
142
|
+
// Tenant-scoped files are shared — authorization middleware already checked access
|
|
143
|
+
if (scope === 'user' && blob.metadata.user_id !== userId) {
|
|
117
144
|
throw new Error('Unauthorized: File belongs to different user');
|
|
118
145
|
}
|
|
119
146
|
|
|
@@ -138,15 +165,27 @@ export class AzureStorageClient {
|
|
|
138
165
|
* List user's files
|
|
139
166
|
* @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
|
|
140
167
|
* @param {string} userId - User ID
|
|
168
|
+
* @param {string} scope - Storage scope: 'user' (default) or 'tenant'
|
|
141
169
|
* @returns {Array} List of file metadata
|
|
142
170
|
*/
|
|
143
|
-
async listFiles(tenantId, userId) {
|
|
171
|
+
async listFiles(tenantId, userId, scope = 'user') {
|
|
172
|
+
if (!this.isConfigured) {
|
|
173
|
+
const error = new Error('Storage not configured');
|
|
174
|
+
error.statusCode = 503;
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
|
|
144
178
|
if (!this.containerClient) {
|
|
145
179
|
throw new Error('Azure Storage not initialized');
|
|
146
180
|
}
|
|
147
181
|
|
|
148
182
|
const files = [];
|
|
149
|
-
|
|
183
|
+
let prefix;
|
|
184
|
+
if (scope === 'tenant') {
|
|
185
|
+
prefix = tenantId ? `${tenantId}/shared/` : 'shared/';
|
|
186
|
+
} else {
|
|
187
|
+
prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
|
|
188
|
+
}
|
|
150
189
|
const blobs = this.containerClient.listBlobsFlat({
|
|
151
190
|
prefix,
|
|
152
191
|
includeMetadata: true
|
|
@@ -172,14 +211,27 @@ export class AzureStorageClient {
|
|
|
172
211
|
* @param {string} fileId - File ID (UUID)
|
|
173
212
|
* @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
|
|
174
213
|
* @param {string} userId - User ID (for authorization)
|
|
214
|
+
* @param {string} scope - Storage scope: 'user' (default) or 'tenant'
|
|
175
215
|
*/
|
|
176
|
-
async deleteFile(fileId, tenantId, userId) {
|
|
216
|
+
async deleteFile(fileId, tenantId, userId, scope = 'user') {
|
|
217
|
+
if (!this.isConfigured) {
|
|
218
|
+
const error = new Error('Storage not configured');
|
|
219
|
+
error.statusCode = 503;
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
|
|
177
223
|
if (!this.containerClient) {
|
|
178
224
|
throw new Error('Azure Storage not initialized');
|
|
179
225
|
}
|
|
180
226
|
|
|
181
|
-
// Find blob by file_id
|
|
182
|
-
|
|
227
|
+
// Find blob by file_id with scope-based prefix
|
|
228
|
+
let prefix;
|
|
229
|
+
if (scope === 'tenant') {
|
|
230
|
+
prefix = tenantId ? `${tenantId}/shared/` : 'shared/';
|
|
231
|
+
} else {
|
|
232
|
+
prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
|
|
233
|
+
}
|
|
234
|
+
|
|
183
235
|
const blobs = this.containerClient.listBlobsFlat({
|
|
184
236
|
prefix,
|
|
185
237
|
includeMetadata: true
|
|
@@ -187,8 +239,8 @@ export class AzureStorageClient {
|
|
|
187
239
|
|
|
188
240
|
for await (const blob of blobs) {
|
|
189
241
|
if (blob.metadata && blob.metadata.file_id === fileId) {
|
|
190
|
-
// Verify ownership
|
|
191
|
-
if (blob.metadata.user_id !== userId) {
|
|
242
|
+
// Verify ownership for user-scoped files only
|
|
243
|
+
if (scope === 'user' && blob.metadata.user_id !== userId) {
|
|
192
244
|
throw new Error('Unauthorized: File belongs to different user');
|
|
193
245
|
}
|
|
194
246
|
|
package/src/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import cors from 'cors';
|
|
|
3
3
|
import { AzureStorageClient } from './azure-storage.js';
|
|
4
4
|
import { createAuthMiddleware, createJwksAuthMiddleware } from './auth.js';
|
|
5
5
|
import { JwksClient } from './jwks-client.js';
|
|
6
|
+
import { createAuthorizationMiddleware } from './authorization.js';
|
|
6
7
|
import { createRateLimiters } from './rate-limit.js';
|
|
7
8
|
import { createUploadRouter } from './routes/upload.js';
|
|
8
9
|
import { createDownloadRouter } from './routes/download.js';
|
|
@@ -13,6 +14,7 @@ import { createHealthRouter } from './routes/health.js';
|
|
|
13
14
|
/**
|
|
14
15
|
* Resolve the authentication middleware based on configuration priority.
|
|
15
16
|
* Priority: authServers > jwksUrl > jwtPublicKey
|
|
17
|
+
* If no auth is configured, returns a pass-through middleware that logs a warning.
|
|
16
18
|
*
|
|
17
19
|
* @param {Object} config - Configuration options
|
|
18
20
|
* @returns {Function} Express middleware function for authentication
|
|
@@ -37,7 +39,14 @@ function resolveAuthMiddleware(config) {
|
|
|
37
39
|
return createAuthMiddleware(jwtPublicKey);
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
// No auth configured - return pass-through middleware with warning
|
|
43
|
+
console.warn('⚠️ No authentication configured — all endpoints will fail with 503');
|
|
44
|
+
return (req, res, next) => {
|
|
45
|
+
res.status(503).json({
|
|
46
|
+
error: 'Service Unavailable',
|
|
47
|
+
message: 'Authentication not configured'
|
|
48
|
+
});
|
|
49
|
+
};
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
/**
|
|
@@ -51,6 +60,8 @@ function resolveAuthMiddleware(config) {
|
|
|
51
60
|
* @param {string} config.jwksUrl - Single JWKS URL for key retrieval
|
|
52
61
|
* @param {number} config.jwksCacheTtl - JWKS cache TTL in seconds (default: 3600)
|
|
53
62
|
* @param {boolean} config.tenantScoped - Enable tenant-scoped file isolation (default: true)
|
|
63
|
+
* @param {string} config.authorizationUrl - URL for authorization checks (default: process.env.AUTHORIZATION_URL). When set, files service calls this URL before upload/download/delete.
|
|
64
|
+
* @param {number} config.authorizationTimeout - Authorization request timeout in ms (default: 3000)
|
|
54
65
|
* @param {number} config.maxFileSize - Max file size in bytes (default: 100MB)
|
|
55
66
|
* @param {string|string[]} config.corsOrigins - CORS allowed origins (default: '*')
|
|
56
67
|
* @param {number} config.rateLimitWindowMs - Rate limit window in ms (default: 60000)
|
|
@@ -72,6 +83,11 @@ export function createFilesServer(config = {}) {
|
|
|
72
83
|
// Resolve auth middleware based on config priority
|
|
73
84
|
const authenticate = resolveAuthMiddleware(config);
|
|
74
85
|
|
|
86
|
+
// Resolve authorization middleware (optional — no-op when URL not set)
|
|
87
|
+
const authorizationUrl = config.authorizationUrl || process.env.AUTHORIZATION_URL || null;
|
|
88
|
+
const authorizationTimeout = config.authorizationTimeout || parseInt(process.env.AUTHORIZATION_TIMEOUT) || 3000;
|
|
89
|
+
const authorize = createAuthorizationMiddleware(authorizationUrl, authorizationTimeout);
|
|
90
|
+
|
|
75
91
|
// Create rate limiters
|
|
76
92
|
const { uploadLimiter, downloadLimiter } = createRateLimiters(config);
|
|
77
93
|
|
|
@@ -91,12 +107,12 @@ export function createFilesServer(config = {}) {
|
|
|
91
107
|
next();
|
|
92
108
|
});
|
|
93
109
|
|
|
94
|
-
// Routes with rate limiters
|
|
110
|
+
// Routes with rate limiters and optional authorization
|
|
95
111
|
app.use(createHealthRouter());
|
|
96
|
-
app.use(authenticate, uploadLimiter, createUploadRouter(storage, maxFileSize));
|
|
97
|
-
app.use(authenticate, downloadLimiter, createDownloadRouter(storage));
|
|
112
|
+
app.use(authenticate, authorize, uploadLimiter, createUploadRouter(storage, maxFileSize));
|
|
113
|
+
app.use(authenticate, authorize, downloadLimiter, createDownloadRouter(storage));
|
|
98
114
|
app.use(authenticate, downloadLimiter, createListRouter(storage));
|
|
99
|
-
app.use(authenticate, downloadLimiter, createDeleteRouter(storage));
|
|
115
|
+
app.use(authenticate, authorize, downloadLimiter, createDeleteRouter(storage));
|
|
100
116
|
|
|
101
117
|
// 404 handler
|
|
102
118
|
app.use((req, res) => {
|
|
@@ -149,6 +165,7 @@ export function createFilesServer(config = {}) {
|
|
|
149
165
|
// Named exports for advanced/composable usage
|
|
150
166
|
export { AzureStorageClient } from './azure-storage.js';
|
|
151
167
|
export { createAuthMiddleware, createJwksAuthMiddleware } from './auth.js';
|
|
168
|
+
export { createAuthorizationMiddleware } from './authorization.js';
|
|
152
169
|
export { JwksClient } from './jwks-client.js';
|
|
153
170
|
export { createRateLimiters } from './rate-limit.js';
|
|
154
171
|
export { createUploadRouter } from './routes/upload.js';
|
package/src/routes/delete.js
CHANGED
|
@@ -19,6 +19,7 @@ export function createDeleteRouter(storage) {
|
|
|
19
19
|
const fileId = req.params.id;
|
|
20
20
|
const userId = req.user.id;
|
|
21
21
|
const tenantId = req.user.tenantId;
|
|
22
|
+
const scope = req.fileScope || 'user';
|
|
22
23
|
|
|
23
24
|
// Validate file ID format (UUID)
|
|
24
25
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -29,8 +30,8 @@ export function createDeleteRouter(storage) {
|
|
|
29
30
|
});
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
// Delete file from Azure (validates ownership)
|
|
33
|
-
await storage.deleteFile(fileId, tenantId, userId);
|
|
33
|
+
// Delete file from Azure (validates ownership for user-scoped files)
|
|
34
|
+
await storage.deleteFile(fileId, tenantId, userId, scope);
|
|
34
35
|
|
|
35
36
|
res.status(200).json({
|
|
36
37
|
success: true,
|
|
@@ -53,6 +54,13 @@ export function createDeleteRouter(storage) {
|
|
|
53
54
|
});
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
if (error.statusCode === 503) {
|
|
58
|
+
return res.status(503).json({
|
|
59
|
+
error: 'Service Unavailable',
|
|
60
|
+
message: error.message
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
res.status(500).json({
|
|
57
65
|
error: 'Internal Server Error',
|
|
58
66
|
message: 'Failed to delete file'
|
package/src/routes/download.js
CHANGED
|
@@ -19,6 +19,7 @@ export function createDownloadRouter(storage) {
|
|
|
19
19
|
const fileId = req.params.id;
|
|
20
20
|
const userId = req.user.id;
|
|
21
21
|
const tenantId = req.user.tenantId;
|
|
22
|
+
const scope = req.fileScope || 'user';
|
|
22
23
|
|
|
23
24
|
// Validate file ID format (UUID)
|
|
24
25
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -29,8 +30,8 @@ export function createDownloadRouter(storage) {
|
|
|
29
30
|
});
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
// Download file from Azure (validates ownership)
|
|
33
|
-
const { stream, metadata } = await storage.downloadFile(fileId, tenantId, userId);
|
|
33
|
+
// Download file from Azure (validates ownership for user-scoped files)
|
|
34
|
+
const { stream, metadata } = await storage.downloadFile(fileId, tenantId, userId, scope);
|
|
34
35
|
|
|
35
36
|
// Set response headers
|
|
36
37
|
res.setHeader('Content-Type', metadata.contentType);
|
|
@@ -67,6 +68,13 @@ export function createDownloadRouter(storage) {
|
|
|
67
68
|
});
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
if (error.statusCode === 503) {
|
|
72
|
+
return res.status(503).json({
|
|
73
|
+
error: 'Service Unavailable',
|
|
74
|
+
message: error.message
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
70
78
|
res.status(500).json({
|
|
71
79
|
error: 'Internal Server Error',
|
|
72
80
|
message: 'Failed to download file'
|
package/src/routes/list.js
CHANGED
|
@@ -30,6 +30,13 @@ export function createListRouter(storage) {
|
|
|
30
30
|
} catch (error) {
|
|
31
31
|
console.error('List files error:', error);
|
|
32
32
|
|
|
33
|
+
if (error.statusCode === 503) {
|
|
34
|
+
return res.status(503).json({
|
|
35
|
+
error: 'Service Unavailable',
|
|
36
|
+
message: error.message
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
res.status(500).json({
|
|
34
41
|
error: 'Internal Server Error',
|
|
35
42
|
message: 'Failed to list files'
|
package/src/routes/upload.js
CHANGED
|
@@ -42,10 +42,11 @@ export function createUploadRouter(storage, maxFileSize = 100 * 1024 * 1024) {
|
|
|
42
42
|
|
|
43
43
|
const userId = req.user.id;
|
|
44
44
|
const tenantId = req.user.tenantId;
|
|
45
|
+
const scope = req.fileScope || 'user';
|
|
45
46
|
const { buffer, originalname, mimetype } = req.file;
|
|
46
47
|
|
|
47
48
|
// Upload to Azure Blob Storage
|
|
48
|
-
const fileMetadata = await storage.uploadFile(tenantId, userId, buffer, originalname, mimetype);
|
|
49
|
+
const fileMetadata = await storage.uploadFile(tenantId, userId, buffer, originalname, mimetype, scope);
|
|
49
50
|
|
|
50
51
|
// Build response
|
|
51
52
|
const fileResponse = {
|
|
@@ -76,6 +77,13 @@ export function createUploadRouter(storage, maxFileSize = 100 * 1024 * 1024) {
|
|
|
76
77
|
});
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
if (error.statusCode === 503) {
|
|
81
|
+
return res.status(503).json({
|
|
82
|
+
error: 'Service Unavailable',
|
|
83
|
+
message: error.message
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
79
87
|
res.status(500).json({
|
|
80
88
|
error: 'Internal Server Error',
|
|
81
89
|
message: 'Failed to upload file'
|
package/HLD.md
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
# @progalaxyelabs/stonescriptphp-files — High Level Design
|
|
2
|
-
|
|
3
|
-
**Version**: 1.0.0
|
|
4
|
-
**Last Updated**: 2026-02-07
|
|
5
|
-
|
|
6
|
-
## Overview
|
|
7
|
-
|
|
8
|
-
A shared npm package that provides a ready-to-use Express file server with Azure Blob Storage and JWT authentication.
|
|
9
|
-
|
|
10
|
-
### Developer Experience
|
|
11
|
-
```bash
|
|
12
|
-
mkdir files && cd files
|
|
13
|
-
npm init -y
|
|
14
|
-
npm i express @progalaxyelabs/stonescriptphp-files
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
```js
|
|
18
|
-
// index.js
|
|
19
|
-
import 'dotenv/config';
|
|
20
|
-
import { createFilesServer } from '@progalaxyelabs/stonescriptphp-files';
|
|
21
|
-
createFilesServer().listen();
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
## Architecture
|
|
25
|
-
|
|
26
|
-
### Package Exports
|
|
27
|
-
|
|
28
|
-
```
|
|
29
|
-
@progalaxyelabs/stonescriptphp-files
|
|
30
|
-
├── createFilesServer(config?) // Factory: returns configured Express app
|
|
31
|
-
├── AzureStorageClient // Class: Azure Blob Storage operations
|
|
32
|
-
├── createAuthMiddleware // Middleware factory: JWT Bearer token validation
|
|
33
|
-
├── createUploadRouter // Express Router factory: POST /upload
|
|
34
|
-
├── createDownloadRouter // Express Router factory: GET /files/:id
|
|
35
|
-
├── createListRouter // Express Router factory: GET /files
|
|
36
|
-
├── createDeleteRouter // Express Router factory: DELETE /files/:id
|
|
37
|
-
└── createHealthRouter // Express Router factory: GET /health
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
### `createFilesServer(config?)` Factory
|
|
41
|
-
|
|
42
|
-
```js
|
|
43
|
-
createFilesServer({
|
|
44
|
-
port: process.env.PORT || 3000,
|
|
45
|
-
containerName: process.env.AZURE_CONTAINER_NAME || 'platform-files',
|
|
46
|
-
azureConnectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
|
|
47
|
-
jwtPublicKey: process.env.JWT_PUBLIC_KEY,
|
|
48
|
-
maxFileSize: 100 * 1024 * 1024, // 100MB default
|
|
49
|
-
corsOrigins: '*',
|
|
50
|
-
});
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
All config is optional — defaults to environment variables so the minimal setup works with zero arguments.
|
|
54
|
-
|
|
55
|
-
### Request Flow
|
|
56
|
-
|
|
57
|
-
```
|
|
58
|
-
Client
|
|
59
|
-
│
|
|
60
|
-
├─ POST /upload (multipart/form-data)
|
|
61
|
-
│ → JWT auth middleware
|
|
62
|
-
│ → multer (memory storage)
|
|
63
|
-
│ → AzureStorageClient.uploadFile()
|
|
64
|
-
│ → Returns { success, file: { id, name, size, contentType, uploadedAt } }
|
|
65
|
-
│
|
|
66
|
-
├─ GET /files/:id
|
|
67
|
-
│ → JWT auth middleware
|
|
68
|
-
│ → AzureStorageClient.downloadFile()
|
|
69
|
-
│ → Streams blob to response (Content-Type, Content-Disposition)
|
|
70
|
-
│
|
|
71
|
-
├─ GET /files
|
|
72
|
-
│ → JWT auth middleware
|
|
73
|
-
│ → AzureStorageClient.listFiles(userId)
|
|
74
|
-
│ → Returns { success, count, files: [...] }
|
|
75
|
-
│
|
|
76
|
-
├─ DELETE /files/:id
|
|
77
|
-
│ → JWT auth middleware
|
|
78
|
-
│ → AzureStorageClient.deleteFile()
|
|
79
|
-
│ → Returns { success, message }
|
|
80
|
-
│
|
|
81
|
-
└─ GET /health
|
|
82
|
-
→ No auth
|
|
83
|
-
→ Returns { status: "healthy", service: "files-service", timestamp }
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
## Components
|
|
87
|
-
|
|
88
|
-
### createFilesServer (factory)
|
|
89
|
-
Main export. Creates and returns a configured Express app with all routes, middleware, and graceful shutdown. Accepts optional config object, defaults to env vars.
|
|
90
|
-
|
|
91
|
-
### AzureStorageClient (class)
|
|
92
|
-
Wraps `@azure/storage-blob` SDK. Methods: `initialize()`, `uploadFile(userId, buffer, filename, contentType)`, `downloadFile(fileId, userId)`, `listFiles(userId)`, `deleteFile(fileId, userId)`. Auto-creates container on init with private access.
|
|
93
|
-
|
|
94
|
-
### createAuthMiddleware (middleware factory)
|
|
95
|
-
Express middleware factory. Validates Bearer token, extracts user identity from standard JWT claims (`sub`, `user_id`, `userId`, `id`), attaches to `req.user`. Supports RS256/ES256/HS256.
|
|
96
|
-
|
|
97
|
-
### Route handlers (router factories)
|
|
98
|
-
Express Router factory functions for upload, download, list, delete, health. Can be used individually for custom mounting or composed via `createFilesServer`.
|
|
99
|
-
|
|
100
|
-
## Tech Stack
|
|
101
|
-
|
|
102
|
-
| Component | Technology |
|
|
103
|
-
|-----------|-----------|
|
|
104
|
-
| Runtime | Node.js 24+ |
|
|
105
|
-
| Framework | Express 5.x (peerDependency) |
|
|
106
|
-
| Language | JavaScript (ESM) |
|
|
107
|
-
| Storage | Azure Blob Storage (`@azure/storage-blob` ^12.24.0) |
|
|
108
|
-
| Auth | JWT (`jsonwebtoken` ^9.0.2) |
|
|
109
|
-
| Upload | `multer` ^1.4.5-lts.1 |
|
|
110
|
-
| Module type | ESM (`"type": "module"`) |
|
|
111
|
-
|
|
112
|
-
## Storage Design
|
|
113
|
-
|
|
114
|
-
- **SDK:** `@azure/storage-blob` v12.x
|
|
115
|
-
- **Auth:** Connection string (env var)
|
|
116
|
-
- **Container:** auto-created on startup with private access
|
|
117
|
-
- **Blob path:** `{userId}/{uuid}.{extension}`
|
|
118
|
-
- **Metadata:** original_name, user_id, content_type, uploaded_at, file_id
|
|
119
|
-
|
|
120
|
-
## JWT Authentication
|
|
121
|
-
|
|
122
|
-
- **Algorithms:** RS256, ES256, HS256
|
|
123
|
-
- **Token source:** `Authorization: Bearer <token>` header
|
|
124
|
-
- **User ID extraction:** `sub` | `user_id` | `userId` | `id` claim
|
|
125
|
-
- **Ownership:** files are scoped to the authenticated user's ID
|
|
126
|
-
- **Health endpoint:** excluded from auth
|
|
127
|
-
- **Role-based auth:** optional `requireRole()` middleware export
|
|
128
|
-
|
|
129
|
-
## Environment Variables
|
|
130
|
-
|
|
131
|
-
| Variable | Required | Default | Description |
|
|
132
|
-
|----------|----------|---------|-------------|
|
|
133
|
-
| `AZURE_STORAGE_CONNECTION_STRING` | Yes | — | Azure Storage connection string |
|
|
134
|
-
| `AZURE_CONTAINER_NAME` | No | `platform-files` | Blob container name |
|
|
135
|
-
| `JWT_PUBLIC_KEY` | Yes | — | Public key for JWT verification |
|
|
136
|
-
| `PORT` | No | `3000` | Server listen port |
|
|
137
|
-
|
|
138
|
-
## Requirements
|
|
139
|
-
|
|
140
|
-
### Functional
|
|
141
|
-
- Upload files via multipart/form-data to Azure Blob Storage
|
|
142
|
-
- Download files with ownership verification and proper headers
|
|
143
|
-
- List all files belonging to the authenticated user
|
|
144
|
-
- Delete files with ownership verification
|
|
145
|
-
- JWT Bearer token authentication on all file operations
|
|
146
|
-
- Health check endpoint (unauthenticated)
|
|
147
|
-
- Auto-create Azure Blob container on startup
|
|
148
|
-
- Graceful shutdown on SIGTERM/SIGINT
|
|
149
|
-
- 100MB default file size limit (configurable)
|
|
150
|
-
- CORS support (configurable origins)
|
|
151
|
-
|
|
152
|
-
### Non-Functional
|
|
153
|
-
- Zero-config startup (all settings from env vars)
|
|
154
|
-
- No platform-specific code — fully generic and reusable
|
|
155
|
-
- ESM module (`"type": "module"`)
|
|
156
|
-
- Node.js 24+ engine requirement
|
|
157
|
-
- Express 5.x as peer dependency
|
|
158
|
-
|
|
159
|
-
### Developer Experience
|
|
160
|
-
- Setup in 3 commands: `npm init`, `npm i express @progalaxyelabs/stonescriptphp-files`, create index.js
|
|
161
|
-
- Consumer service has only 2 dependencies: `express` and this package
|
|
162
|
-
- All config via env vars with sensible defaults
|
|
163
|
-
- Composable exports for advanced/custom usage
|
|
164
|
-
|
|
165
|
-
## Constraints
|
|
166
|
-
|
|
167
|
-
- Must work with any JWT issuer (RS256/ES256/HS256)
|
|
168
|
-
- No breaking changes to Docker environment variables
|
|
169
|
-
- Published to npm as `@progalaxyelabs/stonescriptphp-files` (scoped, public)
|
|
170
|
-
- Source: https://github.com/progalaxyelabs/stonescriptphp-files
|
package/project-info.yaml
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
# project-info.yaml
|
|
2
|
-
# Human-readable project metadata and configuration
|
|
3
|
-
|
|
4
|
-
# REQUIRED FIELDS
|
|
5
|
-
# ===============
|
|
6
|
-
|
|
7
|
-
# Project code in format: XXXX-NNN
|
|
8
|
-
project_code: "STON-003"
|
|
9
|
-
|
|
10
|
-
# Project name (matches directory name)
|
|
11
|
-
name: "stonescriptphp-files"
|
|
12
|
-
|
|
13
|
-
# Project type: platform | interconnected | standalone
|
|
14
|
-
type: "standalone"
|
|
15
|
-
|
|
16
|
-
# Project status: production | active-dev | staging | maintenance | paused | archived | planning | unknown
|
|
17
|
-
status: "production"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
# BASIC METADATA
|
|
21
|
-
# ==============
|
|
22
|
-
|
|
23
|
-
# Human-readable project title
|
|
24
|
-
title: "@progalaxyelabs/stonescriptphp-files"
|
|
25
|
-
|
|
26
|
-
# Brief project description
|
|
27
|
-
description: "Zero-config Express file server with Azure Blob Storage and JWT authentication"
|
|
28
|
-
|
|
29
|
-
# Project version (semantic versioning recommended)
|
|
30
|
-
version: "1.0.0"
|
|
31
|
-
|
|
32
|
-
# Creation date (ISO 8601 format: YYYY-MM-DD)
|
|
33
|
-
created: "2026-02-07"
|
|
34
|
-
|
|
35
|
-
# Last modified date (ISO 8601 format: YYYY-MM-DD)
|
|
36
|
-
last_modified: "2026-02-07"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
# BUSINESS CLASSIFICATION
|
|
40
|
-
# =======================
|
|
41
|
-
|
|
42
|
-
# Domain category
|
|
43
|
-
domain: "tools"
|
|
44
|
-
|
|
45
|
-
# Project purpose: product | client-project | internal-tool | poc | experiment | library | marketing | unknown
|
|
46
|
-
purpose: "library"
|
|
47
|
-
|
|
48
|
-
# Business model: b2b-saas | b2c-saas | b2b-one-off | b2c-one-off | marketplace | internal | unknown
|
|
49
|
-
business_model: "internal"
|
|
50
|
-
|
|
51
|
-
# Ownership: progalaxy | client | partner | open-source | unknown
|
|
52
|
-
ownership: "open-source"
|
|
53
|
-
|
|
54
|
-
# Revenue priority: p0-critical | p1-high | p2-medium | p3-low | p4-none | unknown
|
|
55
|
-
revenue_priority: "p4-none"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# TECHNICAL DETAILS
|
|
59
|
-
# =================
|
|
60
|
-
|
|
61
|
-
# Primary technology stack
|
|
62
|
-
tech_stack:
|
|
63
|
-
language: "JavaScript (ESM)"
|
|
64
|
-
runtime: "Node.js >= 24"
|
|
65
|
-
framework: "Express 5 (peerDependency)"
|
|
66
|
-
storage: "Azure Blob Storage"
|
|
67
|
-
auth: "JWT (RS256/ES256/HS256)"
|
|
68
|
-
|
|
69
|
-
# Key dependencies
|
|
70
|
-
dependencies:
|
|
71
|
-
- "@azure/storage-blob"
|
|
72
|
-
- "multer"
|
|
73
|
-
- "jsonwebtoken"
|
|
74
|
-
- "cors"
|
|
75
|
-
- "uuid"
|
|
76
|
-
|
|
77
|
-
# Deployment tier: tier-0-static | tier-1-simple | tier-2-database | tier-3-services | tier-4-distributed | library | unknown
|
|
78
|
-
deployment_tier: "library"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# GIT & REPOSITORY
|
|
82
|
-
# ================
|
|
83
|
-
|
|
84
|
-
repository:
|
|
85
|
-
type: "git"
|
|
86
|
-
url: "git@github.com:progalaxyelabs/stonescriptphp-files.git"
|
|
87
|
-
branch: "main"
|
|
88
|
-
|
|
89
|
-
# Package registry
|
|
90
|
-
npm:
|
|
91
|
-
name: "@progalaxyelabs/stonescriptphp-files"
|
|
92
|
-
scope: "progalaxyelabs"
|
|
93
|
-
access: "public"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
# CONSUMERS
|
|
97
|
-
# =========
|
|
98
|
-
|
|
99
|
-
consumers:
|
|
100
|
-
- "progalaxyelabs-platform/files"
|
|
101
|
-
- "progalaxy-platform/files"
|
|
102
|
-
- "btechrecruiter-platform/files"
|
|
103
|
-
- "instituteapp-platform/files"
|
|
104
|
-
- "restrantapp-platform/files"
|
|
105
|
-
- "medstoreapp-platform/files"
|
|
106
|
-
- "logisticsapp-platform/files"
|
|
107
|
-
- "specialcomputers-platform/files"
|
|
108
|
-
- "aasaanwork-platform/files"
|
|
109
|
-
- "webmeteor-platform/files"
|
|
110
|
-
- "emcircuitsystems-platform/files"
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
# NOTES
|
|
114
|
-
# =====
|
|
115
|
-
|
|
116
|
-
notes: |
|
|
117
|
-
Factory pattern: createFilesServer(config?) with env var defaults.
|
|
118
|
-
Composable exports for advanced usage.
|
|
119
|
-
HLD in HLD.md.
|