@progalaxyelabs/stonescriptphp-files 1.0.0 → 2.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/HLD.md ADDED
@@ -0,0 +1,170 @@
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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @progalaxyelabs/stonescriptphp-files
2
2
 
3
- Shared file server for ProGalaxy E-Labs platforms — Azure Blob Storage + JWT auth
3
+ Zero-config Express file server with Azure Blob Storage and JWT authentication
4
4
 
5
5
  ## Installation
6
6
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@progalaxyelabs/stonescriptphp-files",
3
- "version": "1.0.0",
4
- "description": "Shared file server for ProGalaxy E-Labs platforms — Azure Blob Storage + JWT auth",
3
+ "version": "2.0.0",
4
+ "description": "Zero-config Express file server with Azure Blob Storage and JWT authentication",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -18,7 +18,8 @@
18
18
  "multer": "^1.4.5-lts.1",
19
19
  "jsonwebtoken": "^9.0.2",
20
20
  "cors": "^2.8.5",
21
- "uuid": "^11.0.5"
21
+ "uuid": "^11.0.5",
22
+ "express-rate-limit": "^7.4.0"
22
23
  },
23
24
  "keywords": [
24
25
  "azure",
@@ -0,0 +1,119 @@
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.
package/src/auth.js CHANGED
@@ -41,9 +41,13 @@ export function createAuthMiddleware(publicKey) {
41
41
  });
42
42
  }
43
43
 
44
+ // Extract tenant_id from token
45
+ const tenantId = decoded.tenant_id || decoded.tid || decoded.tenant_uuid || null;
46
+
44
47
  // Attach user info to request
45
48
  req.user = {
46
49
  id: userId,
50
+ tenantId,
47
51
  email: decoded.email,
48
52
  roles: decoded.roles || [],
49
53
  ...decoded
@@ -75,6 +79,85 @@ export function createAuthMiddleware(publicKey) {
75
79
  };
76
80
  }
77
81
 
82
+ /**
83
+ * JWKS-based JWT validation middleware factory
84
+ * Creates a middleware that validates JWT tokens using JWKS (JSON Web Key Sets)
85
+ *
86
+ * @param {JwksClient} jwksClient - JWKS client instance for key retrieval
87
+ * @returns {Function} Express middleware function
88
+ */
89
+ export function createJwksAuthMiddleware(jwksClient) {
90
+ if (!jwksClient) {
91
+ throw new Error('JWKS client is required');
92
+ }
93
+
94
+ return async function authenticateJwks(req, res, next) {
95
+ const authHeader = req.headers.authorization;
96
+
97
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
98
+ return res.status(401).json({
99
+ error: 'Unauthorized',
100
+ message: 'Missing or invalid authorization header'
101
+ });
102
+ }
103
+
104
+ const token = authHeader.substring(7); // Remove 'Bearer ' prefix
105
+
106
+ try {
107
+ // Get signing key from JWKS
108
+ const { key, algorithm } = await jwksClient.getSigningKey(token);
109
+
110
+ // Verify token with the retrieved key
111
+ const decoded = jwt.verify(token, key, { algorithms: [algorithm] });
112
+
113
+ // Extract user_id from token
114
+ const userId = decoded.sub || decoded.user_id || decoded.userId || decoded.id;
115
+
116
+ if (!userId) {
117
+ return res.status(401).json({
118
+ error: 'Unauthorized',
119
+ message: 'Token missing user identifier'
120
+ });
121
+ }
122
+
123
+ // Extract tenant_id from token
124
+ const tenantId = decoded.tenant_id || decoded.tid || decoded.tenant_uuid || null;
125
+
126
+ // Attach user info to request
127
+ req.user = {
128
+ id: userId,
129
+ tenantId,
130
+ email: decoded.email,
131
+ roles: decoded.roles || [],
132
+ ...decoded
133
+ };
134
+
135
+ next();
136
+ } catch (error) {
137
+ console.error('JWKS JWT validation error:', error.message);
138
+
139
+ if (error.name === 'TokenExpiredError') {
140
+ return res.status(401).json({
141
+ error: 'Unauthorized',
142
+ message: 'Token expired'
143
+ });
144
+ }
145
+
146
+ if (error.name === 'JsonWebTokenError') {
147
+ return res.status(401).json({
148
+ error: 'Unauthorized',
149
+ message: 'Invalid token'
150
+ });
151
+ }
152
+
153
+ return res.status(401).json({
154
+ error: 'Unauthorized',
155
+ message: 'Token validation failed'
156
+ });
157
+ }
158
+ };
159
+ }
160
+
78
161
  /**
79
162
  * Optional: Role-based authorization middleware factory
80
163
  * Use after authenticateJWT to check for specific roles
@@ -42,21 +42,23 @@ export class AzureStorageClient {
42
42
 
43
43
  /**
44
44
  * Upload file to Azure Blob Storage
45
+ * @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
45
46
  * @param {string} userId - User ID (for namespacing)
46
47
  * @param {Buffer} fileBuffer - File content
47
48
  * @param {string} originalFilename - Original filename
48
49
  * @param {string} contentType - MIME type
49
50
  * @returns {Object} File metadata
50
51
  */
