@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@progalaxyelabs/stonescriptphp-files",
3
- "version": "2.0.0",
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);
@@ -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 tenant prefix: {tenant_id}/{user_id}/{uuid}.{ext}
64
+ // Generate unique blob name with scope-based prefix
58
65
  const fileId = uuidv4();
59
66
  const extension = originalFilename.split('.').pop();
60
- 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
+ }
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
- // List blobs with prefix to find the blob
105
- 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
+
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
- 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) {
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
- 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
+ }
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
- 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
+
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
- 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'
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.