@progalaxyelabs/stonescriptphp-files 2.0.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@progalaxyelabs/stonescriptphp-files",
3
- "version": "2.0.1",
3
+ "version": "3.0.0",
4
4
  "description": "Zero-config Express file server with Azure Blob Storage and JWT authentication",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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
+ }
@@ -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);
@@ -45,17 +47,29 @@ export class AzureStorageClient {
45
47
  * @param {Buffer} fileBuffer - File content
46
48
  * @param {string} originalFilename - Original filename
47
49
  * @param {string} contentType - MIME type
50
+ * @param {string} scope - Storage scope: 'user' (default) or 'tenant'
48
51
  * @returns {Object} File metadata
49
52
  */
50
- 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
+
51
60
  if (!this.containerClient) {
52
61
  throw new Error('Azure Storage not initialized');
53
62
  }
54
63
 
55
- // Generate unique blob name with tenant prefix: {tenant_id}/{user_id}/{uuid}.{ext}
64
+ // Generate unique blob name with scope-based prefix
56
65
  const fileId = uuidv4();
57
66
  const extension = originalFilename.split('.').pop();
58
- const prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
67
+ let prefix;
68
+ if (scope === 'tenant') {
69
+ prefix = tenantId ? `${tenantId}/shared/` : 'shared/';
70
+ } else {
71
+ prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
72
+ }
59
73
  const blobName = `${prefix}${fileId}.${extension}`;
60
74
 
61
75
  const blockBlobClient = this.containerClient.getBlockBlobClient(blobName);
@@ -71,7 +85,8 @@ export class AzureStorageClient {
71
85
  original_filename: originalFilename,
72
86
  content_type: contentType,
73
87
  uploaded_at: new Date().toISOString(),
74
- file_id: fileId
88
+ file_id: fileId,
89
+ scope: scope
75
90
  }
76
91
  });
77
92
 
@@ -92,15 +107,28 @@ export class AzureStorageClient {
92
107
  * @param {string} fileId - File ID (UUID)
93
108
  * @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
94
109
  * @param {string} userId - User ID (for authorization)
110
+ * @param {string} scope - Storage scope: 'user' (default) or 'tenant'
95
111
  * @returns {Object} { stream, metadata }
96
112
  */
97
- 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
+
98
120
  if (!this.containerClient) {
99
121
  throw new Error('Azure Storage not initialized');
100
122
  }
101
123
 
102
- // List blobs with prefix to find the blob
103
- const prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
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
+
104
132
  const blobs = this.containerClient.listBlobsFlat({
105
133
  prefix,
106
134
  includeMetadata: true
@@ -110,8 +138,9 @@ export class AzureStorageClient {
110
138
  if (blob.metadata && blob.metadata.file_id === fileId) {
111
139
  const blockBlobClient = this.containerClient.getBlockBlobClient(blob.name);
112
140
 
113
- // Verify ownership
114
- if (blob.metadata.user_id !== userId) {
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) {
115
144
  throw new Error('Unauthorized: File belongs to different user');
116
145
  }
117
146
 
@@ -136,15 +165,27 @@ export class AzureStorageClient {
136
165
  * List user's files
137
166
  * @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
138
167
  * @param {string} userId - User ID
168
+ * @param {string} scope - Storage scope: 'user' (default) or 'tenant'
139
169
  * @returns {Array} List of file metadata
140
170
  */
141
- 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
+
142
178
  if (!this.containerClient) {
143
179
  throw new Error('Azure Storage not initialized');
144
180
  }
145
181
 
146
182
  const files = [];
147
- const prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
183
+ let prefix;
184
+ if (scope === 'tenant') {
185
+ prefix = tenantId ? `${tenantId}/shared/` : 'shared/';
186
+ } else {
187
+ prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
188
+ }
148
189
  const blobs = this.containerClient.listBlobsFlat({
149
190
  prefix,
150
191
  includeMetadata: true
@@ -170,14 +211,27 @@ export class AzureStorageClient {
170
211
  * @param {string} fileId - File ID (UUID)
171
212
  * @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
172
213
  * @param {string} userId - User ID (for authorization)
214
+ * @param {string} scope - Storage scope: 'user' (default) or 'tenant'
173
215
  */
174
- 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
+
175
223
  if (!this.containerClient) {
176
224
  throw new Error('Azure Storage not initialized');
177
225
  }
178
226
 
179
- // Find blob by file_id
180
- const prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
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
+
181
235
  const blobs = this.containerClient.listBlobsFlat({
182
236
  prefix,
183
237
  includeMetadata: true
@@ -185,8 +239,8 @@ export class AzureStorageClient {
185
239
 
186
240
  for await (const blob of blobs) {
187
241
  if (blob.metadata && blob.metadata.file_id === fileId) {
188
- // Verify ownership
189
- if (blob.metadata.user_id !== userId) {
242
+ // Verify ownership for user-scoped files only
243
+ if (scope === 'user' && blob.metadata.user_id !== userId) {
190
244
  throw new Error('Unauthorized: File belongs to different user');
191
245
  }
192
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
- throw new Error('No authentication method configured. Set AUTH_SERVERS, JWKS_URL, or JWT_PUBLIC_KEY.');
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';
@@ -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'
@@ -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'
@@ -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'
@@ -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'