node-server-engine 1.8.0 → 1.9.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/README.md +712 -67
- package/dist/entities/AWSS3/AWSS3.d.ts +157 -0
- package/dist/entities/AWSS3/AWSS3.js +426 -0
- package/dist/entities/AWSS3/AWSS3.js.map +1 -0
- package/dist/entities/AWSS3/AWSS3.types.d.ts +78 -0
- package/dist/entities/AWSS3/AWSS3.types.js +2 -0
- package/dist/entities/AWSS3/AWSS3.types.js.map +1 -0
- package/dist/entities/AWSS3/index.d.ts +2 -0
- package/dist/entities/{Storage → AWSS3}/index.js +8 -8
- package/dist/entities/AWSS3/index.js.map +1 -0
- package/dist/entities/ElasticSearch/ElasticSearch.d.ts +5 -0
- package/dist/entities/ElasticSearch/ElasticSearch.js +137 -38
- package/dist/entities/ElasticSearch/ElasticSearch.js.map +1 -1
- package/dist/entities/GoogleCloudStorage/GoogleCloudStorage.d.ts +141 -0
- package/dist/entities/GoogleCloudStorage/GoogleCloudStorage.js +351 -0
- package/dist/entities/GoogleCloudStorage/GoogleCloudStorage.js.map +1 -0
- package/dist/entities/{Storage/Storage.types.d.ts → GoogleCloudStorage/GoogleCloudStorage.types.d.ts} +14 -0
- package/dist/entities/GoogleCloudStorage/GoogleCloudStorage.types.js +2 -0
- package/dist/entities/GoogleCloudStorage/GoogleCloudStorage.types.js.map +1 -0
- package/dist/entities/GoogleCloudStorage/index.d.ts +2 -0
- package/dist/entities/GoogleCloudStorage/index.js +28 -0
- package/dist/entities/GoogleCloudStorage/index.js.map +1 -0
- package/dist/entities/Localizator/Localizator.js +2 -2
- package/dist/entities/Localizator/Localizator.js.map +1 -1
- package/dist/entities/index.d.ts +2 -1
- package/dist/entities/index.js +15 -4
- package/dist/entities/index.js.map +1 -1
- package/package.json +11 -10
- package/dist/entities/Storage/Storage.d.ts +0 -38
- package/dist/entities/Storage/Storage.js +0 -214
- package/dist/entities/Storage/Storage.js.map +0 -1
- package/dist/entities/Storage/Storage.types.js +0 -2
- package/dist/entities/Storage/Storage.types.js.map +0 -1
- package/dist/entities/Storage/index.d.ts +0 -2
- package/dist/entities/Storage/index.js.map +0 -1
package/README.md
CHANGED
|
@@ -8,18 +8,21 @@ Framework used to develop Node backend services. This package ships with a lot o
|
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
|
-
- 🚀 **Express-based** - Built on the popular Express.js framework
|
|
11
|
+
- 🚀 **Express-based** - Built on the popular Express.js framework (v4.22)
|
|
12
12
|
- 🔒 **Multiple Auth Methods** - JWT, mTLS, HMAC, and Static token authentication
|
|
13
|
-
- 🔌 **WebSocket Support** - Built-in WebSocket server with message handling
|
|
14
|
-
- 📊 **Database Integration** - Sequelize ORM with migrations support
|
|
15
|
-
- 📡 **Pub/Sub** - Google Cloud Pub/Sub integration
|
|
13
|
+
- 🔌 **WebSocket Support** - Built-in WebSocket server with message handling (ws v8.18)
|
|
14
|
+
- 📊 **Database Integration** - Sequelize ORM with migrations support (v6.37)
|
|
15
|
+
- 📡 **Pub/Sub** - Google Cloud Pub/Sub integration (v4.11)
|
|
16
16
|
- 🔔 **Push Notifications** - Built-in push notification support
|
|
17
17
|
- 🌐 **i18n** - Internationalization with translation management
|
|
18
|
-
- 🔍 **ElasticSearch** - Full-text search integration
|
|
18
|
+
- 🔍 **ElasticSearch** - Full-text search integration (v9.2) with auto-migrations
|
|
19
|
+
- 💾 **Redis** - Advanced Redis client with retry logic, TLS support (ioredis v5.8)
|
|
19
20
|
- 📝 **API Documentation** - Swagger/OpenAPI documentation support
|
|
20
|
-
- 📤 **File Uploads** - Single and chunked file upload middleware
|
|
21
|
+
- 📤 **File Uploads** - Single and chunked file upload middleware with validation
|
|
21
22
|
- 🧪 **TypeScript** - Written in TypeScript with full type definitions
|
|
22
23
|
- ✅ **Modern Tooling** - ESLint, Prettier, and automated versioning
|
|
24
|
+
- 🛡️ **Permission System** - Role-based access control with case-insensitive matching
|
|
25
|
+
- 🔐 **Security** - HMAC authentication, TLS/mTLS support, input validation
|
|
23
26
|
|
|
24
27
|
## Requirements
|
|
25
28
|
|
|
@@ -477,29 +480,86 @@ new Endpoint({
|
|
|
477
480
|
|
|
478
481
|
### Socket Client
|
|
479
482
|
|
|
480
|
-
The
|
|
483
|
+
The WebSocket server starts automatically if the [Server](#server)'s `webSocket` option is provided.
|
|
481
484
|
|
|
482
|
-
Each
|
|
485
|
+
Each WebSocket connection creates a new `SocketClient` instance with built-in authentication, message routing, and connection management.
|
|
486
|
+
|
|
487
|
+
#### Features
|
|
488
|
+
|
|
489
|
+
- **JWT Authentication**: Secure token-based authentication with automatic renewal
|
|
490
|
+
- **Message Routing**: Type-based message handlers similar to HTTP endpoints
|
|
491
|
+
- **Connection Health**: Built-in ping/pong mechanism with configurable intervals
|
|
492
|
+
- **Lifecycle Callbacks**: Hooks for initialization, authentication, and shutdown
|
|
493
|
+
- **Error Handling**: Automatic error formatting and client notification
|
|
483
494
|
|
|
484
495
|
#### Socket Client Options
|
|
485
496
|
|
|
486
|
-
Options can be set
|
|
497
|
+
Options can be set when configuring the Server's webSocket:
|
|
498
|
+
|
|
499
|
+
```javascript
|
|
500
|
+
import { Server, MessageHandler } from 'node-server-engine';
|
|
501
|
+
|
|
502
|
+
const server = new Server({
|
|
503
|
+
webSocket: {
|
|
504
|
+
client: {
|
|
505
|
+
handlers: [messageHandler1, messageHandler2],
|
|
506
|
+
initCallbacks: (client) => {
|
|
507
|
+
console.log(`Client ${client.id} connected`);
|
|
508
|
+
},
|
|
509
|
+
authCallbacks: (client) => {
|
|
510
|
+
const user = client.getUser();
|
|
511
|
+
console.log(`User ${user.userId} authenticated`);
|
|
512
|
+
},
|
|
513
|
+
shutdownCallbacks: (client) => {
|
|
514
|
+
console.log(`Client ${client.id} disconnected`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
```
|
|
487
520
|
|
|
488
521
|
| Parameter | Type | Description | Default |
|
|
489
522
|
| ----------------- | ------------------------------------------ | ----------------------------------------------------------------------- | ------------ |
|
|
490
523
|
| handlers | Array\<[MessageHandler](#message-handler)> | A list of message handlers to use | **required** |
|
|
491
|
-
| authCallbacks | Array\<
|
|
492
|
-
| initCallbacks | Array\<
|
|
493
|
-
| shutdownCallbacks | Array\<
|
|
524
|
+
| authCallbacks | Function \| Array\<Function> | Callbacks called when a client successfully authenticates | [] |
|
|
525
|
+
| initCallbacks | Function \| Array\<Function> | Callbacks called when the socket client is created | [] |
|
|
526
|
+
| shutdownCallbacks | Function \| Array\<Function> | Callbacks called when the socket client is destroyed | [] |
|
|
527
|
+
|
|
528
|
+
#### Client Properties & Methods
|
|
529
|
+
|
|
530
|
+
| Property/Method | Type | Description |
|
|
531
|
+
| ----------------- | --------------------------- | ------------------------------------------------------------------------- |
|
|
532
|
+
| id | String | Unique identifier for the connection |
|
|
533
|
+
| establishedAt | Date | Timestamp when connection was established |
|
|
534
|
+
| isAuthenticated() | () => Boolean | Check if client is authenticated |
|
|
535
|
+
| getUser() | () => SocketUser \| undefined | Get authenticated user data (userId, deviceId, tokenId, audience) |
|
|
536
|
+
| sendMessage() | (type, payload, options) | Send a message to the client |
|
|
537
|
+
|
|
538
|
+
#### Client Authentication
|
|
539
|
+
|
|
540
|
+
Clients authenticate by sending a message:
|
|
541
|
+
|
|
542
|
+
```javascript
|
|
543
|
+
// Client-side
|
|
544
|
+
websocket.send(JSON.stringify({
|
|
545
|
+
type: 'authenticate',
|
|
546
|
+
payload: { token: 'your-jwt-token' }
|
|
547
|
+
}));
|
|
548
|
+
|
|
549
|
+
// Server will:
|
|
550
|
+
// 1. Verify the JWT token
|
|
551
|
+
// 2. Extract user information (userId, deviceId, audience)
|
|
552
|
+
// 3. Set authentication status
|
|
553
|
+
// 4. Trigger authCallbacks
|
|
554
|
+
// 5. Send renewal reminder 1 minute before token expiration
|
|
555
|
+
```
|
|
494
556
|
|
|
495
|
-
####
|
|
557
|
+
#### Environment Variables
|
|
496
558
|
|
|
497
|
-
|
|
|
498
|
-
|
|
|
499
|
-
|
|
|
500
|
-
|
|
|
501
|
-
| authenticated | Boolean | Flag indicating that the current user is authenticated |
|
|
502
|
-
| userId | String | Id of the current user **(available when authenticated)** |
|
|
559
|
+
| Variable | Description | Default |
|
|
560
|
+
| ------------------- | ----------------------------------------------- | ------- |
|
|
561
|
+
| WS_PING_INTERVAL | Interval between ping checks (seconds) | 30 |
|
|
562
|
+
| WEBSOCKET_CLOSE_TIMEOUT | Timeout for graceful close (milliseconds) | 3000 |
|
|
503
563
|
|
|
504
564
|
---
|
|
505
565
|
|
|
@@ -532,24 +592,235 @@ new Server({
|
|
|
532
592
|
|
|
533
593
|
### Redis
|
|
534
594
|
|
|
535
|
-
The server engine exposes a Redis client
|
|
595
|
+
The server engine exposes a Redis client configured for production use with automatic reconnection, retry logic, and optional TLS support.
|
|
536
596
|
|
|
537
|
-
It is a pre-configured instance of [ioredis](https://github.com/luin/ioredis). See the package documentation for more details.
|
|
597
|
+
It is a pre-configured instance of [ioredis](https://github.com/luin/ioredis) v5.8.2. See the package documentation for more details.
|
|
538
598
|
|
|
539
599
|
```javascript
|
|
540
600
|
import { Redis } from 'node-server-engine';
|
|
541
601
|
|
|
542
|
-
|
|
602
|
+
// Initialize (automatically called by Server, or manually)
|
|
603
|
+
Redis.init();
|
|
604
|
+
|
|
605
|
+
// Use Redis commands
|
|
606
|
+
await Redis.set('key', 'value');
|
|
607
|
+
const value = await Redis.get('key');
|
|
608
|
+
await Redis.del('key');
|
|
609
|
+
await Redis.expire('key', 3600);
|
|
610
|
+
|
|
611
|
+
// Get underlying client for advanced use
|
|
612
|
+
const client = Redis.getClient();
|
|
613
|
+
|
|
614
|
+
// Shutdown when done
|
|
615
|
+
await Redis.shutdown();
|
|
543
616
|
```
|
|
544
617
|
|
|
545
|
-
|
|
618
|
+
#### Features
|
|
619
|
+
|
|
620
|
+
- **Automatic Reconnection**: Exponential backoff retry strategy (up to 2s delay)
|
|
621
|
+
- **Error Recovery**: Reconnects on READONLY, ECONNRESET, and ETIMEDOUT errors
|
|
622
|
+
- **Connection Management**: Event listeners for connect, ready, error, close, reconnecting
|
|
623
|
+
- **TLS Support**: Automatic TLS configuration when `TLS_CA` is provided
|
|
624
|
+
- **Test Mode**: Lazy connection in test environments to prevent connection attempts
|
|
625
|
+
|
|
626
|
+
#### Environment Variables
|
|
627
|
+
|
|
628
|
+
| Variable | Description | Default | Required |
|
|
629
|
+
| --------------- | -------------------------------------- | ------- | -------- |
|
|
630
|
+
| REDIS_HOST | Redis server hostname or IP | - | ✓ |
|
|
631
|
+
| REDIS_PORT | Redis server port | 6379 | ✗ |
|
|
632
|
+
| REDIS_USERNAME | Username for authentication | - | ✗ |
|
|
633
|
+
| REDIS_PASSWORD | Password for authentication | - | ✗ |
|
|
634
|
+
| TLS_CA | TLS certificate authority for SSL/TLS | - | ✗ |
|
|
635
|
+
|
|
636
|
+
#### Configuration Options
|
|
546
637
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
638
|
+
You can customize Redis client creation:
|
|
639
|
+
|
|
640
|
+
```javascript
|
|
641
|
+
import { createRedisClient } from 'node-server-engine';
|
|
642
|
+
|
|
643
|
+
const client = createRedisClient({
|
|
644
|
+
db: 2, // Database index (default: 0)
|
|
645
|
+
lazyConnect: true, // Don't connect until first command
|
|
646
|
+
enableReadyCheck: false, // Disable ready check
|
|
647
|
+
redis: { // Override any ioredis options
|
|
648
|
+
connectTimeout: 10000,
|
|
649
|
+
commandTimeout: 5000
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
---
|
|
655
|
+
|
|
656
|
+
### AWSS3
|
|
657
|
+
|
|
658
|
+
The AWSS3 entity is a generic wrapper around AWS S3 (and S3-compatible services like MinIO, LocalStack). It supports uploads, downloads, streaming, metadata, and deletion with size validation and UUID-based path generation.
|
|
659
|
+
|
|
660
|
+
Based on @aws-sdk/client-s3 v3.
|
|
661
|
+
|
|
662
|
+
```javascript
|
|
663
|
+
import { AWSS3 } from 'node-server-engine';
|
|
664
|
+
import fs from 'fs';
|
|
665
|
+
|
|
666
|
+
// Initialize with configuration (optional; otherwise uses environment variables)
|
|
667
|
+
AWSS3.init({
|
|
668
|
+
region: 'us-east-1',
|
|
669
|
+
accessKeyId: 'YOUR_ACCESS_KEY',
|
|
670
|
+
secretAccessKey: 'YOUR_SECRET_KEY',
|
|
671
|
+
// For S3-compatible services
|
|
672
|
+
// endpoint: 'http://localhost:4566',
|
|
673
|
+
// forcePathStyle: true
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// Or rely on environment variables and auto-init
|
|
677
|
+
// AWSS3.init();
|
|
678
|
+
|
|
679
|
+
// Upload a file
|
|
680
|
+
const fileStream = fs.createReadStream('photo.jpg');
|
|
681
|
+
const uploaded = await AWSS3.upload(
|
|
682
|
+
fileStream,
|
|
683
|
+
'my-bucket',
|
|
684
|
+
{ directory: 'photos', mime: 'image/jpeg' },
|
|
685
|
+
{ maxSize: '5MB' },
|
|
686
|
+
{ userId: '123' } // optional metadata
|
|
687
|
+
);
|
|
688
|
+
console.log(uploaded.Key); // photos/<uuid>.jpeg
|
|
689
|
+
|
|
690
|
+
// Download entire file into memory
|
|
691
|
+
const { data, metadata } = await AWSS3.get('my-bucket', uploaded.Key);
|
|
692
|
+
console.log(metadata.ContentLength);
|
|
693
|
+
|
|
694
|
+
// Stream a large file
|
|
695
|
+
const { stream } = await AWSS3.download('my-bucket', uploaded.Key);
|
|
696
|
+
stream.pipe(res);
|
|
697
|
+
|
|
698
|
+
// Get a fast stream without metadata
|
|
699
|
+
const s = await AWSS3.getFileStream('my-bucket', uploaded.Key);
|
|
700
|
+
s.pipe(res);
|
|
701
|
+
|
|
702
|
+
// Metadata only
|
|
703
|
+
const head = await AWSS3.getMetadata('my-bucket', uploaded.Key);
|
|
704
|
+
|
|
705
|
+
// Delete
|
|
706
|
+
await AWSS3.delete('my-bucket', uploaded.Key);
|
|
707
|
+
|
|
708
|
+
// Generate unique destination path
|
|
709
|
+
const key = AWSS3.generateFileDestination({ directory: 'uploads/images', mime: 'image/png' });
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
#### Features
|
|
713
|
+
|
|
714
|
+
- Auto-initialization via environment variables
|
|
715
|
+
- Stream-based upload with `maxSize` validation
|
|
716
|
+
- Full download, streaming download, or stream-only access
|
|
717
|
+
- UUID-based file naming with directory and extension inference from MIME
|
|
718
|
+
- S3-compatible (supports custom `endpoint` and `forcePathStyle`)
|
|
719
|
+
|
|
720
|
+
#### API Methods
|
|
721
|
+
|
|
722
|
+
##### `init(config?)`
|
|
723
|
+
|
|
724
|
+
Initialize the S3 client explicitly; otherwise it will lazy-init using environment variables.
|
|
725
|
+
|
|
726
|
+
```javascript
|
|
727
|
+
AWSS3.init({
|
|
728
|
+
region: 'us-east-1',
|
|
729
|
+
accessKeyId: '...',
|
|
730
|
+
secretAccessKey: '...',
|
|
731
|
+
sessionToken: '...', // optional
|
|
732
|
+
endpoint: 'http://localhost:9000', // optional (MinIO/LocalStack)
|
|
733
|
+
forcePathStyle: true // optional (S3-compatible)
|
|
734
|
+
});
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
##### `upload(stream, bucket, destinationOptions?, uploaderOptions?, metadata?)`
|
|
738
|
+
|
|
739
|
+
Uploads content from a readable stream.
|
|
740
|
+
|
|
741
|
+
- `destinationOptions`: `{ directory?, fileName?, mime?, noExtension? }`
|
|
742
|
+
- `uploaderOptions`: `{ maxSize?: string }` e.g. `5MB`, `100KB`
|
|
743
|
+
- `metadata`: key/value pairs stored as object metadata
|
|
744
|
+
|
|
745
|
+
Returns `Promise<S3UploadedFile>` with keys like `Bucket`, `Key`, `ETag`, `Location`.
|
|
746
|
+
|
|
747
|
+
##### `get(bucket, key)`
|
|
748
|
+
|
|
749
|
+
Downloads the full file into memory as `Buffer` and returns `{ data, metadata }`.
|
|
750
|
+
|
|
751
|
+
##### `download(bucket, key)`
|
|
752
|
+
|
|
753
|
+
Returns `{ stream, metadata }` for streaming large files efficiently.
|
|
754
|
+
|
|
755
|
+
##### `getFileStream(bucket, key)`
|
|
756
|
+
|
|
757
|
+
Returns a `Readable` stream without fetching metadata.
|
|
758
|
+
|
|
759
|
+
##### `getMetadata(bucket, key)`
|
|
760
|
+
|
|
761
|
+
Returns file metadata via a HEAD request.
|
|
762
|
+
|
|
763
|
+
##### `delete(bucket, key)`
|
|
764
|
+
|
|
765
|
+
Deletes the object.
|
|
766
|
+
|
|
767
|
+
##### `generateFileDestination(options?)`
|
|
768
|
+
|
|
769
|
+
Generates a unique key using UUID with optional directory and extension (from `mime`).
|
|
770
|
+
|
|
771
|
+
#### Environment Variables
|
|
772
|
+
|
|
773
|
+
| Variable | Description | Required |
|
|
774
|
+
| --- | --- | --- |
|
|
775
|
+
| `AWS_REGION` | AWS region (e.g., `us-east-1`) | ✗* |
|
|
776
|
+
| `AWS_ACCESS_KEY_ID` | Access key ID | ✗* |
|
|
777
|
+
| `AWS_SECRET_ACCESS_KEY` | Secret access key | ✗* |
|
|
778
|
+
| `AWS_SESSION_TOKEN` | Session token (temporary creds) | ✗ |
|
|
779
|
+
| `AWS_S3_ENDPOINT` | Custom S3-compatible endpoint (MinIO/LocalStack) | ✗ |
|
|
780
|
+
| `AWS_S3_FORCE_PATH_STYLE` | Use path-style addressing (`true`/`false`) | ✗ |
|
|
781
|
+
|
|
782
|
+
\* Not required if you call `init()` with a config object.
|
|
783
|
+
|
|
784
|
+
#### Error Handling
|
|
785
|
+
|
|
786
|
+
- Throws `WebError` with status `413` when `maxSize` is exceeded during upload
|
|
787
|
+
- Other errors bubble up from AWS SDK v3 commands
|
|
788
|
+
|
|
789
|
+
**Example:**
|
|
790
|
+
```javascript
|
|
791
|
+
try {
|
|
792
|
+
await AWSS3.upload(stream, 'bucket', {}, { maxSize: '1MB' });
|
|
793
|
+
} catch (e) {
|
|
794
|
+
if (e.statusCode === 413) {
|
|
795
|
+
console.log('File too large');
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
#### Usage in Template Projects
|
|
801
|
+
|
|
802
|
+
```javascript
|
|
803
|
+
import { AWSS3, Endpoint, Method } from 'node-server-engine';
|
|
804
|
+
import { Readable } from 'stream';
|
|
805
|
+
|
|
806
|
+
new Endpoint({
|
|
807
|
+
path: '/upload-s3',
|
|
808
|
+
method: Method.POST,
|
|
809
|
+
files: [{ key: 'file', maxSize: '10MB', required: true }],
|
|
810
|
+
async handler(req, res) {
|
|
811
|
+
const file = req.files[0];
|
|
812
|
+
const stream = Readable.from(file.buffer);
|
|
813
|
+
|
|
814
|
+
const result = await AWSS3.upload(
|
|
815
|
+
stream,
|
|
816
|
+
process.env.S3_UPLOAD_BUCKET,
|
|
817
|
+
{ directory: 'user-uploads', mime: file.mimetype }
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
res.json({ key: result.Key, url: result.Location });
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
```
|
|
553
824
|
|
|
554
825
|
---
|
|
555
826
|
|
|
@@ -679,25 +950,362 @@ await Localizator.shutdown();
|
|
|
679
950
|
|
|
680
951
|
### ElasticSearch
|
|
681
952
|
|
|
682
|
-
The ElasticSearch
|
|
953
|
+
The ElasticSearch entity provides a managed client for Elasticsearch with automatic migrations, connection management, and TLS support.
|
|
954
|
+
|
|
955
|
+
Based on [@elastic/elasticsearch](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html) v9.2.0.
|
|
683
956
|
|
|
684
957
|
```javascript
|
|
685
958
|
import { ElasticSearch } from 'node-server-engine';
|
|
686
959
|
|
|
687
|
-
//
|
|
960
|
+
// Initialize (runs migrations automatically)
|
|
688
961
|
await ElasticSearch.init();
|
|
689
962
|
|
|
690
|
-
//
|
|
963
|
+
// Get the client for operations
|
|
964
|
+
const client = ElasticSearch.getClient();
|
|
965
|
+
|
|
966
|
+
// Use Elasticsearch operations
|
|
967
|
+
await client.index({
|
|
968
|
+
index: 'products',
|
|
969
|
+
id: '123',
|
|
970
|
+
document: { name: 'Product', price: 99.99 }
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
const result = await client.search({
|
|
974
|
+
index: 'products',
|
|
975
|
+
query: { match: { name: 'Product' } }
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
// Shutdown when done
|
|
691
979
|
await ElasticSearch.shutdown();
|
|
692
980
|
```
|
|
693
981
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
982
|
+
#### Features
|
|
983
|
+
|
|
984
|
+
- **Automatic Migrations**: Tracks and runs migrations on startup
|
|
985
|
+
- **Connection Verification**: Pings cluster on initialization
|
|
986
|
+
- **TLS Support**: Optional SSL/TLS configuration
|
|
987
|
+
- **Retry Logic**: Built-in retry mechanism with configurable attempts
|
|
988
|
+
- **Test Mode**: Auto-flushes indices in test environment
|
|
989
|
+
- **Error Handling**: Detailed error reporting with context
|
|
990
|
+
|
|
991
|
+
#### Migration System
|
|
992
|
+
|
|
993
|
+
Create migration files in your specified migration directory:
|
|
994
|
+
|
|
995
|
+
```javascript
|
|
996
|
+
// migrations/001-create-products-index.ts
|
|
997
|
+
import { Client } from '@elastic/elasticsearch';
|
|
998
|
+
|
|
999
|
+
export async function migrate(client: Client): Promise<void> {
|
|
1000
|
+
await client.indices.create({
|
|
1001
|
+
index: 'products',
|
|
1002
|
+
settings: {
|
|
1003
|
+
number_of_shards: 1,
|
|
1004
|
+
number_of_replicas: 1
|
|
1005
|
+
},
|
|
1006
|
+
mappings: {
|
|
1007
|
+
properties: {
|
|
1008
|
+
name: { type: 'text' },
|
|
1009
|
+
price: { type: 'double' },
|
|
1010
|
+
createdAt: { type: 'date' }
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
Migrations are:
|
|
1018
|
+
- Run automatically on `init()`
|
|
1019
|
+
- Tracked in the `migrations` index
|
|
1020
|
+
- Executed once per file
|
|
1021
|
+
- Run in alphabetical order
|
|
1022
|
+
|
|
1023
|
+
#### Environment Variables
|
|
1024
|
+
|
|
1025
|
+
| Variable | Description | Required |
|
|
1026
|
+
| ------------------------------ | ------------------------------------------ | -------- |
|
|
1027
|
+
| ELASTIC_SEARCH_HOST | Elasticsearch cluster URL | ✓ |
|
|
1028
|
+
| ELASTIC_SEARCH_USERNAME | Username for authentication | ✓ |
|
|
1029
|
+
| ELASTIC_SEARCH_PASSWORD | Password for authentication | ✓ |
|
|
1030
|
+
| ELASTIC_SEARCH_MIGRATION_PATH | Absolute path to migrations directory | ✓ |
|
|
1031
|
+
| TLS_CA | TLS certificate authority for SSL/TLS | ✗ |
|
|
1032
|
+
|
|
1033
|
+
#### Configuration
|
|
1034
|
+
|
|
1035
|
+
The client is configured with:
|
|
1036
|
+
- **Max Retries**: 3 attempts
|
|
1037
|
+
- **Request Timeout**: 30 seconds
|
|
1038
|
+
- **Sniff on Start**: Disabled (use with clusters)
|
|
1039
|
+
- **TLS**: Enabled when `TLS_CA` is provided
|
|
1040
|
+
|
|
1041
|
+
---
|
|
1042
|
+
|
|
1043
|
+
### GoogleCloudStorage
|
|
1044
|
+
|
|
1045
|
+
The GoogleCloudStorage entity provides a simple, generic wrapper for Google Cloud Storage operations. It handles file uploads, downloads, streaming, and deletion with built-in size validation and automatic file path generation.
|
|
1046
|
+
|
|
1047
|
+
Based on [@google-cloud/storage](https://cloud.google.com/nodejs/docs/reference/storage/latest) v7.14.0.
|
|
1048
|
+
|
|
1049
|
+
```javascript
|
|
1050
|
+
import { GoogleCloudStorage } from 'node-server-engine';
|
|
1051
|
+
import fs from 'fs';
|
|
1052
|
+
|
|
1053
|
+
// Initialize with configuration
|
|
1054
|
+
GoogleCloudStorage.init({
|
|
1055
|
+
projectId: 'my-project',
|
|
1056
|
+
keyFilename: '/path/to/keyfile.json'
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
// Or use environment variables (GC_PROJECT, GOOGLE_APPLICATION_CREDENTIALS)
|
|
1060
|
+
GoogleCloudStorage.init();
|
|
1061
|
+
|
|
1062
|
+
// Upload a file
|
|
1063
|
+
const fileStream = fs.createReadStream('photo.jpg');
|
|
1064
|
+
const result = await GoogleCloudStorage.upload(
|
|
1065
|
+
fileStream,
|
|
1066
|
+
'my-bucket',
|
|
1067
|
+
{ directory: 'photos', mime: 'image/jpeg' },
|
|
1068
|
+
{ metadata: { contentType: 'image/jpeg' } },
|
|
1069
|
+
{ maxSize: '5MB' }
|
|
1070
|
+
);
|
|
1071
|
+
console.log(result.name); // photos/uuid.jpeg
|
|
1072
|
+
|
|
1073
|
+
// Download file as Buffer
|
|
1074
|
+
const { data, metadata } = await GoogleCloudStorage.get('my-bucket', 'photos/image.jpg');
|
|
1075
|
+
console.log(metadata.size); // File size in bytes
|
|
1076
|
+
fs.writeFileSync('downloaded.jpg', data);
|
|
1077
|
+
|
|
1078
|
+
// Stream a file (for large files)
|
|
1079
|
+
const { stream, metadata } = await GoogleCloudStorage.download('my-bucket', 'videos/video.mp4');
|
|
1080
|
+
stream.pipe(res); // Stream to HTTP response
|
|
1081
|
+
|
|
1082
|
+
// Get file stream directly (fastest, no metadata)
|
|
1083
|
+
const stream = GoogleCloudStorage.getFileStream('my-bucket', 'audio/song.mp3');
|
|
1084
|
+
stream.pipe(res);
|
|
1085
|
+
|
|
1086
|
+
// Delete a file
|
|
1087
|
+
await GoogleCloudStorage.delete('my-bucket', 'temp/old-file.txt');
|
|
1088
|
+
|
|
1089
|
+
// Generate unique file paths
|
|
1090
|
+
const path = GoogleCloudStorage.generateFileDestination({
|
|
1091
|
+
directory: 'uploads/images',
|
|
1092
|
+
mime: 'image/jpeg'
|
|
1093
|
+
});
|
|
1094
|
+
// Result: 'uploads/images/a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpeg'
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
#### Features
|
|
1098
|
+
|
|
1099
|
+
- **Auto-initialization**: Automatically initializes on first use if not manually initialized
|
|
1100
|
+
- **Flexible Configuration**: Supports config object, environment variables, or credentials
|
|
1101
|
+
- **File Upload**: Stream-based uploads with size validation
|
|
1102
|
+
- **Multiple Download Methods**: Full download, streaming, or direct stream access
|
|
1103
|
+
- **Path Generation**: Automatic UUID-based file naming with directory and extension support
|
|
1104
|
+
- **Size Validation**: Built-in file size limits with human-readable formats (5MB, 100KB, etc.)
|
|
1105
|
+
- **Error Handling**: Detailed error reporting with context
|
|
1106
|
+
- **No Project Dependencies**: Generic implementation works with any Google Cloud Storage bucket
|
|
1107
|
+
|
|
1108
|
+
#### API Methods
|
|
1109
|
+
|
|
1110
|
+
##### `init(config?)`
|
|
1111
|
+
|
|
1112
|
+
Initialize Google Cloud Storage with configuration. Optional - will auto-initialize with environment variables if not called.
|
|
1113
|
+
|
|
1114
|
+
```javascript
|
|
1115
|
+
GoogleCloudStorage.init({
|
|
1116
|
+
projectId: 'my-project-id',
|
|
1117
|
+
keyFilename: '/path/to/service-account-key.json',
|
|
1118
|
+
// Or use credentials directly
|
|
1119
|
+
credentials: {
|
|
1120
|
+
client_email: 'service@project.iam.gserviceaccount.com',
|
|
1121
|
+
private_key: '-----BEGIN PRIVATE KEY-----\n...'
|
|
1122
|
+
},
|
|
1123
|
+
// For local emulator
|
|
1124
|
+
apiEndpoint: 'http://localhost:9000'
|
|
1125
|
+
});
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
##### `upload(stream, bucket, destinationOptions?, storageOptions?, uploaderOptions?)`
|
|
1129
|
+
|
|
1130
|
+
Upload a file to Google Cloud Storage.
|
|
1131
|
+
|
|
1132
|
+
**Parameters:**
|
|
1133
|
+
- `stream` (Readable): Node.js readable stream of file content
|
|
1134
|
+
- `bucket` (string): Bucket name
|
|
1135
|
+
- `destinationOptions` (object, optional):
|
|
1136
|
+
- `directory` (string): Subdirectory path (e.g., 'uploads/images')
|
|
1137
|
+
- `fileName` (string): Specific filename (if not provided, generates UUID)
|
|
1138
|
+
- `mime` (string): MIME type for extension detection
|
|
1139
|
+
- `noExtension` (boolean): Don't append file extension
|
|
1140
|
+
- `storageOptions` (object, optional): Google Cloud Storage write stream options
|
|
1141
|
+
- `uploaderOptions` (object, optional):
|
|
1142
|
+
- `maxSize` (string): Maximum file size (e.g., '5MB', '100KB', '1GB')
|
|
1143
|
+
|
|
1144
|
+
**Returns:** Promise\<StorageUploadedFile> - Metadata of uploaded file
|
|
1145
|
+
|
|
1146
|
+
**Example:**
|
|
1147
|
+
```javascript
|
|
1148
|
+
const fileStream = fs.createReadStream('document.pdf');
|
|
1149
|
+
const result = await GoogleCloudStorage.upload(
|
|
1150
|
+
fileStream,
|
|
1151
|
+
'documents-bucket',
|
|
1152
|
+
{ directory: 'legal/contracts', mime: 'application/pdf' },
|
|
1153
|
+
{ metadata: { contentType: 'application/pdf' } },
|
|
1154
|
+
{ maxSize: '10MB' }
|
|
1155
|
+
);
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
##### `get(bucket, path)`
|
|
1159
|
+
|
|
1160
|
+
Download a file and return its content as a Buffer along with metadata.
|
|
1161
|
+
|
|
1162
|
+
**Parameters:**
|
|
1163
|
+
- `bucket` (string): Bucket name
|
|
1164
|
+
- `path` (string): File path in the bucket
|
|
1165
|
+
|
|
1166
|
+
**Returns:** Promise\<{data: Buffer, metadata: StorageUploadedFile}>
|
|
1167
|
+
|
|
1168
|
+
**Example:**
|
|
1169
|
+
```javascript
|
|
1170
|
+
const { data, metadata } = await GoogleCloudStorage.get('my-bucket', 'photos/image.jpg');
|
|
1171
|
+
console.log(metadata.contentType); // 'image/jpeg'
|
|
1172
|
+
console.log(data.length); // File size in bytes
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
##### `download(bucket, path)`
|
|
1176
|
+
|
|
1177
|
+
Get a readable stream for a file along with its metadata. Use this for large files or when you need to stream content.
|
|
1178
|
+
|
|
1179
|
+
**Parameters:**
|
|
1180
|
+
- `bucket` (string): Bucket name
|
|
1181
|
+
- `path` (string): File path in the bucket
|
|
1182
|
+
|
|
1183
|
+
**Returns:** Promise\<{stream: Readable, metadata: StorageUploadedFile}>
|
|
1184
|
+
|
|
1185
|
+
**Example:**
|
|
1186
|
+
```javascript
|
|
1187
|
+
const { stream, metadata } = await GoogleCloudStorage.download('my-bucket', 'videos/large-video.mp4');
|
|
1188
|
+
console.log(metadata.size); // File size
|
|
1189
|
+
stream.pipe(response); // Stream to HTTP response
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
##### `getFileStream(bucket, path)`
|
|
1193
|
+
|
|
1194
|
+
Get a readable stream for a file without fetching metadata. Fastest option when metadata is not needed.
|
|
1195
|
+
|
|
1196
|
+
**Parameters:**
|
|
1197
|
+
- `bucket` (string): Bucket name
|
|
1198
|
+
- `path` (string): File path in the bucket
|
|
1199
|
+
|
|
1200
|
+
**Returns:** Readable - Node.js readable stream
|
|
1201
|
+
|
|
1202
|
+
**Example:**
|
|
1203
|
+
```javascript
|
|
1204
|
+
const stream = GoogleCloudStorage.getFileStream('my-bucket', 'audio/song.mp3');
|
|
1205
|
+
stream.pipe(response); // Direct streaming
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
##### `delete(bucket, path)`
|
|
1209
|
+
|
|
1210
|
+
Delete a file from Google Cloud Storage.
|
|
1211
|
+
|
|
1212
|
+
**Parameters:**
|
|
1213
|
+
- `bucket` (string): Bucket name
|
|
1214
|
+
- `path` (string): File path in the bucket
|
|
1215
|
+
|
|
1216
|
+
**Returns:** Promise\<void>
|
|
1217
|
+
|
|
1218
|
+
**Example:**
|
|
1219
|
+
```javascript
|
|
1220
|
+
await GoogleCloudStorage.delete('my-bucket', 'temp/old-file.txt');
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
##### `generateFileDestination(options?)`
|
|
1224
|
+
|
|
1225
|
+
Generate a unique file path with optional directory and extension.
|
|
1226
|
+
|
|
1227
|
+
**Parameters:**
|
|
1228
|
+
- `options` (object, optional):
|
|
1229
|
+
- `directory` (string): Subdirectory path
|
|
1230
|
+
- `mime` (string): MIME type for extension detection
|
|
1231
|
+
- `noExtension` (boolean): Don't append extension
|
|
1232
|
+
|
|
1233
|
+
**Returns:** string - Generated file path
|
|
1234
|
+
|
|
1235
|
+
**Examples:**
|
|
1236
|
+
```javascript
|
|
1237
|
+
// UUID only
|
|
1238
|
+
GoogleCloudStorage.generateFileDestination();
|
|
1239
|
+
// → 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
|
1240
|
+
|
|
1241
|
+
// With directory and MIME type
|
|
1242
|
+
GoogleCloudStorage.generateFileDestination({
|
|
1243
|
+
directory: 'uploads/images',
|
|
1244
|
+
mime: 'image/jpeg'
|
|
1245
|
+
});
|
|
1246
|
+
// → 'uploads/images/a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpeg'
|
|
1247
|
+
|
|
1248
|
+
// Without extension
|
|
1249
|
+
GoogleCloudStorage.generateFileDestination({
|
|
1250
|
+
directory: 'data',
|
|
1251
|
+
noExtension: true
|
|
1252
|
+
});
|
|
1253
|
+
// → 'data/a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
|
1254
|
+
```
|
|
1255
|
+
|
|
1256
|
+
#### Environment Variables
|
|
1257
|
+
|
|
1258
|
+
| Variable | Description | Required |
|
|
1259
|
+
| ------------------------------- | ------------------------------------ | -------- |
|
|
1260
|
+
| GC_PROJECT | Google Cloud Project ID | ✗* |
|
|
1261
|
+
| GOOGLE_APPLICATION_CREDENTIALS | Path to service account key file | ✗* |
|
|
1262
|
+
|
|
1263
|
+
\* Not required if you call `init()` with a config object
|
|
1264
|
+
|
|
1265
|
+
#### Error Handling
|
|
1266
|
+
|
|
1267
|
+
The entity throws `WebError` with appropriate status codes:
|
|
1268
|
+
|
|
1269
|
+
- **413 (Payload Too Large)**: File exceeds `maxSize` limit
|
|
1270
|
+
- Other errors are passed through from Google Cloud Storage SDK
|
|
1271
|
+
|
|
1272
|
+
**Example:**
|
|
1273
|
+
```javascript
|
|
1274
|
+
try {
|
|
1275
|
+
await GoogleCloudStorage.upload(stream, 'bucket', {}, {}, { maxSize: '1MB' });
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
if (error.statusCode === 413) {
|
|
1278
|
+
console.log('File too large!');
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
```
|
|
1282
|
+
|
|
1283
|
+
#### Usage in Template Projects
|
|
1284
|
+
|
|
1285
|
+
In your node-server-template endpoints:
|
|
1286
|
+
|
|
1287
|
+
```javascript
|
|
1288
|
+
import { GoogleCloudStorage } from 'node-server-engine';
|
|
1289
|
+
import { Endpoint, Method } from 'node-server-engine';
|
|
1290
|
+
|
|
1291
|
+
new Endpoint({
|
|
1292
|
+
path: '/upload',
|
|
1293
|
+
method: Method.POST,
|
|
1294
|
+
files: [{ key: 'file', maxSize: '5MB', required: true }],
|
|
1295
|
+
async handler(req, res) {
|
|
1296
|
+
const file = req.files[0];
|
|
1297
|
+
const stream = Readable.from(file.buffer);
|
|
1298
|
+
|
|
1299
|
+
const result = await GoogleCloudStorage.upload(
|
|
1300
|
+
stream,
|
|
1301
|
+
process.env.UPLOAD_BUCKET,
|
|
1302
|
+
{ directory: 'user-uploads', mime: file.mimetype }
|
|
1303
|
+
);
|
|
1304
|
+
|
|
1305
|
+
res.json({ path: result.name, url: result.mediaLink });
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
```
|
|
701
1309
|
|
|
702
1310
|
---
|
|
703
1311
|
|
|
@@ -980,56 +1588,93 @@ The file's data will be available at `req.body.fileUri req.body.mimeType req.bo
|
|
|
980
1588
|
|
|
981
1589
|
### Check Permission Middleware
|
|
982
1590
|
|
|
983
|
-
|
|
1591
|
+
⚠️ **Must be used in combination with AuthType.JWT**
|
|
984
1592
|
|
|
985
|
-
|
|
1593
|
+
Role-based access control middleware that checks if the authenticated user has at least one of the required permissions. All permission checks are **case-insensitive** for maximum flexibility.
|
|
1594
|
+
|
|
1595
|
+
#### Features
|
|
1596
|
+
|
|
1597
|
+
- ✅ Single or multiple permission checking
|
|
1598
|
+
- ✅ Case-insensitive permission matching
|
|
1599
|
+
- ✅ Integration with JWT authentication
|
|
1600
|
+
- ✅ Clear error messages for debugging
|
|
1601
|
+
- ✅ TypeScript support
|
|
1602
|
+
|
|
1603
|
+
#### Basic Usage
|
|
986
1604
|
|
|
987
1605
|
```javascript
|
|
988
1606
|
import { Endpoint, middleware, AuthType, Method } from 'node-server-engine';
|
|
989
1607
|
|
|
1608
|
+
// Single permission check
|
|
1609
|
+
new Endpoint({
|
|
1610
|
+
path: '/users',
|
|
1611
|
+
method: Method.GET,
|
|
1612
|
+
authType: AuthType.JWT,
|
|
1613
|
+
middleware: [middleware.checkPermission('users:read')],
|
|
1614
|
+
handler: (req, res) => {
|
|
1615
|
+
res.json({ message: 'User list' });
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
// Multiple permissions (user needs at least ONE)
|
|
990
1620
|
new Endpoint({
|
|
991
1621
|
path: '/admin',
|
|
992
1622
|
method: Method.GET,
|
|
993
1623
|
authType: AuthType.JWT,
|
|
994
|
-
middleware: [middleware.checkPermission(['admin', 'superuser'])],
|
|
1624
|
+
middleware: [middleware.checkPermission(['admin', 'superuser', 'moderator'])],
|
|
995
1625
|
handler: (req, res) => {
|
|
996
|
-
res.json({ message: '
|
|
1626
|
+
res.json({ message: 'Admin access granted' });
|
|
997
1627
|
}
|
|
998
1628
|
});
|
|
999
1629
|
```
|
|
1000
1630
|
|
|
1631
|
+
#### User Object Structure
|
|
1632
|
+
|
|
1633
|
+
The middleware expects `req.user` to contain a `permissions` array:
|
|
1634
|
+
|
|
1001
1635
|
```typescript
|
|
1002
|
-
|
|
1636
|
+
interface User {
|
|
1637
|
+
id: string;
|
|
1638
|
+
permissions: string[]; // e.g., ['users:read', 'users:write', 'admin']
|
|
1639
|
+
}
|
|
1640
|
+
```
|
|
1003
1641
|
|
|
1004
|
-
|
|
1005
|
-
export const checkPermission = (requiredActions: string | string[]) => {
|
|
1006
|
-
return (req: Request, res: Response, next: NextFunction) => {
|
|
1007
|
-
const user = req.user;
|
|
1642
|
+
#### Examples
|
|
1008
1643
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
}
|
|
1644
|
+
```javascript
|
|
1645
|
+
// Case-insensitive matching
|
|
1646
|
+
checkPermission('ADMIN') // Matches: 'admin', 'Admin', 'ADMIN'
|
|
1647
|
+
checkPermission(['READ', 'write']) // Matches any case variation
|
|
1014
1648
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1649
|
+
// Namespace-style permissions
|
|
1650
|
+
checkPermission('users:read')
|
|
1651
|
+
checkPermission(['users:write', 'users:delete'])
|
|
1018
1652
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1653
|
+
// Role-based permissions
|
|
1654
|
+
checkPermission(['admin', 'moderator'])
|
|
1655
|
+
```
|
|
1022
1656
|
|
|
1023
|
-
|
|
1024
|
-
requiredActionsLower.includes(permission.toLowerCase())
|
|
1025
|
-
);
|
|
1657
|
+
#### Response Codes
|
|
1026
1658
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1659
|
+
- **200**: Permission granted, request proceeds
|
|
1660
|
+
- **403**: Permission denied
|
|
1661
|
+
- No user authenticated
|
|
1662
|
+
- User has no permissions array
|
|
1663
|
+
- User lacks required permission(s)
|
|
1664
|
+
|
|
1665
|
+
#### Error Responses
|
|
1030
1666
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1667
|
+
```json
|
|
1668
|
+
{
|
|
1669
|
+
"message": "User does not have permissions"
|
|
1670
|
+
}
|
|
1671
|
+
```
|
|
1672
|
+
|
|
1673
|
+
```json
|
|
1674
|
+
{
|
|
1675
|
+
"message": "Permission denied"
|
|
1676
|
+
}
|
|
1677
|
+
```
|
|
1033
1678
|
};
|
|
1034
1679
|
```
|
|
1035
1680
|
|