@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 +170 -0
- package/README.md +1 -1
- package/package.json +4 -3
- package/project-info.yaml +119 -0
- package/src/auth.js +83 -0
- package/src/azure-storage.js +20 -10
- package/src/index.js +54 -10
- package/src/jwks-client.js +59 -0
- package/src/rate-limit.js +29 -0
- package/src/routes/delete.js +2 -1
- package/src/routes/download.js +2 -1
- package/src/routes/list.js +2 -1
- package/src/routes/upload.js +17 -8
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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@progalaxyelabs/stonescriptphp-files",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
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
|
package/src/azure-storage.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
34
|
-
const authenticate =
|
|
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
|
+
}
|
package/src/routes/delete.js
CHANGED
|
@@ -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,
|
package/src/routes/download.js
CHANGED
|
@@ -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);
|
package/src/routes/list.js
CHANGED
|
@@ -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,
|
package/src/routes/upload.js
CHANGED
|
@@ -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);
|