This commit is contained in:
2026-01-20 13:53:03 -05:00
parent 7532fd38cb
commit ae9ec078d3
69 changed files with 7031 additions and 409 deletions

112
CODING_STANDARDS.md Normal file
View 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
View 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
View 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.

View File

@@ -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",

View 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");

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "S3Bucket" ADD COLUMN "policy" TEXT;

View File

@@ -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");

View 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");

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ConsulKVEntry" ADD COLUMN "lockInfo" TEXT;

View File

@@ -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;

View File

@@ -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])
}

View File

@@ -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',

View File

@@ -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';

View File

@@ -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,9 +33,8 @@ 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'),
host: `${this.configService.get('PROTO')}://${this.configService.get('HOST')}:${this.configService.get('PORT')}`, host: `${this.configService.get('PROTO')}://${this.configService.get('HOST')}:${this.configService.get('PORT')}`,
}; };
@@ -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({
response: JSON.stringify(data), __path: request.path,
} __accountId: requestContext.awsProperties.accountId,
}), __region: requestContext.awsProperties.region,
...request.headers,
...request.body,
}),
response: JSON.stringify(data),
},
});
},
error: async (error) => await this.prismaService.audit.create({ error: async error => {
data: { const duration = Date.now() - requestStartTime;
id: requestContext.requestId, this.logger.error(`${action} - ${duration}ms - Error: ${error.message}`);
action,
request: JSON.stringify({ __path: request.path, ...request.headers, ...request.body }), await this.prismaService.audit.create({
response: JSON.stringify(error), data: {
} id: requestContext.requestId,
}), action,
}) request: JSON.stringify({
__path: request.path,
__accountId: requestContext.awsProperties.accountId,
__region: requestContext.awsProperties.region,
...request.headers,
...request.body,
}),
response: JSON.stringify(error),
},
});
},
}),
); );
} }
} }

View File

@@ -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;
} }

View File

@@ -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(),
}); });

View File

@@ -1,22 +1,26 @@
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:',
DB_LOGGING: process.env.DEBUG ? true : false, DB_LOGGING: process.env.DEBUG ? true : false,
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;
} };

File diff suppressed because it is too large Load Diff

View 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 {}

View 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;
}
}

View 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,
};
}
}

View 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,
};
}
}

View File

@@ -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],
}); });

View File

@@ -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();
});
});
}); });

View 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,
};
}
}

View 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;
}
}

View File

@@ -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 = [

View File

@@ -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);
}
} }

View File

@@ -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,
}, },
}; };
} }

View 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 {};
}
}

View 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,
};
}
}

View File

@@ -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();
});
});
}); });

View 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 {};
}
}

View File

@@ -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,
}; };
} }

View File

@@ -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,

View File

@@ -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,
},
},
}); });
} }
} }

View 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,
};
}
}

View File

@@ -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
View 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();
});
});
});

View 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,
};
}
}

View 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);
}
}

View 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);
}
}

View 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,
},
};
}
}

View 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 };
}
}

View 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 } : {},
};
}
}

View 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,
};
}
}

View 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
}
}

View 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,
};
}
}

View 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(),
})),
}
: {},
};
}
}

View 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;
}
}

View 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;
}
}

View 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 {};
}
}

View 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 {};
}
}

View 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
View 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 {}

View 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);
}),
);
}
}

View 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}`;
}
}

View 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
View 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
View 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
View 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
View 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);
}
}

View File

@@ -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' });
});
});
}); });

View File

@@ -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,

View 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
View File

@@ -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==