s3 impl
This commit is contained in:
112
CODING_STANDARDS.md
Normal file
112
CODING_STANDARDS.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Coding Standards
|
||||||
|
|
||||||
|
## Code Structure
|
||||||
|
|
||||||
|
### No Else Blocks - Use Early Returns
|
||||||
|
|
||||||
|
Always prefer early returns and guard clauses over else blocks. This reduces nesting and improves readability.
|
||||||
|
|
||||||
|
**❌ Bad:**
|
||||||
|
```typescript
|
||||||
|
if (condition) {
|
||||||
|
// do something
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
// do something else
|
||||||
|
return otherResult;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Good:**
|
||||||
|
```typescript
|
||||||
|
if (condition) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return otherResult;
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ Bad:**
|
||||||
|
```typescript
|
||||||
|
if (error) {
|
||||||
|
throw new Error('Failed');
|
||||||
|
} else {
|
||||||
|
return processData();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Good:**
|
||||||
|
```typescript
|
||||||
|
if (error) {
|
||||||
|
throw new Error('Failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return processData();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Conditions
|
||||||
|
|
||||||
|
For multiple conditions, stack guard clauses:
|
||||||
|
|
||||||
|
**❌ Bad:**
|
||||||
|
```typescript
|
||||||
|
if (cas !== undefined) {
|
||||||
|
if (cas === 0 && existing) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
if (cas > 0 && (!existing || existing.modifyIndex !== cas)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Good:**
|
||||||
|
```typescript
|
||||||
|
if (cas === 0 && existing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cas > 0 && (!existing || existing.modifyIndex !== cas)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### No Unnecessary Comments
|
||||||
|
|
||||||
|
Avoid conversational comments like "let's", "now we", etc. Code should be self-documenting through good naming.
|
||||||
|
|
||||||
|
**❌ Bad:**
|
||||||
|
```typescript
|
||||||
|
// Let's check if the user exists
|
||||||
|
const user = await findUser(id);
|
||||||
|
|
||||||
|
// Now let's validate the permissions
|
||||||
|
if (!user.hasPermission()) {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Good:**
|
||||||
|
```typescript
|
||||||
|
const user = await findUser(id);
|
||||||
|
|
||||||
|
if (!user.hasPermission()) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Comments should explain **why**, not **what**.
|
||||||
|
|
||||||
|
**✅ Acceptable:**
|
||||||
|
```typescript
|
||||||
|
// Consul API requires plain text "true"/"false" responses, not JSON
|
||||||
|
return success.toString();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Reduced Nesting**: Flatter code structure
|
||||||
|
2. **Better Readability**: Main logic flow is clear
|
||||||
|
3. **Easier Maintenance**: Less cognitive load
|
||||||
|
4. **Clearer Intent**: Guard clauses make preconditions explicit
|
||||||
257
CONSUL_BLOCKING_QUERIES.md
Normal file
257
CONSUL_BLOCKING_QUERIES.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# Consul KV Blocking Queries
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Consul KV service now supports blocking queries (also known as long polling), which allows clients to efficiently wait for changes to keys without continuous polling.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
When a client makes a GET request with blocking query parameters, the request will:
|
||||||
|
|
||||||
|
1. **Return immediately** if the key's ModifyIndex is greater than the provided index
|
||||||
|
2. **Block and wait** if the index matches the current value, until either:
|
||||||
|
- The key is updated (new ModifyIndex)
|
||||||
|
- The wait timeout expires
|
||||||
|
3. **Return the current state** when either condition is met
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
- `index` - The ModifyIndex from a previous request. The query will block until the key's index changes
|
||||||
|
- `wait` - Duration to wait for changes (e.g., `5s`, `10m`, `1h`). Defaults to 5 minutes if not specified
|
||||||
|
|
||||||
|
### Example: Basic Blocking Query
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get the current state
|
||||||
|
curl http://localhost:8500/v1/kv/my-key
|
||||||
|
|
||||||
|
# Response includes ModifyIndex
|
||||||
|
# [{"CreateIndex":1,"ModifyIndex":5,"Key":"my-key","Value":"..."}]
|
||||||
|
|
||||||
|
# Block until the key changes, up to 30 seconds
|
||||||
|
curl "http://localhost:8500/v1/kv/my-key?index=5&wait=30s"
|
||||||
|
|
||||||
|
# This request will:
|
||||||
|
# - Return immediately if ModifyIndex > 5
|
||||||
|
# - Wait up to 30s if ModifyIndex == 5
|
||||||
|
# - Return with new value when key is updated
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Watch Loop in JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function watchKey(key) {
|
||||||
|
let currentIndex = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
// Make blocking query
|
||||||
|
const response = await fetch(`http://localhost:8500/v1/kv/${key}?index=${currentIndex}&wait=60s`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const newIndex = data[0].ModifyIndex;
|
||||||
|
|
||||||
|
// Check if the value actually changed
|
||||||
|
if (newIndex > currentIndex) {
|
||||||
|
console.log('Key changed:', data[0]);
|
||||||
|
currentIndex = newIndex;
|
||||||
|
|
||||||
|
// Process the change
|
||||||
|
handleChange(data[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Watch error:', error);
|
||||||
|
await new Promise(r => setTimeout(r, 1000)); // Backoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Terraform State Watching
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Watch for Terraform state changes
|
||||||
|
async function watchTerraformState(statePath) {
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const response = await fetch(`http://localhost:8500/v1/kv/${statePath}?index=${index}&wait=5m`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const [entry] = await response.json();
|
||||||
|
|
||||||
|
if (entry.ModifyIndex > index) {
|
||||||
|
const stateData = JSON.parse(Buffer.from(entry.Value, 'base64').toString('utf-8'));
|
||||||
|
|
||||||
|
console.log('Terraform state updated:', {
|
||||||
|
version: stateData.version,
|
||||||
|
serial: stateData.serial,
|
||||||
|
modifyIndex: entry.ModifyIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
index = entry.ModifyIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watchTerraformState('terraform/myproject/state');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Concurrent Watchers
|
||||||
|
|
||||||
|
Multiple clients can simultaneously watch the same key. When the key is updated, all waiting requests will be notified and return with the new value.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Minimal server overhead - no continuous polling
|
||||||
|
- Automatic cleanup of watchers on timeout
|
||||||
|
- Notifications are delivered immediately when changes occur
|
||||||
|
- No database polling - uses in-memory event system
|
||||||
|
|
||||||
|
### Wait Durations
|
||||||
|
|
||||||
|
Supported time units:
|
||||||
|
|
||||||
|
- `s` - seconds (e.g., `30s`)
|
||||||
|
- `m` - minutes (e.g., `5m`)
|
||||||
|
- `h` - hours (e.g., `1h`)
|
||||||
|
|
||||||
|
Default: 5 minutes if not specified
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
#### Non-existent Keys
|
||||||
|
|
||||||
|
Blocking queries on non-existent keys with `index=0` will return 404 immediately. To wait for a key to be created, you need to handle 404 responses and retry.
|
||||||
|
|
||||||
|
#### Key Deletion
|
||||||
|
|
||||||
|
If a watched key is deleted, waiting queries will be notified and return 404.
|
||||||
|
|
||||||
|
#### Recursive Queries
|
||||||
|
|
||||||
|
Blocking queries do not currently support `recurse=true`. Use blocking queries only on specific keys.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The test suite includes comprehensive blocking query tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- consul-kv.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests verify:
|
||||||
|
|
||||||
|
- ✅ Immediate return when index is outdated
|
||||||
|
- ✅ Blocking until key changes
|
||||||
|
- ✅ Timeout behavior when no changes occur
|
||||||
|
- ✅ Multiple concurrent watchers on same key
|
||||||
|
- ✅ Non-existent key handling
|
||||||
|
|
||||||
|
## Comparison with Real Consul
|
||||||
|
|
||||||
|
This implementation matches Consul's blocking query behavior for:
|
||||||
|
|
||||||
|
- Standard wait/index semantics
|
||||||
|
- Multiple concurrent watchers
|
||||||
|
- Timeout handling
|
||||||
|
- Immediate return on index mismatch
|
||||||
|
|
||||||
|
Differences from real Consul:
|
||||||
|
|
||||||
|
- No support for `recurse` with blocking queries
|
||||||
|
- Simplified implementation (in-memory events vs raft log)
|
||||||
|
- Wait times capped at request timeout (no separate max wait limit)
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### Configuration Management
|
||||||
|
|
||||||
|
Watch for configuration changes and reload application settings:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function watchConfig(configKey) {
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const response = await fetch(`http://localhost:8500/v1/kv/${configKey}?index=${index}&wait=5m`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const [entry] = await response.json();
|
||||||
|
if (entry.ModifyIndex > index) {
|
||||||
|
const config = JSON.parse(Buffer.from(entry.Value, 'base64').toString('utf-8'));
|
||||||
|
reloadConfig(config);
|
||||||
|
index = entry.ModifyIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Coordination
|
||||||
|
|
||||||
|
Wait for another service to signal readiness:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function waitForService(serviceKey) {
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const response = await fetch(`http://localhost:8500/v1/kv/${serviceKey}/ready?index=${index}&wait=1m`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const [entry] = await response.json();
|
||||||
|
const value = Buffer.from(entry.Value, 'base64').toString('utf-8');
|
||||||
|
|
||||||
|
if (value === 'true') {
|
||||||
|
console.log('Service is ready!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
index = entry.ModifyIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Change Notifications
|
||||||
|
|
||||||
|
Monitor Terraform state for external changes:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function monitorTerraformState(statePath) {
|
||||||
|
let lastSerial = 0;
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const response = await fetch(`http://localhost:8500/v1/kv/${statePath}?index=${index}&wait=10m`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const [entry] = await response.json();
|
||||||
|
|
||||||
|
if (entry.ModifyIndex > index) {
|
||||||
|
const state = JSON.parse(Buffer.from(entry.Value, 'base64').toString('utf-8'));
|
||||||
|
|
||||||
|
if (state.serial > lastSerial) {
|
||||||
|
console.log(`State changed: serial ${lastSerial} → ${state.serial}`);
|
||||||
|
lastSerial = state.serial;
|
||||||
|
|
||||||
|
// Notify about drift
|
||||||
|
if (await detectDrift(state)) {
|
||||||
|
notifyTeam('Terraform drift detected!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
index = entry.ModifyIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
812
PROJECT_STRUCTURE.md
Normal file
812
PROJECT_STRUCTURE.md
Normal file
@@ -0,0 +1,812 @@
|
|||||||
|
# Local AWS - Project Structure Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Local AWS is a NestJS-based emulator for AWS services. It implements AWS APIs locally for development and testing purposes, using SQLite for persistence and following AWS API specifications.
|
||||||
|
|
||||||
|
**Key Architecture Points:**
|
||||||
|
|
||||||
|
- **Handler Pattern**: Each AWS action is implemented as a separate handler class
|
||||||
|
- **Modular Structure**: One module per AWS service (IAM, KMS, S3, SNS, SQS, etc.)
|
||||||
|
- **Multi-Server Architecture**: Main app + separate S3 microservice + Consul KV microservice
|
||||||
|
- S3 uses REST-style routing
|
||||||
|
- Consul KV implements HashiCorp Consul API
|
||||||
|
- **Database**: SQLite with Prisma ORM
|
||||||
|
- **Test Structure**: Each module has its own test suite using AWS SDK or service-specific clients
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Application Entry Points
|
||||||
|
|
||||||
|
### Main Entry (`src/main.ts`)
|
||||||
|
|
||||||
|
Bootstraps two NestJS applications:
|
||||||
|
|
||||||
|
1. **Main Application** (default port 8081)
|
||||||
|
|
||||||
|
- Handles IAM, KMS, SecretsManager, SNS, SQS, STS services
|
||||||
|
- Uses query-based routing (Action parameter in body/query)
|
||||||
|
- Body parsers: JSON (for SNS/SQS), XML, raw binary
|
||||||
|
|
||||||
|
2. **S3 Microservice** (default port 4567)
|
||||||
|
|
||||||
|
- Separate app due to S3's REST/path-style routing
|
||||||
|
- Routes based on HTTP method + path + query parameters
|
||||||
|
- Body parsers: raw binary, URL-encoded
|
||||||
|
|
||||||
|
3. **Consul KV Microservice** (default port 8500)
|
||||||
|
- Implements HashiCorp Consul Key-Value store API
|
||||||
|
- REST-style routing at `/v1/kv/*` endpoints
|
||||||
|
- Supports recursive queries, CAS operations, locks, flags
|
||||||
|
- Body parsers: JSON, raw binary, text/plain
|
||||||
|
|
||||||
|
**Key Global Features:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
Date.prototype.getAwsTime(); // Extension for AWS timestamp format
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module Architecture
|
||||||
|
|
||||||
|
### Module Structure Pattern
|
||||||
|
|
||||||
|
Each AWS service follows this structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
<service>/
|
||||||
|
├── __tests__/
|
||||||
|
│ └── <service>.spec.ts # Integration tests using AWS SDK
|
||||||
|
├── <action>-handler.ts # One handler per AWS action
|
||||||
|
├── <service>.constants.ts # Action enum list & injection tokens
|
||||||
|
├── <service>.module.ts # NestJS module definition
|
||||||
|
├── <service>.service.ts # Business logic layer
|
||||||
|
├── <service>-<entity>.entity.ts # Prisma model wrapper classes
|
||||||
|
└── (optional) <service>.controller.ts # Custom controller if needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Current Modules
|
||||||
|
|
||||||
|
#### Main Application Modules
|
||||||
|
|
||||||
|
1. **IAM** (`src/iam/`)
|
||||||
|
|
||||||
|
- Handlers: CreateRole, GetRole, DeleteRole, CreatePolicy, GetPolicy, AttachRolePolicy, PutRolePolicy, GetRolePolicy, etc.
|
||||||
|
- Entities: IamRole, IamPolicy, IamRoleInlinePolicy
|
||||||
|
- Database: Roles, policies, role-policy attachments, inline policies
|
||||||
|
|
||||||
|
2. **KMS** (`src/kms/`)
|
||||||
|
|
||||||
|
- Handlers: CreateKey, DescribeKey, CreateAlias, Sign, GetPublicKey, EnableKeyRotation, etc.
|
||||||
|
- Entities: KmsKey, KmsAlias
|
||||||
|
- Database: KMS keys, aliases
|
||||||
|
|
||||||
|
3. **Secrets Manager** (`src/secrets-manager/`)
|
||||||
|
|
||||||
|
- Handlers: CreateSecret, GetSecretValue, PutSecretValue, DeleteSecret, etc.
|
||||||
|
- Database: Secrets with versioning
|
||||||
|
|
||||||
|
4. **SNS** (`src/sns/`)
|
||||||
|
|
||||||
|
- Handlers: CreateTopic, Subscribe, Publish, etc.
|
||||||
|
- Database: Topics, subscriptions
|
||||||
|
|
||||||
|
5. **SQS** (`src/sqs/`)
|
||||||
|
|
||||||
|
- Handlers: CreateQueue, SendMessage, ReceiveMessage, etc.
|
||||||
|
- Database: Queues, messages
|
||||||
|
|
||||||
|
6. **STS** (`src/sts/`)
|
||||||
|
- Handlers: AssumeRole, GetCallerIdentity, etc.
|
||||||
|
- Temporary credential generation
|
||||||
|
|
||||||
|
#### S3 Microservice Module
|
||||||
|
|
||||||
|
**S3** (`src/s3/`)
|
||||||
|
|
||||||
|
- Special module with its own app (`S3AppModule`) and controller
|
||||||
|
- Handlers: CreateBucket, ListBuckets, PutObject, GetObject, DeleteObject, PutBucketAcl, GetBucketAcl, etc.
|
||||||
|
- Entities: S3Bucket, S3Object
|
||||||
|
- Custom routing logic in `S3Controller` based on HTTP method + path + query params
|
||||||
|
- Database: Buckets with tags/ACL/policy, objects with metadata
|
||||||
|
|
||||||
|
#### Consul KV Microservice Module
|
||||||
|
|
||||||
|
**Consul KV** (`src/consul-kv/`)
|
||||||
|
|
||||||
|
- Special module with its own app (`ConsulKVAppModule`) and controller
|
||||||
|
- Implements HashiCorp Consul KV API spec: https://developer.hashicorp.com/consul/api-docs/kv
|
||||||
|
- Service: ConsulKVService with getKey, putKey, deleteKey, listKeys, recursive operations
|
||||||
|
- Custom routing in `ConsulKVController` for `/v1/kv/*` paths
|
||||||
|
- Features:
|
||||||
|
- Key-value storage with base64 encoding
|
||||||
|
- Recursive queries with prefix matching
|
||||||
|
- Keys-only listing with separator support
|
||||||
|
- Check-And-Set (CAS) operations
|
||||||
|
- Distributed lock acquisition/release
|
||||||
|
- Flags and index tracking (createIndex, modifyIndex, lockIndex)
|
||||||
|
- Multi-tenancy support (datacenter, namespace)
|
||||||
|
- Database: ConsulKVEntry model
|
||||||
|
- Tests: 19 integration tests using `consul` npm package
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handler Pattern Deep Dive
|
||||||
|
|
||||||
|
### AbstractActionHandler (`src/abstract-action.handler.ts`)
|
||||||
|
|
||||||
|
Base class for all action handlers. All handlers must extend this class.
|
||||||
|
|
||||||
|
**Required Properties:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
abstract class AbstractActionHandler<T> {
|
||||||
|
format: Format; // Format.Xml or Format.Json
|
||||||
|
action: Action | Action[]; // AWS action enum(s)
|
||||||
|
validator: Joi.ObjectSchema; // Request validation schema
|
||||||
|
|
||||||
|
protected abstract handle(
|
||||||
|
queryParams: T, // Validated query parameters
|
||||||
|
context: RequestContext, // AWS properties + request metadata
|
||||||
|
): Record<string, any> | void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Handling:**
|
||||||
|
|
||||||
|
- XML format: Wraps result in `{Action}Result` node with RequestId metadata
|
||||||
|
- JSON format: Returns result directly
|
||||||
|
- Empty response: Returns ResponseMetadata only (for XML) or nothing (for JSON)
|
||||||
|
|
||||||
|
### Handler Implementation Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Injectable()
|
||||||
|
export class CreateRoleHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly iamService: IamService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.IamCreateRole;
|
||||||
|
|
||||||
|
validator = Joi.object<QueryParams>({
|
||||||
|
RoleName: Joi.string().required(),
|
||||||
|
Path: Joi.string().required(),
|
||||||
|
AssumeRolePolicyDocument: Joi.string().required(),
|
||||||
|
// ... other parameters
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle(params: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const role = await this.iamService.createRole({
|
||||||
|
accountId: awsProperties.accountId,
|
||||||
|
name: params.RoleName,
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
return { Role: role.metadata };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handler Registration Flow
|
||||||
|
|
||||||
|
1. **Define handler class** extending `AbstractActionHandler`
|
||||||
|
2. **Add to module's handlers array** in `<service>.module.ts`
|
||||||
|
3. **ExistingActionHandlersProvider** collects all handlers into a map keyed by Action enum
|
||||||
|
4. **DefaultActionHandlerProvider** fills gaps with stub handlers for unimplemented actions
|
||||||
|
5. **Module provides injection token** (e.g., `IAMHandlers`, `S3Handlers`) containing the handler map
|
||||||
|
6. **Controller receives handler map** and routes requests to appropriate handler
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In module
|
||||||
|
const handlers = [CreateRoleHandler, GetRoleHandler, /* ... */];
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
...handlers,
|
||||||
|
ExistingActionHandlersProvider(handlers),
|
||||||
|
DefaultActionHandlerProvider(IAMHandlers, Format.Xml, allIamActions),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Controllers
|
||||||
|
|
||||||
|
### Main Controller (`src/app.controller.ts`)
|
||||||
|
|
||||||
|
Routes all non-S3 AWS service requests.
|
||||||
|
|
||||||
|
**Request Flow:**
|
||||||
|
|
||||||
|
1. Extracts action from `x-amz-target` header or `Action` body parameter
|
||||||
|
2. Validates action exists in Action enum
|
||||||
|
3. Looks up handler from injected ActionHandlers map
|
||||||
|
4. Validates request params using handler's Joi validator
|
||||||
|
5. Calls `handler.getResponse(params, context)`
|
||||||
|
6. Returns XML or JSON based on handler's format
|
||||||
|
|
||||||
|
### S3 Controller (`src/s3/s3.controller.ts`)
|
||||||
|
|
||||||
|
Custom controller for S3 REST-style API (separate microservice).
|
||||||
|
|
||||||
|
**Request Flow:**
|
||||||
|
|
||||||
|
1. Parses path into bucket/key components
|
||||||
|
2. Determines action from HTTP method + path + query parameters
|
||||||
|
- `PUT /{bucket}` → CreateBucket
|
||||||
|
- `GET /{bucket}` → ListObjects (or other sub-resource if query param present)
|
||||||
|
- `PUT /{bucket}?acl` → PutBucketAcl
|
||||||
|
- `GET /{bucket}/{key}` → GetObject
|
||||||
|
3. Normalizes query parameters (e.g., `max-keys` → `MaxKeys`)
|
||||||
|
4. Extracts metadata from `x-amz-meta-*` headers
|
||||||
|
5. Converts binary body to base64 for handlers
|
||||||
|
6. Routes to handler and returns response with appropriate headers
|
||||||
|
|
||||||
|
**Special S3 Response Handling:**
|
||||||
|
|
||||||
|
- Creates Location header for bucket creation
|
||||||
|
- Returns ETag, Last-Modified, Content-Type headers
|
||||||
|
- Returns raw binary data for GetObject (not XML/JSON)
|
||||||
|
- Returns empty 200 for PutBucketAcl, PutBucketTagging
|
||||||
|
- Returns 204 for DeleteBucket, DeleteObject
|
||||||
|
|
||||||
|
### Consul KV Controller (`src/consul-kv/consul-kv.controller.ts`)
|
||||||
|
|
||||||
|
Custom controller for Consul KV REST API (separate microservice).
|
||||||
|
|
||||||
|
**Request Flow:**
|
||||||
|
|
||||||
|
1. Catches all `/v1/kv/*` paths
|
||||||
|
2. Extracts and URL-decodes the key from the path
|
||||||
|
3. Routes based on HTTP method (GET/PUT/DELETE)
|
||||||
|
4. Parses query parameters:
|
||||||
|
- `dc`: datacenter (default: dc1)
|
||||||
|
- `ns`: namespace (default: default)
|
||||||
|
- `recurse`: recursive key retrieval
|
||||||
|
- `keys`: return only key names
|
||||||
|
- `raw`: return raw value (not JSON)
|
||||||
|
- `separator`: group keys by separator
|
||||||
|
- `flags`: numeric flags for application use
|
||||||
|
- `cas`: Check-And-Set with modify index
|
||||||
|
- `acquire`: session ID for lock acquisition
|
||||||
|
- `release`: session ID for lock release
|
||||||
|
5. Processes request through ConsulKVService
|
||||||
|
6. Returns appropriate response format
|
||||||
|
|
||||||
|
**Special Consul Response Handling:**
|
||||||
|
|
||||||
|
- PUT/DELETE return plain text "true" or "false"
|
||||||
|
- GET returns JSON array of key metadata or raw value
|
||||||
|
- Keys-only returns JSON array of key strings
|
||||||
|
- 404 status for non-existent keys
|
||||||
|
- Base64 encoding for values in JSON responses
|
||||||
|
- Supports keys with slashes (proper URL decoding)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service Layer
|
||||||
|
|
||||||
|
Each module has a service class that:
|
||||||
|
|
||||||
|
- Encapsulates business logic
|
||||||
|
- Interacts with Prisma for database operations
|
||||||
|
- Validates entity constraints
|
||||||
|
- Throws AWS-compatible exceptions
|
||||||
|
|
||||||
|
**Example: IAM Service** (`src/iam/iam.service.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Injectable()
|
||||||
|
export class IamService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async createRole(data: CreateRoleInput): Promise<IamRole> {
|
||||||
|
// Check for duplicates
|
||||||
|
// Create in database
|
||||||
|
// Return entity
|
||||||
|
}
|
||||||
|
|
||||||
|
async putRoleInlinePolicy(...): Promise<void> {
|
||||||
|
// Verify role exists
|
||||||
|
// Upsert policy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entity Layer
|
||||||
|
|
||||||
|
Entity classes wrap Prisma models and provide:
|
||||||
|
|
||||||
|
- Type safety
|
||||||
|
- Computed properties (e.g., ARNs, metadata formatters)
|
||||||
|
- Serialization logic
|
||||||
|
|
||||||
|
**Example: IAM Role Entity** (`src/iam/iam-role.entity.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class IamRole implements PrismaIamRole {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
accountId: string;
|
||||||
|
assumeRolePolicy: string;
|
||||||
|
// ...
|
||||||
|
|
||||||
|
get arn(): string {
|
||||||
|
return `arn:aws:iam::${this.accountId}:role${this.path}${this.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get metadata() {
|
||||||
|
// Returns AWS API response format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Layer
|
||||||
|
|
||||||
|
### Prisma Setup (`src/_prisma/`)
|
||||||
|
|
||||||
|
- **prisma.service.ts**: PrismaClient wrapper with connection lifecycle
|
||||||
|
- **prisma.module.ts**: Exports PrismaService globally
|
||||||
|
|
||||||
|
### Schema Location: `prisma/schema.prisma`
|
||||||
|
|
||||||
|
**Key Models:**
|
||||||
|
|
||||||
|
- `IamRole`, `IamPolicy`, `IamRoleIamPolicyAttachment`, `IamRoleInlinePolicy`
|
||||||
|
- `KmsKey`, `KmsAlias`
|
||||||
|
- `S3Bucket`, `S3Object`
|
||||||
|
- `SecretsManagerSecret`
|
||||||
|
- `SnsTopic`, `SnsSubscription`
|
||||||
|
- `SqsQueue`
|
||||||
|
- `ConsulKVEntry`
|
||||||
|
|
||||||
|
**Migrations:** `prisma/migrations/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Infrastructure
|
||||||
|
|
||||||
|
### AWS Shared Entities (`src/aws-shared-entities/`)
|
||||||
|
|
||||||
|
- **aws-exceptions.ts**: All AWS exception classes
|
||||||
|
- Base `AwsException` with `toXml()` and `toJson()` methods
|
||||||
|
- Specific exceptions: `NoSuchEntity`, `ValidationError`, `AccessDeniedException`, etc.
|
||||||
|
- **attributes.service.ts**: Common attribute parsing/validation
|
||||||
|
- **tags.service.ts**: Resource tagging utilities
|
||||||
|
|
||||||
|
### Request Context (`src/_context/`)
|
||||||
|
|
||||||
|
- **request.context.ts**: TypeScript interfaces for request context
|
||||||
|
```typescript
|
||||||
|
interface RequestContext {
|
||||||
|
action?: Action;
|
||||||
|
format?: Format;
|
||||||
|
awsProperties: AwsProperties; // accountId, region, host
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **exception.filter.ts**: Global exception filter for AWS exceptions
|
||||||
|
|
||||||
|
### Audit System (`src/audit/`)
|
||||||
|
|
||||||
|
- **audit.interceptor.ts**: Logs requests/responses (main app)
|
||||||
|
- **s3-audit.interceptor.ts**: Logs S3 requests/responses
|
||||||
|
- **audit.service.ts**: Audit persistence service (currently minimal)
|
||||||
|
|
||||||
|
### Configuration (`src/config/`)
|
||||||
|
|
||||||
|
- **local.config.ts**: Loads environment variables
|
||||||
|
- **config.validator.ts**: Validates config on startup
|
||||||
|
- **common-config.interface.ts**: TypeScript interface for config
|
||||||
|
|
||||||
|
**Environment Variables:**
|
||||||
|
|
||||||
|
- `AWS_ACCOUNT_ID` (default: 000000000000)
|
||||||
|
- `AWS_REGION` (default: us-east-1)
|
||||||
|
- `PORT` (default: 8081) - Main app port
|
||||||
|
- `S3_PORT` (default: 4567) - S3 microservice port
|
||||||
|
- `CONSUL_PORT` (default: 8500) - Consul KV microservice port
|
||||||
|
- `DATABASE_URL` (default: :memory:) - SQLite connection string
|
||||||
|
- `HOST`, `PROTO` - Used for URL generation
|
||||||
|
|
||||||
|
### Default Action Handler (`src/default-action-handler/`)
|
||||||
|
|
||||||
|
Provides stub implementations for unimplemented actions.
|
||||||
|
|
||||||
|
- **default-action-handler.provider.ts**: Creates default handlers that throw `UnsupportedOperationException`
|
||||||
|
- **existing-action-handlers.provider.ts**: Collects implemented handlers into map
|
||||||
|
- **default-action-handler.constants.ts**: Shared constants
|
||||||
|
|
||||||
|
**Purpose:** Allows modules to declare all AWS actions in their enum list, then provides automatic "not implemented" responses for actions without handlers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Action Enum (`src/action.enum.ts`)
|
||||||
|
|
||||||
|
Central enum containing all AWS actions across all services:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export enum Action {
|
||||||
|
// IAM
|
||||||
|
IamCreateRole = 'CreateRole',
|
||||||
|
IamGetRole = 'GetRole',
|
||||||
|
IamPutRolePolicy = 'PutRolePolicy',
|
||||||
|
|
||||||
|
// KMS
|
||||||
|
KmsCreateKey = 'CreateKey',
|
||||||
|
KmsSign = 'Sign',
|
||||||
|
|
||||||
|
// S3
|
||||||
|
S3CreateBucket = 'CreateBucket',
|
||||||
|
S3GetObject = 'GetObject',
|
||||||
|
S3PutBucketAcl = 'PutBucketAcl',
|
||||||
|
|
||||||
|
// ... all other actions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
Each module has a dedicated test suite in `src/<module>/__tests__/<module>.spec.ts`
|
||||||
|
|
||||||
|
**Test Pattern:**
|
||||||
|
|
||||||
|
1. Create isolated test app with in-memory SQLite database
|
||||||
|
2. Use actual AWS SDK clients pointing to local endpoints
|
||||||
|
3. Test against real AWS API contracts
|
||||||
|
4. Verify both API responses and database state
|
||||||
|
|
||||||
|
**Example Test Setup:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('IAM Integration Tests', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let iamClient: IAMClient;
|
||||||
|
let prismaService: PrismaService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Unique in-memory database per test suite
|
||||||
|
process.env.DATABASE_URL = `file::memory:?cache=shared&unique=${Date.now()}`;
|
||||||
|
|
||||||
|
const moduleFixture = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
// Real AWS SDK client pointing to local endpoint
|
||||||
|
iamClient = new IAMClient({
|
||||||
|
endpoint: `http://localhost:${testPort}`,
|
||||||
|
credentials: { accessKeyId: 'test', secretAccessKey: 'test' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a role', async () => {
|
||||||
|
const response = await iamClient.send(new CreateRoleCommand({ ... }));
|
||||||
|
expect(response.Role.RoleName).toBe('TestRole');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Organization:**
|
||||||
|
|
||||||
|
- `src/iam/__tests__/iam.spec.ts` - IAM tests
|
||||||
|
- `src/s3/__tests__/s3.spec.ts` - S3 tests
|
||||||
|
- `src/kms/__tests__/kms.spec.ts` - KMS tests
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
**Current Test Count:** 222 tests across 8 test suites
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Patterns & Conventions
|
||||||
|
|
||||||
|
### 1. Handler Naming
|
||||||
|
|
||||||
|
- Handlers named after AWS action: `<Action>Handler` (e.g., `CreateRoleHandler`)
|
||||||
|
- One handler per file: `<action>-handler.ts` (kebab-case)
|
||||||
|
- Handler must be Injectable and registered in module
|
||||||
|
|
||||||
|
### 2. Service Layer
|
||||||
|
|
||||||
|
- One service per module: `<service>.service.ts`
|
||||||
|
- Service injected into handlers via constructor
|
||||||
|
- Services interact with Prisma, not handlers directly
|
||||||
|
|
||||||
|
### 3. Entity Layer
|
||||||
|
|
||||||
|
- Entities wrap Prisma models: `<service>-<entity>.entity.ts`
|
||||||
|
- Implement Prisma type: `implements Prisma<Entity>`
|
||||||
|
- Provide computed properties and formatting methods
|
||||||
|
|
||||||
|
### 4. Module Constants
|
||||||
|
|
||||||
|
- `<service>.constants.ts` contains:
|
||||||
|
- Injection token: `export const ServiceHandlers = Symbol('ServiceHandlers');`
|
||||||
|
- Action list: `export const serviceActions: Action[] = [...]`
|
||||||
|
|
||||||
|
### 5. Error Handling
|
||||||
|
|
||||||
|
- Throw AWS exception classes from `aws-shared-entities/aws-exceptions.ts`
|
||||||
|
- Exception filter converts to XML or JSON automatically
|
||||||
|
- Include descriptive messages matching AWS API
|
||||||
|
|
||||||
|
### 6. Database Conventions
|
||||||
|
|
||||||
|
- Use Prisma for all database operations
|
||||||
|
- Models use camelCase, match entity class names
|
||||||
|
- Migrations created via `npx prisma migrate dev --name <description>`
|
||||||
|
- Always include `accountId` for multi-tenancy support
|
||||||
|
|
||||||
|
### 7. Request Validation
|
||||||
|
|
||||||
|
- Use Joi schemas in handler's `validator` property
|
||||||
|
- Validate early, fail fast with `ValidationError`
|
||||||
|
- Allow unknown properties for forward compatibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Adding a New Handler
|
||||||
|
|
||||||
|
1. **Create handler file** `src/<service>/<action>-handler.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Injectable()
|
||||||
|
export class MyActionHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.ServiceMyAction;
|
||||||
|
validator = Joi.object({
|
||||||
|
/* ... */
|
||||||
|
});
|
||||||
|
protected async handle(params, context) {
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add to module** in `src/<service>/<service>.module.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handlers = [
|
||||||
|
// ... existing handlers
|
||||||
|
MyActionHandler,
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Implement service method** if needed in `src/<service>/<service>.service.ts`
|
||||||
|
|
||||||
|
4. **Add tests** in `src/<service>/__tests__/<service>.spec.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it('should perform my action', async () => {
|
||||||
|
const response = await client.send(new MyActionCommand({ ... }));
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run tests** `npm test`
|
||||||
|
|
||||||
|
### Adding a New AWS Service Module
|
||||||
|
|
||||||
|
1. **Create directory** `src/<service>/`
|
||||||
|
|
||||||
|
2. **Create files:**
|
||||||
|
|
||||||
|
- `<service>.module.ts` - NestJS module
|
||||||
|
- `<service>.service.ts` - Business logic
|
||||||
|
- `<service>.constants.ts` - Injection tokens and action list
|
||||||
|
- `<handler>-handler.ts` - Handler implementations
|
||||||
|
- `<service>-<entity>.entity.ts` - Entity classes
|
||||||
|
- `__tests__/<service>.spec.ts` - Tests
|
||||||
|
|
||||||
|
3. **Update** `src/action.enum.ts` with service actions
|
||||||
|
|
||||||
|
4. **Update** `src/app.module.ts` to import new module
|
||||||
|
|
||||||
|
5. **Create Prisma models** in `prisma/schema.prisma`
|
||||||
|
|
||||||
|
6. **Run migration** `npx prisma migrate dev --name add_<service>`
|
||||||
|
|
||||||
|
7. **Implement handlers** following handler pattern
|
||||||
|
|
||||||
|
8. **Write tests** using AWS SDK client
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Troubleshooting
|
||||||
|
|
||||||
|
### Handler not found
|
||||||
|
|
||||||
|
- Check handler is in module's `handlers` array
|
||||||
|
- Verify action enum matches exactly
|
||||||
|
- Ensure handler is Injectable
|
||||||
|
|
||||||
|
### Database errors
|
||||||
|
|
||||||
|
- Run `npx prisma generate` after schema changes
|
||||||
|
- Check migration applied: `npx prisma migrate dev`
|
||||||
|
- Verify Prisma model matches entity class
|
||||||
|
|
||||||
|
### Validation errors
|
||||||
|
|
||||||
|
- Check Joi schema matches AWS API requirements
|
||||||
|
- Verify parameter casing (AWS uses PascalCase)
|
||||||
|
- Test with actual AWS SDK client
|
||||||
|
|
||||||
|
### S3 routing issues
|
||||||
|
|
||||||
|
- S3 uses different routing logic (HTTP method + path)
|
||||||
|
- Check `determineS3Action()` in S3Controller
|
||||||
|
- Verify query parameter handling (e.g., `?acl`)
|
||||||
|
|
||||||
|
### Test failures
|
||||||
|
|
||||||
|
- Ensure unique database: `file::memory:?cache=shared&unique=...`
|
||||||
|
- Check port conflicts (8086-8090 commonly used)
|
||||||
|
- Clean up resources in afterEach hooks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
|
||||||
|
### Why Separate S3 and Consul KV Microservices?
|
||||||
|
|
||||||
|
**S3 Microservice:**
|
||||||
|
S3 uses REST-style routing (path-based) rather than action-based routing. The main app routes based on `Action` parameter, while S3 needs to parse URLs like `/{bucket}/{key}` and route based on HTTP method + query parameters.
|
||||||
|
|
||||||
|
**Consul KV Microservice:**
|
||||||
|
Consul KV implements the HashiCorp Consul API specification, which uses REST-style routing at `/v1/kv/*` endpoints. It requires different routing logic, query parameter handling, and response formats than AWS services. Running it as a separate microservice allows:
|
||||||
|
|
||||||
|
- Clean separation of concerns between AWS and Consul APIs
|
||||||
|
- Different port (8500) matching standard Consul deployment
|
||||||
|
- Independent body parsing and response formatting
|
||||||
|
- Compatibility with existing Consul client libraries
|
||||||
|
|
||||||
|
### Why Handler Pattern?
|
||||||
|
|
||||||
|
- **Modularity**: Each AWS action is isolated
|
||||||
|
- **Testability**: Easy to test individual handlers
|
||||||
|
- **Scalability**: Easy to add new actions
|
||||||
|
- **Type Safety**: Each handler has its own request type
|
||||||
|
- **Validation**: Built-in Joi validation per action
|
||||||
|
|
||||||
|
### Why Prisma + SQLite?
|
||||||
|
|
||||||
|
- **Simplicity**: No external database required
|
||||||
|
- **Type Safety**: Generated TypeScript types
|
||||||
|
- **Migrations**: Schema versioning out of the box
|
||||||
|
- **Performance**: Fast for local development/testing
|
||||||
|
- **Portability**: Single file database or in-memory
|
||||||
|
|
||||||
|
### Why Dual Format Support (XML/JSON)?
|
||||||
|
|
||||||
|
Different AWS services use different protocols:
|
||||||
|
|
||||||
|
- IAM, KMS: XML (AWS Query protocol)
|
||||||
|
- SNS, SQS: JSON (AWS JSON protocol)
|
||||||
|
- S3: XML with custom headers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Organization Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.ts # Application bootstrap
|
||||||
|
├── app.module.ts # Main app module
|
||||||
|
├── app.controller.ts # Main API controller
|
||||||
|
├── abstract-action.handler.ts # Base handler class
|
||||||
|
├── action.enum.ts # All AWS actions
|
||||||
|
├── app.constants.ts # App-level constants
|
||||||
|
│
|
||||||
|
├── _context/ # Request context types
|
||||||
|
├── _prisma/ # Prisma client wrapper
|
||||||
|
├── audit/ # Request/response logging
|
||||||
|
├── aws-shared-entities/ # AWS exceptions & utilities
|
||||||
|
├── config/ # Environment configuration
|
||||||
|
├── default-action-handler/ # Stub handler generation
|
||||||
|
│
|
||||||
|
├── iam/ # IAM service module
|
||||||
|
│ ├── __tests__/
|
||||||
|
│ ├── *-handler.ts # Action handlers
|
||||||
|
│ ├── iam.service.ts # Business logic
|
||||||
|
│ ├── iam.module.ts # Module definition
|
||||||
|
│ ├── iam.constants.ts # Constants
|
||||||
|
│ └── *.entity.ts # Entity classes
|
||||||
|
│
|
||||||
|
├── s3/ # S3 service module (microservice)
|
||||||
|
│ ├── __tests__/
|
||||||
|
│ ├── s3-app.module.ts # Separate app module
|
||||||
|
│ ├── s3.controller.ts # Custom controller
|
||||||
|
│ ├── *-handler.ts # Action handlers
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
├── consul-kv/ # Consul KV module (microservice)
|
||||||
|
│ ├── __tests__/
|
||||||
|
│ ├── consul-kv-app.module.ts # Separate app module
|
||||||
|
│ ├── consul-kv.controller.ts # Custom controller
|
||||||
|
│ ├── consul-kv.service.ts # Business logic
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
├── kms/ # KMS service module
|
||||||
|
├── secrets-manager/ # Secrets Manager module
|
||||||
|
├── sns/ # SNS service module
|
||||||
|
├── sqs/ # SQS service module
|
||||||
|
└── sts/ # STS service module
|
||||||
|
|
||||||
|
prisma/
|
||||||
|
├── schema.prisma # Database schema
|
||||||
|
└── migrations/ # Schema migrations
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
**Start Development:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=8081 S3_PORT=4567 CONSUL_PORT=8500 yarn start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run Tests:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # All tests
|
||||||
|
npm test -- iam.spec.ts # Specific suite
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database Commands:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate dev --name <description>
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma studio # GUI database browser
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test AWS CLI:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# IAM
|
||||||
|
aws iam create-role --role-name TestRole --assume-role-policy-document '{}' \
|
||||||
|
--endpoint-url http://localhost:8081
|
||||||
|
|
||||||
|
# S3
|
||||||
|
aws s3 mb s3://my-bucket --endpoint-url http://localhost:4567
|
||||||
|
aws s3 ls s3://my-bucket --endpoint-url http://localhost:4567
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Consul API:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using curl
|
||||||
|
curl -X PUT http://localhost:8500/v1/kv/my-key -d 'my-value'
|
||||||
|
curl http://localhost:8500/v1/kv/my-key
|
||||||
|
curl -X DELETE http://localhost:8500/v1/kv/my-key
|
||||||
|
|
||||||
|
# Using consul CLI (if installed)
|
||||||
|
export CONSUL_HTTP_ADDR=http://localhost:8500
|
||||||
|
consul kv put my-key my-value
|
||||||
|
consul kv get my-key
|
||||||
|
consul kv delete my-key
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This documentation should be your starting point for understanding the project. Read this first before making changes to understand the patterns and conventions in use.
|
||||||
@@ -16,8 +16,7 @@
|
|||||||
"@nestjs/core": "^10.4.15",
|
"@nestjs/core": "^10.4.15",
|
||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@prisma/client": "^6.1.0",
|
"@prisma/client": "^6.1.0",
|
||||||
"class-transformer": "^0.5.1",
|
"consul": "^2.0.1",
|
||||||
"execa": "^9.5.2",
|
|
||||||
"joi": "^17.9.0",
|
"joi": "^17.9.0",
|
||||||
"js2xmlparser": "^5.0.0",
|
"js2xmlparser": "^5.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -26,18 +25,18 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-iam": "^3.969.0",
|
"@aws-sdk/client-iam": "^3.969.0",
|
||||||
"@aws-sdk/client-s3": "^3.968.0",
|
"@aws-sdk/client-s3": "^3.969.0",
|
||||||
"@aws-sdk/client-secrets-manager": "^3.968.0",
|
"@aws-sdk/client-secrets-manager": "^3.968.0",
|
||||||
"@aws-sdk/client-sns": "^3.968.0",
|
"@aws-sdk/client-sns": "^3.968.0",
|
||||||
"@aws-sdk/client-sqs": "^3.968.0",
|
"@aws-sdk/client-sqs": "^3.968.0",
|
||||||
"@aws-sdk/client-sts": "^3.969.0",
|
"@aws-sdk/client-sts": "^3.969.0",
|
||||||
"@nestjs/cli": "^10.4.9",
|
"@nestjs/cli": "^10.4.9",
|
||||||
"@nestjs/testing": "10.4.15",
|
"@nestjs/testing": "10.4.15",
|
||||||
|
"@types/consul": "^2.0.0",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/joi": "^17.2.2",
|
"@types/joi": "^17.2.2",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"aws-sdk-client-mock": "^4.1.0",
|
|
||||||
"eslint": "^8.36.0",
|
"eslint": "^8.36.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"prisma": "^6.1.0",
|
"prisma": "^6.1.0",
|
||||||
|
|||||||
34
prisma/migrations/20260115183458_add_s3_models/migration.sql
Normal file
34
prisma/migrations/20260115183458_add_s3_models/migration.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "S3Bucket" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"region" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "S3Object" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"bucketId" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"versionId" TEXT,
|
||||||
|
"content" BLOB NOT NULL,
|
||||||
|
"contentType" TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||||
|
"size" INTEGER NOT NULL,
|
||||||
|
"etag" TEXT NOT NULL,
|
||||||
|
"metadata" TEXT NOT NULL DEFAULT '{}',
|
||||||
|
"storageClass" TEXT NOT NULL DEFAULT 'STANDARD',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "S3Object_bucketId_fkey" FOREIGN KEY ("bucketId") REFERENCES "S3Bucket" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "S3Bucket_accountId_region_name_key" ON "S3Bucket"("accountId", "region", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "S3Object_bucketId_idx" ON "S3Object"("bucketId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "S3Object_bucketId_key_key" ON "S3Object"("bucketId", "key");
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `accountId` on the `S3Bucket` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `region` on the `S3Bucket` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_S3Bucket" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
INSERT INTO "new_S3Bucket" ("createdAt", "id", "name") SELECT "createdAt", "id", "name" FROM "S3Bucket";
|
||||||
|
DROP TABLE "S3Bucket";
|
||||||
|
ALTER TABLE "new_S3Bucket" RENAME TO "S3Bucket";
|
||||||
|
CREATE UNIQUE INDEX "S3Bucket_name_key" ON "S3Bucket"("name");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_S3Bucket" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"tags" TEXT NOT NULL DEFAULT '{}',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
INSERT INTO "new_S3Bucket" ("createdAt", "id", "name") SELECT "createdAt", "id", "name" FROM "S3Bucket";
|
||||||
|
DROP TABLE "S3Bucket";
|
||||||
|
ALTER TABLE "new_S3Bucket" RENAME TO "S3Bucket";
|
||||||
|
CREATE UNIQUE INDEX "S3Bucket_name_key" ON "S3Bucket"("name");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "S3Bucket" ADD COLUMN "policy" TEXT;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IamRoleInlinePolicy" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"roleName" TEXT NOT NULL,
|
||||||
|
"policyName" TEXT NOT NULL,
|
||||||
|
"policyDocument" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "IamRoleInlinePolicy_accountId_roleName_fkey" FOREIGN KEY ("accountId", "roleName") REFERENCES "IamRole" ("accountId", "name") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_S3Bucket" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"tags" TEXT NOT NULL DEFAULT '{}',
|
||||||
|
"policy" TEXT,
|
||||||
|
"acl" TEXT NOT NULL DEFAULT '{"Owner":{"ID":"local-user","DisplayName":"local-user"},"Grants":[]}',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
INSERT INTO "new_S3Bucket" ("createdAt", "id", "name", "policy", "tags") SELECT "createdAt", "id", "name", "policy", "tags" FROM "S3Bucket";
|
||||||
|
DROP TABLE "S3Bucket";
|
||||||
|
ALTER TABLE "new_S3Bucket" RENAME TO "S3Bucket";
|
||||||
|
CREATE UNIQUE INDEX "S3Bucket_name_key" ON "S3Bucket"("name");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "IamRoleInlinePolicy_accountId_roleName_policyName_key" ON "IamRoleInlinePolicy"("accountId", "roleName", "policyName");
|
||||||
20
prisma/migrations/20260116200219_add_consul_kv/migration.sql
Normal file
20
prisma/migrations/20260116200219_add_consul_kv/migration.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ConsulKVEntry" (
|
||||||
|
"key" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"flags" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createIndex" INTEGER NOT NULL,
|
||||||
|
"modifyIndex" INTEGER NOT NULL,
|
||||||
|
"lockIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"session" TEXT,
|
||||||
|
"datacenter" TEXT NOT NULL DEFAULT 'dc1',
|
||||||
|
"namespace" TEXT NOT NULL DEFAULT 'default',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConsulKVEntry_datacenter_idx" ON "ConsulKVEntry"("datacenter");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConsulKVEntry_namespace_idx" ON "ConsulKVEntry"("namespace");
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to alter the column `flags` on the `ConsulKVEntry` table. The data in that column could be lost. The data in that column will be cast from `Int` to `BigInt`.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_ConsulKVEntry" (
|
||||||
|
"key" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"flags" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"createIndex" INTEGER NOT NULL,
|
||||||
|
"modifyIndex" INTEGER NOT NULL,
|
||||||
|
"lockIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"session" TEXT,
|
||||||
|
"datacenter" TEXT NOT NULL DEFAULT 'dc1',
|
||||||
|
"namespace" TEXT NOT NULL DEFAULT 'default',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "new_ConsulKVEntry" ("createIndex", "createdAt", "datacenter", "flags", "key", "lockIndex", "modifyIndex", "namespace", "session", "updatedAt", "value") SELECT "createIndex", "createdAt", "datacenter", "flags", "key", "lockIndex", "modifyIndex", "namespace", "session", "updatedAt", "value" FROM "ConsulKVEntry";
|
||||||
|
DROP TABLE "ConsulKVEntry";
|
||||||
|
ALTER TABLE "new_ConsulKVEntry" RENAME TO "ConsulKVEntry";
|
||||||
|
CREATE INDEX "ConsulKVEntry_datacenter_idx" ON "ConsulKVEntry"("datacenter");
|
||||||
|
CREATE INDEX "ConsulKVEntry_namespace_idx" ON "ConsulKVEntry"("namespace");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ConsulKVEntry" ADD COLUMN "lockInfo" TEXT;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `lockInfo` on the `ConsulKVEntry` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_ConsulKVEntry" (
|
||||||
|
"key" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"flags" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"createIndex" INTEGER NOT NULL,
|
||||||
|
"modifyIndex" INTEGER NOT NULL,
|
||||||
|
"lockIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"session" TEXT,
|
||||||
|
"datacenter" TEXT NOT NULL DEFAULT 'dc1',
|
||||||
|
"namespace" TEXT NOT NULL DEFAULT 'default',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "new_ConsulKVEntry" ("createIndex", "createdAt", "datacenter", "flags", "key", "lockIndex", "modifyIndex", "namespace", "session", "updatedAt", "value") SELECT "createIndex", "createdAt", "datacenter", "flags", "key", "lockIndex", "modifyIndex", "namespace", "session", "updatedAt", "value" FROM "ConsulKVEntry";
|
||||||
|
DROP TABLE "ConsulKVEntry";
|
||||||
|
ALTER TABLE "new_ConsulKVEntry" RENAME TO "ConsulKVEntry";
|
||||||
|
CREATE INDEX "ConsulKVEntry_datacenter_idx" ON "ConsulKVEntry"("datacenter");
|
||||||
|
CREATE INDEX "ConsulKVEntry_namespace_idx" ON "ConsulKVEntry"("namespace");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -38,10 +38,25 @@ model IamRole {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
policies IamRoleIamPolicyAttachment[]
|
policies IamRoleIamPolicyAttachment[]
|
||||||
|
inlinePolicies IamRoleInlinePolicy[]
|
||||||
|
|
||||||
@@unique([accountId, name])
|
@@unique([accountId, name])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model IamRoleInlinePolicy {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
roleName String
|
||||||
|
policyName String
|
||||||
|
policyDocument String
|
||||||
|
accountId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
role IamRole @relation(fields: [accountId, roleName], references: [accountId, name], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([accountId, roleName, policyName])
|
||||||
|
}
|
||||||
|
|
||||||
model IamPolicy {
|
model IamPolicy {
|
||||||
id String
|
id String
|
||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
@@ -166,3 +181,51 @@ model Tag {
|
|||||||
|
|
||||||
@@unique([arn, name])
|
@@unique([arn, name])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model S3Bucket {
|
||||||
|
id String @id
|
||||||
|
name String @unique
|
||||||
|
tags String @default("{}")
|
||||||
|
policy String?
|
||||||
|
acl String @default("{\"Owner\":{\"ID\":\"local-user\",\"DisplayName\":\"local-user\"},\"Grants\":[]}")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
objects S3Object[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model S3Object {
|
||||||
|
id String @id
|
||||||
|
bucketId String
|
||||||
|
key String
|
||||||
|
versionId String?
|
||||||
|
content Bytes
|
||||||
|
contentType String @default("application/octet-stream")
|
||||||
|
size Int
|
||||||
|
etag String
|
||||||
|
metadata String @default("{}")
|
||||||
|
storageClass String @default("STANDARD")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
bucket S3Bucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([bucketId, key])
|
||||||
|
@@index([bucketId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ConsulKVEntry {
|
||||||
|
key String @id
|
||||||
|
value String // Base64 encoded
|
||||||
|
flags BigInt @default(0)
|
||||||
|
createIndex Int
|
||||||
|
modifyIndex Int
|
||||||
|
lockIndex Int @default(0)
|
||||||
|
session String?
|
||||||
|
datacenter String @default("dc1")
|
||||||
|
namespace String @default("default")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([datacenter])
|
||||||
|
@@index([namespace])
|
||||||
|
}
|
||||||
|
|||||||
@@ -330,12 +330,17 @@ export enum Action {
|
|||||||
S3CreateMultipartUpload = 'CreateMultipartUpload',
|
S3CreateMultipartUpload = 'CreateMultipartUpload',
|
||||||
S3DeleteBucket = 'DeleteBucket',
|
S3DeleteBucket = 'DeleteBucket',
|
||||||
S3DeleteObject = 'DeleteObject',
|
S3DeleteObject = 'DeleteObject',
|
||||||
|
S3GetBucketAcl = 'GetBucketAcl',
|
||||||
|
S3GetBucketPolicy = 'GetBucketPolicy',
|
||||||
|
S3GetBucketTagging = 'GetBucketTagging',
|
||||||
S3GetObject = 'GetObject',
|
S3GetObject = 'GetObject',
|
||||||
S3HeadBucket = 'HeadBucket',
|
S3HeadBucket = 'HeadBucket',
|
||||||
S3HeadObject = 'HeadObject',
|
S3HeadObject = 'HeadObject',
|
||||||
S3ListBuckets = 'ListBuckets',
|
S3ListBuckets = 'ListBuckets',
|
||||||
S3ListObjects = 'ListObjects',
|
S3ListObjects = 'ListObjects',
|
||||||
S3ListObjectsV2 = 'ListObjectsV2',
|
S3ListObjectsV2 = 'ListObjectsV2',
|
||||||
|
S3PutBucketAcl = 'PutBucketAcl',
|
||||||
|
S3PutBucketTagging = 'PutBucketTagging',
|
||||||
S3PutObject = 'PutObject',
|
S3PutObject = 'PutObject',
|
||||||
S3UploadPart = 'UploadPart',
|
S3UploadPart = 'UploadPart',
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Delete, Get, Headers, HttpCode, Inject, Post, Put, Query, Req, Res, UseInterceptors } from '@nestjs/common';
|
import { All, Body, Controller, Delete, Get, Headers, HttpCode, Inject, Post, Put, Query, Req, Res, UseInterceptors } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import * as Joi from 'joi';
|
import * as Joi from 'joi';
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { CallHandler, ExecutionContext, HttpException, Inject, Injectable, Logger, NestInterceptor, RequestTimeoutException } from '@nestjs/common';
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NestInterceptor,
|
||||||
|
RequestTimeoutException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { catchError, Observable, tap, throwError } from 'rxjs';
|
import { catchError, Observable, tap, throwError } from 'rxjs';
|
||||||
import { Request as ExpressRequest, Response } from 'express';
|
import { Request as ExpressRequest, Response } from 'express';
|
||||||
@@ -12,10 +21,8 @@ import { AwsException, InternalFailure } from '../aws-shared-entities/aws-except
|
|||||||
import { IRequest, RequestContext } from '../_context/request.context';
|
import { IRequest, RequestContext } from '../_context/request.context';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuditInterceptor<T> implements NestInterceptor<T, Response> {
|
export class AuditInterceptor<T> implements NestInterceptor<T, Response> {
|
||||||
|
|
||||||
private readonly logger = new Logger(AuditInterceptor.name);
|
private readonly logger = new Logger(AuditInterceptor.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -26,7 +33,6 @@ export class AuditInterceptor<T> implements NestInterceptor<T, Response> {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<any> {
|
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<any> {
|
||||||
|
|
||||||
const awsProperties = {
|
const awsProperties = {
|
||||||
accountId: this.configService.get('AWS_ACCOUNT_ID'),
|
accountId: this.configService.get('AWS_ACCOUNT_ID'),
|
||||||
region: this.configService.get('AWS_REGION'),
|
region: this.configService.get('AWS_REGION'),
|
||||||
@@ -36,34 +42,47 @@ export class AuditInterceptor<T> implements NestInterceptor<T, Response> {
|
|||||||
const requestContext: RequestContext = {
|
const requestContext: RequestContext = {
|
||||||
requestId: randomUUID(),
|
requestId: randomUUID(),
|
||||||
awsProperties,
|
awsProperties,
|
||||||
}
|
};
|
||||||
|
|
||||||
const httpContext = context.switchToHttp();
|
const httpContext = context.switchToHttp();
|
||||||
const request = httpContext.getRequest<IRequest>();
|
const request = httpContext.getRequest<IRequest>();
|
||||||
request.context = requestContext;
|
request.context = requestContext;
|
||||||
|
|
||||||
const hasTargetHeader = Object.keys(request.headers).some( k => k.toLocaleLowerCase() === 'x-amz-target');
|
const hasTargetHeader = Object.keys(request.headers).some(k => k.toLocaleLowerCase() === 'x-amz-target');
|
||||||
const action = hasTargetHeader ? request.headers['x-amz-target'] : request.body.Action;
|
const action = hasTargetHeader ? request.headers['x-amz-target'] : request.body.Action;
|
||||||
const { value: resolvedAction } = Joi.string().required().valid(...Object.values(Action)).validate(action) as { value: Action | undefined };
|
const { value: resolvedAction } = Joi.string()
|
||||||
|
.required()
|
||||||
|
.valid(...Object.values(Action))
|
||||||
|
.validate(action) as { value: Action | undefined };
|
||||||
requestContext.action = resolvedAction;
|
requestContext.action = resolvedAction;
|
||||||
|
|
||||||
const response = context.switchToHttp().getResponse<Response>();
|
const response = context.switchToHttp().getResponse<Response>();
|
||||||
response.header('x-amzn-RequestId', requestContext.requestId);
|
response.header('x-amzn-RequestId', requestContext.requestId);
|
||||||
|
|
||||||
|
const requestStartTime = Date.now();
|
||||||
|
|
||||||
if (!resolvedAction || !this.handlers[resolvedAction]?.audit) {
|
if (!resolvedAction || !this.handlers[resolvedAction]?.audit) {
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
catchError(async (error: Error) => {
|
catchError(async (error: Error) => {
|
||||||
|
const duration = Date.now() - requestStartTime;
|
||||||
|
this.logger.error(`${action} - ${duration}ms - Error: ${error.message}`);
|
||||||
|
|
||||||
await this.prismaService.audit.create({
|
await this.prismaService.audit.create({
|
||||||
data: {
|
data: {
|
||||||
id: requestContext.requestId,
|
id: requestContext.requestId,
|
||||||
action,
|
action,
|
||||||
request: JSON.stringify({ __path: request.path, ...request.headers, ...request.body }),
|
request: JSON.stringify({
|
||||||
|
__path: request.path,
|
||||||
|
__accountId: requestContext.awsProperties.accountId,
|
||||||
|
__region: requestContext.awsProperties.region,
|
||||||
|
...request.headers,
|
||||||
|
...request.body,
|
||||||
|
}),
|
||||||
response: JSON.stringify(error),
|
response: JSON.stringify(error),
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
this.logger.error(error.message);
|
|
||||||
return error;
|
return error;
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,9 +91,7 @@ export class AuditInterceptor<T> implements NestInterceptor<T, Response> {
|
|||||||
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
catchError((error: Error) => {
|
catchError((error: Error) => {
|
||||||
|
|
||||||
return throwError(() => {
|
return throwError(() => {
|
||||||
|
|
||||||
if (error instanceof AwsException) {
|
if (error instanceof AwsException) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
@@ -87,25 +104,46 @@ export class AuditInterceptor<T> implements NestInterceptor<T, Response> {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
tap({
|
tap({
|
||||||
|
next: async data => {
|
||||||
|
const duration = Date.now() - requestStartTime;
|
||||||
|
this.logger.log(`${action} - ${duration}ms`);
|
||||||
|
|
||||||
next: async (data) => await this.prismaService.audit.create({
|
await this.prismaService.audit.create({
|
||||||
data: {
|
data: {
|
||||||
id: requestContext.requestId,
|
id: requestContext.requestId,
|
||||||
action,
|
action,
|
||||||
request: JSON.stringify({ __path: request.path, ...request.headers, ...request.body }),
|
request: JSON.stringify({
|
||||||
|
__path: request.path,
|
||||||
|
__accountId: requestContext.awsProperties.accountId,
|
||||||
|
__region: requestContext.awsProperties.region,
|
||||||
|
...request.headers,
|
||||||
|
...request.body,
|
||||||
|
}),
|
||||||
response: JSON.stringify(data),
|
response: JSON.stringify(data),
|
||||||
}
|
},
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
|
|
||||||
error: async (error) => await this.prismaService.audit.create({
|
error: async error => {
|
||||||
|
const duration = Date.now() - requestStartTime;
|
||||||
|
this.logger.error(`${action} - ${duration}ms - Error: ${error.message}`);
|
||||||
|
|
||||||
|
await this.prismaService.audit.create({
|
||||||
data: {
|
data: {
|
||||||
id: requestContext.requestId,
|
id: requestContext.requestId,
|
||||||
action,
|
action,
|
||||||
request: JSON.stringify({ __path: request.path, ...request.headers, ...request.body }),
|
request: JSON.stringify({
|
||||||
response: JSON.stringify(error),
|
__path: request.path,
|
||||||
}
|
__accountId: requestContext.awsProperties.accountId,
|
||||||
|
__region: requestContext.awsProperties.region,
|
||||||
|
...request.headers,
|
||||||
|
...request.body,
|
||||||
|
}),
|
||||||
|
response: JSON.stringify(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,7 @@ export interface CommonConfig {
|
|||||||
DB_SYNCHRONIZE?: boolean;
|
DB_SYNCHRONIZE?: boolean;
|
||||||
HOST: string;
|
HOST: string;
|
||||||
PORT: number;
|
PORT: number;
|
||||||
|
S3_PORT: number;
|
||||||
|
CONSUL_PORT: number;
|
||||||
PROTO: string;
|
PROTO: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,7 @@ export const configValidator = Joi.object<CommonConfig, true>({
|
|||||||
DB_SYNCHRONIZE: Joi.boolean().valid(true).required(),
|
DB_SYNCHRONIZE: Joi.boolean().valid(true).required(),
|
||||||
HOST: Joi.string().required(),
|
HOST: Joi.string().required(),
|
||||||
PORT: Joi.number().required(),
|
PORT: Joi.number().required(),
|
||||||
|
S3_PORT: Joi.number().required(),
|
||||||
|
CONSUL_PORT: Joi.number().required(),
|
||||||
PROTO: Joi.string().valid('http', 'https').required(),
|
PROTO: Joi.string().valid('http', 'https').required(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { CommonConfig } from "./common-config.interface";
|
import { CommonConfig } from './common-config.interface';
|
||||||
import { configValidator } from './config.validator';
|
import { configValidator } from './config.validator';
|
||||||
|
|
||||||
export default (): CommonConfig => {
|
export default (): CommonConfig => {
|
||||||
|
const { error, value } = configValidator.validate(
|
||||||
const { error, value } = configValidator.validate({
|
{
|
||||||
AWS_ACCOUNT_ID: process.env.AWS_ACCOUNT_ID ?? '000000000000',
|
AWS_ACCOUNT_ID: process.env.AWS_ACCOUNT_ID ?? '000000000000',
|
||||||
AWS_REGION: process.env.AWS_REGION ?? 'us-east-1',
|
AWS_REGION: process.env.AWS_REGION ?? 'us-east-1',
|
||||||
DB_DATABASE: process.env.PERSISTANCE ?? ':memory:',
|
DB_DATABASE: process.env.PERSISTANCE ?? ':memory:',
|
||||||
@@ -11,12 +11,16 @@ export default (): CommonConfig => {
|
|||||||
DB_SYNCHRONIZE: true,
|
DB_SYNCHRONIZE: true,
|
||||||
HOST: process.env.HOST ?? 'localhost',
|
HOST: process.env.HOST ?? 'localhost',
|
||||||
PROTO: process.env.PROTOCOL ?? 'http',
|
PROTO: process.env.PROTOCOL ?? 'http',
|
||||||
PORT: process.env.PORT as any ?? 8081,
|
PORT: (process.env.PORT as any) ?? 4566,
|
||||||
}, { abortEarly: false });
|
S3_PORT: (process.env.S3_PORT as any) ?? 9000,
|
||||||
|
CONSUL_PORT: (process.env.CONSUL_PORT as any) ?? 8500,
|
||||||
|
},
|
||||||
|
{ abortEarly: false },
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
}
|
};
|
||||||
|
|||||||
1004
src/consul-kv/__tests__/consul-kv.spec.ts
Normal file
1004
src/consul-kv/__tests__/consul-kv.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
27
src/consul-kv/consul-kv-app.module.ts
Normal file
27
src/consul-kv/consul-kv-app.module.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
import { PrismaModule } from '../_prisma/prisma.module';
|
||||||
|
import localConfig from '../config/local.config';
|
||||||
|
import { ConsulKVController } from './consul-kv.controller';
|
||||||
|
import { ConsulKVService } from './consul-kv.service';
|
||||||
|
import { ConsulKVAuditInterceptor } from './consul-kv-audit.interceptor';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [localConfig],
|
||||||
|
}),
|
||||||
|
PrismaModule,
|
||||||
|
],
|
||||||
|
controllers: [ConsulKVController],
|
||||||
|
providers: [
|
||||||
|
ConsulKVService,
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: ConsulKVAuditInterceptor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ConsulKVAppModule {}
|
||||||
114
src/consul-kv/consul-kv-audit.interceptor.ts
Normal file
114
src/consul-kv/consul-kv-audit.interceptor.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { catchError, Observable, tap, throwError } from 'rxjs';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { PrismaService } from '../_prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConsulKVAuditInterceptor implements NestInterceptor {
|
||||||
|
private readonly logger = new Logger(ConsulKVAuditInterceptor.name);
|
||||||
|
|
||||||
|
constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
const httpContext = context.switchToHttp();
|
||||||
|
const request = httpContext.getRequest<Request>();
|
||||||
|
const response = httpContext.getResponse<Response>();
|
||||||
|
|
||||||
|
const requestId = randomUUID();
|
||||||
|
response.header('x-consul-request-id', requestId);
|
||||||
|
|
||||||
|
const requestStartTime = Date.now();
|
||||||
|
const method = request.method;
|
||||||
|
const path = request.path;
|
||||||
|
const query = request.query;
|
||||||
|
const headers = request.headers;
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap({
|
||||||
|
next: async data => {
|
||||||
|
const duration = Date.now() - requestStartTime;
|
||||||
|
this.logger.log(`${method} ${path} - ${duration}ms - ${response.statusCode}`);
|
||||||
|
|
||||||
|
await this.prismaService.audit.create({
|
||||||
|
data: {
|
||||||
|
id: requestId,
|
||||||
|
action: `consul:${method}:${path}`,
|
||||||
|
request: JSON.stringify({
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
headers: {
|
||||||
|
'content-type': headers['content-type'],
|
||||||
|
'x-consul-namespace': headers['x-consul-namespace'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
response: JSON.stringify({
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
data: this.sanitizeResponse(data),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
error: async error => {
|
||||||
|
const duration = Date.now() - requestStartTime;
|
||||||
|
this.logger.error(`${method} ${path} - ${duration}ms - Error: ${error.message}`);
|
||||||
|
|
||||||
|
await this.prismaService.audit.create({
|
||||||
|
data: {
|
||||||
|
id: requestId,
|
||||||
|
action: `consul:${method}:${path}`,
|
||||||
|
request: JSON.stringify({
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
headers: {
|
||||||
|
'content-type': headers['content-type'],
|
||||||
|
'x-consul-namespace': headers['x-consul-namespace'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
response: JSON.stringify({
|
||||||
|
error: error.message,
|
||||||
|
statusCode: error.status || 500,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
catchError((error: Error) => {
|
||||||
|
return throwError(() => error);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeResponse(data: any): any {
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return data.length > 1000 ? `${data.substring(0, 1000)}...` : data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.map(item => this.sanitizeResponseItem(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sanitizeResponseItem(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeResponseItem(item: any): any {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized: any = { ...item };
|
||||||
|
|
||||||
|
if (sanitized.Value && typeof sanitized.Value === 'string' && sanitized.Value.length > 100) {
|
||||||
|
sanitized.Value = `${sanitized.Value.substring(0, 100)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/consul-kv/consul-kv.controller.ts
Normal file
167
src/consul-kv/consul-kv.controller.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { Controller, Get, Put, Delete, Headers, Query, Req, Body, NotFoundException, HttpCode, Header, Param } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { ConsulKVService } from './consul-kv.service';
|
||||||
|
import { ConsulKVEntry } from '@prisma/client';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class ConsulKVController {
|
||||||
|
constructor(private readonly consulKVService: ConsulKVService) {}
|
||||||
|
|
||||||
|
@Get('v1/kv/*')
|
||||||
|
async getKey(@Req() request: Request, @Headers() headers: Record<string, any>, @Query() query: Record<string, any>) {
|
||||||
|
const key = this.extractKey(request.path);
|
||||||
|
const { datacenter, namespace } = this.extractTenancy(query, headers);
|
||||||
|
|
||||||
|
const recurse = query.recurse === 'true' || query.recurse === '';
|
||||||
|
const raw = query.raw === 'true' || query.raw === '';
|
||||||
|
const keys = query.keys === 'true' || query.keys === '';
|
||||||
|
const separator = query.separator;
|
||||||
|
|
||||||
|
// Blocking query parameters
|
||||||
|
const waitIndex = query.index !== undefined ? parseInt(query.index, 10) : undefined;
|
||||||
|
const waitDuration = query.wait;
|
||||||
|
|
||||||
|
if (keys) {
|
||||||
|
// Return only key names
|
||||||
|
return this.consulKVService.listKeys(key, separator, datacenter, namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurse) {
|
||||||
|
// Return all keys with prefix
|
||||||
|
const entries = await this.consulKVService.getKeysWithPrefix(key, datacenter, namespace);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
// For recursive + raw, return the first entry's raw value
|
||||||
|
return Buffer.from(entries[0].value, 'base64').toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.map(entry => this.formatEntry(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return single key - use blocking query if wait parameters provided
|
||||||
|
let entry: ConsulKVEntry | null;
|
||||||
|
|
||||||
|
if (waitIndex !== undefined && waitDuration) {
|
||||||
|
entry = await this.consulKVService.getKeyBlocking(key, datacenter, namespace, waitIndex, waitDuration);
|
||||||
|
} else {
|
||||||
|
entry = await this.consulKVService.getKey(key, datacenter, namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
throw new NotFoundException('Key not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
return Buffer.from(entry.value, 'base64').toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [this.formatEntry(entry)];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('v1/kv/*')
|
||||||
|
@HttpCode(200)
|
||||||
|
@Header('Content-Type', 'text/plain')
|
||||||
|
async putKey(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Body() body: any,
|
||||||
|
@Headers() headers: Record<string, any>,
|
||||||
|
@Query() query: Record<string, any>,
|
||||||
|
): Promise<string> {
|
||||||
|
const key = this.extractKey(request.path);
|
||||||
|
const { datacenter, namespace } = this.extractTenancy(query, headers);
|
||||||
|
|
||||||
|
const flags = parseInt(query.flags) || 0;
|
||||||
|
const cas = query.cas !== undefined ? parseInt(query.cas) : undefined;
|
||||||
|
const acquire = query.acquire;
|
||||||
|
const release = query.release;
|
||||||
|
|
||||||
|
// Body is the raw value
|
||||||
|
const rawValue = Buffer.isBuffer(body) ? body.toString('utf-8') : typeof body === 'string' ? body : JSON.stringify(body);
|
||||||
|
const value = Buffer.from(rawValue).toString('base64');
|
||||||
|
|
||||||
|
const result = await this.consulKVService.putKey(key, value, flags, datacenter, namespace, cas, acquire, release);
|
||||||
|
|
||||||
|
// If this was a lock acquisition and there's lock info in headers, store it
|
||||||
|
// if (result.success && acquire && headers['x-consul-lock-info']) {
|
||||||
|
// try {
|
||||||
|
// const lockInfo = JSON.parse(headers['x-consul-lock-info']);
|
||||||
|
// await this.consulKVService.putLockInfo(key, lockInfo, datacenter, namespace);
|
||||||
|
// } catch {
|
||||||
|
// // Ignore invalid lock info
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Consul API returns plain text "true" or "false"
|
||||||
|
return result.success.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('v1/kv/*')
|
||||||
|
@HttpCode(200)
|
||||||
|
@Header('Content-Type', 'text/plain')
|
||||||
|
async deleteKey(@Req() request: Request, @Headers() headers: Record<string, any>, @Query() query: Record<string, any>): Promise<string> {
|
||||||
|
const key = this.extractKey(request.path);
|
||||||
|
const { datacenter, namespace } = this.extractTenancy(query, headers);
|
||||||
|
|
||||||
|
const recurse = query.recurse === 'true' || query.recurse === '';
|
||||||
|
const cas = query.cas !== undefined ? parseInt(query.cas) : undefined;
|
||||||
|
|
||||||
|
const success = await this.consulKVService.deleteKey(key, datacenter, namespace, recurse, cas);
|
||||||
|
|
||||||
|
// Consul API returns plain text "true" or "false"
|
||||||
|
return success.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('v1/session/create')
|
||||||
|
async createSession(@Body() body: any, @Query() query: Record<string, any>) {
|
||||||
|
const { datacenter } = this.extractTenancy(query, {});
|
||||||
|
const sessionId = await this.consulKVService.createSession(body?.Name, body?.TTL, datacenter);
|
||||||
|
return { ID: sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('v1/session/renew/:sessionId')
|
||||||
|
async renewSession(@Param('sessionId') sessionId: string, @Query() query: Record<string, any>) {
|
||||||
|
const success = await this.consulKVService.renewSession(sessionId);
|
||||||
|
if (!success) {
|
||||||
|
throw new NotFoundException('Session not found');
|
||||||
|
}
|
||||||
|
// Consul returns the session with TTL info on renewal
|
||||||
|
const session = await this.consulKVService.getSession(sessionId);
|
||||||
|
return [{ ID: sessionId, TTL: session?.ttl || '15s' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('v1/session/destroy/:sessionId')
|
||||||
|
async destroySession(@Param('sessionId') sessionId: string, @Query() query: Record<string, any>) {
|
||||||
|
const { datacenter } = this.extractTenancy(query, {});
|
||||||
|
const success = await this.consulKVService.destroySession(sessionId, datacenter);
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractKey(path: string): string {
|
||||||
|
// Extract key from path: /v1/kv/:key and decode URL encoding
|
||||||
|
const encodedKey = path.replace(/^\/v1\/kv\/?/, '') || '';
|
||||||
|
return decodeURIComponent(encodedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTenancy(query: Record<string, any>, headers: Record<string, any>): { datacenter: string; namespace: string } {
|
||||||
|
return {
|
||||||
|
datacenter: query.dc || 'dc1',
|
||||||
|
namespace: query.ns || headers['x-consul-namespace'] || 'default',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatEntry(entry: ConsulKVEntry) {
|
||||||
|
return {
|
||||||
|
CreateIndex: entry.createIndex,
|
||||||
|
ModifyIndex: entry.modifyIndex,
|
||||||
|
LockIndex: entry.lockIndex,
|
||||||
|
Key: entry.key,
|
||||||
|
Flags: Number(entry.flags),
|
||||||
|
Value: entry.value,
|
||||||
|
Session: entry.session || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
404
src/consul-kv/consul-kv.service.ts
Normal file
404
src/consul-kv/consul-kv.service.ts
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../_prisma/prisma.service';
|
||||||
|
import { ConsulKVEntry } from '@prisma/client';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConsulKVService implements OnModuleInit {
|
||||||
|
private indexCounter = 1;
|
||||||
|
private sessions = new Map<string, { name?: string; ttl?: string; datacenter: string; createdAt: Date; lastRenewal: Date }>();
|
||||||
|
private sessionCleanupInterval?: NodeJS.Timeout;
|
||||||
|
private changeNotifiers = new Map<string, Set<(entry: ConsulKVEntry | null) => void>>();
|
||||||
|
|
||||||
|
constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.sessionCleanupInterval) {
|
||||||
|
clearInterval(this.sessionCleanupInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
const maxEntry = await this.prismaService.consulKVEntry.findFirst({
|
||||||
|
orderBy: { modifyIndex: 'desc' },
|
||||||
|
select: { modifyIndex: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (maxEntry) {
|
||||||
|
this.indexCounter = maxEntry.modifyIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionCleanupInterval = setInterval(() => {
|
||||||
|
this.cleanupExpiredSessions();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNextIndex(): number {
|
||||||
|
return this.indexCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getKey(key: string, datacenter: string = 'dc1', namespace: string = 'default'): Promise<ConsulKVEntry | null> {
|
||||||
|
return await this.prismaService.consulKVEntry.findFirst({
|
||||||
|
where: { key, datacenter, namespace },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getKeyBlocking(
|
||||||
|
key: string,
|
||||||
|
datacenter: string = 'dc1',
|
||||||
|
namespace: string = 'default',
|
||||||
|
waitIndex?: number,
|
||||||
|
waitDuration?: string,
|
||||||
|
): Promise<ConsulKVEntry | null> {
|
||||||
|
const entry = await this.getKey(key, datacenter, namespace);
|
||||||
|
|
||||||
|
if (waitIndex === undefined || !waitDuration) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry && entry.modifyIndex > waitIndex) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry && waitIndex === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitMs = this.parseDuration(waitDuration);
|
||||||
|
const notifierKey = `${datacenter}:${namespace}:${key}`;
|
||||||
|
|
||||||
|
return new Promise<ConsulKVEntry | null>(resolve => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.removeChangeNotifier(notifierKey, notifier);
|
||||||
|
resolve(entry);
|
||||||
|
}, waitMs);
|
||||||
|
|
||||||
|
const notifier = (updatedEntry: ConsulKVEntry | null) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
this.removeChangeNotifier(notifierKey, notifier);
|
||||||
|
|
||||||
|
if (updatedEntry && updatedEntry.modifyIndex > waitIndex) {
|
||||||
|
resolve(updatedEntry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updatedEntry && entry !== null) {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addChangeNotifier(notifierKey, notifier);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDuration(duration: string): number {
|
||||||
|
const match = duration.match(/^(\d+)([smh])$/);
|
||||||
|
if (!match) {
|
||||||
|
return 5 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = parseInt(match[1], 10);
|
||||||
|
const unit = match[2];
|
||||||
|
|
||||||
|
if (unit === 's') {
|
||||||
|
return value * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === 'm') {
|
||||||
|
return value * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addChangeNotifier(key: string, callback: (entry: ConsulKVEntry | null) => void): void {
|
||||||
|
if (!this.changeNotifiers.has(key)) {
|
||||||
|
this.changeNotifiers.set(key, new Set());
|
||||||
|
}
|
||||||
|
this.changeNotifiers.get(key)!.add(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeChangeNotifier(key: string, callback: (entry: ConsulKVEntry | null) => void): void {
|
||||||
|
const notifiers = this.changeNotifiers.get(key);
|
||||||
|
if (notifiers) {
|
||||||
|
notifiers.delete(callback);
|
||||||
|
if (notifiers.size === 0) {
|
||||||
|
this.changeNotifiers.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyChange(key: string, datacenter: string, namespace: string, entry: ConsulKVEntry | null): void {
|
||||||
|
const notifierKey = `${datacenter}:${namespace}:${key}`;
|
||||||
|
const notifiers = this.changeNotifiers.get(notifierKey);
|
||||||
|
if (notifiers) {
|
||||||
|
notifiers.forEach(callback => callback(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getKeysWithPrefix(prefix: string, datacenter: string = 'dc1', namespace: string = 'default'): Promise<ConsulKVEntry[]> {
|
||||||
|
return await this.prismaService.consulKVEntry.findMany({
|
||||||
|
where: {
|
||||||
|
key: { startsWith: prefix },
|
||||||
|
datacenter,
|
||||||
|
namespace,
|
||||||
|
},
|
||||||
|
orderBy: { key: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listKeys(
|
||||||
|
prefix: string,
|
||||||
|
separator: string | undefined,
|
||||||
|
datacenter: string = 'dc1',
|
||||||
|
namespace: string = 'default',
|
||||||
|
): Promise<string[]> {
|
||||||
|
const entries = await this.getKeysWithPrefix(prefix, datacenter, namespace);
|
||||||
|
|
||||||
|
if (!separator) {
|
||||||
|
return entries.map(e => e.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const keyAfterPrefix = entry.key.substring(prefix.length);
|
||||||
|
const sepIndex = keyAfterPrefix.indexOf(separator);
|
||||||
|
|
||||||
|
if (sepIndex === -1) {
|
||||||
|
keys.add(entry.key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
keys.add(prefix + keyAfterPrefix.substring(0, sepIndex + separator.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(keys).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async putKey(
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
flags: number = 0,
|
||||||
|
datacenter: string = 'dc1',
|
||||||
|
namespace: string = 'default',
|
||||||
|
cas?: number,
|
||||||
|
acquire?: string,
|
||||||
|
release?: string,
|
||||||
|
): Promise<{ success: boolean; entry?: ConsulKVEntry }> {
|
||||||
|
const existing = await this.getKey(key, datacenter, namespace);
|
||||||
|
|
||||||
|
// Validate session exists if trying to acquire
|
||||||
|
if (acquire && !this.isSessionValid(acquire)) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle lock acquisition - must check before CAS
|
||||||
|
if (acquire && existing?.session && existing.session !== acquire) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cas === 0 && existing) {
|
||||||
|
const isUnlocked = !existing.session;
|
||||||
|
const isSameSession = existing.session === acquire;
|
||||||
|
const allowedOperation = acquire && (isUnlocked || isSameSession);
|
||||||
|
|
||||||
|
if (!allowedOperation) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cas !== undefined && cas > 0 && (!existing || existing.modifyIndex !== cas)) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle lock release
|
||||||
|
if (release && (!existing || existing.session !== release)) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const modifyIndex = this.getNextIndex();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const lockIndex = acquire && existing.session !== acquire ? existing.lockIndex + 1 : existing.lockIndex;
|
||||||
|
const session = release ? null : acquire || existing.session;
|
||||||
|
|
||||||
|
const updated = await this.prismaService.consulKVEntry.update({
|
||||||
|
where: { key },
|
||||||
|
data: {
|
||||||
|
value,
|
||||||
|
flags,
|
||||||
|
modifyIndex,
|
||||||
|
lockIndex,
|
||||||
|
session,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.notifyChange(key, datacenter, namespace, updated);
|
||||||
|
|
||||||
|
return { success: true, entry: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
const createIndex = this.getNextIndex();
|
||||||
|
const entry = await this.prismaService.consulKVEntry.create({
|
||||||
|
data: {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
flags,
|
||||||
|
createIndex,
|
||||||
|
modifyIndex: createIndex,
|
||||||
|
lockIndex: acquire ? 1 : 0,
|
||||||
|
session: acquire || null,
|
||||||
|
datacenter,
|
||||||
|
namespace,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.notifyChange(key, datacenter, namespace, entry);
|
||||||
|
|
||||||
|
return { success: true, entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteKey(
|
||||||
|
key: string,
|
||||||
|
datacenter: string = 'dc1',
|
||||||
|
namespace: string = 'default',
|
||||||
|
recurse: boolean = false,
|
||||||
|
cas?: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (recurse) {
|
||||||
|
const entries = await this.getKeysWithPrefix(key, datacenter, namespace);
|
||||||
|
await this.prismaService.consulKVEntry.deleteMany({
|
||||||
|
where: {
|
||||||
|
key: { in: entries.map(e => e.key) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
entries.forEach(entry => {
|
||||||
|
this.notifyChange(entry.key, datacenter, namespace, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.getKey(key, datacenter, namespace);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cas !== undefined && cas > 0 && existing.modifyIndex !== cas) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prismaService.consulKVEntry.delete({
|
||||||
|
where: { key },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.notifyChange(key, datacenter, namespace, null);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(name?: string, ttl?: string, datacenter: string = 'dc1'): Promise<string> {
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
this.sessions.set(sessionId, {
|
||||||
|
name,
|
||||||
|
ttl: ttl || '15s',
|
||||||
|
datacenter,
|
||||||
|
createdAt: now,
|
||||||
|
lastRenewal: now,
|
||||||
|
});
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async renewSession(sessionId: string): Promise<boolean> {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.lastRenewal = new Date();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSessionValid(sessionId: string): boolean {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttlMs = this.parseTTL(session.ttl);
|
||||||
|
const now = Date.now();
|
||||||
|
const lastRenewalTime = session.lastRenewal.getTime();
|
||||||
|
|
||||||
|
return now - lastRenewalTime < ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseTTL(ttl?: string): number {
|
||||||
|
if (!ttl) {
|
||||||
|
return 15000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = ttl.match(/^(\d+)(s|m|h)?$/);
|
||||||
|
if (!match) {
|
||||||
|
return 15000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = parseInt(match[1]);
|
||||||
|
const unit = match[2] || 's';
|
||||||
|
|
||||||
|
if (unit === 's') {
|
||||||
|
return value * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === 'm') {
|
||||||
|
return value * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupExpiredSessions(): Promise<void> {
|
||||||
|
const expiredSessions: string[] = [];
|
||||||
|
|
||||||
|
for (const [sessionId, session] of this.sessions.entries()) {
|
||||||
|
if (!this.isSessionValid(sessionId)) {
|
||||||
|
expiredSessions.push(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sessionId of expiredSessions) {
|
||||||
|
await this.destroySession(sessionId, this.sessions.get(sessionId)!.datacenter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroySession(sessionId: string, datacenter: string = 'dc1'): Promise<boolean> {
|
||||||
|
const sessionExists = this.sessions.has(sessionId);
|
||||||
|
|
||||||
|
if (!sessionExists) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prismaService.consulKVEntry.updateMany({
|
||||||
|
where: { session: sessionId },
|
||||||
|
data: { session: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(sessionId: string): Promise<{ name?: string; ttl?: string; datacenter: string } | undefined> {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: session.name,
|
||||||
|
ttl: session.ttl,
|
||||||
|
datacenter: session.datacenter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Action } from '../action.enum';
|
|||||||
import { ExistingActionHandlers } from './default-action-handler.constants';
|
import { ExistingActionHandlers } from './default-action-handler.constants';
|
||||||
import * as Joi from 'joi';
|
import * as Joi from 'joi';
|
||||||
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { UnsupportedOperationException } from '../aws-shared-entities/aws-exceptions';
|
||||||
|
|
||||||
export const DefaultActionHandlerProvider = (symbol: InjectionToken, format: Format, actions: Action[]): Provider => ({
|
export const DefaultActionHandlerProvider = (symbol: InjectionToken, format: Format, actions: Action[]): Provider => ({
|
||||||
provide: symbol,
|
provide: symbol,
|
||||||
@@ -10,10 +11,17 @@ export const DefaultActionHandlerProvider = (symbol: InjectionToken, format: For
|
|||||||
const cloned = { ...existingActionHandlers };
|
const cloned = { ...existingActionHandlers };
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
if (!cloned[action]) {
|
if (!cloned[action]) {
|
||||||
cloned[action] = new (class Default extends AbstractActionHandler { action = action; format = format; validator = Joi.object(); handle = () => {} });
|
cloned[action] = new (class Default extends AbstractActionHandler {
|
||||||
|
action = action;
|
||||||
|
format = format;
|
||||||
|
validator = Joi.object();
|
||||||
|
handle = () => {
|
||||||
|
throw new UnsupportedOperationException(`Action ${action} is not yet implemented`);
|
||||||
|
};
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cloned;
|
return cloned;
|
||||||
},
|
},
|
||||||
inject: [ExistingActionHandlers]
|
inject: [ExistingActionHandlers],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
AttachRolePolicyCommand,
|
AttachRolePolicyCommand,
|
||||||
ListAttachedRolePoliciesCommand,
|
ListAttachedRolePoliciesCommand,
|
||||||
ListRolePoliciesCommand,
|
ListRolePoliciesCommand,
|
||||||
|
PutRolePolicyCommand,
|
||||||
|
GetRolePolicyCommand,
|
||||||
|
UpdateRoleDescriptionCommand,
|
||||||
} from '@aws-sdk/client-iam';
|
} from '@aws-sdk/client-iam';
|
||||||
import { AppModule } from '../../app.module';
|
import { AppModule } from '../../app.module';
|
||||||
import { PrismaService } from '../../_prisma/prisma.service';
|
import { PrismaService } from '../../_prisma/prisma.service';
|
||||||
@@ -246,6 +249,71 @@ describe('IAM Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('UpdateRoleDescription', () => {
|
||||||
|
let roleName: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
roleName = 'UpdateDescriptionTest';
|
||||||
|
const assumeRolePolicyDocument = JSON.stringify({
|
||||||
|
Version: '2012-10-17',
|
||||||
|
Statement: [
|
||||||
|
{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Principal: { Service: 'lambda.amazonaws.com' },
|
||||||
|
Action: 'sts:AssumeRole',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await iamClient.send(
|
||||||
|
new CreateRoleCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
AssumeRolePolicyDocument: assumeRolePolicyDocument,
|
||||||
|
Path: '/',
|
||||||
|
Description: 'Initial description',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update role description', async () => {
|
||||||
|
const newDescription = 'Updated role description';
|
||||||
|
const command = new UpdateRoleDescriptionCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
Description: newDescription,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await iamClient.send(command);
|
||||||
|
|
||||||
|
expect(response.Role).toBeDefined();
|
||||||
|
expect(response.Role?.RoleName).toBe(roleName);
|
||||||
|
expect(response.Role?.Description).toBe(newDescription);
|
||||||
|
|
||||||
|
// Verify by getting the role
|
||||||
|
const getResponse = await iamClient.send(new GetRoleCommand({ RoleName: roleName }));
|
||||||
|
expect(getResponse.Role?.Description).toBe(newDescription);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update role with empty description', async () => {
|
||||||
|
const command = new UpdateRoleDescriptionCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
Description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await iamClient.send(command);
|
||||||
|
|
||||||
|
expect(response.Role?.Description).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to update non-existent role', async () => {
|
||||||
|
const command = new UpdateRoleDescriptionCommand({
|
||||||
|
RoleName: 'NonExistentRole',
|
||||||
|
Description: 'Some description',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(iamClient.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('DeleteRole', () => {
|
describe('DeleteRole', () => {
|
||||||
it('should delete a role', async () => {
|
it('should delete a role', async () => {
|
||||||
const roleName = 'DeleteRoleTest';
|
const roleName = 'DeleteRoleTest';
|
||||||
@@ -670,14 +738,85 @@ describe('IAM Integration Tests', () => {
|
|||||||
|
|
||||||
describe('ListRolePolicies', () => {
|
describe('ListRolePolicies', () => {
|
||||||
it('should list role policies', async () => {
|
it('should list role policies', async () => {
|
||||||
|
// Create a role first
|
||||||
|
const roleName = 'RoleWithPolicies';
|
||||||
|
await iamClient.send(
|
||||||
|
new CreateRoleCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
Path: '/',
|
||||||
|
AssumeRolePolicyDocument: JSON.stringify({
|
||||||
|
Version: '2012-10-17',
|
||||||
|
Statement: [
|
||||||
|
{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Principal: { Service: 'lambda.amazonaws.com' },
|
||||||
|
Action: 'sts:AssumeRole',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const command = new ListRolePoliciesCommand({
|
const command = new ListRolePoliciesCommand({
|
||||||
RoleName: 'SomeRole',
|
RoleName: roleName,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await iamClient.send(command);
|
const response = await iamClient.send(command);
|
||||||
|
|
||||||
expect(response.PolicyNames).toBeDefined();
|
expect(response.PolicyNames).toBeDefined();
|
||||||
expect(Array.isArray(response.PolicyNames)).toBe(true);
|
expect(Array.isArray(response.PolicyNames)).toBe(true);
|
||||||
|
expect(response.PolicyNames!.length).toBe(0); // No inline policies yet
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list inline policies for a role', async () => {
|
||||||
|
// Create a role
|
||||||
|
const roleName = 'RoleWithInlinePolicies';
|
||||||
|
await iamClient.send(
|
||||||
|
new CreateRoleCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
Path: '/',
|
||||||
|
AssumeRolePolicyDocument: JSON.stringify({
|
||||||
|
Version: '2012-10-17',
|
||||||
|
Statement: [
|
||||||
|
{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Principal: { Service: 'lambda.amazonaws.com' },
|
||||||
|
Action: 'sts:AssumeRole',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add inline policies
|
||||||
|
await iamClient.send(
|
||||||
|
new PutRolePolicyCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
PolicyName: 'Policy1',
|
||||||
|
PolicyDocument: JSON.stringify({ Version: '2012-10-17', Statement: [] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await iamClient.send(
|
||||||
|
new PutRolePolicyCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
PolicyName: 'Policy2',
|
||||||
|
PolicyDocument: JSON.stringify({ Version: '2012-10-17', Statement: [] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// List inline policies
|
||||||
|
const response = await iamClient.send(
|
||||||
|
new ListRolePoliciesCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.PolicyNames).toBeDefined();
|
||||||
|
expect(Array.isArray(response.PolicyNames)).toBe(true);
|
||||||
|
expect(response.PolicyNames!.length).toBe(2);
|
||||||
|
expect(response.PolicyNames).toContain('Policy1');
|
||||||
|
expect(response.PolicyNames).toContain('Policy2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -790,4 +929,183 @@ describe('IAM Integration Tests', () => {
|
|||||||
await expect(iamClient.send(new GetRoleCommand({ RoleName: roleName }))).rejects.toThrow();
|
await expect(iamClient.send(new GetRoleCommand({ RoleName: roleName }))).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Inline Policies', () => {
|
||||||
|
const roleName = 'TestRole';
|
||||||
|
const policyName = 'TestInlinePolicy';
|
||||||
|
const assumeRolePolicyDocument = JSON.stringify({
|
||||||
|
Version: '2012-10-17',
|
||||||
|
Statement: [
|
||||||
|
{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Principal: { Service: 'lambda.amazonaws.com' },
|
||||||
|
Action: 'sts:AssumeRole',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const policyDocument = JSON.stringify({
|
||||||
|
Version: '2012-10-17',
|
||||||
|
Statement: [
|
||||||
|
{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Action: 's3:GetObject',
|
||||||
|
Resource: 'arn:aws:s3:::my-bucket/*',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await iamClient.send(
|
||||||
|
new CreateRoleCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
Path: '/',
|
||||||
|
AssumeRolePolicyDocument: assumeRolePolicyDocument,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
try {
|
||||||
|
await iamClient.send(new DeleteRoleCommand({ RoleName: roleName }));
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore if role doesn't exist
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should put an inline policy on a role', async () => {
|
||||||
|
const response = await iamClient.send(
|
||||||
|
new PutRolePolicyCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
PolicyName: policyName,
|
||||||
|
PolicyDocument: policyDocument,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.$metadata).toBeDefined();
|
||||||
|
expect(response.$metadata.httpStatusCode).toBe(200);
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
const policy = await prismaService.iamRoleInlinePolicy.findFirst({
|
||||||
|
where: {
|
||||||
|
roleName,
|
||||||
|
policyName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(policy).toBeDefined();
|
||||||
|
expect(policy?.policyDocument).toBe(policyDocument);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retrieve an inline policy from a role', async () => {
|
||||||
|
await iamClient.send(
|
||||||
|
new PutRolePolicyCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
PolicyName: policyName,
|
||||||
|
PolicyDocument: policyDocument,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await iamClient.send(
|
||||||
|
new GetRolePolicyCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
PolicyName: policyName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.RoleName).toBe(roleName);
|
||||||
|
expect(response.PolicyName).toBe(policyName);
|
||||||
|
expect(response.PolicyDocument).toBeDefined();
|
||||||
|
expect(response.PolicyDocument).toContain('s3:GetObject');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update an existing inline policy', async () => {
|
||||||
|
await iamClient.send(
|
||||||
|
new PutRolePolicyCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
PolicyName: policyName,
|
||||||
|
PolicyDocument: policyDocument,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedPolicyDocument = JSON.stringify({
|
||||||
|
Version: '2012-10-17',
|
||||||
|
Statement: [
|
||||||
|
{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Action: ['s3:GetObject', 's3:PutObject'],
|
||||||
|
Resource: 'arn:aws:s3:::my-bucket/*',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await iamClient.send(
|
||||||
|
new PutRolePolicyCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
PolicyName: policyName,
|
||||||
|
PolicyDocument: updatedPolicyDocument,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await iamClient.send(
|
||||||
|
new GetRolePolicyCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
PolicyName: policyName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.PolicyDocument).toContain('s3:PutObject');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when getting non-existent inline policy', async () => {
|
||||||
|
try {
|
||||||
|
await iamClient.send(
|
||||||
|
new GetRolePolicyCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
PolicyName: 'NonExistentPolicy',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fail('Expected NoSuchEntityException error');
|
||||||
|
} catch (error: any) {
|
||||||
|
expect(error.name).toBe('NoSuchEntityException');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when getting inline policy for non-existent role', async () => {
|
||||||
|
try {
|
||||||
|
await iamClient.send(
|
||||||
|
new GetRolePolicyCommand({
|
||||||
|
RoleName: 'NonExistentRole',
|
||||||
|
PolicyName: policyName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fail('Expected NotFoundException error');
|
||||||
|
} catch (error: any) {
|
||||||
|
expect(error.name).toBe('NotFoundException');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete inline policies when role is deleted (cascade)', async () => {
|
||||||
|
await iamClient.send(
|
||||||
|
new PutRolePolicyCommand({
|
||||||
|
RoleName: roleName,
|
||||||
|
PolicyName: policyName,
|
||||||
|
PolicyDocument: policyDocument,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify policy exists
|
||||||
|
let policy = await prismaService.iamRoleInlinePolicy.findFirst({
|
||||||
|
where: { roleName, policyName },
|
||||||
|
});
|
||||||
|
expect(policy).toBeDefined();
|
||||||
|
|
||||||
|
// Delete role
|
||||||
|
await iamClient.send(new DeleteRoleCommand({ RoleName: roleName }));
|
||||||
|
|
||||||
|
// Verify policy was cascade deleted
|
||||||
|
policy = await prismaService.iamRoleInlinePolicy.findFirst({
|
||||||
|
where: { roleName, policyName },
|
||||||
|
});
|
||||||
|
expect(policy).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
40
src/iam/get-role-policy.handler.ts
Normal file
40
src/iam/get-role-policy.handler.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { IamService } from './iam.service';
|
||||||
|
import { NoSuchEntity } from '../aws-shared-entities/aws-exceptions';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
PolicyName: string;
|
||||||
|
RoleName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetRolePolicyHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly iamService: IamService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.IamGetRolePolicy;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
PolicyName: Joi.string().required(),
|
||||||
|
RoleName: Joi.string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ PolicyName, RoleName }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
// Verify role exists
|
||||||
|
await this.iamService.findOneRoleByName(awsProperties.accountId, RoleName);
|
||||||
|
|
||||||
|
// Get inline policy
|
||||||
|
const inlinePolicy = await this.iamService.getRoleInlinePolicy(awsProperties.accountId, RoleName, PolicyName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
RoleName,
|
||||||
|
PolicyName,
|
||||||
|
PolicyDocument: inlinePolicy.policyDocument,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/iam/iam-role-inline-policy.entity.ts
Normal file
21
src/iam/iam-role-inline-policy.entity.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { IamRoleInlinePolicy as PrismaIamRoleInlinePolicy } from '@prisma/client';
|
||||||
|
|
||||||
|
export class IamRoleInlinePolicy implements PrismaIamRoleInlinePolicy {
|
||||||
|
id: string;
|
||||||
|
roleName: string;
|
||||||
|
policyName: string;
|
||||||
|
policyDocument: string;
|
||||||
|
accountId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(policy: PrismaIamRoleInlinePolicy) {
|
||||||
|
this.id = policy.id;
|
||||||
|
this.roleName = policy.roleName;
|
||||||
|
this.policyName = policy.policyName;
|
||||||
|
this.policyDocument = policy.policyDocument;
|
||||||
|
this.accountId = policy.accountId;
|
||||||
|
this.createdAt = policy.createdAt;
|
||||||
|
this.updatedAt = policy.updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ import { GetPolicyVersionHandler } from './get-policy-version.handler';
|
|||||||
import { AttachRolePolicyHandler } from './attach-role-policy.handler';
|
import { AttachRolePolicyHandler } from './attach-role-policy.handler';
|
||||||
import { ListAttachedRolePoliciesHandler } from './list-attached-role-policies';
|
import { ListAttachedRolePoliciesHandler } from './list-attached-role-policies';
|
||||||
import { ListRolePoliciesHandler } from './list-role-policies.handler';
|
import { ListRolePoliciesHandler } from './list-role-policies.handler';
|
||||||
|
import { GetRolePolicyHandler } from './get-role-policy.handler';
|
||||||
|
import { PutRolePolicyHandler } from './put-role-policy.handler';
|
||||||
|
import { UpdateRoleDescriptionHandler } from './update-role-description.handler';
|
||||||
|
|
||||||
const handlers = [
|
const handlers = [
|
||||||
AttachRolePolicyHandler,
|
AttachRolePolicyHandler,
|
||||||
@@ -27,8 +30,11 @@ const handlers = [
|
|||||||
GetPolicyVersionHandler,
|
GetPolicyVersionHandler,
|
||||||
GetPolicyHandler,
|
GetPolicyHandler,
|
||||||
GetRoleHandler,
|
GetRoleHandler,
|
||||||
|
GetRolePolicyHandler,
|
||||||
ListAttachedRolePoliciesHandler,
|
ListAttachedRolePoliciesHandler,
|
||||||
ListRolePoliciesHandler,
|
ListRolePoliciesHandler,
|
||||||
|
PutRolePolicyHandler,
|
||||||
|
UpdateRoleDescriptionHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ export class IamService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateRoleDescription(accountId: string, name: string, description: string): Promise<IamRole> {
|
||||||
|
// First verify the role exists
|
||||||
|
const role = await this.findOneRoleByName(accountId, name);
|
||||||
|
|
||||||
|
// Update the description
|
||||||
|
const updated = await this.prismaService.iamRole.update({
|
||||||
|
where: {
|
||||||
|
id: role.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new IamRole(updated);
|
||||||
|
}
|
||||||
|
|
||||||
async listRolePolicies(): Promise<IamPolicy[]> {
|
async listRolePolicies(): Promise<IamPolicy[]> {
|
||||||
const records = await this.prismaService.iamPolicy.findMany();
|
const records = await this.prismaService.iamPolicy.findMany();
|
||||||
return records.map(r => new IamPolicy(r));
|
return records.map(r => new IamPolicy(r));
|
||||||
@@ -181,4 +198,66 @@ export class IamService {
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async putRoleInlinePolicy(accountId: string, roleName: string, policyName: string, policyDocument: string): Promise<void> {
|
||||||
|
// Verify role exists
|
||||||
|
await this.findOneRoleByName(accountId, roleName);
|
||||||
|
|
||||||
|
await this.prismaService.iamRoleInlinePolicy.upsert({
|
||||||
|
where: {
|
||||||
|
accountId_roleName_policyName: {
|
||||||
|
accountId,
|
||||||
|
roleName,
|
||||||
|
policyName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
accountId,
|
||||||
|
roleName,
|
||||||
|
policyName,
|
||||||
|
policyDocument,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
policyDocument,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRoleInlinePolicy(accountId: string, roleName: string, policyName: string): Promise<{ policyDocument: string }> {
|
||||||
|
// Verify role exists
|
||||||
|
await this.findOneRoleByName(accountId, roleName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const policy = await this.prismaService.iamRoleInlinePolicy.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
accountId_roleName_policyName: {
|
||||||
|
accountId,
|
||||||
|
roleName,
|
||||||
|
policyName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { policyDocument: policy.policyDocument };
|
||||||
|
} catch (err) {
|
||||||
|
throw new NoSuchEntity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRoleInlinePolicies(accountId: string, roleName: string): Promise<string[]> {
|
||||||
|
// Verify role exists
|
||||||
|
await this.findOneRoleByName(accountId, roleName);
|
||||||
|
|
||||||
|
const policies = await this.prismaService.iamRoleInlinePolicy.findMany({
|
||||||
|
where: {
|
||||||
|
accountId,
|
||||||
|
roleName,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
policyName: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return policies.map(p => p.policyName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ export class ListRolePoliciesHandler extends AbstractActionHandler<QueryParams>
|
|||||||
});
|
});
|
||||||
|
|
||||||
protected async handle({ RoleName }: QueryParams, { awsProperties }: RequestContext) {
|
protected async handle({ RoleName }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
const policies = await this.iamService.listRolePolicies();
|
const policyNames = await this.iamService.listRoleInlinePolicies(awsProperties.accountId, RoleName);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
IsTruncated: false,
|
IsTruncated: false,
|
||||||
PolicyNames: {
|
PolicyNames: {
|
||||||
member: policies?.map(p => p.name) || [],
|
member: policyNames,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/iam/put-role-policy.handler.ts
Normal file
32
src/iam/put-role-policy.handler.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { IamService } from './iam.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
PolicyDocument: string;
|
||||||
|
PolicyName: string;
|
||||||
|
RoleName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PutRolePolicyHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly iamService: IamService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.IamPutRolePolicy;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
PolicyDocument: Joi.string().required(),
|
||||||
|
PolicyName: Joi.string().required(),
|
||||||
|
RoleName: Joi.string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ PolicyDocument, PolicyName, RoleName }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
await this.iamService.putRoleInlinePolicy(awsProperties.accountId, RoleName, PolicyName, PolicyDocument);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/iam/update-role-description.handler.ts
Normal file
33
src/iam/update-role-description.handler.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { IamService } from './iam.service';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
RoleName: string;
|
||||||
|
Description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UpdateRoleDescriptionHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly iamService: IamService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.IamUpdateRoleDescription;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
RoleName: Joi.string().min(1).max(64).required(),
|
||||||
|
Description: Joi.string().max(1000).allow('').required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ RoleName, Description }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const role = await this.iamService.updateRoleDescription(awsProperties.accountId, RoleName, Description);
|
||||||
|
|
||||||
|
return {
|
||||||
|
Role: role.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
SigningAlgorithmSpec,
|
SigningAlgorithmSpec,
|
||||||
KeyUsageType,
|
KeyUsageType,
|
||||||
KeySpec,
|
KeySpec,
|
||||||
|
DeleteAliasCommand,
|
||||||
|
ScheduleKeyDeletionCommand,
|
||||||
|
KeyState,
|
||||||
} from '@aws-sdk/client-kms';
|
} from '@aws-sdk/client-kms';
|
||||||
import { AppModule } from '../../app.module';
|
import { AppModule } from '../../app.module';
|
||||||
import { PrismaService } from '../../_prisma/prisma.service';
|
import { PrismaService } from '../../_prisma/prisma.service';
|
||||||
@@ -231,6 +234,17 @@ describe('KMS Integration Tests', () => {
|
|||||||
expect(response.KeyMetadata?.Description).toBe('Key for describe test');
|
expect(response.KeyMetadata?.Description).toBe('Key for describe test');
|
||||||
expect(response.KeyMetadata?.Enabled).toBe(true);
|
expect(response.KeyMetadata?.Enabled).toBe(true);
|
||||||
expect(response.KeyMetadata?.CreationDate).toBeDefined();
|
expect(response.KeyMetadata?.CreationDate).toBeDefined();
|
||||||
|
// Verify AWS API compliance - both KeySpec and CustomerMasterKeySpec should be present
|
||||||
|
expect(response.KeyMetadata?.KeySpec).toBeDefined();
|
||||||
|
expect(response.KeyMetadata?.CustomerMasterKeySpec).toBeDefined();
|
||||||
|
expect(response.KeyMetadata?.CustomerMasterKeySpec).toBe(response.KeyMetadata?.KeySpec);
|
||||||
|
// Verify other required fields per AWS API spec
|
||||||
|
expect(response.KeyMetadata?.Arn).toBeDefined();
|
||||||
|
expect(response.KeyMetadata?.KeyManager).toBe('CUSTOMER');
|
||||||
|
expect(response.KeyMetadata?.KeyState).toBeDefined();
|
||||||
|
expect(response.KeyMetadata?.KeyUsage).toBeDefined();
|
||||||
|
expect(response.KeyMetadata?.Origin).toBeDefined();
|
||||||
|
expect(response.KeyMetadata?.MultiRegion).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should describe key by ARN', async () => {
|
it('should describe key by ARN', async () => {
|
||||||
@@ -824,4 +838,210 @@ describe('KMS Integration Tests', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DeleteAlias', () => {
|
||||||
|
let keyId: string;
|
||||||
|
const aliasName = 'alias/delete-test';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const createResponse = await kmsClient.send(
|
||||||
|
new CreateKeyCommand({
|
||||||
|
Description: 'Key for delete alias test',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
keyId = createResponse.KeyMetadata!.KeyId!;
|
||||||
|
|
||||||
|
// Create an alias to delete
|
||||||
|
await kmsClient.send(
|
||||||
|
new CreateAliasCommand({
|
||||||
|
AliasName: aliasName,
|
||||||
|
TargetKeyId: keyId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete an alias successfully', async () => {
|
||||||
|
const command = new DeleteAliasCommand({
|
||||||
|
AliasName: aliasName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await kmsClient.send(command);
|
||||||
|
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
|
||||||
|
// Verify alias no longer exists by trying to describe the key using the alias
|
||||||
|
await expect(kmsClient.send(new DescribeKeyCommand({ KeyId: aliasName }))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to delete non-existent alias', async () => {
|
||||||
|
const command = new DeleteAliasCommand({
|
||||||
|
AliasName: 'alias/does-not-exist',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(kmsClient.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow key to be used after alias deletion', async () => {
|
||||||
|
// Delete the alias
|
||||||
|
await kmsClient.send(new DeleteAliasCommand({ AliasName: aliasName }));
|
||||||
|
|
||||||
|
// Key should still be accessible by KeyId
|
||||||
|
const describeResponse = await kmsClient.send(new DescribeKeyCommand({ KeyId: keyId }));
|
||||||
|
|
||||||
|
expect(describeResponse.KeyMetadata?.KeyId).toBe(keyId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deletion of multiple aliases for same key', async () => {
|
||||||
|
const secondAlias = 'alias/delete-test-2';
|
||||||
|
const thirdAlias = 'alias/delete-test-3';
|
||||||
|
|
||||||
|
// Create additional aliases
|
||||||
|
await kmsClient.send(
|
||||||
|
new CreateAliasCommand({
|
||||||
|
AliasName: secondAlias,
|
||||||
|
TargetKeyId: keyId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await kmsClient.send(
|
||||||
|
new CreateAliasCommand({
|
||||||
|
AliasName: thirdAlias,
|
||||||
|
TargetKeyId: keyId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete first alias
|
||||||
|
await kmsClient.send(new DeleteAliasCommand({ AliasName: aliasName }));
|
||||||
|
|
||||||
|
// Other aliases should still work
|
||||||
|
const describeResponse = await kmsClient.send(new DescribeKeyCommand({ KeyId: secondAlias }));
|
||||||
|
expect(describeResponse.KeyMetadata?.KeyId).toBe(keyId);
|
||||||
|
|
||||||
|
// Delete remaining aliases
|
||||||
|
await kmsClient.send(new DeleteAliasCommand({ AliasName: secondAlias }));
|
||||||
|
await kmsClient.send(new DeleteAliasCommand({ AliasName: thirdAlias }));
|
||||||
|
|
||||||
|
// All aliases should be gone
|
||||||
|
await expect(kmsClient.send(new DescribeKeyCommand({ KeyId: aliasName }))).rejects.toThrow();
|
||||||
|
await expect(kmsClient.send(new DescribeKeyCommand({ KeyId: secondAlias }))).rejects.toThrow();
|
||||||
|
await expect(kmsClient.send(new DescribeKeyCommand({ KeyId: thirdAlias }))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ScheduleKeyDeletion', () => {
|
||||||
|
let keyId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const createResponse = await kmsClient.send(
|
||||||
|
new CreateKeyCommand({
|
||||||
|
Description: 'Key for deletion test',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
keyId = createResponse.KeyMetadata!.KeyId!;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should schedule key deletion with default window', async () => {
|
||||||
|
const command = new ScheduleKeyDeletionCommand({
|
||||||
|
KeyId: keyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await kmsClient.send(command);
|
||||||
|
|
||||||
|
expect(response.KeyId).toBe(keyId);
|
||||||
|
expect(response.KeyState).toBe(KeyState.PendingDeletion);
|
||||||
|
expect(response.PendingWindowInDays).toBe(30);
|
||||||
|
expect(response.DeletionDate).toBeDefined();
|
||||||
|
|
||||||
|
// Verify key state changed
|
||||||
|
const describeResponse = await kmsClient.send(new DescribeKeyCommand({ KeyId: keyId }));
|
||||||
|
expect(describeResponse.KeyMetadata?.KeyState).toBe(KeyState.PendingDeletion);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should schedule key deletion with custom window', async () => {
|
||||||
|
const command = new ScheduleKeyDeletionCommand({
|
||||||
|
KeyId: keyId,
|
||||||
|
PendingWindowInDays: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await kmsClient.send(command);
|
||||||
|
|
||||||
|
expect(response.KeyId).toBe(keyId);
|
||||||
|
expect(response.PendingWindowInDays).toBe(7);
|
||||||
|
expect(response.DeletionDate).toBeDefined();
|
||||||
|
|
||||||
|
// DeletionDate should be a Date object
|
||||||
|
expect(response.DeletionDate).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should schedule key deletion with maximum window', async () => {
|
||||||
|
const command = new ScheduleKeyDeletionCommand({
|
||||||
|
KeyId: keyId,
|
||||||
|
PendingWindowInDays: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await kmsClient.send(command);
|
||||||
|
|
||||||
|
expect(response.PendingWindowInDays).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should schedule key deletion by ARN', async () => {
|
||||||
|
const describeResponse = await kmsClient.send(new DescribeKeyCommand({ KeyId: keyId }));
|
||||||
|
const arn = describeResponse.KeyMetadata!.Arn!;
|
||||||
|
|
||||||
|
const command = new ScheduleKeyDeletionCommand({
|
||||||
|
KeyId: arn,
|
||||||
|
PendingWindowInDays: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await kmsClient.send(command);
|
||||||
|
|
||||||
|
expect(response.KeyId).toBe(keyId);
|
||||||
|
expect(response.KeyState).toBe(KeyState.PendingDeletion);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should schedule key deletion by alias', async () => {
|
||||||
|
const aliasName = 'alias/deletion-test';
|
||||||
|
await kmsClient.send(
|
||||||
|
new CreateAliasCommand({
|
||||||
|
AliasName: aliasName,
|
||||||
|
TargetKeyId: keyId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = new ScheduleKeyDeletionCommand({
|
||||||
|
KeyId: aliasName,
|
||||||
|
PendingWindowInDays: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await kmsClient.send(command);
|
||||||
|
|
||||||
|
expect(response.KeyId).toBe(keyId);
|
||||||
|
expect(response.PendingWindowInDays).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to schedule deletion of non-existent key', async () => {
|
||||||
|
const command = new ScheduleKeyDeletionCommand({
|
||||||
|
KeyId: 'non-existent-key-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(kmsClient.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid pending window (too short)', async () => {
|
||||||
|
const command = new ScheduleKeyDeletionCommand({
|
||||||
|
KeyId: keyId,
|
||||||
|
PendingWindowInDays: 6, // Minimum is 7
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(kmsClient.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid pending window (too long)', async () => {
|
||||||
|
const command = new ScheduleKeyDeletionCommand({
|
||||||
|
KeyId: keyId,
|
||||||
|
PendingWindowInDays: 31, // Maximum is 30
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(kmsClient.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
38
src/kms/delete-alias.handler.ts
Normal file
38
src/kms/delete-alias.handler.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { KmsService } from './kms.service';
|
||||||
|
import { NotFoundException } from '../aws-shared-entities/aws-exceptions';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
AliasName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeleteAliasHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly kmsService: KmsService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Json;
|
||||||
|
action = Action.KmsDeleteAlias;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
AliasName: Joi.string().min(1).max(256).regex(new RegExp(`^alias/[a-zA-Z0-9/_-]+$`)).required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ AliasName }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
// Verify the alias exists before deleting
|
||||||
|
const alias = await this.kmsService.findAliasByName(awsProperties.accountId, awsProperties.region, AliasName);
|
||||||
|
|
||||||
|
if (!alias) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.kmsService.deleteAlias(awsProperties.accountId, awsProperties.region, AliasName);
|
||||||
|
|
||||||
|
// DeleteAlias returns an empty response on success
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,19 +119,16 @@ export class KmsKey implements PrismaKmsKey {
|
|||||||
AWSAccountId: this.accountId,
|
AWSAccountId: this.accountId,
|
||||||
Arn: this.arn,
|
Arn: this.arn,
|
||||||
CreationDate: this.createdAt.getAwsTime(),
|
CreationDate: this.createdAt.getAwsTime(),
|
||||||
CustomerMasterKeySpec: this.keySpec,
|
CustomerMasterKeySpec: this.keySpec, // Deprecated but still returned by AWS API for backwards compatibility
|
||||||
Description: this.description,
|
Description: this.description,
|
||||||
Enabled: true,
|
Enabled: this.enabled,
|
||||||
KeyId: this.id,
|
KeyId: this.id,
|
||||||
KeyManager: undefined,
|
KeyManager: 'CUSTOMER',
|
||||||
KeySpec: this.keySpec,
|
KeySpec: this.keySpec,
|
||||||
KeyState: this.keyState,
|
KeyState: this.keyState,
|
||||||
KeyUsage: this.usage,
|
KeyUsage: this.usage,
|
||||||
MultiRegion: this.multiRegion,
|
MultiRegion: this.multiRegion,
|
||||||
Origin: this.origin,
|
Origin: this.origin,
|
||||||
PendingDeletionWindowInDays: undefined,
|
|
||||||
ValidTo: undefined,
|
|
||||||
XksKeyConfiguration: undefined,
|
|
||||||
...dynamicContent,
|
...dynamicContent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ import { ListResourceTagsHandler } from './list-resource-tags.handler';
|
|||||||
import { CreateAliasHandler } from './create-alias.handler';
|
import { CreateAliasHandler } from './create-alias.handler';
|
||||||
import { GetPublicKeyHandler } from './get-public-key.handler';
|
import { GetPublicKeyHandler } from './get-public-key.handler';
|
||||||
import { SignHandler } from './sign.handler';
|
import { SignHandler } from './sign.handler';
|
||||||
|
import { DeleteAliasHandler } from './delete-alias.handler';
|
||||||
|
import { ScheduleKeyDeletionHandler } from './schedule-key-deletion.handler';
|
||||||
|
|
||||||
const handlers = [
|
const handlers = [
|
||||||
CreateAliasHandler,
|
CreateAliasHandler,
|
||||||
CreateKeyHandler,
|
CreateKeyHandler,
|
||||||
|
DeleteAliasHandler,
|
||||||
DescribeKeyHandler,
|
DescribeKeyHandler,
|
||||||
EnableKeyRotationHandler,
|
EnableKeyRotationHandler,
|
||||||
GetKeyPolicyHandler,
|
GetKeyPolicyHandler,
|
||||||
@@ -29,8 +32,9 @@ const handlers = [
|
|||||||
GetPublicKeyHandler,
|
GetPublicKeyHandler,
|
||||||
ListAliasesHandler,
|
ListAliasesHandler,
|
||||||
ListResourceTagsHandler,
|
ListResourceTagsHandler,
|
||||||
|
ScheduleKeyDeletionHandler,
|
||||||
SignHandler,
|
SignHandler,
|
||||||
]
|
];
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
Action.KmsCancelKeyDeletion,
|
Action.KmsCancelKeyDeletion,
|
||||||
@@ -83,13 +87,10 @@ const actions = [
|
|||||||
Action.KmsUpdatePrimaryRegion,
|
Action.KmsUpdatePrimaryRegion,
|
||||||
Action.KmsVerify,
|
Action.KmsVerify,
|
||||||
Action.KmsVerifyMac,
|
Action.KmsVerifyMac,
|
||||||
]
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [AwsSharedEntitiesModule, PrismaModule],
|
||||||
AwsSharedEntitiesModule,
|
|
||||||
PrismaModule,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
...handlers,
|
...handlers,
|
||||||
KmsService,
|
KmsService,
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ import { RequestContext } from '../_context/request.context';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KmsService {
|
export class KmsService {
|
||||||
constructor(
|
constructor(private readonly prismaService: PrismaService) {}
|
||||||
private readonly prismaService: PrismaService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async findOneByRef(ref: string, awsProperties: AwsProperties): Promise<KmsKey> {
|
async findOneByRef(ref: string, awsProperties: AwsProperties): Promise<KmsKey> {
|
||||||
if (ref.startsWith('arn')) {
|
if (ref.startsWith('arn')) {
|
||||||
@@ -28,25 +26,24 @@ export class KmsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findOneById(accountId: string, region: string, ref: string): Promise<KmsKey> {
|
async findOneById(accountId: string, region: string, ref: string): Promise<KmsKey> {
|
||||||
|
|
||||||
const [alias, record] = await Promise.all([
|
const [alias, record] = await Promise.all([
|
||||||
this.prismaService.kmsAlias.findFirst({
|
this.prismaService.kmsAlias.findFirst({
|
||||||
include: {
|
include: {
|
||||||
kmsKey: true
|
kmsKey: true,
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
accountId,
|
accountId,
|
||||||
region,
|
region,
|
||||||
name: ref,
|
name: ref,
|
||||||
}
|
},
|
||||||
}),
|
}),
|
||||||
this.prismaService.kmsKey.findFirst({
|
this.prismaService.kmsKey.findFirst({
|
||||||
where: {
|
where: {
|
||||||
accountId,
|
accountId,
|
||||||
region,
|
region,
|
||||||
id: ref,
|
id: ref,
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!alias?.kmsKey && !record) {
|
if (!alias?.kmsKey && !record) {
|
||||||
@@ -65,7 +62,7 @@ export class KmsService {
|
|||||||
kmsKeyId,
|
kmsKeyId,
|
||||||
name: {
|
name: {
|
||||||
gte: marker,
|
gte: marker,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
take,
|
take,
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@@ -84,7 +81,7 @@ export class KmsService {
|
|||||||
region,
|
region,
|
||||||
name: {
|
name: {
|
||||||
gte: marker,
|
gte: marker,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
take,
|
take,
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@@ -97,7 +94,7 @@ export class KmsService {
|
|||||||
|
|
||||||
async createKmsKey(data: Prisma.KmsKeyCreateInput): Promise<KmsKey> {
|
async createKmsKey(data: Prisma.KmsKeyCreateInput): Promise<KmsKey> {
|
||||||
const record = await this.prismaService.kmsKey.create({
|
const record = await this.prismaService.kmsKey.create({
|
||||||
data
|
data,
|
||||||
});
|
});
|
||||||
return new KmsKey(record);
|
return new KmsKey(record);
|
||||||
}
|
}
|
||||||
@@ -111,7 +108,33 @@ export class KmsService {
|
|||||||
|
|
||||||
async createAlias(data: Prisma.KmsAliasCreateInput) {
|
async createAlias(data: Prisma.KmsAliasCreateInput) {
|
||||||
await this.prismaService.kmsAlias.create({
|
await this.prismaService.kmsAlias.create({
|
||||||
data
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAliasByName(accountId: string, region: string, name: string): Promise<KmsAlias | null> {
|
||||||
|
const record = await this.prismaService.kmsAlias.findUnique({
|
||||||
|
where: {
|
||||||
|
accountId_region_name: {
|
||||||
|
accountId,
|
||||||
|
region,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return record ? new KmsAlias(record) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAlias(accountId: string, region: string, name: string): Promise<void> {
|
||||||
|
await this.prismaService.kmsAlias.delete({
|
||||||
|
where: {
|
||||||
|
accountId_region_name: {
|
||||||
|
accountId,
|
||||||
|
region,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/kms/schedule-key-deletion.handler.ts
Normal file
53
src/kms/schedule-key-deletion.handler.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AbstractActionHandler, AwsProperties, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { KmsService } from './kms.service';
|
||||||
|
import { NotFoundException } from '../aws-shared-entities/aws-exceptions';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { KeyState } from '@aws-sdk/client-kms';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
KeyId: string;
|
||||||
|
PendingWindowInDays?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ScheduleKeyDeletionHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly kmsService: KmsService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Json;
|
||||||
|
action = Action.KmsScheduleKeyDeletion;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
KeyId: Joi.string().required(),
|
||||||
|
PendingWindowInDays: Joi.number().integer().min(7).max(30).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ KeyId, PendingWindowInDays = 30 }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const keyRecord = await this.kmsService.findOneByRef(KeyId, awsProperties);
|
||||||
|
|
||||||
|
if (!keyRecord) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the deletion date based on the pending window
|
||||||
|
const deletionDate = new Date();
|
||||||
|
deletionDate.setDate(deletionDate.getDate() + PendingWindowInDays);
|
||||||
|
|
||||||
|
// Update the key state to PendingDeletion
|
||||||
|
await this.kmsService.updateKmsKey(keyRecord.id, {
|
||||||
|
keyState: KeyState.PendingDeletion,
|
||||||
|
// Note: In a full implementation, you'd store the deletion date
|
||||||
|
// and have a background job that actually deletes keys after the window
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
KeyId: keyRecord.id,
|
||||||
|
DeletionDate: deletionDate.getAwsTime(),
|
||||||
|
KeyState: KeyState.PendingDeletion,
|
||||||
|
PendingWindowInDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/main.ts
40
src/main.ts
@@ -1,8 +1,9 @@
|
|||||||
import { ClassSerializerInterceptor } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { NestFactory, Reflector } from '@nestjs/core';
|
import { NestFactory, Reflector } from '@nestjs/core';
|
||||||
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { S3AppModule } from './s3/s3-app.module';
|
||||||
|
import { ConsulKVAppModule } from './consul-kv/consul-kv-app.module';
|
||||||
import { CommonConfig } from './config/common-config.interface';
|
import { CommonConfig } from './config/common-config.interface';
|
||||||
import { AwsExceptionFilter } from './_context/exception.filter';
|
import { AwsExceptionFilter } from './_context/exception.filter';
|
||||||
|
|
||||||
@@ -19,8 +20,8 @@ Date.prototype.getAwsTime = function (this: Date) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
// Start main application
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
// app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
|
||||||
app.useGlobalFilters(new AwsExceptionFilter());
|
app.useGlobalFilters(new AwsExceptionFilter());
|
||||||
|
|
||||||
// Parse JSON for SNS/SQS
|
// Parse JSON for SNS/SQS
|
||||||
@@ -36,6 +37,39 @@ Date.prototype.getAwsTime = function (this: Date) {
|
|||||||
app.use(bodyParser.text({ type: 'text/xml' }));
|
app.use(bodyParser.text({ type: 'text/xml' }));
|
||||||
|
|
||||||
const configService: ConfigService<CommonConfig, true> = app.get(ConfigService);
|
const configService: ConfigService<CommonConfig, true> = app.get(ConfigService);
|
||||||
|
const mainPort = configService.get('PORT');
|
||||||
|
|
||||||
await app.listen(configService.get('PORT'), () => console.log(`Listening on port ${configService.get('PORT')}`));
|
await app.listen(mainPort, () => console.log(`Main service listening on port ${mainPort}`));
|
||||||
|
|
||||||
|
// Start S3 microservice
|
||||||
|
const s3App = await NestFactory.create(S3AppModule);
|
||||||
|
s3App.useGlobalFilters(new AwsExceptionFilter());
|
||||||
|
|
||||||
|
// Parse raw body for S3 binary data
|
||||||
|
s3App.use(bodyParser.raw({ type: '*/*', limit: '50mb' }));
|
||||||
|
|
||||||
|
// Parse URL encoded for S3 operations
|
||||||
|
s3App.use(bodyParser.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
const s3ConfigService: ConfigService<CommonConfig, true> = s3App.get(ConfigService);
|
||||||
|
const s3Port = s3ConfigService.get('S3_PORT');
|
||||||
|
|
||||||
|
await s3App.listen(s3Port, () => console.log(`S3 service listening on port ${s3Port}`));
|
||||||
|
|
||||||
|
// Start Consul KV microservice
|
||||||
|
const consulApp = await NestFactory.create(ConsulKVAppModule);
|
||||||
|
|
||||||
|
// Parse JSON for Consul KV
|
||||||
|
consulApp.use(bodyParser.json());
|
||||||
|
|
||||||
|
// Parse raw body for Consul KV binary data
|
||||||
|
consulApp.use(bodyParser.raw({ type: '*/*', limit: '50mb' }));
|
||||||
|
|
||||||
|
// Parse text for Consul KV
|
||||||
|
consulApp.use(bodyParser.text({ type: 'text/plain' }));
|
||||||
|
|
||||||
|
const consulConfigService: ConfigService<CommonConfig, true> = consulApp.get(ConfigService);
|
||||||
|
const consulPort = consulConfigService.get('CONSUL_PORT');
|
||||||
|
|
||||||
|
await consulApp.listen(consulPort, () => console.log(`Consul KV service listening on port ${consulPort}`));
|
||||||
})();
|
})();
|
||||||
|
|||||||
967
src/s3/__tests__/s3.spec.ts
Normal file
967
src/s3/__tests__/s3.spec.ts
Normal file
@@ -0,0 +1,967 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
CreateBucketCommand,
|
||||||
|
ListBucketsCommand,
|
||||||
|
HeadBucketCommand,
|
||||||
|
DeleteBucketCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
ListObjectsCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
PutBucketTaggingCommand,
|
||||||
|
GetBucketTaggingCommand,
|
||||||
|
GetBucketPolicyCommand,
|
||||||
|
PutBucketAclCommand,
|
||||||
|
GetBucketAclCommand,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { S3AppModule } from '../s3-app.module';
|
||||||
|
import { PrismaService } from '../../_prisma/prisma.service';
|
||||||
|
import { AwsExceptionFilter } from '../../_context/exception.filter';
|
||||||
|
|
||||||
|
const bodyParser = require('body-parser');
|
||||||
|
|
||||||
|
describe('S3 Integration Tests', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let s3Client: S3Client;
|
||||||
|
let prismaService: PrismaService;
|
||||||
|
const testPort = 8088;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Set test environment variables
|
||||||
|
process.env.PORT = testPort.toString();
|
||||||
|
process.env.S3_PORT = testPort.toString();
|
||||||
|
process.env.AWS_ACCOUNT_ID = '123456789012';
|
||||||
|
process.env.AWS_REGION = 'us-east-1';
|
||||||
|
process.env.DATABASE_URL = `file::memory:?cache=shared&unique=${Date.now()}-${Math.random()}`;
|
||||||
|
process.env.PERSISTANCE = ':memory:';
|
||||||
|
|
||||||
|
// Create NestJS testing module
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [S3AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
app.useGlobalFilters(new AwsExceptionFilter());
|
||||||
|
app.use(bodyParser.raw({ type: '*/*', limit: '10mb' }));
|
||||||
|
app.use(bodyParser.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
await app.listen(testPort);
|
||||||
|
|
||||||
|
// Configure S3 client to point to local endpoint
|
||||||
|
s3Client = new S3Client({
|
||||||
|
region: 'us-east-1',
|
||||||
|
endpoint: `http://localhost:${testPort}`,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: 'test',
|
||||||
|
secretAccessKey: 'test',
|
||||||
|
},
|
||||||
|
forcePathStyle: true, // Use path-style URLs (localhost:8088/bucket/key)
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaService = moduleFixture.get<PrismaService>(PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
s3Client.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clean up database before each test
|
||||||
|
await prismaService.s3Object.deleteMany({});
|
||||||
|
await prismaService.s3Bucket.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CreateBucket', () => {
|
||||||
|
it('should create a bucket successfully', async () => {
|
||||||
|
const command = new CreateBucketCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
// Verify response structure per AWS S3 API
|
||||||
|
expect(response.Location).toBeDefined();
|
||||||
|
expect(response.Location).toBe('/test-bucket');
|
||||||
|
expect(response.$metadata).toBeDefined();
|
||||||
|
expect(response.$metadata.httpStatusCode).toBe(200);
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
const bucket = await prismaService.s3Bucket.findFirst({
|
||||||
|
where: { name: 'test-bucket' },
|
||||||
|
});
|
||||||
|
expect(bucket).toBeDefined();
|
||||||
|
expect(bucket?.name).toBe('test-bucket');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle duplicate bucket creation', async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'duplicate-bucket' }));
|
||||||
|
|
||||||
|
const duplicateCommand = new CreateBucketCommand({ Bucket: 'duplicate-bucket' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await s3Client.send(duplicateCommand);
|
||||||
|
fail('Expected BucketAlreadyExists error');
|
||||||
|
} catch (error: any) {
|
||||||
|
expect(error.name).toBe('BucketAlreadyExists');
|
||||||
|
expect(error.$metadata?.httpStatusCode).toBe(409);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create multiple buckets', async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'bucket1' }));
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'bucket2' }));
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'bucket3' }));
|
||||||
|
|
||||||
|
const buckets = await prismaService.s3Bucket.findMany({});
|
||||||
|
expect(buckets.length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ListBuckets', () => {
|
||||||
|
it('should list all buckets', async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'bucket-a' }));
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'bucket-b' }));
|
||||||
|
|
||||||
|
const command = new ListBucketsCommand({});
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
// Verify response structure per AWS S3 API
|
||||||
|
expect(response.Buckets).toBeDefined();
|
||||||
|
expect(response.Buckets!.length).toBe(2);
|
||||||
|
expect(response.Buckets!.map(b => b.Name)).toContain('bucket-a');
|
||||||
|
expect(response.Buckets!.map(b => b.Name)).toContain('bucket-b');
|
||||||
|
expect(response.Owner).toBeDefined();
|
||||||
|
expect(response.Owner?.ID).toBeDefined();
|
||||||
|
expect(response.$metadata.httpStatusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty list when no buckets exist', async () => {
|
||||||
|
const command = new ListBucketsCommand({});
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
expect(response.Buckets).toBeDefined();
|
||||||
|
expect(response.Buckets!.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include creation dates', async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'dated-bucket' }));
|
||||||
|
|
||||||
|
const command = new ListBucketsCommand({});
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
expect(response.Buckets![0].CreationDate).toBeDefined();
|
||||||
|
expect(response.Buckets![0].CreationDate).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HeadBucket', () => {
|
||||||
|
it('should check if bucket exists', async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'existing-bucket' }));
|
||||||
|
|
||||||
|
const command = new HeadBucketCommand({ Bucket: 'existing-bucket' });
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
// HeadBucket returns 200 with no body on success
|
||||||
|
expect(response.$metadata.httpStatusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent bucket', async () => {
|
||||||
|
const command = new HeadBucketCommand({ Bucket: 'non-existent-bucket' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await s3Client.send(command);
|
||||||
|
fail('Expected NoSuchBucket error');
|
||||||
|
} catch (error: any) {
|
||||||
|
// SDK may parse error.name as NotFound but Code should be NoSuchBucket
|
||||||
|
expect(error.Code || error.name).toMatch(/NoSuchBucket|NotFound/);
|
||||||
|
expect(error.$metadata?.httpStatusCode).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeleteBucket', () => {
|
||||||
|
it('should delete an empty bucket', async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'delete-me' }));
|
||||||
|
|
||||||
|
const command = new DeleteBucketCommand({ Bucket: 'delete-me' });
|
||||||
|
await s3Client.send(command);
|
||||||
|
|
||||||
|
// Verify deleted
|
||||||
|
const bucket = await prismaService.s3Bucket.findFirst({
|
||||||
|
where: { name: 'delete-me' },
|
||||||
|
});
|
||||||
|
expect(bucket).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to delete non-empty bucket', async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'non-empty-bucket' }));
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'non-empty-bucket',
|
||||||
|
Key: 'test.txt',
|
||||||
|
Body: Buffer.from('data'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = new DeleteBucketCommand({ Bucket: 'non-empty-bucket' });
|
||||||
|
await expect(s3Client.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to delete non-existent bucket', async () => {
|
||||||
|
const command = new DeleteBucketCommand({ Bucket: 'does-not-exist' });
|
||||||
|
await expect(s3Client.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PutObject', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'test-bucket' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should put an object successfully', async () => {
|
||||||
|
const content = Buffer.from('Hello, S3!');
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'hello.txt',
|
||||||
|
Body: content,
|
||||||
|
ContentType: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
expect(response.ETag).toBeDefined();
|
||||||
|
expect(response.ETag).toMatch(/^"[a-f0-9]{32}"$/);
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
const object = await prismaService.s3Object.findFirst({
|
||||||
|
where: { key: 'hello.txt' },
|
||||||
|
});
|
||||||
|
expect(object).toBeDefined();
|
||||||
|
expect(Buffer.from(object!.content).toString()).toBe('Hello, S3!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should put object with metadata', async () => {
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'metadata.txt',
|
||||||
|
Body: Buffer.from('test'),
|
||||||
|
Metadata: {
|
||||||
|
author: 'test-user',
|
||||||
|
version: '1.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3Client.send(command);
|
||||||
|
|
||||||
|
const object = await prismaService.s3Object.findFirst({
|
||||||
|
where: { key: 'metadata.txt' },
|
||||||
|
});
|
||||||
|
const metadata = JSON.parse(object!.metadata);
|
||||||
|
expect(metadata.author).toBe('test-user');
|
||||||
|
expect(metadata.version).toBe('1.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite existing object', async () => {
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'overwrite.txt',
|
||||||
|
Body: Buffer.from('version 1'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'overwrite.txt',
|
||||||
|
Body: Buffer.from('version 2'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const objects = await prismaService.s3Object.findMany({
|
||||||
|
where: { key: 'overwrite.txt' },
|
||||||
|
});
|
||||||
|
expect(objects.length).toBe(1);
|
||||||
|
expect(Buffer.from(objects[0].content).toString()).toBe('version 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested keys', async () => {
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'folder/subfolder/file.txt',
|
||||||
|
Body: Buffer.from('nested'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3Client.send(command);
|
||||||
|
|
||||||
|
const object = await prismaService.s3Object.findFirst({
|
||||||
|
where: { key: 'folder/subfolder/file.txt' },
|
||||||
|
});
|
||||||
|
expect(object).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store binary content', async () => {
|
||||||
|
const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xff]);
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'binary.dat',
|
||||||
|
Body: binaryData,
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3Client.send(command);
|
||||||
|
|
||||||
|
const object = await prismaService.s3Object.findFirst({
|
||||||
|
where: { key: 'binary.dat' },
|
||||||
|
});
|
||||||
|
expect(Buffer.from(object!.content)).toEqual(binaryData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GetObject', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'test-bucket' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get an object successfully', async () => {
|
||||||
|
const content = Buffer.from('Hello, World!');
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'test.txt',
|
||||||
|
Body: content,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'test.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
expect(response.Body).toBeDefined();
|
||||||
|
expect(response.ETag).toBeDefined();
|
||||||
|
expect(response.ContentLength).toBe(content.length);
|
||||||
|
|
||||||
|
const bodyString = await response.Body!.transformToString();
|
||||||
|
expect(bodyString).toBe('Hello, World!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return metadata headers', async () => {
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'meta.txt',
|
||||||
|
Body: Buffer.from('test'),
|
||||||
|
Metadata: {
|
||||||
|
custom: 'value',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'meta.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
expect(response.Metadata).toBeDefined();
|
||||||
|
expect(response.Metadata!.custom).toBe('value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for non-existent object', async () => {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'does-not-exist.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await s3Client.send(command);
|
||||||
|
fail('Expected error to be thrown');
|
||||||
|
} catch (error: any) {
|
||||||
|
expect(error.name).toBe('NoSuchKey');
|
||||||
|
expect(error.Code).toBe('NoSuchKey');
|
||||||
|
expect(error.Key).toBe('does-not-exist.txt');
|
||||||
|
expect(error.$metadata?.httpStatusCode).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for non-existent bucket', async () => {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: 'non-existent-bucket',
|
||||||
|
Key: 'test.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(s3Client.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HeadObject', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'test-bucket' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get object metadata without body', async () => {
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'head.txt',
|
||||||
|
Body: Buffer.from('content'),
|
||||||
|
ContentType: 'text/plain',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = new HeadObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'head.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
expect(response.ContentLength).toBe(7);
|
||||||
|
expect(response.ContentType).toBe('text/plain');
|
||||||
|
expect(response.ETag).toBeDefined();
|
||||||
|
expect(response.LastModified).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for non-existent object', async () => {
|
||||||
|
const command = new HeadObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'missing.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(s3Client.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeleteObject', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'test-bucket' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete an object', async () => {
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'delete.txt',
|
||||||
|
Body: Buffer.from('delete me'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = new DeleteObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'delete.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3Client.send(command);
|
||||||
|
|
||||||
|
// Verify deleted
|
||||||
|
const object = await prismaService.s3Object.findFirst({
|
||||||
|
where: { key: 'delete.txt' },
|
||||||
|
});
|
||||||
|
expect(object).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should succeed even if object does not exist', async () => {
|
||||||
|
const command = new DeleteObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'never-existed.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(s3Client.send(command)).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for non-existent bucket', async () => {
|
||||||
|
const command = new DeleteObjectCommand({
|
||||||
|
Bucket: 'non-existent-bucket',
|
||||||
|
Key: 'test.txt',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(s3Client.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ListObjects', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'test-bucket' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list objects in a bucket using ListObjects v1', async () => {
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'file1.txt',
|
||||||
|
Body: Buffer.from('data1'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'file2.txt',
|
||||||
|
Body: Buffer.from('data2'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = new ListObjectsCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
expect(response.Contents).toBeDefined();
|
||||||
|
expect(response.Contents!.length).toBe(2);
|
||||||
|
expect(response.Contents!.map(o => o.Key)).toContain('file1.txt');
|
||||||
|
expect(response.Contents!.map(o => o.Key)).toContain('file2.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by prefix using ListObjects v1', async () => {
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'folder1/file1.txt',
|
||||||
|
Body: Buffer.from('data'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'folder2/file2.txt',
|
||||||
|
Body: Buffer.from('data'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = new ListObjectsCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Prefix: 'folder1/',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
expect(response.Contents!.length).toBe(1);
|
||||||
|
expect(response.Contents![0].Key).toBe('folder1/file1.txt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ListObjectsV2', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'test-bucket' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list objects in a bucket', async () => {
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'file1.txt',
|
||||||
|
Body: Buffer.from('data1'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'file2.txt',
|
||||||
|
Body: Buffer.from('data2'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = new ListObjectsV2Command({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
expect(response.Contents).toBeDefined();
|
||||||
|
expect(response.Contents!.length).toBe(2);
|
||||||
|
expect(response.KeyCount).toBe(2);
|
||||||
|
expect(response.Contents!.map(o => o.Key)).toContain('file1.txt');
|
||||||
|
expect(response.Contents!.map(o => o.Key)).toContain('file2.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by prefix', async () => {
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'folder1/file1.txt',
|
||||||
|
Body: Buffer.from('data'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: 'folder2/file2.txt',
|
||||||
|
Body: Buffer.from('data'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = new ListObjectsV2Command({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Prefix: 'folder1/',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
expect(response.Contents!.length).toBe(1);
|
||||||
|
expect(response.Contents![0].Key).toBe('folder1/file1.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect MaxKeys parameter', async () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
Key: `file${i}.txt`,
|
||||||
|
Body: Buffer.from(`data${i}`),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new ListObjectsV2Command({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
MaxKeys: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
expect(response.Contents!.length).toBe(3);
|
||||||
|
expect(response.IsTruncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty list for empty bucket', async () => {
|
||||||
|
const command = new ListObjectsV2Command({
|
||||||
|
Bucket: 'test-bucket',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
// Contents can be undefined or empty array when bucket is empty
|
||||||
|
expect(response.Contents || []).toEqual([]);
|
||||||
|
expect(response.KeyCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('End-to-End Workflow', () => {
|
||||||
|
it('should complete full S3 workflow', async () => {
|
||||||
|
// 1. Create bucket
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: 'workflow-bucket' }));
|
||||||
|
|
||||||
|
// 2. List buckets
|
||||||
|
const listBucketsResponse = await s3Client.send(new ListBucketsCommand({}));
|
||||||
|
expect(listBucketsResponse.Buckets!.some(b => b.Name === 'workflow-bucket')).toBe(true);
|
||||||
|
|
||||||
|
// 3. Put objects
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'workflow-bucket',
|
||||||
|
Key: 'doc1.txt',
|
||||||
|
Body: Buffer.from('Document 1'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: 'workflow-bucket',
|
||||||
|
Key: 'doc2.txt',
|
||||||
|
Body: Buffer.from('Document 2'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. List objects
|
||||||
|
const listObjectsResponse = await s3Client.send(new ListObjectsV2Command({ Bucket: 'workflow-bucket' }));
|
||||||
|
expect(listObjectsResponse.KeyCount).toBe(2);
|
||||||
|
|
||||||
|
// 5. Get object
|
||||||
|
const getResponse = await s3Client.send(new GetObjectCommand({ Bucket: 'workflow-bucket', Key: 'doc1.txt' }));
|
||||||
|
const content = await getResponse.Body!.transformToString();
|
||||||
|
expect(content).toBe('Document 1');
|
||||||
|
|
||||||
|
// 6. Delete object
|
||||||
|
await s3Client.send(new DeleteObjectCommand({ Bucket: 'workflow-bucket', Key: 'doc1.txt' }));
|
||||||
|
|
||||||
|
// 7. Verify deletion
|
||||||
|
const listAfterDelete = await s3Client.send(new ListObjectsV2Command({ Bucket: 'workflow-bucket' }));
|
||||||
|
expect(listAfterDelete.KeyCount).toBe(1);
|
||||||
|
|
||||||
|
// 8. Delete remaining object
|
||||||
|
await s3Client.send(new DeleteObjectCommand({ Bucket: 'workflow-bucket', Key: 'doc2.txt' }));
|
||||||
|
|
||||||
|
// 9. Delete bucket
|
||||||
|
await s3Client.send(new DeleteBucketCommand({ Bucket: 'workflow-bucket' }));
|
||||||
|
|
||||||
|
// 10. Verify bucket deletion
|
||||||
|
await expect(s3Client.send(new HeadBucketCommand({ Bucket: 'workflow-bucket' }))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PutBucketTagging', () => {
|
||||||
|
let bucketName: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
bucketName = `tagging-test-${Date.now()}`;
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: bucketName }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await s3Client.send(new DeleteBucketCommand({ Bucket: bucketName }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should put bucket tags successfully', async () => {
|
||||||
|
const command = new PutBucketTaggingCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Tagging: {
|
||||||
|
TagSet: [
|
||||||
|
{ Key: 'Environment', Value: 'Test' },
|
||||||
|
{ Key: 'Owner', Value: 'TestUser' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
expect(response.$metadata.httpStatusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite existing tags', async () => {
|
||||||
|
// Set initial tags
|
||||||
|
await s3Client.send(
|
||||||
|
new PutBucketTaggingCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Tagging: {
|
||||||
|
TagSet: [{ Key: 'Environment', Value: 'Dev' }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Overwrite with new tags
|
||||||
|
await s3Client.send(
|
||||||
|
new PutBucketTaggingCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Tagging: {
|
||||||
|
TagSet: [
|
||||||
|
{ Key: 'Environment', Value: 'Prod' },
|
||||||
|
{ Key: 'Team', Value: 'Engineering' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify new tags
|
||||||
|
const getResponse = await s3Client.send(new GetBucketTaggingCommand({ Bucket: bucketName }));
|
||||||
|
expect(getResponse.TagSet).toHaveLength(2);
|
||||||
|
expect(getResponse.TagSet).toContainEqual({ Key: 'Environment', Value: 'Prod' });
|
||||||
|
expect(getResponse.TagSet).toContainEqual({ Key: 'Team', Value: 'Engineering' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty tag set', async () => {
|
||||||
|
const command = new PutBucketTaggingCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Tagging: {
|
||||||
|
TagSet: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
expect(response.$metadata.httpStatusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for non-existent bucket', async () => {
|
||||||
|
const command = new PutBucketTaggingCommand({
|
||||||
|
Bucket: 'non-existent-bucket',
|
||||||
|
Tagging: {
|
||||||
|
TagSet: [{ Key: 'Test', Value: 'Value' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(s3Client.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle tags with special characters', async () => {
|
||||||
|
await s3Client.send(
|
||||||
|
new PutBucketTaggingCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Tagging: {
|
||||||
|
TagSet: [
|
||||||
|
{ Key: 'app:name', Value: 'my-app' },
|
||||||
|
{ Key: 'cost-center', Value: 'eng_123' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getResponse = await s3Client.send(new GetBucketTaggingCommand({ Bucket: bucketName }));
|
||||||
|
expect(getResponse.TagSet).toContainEqual({ Key: 'app:name', Value: 'my-app' });
|
||||||
|
expect(getResponse.TagSet).toContainEqual({ Key: 'cost-center', Value: 'eng_123' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GetBucketTagging', () => {
|
||||||
|
let bucketName: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
bucketName = `tagging-get-test-${Date.now()}`;
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: bucketName }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await s3Client.send(new DeleteBucketCommand({ Bucket: bucketName }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get bucket tags', async () => {
|
||||||
|
// Set tags
|
||||||
|
await s3Client.send(
|
||||||
|
new PutBucketTaggingCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Tagging: {
|
||||||
|
TagSet: [
|
||||||
|
{ Key: 'Project', Value: 'TestProject' },
|
||||||
|
{ Key: 'Stage', Value: 'Development' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get tags
|
||||||
|
const response = await s3Client.send(new GetBucketTaggingCommand({ Bucket: bucketName }));
|
||||||
|
|
||||||
|
expect(response.TagSet).toBeDefined();
|
||||||
|
expect(response.TagSet).toHaveLength(2);
|
||||||
|
expect(response.TagSet).toContainEqual({ Key: 'Project', Value: 'TestProject' });
|
||||||
|
expect(response.TagSet).toContainEqual({ Key: 'Stage', Value: 'Development' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty tags for bucket without tags', async () => {
|
||||||
|
const response = await s3Client.send(new GetBucketTaggingCommand({ Bucket: bucketName }));
|
||||||
|
|
||||||
|
expect(response.TagSet).toBeDefined();
|
||||||
|
expect(response.TagSet).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for non-existent bucket', async () => {
|
||||||
|
const command = new GetBucketTaggingCommand({
|
||||||
|
Bucket: 'non-existent-bucket',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(s3Client.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retrieve tags after multiple updates', async () => {
|
||||||
|
// First set of tags
|
||||||
|
await s3Client.send(
|
||||||
|
new PutBucketTaggingCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Tagging: {
|
||||||
|
TagSet: [{ Key: 'Version', Value: '1.0' }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second set of tags
|
||||||
|
await s3Client.send(
|
||||||
|
new PutBucketTaggingCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Tagging: {
|
||||||
|
TagSet: [
|
||||||
|
{ Key: 'Version', Value: '2.0' },
|
||||||
|
{ Key: 'Status', Value: 'Active' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get final tags
|
||||||
|
const response = await s3Client.send(new GetBucketTaggingCommand({ Bucket: bucketName }));
|
||||||
|
|
||||||
|
expect(response.TagSet).toHaveLength(2);
|
||||||
|
expect(response.TagSet).toContainEqual({ Key: 'Version', Value: '2.0' });
|
||||||
|
expect(response.TagSet).toContainEqual({ Key: 'Status', Value: 'Active' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GetBucketPolicy', () => {
|
||||||
|
let bucketName: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
bucketName = `policy-bucket-${Date.now()}`;
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: bucketName }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NoSuchBucketPolicy for bucket without policy', async () => {
|
||||||
|
const command = new GetBucketPolicyCommand({ Bucket: bucketName });
|
||||||
|
|
||||||
|
await expect(s3Client.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for non-existent bucket', async () => {
|
||||||
|
const command = new GetBucketPolicyCommand({
|
||||||
|
Bucket: 'non-existent-bucket',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(s3Client.send(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bucket ACL', () => {
|
||||||
|
const bucketName = 'acl-test-bucket';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: bucketName }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
try {
|
||||||
|
await s3Client.send(new DeleteBucketCommand({ Bucket: bucketName }));
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore if bucket doesn't exist
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default ACL for newly created bucket', async () => {
|
||||||
|
const response = await s3Client.send(new GetBucketAclCommand({ Bucket: bucketName }));
|
||||||
|
|
||||||
|
expect(response.Owner).toBeDefined();
|
||||||
|
expect(response.Owner?.ID).toBe('local-user');
|
||||||
|
expect(response.Owner?.DisplayName).toBe('local-user');
|
||||||
|
expect(response.Grants).toBeDefined();
|
||||||
|
expect(Array.isArray(response.Grants)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set and retrieve bucket ACL using canned ACL (private)', async () => {
|
||||||
|
await s3Client.send(
|
||||||
|
new PutBucketAclCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
ACL: 'private',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await s3Client.send(new GetBucketAclCommand({ Bucket: bucketName }));
|
||||||
|
|
||||||
|
expect(response.Owner).toBeDefined();
|
||||||
|
expect(response.Owner?.ID).toBe('local-user');
|
||||||
|
expect(response.Grants).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set and retrieve bucket ACL using canned ACL (public-read)', async () => {
|
||||||
|
await s3Client.send(
|
||||||
|
new PutBucketAclCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
ACL: 'public-read',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await s3Client.send(new GetBucketAclCommand({ Bucket: bucketName }));
|
||||||
|
|
||||||
|
expect(response.Owner).toBeDefined();
|
||||||
|
expect(response.Grants).toBeDefined();
|
||||||
|
expect(response.Grants!.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check for public read grant
|
||||||
|
const publicReadGrant = response.Grants!.find(
|
||||||
|
grant => grant.Grantee?.URI === 'http://acs.amazonaws.com/groups/global/AllUsers' && grant.Permission === 'READ',
|
||||||
|
);
|
||||||
|
expect(publicReadGrant).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/s3/create-bucket.handler.ts
Normal file
31
src/s3/create-bucket.handler.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CreateBucketHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.S3CreateBucket;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const bucket = await this.s3Service.createBucket(Bucket);
|
||||||
|
|
||||||
|
return {
|
||||||
|
Location: bucket.location,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/s3/delete-bucket.handler.ts
Normal file
27
src/s3/delete-bucket.handler.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeleteBucketHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.S3DeleteBucket;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
await this.s3Service.deleteBucket(Bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/s3/delete-object.handler.ts
Normal file
29
src/s3/delete-object.handler.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
Key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeleteObjectHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.S3DeleteObject;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
Key: Joi.string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket, Key }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
await this.s3Service.deleteObject(Bucket, Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/s3/get-bucket-acl.handler.ts
Normal file
34
src/s3/get-bucket-acl.handler.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetBucketAclHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.S3GetBucketAcl;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const acl = await this.s3Service.getBucketAcl(Bucket);
|
||||||
|
|
||||||
|
return {
|
||||||
|
Owner: acl.Owner,
|
||||||
|
AccessControlList: {
|
||||||
|
Grant: acl.Grants.length > 0 ? acl.Grants : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/s3/get-bucket-policy.handler.ts
Normal file
31
src/s3/get-bucket-policy.handler.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetBucketPolicyHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Json;
|
||||||
|
action = Action.S3GetBucketPolicy;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const policy = await this.s3Service.getBucketPolicy(Bucket);
|
||||||
|
|
||||||
|
// AWS S3 GetBucketPolicy returns {Policy: policyDocument}
|
||||||
|
// where policyDocument is either a JSON string or null
|
||||||
|
return { Policy: policy };
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/s3/get-bucket-tagging.handler.ts
Normal file
37
src/s3/get-bucket-tagging.handler.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetBucketTaggingHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.S3GetBucketTagging;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const tags = await this.s3Service.getBucketTagging(Bucket);
|
||||||
|
|
||||||
|
// Convert tags object to AWS S3 TagSet format
|
||||||
|
const tagArray = Object.entries(tags).map(([Key, Value]) => ({
|
||||||
|
Key,
|
||||||
|
Value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
TagSet: tagArray.length > 0 ? { Tag: tagArray } : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/s3/get-object.handler.ts
Normal file
38
src/s3/get-object.handler.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
Key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetObjectHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Json;
|
||||||
|
action = Action.S3GetObject;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
Key: Joi.string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket, Key }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const object = await this.s3Service.getObject(Bucket, Key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
Body: object.content.toString('base64'),
|
||||||
|
ContentType: object.contentType,
|
||||||
|
ContentLength: object.size,
|
||||||
|
ETag: `"${object.etag}"`,
|
||||||
|
LastModified: object.updatedAt.toISOString(),
|
||||||
|
Metadata: object.parsedMetadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/s3/head-bucket.handler.ts
Normal file
28
src/s3/head-bucket.handler.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HeadBucketHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.S3HeadBucket;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
await this.s3Service.getBucket(Bucket);
|
||||||
|
// HeadBucket returns no body on success
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/s3/head-object.handler.ts
Normal file
37
src/s3/head-object.handler.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
Key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HeadObjectHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Json;
|
||||||
|
action = Action.S3HeadObject;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
Key: Joi.string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket, Key }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const object = await this.s3Service.headObject(Bucket, Key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ContentType: object.contentType,
|
||||||
|
ContentLength: object.size,
|
||||||
|
ETag: `"${object.etag}"`,
|
||||||
|
LastModified: object.updatedAt.toISOString(),
|
||||||
|
Metadata: object.parsedMetadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/s3/list-buckets.handler.ts
Normal file
39
src/s3/list-buckets.handler.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ListBucketsHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.S3ListBuckets;
|
||||||
|
validator = Joi.object<QueryParams, true>({});
|
||||||
|
|
||||||
|
protected async handle(params: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const buckets = await this.s3Service.listBuckets();
|
||||||
|
|
||||||
|
return {
|
||||||
|
Owner: {
|
||||||
|
ID: awsProperties.accountId,
|
||||||
|
DisplayName: 'localstack',
|
||||||
|
},
|
||||||
|
Buckets:
|
||||||
|
buckets.length > 0
|
||||||
|
? {
|
||||||
|
Bucket: buckets.map(b => ({
|
||||||
|
Name: b.name,
|
||||||
|
CreationDate: b.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/s3/list-objects-v2.handler.ts
Normal file
55
src/s3/list-objects-v2.handler.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
Prefix?: string;
|
||||||
|
MaxKeys?: number;
|
||||||
|
'list-type'?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ListObjectsV2Handler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.S3ListObjectsV2;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
Prefix: Joi.string().default(''),
|
||||||
|
MaxKeys: Joi.number().default(1000),
|
||||||
|
'list-type': Joi.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket, Prefix, MaxKeys }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const { objects, isTruncated } = await this.s3Service.listObjects(Bucket, Prefix!, MaxKeys!);
|
||||||
|
|
||||||
|
const result: any = {
|
||||||
|
Name: Bucket,
|
||||||
|
Prefix: Prefix,
|
||||||
|
MaxKeys: MaxKeys,
|
||||||
|
IsTruncated: isTruncated,
|
||||||
|
KeyCount: objects.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include Contents if there are objects, otherwise omit it
|
||||||
|
// AWS SDK will interpret missing Contents as empty array in some versions
|
||||||
|
if (objects.length > 0) {
|
||||||
|
result.Contents = objects.map(obj => ({
|
||||||
|
Key: obj.key,
|
||||||
|
LastModified: obj.updatedAt.toISOString(),
|
||||||
|
ETag: `"${obj.etag}"`,
|
||||||
|
Size: obj.size,
|
||||||
|
StorageClass: obj.storageClass,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/s3/list-objects.handler.ts
Normal file
65
src/s3/list-objects.handler.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
Prefix?: string;
|
||||||
|
MaxKeys?: number;
|
||||||
|
Marker?: string;
|
||||||
|
Delimiter?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ListObjectsHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.S3ListObjects;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
Prefix: Joi.string().default(''),
|
||||||
|
MaxKeys: Joi.number().default(1000),
|
||||||
|
Marker: Joi.string().optional(),
|
||||||
|
Delimiter: Joi.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket, Prefix, MaxKeys, Marker, Delimiter }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const { objects, isTruncated } = await this.s3Service.listObjects(Bucket, Prefix!, MaxKeys!);
|
||||||
|
|
||||||
|
const result: any = {
|
||||||
|
Name: Bucket,
|
||||||
|
Prefix: Prefix,
|
||||||
|
MaxKeys: MaxKeys,
|
||||||
|
IsTruncated: isTruncated,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add Marker if provided
|
||||||
|
if (Marker) {
|
||||||
|
result.Marker = Marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Delimiter if provided
|
||||||
|
if (Delimiter) {
|
||||||
|
result.Delimiter = Delimiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include Contents if there are objects
|
||||||
|
if (objects.length > 0) {
|
||||||
|
result.Contents = objects.map(obj => ({
|
||||||
|
Key: obj.key,
|
||||||
|
LastModified: obj.updatedAt.toISOString(),
|
||||||
|
ETag: `"${obj.etag}"`,
|
||||||
|
Size: obj.size,
|
||||||
|
StorageClass: obj.storageClass,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/s3/put-bucket-acl.handler.ts
Normal file
91
src/s3/put-bucket-acl.handler.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
Body?: string;
|
||||||
|
'x-amz-acl'?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PutBucketAclHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.S3PutBucketAcl;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
Body: Joi.string().allow('', null),
|
||||||
|
'x-amz-acl': Joi.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket, Body, 'x-amz-acl': cannedAcl }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
let aclData: any;
|
||||||
|
|
||||||
|
if (Body) {
|
||||||
|
// Parse XML ACL from body
|
||||||
|
const aclMatches = Body.matchAll(
|
||||||
|
/<Grant>[\s\S]*?<Grantee[^>]*>[\s\S]*?<\/Grantee>[\s\S]*?<Permission>(.*?)<\/Permission>[\s\S]*?<\/Grant>/g,
|
||||||
|
);
|
||||||
|
const grants = [];
|
||||||
|
|
||||||
|
for (const match of aclMatches) {
|
||||||
|
const permission = match[1];
|
||||||
|
const granteeMatch = Body.match(/<Grantee[^>]*xsi:type="([^"]*)"[^>]*>[\s\S]*?<\/Grantee>/);
|
||||||
|
|
||||||
|
if (granteeMatch) {
|
||||||
|
const type = granteeMatch[1];
|
||||||
|
grants.push({
|
||||||
|
Grantee: { Type: type },
|
||||||
|
Permission: permission,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aclData = {
|
||||||
|
Owner: {
|
||||||
|
ID: 'local-user',
|
||||||
|
DisplayName: 'local-user',
|
||||||
|
},
|
||||||
|
Grants: grants,
|
||||||
|
};
|
||||||
|
} else if (cannedAcl) {
|
||||||
|
// Handle canned ACL (private, public-read, etc.)
|
||||||
|
const grants =
|
||||||
|
cannedAcl === 'public-read'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
Grantee: { Type: 'Group', URI: 'http://acs.amazonaws.com/groups/global/AllUsers' },
|
||||||
|
Permission: 'READ',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
aclData = {
|
||||||
|
Owner: {
|
||||||
|
ID: 'local-user',
|
||||||
|
DisplayName: 'local-user',
|
||||||
|
},
|
||||||
|
Grants: grants,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Default private ACL
|
||||||
|
aclData = {
|
||||||
|
Owner: {
|
||||||
|
ID: 'local-user',
|
||||||
|
DisplayName: 'local-user',
|
||||||
|
},
|
||||||
|
Grants: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.s3Service.putBucketAcl(Bucket, aclData);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/s3/put-bucket-tagging.handler.ts
Normal file
51
src/s3/put-bucket-tagging.handler.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
Body?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PutBucketTaggingHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.S3PutBucketTagging;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
Body: Joi.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket, Body }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
// Parse XML body to extract tags
|
||||||
|
const tags: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (Body) {
|
||||||
|
// Decode from base64
|
||||||
|
const xmlBody = Buffer.from(Body, 'base64').toString('utf-8');
|
||||||
|
|
||||||
|
// Simple XML parsing for AWS S3 tagging format: <Tagging><TagSet><Tag><Key>...</Key><Value>...</Value></Tag>...</TagSet></Tagging>
|
||||||
|
const tagMatches = xmlBody.matchAll(/<Tag>\s*<Key>(.*?)<\/Key>\s*<Value>(.*?)<\/Value>\s*<\/Tag>/gs);
|
||||||
|
|
||||||
|
for (const match of tagMatches) {
|
||||||
|
const key = match[1].trim();
|
||||||
|
const value = match[2].trim();
|
||||||
|
if (key) {
|
||||||
|
tags[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.s3Service.putBucketTagging(Bucket, tags);
|
||||||
|
|
||||||
|
// PutBucketTagging returns no content on success
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/s3/put-object.handler.ts
Normal file
42
src/s3/put-object.handler.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
Bucket: string;
|
||||||
|
Key: string;
|
||||||
|
Body?: string;
|
||||||
|
ContentType?: string;
|
||||||
|
Metadata?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PutObjectHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly s3Service: S3Service) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Xml;
|
||||||
|
action = Action.S3PutObject;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
Bucket: Joi.string().required(),
|
||||||
|
Key: Joi.string().required(),
|
||||||
|
Body: Joi.string().allow('').optional(),
|
||||||
|
ContentType: Joi.string().default('application/octet-stream'),
|
||||||
|
Metadata: Joi.object().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ Bucket, Key, Body, ContentType, Metadata }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const content = Buffer.from(Body || '', 'base64');
|
||||||
|
const metadata = Metadata || {};
|
||||||
|
|
||||||
|
const object = await this.s3Service.putObject(Bucket, Key, content, ContentType!, metadata);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ETag: `"${object.etag}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/s3/s3-app.module.ts
Normal file
19
src/s3/s3-app.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { PrismaModule } from '../_prisma/prisma.module';
|
||||||
|
import { AwsSharedEntitiesModule } from '../aws-shared-entities/aws-shared-entities.module';
|
||||||
|
import localConfig from '../config/local.config';
|
||||||
|
import { S3Module } from './s3.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
load: [localConfig],
|
||||||
|
isGlobal: true,
|
||||||
|
}),
|
||||||
|
PrismaModule,
|
||||||
|
AwsSharedEntitiesModule,
|
||||||
|
S3Module,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class S3AppModule {}
|
||||||
110
src/s3/s3-audit.interceptor.ts
Normal file
110
src/s3/s3-audit.interceptor.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { Observable, tap, catchError, throwError } from 'rxjs';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
import { IRequest, RequestContext } from '../_context/request.context';
|
||||||
|
import { PrismaService } from '../_prisma/prisma.service';
|
||||||
|
import { AwsException, InternalFailure } from '../aws-shared-entities/aws-exceptions';
|
||||||
|
import { Format } from '../abstract-action.handler';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class S3AuditInterceptor implements NestInterceptor {
|
||||||
|
private readonly logger = new Logger(S3AuditInterceptor.name);
|
||||||
|
|
||||||
|
constructor(private readonly prismaService: PrismaService, private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
const awsProperties = {
|
||||||
|
accountId: this.configService.get('AWS_ACCOUNT_ID'),
|
||||||
|
region: this.configService.get('AWS_REGION'),
|
||||||
|
host: `${this.configService.get('PROTO')}://${this.configService.get('HOST')}:${this.configService.get('S3_PORT') || '4572'}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestContext: RequestContext = {
|
||||||
|
requestId: randomUUID(),
|
||||||
|
awsProperties,
|
||||||
|
format: Format.Xml, // S3 uses XML format for errors
|
||||||
|
};
|
||||||
|
|
||||||
|
const httpContext = context.switchToHttp();
|
||||||
|
const request = httpContext.getRequest<IRequest>();
|
||||||
|
request.context = requestContext;
|
||||||
|
|
||||||
|
// Set response header for request ID
|
||||||
|
const response = context.switchToHttp().getResponse<Response>();
|
||||||
|
response.header('x-amzn-RequestId', requestContext.requestId);
|
||||||
|
|
||||||
|
// Use method + path as action identifier for S3 operations
|
||||||
|
const action = `${request.method} ${request.path}`;
|
||||||
|
|
||||||
|
const requestStartTime = Date.now();
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap({
|
||||||
|
next: async data => {
|
||||||
|
const duration = Date.now() - requestStartTime;
|
||||||
|
this.logger.log(`${action} - ${duration}ms`);
|
||||||
|
|
||||||
|
// Log to audit table
|
||||||
|
await this.prismaService.audit.create({
|
||||||
|
data: {
|
||||||
|
id: requestContext.requestId,
|
||||||
|
action,
|
||||||
|
request: JSON.stringify({
|
||||||
|
__path: request.path,
|
||||||
|
__method: request.method,
|
||||||
|
__accountId: requestContext.awsProperties.accountId,
|
||||||
|
__region: requestContext.awsProperties.region,
|
||||||
|
...request.headers,
|
||||||
|
...request.query,
|
||||||
|
body: request.body,
|
||||||
|
}),
|
||||||
|
response: JSON.stringify(data || { statusCode: 200 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
error: async err => {
|
||||||
|
const duration = Date.now() - requestStartTime;
|
||||||
|
this.logger.error(`${action} - ${duration}ms - Error: ${err.message}`);
|
||||||
|
|
||||||
|
// Log error to audit table
|
||||||
|
await this.prismaService.audit.create({
|
||||||
|
data: {
|
||||||
|
id: requestContext.requestId,
|
||||||
|
action,
|
||||||
|
request: JSON.stringify({
|
||||||
|
__path: request.path,
|
||||||
|
__method: request.method,
|
||||||
|
__accountId: requestContext.awsProperties.accountId,
|
||||||
|
__region: requestContext.awsProperties.region,
|
||||||
|
...request.headers,
|
||||||
|
...request.query,
|
||||||
|
body: request.body,
|
||||||
|
}),
|
||||||
|
response: JSON.stringify({
|
||||||
|
error: err.message,
|
||||||
|
statusCode: err.statusCode || 500,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
catchError(err => {
|
||||||
|
if (err instanceof AwsException) {
|
||||||
|
return throwError(() => err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof Error && !(err as any).statusCode) {
|
||||||
|
const internalError = new InternalFailure(err.message);
|
||||||
|
internalError.requestId = requestContext.requestId;
|
||||||
|
return throwError(() => internalError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return throwError(() => err);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/s3/s3-bucket.entity.ts
Normal file
23
src/s3/s3-bucket.entity.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { S3Bucket as PrismaS3Bucket } from '@prisma/client';
|
||||||
|
|
||||||
|
export class S3Bucket implements PrismaS3Bucket {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tags: string;
|
||||||
|
policy: string | null;
|
||||||
|
acl: string;
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
constructor(bucket: PrismaS3Bucket) {
|
||||||
|
this.id = bucket.id;
|
||||||
|
this.name = bucket.name;
|
||||||
|
this.tags = bucket.tags;
|
||||||
|
this.policy = bucket.policy;
|
||||||
|
this.acl = bucket.acl;
|
||||||
|
this.createdAt = bucket.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get location() {
|
||||||
|
return `/${this.name}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/s3/s3-object.entity.ts
Normal file
44
src/s3/s3-object.entity.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { S3Object as PrismaS3Object } from '@prisma/client';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
export class S3Object implements PrismaS3Object {
|
||||||
|
id: string;
|
||||||
|
bucketId: string;
|
||||||
|
key: string;
|
||||||
|
versionId: string | null;
|
||||||
|
content: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
etag: string;
|
||||||
|
metadata: string;
|
||||||
|
storageClass: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(object: PrismaS3Object) {
|
||||||
|
this.id = object.id;
|
||||||
|
this.bucketId = object.bucketId;
|
||||||
|
this.key = object.key;
|
||||||
|
this.versionId = object.versionId;
|
||||||
|
this.content = Buffer.from(object.content);
|
||||||
|
this.contentType = object.contentType;
|
||||||
|
this.size = object.size;
|
||||||
|
this.etag = object.etag;
|
||||||
|
this.metadata = object.metadata;
|
||||||
|
this.storageClass = object.storageClass;
|
||||||
|
this.createdAt = object.createdAt;
|
||||||
|
this.updatedAt = object.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
static calculateETag(content: Buffer): string {
|
||||||
|
return createHash('md5').update(content).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
get parsedMetadata(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(this.metadata);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/s3/s3.constants.ts
Normal file
20
src/s3/s3.constants.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Action } from '../action.enum';
|
||||||
|
|
||||||
|
export const S3Handlers = 'S3_HANDLERS';
|
||||||
|
|
||||||
|
export const s3Actions = [
|
||||||
|
Action.S3AbortMultipartUpload,
|
||||||
|
Action.S3CompleteMultipartUpload,
|
||||||
|
Action.S3CreateBucket,
|
||||||
|
Action.S3CreateMultipartUpload,
|
||||||
|
Action.S3DeleteBucket,
|
||||||
|
Action.S3DeleteObject,
|
||||||
|
Action.S3GetObject,
|
||||||
|
Action.S3HeadBucket,
|
||||||
|
Action.S3HeadObject,
|
||||||
|
Action.S3ListBuckets,
|
||||||
|
Action.S3ListObjects,
|
||||||
|
Action.S3ListObjectsV2,
|
||||||
|
Action.S3PutObject,
|
||||||
|
Action.S3UploadPart,
|
||||||
|
];
|
||||||
365
src/s3/s3.controller.ts
Normal file
365
src/s3/s3.controller.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import { All, Body, Controller, Headers, HttpCode, Inject, Query, Req, Res, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import * as js2xmlparser from 'js2xmlparser';
|
||||||
|
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { S3AuditInterceptor } from './s3-audit.interceptor';
|
||||||
|
import { InvalidAction, ValidationError } from '../aws-shared-entities/aws-exceptions';
|
||||||
|
import { IRequest } from '../_context/request.context';
|
||||||
|
import { S3Handlers } from './s3.constants';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
__path: string;
|
||||||
|
__method: string;
|
||||||
|
Bucket?: string;
|
||||||
|
Key?: string;
|
||||||
|
Body?: string;
|
||||||
|
Metadata?: Record<string, string>;
|
||||||
|
} & Record<string, any>;
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
@UseInterceptors(S3AuditInterceptor)
|
||||||
|
export class S3Controller {
|
||||||
|
constructor(
|
||||||
|
@Inject(S3Handlers)
|
||||||
|
private readonly s3Handlers: Record<Action, AbstractActionHandler>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@All('*')
|
||||||
|
async handleS3Request(
|
||||||
|
@Req() request: IRequest,
|
||||||
|
@Res() response: Response,
|
||||||
|
@Body() body: any,
|
||||||
|
@Headers() headers: Record<string, any>,
|
||||||
|
@Query() query: Record<string, any>,
|
||||||
|
) {
|
||||||
|
const method = request.method;
|
||||||
|
const path = request.path;
|
||||||
|
|
||||||
|
// Parse S3 path: /{bucket} or /{bucket}/{key}
|
||||||
|
const pathParts = path.split('/').filter(p => p.length > 0);
|
||||||
|
const bucket = pathParts[0];
|
||||||
|
const key = pathParts.slice(1).join('/');
|
||||||
|
|
||||||
|
// Determine S3 action based on method, path, and query parameters
|
||||||
|
const action = this.determineS3Action(method, bucket, key, query);
|
||||||
|
|
||||||
|
// Normalize query parameter casing for AWS SDK compatibility
|
||||||
|
const normalizedQuery: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
// Convert common lowercase params to PascalCase
|
||||||
|
const normalizedKey =
|
||||||
|
key === 'prefix'
|
||||||
|
? 'Prefix'
|
||||||
|
: key === 'max-keys'
|
||||||
|
? 'MaxKeys'
|
||||||
|
: key === 'marker'
|
||||||
|
? 'Marker'
|
||||||
|
: key === 'delimiter'
|
||||||
|
? 'Delimiter'
|
||||||
|
: key;
|
||||||
|
normalizedQuery[normalizedKey] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerCasedHeaders = Object.keys(headers).reduce((o, k) => {
|
||||||
|
o[k.toLowerCase()] = headers[k];
|
||||||
|
return o;
|
||||||
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
// Build query params for handler
|
||||||
|
const queryParams: QueryParams = {
|
||||||
|
__path: path,
|
||||||
|
__method: method,
|
||||||
|
...normalizedQuery,
|
||||||
|
...lowerCasedHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (bucket) {
|
||||||
|
queryParams.Bucket = bucket;
|
||||||
|
}
|
||||||
|
if (key) {
|
||||||
|
queryParams.Key = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle body for PUT operations
|
||||||
|
if (method === 'PUT' && body) {
|
||||||
|
// Convert Buffer to base64 string for handlers
|
||||||
|
if (Buffer.isBuffer(body)) {
|
||||||
|
queryParams.Body = body.toString('base64');
|
||||||
|
} else if (typeof body === 'string') {
|
||||||
|
queryParams.Body = Buffer.from(body).toString('base64');
|
||||||
|
} else {
|
||||||
|
queryParams.Body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata headers for PUT object operations
|
||||||
|
if (method === 'PUT' && key) {
|
||||||
|
const metadata: Record<string, string> = {};
|
||||||
|
for (const [headerKey, headerValue] of Object.entries(lowerCasedHeaders)) {
|
||||||
|
if (headerKey.startsWith('x-amz-meta-')) {
|
||||||
|
const metadataKey = headerKey.substring('x-amz-meta-'.length);
|
||||||
|
metadata[metadataKey] = headerValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(metadata).length > 0) {
|
||||||
|
queryParams.Metadata = metadata;
|
||||||
|
}
|
||||||
|
// Extract Content-Type header
|
||||||
|
if (lowerCasedHeaders['content-type']) {
|
||||||
|
queryParams.ContentType = lowerCasedHeaders['content-type'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler: AbstractActionHandler = this.s3Handlers[action];
|
||||||
|
|
||||||
|
if (!handler) {
|
||||||
|
throw new InvalidAction(`No handler for action: ${action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: validatorError, value: validQueryParams } = handler.validator.validate(queryParams, {
|
||||||
|
allowUnknown: true,
|
||||||
|
abortEarly: false,
|
||||||
|
stripUnknown: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validatorError) {
|
||||||
|
throw new ValidationError(validatorError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For S3, we need the raw response without XML wrapping
|
||||||
|
const rawResponse: any = await (handler as any).handle(validQueryParams, request.context);
|
||||||
|
|
||||||
|
// Handle S3-specific response headers
|
||||||
|
if (action === Action.S3CreateBucket && rawResponse?.Location) {
|
||||||
|
response.setHeader('Location', rawResponse.Location);
|
||||||
|
response.status(200).send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === Action.S3PutObject) {
|
||||||
|
if (rawResponse?.ETag) {
|
||||||
|
response.setHeader('ETag', rawResponse.ETag);
|
||||||
|
}
|
||||||
|
response.status(200).send('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === Action.S3HeadBucket) {
|
||||||
|
response.status(200).send('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === Action.S3HeadObject) {
|
||||||
|
if (rawResponse?.ContentType) {
|
||||||
|
response.setHeader('Content-Type', rawResponse.ContentType);
|
||||||
|
}
|
||||||
|
if (rawResponse?.ContentLength !== undefined) {
|
||||||
|
response.setHeader('Content-Length', rawResponse.ContentLength.toString());
|
||||||
|
}
|
||||||
|
if (rawResponse?.ETag) {
|
||||||
|
response.setHeader('ETag', rawResponse.ETag);
|
||||||
|
}
|
||||||
|
if (rawResponse?.LastModified) {
|
||||||
|
response.setHeader('Last-Modified', new Date(rawResponse.LastModified).toUTCString());
|
||||||
|
}
|
||||||
|
// Set metadata headers
|
||||||
|
if (rawResponse?.Metadata) {
|
||||||
|
for (const [key, value] of Object.entries(rawResponse.Metadata)) {
|
||||||
|
response.setHeader(`x-amz-meta-${key}`, value as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.status(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === Action.S3DeleteObject || action === Action.S3DeleteBucket) {
|
||||||
|
response.status(204).send('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === Action.S3PutBucketTagging) {
|
||||||
|
response.status(200).send('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === Action.S3PutBucketAcl) {
|
||||||
|
response.status(200).send('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === Action.S3GetObject) {
|
||||||
|
// Return object body with headers
|
||||||
|
if (rawResponse?.ContentType) {
|
||||||
|
response.setHeader('Content-Type', rawResponse.ContentType);
|
||||||
|
}
|
||||||
|
if (rawResponse?.ETag) {
|
||||||
|
response.setHeader('ETag', rawResponse.ETag);
|
||||||
|
}
|
||||||
|
if (rawResponse?.LastModified) {
|
||||||
|
response.setHeader('Last-Modified', new Date(rawResponse.LastModified).toUTCString());
|
||||||
|
}
|
||||||
|
if (rawResponse?.Metadata) {
|
||||||
|
for (const [key, value] of Object.entries(rawResponse.Metadata)) {
|
||||||
|
response.setHeader(`x-amz-meta-${key}`, value as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body is base64 encoded
|
||||||
|
const bodyBuffer = Buffer.from(rawResponse.Body, 'base64');
|
||||||
|
response.status(200).send(bodyBuffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON response for policy operations
|
||||||
|
if (handler.format === Format.Json) {
|
||||||
|
// GetBucketPolicy returns the policy document as the raw response body
|
||||||
|
if (action === Action.S3GetBucketPolicy) {
|
||||||
|
const policyDoc = rawResponse.Policy;
|
||||||
|
if (policyDoc === null || policyDoc === undefined) {
|
||||||
|
response.status(200).send('');
|
||||||
|
} else {
|
||||||
|
response.status(200).type('application/json').send(policyDoc);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.status(200).json(rawResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default XML response for list operations
|
||||||
|
if (handler.format === Format.Xml) {
|
||||||
|
let rootElement = 'Response';
|
||||||
|
|
||||||
|
if (action === Action.S3GetBucketTagging) {
|
||||||
|
rootElement = 'Tagging';
|
||||||
|
}
|
||||||
|
if (action === Action.S3ListBuckets) {
|
||||||
|
rootElement = 'ListAllMyBucketsResult';
|
||||||
|
} else if (action === Action.S3ListObjectsV2 || action === Action.S3ListObjects) {
|
||||||
|
rootElement = 'ListBucketResult';
|
||||||
|
}
|
||||||
|
|
||||||
|
const xmlResponse = js2xmlparser.parse(rootElement, rawResponse, {
|
||||||
|
declaration: { include: false },
|
||||||
|
format: { doubleQuotes: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
response.status(200).type('application/xml').send(xmlResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status(200).json(rawResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
private determineS3Action(method: string, bucket: string, key: string, query: Record<string, any>): Action {
|
||||||
|
// Bucket operations
|
||||||
|
if (!key || key === '') {
|
||||||
|
if (method === 'PUT') {
|
||||||
|
// Check for bucket sub-resource operations via query parameters
|
||||||
|
if (query['tagging'] !== undefined) {
|
||||||
|
return Action.S3PutBucketTagging;
|
||||||
|
}
|
||||||
|
if (query['versioning'] !== undefined) {
|
||||||
|
throw new InvalidAction('PutBucketVersioning is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['lifecycle'] !== undefined) {
|
||||||
|
throw new InvalidAction('PutBucketLifecycleConfiguration is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['cors'] !== undefined) {
|
||||||
|
throw new InvalidAction('PutBucketCors is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['policy'] !== undefined) {
|
||||||
|
throw new InvalidAction('PutBucketPolicy is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['acl'] !== undefined) {
|
||||||
|
return Action.S3PutBucketAcl;
|
||||||
|
}
|
||||||
|
if (query['encryption'] !== undefined) {
|
||||||
|
throw new InvalidAction('PutBucketEncryption is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['website'] !== undefined) {
|
||||||
|
throw new InvalidAction('PutBucketWebsite is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['logging'] !== undefined) {
|
||||||
|
throw new InvalidAction('PutBucketLogging is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['replication'] !== undefined) {
|
||||||
|
throw new InvalidAction('PutBucketReplication is not yet implemented');
|
||||||
|
}
|
||||||
|
// Default PUT on bucket is CreateBucket
|
||||||
|
return Action.S3CreateBucket;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
return Action.S3DeleteBucket;
|
||||||
|
}
|
||||||
|
if (method === 'HEAD') {
|
||||||
|
return Action.S3HeadBucket;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
if (!bucket || bucket === '') {
|
||||||
|
return Action.S3ListBuckets;
|
||||||
|
}
|
||||||
|
// Check for bucket sub-resource GET operations
|
||||||
|
if (query['tagging'] !== undefined) {
|
||||||
|
return Action.S3GetBucketTagging;
|
||||||
|
}
|
||||||
|
if (query['versioning'] !== undefined) {
|
||||||
|
throw new InvalidAction('GetBucketVersioning is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['lifecycle'] !== undefined) {
|
||||||
|
throw new InvalidAction('GetBucketLifecycleConfiguration is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['cors'] !== undefined) {
|
||||||
|
throw new InvalidAction('GetBucketCors is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['policy'] !== undefined) {
|
||||||
|
return Action.S3GetBucketPolicy;
|
||||||
|
}
|
||||||
|
if (query['acl'] !== undefined) {
|
||||||
|
return Action.S3GetBucketAcl;
|
||||||
|
}
|
||||||
|
if (query['acl'] !== undefined) {
|
||||||
|
throw new InvalidAction('GetBucketAcl is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['encryption'] !== undefined) {
|
||||||
|
throw new InvalidAction('GetBucketEncryption is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['website'] !== undefined) {
|
||||||
|
throw new InvalidAction('GetBucketWebsite is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['logging'] !== undefined) {
|
||||||
|
throw new InvalidAction('GetBucketLogging is not yet implemented');
|
||||||
|
}
|
||||||
|
if (query['replication'] !== undefined) {
|
||||||
|
throw new InvalidAction('GetBucketReplication is not yet implemented');
|
||||||
|
}
|
||||||
|
// List objects - check for list-type=2 query param
|
||||||
|
if (query['list-type'] === '2') {
|
||||||
|
return Action.S3ListObjectsV2;
|
||||||
|
}
|
||||||
|
return Action.S3ListObjects;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object operations
|
||||||
|
if (key && key !== '') {
|
||||||
|
if (method === 'PUT') {
|
||||||
|
return Action.S3PutObject;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
return Action.S3GetObject;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
return Action.S3DeleteObject;
|
||||||
|
}
|
||||||
|
if (method === 'HEAD') {
|
||||||
|
return Action.S3HeadObject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidAction(`Unable to determine S3 action for ${method} ${bucket}/${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/s3/s3.module.ts
Normal file
57
src/s3/s3.module.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { DefaultActionHandlerProvider } from '../default-action-handler/default-action-handler.provider';
|
||||||
|
import { ExistingActionHandlersProvider } from '../default-action-handler/existing-action-handlers.provider';
|
||||||
|
import { PrismaModule } from '../_prisma/prisma.module';
|
||||||
|
import { S3Service } from './s3.service';
|
||||||
|
import { S3Handlers } from './s3.constants';
|
||||||
|
import { S3Controller } from './s3.controller';
|
||||||
|
import { S3AuditInterceptor } from './s3-audit.interceptor';
|
||||||
|
import { CreateBucketHandler } from './create-bucket.handler';
|
||||||
|
import { ListBucketsHandler } from './list-buckets.handler';
|
||||||
|
import { DeleteBucketHandler } from './delete-bucket.handler';
|
||||||
|
import { HeadBucketHandler } from './head-bucket.handler';
|
||||||
|
import { PutObjectHandler } from './put-object.handler';
|
||||||
|
import { GetObjectHandler } from './get-object.handler';
|
||||||
|
import { DeleteObjectHandler } from './delete-object.handler';
|
||||||
|
import { HeadObjectHandler } from './head-object.handler';
|
||||||
|
import { ListObjectsV2Handler } from './list-objects-v2.handler';
|
||||||
|
import { ListObjectsHandler } from './list-objects.handler';
|
||||||
|
import { PutBucketTaggingHandler } from './put-bucket-tagging.handler';
|
||||||
|
import { GetBucketTaggingHandler } from './get-bucket-tagging.handler';
|
||||||
|
import { GetBucketPolicyHandler } from './get-bucket-policy.handler';
|
||||||
|
import { PutBucketAclHandler } from './put-bucket-acl.handler';
|
||||||
|
import { GetBucketAclHandler } from './get-bucket-acl.handler';
|
||||||
|
import { s3Actions } from './s3.constants';
|
||||||
|
|
||||||
|
const handlers = [
|
||||||
|
CreateBucketHandler,
|
||||||
|
ListBucketsHandler,
|
||||||
|
DeleteBucketHandler,
|
||||||
|
HeadBucketHandler,
|
||||||
|
PutObjectHandler,
|
||||||
|
GetObjectHandler,
|
||||||
|
DeleteObjectHandler,
|
||||||
|
HeadObjectHandler,
|
||||||
|
ListObjectsV2Handler,
|
||||||
|
ListObjectsHandler,
|
||||||
|
PutBucketTaggingHandler,
|
||||||
|
GetBucketTaggingHandler,
|
||||||
|
GetBucketPolicyHandler,
|
||||||
|
PutBucketAclHandler,
|
||||||
|
GetBucketAclHandler,
|
||||||
|
];
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [S3Controller],
|
||||||
|
providers: [
|
||||||
|
S3Service,
|
||||||
|
S3AuditInterceptor,
|
||||||
|
...handlers,
|
||||||
|
ExistingActionHandlersProvider(handlers),
|
||||||
|
DefaultActionHandlerProvider(S3Handlers, Format.Xml, s3Actions),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class S3Module {}
|
||||||
293
src/s3/s3.service.ts
Normal file
293
src/s3/s3.service.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { Injectable, HttpStatus } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import * as js2xmlparser from 'js2xmlparser';
|
||||||
|
import { PrismaService } from '../_prisma/prisma.service';
|
||||||
|
import { AwsException } from '../aws-shared-entities/aws-exceptions';
|
||||||
|
import { S3Bucket } from './s3-bucket.entity';
|
||||||
|
import { S3Object } from './s3-object.entity';
|
||||||
|
|
||||||
|
export class BucketAlreadyExistsException extends AwsException {
|
||||||
|
constructor(bucketName: string) {
|
||||||
|
super(
|
||||||
|
`The requested bucket name is not available. The bucket namespace is shared by all users of the system. Please select a different name and try again.`,
|
||||||
|
'BucketAlreadyExists',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toXml(): string {
|
||||||
|
return js2xmlparser.parse('Error', {
|
||||||
|
Code: this.errorType,
|
||||||
|
Message: this.message,
|
||||||
|
BucketName: (this as any).bucketName,
|
||||||
|
RequestId: this.requestId,
|
||||||
|
HostId: 'local-aws-host-id',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NoSuchBucketException extends AwsException {
|
||||||
|
constructor(private bucketName: string) {
|
||||||
|
super(`The specified bucket does not exist`, 'NoSuchBucket', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
toXml(): string {
|
||||||
|
return js2xmlparser.parse('Error', {
|
||||||
|
Code: this.errorType,
|
||||||
|
Message: this.message,
|
||||||
|
BucketName: this.bucketName,
|
||||||
|
RequestId: this.requestId,
|
||||||
|
HostId: 'local-aws-host-id',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NoSuchKeyException extends AwsException {
|
||||||
|
constructor(private key: string) {
|
||||||
|
super(`The specified key does not exist.`, 'NoSuchKey', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
toXml(): string {
|
||||||
|
return js2xmlparser.parse('Error', {
|
||||||
|
Code: this.errorType,
|
||||||
|
Message: this.message,
|
||||||
|
Key: this.key,
|
||||||
|
RequestId: this.requestId,
|
||||||
|
HostId: 'local-aws-host-id',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BucketNotEmptyException extends AwsException {
|
||||||
|
constructor(private bucketName: string) {
|
||||||
|
super(`The bucket you tried to delete is not empty`, 'BucketNotEmpty', HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
toXml(): string {
|
||||||
|
return js2xmlparser.parse('Error', {
|
||||||
|
Code: this.errorType,
|
||||||
|
Message: this.message,
|
||||||
|
BucketName: this.bucketName,
|
||||||
|
RequestId: this.requestId,
|
||||||
|
HostId: 'local-aws-host-id',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NoSuchBucketPolicyException extends AwsException {
|
||||||
|
constructor(private bucketName: string) {
|
||||||
|
super(`The bucket policy does not exist`, 'NoSuchBucketPolicy', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
toXml(): string {
|
||||||
|
return js2xmlparser.parse('Error', {
|
||||||
|
Code: this.errorType,
|
||||||
|
Message: this.message,
|
||||||
|
BucketName: this.bucketName,
|
||||||
|
RequestId: this.requestId,
|
||||||
|
HostId: 'local-aws-host-id',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class S3Service {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async createBucket(name: string): Promise<S3Bucket> {
|
||||||
|
// Check if bucket already exists
|
||||||
|
const existing = await this.prisma.s3Bucket.findUnique({
|
||||||
|
where: { name },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new BucketAlreadyExistsException(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = await this.prisma.s3Bucket.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new S3Bucket(bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBucket(name: string): Promise<S3Bucket> {
|
||||||
|
const bucket = await this.prisma.s3Bucket.findUnique({
|
||||||
|
where: { name },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bucket) {
|
||||||
|
throw new NoSuchBucketException(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new S3Bucket(bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBuckets(): Promise<S3Bucket[]> {
|
||||||
|
const buckets = await this.prisma.s3Bucket.findMany({
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return buckets.map(b => new S3Bucket(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBucket(name: string): Promise<void> {
|
||||||
|
const bucket = await this.getBucket(name);
|
||||||
|
|
||||||
|
// Check if bucket has objects
|
||||||
|
const objectCount = await this.prisma.s3Object.count({
|
||||||
|
where: { bucketId: bucket.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (objectCount > 0) {
|
||||||
|
throw new BucketNotEmptyException(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.s3Bucket.delete({
|
||||||
|
where: { id: bucket.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async putObject(
|
||||||
|
bucketName: string,
|
||||||
|
key: string,
|
||||||
|
content: Buffer,
|
||||||
|
contentType: string,
|
||||||
|
metadata: Record<string, string>,
|
||||||
|
): Promise<S3Object> {
|
||||||
|
const bucket = await this.getBucket(bucketName);
|
||||||
|
|
||||||
|
const etag = S3Object.calculateETag(content);
|
||||||
|
|
||||||
|
// Delete existing object if it exists
|
||||||
|
await this.prisma.s3Object.deleteMany({
|
||||||
|
where: { bucketId: bucket.id, key },
|
||||||
|
});
|
||||||
|
|
||||||
|
const object = await this.prisma.s3Object.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
bucketId: bucket.id,
|
||||||
|
key,
|
||||||
|
content,
|
||||||
|
contentType,
|
||||||
|
size: content.length,
|
||||||
|
etag,
|
||||||
|
metadata: JSON.stringify(metadata),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new S3Object(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getObject(bucketName: string, key: string): Promise<S3Object> {
|
||||||
|
const bucket = await this.getBucket(bucketName);
|
||||||
|
|
||||||
|
const object = await this.prisma.s3Object.findUnique({
|
||||||
|
where: {
|
||||||
|
bucketId_key: {
|
||||||
|
bucketId: bucket.id,
|
||||||
|
key,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log({ key, object });
|
||||||
|
if (!object) {
|
||||||
|
throw new NoSuchKeyException(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new S3Object(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
async headObject(bucketName: string, key: string): Promise<S3Object> {
|
||||||
|
return this.getObject(bucketName, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteObject(bucketName: string, key: string): Promise<void> {
|
||||||
|
const bucket = await this.getBucket(bucketName);
|
||||||
|
|
||||||
|
await this.prisma.s3Object.deleteMany({
|
||||||
|
where: {
|
||||||
|
bucketId: bucket.id,
|
||||||
|
key,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listObjects(
|
||||||
|
bucketName: string,
|
||||||
|
prefix: string = '',
|
||||||
|
maxKeys: number = 1000,
|
||||||
|
): Promise<{ objects: S3Object[]; isTruncated: boolean }> {
|
||||||
|
const bucket = await this.getBucket(bucketName);
|
||||||
|
|
||||||
|
const objects = await this.prisma.s3Object.findMany({
|
||||||
|
where: {
|
||||||
|
bucketId: bucket.id,
|
||||||
|
key: {
|
||||||
|
startsWith: prefix,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { key: 'asc' },
|
||||||
|
take: maxKeys + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isTruncated = objects.length > maxKeys;
|
||||||
|
const returnObjects = objects.slice(0, maxKeys);
|
||||||
|
|
||||||
|
return {
|
||||||
|
objects: returnObjects.map(o => new S3Object(o)),
|
||||||
|
isTruncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async putBucketTagging(bucketName: string, tags: Record<string, string>): Promise<void> {
|
||||||
|
const bucket = await this.getBucket(bucketName);
|
||||||
|
|
||||||
|
await this.prisma.s3Bucket.update({
|
||||||
|
where: { id: bucket.id },
|
||||||
|
data: {
|
||||||
|
tags: JSON.stringify(tags),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBucketTagging(bucketName: string): Promise<Record<string, string>> {
|
||||||
|
const bucket = await this.getBucket(bucketName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(bucket.tags);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBucketPolicy(bucketName: string): Promise<string | null> {
|
||||||
|
const bucket = await this.getBucket(bucketName);
|
||||||
|
|
||||||
|
if (!bucket.policy) {
|
||||||
|
throw new NoSuchBucketPolicyException(bucketName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucket.policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async putBucketAcl(bucketName: string, acl: any): Promise<void> {
|
||||||
|
const bucket = await this.getBucket(bucketName);
|
||||||
|
|
||||||
|
await this.prisma.s3Bucket.update({
|
||||||
|
where: { id: bucket.id },
|
||||||
|
data: {
|
||||||
|
acl: JSON.stringify(acl),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBucketAcl(bucketName: string): Promise<any> {
|
||||||
|
const bucket = await this.getBucket(bucketName);
|
||||||
|
return JSON.parse(bucket.acl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
PutSecretValueCommand,
|
PutSecretValueCommand,
|
||||||
GetResourcePolicyCommand,
|
GetResourcePolicyCommand,
|
||||||
PutResourcePolicyCommand,
|
PutResourcePolicyCommand,
|
||||||
|
TagResourceCommand,
|
||||||
} from '@aws-sdk/client-secrets-manager';
|
} from '@aws-sdk/client-secrets-manager';
|
||||||
import { AppModule } from '../../app.module';
|
import { AppModule } from '../../app.module';
|
||||||
import { PrismaService } from '../../_prisma/prisma.service';
|
import { PrismaService } from '../../_prisma/prisma.service';
|
||||||
@@ -764,4 +765,106 @@ describe('Secrets Manager Integration Tests', () => {
|
|||||||
expect(getResponse.SecretString!.length).toBe(10000);
|
expect(getResponse.SecretString!.length).toBe(10000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TagResource', () => {
|
||||||
|
it('should tag a secret successfully', async () => {
|
||||||
|
const createResponse = await secretsManagerClient.send(
|
||||||
|
new CreateSecretCommand({
|
||||||
|
Name: 'tagged-secret',
|
||||||
|
SecretString: 'secret-value',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await secretsManagerClient.send(
|
||||||
|
new TagResourceCommand({
|
||||||
|
SecretId: createResponse.ARN,
|
||||||
|
Tags: [
|
||||||
|
{ Key: 'Environment', Value: 'Production' },
|
||||||
|
{ Key: 'Application', Value: 'MyApp' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const describeResponse = await secretsManagerClient.send(
|
||||||
|
new DescribeSecretCommand({
|
||||||
|
SecretId: 'tagged-secret',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(describeResponse.Tags).toBeDefined();
|
||||||
|
expect(describeResponse.Tags!.length).toBe(2);
|
||||||
|
expect(describeResponse.Tags).toContainEqual({ Key: 'Environment', Value: 'Production' });
|
||||||
|
expect(describeResponse.Tags).toContainEqual({ Key: 'Application', Value: 'MyApp' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update existing tag values', async () => {
|
||||||
|
const createResponse = await secretsManagerClient.send(
|
||||||
|
new CreateSecretCommand({
|
||||||
|
Name: 'update-tags-secret',
|
||||||
|
SecretString: 'secret-value',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add initial tags
|
||||||
|
await secretsManagerClient.send(
|
||||||
|
new TagResourceCommand({
|
||||||
|
SecretId: createResponse.ARN,
|
||||||
|
Tags: [{ Key: 'Version', Value: '1.0' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update tag value
|
||||||
|
await secretsManagerClient.send(
|
||||||
|
new TagResourceCommand({
|
||||||
|
SecretId: createResponse.ARN,
|
||||||
|
Tags: [{ Key: 'Version', Value: '2.0' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const describeResponse = await secretsManagerClient.send(
|
||||||
|
new DescribeSecretCommand({
|
||||||
|
SecretId: 'update-tags-secret',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(describeResponse.Tags).toBeDefined();
|
||||||
|
expect(describeResponse.Tags!.length).toBe(1);
|
||||||
|
expect(describeResponse.Tags).toContainEqual({ Key: 'Version', Value: '2.0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to tag non-existent secret', async () => {
|
||||||
|
await expect(
|
||||||
|
secretsManagerClient.send(
|
||||||
|
new TagResourceCommand({
|
||||||
|
SecretId: 'non-existent-secret',
|
||||||
|
Tags: [{ Key: 'Test', Value: 'Value' }],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should tag secret using secret name instead of ARN', async () => {
|
||||||
|
await secretsManagerClient.send(
|
||||||
|
new CreateSecretCommand({
|
||||||
|
Name: 'tag-by-name-secret',
|
||||||
|
SecretString: 'secret-value',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await secretsManagerClient.send(
|
||||||
|
new TagResourceCommand({
|
||||||
|
SecretId: 'tag-by-name-secret',
|
||||||
|
Tags: [{ Key: 'TaggedBy', Value: 'Name' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const describeResponse = await secretsManagerClient.send(
|
||||||
|
new DescribeSecretCommand({
|
||||||
|
SecretId: 'tag-by-name-secret',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(describeResponse.Tags).toContainEqual({ Key: 'TaggedBy', Value: 'Name' });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { GetResourcePolicyHandler } from './get-resource-policy.handler';
|
|||||||
import { GetSecretValueHandler } from './get-secret-value.handler';
|
import { GetSecretValueHandler } from './get-secret-value.handler';
|
||||||
import { PutResourcePolicyHandler } from './put-resource-policy.handler';
|
import { PutResourcePolicyHandler } from './put-resource-policy.handler';
|
||||||
import { PutSecretValueHandler } from './put-secret-value.handler';
|
import { PutSecretValueHandler } from './put-secret-value.handler';
|
||||||
|
import { TagResourceHandler } from './tag-resource.handler';
|
||||||
import { SecretService } from './secret.service';
|
import { SecretService } from './secret.service';
|
||||||
import { SecretsManagerHandlers } from './secrets-manager.constants';
|
import { SecretsManagerHandlers } from './secrets-manager.constants';
|
||||||
|
|
||||||
@@ -24,7 +25,8 @@ const handlers = [
|
|||||||
GetSecretValueHandler,
|
GetSecretValueHandler,
|
||||||
PutResourcePolicyHandler,
|
PutResourcePolicyHandler,
|
||||||
PutSecretValueHandler,
|
PutSecretValueHandler,
|
||||||
]
|
TagResourceHandler,
|
||||||
|
];
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
Action.SecretsManagerCancelRotateSecret,
|
Action.SecretsManagerCancelRotateSecret,
|
||||||
@@ -49,13 +51,10 @@ const actions = [
|
|||||||
Action.SecretsManagerUpdateSecret,
|
Action.SecretsManagerUpdateSecret,
|
||||||
Action.SecretsManagerUpdateSecretVersionStage,
|
Action.SecretsManagerUpdateSecretVersionStage,
|
||||||
Action.SecretsManagerValidateResourcePolicy,
|
Action.SecretsManagerValidateResourcePolicy,
|
||||||
]
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [PrismaModule, AwsSharedEntitiesModule],
|
||||||
PrismaModule,
|
|
||||||
AwsSharedEntitiesModule,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
SecretService,
|
SecretService,
|
||||||
...handlers,
|
...handlers,
|
||||||
|
|||||||
57
src/secrets-manager/tag-resource.handler.ts
Normal file
57
src/secrets-manager/tag-resource.handler.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
|
||||||
|
import { AbstractActionHandler, Format } from '../abstract-action.handler';
|
||||||
|
import { Action } from '../action.enum';
|
||||||
|
import { TagsService } from '../aws-shared-entities/tags.service';
|
||||||
|
import { ArnUtil } from '../util/arn-util.static';
|
||||||
|
import { SecretService } from './secret.service';
|
||||||
|
import { NotFoundException } from '../aws-shared-entities/aws-exceptions';
|
||||||
|
import { RequestContext } from '../_context/request.context';
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
SecretId: string;
|
||||||
|
Tags: Array<{ Key: string; Value: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TagResourceHandler extends AbstractActionHandler<QueryParams> {
|
||||||
|
constructor(private readonly secretService: SecretService, private readonly tagsService: TagsService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
format = Format.Json;
|
||||||
|
action = Action.SecretsManagerTagResource;
|
||||||
|
validator = Joi.object<QueryParams, true>({
|
||||||
|
SecretId: Joi.string().required(),
|
||||||
|
Tags: Joi.array()
|
||||||
|
.items(
|
||||||
|
Joi.object({
|
||||||
|
Key: Joi.string().required(),
|
||||||
|
Value: Joi.string().required(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async handle({ SecretId, Tags }: QueryParams, { awsProperties }: RequestContext) {
|
||||||
|
const name = ArnUtil.getSecretNameFromSecretId(SecretId);
|
||||||
|
const secret = await this.secretService.findLatestByNameAndRegion(name, awsProperties.region);
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const arn = ArnUtil.fromSecret(secret);
|
||||||
|
|
||||||
|
// Convert tags to the format expected by TagsService
|
||||||
|
const tagRecords = Tags.map(tag => ({
|
||||||
|
key: tag.Key,
|
||||||
|
value: tag.Value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.tagsService.createMany(arn, tagRecords);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
475
yarn.lock
475
yarn.lock
@@ -196,65 +196,65 @@
|
|||||||
"@smithy/util-utf8" "^4.2.0"
|
"@smithy/util-utf8" "^4.2.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/client-s3@^3.968.0":
|
"@aws-sdk/client-s3@^3.969.0":
|
||||||
version "3.968.0"
|
version "3.969.0"
|
||||||
resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.968.0.tgz#b9b8b1825abc10788cc4fac8752b0306a6e9702d"
|
resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.969.0.tgz#fe6f323c093d7ed143d5a934fbb530991efbad44"
|
||||||
integrity sha512-YQARjiiucSkaSLS0HNyexOQzYM5pPRWSo+FNtq5JSuXwJQb8vs53JeZfk7yKb59G94Oh0BLAv1598XaEdtAFyA==
|
integrity sha512-dd19qt9wCY60AS0gc7K+C26U1SdtJddn8DkwHu3psCuGaZ8r9EAKbHTNC53iLsYD5OVGsZ5bkHKQ/BjjbSyVTQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@aws-crypto/sha1-browser" "5.2.0"
|
"@aws-crypto/sha1-browser" "5.2.0"
|
||||||
"@aws-crypto/sha256-browser" "5.2.0"
|
"@aws-crypto/sha256-browser" "5.2.0"
|
||||||
"@aws-crypto/sha256-js" "5.2.0"
|
"@aws-crypto/sha256-js" "5.2.0"
|
||||||
"@aws-sdk/core" "3.968.0"
|
"@aws-sdk/core" "3.969.0"
|
||||||
"@aws-sdk/credential-provider-node" "3.968.0"
|
"@aws-sdk/credential-provider-node" "3.969.0"
|
||||||
"@aws-sdk/middleware-bucket-endpoint" "3.968.0"
|
"@aws-sdk/middleware-bucket-endpoint" "3.969.0"
|
||||||
"@aws-sdk/middleware-expect-continue" "3.968.0"
|
"@aws-sdk/middleware-expect-continue" "3.969.0"
|
||||||
"@aws-sdk/middleware-flexible-checksums" "3.968.0"
|
"@aws-sdk/middleware-flexible-checksums" "3.969.0"
|
||||||
"@aws-sdk/middleware-host-header" "3.968.0"
|
"@aws-sdk/middleware-host-header" "3.969.0"
|
||||||
"@aws-sdk/middleware-location-constraint" "3.968.0"
|
"@aws-sdk/middleware-location-constraint" "3.969.0"
|
||||||
"@aws-sdk/middleware-logger" "3.968.0"
|
"@aws-sdk/middleware-logger" "3.969.0"
|
||||||
"@aws-sdk/middleware-recursion-detection" "3.968.0"
|
"@aws-sdk/middleware-recursion-detection" "3.969.0"
|
||||||
"@aws-sdk/middleware-sdk-s3" "3.968.0"
|
"@aws-sdk/middleware-sdk-s3" "3.969.0"
|
||||||
"@aws-sdk/middleware-ssec" "3.968.0"
|
"@aws-sdk/middleware-ssec" "3.969.0"
|
||||||
"@aws-sdk/middleware-user-agent" "3.968.0"
|
"@aws-sdk/middleware-user-agent" "3.969.0"
|
||||||
"@aws-sdk/region-config-resolver" "3.968.0"
|
"@aws-sdk/region-config-resolver" "3.969.0"
|
||||||
"@aws-sdk/signature-v4-multi-region" "3.968.0"
|
"@aws-sdk/signature-v4-multi-region" "3.969.0"
|
||||||
"@aws-sdk/types" "3.968.0"
|
"@aws-sdk/types" "3.969.0"
|
||||||
"@aws-sdk/util-endpoints" "3.968.0"
|
"@aws-sdk/util-endpoints" "3.969.0"
|
||||||
"@aws-sdk/util-user-agent-browser" "3.968.0"
|
"@aws-sdk/util-user-agent-browser" "3.969.0"
|
||||||
"@aws-sdk/util-user-agent-node" "3.968.0"
|
"@aws-sdk/util-user-agent-node" "3.969.0"
|
||||||
"@smithy/config-resolver" "^4.4.5"
|
"@smithy/config-resolver" "^4.4.6"
|
||||||
"@smithy/core" "^3.20.3"
|
"@smithy/core" "^3.20.5"
|
||||||
"@smithy/eventstream-serde-browser" "^4.2.7"
|
"@smithy/eventstream-serde-browser" "^4.2.8"
|
||||||
"@smithy/eventstream-serde-config-resolver" "^4.3.7"
|
"@smithy/eventstream-serde-config-resolver" "^4.3.8"
|
||||||
"@smithy/eventstream-serde-node" "^4.2.7"
|
"@smithy/eventstream-serde-node" "^4.2.8"
|
||||||
"@smithy/fetch-http-handler" "^5.3.8"
|
"@smithy/fetch-http-handler" "^5.3.9"
|
||||||
"@smithy/hash-blob-browser" "^4.2.8"
|
"@smithy/hash-blob-browser" "^4.2.9"
|
||||||
"@smithy/hash-node" "^4.2.7"
|
"@smithy/hash-node" "^4.2.8"
|
||||||
"@smithy/hash-stream-node" "^4.2.7"
|
"@smithy/hash-stream-node" "^4.2.8"
|
||||||
"@smithy/invalid-dependency" "^4.2.7"
|
"@smithy/invalid-dependency" "^4.2.8"
|
||||||
"@smithy/md5-js" "^4.2.7"
|
"@smithy/md5-js" "^4.2.8"
|
||||||
"@smithy/middleware-content-length" "^4.2.7"
|
"@smithy/middleware-content-length" "^4.2.8"
|
||||||
"@smithy/middleware-endpoint" "^4.4.4"
|
"@smithy/middleware-endpoint" "^4.4.6"
|
||||||
"@smithy/middleware-retry" "^4.4.20"
|
"@smithy/middleware-retry" "^4.4.22"
|
||||||
"@smithy/middleware-serde" "^4.2.8"
|
"@smithy/middleware-serde" "^4.2.9"
|
||||||
"@smithy/middleware-stack" "^4.2.7"
|
"@smithy/middleware-stack" "^4.2.8"
|
||||||
"@smithy/node-config-provider" "^4.3.7"
|
"@smithy/node-config-provider" "^4.3.8"
|
||||||
"@smithy/node-http-handler" "^4.4.7"
|
"@smithy/node-http-handler" "^4.4.8"
|
||||||
"@smithy/protocol-http" "^5.3.7"
|
"@smithy/protocol-http" "^5.3.8"
|
||||||
"@smithy/smithy-client" "^4.10.5"
|
"@smithy/smithy-client" "^4.10.7"
|
||||||
"@smithy/types" "^4.11.0"
|
"@smithy/types" "^4.12.0"
|
||||||
"@smithy/url-parser" "^4.2.7"
|
"@smithy/url-parser" "^4.2.8"
|
||||||
"@smithy/util-base64" "^4.3.0"
|
"@smithy/util-base64" "^4.3.0"
|
||||||
"@smithy/util-body-length-browser" "^4.2.0"
|
"@smithy/util-body-length-browser" "^4.2.0"
|
||||||
"@smithy/util-body-length-node" "^4.2.1"
|
"@smithy/util-body-length-node" "^4.2.1"
|
||||||
"@smithy/util-defaults-mode-browser" "^4.3.19"
|
"@smithy/util-defaults-mode-browser" "^4.3.21"
|
||||||
"@smithy/util-defaults-mode-node" "^4.2.22"
|
"@smithy/util-defaults-mode-node" "^4.2.24"
|
||||||
"@smithy/util-endpoints" "^3.2.7"
|
"@smithy/util-endpoints" "^3.2.8"
|
||||||
"@smithy/util-middleware" "^4.2.7"
|
"@smithy/util-middleware" "^4.2.8"
|
||||||
"@smithy/util-retry" "^4.2.7"
|
"@smithy/util-retry" "^4.2.8"
|
||||||
"@smithy/util-stream" "^4.5.8"
|
"@smithy/util-stream" "^4.5.10"
|
||||||
"@smithy/util-utf8" "^4.2.0"
|
"@smithy/util-utf8" "^4.2.0"
|
||||||
"@smithy/util-waiter" "^4.2.7"
|
"@smithy/util-waiter" "^4.2.8"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/client-secrets-manager@^3.968.0":
|
"@aws-sdk/client-secrets-manager@^3.968.0":
|
||||||
@@ -565,12 +565,12 @@
|
|||||||
"@smithy/util-utf8" "^4.2.0"
|
"@smithy/util-utf8" "^4.2.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/crc64-nvme@3.968.0":
|
"@aws-sdk/crc64-nvme@3.969.0":
|
||||||
version "3.968.0"
|
version "3.969.0"
|
||||||
resolved "https://registry.yarnpkg.com/@aws-sdk/crc64-nvme/-/crc64-nvme-3.968.0.tgz#eba8f120d8ec5e1f6d071789efdaceab80bd4bc4"
|
resolved "https://registry.yarnpkg.com/@aws-sdk/crc64-nvme/-/crc64-nvme-3.969.0.tgz#1c7d9ffb550c26d26376e3e6129ad9f77c473802"
|
||||||
integrity sha512-buylEu7i7I42uzfnQlu0oY35GAWcslU+Vyu9mlNszDKEDwsSyFDy1wg0wQ4vPyKDHlwsIm1srGa/MIaxZk1msg==
|
integrity sha512-IGNkP54HD3uuLnrPCYsv3ZD478UYq+9WwKrIVJ9Pdi3hxPg8562CH3ZHf8hEgfePN31P9Kj+Zu9kq2Qcjjt61A==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@smithy/types" "^4.11.0"
|
"@smithy/types" "^4.12.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/credential-provider-env@3.968.0":
|
"@aws-sdk/credential-provider-env@3.968.0":
|
||||||
@@ -809,46 +809,46 @@
|
|||||||
"@smithy/types" "^4.12.0"
|
"@smithy/types" "^4.12.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/middleware-bucket-endpoint@3.968.0":
|
"@aws-sdk/middleware-bucket-endpoint@3.969.0":
|
||||||
version "3.968.0"
|
version "3.969.0"
|
||||||
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.968.0.tgz#4a2d82ec3f35349039f7bdfc33726a680d5de2b4"
|
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.969.0.tgz#806dd79c406a689332c6f8b3d9b948eb8dae9bb8"
|
||||||
integrity sha512-KlA6D9wgyGF3KkKIRmmXxvKfzzGkibnnR6Kjp0NQAOi4jvKWuT/HKJX87sBJIrk8RWq+9Aq0SOY9LYqkdx9zJQ==
|
integrity sha512-MlbrlixtkTVhYhoasblKOkr7n2yydvUZjjxTnBhIuHmkyBS1619oGnTfq/uLeGYb4NYXdeQ5OYcqsRGvmWSuTw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@aws-sdk/types" "3.968.0"
|
"@aws-sdk/types" "3.969.0"
|
||||||
"@aws-sdk/util-arn-parser" "3.968.0"
|
"@aws-sdk/util-arn-parser" "3.968.0"
|
||||||
"@smithy/node-config-provider" "^4.3.7"
|
"@smithy/node-config-provider" "^4.3.8"
|
||||||
"@smithy/protocol-http" "^5.3.7"
|
"@smithy/protocol-http" "^5.3.8"
|
||||||
"@smithy/types" "^4.11.0"
|
"@smithy/types" "^4.12.0"
|
||||||
"@smithy/util-config-provider" "^4.2.0"
|
"@smithy/util-config-provider" "^4.2.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/middleware-expect-continue@3.968.0":
|
"@aws-sdk/middleware-expect-continue@3.969.0":
|
||||||
version "3.968.0"
|
version "3.969.0"
|
||||||
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.968.0.tgz#11ab8d7346b1f027a723fb7c6b58a8a3f1d14815"
|
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.969.0.tgz#b040eca51f73681280ea9c39e20728558355e1e8"
|
||||||
integrity sha512-VCcDw21JCJywZH8+vpZCsVB9HV2BQ6BdF+cXww5nKnPNi+d05sHFczRHUQjfsEJiZ8Wb/a4M3mJuVrQ5gjiNUA==
|
integrity sha512-qXygzSi8osok7tH9oeuS3HoKw6jRfbvg5Me/X5RlHOvSSqQz8c5O9f3MjUApaCUSwbAU92KrbZWasw2PKiaVHg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@aws-sdk/types" "3.968.0"
|
"@aws-sdk/types" "3.969.0"
|
||||||
"@smithy/protocol-http" "^5.3.7"
|
"@smithy/protocol-http" "^5.3.8"
|
||||||
"@smithy/types" "^4.11.0"
|
"@smithy/types" "^4.12.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/middleware-flexible-checksums@3.968.0":
|
"@aws-sdk/middleware-flexible-checksums@3.969.0":
|
||||||
version "3.968.0"
|
version "3.969.0"
|
||||||
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.968.0.tgz#f13d2998225dc76093e7cf127f27f94861fc11e3"
|
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.969.0.tgz#56f0ff7ea539574540610e6b5d9563c9d0255896"
|
||||||
integrity sha512-5G4hpKS0XbU8s3WuuFP6qpB6kkFB45LQ2VomrS0FoyTXH9XUDYL1OmwraBe3t2N5LnpqOh1+RAJOyO8gRwO7xA==
|
integrity sha512-RKpo76qcHhQkSgu+wJNvwio8MzMD7ScwBaMCQhJfqzFTrhhlKtMkf8oxhBRRYU7rat368p35h6CbfxM18g/WNQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@aws-crypto/crc32" "5.2.0"
|
"@aws-crypto/crc32" "5.2.0"
|
||||||
"@aws-crypto/crc32c" "5.2.0"
|
"@aws-crypto/crc32c" "5.2.0"
|
||||||
"@aws-crypto/util" "5.2.0"
|
"@aws-crypto/util" "5.2.0"
|
||||||
"@aws-sdk/core" "3.968.0"
|
"@aws-sdk/core" "3.969.0"
|
||||||
"@aws-sdk/crc64-nvme" "3.968.0"
|
"@aws-sdk/crc64-nvme" "3.969.0"
|
||||||
"@aws-sdk/types" "3.968.0"
|
"@aws-sdk/types" "3.969.0"
|
||||||
"@smithy/is-array-buffer" "^4.2.0"
|
"@smithy/is-array-buffer" "^4.2.0"
|
||||||
"@smithy/node-config-provider" "^4.3.7"
|
"@smithy/node-config-provider" "^4.3.8"
|
||||||
"@smithy/protocol-http" "^5.3.7"
|
"@smithy/protocol-http" "^5.3.8"
|
||||||
"@smithy/types" "^4.11.0"
|
"@smithy/types" "^4.12.0"
|
||||||
"@smithy/util-middleware" "^4.2.7"
|
"@smithy/util-middleware" "^4.2.8"
|
||||||
"@smithy/util-stream" "^4.5.8"
|
"@smithy/util-stream" "^4.5.10"
|
||||||
"@smithy/util-utf8" "^4.2.0"
|
"@smithy/util-utf8" "^4.2.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
@@ -872,13 +872,13 @@
|
|||||||
"@smithy/types" "^4.12.0"
|
"@smithy/types" "^4.12.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/middleware-location-constraint@3.968.0":
|
"@aws-sdk/middleware-location-constraint@3.969.0":
|
||||||
version "3.968.0"
|
version "3.969.0"
|
||||||
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.968.0.tgz#94f11537a71a28267ca00e9d04e803527d698b53"
|
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.969.0.tgz#6530b94097d22b5ef69fffda8d194a2f55f6980a"
|
||||||
integrity sha512-+usAEX4rPmOofmLhZHgnRvW3idDnXdYnhaiOjfj2ynU05elTUkF2b4fyq+KhdjZQVbUpCewq4eKqgjGaGhIyyw==
|
integrity sha512-zH7pDfMLG/C4GWMOpvJEoYcSpj7XsNP9+irlgqwi667sUQ6doHQJ3yyDut3yiTk0maq1VgmriPFELyI9lrvH/g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@aws-sdk/types" "3.968.0"
|
"@aws-sdk/types" "3.969.0"
|
||||||
"@smithy/types" "^4.11.0"
|
"@smithy/types" "^4.12.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/middleware-logger@3.968.0":
|
"@aws-sdk/middleware-logger@3.968.0":
|
||||||
@@ -921,23 +921,23 @@
|
|||||||
"@smithy/types" "^4.12.0"
|
"@smithy/types" "^4.12.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/middleware-sdk-s3@3.968.0":
|
"@aws-sdk/middleware-sdk-s3@3.969.0":
|
||||||
version "3.968.0"
|
version "3.969.0"
|
||||||
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.968.0.tgz#f1928b9f3ad9f9b9b66c83b2af4f257895827cf5"
|
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.969.0.tgz#56453735dea8e5c2b413478b5744a4172f0821bd"
|
||||||
integrity sha512-fh2mQ/uwJ1Sth1q2dWAbeyky/SBPaqe1fjxvsNeEY6dtfi8PjW85zHpz1JoAhCKTRkrEdXYAqkqUwsUydLucyQ==
|
integrity sha512-xjcyZrbtvVaqkmjkhmqX+16Wf7zFVS/cYnNFu/JyG6ekkIxSXEAjptNwSEDzlAiLzf0Hf6dYj5erLZYGa40eWg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@aws-sdk/core" "3.968.0"
|
"@aws-sdk/core" "3.969.0"
|
||||||
"@aws-sdk/types" "3.968.0"
|
"@aws-sdk/types" "3.969.0"
|
||||||
"@aws-sdk/util-arn-parser" "3.968.0"
|
"@aws-sdk/util-arn-parser" "3.968.0"
|
||||||
"@smithy/core" "^3.20.3"
|
"@smithy/core" "^3.20.5"
|
||||||
"@smithy/node-config-provider" "^4.3.7"
|
"@smithy/node-config-provider" "^4.3.8"
|
||||||
"@smithy/protocol-http" "^5.3.7"
|
"@smithy/protocol-http" "^5.3.8"
|
||||||
"@smithy/signature-v4" "^5.3.7"
|
"@smithy/signature-v4" "^5.3.8"
|
||||||
"@smithy/smithy-client" "^4.10.5"
|
"@smithy/smithy-client" "^4.10.7"
|
||||||
"@smithy/types" "^4.11.0"
|
"@smithy/types" "^4.12.0"
|
||||||
"@smithy/util-config-provider" "^4.2.0"
|
"@smithy/util-config-provider" "^4.2.0"
|
||||||
"@smithy/util-middleware" "^4.2.7"
|
"@smithy/util-middleware" "^4.2.8"
|
||||||
"@smithy/util-stream" "^4.5.8"
|
"@smithy/util-stream" "^4.5.10"
|
||||||
"@smithy/util-utf8" "^4.2.0"
|
"@smithy/util-utf8" "^4.2.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
@@ -953,13 +953,13 @@
|
|||||||
"@smithy/util-utf8" "^4.2.0"
|
"@smithy/util-utf8" "^4.2.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/middleware-ssec@3.968.0":
|
"@aws-sdk/middleware-ssec@3.969.0":
|
||||||
version "3.968.0"
|
version "3.969.0"
|
||||||
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-ssec/-/middleware-ssec-3.968.0.tgz#f9d719af2a70d472be84d0f78c1feb3b5e450c71"
|
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-ssec/-/middleware-ssec-3.969.0.tgz#3f02ef168f3c29254739742ebb895237e85dde19"
|
||||||
integrity sha512-gbrhJ/JrKJ48SDPtlt5jPOadiPl2Rae0VLuNRyNg0ng7ygRO/0NjgKME4D1XINDjMOiZsOLNAcXmmwGFsVZsyw==
|
integrity sha512-9wUYtd5ye4exygKHyl02lPVHUoAFlxxXoqvlw7u2sycfkK6uHLlwdsPru3MkMwj47ZSZs+lkyP/sVKXVMhuaAg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@aws-sdk/types" "3.968.0"
|
"@aws-sdk/types" "3.969.0"
|
||||||
"@smithy/types" "^4.11.0"
|
"@smithy/types" "^4.12.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/middleware-user-agent@3.968.0":
|
"@aws-sdk/middleware-user-agent@3.968.0":
|
||||||
@@ -1098,16 +1098,16 @@
|
|||||||
"@smithy/types" "^4.12.0"
|
"@smithy/types" "^4.12.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/signature-v4-multi-region@3.968.0":
|
"@aws-sdk/signature-v4-multi-region@3.969.0":
|
||||||
version "3.968.0"
|
version "3.969.0"
|
||||||
resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.968.0.tgz#880d19f3287cdc7418f202ff11ded2c111f06aa0"
|
resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.969.0.tgz#d187298811c8702b278c71f02c47b3a15f40b7ef"
|
||||||
integrity sha512-kRBA1KK3LTHnfYJLPsESNF2WhQN6DyGc9MiM6qG8AdJwMPQkanF5hwtckV1ToO2KB5v1q+1PuvBvy6Npd2IV+w==
|
integrity sha512-pv8BEQOlUzK+ww8ZfXZOnDzLfPO5+O7puBFtU1fE8CdCAQ/RP/B1XY3hxzW9Xs0dax7graYKnY8wd8ooYy7vBw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@aws-sdk/middleware-sdk-s3" "3.968.0"
|
"@aws-sdk/middleware-sdk-s3" "3.969.0"
|
||||||
"@aws-sdk/types" "3.968.0"
|
"@aws-sdk/types" "3.969.0"
|
||||||
"@smithy/protocol-http" "^5.3.7"
|
"@smithy/protocol-http" "^5.3.8"
|
||||||
"@smithy/signature-v4" "^5.3.7"
|
"@smithy/signature-v4" "^5.3.8"
|
||||||
"@smithy/types" "^4.11.0"
|
"@smithy/types" "^4.12.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@aws-sdk/token-providers@3.968.0":
|
"@aws-sdk/token-providers@3.968.0":
|
||||||
@@ -2158,11 +2158,6 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@prisma/debug" "6.19.2"
|
"@prisma/debug" "6.19.2"
|
||||||
|
|
||||||
"@sec-ant/readable-stream@^0.4.1":
|
|
||||||
version "0.4.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz#60de891bb126abfdc5410fdc6166aca065f10a0c"
|
|
||||||
integrity sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==
|
|
||||||
|
|
||||||
"@sideway/address@^4.1.5":
|
"@sideway/address@^4.1.5":
|
||||||
version "4.1.5"
|
version "4.1.5"
|
||||||
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5"
|
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5"
|
||||||
@@ -2185,45 +2180,20 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.34.47.tgz#61b684d8a20d2890b9f1f7b0d4f76b4b39f5bc0d"
|
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.34.47.tgz#61b684d8a20d2890b9f1f7b0d4f76b4b39f5bc0d"
|
||||||
integrity sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==
|
integrity sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==
|
||||||
|
|
||||||
"@sindresorhus/merge-streams@^4.0.0":
|
"@sinonjs/commons@^3.0.1":
|
||||||
version "4.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz#abb11d99aeb6d27f1b563c38147a72d50058e339"
|
|
||||||
integrity sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==
|
|
||||||
|
|
||||||
"@sinonjs/commons@^3.0.0", "@sinonjs/commons@^3.0.1":
|
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd"
|
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd"
|
||||||
integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==
|
integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
type-detect "4.0.8"
|
type-detect "4.0.8"
|
||||||
|
|
||||||
"@sinonjs/fake-timers@11.2.2":
|
"@sinonjs/fake-timers@^13.0.0":
|
||||||
version "11.2.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz#50063cc3574f4a27bd8453180a04171c85cc9699"
|
|
||||||
integrity sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==
|
|
||||||
dependencies:
|
|
||||||
"@sinonjs/commons" "^3.0.0"
|
|
||||||
|
|
||||||
"@sinonjs/fake-timers@^13.0.0", "@sinonjs/fake-timers@^13.0.1":
|
|
||||||
version "13.0.5"
|
version "13.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz#36b9dbc21ad5546486ea9173d6bea063eb1717d5"
|
resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz#36b9dbc21ad5546486ea9173d6bea063eb1717d5"
|
||||||
integrity sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==
|
integrity sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@sinonjs/commons" "^3.0.1"
|
"@sinonjs/commons" "^3.0.1"
|
||||||
|
|
||||||
"@sinonjs/samsam@^8.0.0":
|
|
||||||
version "8.0.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-8.0.3.tgz#eb6ffaef421e1e27783cc9b52567de20cb28072d"
|
|
||||||
integrity sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==
|
|
||||||
dependencies:
|
|
||||||
"@sinonjs/commons" "^3.0.1"
|
|
||||||
type-detect "^4.1.0"
|
|
||||||
|
|
||||||
"@sinonjs/text-encoding@^0.7.3":
|
|
||||||
version "0.7.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz#282046f03e886e352b2d5f5da5eb755e01457f3f"
|
|
||||||
integrity sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==
|
|
||||||
|
|
||||||
"@smithy/abort-controller@^4.2.8":
|
"@smithy/abort-controller@^4.2.8":
|
||||||
version "4.2.8"
|
version "4.2.8"
|
||||||
resolved "https://registry.yarnpkg.com/@smithy/abort-controller/-/abort-controller-4.2.8.tgz#3bfd7a51acce88eaec9a65c3382542be9f3a053a"
|
resolved "https://registry.yarnpkg.com/@smithy/abort-controller/-/abort-controller-4.2.8.tgz#3bfd7a51acce88eaec9a65c3382542be9f3a053a"
|
||||||
@@ -2296,7 +2266,7 @@
|
|||||||
"@smithy/util-hex-encoding" "^4.2.0"
|
"@smithy/util-hex-encoding" "^4.2.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@smithy/eventstream-serde-browser@^4.2.7":
|
"@smithy/eventstream-serde-browser@^4.2.8":
|
||||||
version "4.2.8"
|
version "4.2.8"
|
||||||
resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz#04e2e1fad18e286d5595fbc0bff22e71251fca38"
|
resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz#04e2e1fad18e286d5595fbc0bff22e71251fca38"
|
||||||
integrity sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==
|
integrity sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==
|
||||||
@@ -2305,7 +2275,7 @@
|
|||||||
"@smithy/types" "^4.12.0"
|
"@smithy/types" "^4.12.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@smithy/eventstream-serde-config-resolver@^4.3.7":
|
"@smithy/eventstream-serde-config-resolver@^4.3.8":
|
||||||
version "4.3.8"
|
version "4.3.8"
|
||||||
resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz#b913d23834c6ebf1646164893e1bec89dffe4f3b"
|
resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz#b913d23834c6ebf1646164893e1bec89dffe4f3b"
|
||||||
integrity sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==
|
integrity sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==
|
||||||
@@ -2313,7 +2283,7 @@
|
|||||||
"@smithy/types" "^4.12.0"
|
"@smithy/types" "^4.12.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@smithy/eventstream-serde-node@^4.2.7":
|
"@smithy/eventstream-serde-node@^4.2.8":
|
||||||
version "4.2.8"
|
version "4.2.8"
|
||||||
resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz#5f2dfa2cbb30bf7564c8d8d82a9832e9313f5243"
|
resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz#5f2dfa2cbb30bf7564c8d8d82a9832e9313f5243"
|
||||||
integrity sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==
|
integrity sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==
|
||||||
@@ -2342,7 +2312,7 @@
|
|||||||
"@smithy/util-base64" "^4.3.0"
|
"@smithy/util-base64" "^4.3.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@smithy/hash-blob-browser@^4.2.8":
|
"@smithy/hash-blob-browser@^4.2.9":
|
||||||
version "4.2.9"
|
version "4.2.9"
|
||||||
resolved "https://registry.yarnpkg.com/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz#4f8e19b12b5a1000b7292b30f5ee237d32216af3"
|
resolved "https://registry.yarnpkg.com/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz#4f8e19b12b5a1000b7292b30f5ee237d32216af3"
|
||||||
integrity sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==
|
integrity sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==
|
||||||
@@ -2362,7 +2332,7 @@
|
|||||||
"@smithy/util-utf8" "^4.2.0"
|
"@smithy/util-utf8" "^4.2.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@smithy/hash-stream-node@^4.2.7":
|
"@smithy/hash-stream-node@^4.2.8":
|
||||||
version "4.2.8"
|
version "4.2.8"
|
||||||
resolved "https://registry.yarnpkg.com/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz#d541a31c714ac9c85ae9fec91559e81286707ddb"
|
resolved "https://registry.yarnpkg.com/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz#d541a31c714ac9c85ae9fec91559e81286707ddb"
|
||||||
integrity sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==
|
integrity sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==
|
||||||
@@ -2393,7 +2363,7 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@smithy/md5-js@^4.2.7":
|
"@smithy/md5-js@^4.2.7", "@smithy/md5-js@^4.2.8":
|
||||||
version "4.2.8"
|
version "4.2.8"
|
||||||
resolved "https://registry.yarnpkg.com/@smithy/md5-js/-/md5-js-4.2.8.tgz#d354dbf9aea7a580be97598a581e35eef324ce22"
|
resolved "https://registry.yarnpkg.com/@smithy/md5-js/-/md5-js-4.2.8.tgz#d354dbf9aea7a580be97598a581e35eef324ce22"
|
||||||
integrity sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==
|
integrity sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==
|
||||||
@@ -2708,7 +2678,7 @@
|
|||||||
"@smithy/util-buffer-from" "^4.2.0"
|
"@smithy/util-buffer-from" "^4.2.0"
|
||||||
tslib "^2.6.2"
|
tslib "^2.6.2"
|
||||||
|
|
||||||
"@smithy/util-waiter@^4.2.7", "@smithy/util-waiter@^4.2.8":
|
"@smithy/util-waiter@^4.2.8":
|
||||||
version "4.2.8"
|
version "4.2.8"
|
||||||
resolved "https://registry.yarnpkg.com/@smithy/util-waiter/-/util-waiter-4.2.8.tgz#35d7bd8b2be7a2ebc12d8c38a0818c501b73e928"
|
resolved "https://registry.yarnpkg.com/@smithy/util-waiter/-/util-waiter-4.2.8.tgz#35d7bd8b2be7a2ebc12d8c38a0818c501b73e928"
|
||||||
integrity sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==
|
integrity sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==
|
||||||
@@ -2803,6 +2773,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@types/consul@^2.0.0":
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/consul/-/consul-2.0.0.tgz#f931a968d480234236435c9294ab1ac4aaeb11fc"
|
||||||
|
integrity sha512-IyvUaJNOrU+X/Zh46/zwwUPHOEULuC4zod8KNUDElBqN8d9XwtPdyvEeHBZ/HPmkWN9EqR4KbfnzQuRLlWt9+Q==
|
||||||
|
dependencies:
|
||||||
|
consul "*"
|
||||||
|
|
||||||
"@types/eslint-scope@^3.7.7":
|
"@types/eslint-scope@^3.7.7":
|
||||||
version "3.7.7"
|
version "3.7.7"
|
||||||
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5"
|
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5"
|
||||||
@@ -2941,18 +2918,6 @@
|
|||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
"@types/send" "<1"
|
"@types/send" "<1"
|
||||||
|
|
||||||
"@types/sinon@^17.0.3":
|
|
||||||
version "17.0.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.4.tgz#fd9a3e8e07eea1a3f4a6f82a972c899e5778f369"
|
|
||||||
integrity sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==
|
|
||||||
dependencies:
|
|
||||||
"@types/sinonjs__fake-timers" "*"
|
|
||||||
|
|
||||||
"@types/sinonjs__fake-timers@*":
|
|
||||||
version "15.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz#49f731d9453f52d64dd79f5a5626c1cf1b81bea4"
|
|
||||||
integrity sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==
|
|
||||||
|
|
||||||
"@types/stack-utils@^2.0.3":
|
"@types/stack-utils@^2.0.3":
|
||||||
version "2.0.3"
|
version "2.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8"
|
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8"
|
||||||
@@ -3384,15 +3349,6 @@ array-timsort@^1.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/array-timsort/-/array-timsort-1.0.3.tgz#3c9e4199e54fb2b9c3fe5976396a21614ef0d926"
|
resolved "https://registry.yarnpkg.com/array-timsort/-/array-timsort-1.0.3.tgz#3c9e4199e54fb2b9c3fe5976396a21614ef0d926"
|
||||||
integrity sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==
|
integrity sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==
|
||||||
|
|
||||||
aws-sdk-client-mock@^4.1.0:
|
|
||||||
version "4.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/aws-sdk-client-mock/-/aws-sdk-client-mock-4.1.0.tgz#ae1950b2277f8e65f9a039975d79ff9fffab39e3"
|
|
||||||
integrity sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw==
|
|
||||||
dependencies:
|
|
||||||
"@types/sinon" "^17.0.3"
|
|
||||||
sinon "^18.0.1"
|
|
||||||
tslib "^2.1.0"
|
|
||||||
|
|
||||||
babel-jest@30.2.0:
|
babel-jest@30.2.0:
|
||||||
version "30.2.0"
|
version "30.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-30.2.0.tgz#fd44a1ec9552be35ead881f7381faa7d8f3b95ac"
|
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-30.2.0.tgz#fd44a1ec9552be35ead881f7381faa7d8f3b95ac"
|
||||||
@@ -3749,11 +3705,6 @@ cjs-module-lexer@^2.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz#b3ca5101843389259ade7d88c77bd06ce55849ca"
|
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz#b3ca5101843389259ade7d88c77bd06ce55849ca"
|
||||||
integrity sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==
|
integrity sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==
|
||||||
|
|
||||||
class-transformer@^0.5.1:
|
|
||||||
version "0.5.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.5.1.tgz#24147d5dffd2a6cea930a3250a677addf96ab336"
|
|
||||||
integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==
|
|
||||||
|
|
||||||
clean-stack@^2.0.0:
|
clean-stack@^2.0.0:
|
||||||
version "2.2.0"
|
version "2.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b"
|
resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b"
|
||||||
@@ -3887,6 +3838,14 @@ console-control-strings@^1.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
|
resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
|
||||||
integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==
|
integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==
|
||||||
|
|
||||||
|
consul@*, consul@^2.0.1:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/consul/-/consul-2.0.1.tgz#414a1a89807c0b8ad7f07a7471c9b794988536fa"
|
||||||
|
integrity sha512-91ExUUelOJ1yyB0etYAR0w1p6Ues1VosEyBVxPcWJdnQDTKqAEFzL0MHfOqZWYI2d4HZ4FgotHZkAPW2A/xahA==
|
||||||
|
dependencies:
|
||||||
|
papi "^1.1.0"
|
||||||
|
uuid "^10.0.0"
|
||||||
|
|
||||||
content-disposition@~0.5.4:
|
content-disposition@~0.5.4:
|
||||||
version "0.5.4"
|
version "0.5.4"
|
||||||
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
|
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
|
||||||
@@ -4043,11 +4002,6 @@ detect-newline@^3.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
|
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
|
||||||
integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==
|
integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==
|
||||||
|
|
||||||
diff@^5.2.0:
|
|
||||||
version "5.2.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531"
|
|
||||||
integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==
|
|
||||||
|
|
||||||
doctrine@^3.0.0:
|
doctrine@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
|
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
|
||||||
@@ -4346,24 +4300,6 @@ execa@^5.1.1:
|
|||||||
signal-exit "^3.0.3"
|
signal-exit "^3.0.3"
|
||||||
strip-final-newline "^2.0.0"
|
strip-final-newline "^2.0.0"
|
||||||
|
|
||||||
execa@^9.5.2:
|
|
||||||
version "9.6.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/execa/-/execa-9.6.1.tgz#5b90acedc6bdc0fa9b9a6ddf8f9cbb0c75a7c471"
|
|
||||||
integrity sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==
|
|
||||||
dependencies:
|
|
||||||
"@sindresorhus/merge-streams" "^4.0.0"
|
|
||||||
cross-spawn "^7.0.6"
|
|
||||||
figures "^6.1.0"
|
|
||||||
get-stream "^9.0.0"
|
|
||||||
human-signals "^8.0.1"
|
|
||||||
is-plain-obj "^4.1.0"
|
|
||||||
is-stream "^4.0.1"
|
|
||||||
npm-run-path "^6.0.0"
|
|
||||||
pretty-ms "^9.2.0"
|
|
||||||
signal-exit "^4.1.0"
|
|
||||||
strip-final-newline "^4.0.0"
|
|
||||||
yoctocolors "^2.1.1"
|
|
||||||
|
|
||||||
exit-x@^0.2.2:
|
exit-x@^0.2.2:
|
||||||
version "0.2.2"
|
version "0.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/exit-x/-/exit-x-0.2.2.tgz#1f9052de3b8d99a696b10dad5bced9bdd5c3aa64"
|
resolved "https://registry.yarnpkg.com/exit-x/-/exit-x-0.2.2.tgz#1f9052de3b8d99a696b10dad5bced9bdd5c3aa64"
|
||||||
@@ -4502,13 +4438,6 @@ figures@^3.0.0, figures@^3.2.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
escape-string-regexp "^1.0.5"
|
escape-string-regexp "^1.0.5"
|
||||||
|
|
||||||
figures@^6.1.0:
|
|
||||||
version "6.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/figures/-/figures-6.1.0.tgz#935479f51865fa7479f6fa94fc6fc7ac14e62c4a"
|
|
||||||
integrity sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==
|
|
||||||
dependencies:
|
|
||||||
is-unicode-supported "^2.0.0"
|
|
||||||
|
|
||||||
file-entry-cache@^6.0.1:
|
file-entry-cache@^6.0.1:
|
||||||
version "6.0.1"
|
version "6.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027"
|
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027"
|
||||||
@@ -4716,14 +4645,6 @@ get-stream@^6.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
|
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
|
||||||
integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
|
integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
|
||||||
|
|
||||||
get-stream@^9.0.0:
|
|
||||||
version "9.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-9.0.1.tgz#95157d21df8eb90d1647102b63039b1df60ebd27"
|
|
||||||
integrity sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==
|
|
||||||
dependencies:
|
|
||||||
"@sec-ant/readable-stream" "^0.4.1"
|
|
||||||
is-stream "^4.0.1"
|
|
||||||
|
|
||||||
giget@^2.0.0:
|
giget@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/giget/-/giget-2.0.0.tgz#395fc934a43f9a7a29a29d55b99f23e30c14f195"
|
resolved "https://registry.yarnpkg.com/giget/-/giget-2.0.0.tgz#395fc934a43f9a7a29a29d55b99f23e30c14f195"
|
||||||
@@ -4907,11 +4828,6 @@ human-signals@^2.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
|
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
|
||||||
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
|
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
|
||||||
|
|
||||||
human-signals@^8.0.1:
|
|
||||||
version "8.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-8.0.1.tgz#f08bb593b6d1db353933d06156cedec90abe51fb"
|
|
||||||
integrity sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==
|
|
||||||
|
|
||||||
humanize-ms@^1.2.1:
|
humanize-ms@^1.2.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed"
|
resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed"
|
||||||
@@ -5098,31 +5014,16 @@ is-path-inside@^3.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
|
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
|
||||||
integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
|
integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
|
||||||
|
|
||||||
is-plain-obj@^4.1.0:
|
|
||||||
version "4.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0"
|
|
||||||
integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==
|
|
||||||
|
|
||||||
is-stream@^2.0.0:
|
is-stream@^2.0.0:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
|
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
|
||||||
integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
|
integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
|
||||||
|
|
||||||
is-stream@^4.0.1:
|
|
||||||
version "4.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-4.0.1.tgz#375cf891e16d2e4baec250b85926cffc14720d9b"
|
|
||||||
integrity sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==
|
|
||||||
|
|
||||||
is-unicode-supported@^0.1.0:
|
is-unicode-supported@^0.1.0:
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7"
|
resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7"
|
||||||
integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==
|
integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==
|
||||||
|
|
||||||
is-unicode-supported@^2.0.0:
|
|
||||||
version "2.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a"
|
|
||||||
integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==
|
|
||||||
|
|
||||||
isexe@^2.0.0:
|
isexe@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||||
@@ -5659,11 +5560,6 @@ jsonfile@^6.0.1:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs "^4.1.6"
|
graceful-fs "^4.1.6"
|
||||||
|
|
||||||
just-extend@^6.2.0:
|
|
||||||
version "6.2.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-6.2.0.tgz#b816abfb3d67ee860482e7401564672558163947"
|
|
||||||
integrity sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==
|
|
||||||
|
|
||||||
keyv@^4.5.3:
|
keyv@^4.5.3:
|
||||||
version "4.5.4"
|
version "4.5.4"
|
||||||
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
|
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
|
||||||
@@ -6028,17 +5924,6 @@ neo-async@^2.6.2:
|
|||||||
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
|
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
|
||||||
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
|
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
|
||||||
|
|
||||||
nise@^6.0.0:
|
|
||||||
version "6.1.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/nise/-/nise-6.1.1.tgz#78ea93cc49be122e44cb7c8fdf597b0e8778b64a"
|
|
||||||
integrity sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==
|
|
||||||
dependencies:
|
|
||||||
"@sinonjs/commons" "^3.0.1"
|
|
||||||
"@sinonjs/fake-timers" "^13.0.1"
|
|
||||||
"@sinonjs/text-encoding" "^0.7.3"
|
|
||||||
just-extend "^6.2.0"
|
|
||||||
path-to-regexp "^8.1.0"
|
|
||||||
|
|
||||||
node-abi@^3.3.0:
|
node-abi@^3.3.0:
|
||||||
version "3.85.0"
|
version "3.85.0"
|
||||||
resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.85.0.tgz#b115d575e52b2495ef08372b058e13d202875a7d"
|
resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.85.0.tgz#b115d575e52b2495ef08372b058e13d202875a7d"
|
||||||
@@ -6120,14 +6005,6 @@ npm-run-path@^4.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
path-key "^3.0.0"
|
path-key "^3.0.0"
|
||||||
|
|
||||||
npm-run-path@^6.0.0:
|
|
||||||
version "6.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-6.0.0.tgz#25cfdc4eae04976f3349c0b1afc089052c362537"
|
|
||||||
integrity sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==
|
|
||||||
dependencies:
|
|
||||||
path-key "^4.0.0"
|
|
||||||
unicorn-magic "^0.3.0"
|
|
||||||
|
|
||||||
npmlog@^6.0.0:
|
npmlog@^6.0.0:
|
||||||
version "6.0.2"
|
version "6.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-6.0.2.tgz#c8166017a42f2dea92d6453168dd865186a70830"
|
resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-6.0.2.tgz#c8166017a42f2dea92d6453168dd865186a70830"
|
||||||
@@ -6262,6 +6139,11 @@ package-json-from-dist@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
|
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
|
||||||
integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
|
integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
|
||||||
|
|
||||||
|
papi@^1.1.0:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/papi/-/papi-1.1.2.tgz#5b1d7686834bec489f1f823103c5e8554f2b984f"
|
||||||
|
integrity sha512-cwM6pPpfAYgPe3EQi23SmB5J5s4XFS9lou9z63I5BbnMGmFaR8LAKvKboW7n1IUAKj76OtnyK0YU16JjnZrqVg==
|
||||||
|
|
||||||
parent-module@^1.0.0:
|
parent-module@^1.0.0:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
|
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
|
||||||
@@ -6279,11 +6161,6 @@ parse-json@^5.2.0:
|
|||||||
json-parse-even-better-errors "^2.3.0"
|
json-parse-even-better-errors "^2.3.0"
|
||||||
lines-and-columns "^1.1.6"
|
lines-and-columns "^1.1.6"
|
||||||
|
|
||||||
parse-ms@^4.0.0:
|
|
||||||
version "4.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-4.0.0.tgz#c0c058edd47c2a590151a718990533fd62803df4"
|
|
||||||
integrity sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==
|
|
||||||
|
|
||||||
parseurl@~1.3.3:
|
parseurl@~1.3.3:
|
||||||
version "1.3.3"
|
version "1.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||||
@@ -6304,11 +6181,6 @@ path-key@^3.0.0, path-key@^3.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
||||||
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
|
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
|
||||||
|
|
||||||
path-key@^4.0.0:
|
|
||||||
version "4.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18"
|
|
||||||
integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==
|
|
||||||
|
|
||||||
path-scurry@^1.11.1:
|
path-scurry@^1.11.1:
|
||||||
version "1.11.1"
|
version "1.11.1"
|
||||||
resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2"
|
resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2"
|
||||||
@@ -6322,11 +6194,6 @@ path-to-regexp@3.3.0:
|
|||||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.3.0.tgz#f7f31d32e8518c2660862b644414b6d5c63a611b"
|
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.3.0.tgz#f7f31d32e8518c2660862b644414b6d5c63a611b"
|
||||||
integrity sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==
|
integrity sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==
|
||||||
|
|
||||||
path-to-regexp@^8.1.0:
|
|
||||||
version "8.3.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz#aa818a6981f99321003a08987d3cec9c3474cd1f"
|
|
||||||
integrity sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==
|
|
||||||
|
|
||||||
path-to-regexp@~0.1.12:
|
path-to-regexp@~0.1.12:
|
||||||
version "0.1.12"
|
version "0.1.12"
|
||||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7"
|
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7"
|
||||||
@@ -6425,13 +6292,6 @@ pretty-format@30.2.0, pretty-format@^30.0.0:
|
|||||||
ansi-styles "^5.2.0"
|
ansi-styles "^5.2.0"
|
||||||
react-is "^18.3.1"
|
react-is "^18.3.1"
|
||||||
|
|
||||||
pretty-ms@^9.2.0:
|
|
||||||
version "9.3.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-9.3.0.tgz#dd2524fcb3c326b4931b2272dfd1e1a8ed9a9f5a"
|
|
||||||
integrity sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==
|
|
||||||
dependencies:
|
|
||||||
parse-ms "^4.0.0"
|
|
||||||
|
|
||||||
prisma@^6.1.0:
|
prisma@^6.1.0:
|
||||||
version "6.19.2"
|
version "6.19.2"
|
||||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-6.19.2.tgz#ad6f41a57fd855c730898cccb77da5d2c9d1774d"
|
resolved "https://registry.yarnpkg.com/prisma/-/prisma-6.19.2.tgz#ad6f41a57fd855c730898cccb77da5d2c9d1774d"
|
||||||
@@ -6809,7 +6669,7 @@ signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7:
|
|||||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
|
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
|
||||||
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
|
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
|
||||||
|
|
||||||
signal-exit@^4.0.1, signal-exit@^4.1.0:
|
signal-exit@^4.0.1:
|
||||||
version "4.1.0"
|
version "4.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
|
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
|
||||||
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
|
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
|
||||||
@@ -6828,18 +6688,6 @@ simple-get@^4.0.0:
|
|||||||
once "^1.3.1"
|
once "^1.3.1"
|
||||||
simple-concat "^1.0.0"
|
simple-concat "^1.0.0"
|
||||||
|
|
||||||
sinon@^18.0.1:
|
|
||||||
version "18.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/sinon/-/sinon-18.0.1.tgz#464334cdfea2cddc5eda9a4ea7e2e3f0c7a91c5e"
|
|
||||||
integrity sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw==
|
|
||||||
dependencies:
|
|
||||||
"@sinonjs/commons" "^3.0.1"
|
|
||||||
"@sinonjs/fake-timers" "11.2.2"
|
|
||||||
"@sinonjs/samsam" "^8.0.0"
|
|
||||||
diff "^5.2.0"
|
|
||||||
nise "^6.0.0"
|
|
||||||
supports-color "^7"
|
|
||||||
|
|
||||||
slash@^3.0.0:
|
slash@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
|
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
|
||||||
@@ -7012,11 +6860,6 @@ strip-final-newline@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
|
resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
|
||||||
integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
|
integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
|
||||||
|
|
||||||
strip-final-newline@^4.0.0:
|
|
||||||
version "4.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz#35a369ec2ac43df356e3edd5dcebb6429aa1fa5c"
|
|
||||||
integrity sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==
|
|
||||||
|
|
||||||
strip-json-comments@^3.1.1:
|
strip-json-comments@^3.1.1:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
|
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
|
||||||
@@ -7039,7 +6882,7 @@ strtok3@^10.2.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@tokenizer/token" "^0.3.0"
|
"@tokenizer/token" "^0.3.0"
|
||||||
|
|
||||||
supports-color@^7, supports-color@^7.1.0:
|
supports-color@^7.1.0:
|
||||||
version "7.2.0"
|
version "7.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
|
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
|
||||||
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
|
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
|
||||||
@@ -7249,11 +7092,6 @@ type-detect@4.0.8:
|
|||||||
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
|
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
|
||||||
integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
|
integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
|
||||||
|
|
||||||
type-detect@^4.1.0:
|
|
||||||
version "4.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c"
|
|
||||||
integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==
|
|
||||||
|
|
||||||
type-fest@^0.20.2:
|
type-fest@^0.20.2:
|
||||||
version "0.20.2"
|
version "0.20.2"
|
||||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
|
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
|
||||||
@@ -7314,11 +7152,6 @@ undici-types@~7.16.0:
|
|||||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
|
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
|
||||||
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
|
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
|
||||||
|
|
||||||
unicorn-magic@^0.3.0:
|
|
||||||
version "0.3.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz#4efd45c85a69e0dd576d25532fbfa22aa5c8a104"
|
|
||||||
integrity sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==
|
|
||||||
|
|
||||||
unique-filename@^1.1.1:
|
unique-filename@^1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230"
|
resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230"
|
||||||
@@ -7395,6 +7228,11 @@ utils-merge@1.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||||
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
|
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
|
||||||
|
|
||||||
|
uuid@^10.0.0:
|
||||||
|
version "10.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
|
||||||
|
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
|
||||||
|
|
||||||
v8-to-istanbul@^9.0.1:
|
v8-to-istanbul@^9.0.1:
|
||||||
version "9.3.0"
|
version "9.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175"
|
resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175"
|
||||||
@@ -7603,8 +7441,3 @@ yocto-queue@^0.1.0:
|
|||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||||
|
|
||||||
yoctocolors@^2.1.1:
|
|
||||||
version "2.1.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/yoctocolors/-/yoctocolors-2.1.2.tgz#d795f54d173494e7d8db93150cec0ed7f678c83a"
|
|
||||||
integrity sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==
|
|
||||||
|
|||||||
Reference in New Issue
Block a user