51
- async uploadFile(userId, fileBuffer, originalFilename, contentType) {
52
+ async uploadFile(tenantId, userId, fileBuffer, originalFilename, contentType) {
52
53
  if (!this.containerClient) {
53
54
  throw new Error('Azure Storage not initialized');
54
55
  }
55
56
 
56
- // Generate unique blob name: {user_id}/{uuid}.{ext}
57
+ // Generate unique blob name with tenant prefix: {tenant_id}/{user_id}/{uuid}.{ext}
57
58
  const fileId = uuidv4();
58
59
  const extension = originalFilename.split('.').pop();
59
- const blobName = `${userId}/${fileId}.${extension}`;
60
+ const prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
61
+ const blobName = `${prefix}${fileId}.${extension}`;
60
62
 
61
63
  const blockBlobClient = this.containerClient.getBlockBlobClient(blobName);
62
64
 
@@ -66,6 +68,7 @@ export class AzureStorageClient {
66
68
  blobContentType: contentType
67
69
  },
68
70
  metadata: {
71
+ tenant_id: tenantId || '',
69
72
  user_id: userId,
70
73
  original_filename: originalFilename,
71
74
  content_type: contentType,
@@ -77,6 +80,7 @@ export class AzureStorageClient {
77
80
  return {
78
81
  fileId,
79
82
  blobName,
83
+ tenantId,
80
84
  userId,
81
85
  originalFilename,
82
86
  contentType,
@@ -88,17 +92,19 @@ export class AzureStorageClient {
88
92
  /**
89
93
  * Download file from Azure Blob Storage
90
94
  * @param {string} fileId - File ID (UUID)
95
+ * @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
91
96
  * @param {string} userId - User ID (for authorization)
92
97
  * @returns {Object} { stream, metadata }
93
98
  */
94
- async downloadFile(fileId, userId) {
99
+ async downloadFile(fileId, tenantId, userId) {
95
100
  if (!this.containerClient) {
96
101
  throw new Error('Azure Storage not initialized');
97
102
  }
98
103
 
99
- // List blobs with file_id metadata to find the blob
104
+ // List blobs with prefix to find the blob
105
+ const prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
100
106
  const blobs = this.containerClient.listBlobsFlat({
101
- prefix: `${userId}/`,
107
+ prefix,
102
108
  includeMetadata: true
103
109
  });
104
110
 
@@ -130,17 +136,19 @@ export class AzureStorageClient {
130
136
 
131
137
  /**
132
138
  * List user's files
139
+ * @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
133
140
  * @param {string} userId - User ID
134
141
  * @returns {Array} List of file metadata
135
142
  */
136
- async listFiles(userId) {
143
+ async listFiles(tenantId, userId) {
137
144
  if (!this.containerClient) {
138
145
  throw new Error('Azure Storage not initialized');
139
146
  }
140
147
 
141
148
  const files = [];
149
+ const prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
142
150
  const blobs = this.containerClient.listBlobsFlat({
143
- prefix: `${userId}/`,
151
+ prefix,
144
152
  includeMetadata: true
145
153
  });
146
154
 
@@ -162,16 +170,18 @@ export class AzureStorageClient {
162
170
  /**
163
171
  * Delete file from Azure Blob Storage
164
172
  * @param {string} fileId - File ID (UUID)
173
+ * @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
165
174
  * @param {string} userId - User ID (for authorization)
166
175
  */
167
- async deleteFile(fileId, userId) {
176
+ async deleteFile(fileId, tenantId, userId) {
168
177
  if (!this.containerClient) {
169
178
  throw new Error('Azure Storage not initialized');
170
179
  }
171
180
 
172
181
  // Find blob by file_id
182
+ const prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
173
183
  const blobs = this.containerClient.listBlobsFlat({
174
- prefix: `${userId}/`,
184
+ prefix,
175
185
  includeMetadata: true
176
186
  });
177
187
 
package/src/index.js CHANGED
@@ -1,13 +1,45 @@
1
1
  import express from 'express';
2
2
  import cors from 'cors';
3
3
  import { AzureStorageClient } from './azure-storage.js';
4
- import { createAuthMiddleware } from './auth.js';
4
+ import { createAuthMiddleware, createJwksAuthMiddleware } from './auth.js';
5
+ import { JwksClient } from './jwks-client.js';
6
+ import { createRateLimiters } from './rate-limit.js';
5
7
  import { createUploadRouter } from './routes/upload.js';
6
8
  import { createDownloadRouter } from './routes/download.js';
7
9
  import { createListRouter } from './routes/list.js';
8
10
  import { createDeleteRouter } from './routes/delete.js';
9
11
  import { createHealthRouter } from './routes/health.js';
10
12
 
13
+ /**
14
+ * Resolve the authentication middleware based on configuration priority.
15
+ * Priority: authServers > jwksUrl > jwtPublicKey
16
+ *
17
+ * @param {Object} config - Configuration options
18
+ * @returns {Function} Express middleware function for authentication
19
+ */
20
+ function resolveAuthMiddleware(config) {
21
+ const authServersJson = config.authServers || (process.env.AUTH_SERVERS ? JSON.parse(process.env.AUTH_SERVERS) : null);
22
+ const jwksUrl = config.jwksUrl || process.env.JWKS_URL;
23
+ const jwtPublicKey = config.jwtPublicKey || process.env.JWT_PUBLIC_KEY;
24
+ const cacheTtl = config.jwksCacheTtl || parseInt(process.env.JWKS_CACHE_TTL) || 3600;
25
+
26
+ if (authServersJson) {
27
+ const jwksClient = new JwksClient(authServersJson.map(s => ({ ...s, cacheTtl })));
28
+ return createJwksAuthMiddleware(jwksClient);
29
+ }
30
+
31
+ if (jwksUrl) {
32
+ const jwksClient = new JwksClient([{ issuer: '*', jwksUrl, cacheTtl }]);
33
+ return createJwksAuthMiddleware(jwksClient);
34
+ }
35
+
36
+ if (jwtPublicKey) {
37
+ return createAuthMiddleware(jwtPublicKey);
38
+ }
39
+
40
+ throw new Error('No authentication method configured. Set AUTH_SERVERS, JWKS_URL, or JWT_PUBLIC_KEY.');
41
+ }
42
+
11
43
  /**
12
44
  * Create a configured files server
13
45
  * @param {Object} config - Configuration options
@@ -15,23 +47,33 @@ import { createHealthRouter } from './routes/health.js';
15
47
  * @param {string} config.containerName - Azure container name (default: process.env.AZURE_CONTAINER_NAME || 'platform-files')
16
48
  * @param {string} config.azureConnectionString - Azure storage connection string (default: process.env.AZURE_STORAGE_CONNECTION_STRING)
17
49
  * @param {string} config.jwtPublicKey - JWT public key for auth (default: process.env.JWT_PUBLIC_KEY)
50
+ * @param {Array} config.authServers - Array of auth server configs [{issuer, jwksUrl, cacheTtl?}]
51
+ * @param {string} config.jwksUrl - Single JWKS URL for key retrieval
52
+ * @param {number} config.jwksCacheTtl - JWKS cache TTL in seconds (default: 3600)
53
+ * @param {boolean} config.tenantScoped - Enable tenant-scoped file isolation (default: true)
18
54
  * @param {number} config.maxFileSize - Max file size in bytes (default: 100MB)
19
55
  * @param {string|string[]} config.corsOrigins - CORS allowed origins (default: '*')
56
+ * @param {number} config.rateLimitWindowMs - Rate limit window in ms (default: 60000)
57
+ * @param {number} config.rateLimitUpload - Max uploads per window (default: 10)
58
+ * @param {number} config.rateLimitDownload - Max downloads per window (default: 60)
20
59
  * @returns {Object} Server object with app, storage, and listen() method
21
60
  */
22
61
  export function createFilesServer(config = {}) {
23
62
  const port = config.port || process.env.PORT || 3000;
24
63
  const containerName = config.containerName || process.env.AZURE_CONTAINER_NAME || 'platform-files';
25
64
  const connectionString = config.azureConnectionString || process.env.AZURE_STORAGE_CONNECTION_STRING;
26
- const jwtPublicKey = config.jwtPublicKey || process.env.JWT_PUBLIC_KEY;
27
65
  const maxFileSize = config.maxFileSize || 100 * 1024 * 1024;
28
66
  const corsOrigins = config.corsOrigins || '*';
67
+ const tenantScoped = config.tenantScoped !== undefined ? config.tenantScoped : (process.env.TENANT_SCOPED !== 'false');
29
68
 
30
69
  // Create storage client
31
70
  const storage = new AzureStorageClient(connectionString, containerName);
32
71
 
33
- // Create auth middleware
34
- const authenticate = createAuthMiddleware(jwtPublicKey);
72
+ // Resolve auth middleware based on config priority
73
+ const authenticate = resolveAuthMiddleware(config);
74
+
75
+ // Create rate limiters
76
+ const { uploadLimiter, downloadLimiter } = createRateLimiters(config);
35
77
 
36
78
  // Create Express app
37
79
  const app = express();
@@ -49,12 +91,12 @@ export function createFilesServer(config = {}) {
49
91
  next();
50
92
  });
51
93
 
52
- // Routes
94
+ // Routes with rate limiters
53
95
  app.use(createHealthRouter());
54
- app.use(authenticate, createUploadRouter(storage, maxFileSize));
55
- app.use(authenticate, createDownloadRouter(storage));
56
- app.use(authenticate, createListRouter(storage));
57
- app.use(authenticate, createDeleteRouter(storage));
96
+ app.use(authenticate, uploadLimiter, createUploadRouter(storage, maxFileSize));
97
+ app.use(authenticate, downloadLimiter, createDownloadRouter(storage));
98
+ app.use(authenticate, downloadLimiter, createListRouter(storage));
99
+ app.use(authenticate, downloadLimiter, createDeleteRouter(storage));
58
100
 
59
101
  // 404 handler
60
102
  app.use((req, res) => {
@@ -106,7 +148,9 @@ export function createFilesServer(config = {}) {
106
148
 
107
149
  // Named exports for advanced/composable usage
108
150
  export { AzureStorageClient } from './azure-storage.js';
109
- export { createAuthMiddleware } from './auth.js';
151
+ export { createAuthMiddleware, createJwksAuthMiddleware } from './auth.js';
152
+ export { JwksClient } from './jwks-client.js';
153
+ export { createRateLimiters } from './rate-limit.js';
110
154
  export { createUploadRouter } from './routes/upload.js';
111
155
  export { createDownloadRouter } from './routes/download.js';
112
156
  export { createListRouter } from './routes/list.js';
@@ -0,0 +1,59 @@
1
+ import crypto from 'crypto';
2
+ import jwt from 'jsonwebtoken';
3
+
4
+ export class JwksClient {
5
+ constructor(authServers) {
6
+ // authServers: [{issuer, jwksUrl, cacheTtl?}]
7
+ this.authServers = authServers;
8
+ this.cache = new Map(); // key: issuerKey, value: {jwks, fetchedAt}
9
+ this.defaultCacheTtl = 3600 * 1000; // 1 hour in ms
10
+ }
11
+
12
+ async getSigningKey(token) {
13
+ // 1. Decode token header (without verification) to get kid and iss
14
+ const decoded = jwt.decode(token, { complete: true });
15
+ if (!decoded) throw new Error('Invalid token');
16
+
17
+ const { kid, alg } = decoded.header;
18
+ const iss = decoded.payload.iss;
19
+
20
+ // 2. Find matching auth server by issuer (or use first if iss is '*' or not found)
21
+ const server = this.authServers.find(s => s.issuer === iss || s.issuer === '*') || this.authServers[0];
22
+ if (!server) throw new Error('No auth server configured for issuer: ' + iss);
23
+
24
+ // 3. Fetch JWKS (cached)
25
+ const jwks = await this.fetchJwks(server.issuer, server);
26
+
27
+ // 4. Find key by kid
28
+ const jwk = jwks.keys.find(k => k.kid === kid);
29
+ if (!jwk) throw new Error('Signing key not found for kid: ' + kid);
30
+
31
+ // 5. Convert JWK to PEM
32
+ const key = this.jwkToPem(jwk);
33
+
34
+ return { key, algorithm: alg || 'RS256' };
35
+ }
36
+
37
+ async fetchJwks(issuerKey, serverConfig) {
38
+ const cacheTtl = (serverConfig.cacheTtl || this.defaultCacheTtl / 1000) * 1000;
39
+ const cached = this.cache.get(issuerKey);
40
+
41
+ if (cached && (Date.now() - cached.fetchedAt) < cacheTtl) {
42
+ return cached.jwks;
43
+ }
44
+
45
+ const response = await fetch(serverConfig.jwksUrl);
46
+ if (!response.ok) {
47
+ throw new Error('Failed to fetch JWKS from ' + serverConfig.jwksUrl + ': ' + response.status);
48
+ }
49
+
50
+ const jwks = await response.json();
51
+ this.cache.set(issuerKey, { jwks, fetchedAt: Date.now() });
52
+
53
+ return jwks;
54
+ }
55
+
56
+ jwkToPem(jwk) {
57
+ return crypto.createPublicKey({ key: jwk, format: 'jwk' });
58
+ }
59
+ }
@@ -0,0 +1,29 @@
1
+ import rateLimit from 'express-rate-limit';
2
+
3
+ export function createRateLimiters(config = {}) {
4
+ const windowMs = config.rateLimitWindowMs || parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60 * 1000;
5
+ const uploadMax = config.rateLimitUpload || parseInt(process.env.RATE_LIMIT_UPLOAD) || 10;
6
+ const downloadMax = config.rateLimitDownload || parseInt(process.env.RATE_LIMIT_DOWNLOAD) || 60;
7
+
8
+ const keyGenerator = (req) => req.user?.id || req.ip;
9
+
10
+ const uploadLimiter = rateLimit({
11
+ windowMs,
12
+ max: uploadMax,
13
+ keyGenerator,
14
+ standardHeaders: true,
15
+ legacyHeaders: false,
16
+ message: { error: 'Too Many Requests', message: 'Upload rate limit exceeded. Try again later.' }
17
+ });
18
+
19
+ const downloadLimiter = rateLimit({
20
+ windowMs,
21
+ max: downloadMax,
22
+ keyGenerator,
23
+ standardHeaders: true,
24
+ legacyHeaders: false,
25
+ message: { error: 'Too Many Requests', message: 'Rate limit exceeded. Try again later.' }
26
+ });
27
+
28
+ return { uploadLimiter, downloadLimiter };
29
+ }
@@ -18,6 +18,7 @@ export function createDeleteRouter(storage) {
18
18
  try {
19
19
  const fileId = req.params.id;
20
20
  const userId = req.user.id;
21
+ const tenantId = req.user.tenantId;
21
22
 
22
23
  // Validate file ID format (UUID)
23
24
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -29,7 +30,7 @@ export function createDeleteRouter(storage) {
29
30
  }
30
31
 
31
32
  // Delete file from Azure (validates ownership)
32
- await storage.deleteFile(fileId, userId);
33
+ await storage.deleteFile(fileId, tenantId, userId);
33
34
 
34
35
  res.status(200).json({
35
36
  success: true,
@@ -18,6 +18,7 @@ export function createDownloadRouter(storage) {
18
18
  try {
19
19
  const fileId = req.params.id;
20
20
  const userId = req.user.id;
21
+ const tenantId = req.user.tenantId;
21
22
 
22
23
  // Validate file ID format (UUID)
23
24
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -29,7 +30,7 @@ export function createDownloadRouter(storage) {
29
30
  }
30
31
 
31
32
  // Download file from Azure (validates ownership)
32
- const { stream, metadata } = await storage.downloadFile(fileId, userId);
33
+ const { stream, metadata } = await storage.downloadFile(fileId, tenantId, userId);
33
34
 
34
35
  // Set response headers
35
36
  res.setHeader('Content-Type', metadata.contentType);
@@ -17,9 +17,10 @@ export function createListRouter(storage) {
17
17
  router.get('/files', async (req, res) => {
18
18
  try {
19
19
  const userId = req.user.id;
20
+ const tenantId = req.user.tenantId;
20
21
 
21
22
  // List files for current user
22
- const files = await storage.listFiles(userId);
23
+ const files = await storage.listFiles(tenantId, userId);
23
24
 
24
25
  res.status(200).json({
25
26
  success: true,
@@ -41,21 +41,30 @@ export function createUploadRouter(storage, maxFileSize = 100 * 1024 * 1024) {
41
41
  }
42
42
 
43
43
  const userId = req.user.id;
44
+ const tenantId = req.user.tenantId;
44
45
  const { buffer, originalname, mimetype } = req.file;
45
46
 
46
47
  // Upload to Azure Blob Storage
47
- const fileMetadata = await storage.uploadFile(userId, buffer, originalname, mimetype);
48
+ const fileMetadata = await storage.uploadFile(tenantId, userId, buffer, originalname, mimetype);
49
+
50
+ // Build response
51
+ const fileResponse = {
52
+ id: fileMetadata.fileId,
53
+ name: fileMetadata.originalFilename,
54
+ contentType: fileMetadata.contentType,
55
+ size: fileMetadata.size,
56
+ uploadedAt: fileMetadata.uploadedAt
57
+ };
58
+
59
+ // Include tenantId in response if present
60
+ if (tenantId) {
61
+ fileResponse.tenantId = tenantId;
62
+ }
48
63
 
49
64
  // Return success response
50
65
  res.status(201).json({
51
66
  success: true,
52
- file: {
53
- id: fileMetadata.fileId,
54
- name: fileMetadata.originalFilename,
55
- contentType: fileMetadata.contentType,
56
- size: fileMetadata.size,
57
- uploadedAt: fileMetadata.uploadedAt
58
- }
67
+ file: fileResponse
59
68
  });
60
69
  } catch (error) {
61
70
  console.error('Upload error:', error);