diff --git a/docs/architecture-decisions/010-decouple-aws-from-core.md b/docs/architecture-decisions/010-decouple-aws-from-core.md new file mode 100644 index 000000000..c302e9f1b --- /dev/null +++ b/docs/architecture-decisions/010-decouple-aws-from-core.md @@ -0,0 +1,248 @@ +# ADR-010: Decouple AWS SDK Dependencies from @friggframework/core + +**Status**: Accepted +**Date**: 2026-03-03 +**Deciders**: Sean, Frigg Team + +## Context + +`@friggframework/core` directly imported multiple AWS SDK v3 packages: + +- `@aws-sdk/client-sqs` — in `Worker.js`, `queuer-util.js` +- `@aws-sdk/client-kms` — in `Cryptor.js` +- `@aws-sdk/client-apigatewaymanagementapi` — in WebSocket connection repositories and the Mongoose `WebsocketConnection` model +- AWS-specific health check logic (VPC detection, KMS capability) — in `health.js` + +This tight coupling meant that **any** consumer of `@friggframework/core` pulled in all AWS SDKs at install time, even if deploying to a non-AWS platform (e.g., Netlify, Vercel, or Docker on GCP/Azure). Bundle size on Netlify was unnecessarily large, and esbuild would fail or produce warnings when tree-shaking unused AWS SDK code. + +The recent DDD/hexagonal architecture work established clean layer boundaries (handlers → use cases → repositories), but the infrastructure layer itself was still AWS-native throughout. + +## Decision + +**Extract all AWS SDK dependencies from `@friggframework/core` into `@friggframework/provider-aws`.** + +This is a **breaking change**. Instead of using lazy-loading with backward-compatible auto-discovery of AWS adapters, we require consumers to explicitly install `@friggframework/provider-aws` and inject the appropriate adapters via constructor options or setter methods. + +### Why breaking change instead of backward compatibility? + +1. **Explicit is better than implicit** — Lazy-loading hid a hard dependency behind a `require()` call that would fail at runtime with a confusing error if `@friggframework/provider-aws` wasn't installed. An explicit constructor error at initialization time is clearer. + +2. **No phantom dependencies** — With lazy-loading, `@friggframework/core` still had a runtime dependency on `@friggframework/provider-aws` for AWS users, but this wasn't declared in `package.json`. Forgetting to install it would cause cryptic failures deep in the call stack. + +3. **Clean architecture** — Hexagonal architecture requires that adapters are explicitly wired at the composition root (app startup), not auto-discovered at call time. Lazy-loading violated this principle. + +4. **Bundle safety** — Even with lazy `require()`, some bundlers (esbuild, webpack) will follow the require path and include the AWS SDK in the bundle. Removing the `require()` entirely guarantees zero AWS SDK code in non-AWS bundles. + +### Architecture + +``` +@friggframework/core (ports/interfaces) +├── QueueClientInterface — port for queue messaging +├── EncryptionKeyProviderInterface — port for envelope encryption keys +├── WebSocketMessageSenderInterface — port for WebSocket message sending +├── AesEncryptionKeyProvider — built-in AES adapter (no AWS) +└── StaleConnectionError — shared error type + +@friggframework/provider-aws (adapters) +├── SqsQueueClient — implements QueueClientInterface +├── KmsEncryptionKeyProvider — implements EncryptionKeyProviderInterface +├── ApiGatewayMessageSender — implements WebSocketMessageSenderInterface +├── EventBridgeSchedulerAdapter — scheduler adapter +└── health/kms-health-check — KMS + VPC health checks + +@friggframework/provider-netlify (adapters) +├── NetlifyBackgroundProvider — implements QueueProvider +└── QStashQueueProvider — implements QueueProvider +``` + +## Migration Guide + +### 1. Install the AWS provider package + +```bash +npm install @friggframework/provider-aws +``` + +### 2. Worker — inject queueClient + +**Before (v2):** +```javascript +const { Worker } = require('@friggframework/core'); + +class MyWorker extends Worker { + // queueClient was auto-loaded from SQS +} +const worker = new MyWorker(); +``` + +**After (v3):** +```javascript +const { Worker } = require('@friggframework/core'); +const { SqsQueueClient } = require('@friggframework/provider-aws'); + +class MyWorker extends Worker { + // Same _run() implementation — no changes needed +} +const worker = new MyWorker({ queueClient: new SqsQueueClient() }); +``` + +### 3. Cryptor — inject keyProvider for KMS + +**Before (v2):** +```javascript +const { Cryptor } = require('@friggframework/core'); +const cryptor = new Cryptor({ shouldUseAws: true }); +``` + +**After (v3):** +```javascript +const { Cryptor } = require('@friggframework/core'); +const { KmsEncryptionKeyProvider } = require('@friggframework/provider-aws'); + +const cryptor = new Cryptor({ + shouldUseAws: true, + keyProvider: new KmsEncryptionKeyProvider(), +}); +``` + +**AES mode (unchanged):** +```javascript +// AES mode still works without provider-aws — no changes needed +const cryptor = new Cryptor({ shouldUseAws: false }); +``` + +### 4. QueuerUtil — call setQueueClient() at startup + +**Before (v2):** +```javascript +const { QueuerUtil } = require('@friggframework/core'); +await QueuerUtil.send(message, queueUrl); // auto-loaded SQS +``` + +**After (v3):** +```javascript +const { QueuerUtil } = require('@friggframework/core'); +const { SqsQueueClient } = require('@friggframework/provider-aws'); + +// At application startup: +QueuerUtil.setQueueClient(new SqsQueueClient()); + +// Then use as before: +await QueuerUtil.send(message, queueUrl); +``` + +### 5. WebSocket Connection Repositories — inject messageSender + +**Before (v2):** +```javascript +const repo = new WebsocketConnectionRepository(prisma); +// messageSender was auto-loaded from API Gateway +``` + +**After (v3):** +```javascript +const { ApiGatewayMessageSender } = require('@friggframework/provider-aws'); + +const repo = new WebsocketConnectionRepository( + prisma, + new ApiGatewayMessageSender() +); +``` + +### 6. WebsocketConnection Mongoose model — call setMessageSender() + +**Before (v2):** +```javascript +const { WebsocketConnection } = require('@friggframework/core'); +const connections = await WebsocketConnection.getActiveConnections(); +// messageSender was auto-loaded from API Gateway +``` + +**After (v3):** +```javascript +const { WebsocketConnection } = require('@friggframework/core'); +const { ApiGatewayMessageSender } = require('@friggframework/provider-aws'); + +// At application startup: +WebsocketConnection.setMessageSender(new ApiGatewayMessageSender()); + +// Then use as before: +const connections = await WebsocketConnection.getActiveConnections(); +``` + +### 7. Health checks — no changes needed + +The health router (`health.js`) uses a try/catch around `require('@friggframework/provider-aws')`. If the package is installed, AWS health checks (KMS, VPC) run as before. If not, they return `{ status: 'skipped' }`. No migration needed. + +### 8. Recommended: wire adapters at the composition root + +For clean architecture, wire all adapters at your application's entry point: + +```javascript +// app.js or handler.js — composition root +const { SqsQueueClient, KmsEncryptionKeyProvider, ApiGatewayMessageSender } = + require('@friggframework/provider-aws'); +const { QueuerUtil } = require('@friggframework/core'); + +// Wire queue adapter +QueuerUtil.setQueueClient(new SqsQueueClient()); + +// Wire encryption adapter (in prisma.js or wherever Cryptor is instantiated) +const cryptor = new Cryptor({ + shouldUseAws: true, + keyProvider: new KmsEncryptionKeyProvider(), +}); + +// Wire WebSocket adapter (in WebSocket handler setup) +const wsRepo = new WebsocketConnectionRepository( + prisma, + new ApiGatewayMessageSender() +); +``` + +## Consequences + +### Positive + +- **Zero AWS SDK in non-AWS bundles** — `@friggframework/core` has no AWS SDK imports at all +- **Explicit dependencies** — consumers declare which provider they use in `package.json` +- **Clean hexagonal architecture** — ports in core, adapters in provider packages, wired at composition root +- **Platform flexibility** — same core works on AWS, Netlify, Vercel, Docker, or any other platform +- **Better error messages** — clear errors at initialization time instead of cryptic failures deep in the call stack +- **Testability** — easy to inject mock adapters in tests without mocking AWS SDK internals + +### Negative + +- **Breaking change** — all existing AWS consumers must update their wiring code +- **More boilerplate at startup** — a few extra lines to instantiate and inject adapters +- **Two packages to install** — AWS users need both `@friggframework/core` and `@friggframework/provider-aws` + +### Risks + +- **Missed injection** — if a consumer forgets to inject an adapter, they get a clear error message pointing to this ADR and showing exactly what code to add +- **Version drift** — core and provider-aws must be compatible; managed via monorepo versioning + +## Affected Files + +### Core (ports/interfaces created) +- `packages/core/queues/queue-client-interface.js` +- `packages/core/encrypt/encryption-key-provider-interface.js` +- `packages/core/websocket/websocket-message-sender-interface.js` +- `packages/core/encrypt/aes-encryption-key-provider.js` + +### Core (lazy-loading removed, explicit injection required) +- `packages/core/core/Worker.js` +- `packages/core/encrypt/Cryptor.js` +- `packages/core/queues/queuer-util.js` +- `packages/core/websocket/repositories/websocket-connection-repository.js` +- `packages/core/websocket/repositories/websocket-connection-repository-mongo.js` +- `packages/core/websocket/repositories/websocket-connection-repository-postgres.js` +- `packages/core/websocket/repositories/websocket-connection-repository-documentdb.js` +- `packages/core/database/models/WebsocketConnection.js` +- `packages/core/handlers/routers/health.js` (graceful try/catch, not strict) + +### Provider-AWS (adapters created) +- `packages/providers/aws/queues/sqs-queue-client.js` +- `packages/providers/aws/encryption/kms-encryption-key-provider.js` +- `packages/providers/aws/websocket/api-gateway-message-sender.js` +- `packages/providers/aws/health/kms-health-check.js` diff --git a/docs/architecture/ADR-MULTI-PROVIDER-SUPPORT.md b/docs/architecture/ADR-MULTI-PROVIDER-SUPPORT.md new file mode 100644 index 000000000..338616a17 --- /dev/null +++ b/docs/architecture/ADR-MULTI-PROVIDER-SUPPORT.md @@ -0,0 +1,263 @@ +# Architecture Decision Record: Multi-Provider Support + +**Status**: Accepted +**Date**: 2026-03-02 + +## Context + +Frigg was originally built as an AWS-first framework: Lambda for compute, SQS for queues, EventBridge Scheduler for one-time jobs, KMS for encryption, and Serverless Framework for deployment. Every CLI command (`deploy`, `build`, `start`, `doctor`, `repair`, `generate-iam`) assumed AWS. + +Customers and community members want to deploy Frigg integrations to platforms beyond AWS — starting with Netlify, with potential for Vercel, GCP Cloud Run, and others. Adding a second provider forces the right abstraction boundaries; supporting N providers is then incremental. + +### Requirements + +1. Existing AWS deployments must work with minimal migration (see ADR-010 for breaking changes and migration guide). +2. A single `provider` field in the app definition switches the entire toolchain. +3. Provider-specific code lives in separate, installable packages. +4. Core framework code must not import provider-specific modules directly. +5. The CLI, runtime handlers, queues, scheduling, and encryption must all dispatch through the same provider abstraction. + +## Decision + +Introduce a **provider plugin system** with a standard interface. Each provider is an npm package (`@friggframework/provider-{name}`) that exports adapters for every platform-dependent concern: deployment, configuration generation, queue dispatch, job scheduling, secret loading, and handler creation. + +### Provider Plugin Interface + +A provider package exports an object conforming to this shape: + +```javascript +module.exports = { + // ─── Identity ──────────────────────────────────── + name: 'netlify', // Provider identifier + + // ─── Runtime adapters ──────────────────────────── + createHandler, // Express handler factory for the platform + createAppHandler, // App-level handler factory + QueueProvider, // Class: send(), batchSend(), parseEvent() + SchedulerAdapter, // Class: scheduleOneTime(), deleteSchedule(), getScheduleStatus() + ScheduledJobRepository, // Class: save(), delete(), findByName(), findDue() + loadSecrets, // async fn: load secrets from platform vault + invokeFunctionAdapter, // { invoke(name, payload) } + + // ─── Encryption & WebSockets (optional) ────────── + CryptorAdapter: null, // null = reuse core Cryptor + WebSocketAdapter: null, // null = not supported + + // ─── Build-time ────────────────────────────────── + generateConfig, // fn(appDef) → config file content (e.g. netlify.toml) + generateEnvTemplate, // fn(appDef) → { VAR_NAME: 'description' } + getFunctionEntryPoints, // fn(appDef) → { 'api.js': '...code...' } + + // ─── Deployment ────────────────────────────────── + deploy, // async fn(appDef, options) + preflightCheck, // async fn(appDef) → { ready, missing } + validate, // fn(appDef) → { valid, errors[], warnings[] } + teardown, // async fn() — cleanup + + // ─── Detection & metadata ──────────────────────── + detect, // fn() → boolean (is this env the provider?) + recommendedDatabases: ['postgresql'], + providedEnvVars: ['NETLIFY', 'URL'], +}; +``` + +### Resolution Chain + +The `resolveProvider(appDefinition, options)` function in `@friggframework/core` resolves a provider name to its package: + +``` +1. Explicit providerName argument (programmatic override) +2. appDefinition.provider (app definition file) +3. FRIGG_PROVIDER env var (CI/CD override) +4. Default: 'aws' +``` + +Name → package: `'netlify'` → `require('@friggframework/provider-netlify')` + +AWS is the default, so existing apps that don't set `provider` continue to work unchanged. + +### CLI Command Dispatch + +Each CLI command follows the same pattern: + +``` +1. loadProviderForCli() — reads appDefinition, resolves provider +2. if provider is non-AWS — delegate to provider.{method}() +3. if provider is AWS/null — fall through to existing serverless behavior +``` + +| Command | AWS path | Non-AWS path | +|---------|----------|-------------| +| `deploy` | `osls deploy` | `provider.validate()` → `provider.deploy()` | +| `build` | `osls package` | `provider.validate()` → `provider.generateConfig()` → `provider.getFunctionEntryPoints()` | +| `start` | `serverless-offline` | Platform CLI (e.g. `netlify dev`) | +| `doctor` | CloudFormation health check | Rejected with message (AWS-only) | +| `repair` | CloudFormation import | Rejected with message (AWS-only) | +| `generate-iam` | IAM policy generation | Rejected with message (AWS-only) | + +Commands that are inherently AWS-specific (`doctor`, `repair`, `generate-iam`) guard with an early exit for non-AWS providers rather than implementing no-op stubs. + +### Runtime Adapter Dispatch + +At runtime, integrations use platform-agnostic interfaces. Factories create the right adapter based on the provider: + +**Scheduler**: `SchedulerServiceFactory` creates either: +- `EventBridgeSchedulerAdapter` — AWS push model (precise timing, cloud-native) +- `NetlifySchedulerAdapter` — poll-and-dispatch model (cron queries database for due jobs) +- `MockSchedulerAdapter` — in-memory for dev/test + +**Queue**: `QueueProvider` base class with platform-specific subclasses: +- AWS SQS provider — SQS send/batch/parse +- `NetlifyBackgroundProvider` — HTTP POST to background functions + +**Encryption**: Core `Cryptor` shared across providers. Providers can optionally supply a `CryptorAdapter` override, but `null` reuses the default (KMS or AES based on env vars). + +### Scheduling: Push vs Poll-and-Dispatch + +This is the most significant architectural divergence between providers: + +``` +AWS (Push Model) Netlify (Poll-and-Dispatch) +───────────────── ─────────────────────────── +scheduleOneTime() scheduleOneTime() + │ │ + ▼ ▼ +EventBridge Scheduler Database (state: PENDING) +creates a one-time rule │ + │ │ ← cron fires every N min + │ at(2025-06-01T12:00) │ + │ ▼ + ▼ processDueSchedules() +SQS receives message │ mark PROCESSING +at exact time │ send to queue + │ delete on success + │ mark FAILED on error +``` + +**Trade-offs**: +- Push is more precise (sub-second), poll has up to cron-interval delay +- Push requires cloud-specific APIs, poll works on any platform with a database +- Poll needs the PROCESSING state guard to prevent duplicate dispatch (race condition between concurrent cron invocations) + +### State Machine for Netlify Schedules + +``` +PENDING ──→ PROCESSING ──→ (deleted) + │ + └──→ FAILED +``` + +`findDue()` only returns `PENDING` records, so `PROCESSING` acts as a distributed lock — if a second cron invocation fires while the first is dispatching, it won't re-pick the same schedule. + +## Package Structure + +``` +packages/ +├── core/ +│ ├── providers/ +│ │ └── resolve-provider.js # Resolution chain +│ ├── infrastructure/scheduler/ +│ │ ├── scheduler-service-interface.js +│ │ ├── scheduler-service-factory.js +│ │ ├── eventbridge-scheduler-adapter.js +│ │ ├── netlify-scheduler-adapter.js +│ │ └── mock-scheduler-adapter.js +│ └── queues/ +│ ├── queue-provider.js # Base class +│ └── providers/ +│ └── netlify-background-provider.js +├── providers/ +│ ├── aws/ # @friggframework/provider-aws +│ │ ├── queues/sqs-queue-client.js +│ │ ├── encryption/kms-encryption-key-provider.js +│ │ ├── websocket/api-gateway-message-sender.js +│ │ ├── lambda/lambda-invoker.js +│ │ └── storage/migration-status-repository-s3.js +│ └── netlify/ # @friggframework/provider-netlify +│ ├── index.js # Plugin interface export +│ └── lib/ +│ ├── generate-netlify-config.js +│ ├── get-function-entry-points.js +│ ├── netlify-background-provider.js +│ ├── scheduled-job-repository.js +│ └── ... +├── devtools/ +│ └── frigg-cli/ +│ ├── utils/provider-helper.js # CLI provider loading +│ ├── deploy-command/ # Provider dispatch +│ ├── build-command/ # Provider dispatch +│ └── start-command/ # Provider dispatch +``` + +## How to Add a New Provider + +1. Create `packages/providers/{name}/` implementing the plugin interface. +2. Add the name to `KNOWN_PROVIDERS` in `resolve-provider.js`. +3. Run the existing provider dispatch tests — they should pass without changes. +4. Add provider-specific tests in the new package. +5. Publish as `@friggframework/provider-{name}`. + +The provider dispatch tests (`provider-dispatch.test.js`) verify that: +- AWS falls through to existing serverless behavior +- Non-AWS providers delegate to the plugin methods +- AWS-only commands reject non-AWS providers cleanly + +These tests are provider-agnostic, so they validate the wiring for any new provider. + +## Alternatives Considered + +### Alternative 1: Conditional imports in core + +Scatter `if (provider === 'netlify')` checks throughout framework code. + +**Rejected**: Violates open-closed principle. Every new provider requires modifying core code. Leads to import-time side effects and difficult-to-test conditionals. + +### Alternative 2: Abstract factory for everything + +Create factories for every concern (handler, queue, scheduler, encryption, deployment) and compose them in a single configuration object. + +**Partially adopted**: We use factories for scheduler and queue, but the provider plugin itself acts as the top-level factory. This avoids factory-of-factories complexity while keeping each concern independently testable. + +### Alternative 3: Docker-based universal deployment + +Containerize the app and deploy the same Docker image everywhere. + +**Rejected for now**: Loses platform-native advantages (Netlify Edge Functions, Lambda cold-start optimizations, platform-specific DX). May revisit as a "universal provider" in the future. + +### Alternative 4: Provider stubs for AWS-only commands + +Instead of rejecting `doctor`/`repair`/`generate-iam` for non-AWS providers, implement no-op or generic versions. + +**Rejected**: These commands are deeply tied to CloudFormation concepts. No-op stubs would be misleading. Clear rejection messages are more honest and push providers to implement equivalent commands when platform support exists. + +## Risks and Mitigations + +| Risk | Mitigation | +|------|-----------| +| Provider interface grows unwieldy | Keep interface minimal; optional fields return `null` to skip features | +| Breaking changes to plugin interface | Semantic versioning; interface is tested via `provider-plugin-interface.test.js` | +| Poll-and-dispatch causes duplicate jobs | PROCESSING state guard prevents re-dispatch; tested in `netlify-scheduler-adapter.test.js` | +| Provider packages not installed | `resolveProvider` throws a clear error with `npm install` instructions | +| AWS assumptions leak into core | Provider dispatch tests catch AWS-specific calls that should be guarded | + +## Test Coverage + +| Test Suite | Location | Tests | +|-----------|----------|-------| +| Core resolver | `packages/core/providers/resolve-provider.test.js` | 11 | +| CLI provider helper | `frigg-cli/utils/__tests__/provider-helper.test.js` | 4 | +| Provider dispatch (CLI commands) | `frigg-cli/__tests__/unit/commands/provider-dispatch.test.js` | 9 | +| Netlify scheduler adapter | `core/infrastructure/scheduler/netlify-scheduler-adapter.test.js` | 23 | +| Scheduled job repository | `packages/providers/netlify/__tests__/scheduled-job-repository.test.js` | 9 | +| Netlify plugin interface | `packages/providers/netlify/__tests__/provider-plugin-interface.test.js` | — | +| Netlify config generation | `packages/providers/netlify/__tests__/generate-netlify-config.test.js` | — | +| Netlify deploy | `packages/providers/netlify/__tests__/deploy.test.js` | — | +| Netlify validate | `packages/providers/netlify/__tests__/validate.test.js` | — | + +## References + +- Plugin interface: `packages/providers/netlify/index.js` +- Resolution chain: `packages/core/providers/resolve-provider.js` +- Scheduler interface: `packages/core/infrastructure/scheduler/scheduler-service-interface.js` +- Queue provider base: `packages/core/queues/queue-provider.js` +- CLI dispatch: `packages/devtools/frigg-cli/deploy-command/index.js` diff --git a/layers/prisma/nodejs/package.json b/layers/prisma/nodejs/package.json deleted file mode 100644 index 62b4ea8f3..000000000 --- a/layers/prisma/nodejs/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "prisma-lambda-layer", - "version": "1.0.0", - "private": true, - "dependencies": { - "@prisma/client": "^6.16.3" - } -} diff --git a/lerna.json b/lerna.json index b4c0b7bed..4fbee8836 100644 --- a/lerna.json +++ b/lerna.json @@ -2,6 +2,8 @@ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "version": "2.0.0-next.0", "packages": [ - "packages/*" + "packages/*", + "packages/providers/*", + "packages/extensions/*" ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f86b2b97e..941dcd966 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "1.0.0", "license": "MIT", "workspaces": [ - "packages/*" + "packages/*", + "packages/providers/*", + "packages/extensions/*" ], "devDependencies": { "@auto-it/all-contributors": "11.3.0", @@ -6248,468 +6250,419 @@ "license": "0BSD" }, "node_modules/@aws-sdk/client-scheduler": { - "version": "3.948.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-scheduler/-/client-scheduler-3.948.0.tgz", - "integrity": "sha512-UAMeFOGlXpF5OSIF+WDTD0oYtNWlLmkySqZWMldTFxMb3YSS3RsjQn/UvCNdjGXw9N/cHhtXDMEBpwUORN41SQ==", + "version": "3.1002.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-scheduler/-/client-scheduler-3.1002.0.tgz", + "integrity": "sha512-H1oZU7V2CiOrqAlkU67nUEcM6uaVMpyS9DJKgo4CUzSBS8QvMNkZh+xmd+W95RwXupVqdGh86pUVGVnQhUtWqQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.947.0", - "@aws-sdk/credential-provider-node": "3.948.0", - "@aws-sdk/middleware-host-header": "3.936.0", - "@aws-sdk/middleware-logger": "3.936.0", - "@aws-sdk/middleware-recursion-detection": "3.948.0", - "@aws-sdk/middleware-user-agent": "3.947.0", - "@aws-sdk/region-config-resolver": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@aws-sdk/util-user-agent-browser": "3.936.0", - "@aws-sdk/util-user-agent-node": "3.947.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/core": "^3.18.7", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/hash-node": "^4.2.5", - "@smithy/invalid-dependency": "^4.2.5", - "@smithy/middleware-content-length": "^4.2.5", - "@smithy/middleware-endpoint": "^4.3.14", - "@smithy/middleware-retry": "^4.4.14", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.13", - "@smithy/util-defaults-mode-node": "^4.2.16", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/credential-provider-node": "^3.972.16", + "@aws-sdk/middleware-host-header": "^3.972.6", + "@aws-sdk/middleware-logger": "^3.972.6", + "@aws-sdk/middleware-recursion-detection": "^3.972.6", + "@aws-sdk/middleware-user-agent": "^3.972.17", + "@aws-sdk/region-config-resolver": "^3.972.6", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/util-endpoints": "^3.996.3", + "@aws-sdk/util-user-agent-browser": "^3.972.6", + "@aws-sdk/util-user-agent-node": "^3.973.2", + "@smithy/config-resolver": "^4.4.9", + "@smithy/core": "^3.23.7", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/hash-node": "^4.2.10", + "@smithy/invalid-dependency": "^4.2.10", + "@smithy/middleware-content-length": "^4.2.10", + "@smithy/middleware-endpoint": "^4.4.21", + "@smithy/middleware-retry": "^4.4.38", + "@smithy/middleware-serde": "^4.2.11", + "@smithy/middleware-stack": "^4.2.10", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-http-handler": "^4.4.13", + "@smithy/protocol-http": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-base64": "^4.3.1", + "@smithy/util-body-length-browser": "^4.2.1", + "@smithy/util-body-length-node": "^4.2.2", + "@smithy/util-defaults-mode-browser": "^4.3.37", + "@smithy/util-defaults-mode-node": "^4.2.40", + "@smithy/util-endpoints": "^3.3.1", + "@smithy/util-middleware": "^4.2.10", + "@smithy/util-retry": "^4.2.10", + "@smithy/util-utf8": "^4.2.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/client-sso": { - "version": "3.948.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.948.0.tgz", - "integrity": "sha512-iWjchXy8bIAVBUsKnbfKYXRwhLgRg3EqCQ5FTr3JbR+QR75rZm4ZOYXlvHGztVTmtAZ+PQVA1Y4zO7v7N87C0A==", + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/core": { + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.17.tgz", + "integrity": "sha512-VtgGP0TjbCeyp6DQpiBqJKbemTSIaN2bZc3UbeTDCani3lBCyxn75ouJYD6koSSp0bh7rKLEbUpiFsNCI7tr0w==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.947.0", - "@aws-sdk/middleware-host-header": "3.936.0", - "@aws-sdk/middleware-logger": "3.936.0", - "@aws-sdk/middleware-recursion-detection": "3.948.0", - "@aws-sdk/middleware-user-agent": "3.947.0", - "@aws-sdk/region-config-resolver": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@aws-sdk/util-user-agent-browser": "3.936.0", - "@aws-sdk/util-user-agent-node": "3.947.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/core": "^3.18.7", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/hash-node": "^4.2.5", - "@smithy/invalid-dependency": "^4.2.5", - "@smithy/middleware-content-length": "^4.2.5", - "@smithy/middleware-endpoint": "^4.3.14", - "@smithy/middleware-retry": "^4.4.14", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.13", - "@smithy/util-defaults-mode-node": "^4.2.16", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/xml-builder": "^3.972.9", + "@smithy/core": "^3.23.7", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/property-provider": "^4.2.10", + "@smithy/protocol-http": "^5.3.10", + "@smithy/signature-v4": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.1", + "@smithy/util-middleware": "^4.2.10", + "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/core": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.947.0.tgz", - "integrity": "sha512-Khq4zHhuAkvCFuFbgcy3GrZTzfSX7ZIjIcW1zRDxXRLZKRtuhnZdonqTUfaWi5K42/4OmxkYNpsO7X7trQOeHw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@aws-sdk/xml-builder": "3.930.0", - "@smithy/core": "^3.18.7", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/signature-v4": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-env": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.947.0.tgz", - "integrity": "sha512-VR2V6dRELmzwAsCpK4GqxUi6UW5WNhAXS9F9AzWi5jvijwJo3nH92YNJUP4quMpgFZxJHEWyXLWgPjh9u0zYOA==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.15.tgz", + "integrity": "sha512-RhHQG1lhkWHL4tK1C/KDjaOeis+9U0tAMnWDiwiSVQZMC7CsST9Xin+sK89XywJ5g/tyABtb7TvFePJ4Te5XSQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/types": "^3.973.4", + "@smithy/property-provider": "^4.2.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-http": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.947.0.tgz", - "integrity": "sha512-inF09lh9SlHj63Vmr5d+LmwPXZc2IbK8lAruhOr3KLsZAIHEgHgGPXWDC2ukTEMzg0pkexQ6FOhXXad6klK4RA==", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.17.tgz", + "integrity": "sha512-b/bDL76p51+yQ+0O9ZDH5nw/ioE0sRYkjwjOwFWAWZXo6it2kQZUOXhVpjohx3ldKyUxt/SwAivjUu1Nr/PWlQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/util-stream": "^4.5.6", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/types": "^3.973.4", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/node-http-handler": "^4.4.13", + "@smithy/property-provider": "^4.2.10", + "@smithy/protocol-http": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", + "@smithy/util-stream": "^4.5.16", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.948.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.948.0.tgz", - "integrity": "sha512-Cl//Qh88e8HBL7yYkJNpF5eq76IO6rq8GsatKcfVBm7RFVxCqYEPSSBtkHdbtNwQdRQqAMXc6E/lEB/CZUDxnA==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.15.tgz", + "integrity": "sha512-qWnM+wB8MmU2kKY7f4KowKjOjkwRosaFxrtseEEIefwoXn1SjN+CbHzXBVdTAQxxkbBiqhPgJ/WHiPtES4grRQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/credential-provider-env": "3.947.0", - "@aws-sdk/credential-provider-http": "3.947.0", - "@aws-sdk/credential-provider-login": "3.948.0", - "@aws-sdk/credential-provider-process": "3.947.0", - "@aws-sdk/credential-provider-sso": "3.948.0", - "@aws-sdk/credential-provider-web-identity": "3.948.0", - "@aws-sdk/nested-clients": "3.948.0", - "@aws-sdk/types": "3.936.0", - "@smithy/credential-provider-imds": "^4.2.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/credential-provider-env": "^3.972.15", + "@aws-sdk/credential-provider-http": "^3.972.17", + "@aws-sdk/credential-provider-login": "^3.972.15", + "@aws-sdk/credential-provider-process": "^3.972.15", + "@aws-sdk/credential-provider-sso": "^3.972.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.15", + "@aws-sdk/nested-clients": "^3.996.5", + "@aws-sdk/types": "^3.973.4", + "@smithy/credential-provider-imds": "^4.2.10", + "@smithy/property-provider": "^4.2.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.948.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.948.0.tgz", - "integrity": "sha512-ep5vRLnrRdcsP17Ef31sNN4g8Nqk/4JBydcUJuFRbGuyQtrZZrVT81UeH2xhz6d0BK6ejafDB9+ZpBjXuWT5/Q==", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.16.tgz", + "integrity": "sha512-7mlt14Ee4rPFAFUVgpWE7+0CBhetJJyzVFqfIsMp7sgyOSm9Y/+qHZOWAuK5I4JNc+Y5PltvJ9kssTzRo92iXQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "3.947.0", - "@aws-sdk/credential-provider-http": "3.947.0", - "@aws-sdk/credential-provider-ini": "3.948.0", - "@aws-sdk/credential-provider-process": "3.947.0", - "@aws-sdk/credential-provider-sso": "3.948.0", - "@aws-sdk/credential-provider-web-identity": "3.948.0", - "@aws-sdk/types": "3.936.0", - "@smithy/credential-provider-imds": "^4.2.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/credential-provider-env": "^3.972.15", + "@aws-sdk/credential-provider-http": "^3.972.17", + "@aws-sdk/credential-provider-ini": "^3.972.15", + "@aws-sdk/credential-provider-process": "^3.972.15", + "@aws-sdk/credential-provider-sso": "^3.972.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.15", + "@aws-sdk/types": "^3.973.4", + "@smithy/credential-provider-imds": "^4.2.10", + "@smithy/property-provider": "^4.2.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-process": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.947.0.tgz", - "integrity": "sha512-WpanFbHe08SP1hAJNeDdBDVz9SGgMu/gc0XJ9u3uNpW99nKZjDpvPRAdW7WLA4K6essMjxWkguIGNOpij6Do2Q==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.15.tgz", + "integrity": "sha512-PrH3iTeD18y/8uJvQD2s/T87BTGhsdS/1KZU7ReWHXsplBwvCqi7AbnnNbML1pFlQwRWCE2RdSZFWDVId3CvkA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/types": "^3.973.4", + "@smithy/property-provider": "^4.2.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.948.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.948.0.tgz", - "integrity": "sha512-gqLhX1L+zb/ZDnnYbILQqJ46j735StfWV5PbDjxRzBKS7GzsiYoaf6MyHseEopmWrez5zl5l6aWzig7UpzSeQQ==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.15.tgz", + "integrity": "sha512-M/+LBHTPKZxxXckM6m4dnJeR+jlm9NynH9b2YDswN4Zj2St05SK/crdL3Wy3WfJTZootnnhm3oTh87Usl7PS7w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.948.0", - "@aws-sdk/core": "3.947.0", - "@aws-sdk/token-providers": "3.948.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/nested-clients": "^3.996.5", + "@aws-sdk/token-providers": "3.1002.0", + "@aws-sdk/types": "^3.973.4", + "@smithy/property-provider": "^4.2.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.948.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.948.0.tgz", - "integrity": "sha512-MvYQlXVoJyfF3/SmnNzOVEtANRAiJIObEUYYyjTqKZTmcRIVVky0tPuG26XnB8LmTYgtESwJIZJj/Eyyc9WURQ==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.15.tgz", + "integrity": "sha512-QTH6k93v+UOfFam/ado8zc71tH+enTVyuvLy9uEWXX1x894dN5ovtf/MdBDgFwq3g6c9mbtgVJ4B+yBqDtXvdA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/nested-clients": "3.948.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/nested-clients": "^3.996.5", + "@aws-sdk/types": "^3.973.4", + "@smithy/property-provider": "^4.2.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/middleware-host-header": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.936.0.tgz", - "integrity": "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.6.tgz", + "integrity": "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/types": "^3.973.4", + "@smithy/protocol-http": "^5.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/middleware-logger": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.936.0.tgz", - "integrity": "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.6.tgz", + "integrity": "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.948.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.948.0.tgz", - "integrity": "sha512-Qa8Zj+EAqA0VlAVvxpRnpBpIWJI9KUwaioY1vkeNVwXPlNaz9y9zCKVM9iU9OZ5HXpoUg6TnhATAHXHAE8+QsQ==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.6.tgz", + "integrity": "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", + "@aws-sdk/types": "^3.973.4", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", + "@smithy/protocol-http": "^5.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.947.0.tgz", - "integrity": "sha512-7rpKV8YNgCP2R4F9RjWZFcD2R+SO/0R4VHIbY9iZJdH2MzzJ8ZG7h8dZ2m8QkQd1fjx4wrFJGGPJUTYXPV3baA==", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.17.tgz", + "integrity": "sha512-HHArkgWzomuwufXwheQqkddu763PWCpoNTq1dGjqXzJT/lojX3VlOqjNSR2Xvb6/T9ISfwYcMOcbFgUp4EWxXA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@smithy/core": "^3.18.7", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/util-endpoints": "^3.996.3", + "@smithy/core": "^3.23.7", + "@smithy/protocol-http": "^5.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/nested-clients": { - "version": "3.948.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.948.0.tgz", - "integrity": "sha512-zcbJfBsB6h254o3NuoEkf0+UY1GpE9ioiQdENWv7odo69s8iaGBEQ4BDpsIMqcuiiUXw1uKIVNxCB1gUGYz8lw==", + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.5.tgz", + "integrity": "sha512-zn0WApcULn7Rtl6T+KP2CQTZo/7wOa2YV1yHQnbijTQoi4YXQHM8s21JcJzt33/mqPh8AdvWX1f+83KvKuxlZw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.947.0", - "@aws-sdk/middleware-host-header": "3.936.0", - "@aws-sdk/middleware-logger": "3.936.0", - "@aws-sdk/middleware-recursion-detection": "3.948.0", - "@aws-sdk/middleware-user-agent": "3.947.0", - "@aws-sdk/region-config-resolver": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@aws-sdk/util-user-agent-browser": "3.936.0", - "@aws-sdk/util-user-agent-node": "3.947.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/core": "^3.18.7", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/hash-node": "^4.2.5", - "@smithy/invalid-dependency": "^4.2.5", - "@smithy/middleware-content-length": "^4.2.5", - "@smithy/middleware-endpoint": "^4.3.14", - "@smithy/middleware-retry": "^4.4.14", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.13", - "@smithy/util-defaults-mode-node": "^4.2.16", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/middleware-host-header": "^3.972.6", + "@aws-sdk/middleware-logger": "^3.972.6", + "@aws-sdk/middleware-recursion-detection": "^3.972.6", + "@aws-sdk/middleware-user-agent": "^3.972.17", + "@aws-sdk/region-config-resolver": "^3.972.6", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/util-endpoints": "^3.996.3", + "@aws-sdk/util-user-agent-browser": "^3.972.6", + "@aws-sdk/util-user-agent-node": "^3.973.2", + "@smithy/config-resolver": "^4.4.9", + "@smithy/core": "^3.23.7", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/hash-node": "^4.2.10", + "@smithy/invalid-dependency": "^4.2.10", + "@smithy/middleware-content-length": "^4.2.10", + "@smithy/middleware-endpoint": "^4.4.21", + "@smithy/middleware-retry": "^4.4.38", + "@smithy/middleware-serde": "^4.2.11", + "@smithy/middleware-stack": "^4.2.10", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-http-handler": "^4.4.13", + "@smithy/protocol-http": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-base64": "^4.3.1", + "@smithy/util-body-length-browser": "^4.2.1", + "@smithy/util-body-length-node": "^4.2.2", + "@smithy/util-defaults-mode-browser": "^4.3.37", + "@smithy/util-defaults-mode-node": "^4.2.40", + "@smithy/util-endpoints": "^3.3.1", + "@smithy/util-middleware": "^4.2.10", + "@smithy/util-retry": "^4.2.10", + "@smithy/util-utf8": "^4.2.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/region-config-resolver": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.936.0.tgz", - "integrity": "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.6.tgz", + "integrity": "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/types": "^3.973.4", + "@smithy/config-resolver": "^4.4.9", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/token-providers": { - "version": "3.948.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.948.0.tgz", - "integrity": "sha512-V487/kM4Teq5dcr1t5K6eoUKuqlGr9FRWL3MIMukMERJXHZvio6kox60FZ/YtciRHRI75u14YUqm2Dzddcu3+A==", + "version": "3.1002.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1002.0.tgz", + "integrity": "sha512-x972uKOydFn4Rb0PZJzLdNW59rH0KWC78Q2JbQzZpGlGt0DxjYdDRwBG6F42B1MyaEwHGqO/tkGc4r3/PRFfMw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/nested-clients": "3.948.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/nested-clients": "^3.996.5", + "@aws-sdk/types": "^3.973.4", + "@smithy/property-provider": "^4.2.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/types": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.936.0.tgz", - "integrity": "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==", + "version": "3.973.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.4.tgz", + "integrity": "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/util-endpoints": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.936.0.tgz", - "integrity": "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==", + "version": "3.996.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", + "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-endpoints": "^3.2.5", + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.936.0.tgz", - "integrity": "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.6.tgz", + "integrity": "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.947.0.tgz", - "integrity": "sha512-+vhHoDrdbb+zerV4noQk1DHaUMNzWFWPpPYjVTwW2186k5BEJIecAMChYkghRrBVJ3KPWP1+JnZwOd72F3d4rQ==", + "version": "3.973.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.2.tgz", + "integrity": "sha512-lpaIuekdkpw7VRiik0IZmd6TyvEUcuLgKZ5fKRGpCA3I4PjrD/XH15sSwW+OptxQjNU4DEzSxag70spC9SluvA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/middleware-user-agent": "^3.972.17", + "@aws-sdk/types": "^3.973.4", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "aws-crt": ">=1.0.0" @@ -6721,28 +6674,47 @@ } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/xml-builder": { - "version": "3.930.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", - "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.9.tgz", + "integrity": "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", - "fast-xml-parser": "5.2.5", + "@smithy/types": "^4.13.0", + "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/client-scheduler/node_modules/@aws/lambda-invoke-store": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", - "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/client-scheduler/node_modules/fast-xml-parser": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/@aws-sdk/client-scheduler/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8564,231 +8536,231 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.948.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.948.0.tgz", - "integrity": "sha512-gcKO2b6eeTuZGp3Vvgr/9OxajMrD3W+FZ2FCyJox363ZgMoYJsyNid1vuZrEuAGkx0jvveLXfwiVS0UXyPkgtw==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.15.tgz", + "integrity": "sha512-x92FJy34/95wgu+qOGD8SHcgh1hZ9Qx2uFtQEGn4m9Ljou8ICIv3Ybq5yxdB7A60S8ZGCQB0mIopmjJwiLbh5g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/nested-clients": "3.948.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/nested-clients": "^3.996.5", + "@aws-sdk/types": "^3.973.4", + "@smithy/property-provider": "^4.2.10", + "@smithy/protocol-http": "^5.3.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/core": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.947.0.tgz", - "integrity": "sha512-Khq4zHhuAkvCFuFbgcy3GrZTzfSX7ZIjIcW1zRDxXRLZKRtuhnZdonqTUfaWi5K42/4OmxkYNpsO7X7trQOeHw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@aws-sdk/xml-builder": "3.930.0", - "@smithy/core": "^3.18.7", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/signature-v4": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.17.tgz", + "integrity": "sha512-VtgGP0TjbCeyp6DQpiBqJKbemTSIaN2bZc3UbeTDCani3lBCyxn75ouJYD6koSSp0bh7rKLEbUpiFsNCI7tr0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/xml-builder": "^3.972.9", + "@smithy/core": "^3.23.7", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/property-provider": "^4.2.10", + "@smithy/protocol-http": "^5.3.10", + "@smithy/signature-v4": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.1", + "@smithy/util-middleware": "^4.2.10", + "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-host-header": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.936.0.tgz", - "integrity": "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.6.tgz", + "integrity": "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/types": "^3.973.4", + "@smithy/protocol-http": "^5.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-logger": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.936.0.tgz", - "integrity": "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.6.tgz", + "integrity": "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.948.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.948.0.tgz", - "integrity": "sha512-Qa8Zj+EAqA0VlAVvxpRnpBpIWJI9KUwaioY1vkeNVwXPlNaz9y9zCKVM9iU9OZ5HXpoUg6TnhATAHXHAE8+QsQ==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.6.tgz", + "integrity": "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", + "@aws-sdk/types": "^3.973.4", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", + "@smithy/protocol-http": "^5.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.947.0.tgz", - "integrity": "sha512-7rpKV8YNgCP2R4F9RjWZFcD2R+SO/0R4VHIbY9iZJdH2MzzJ8ZG7h8dZ2m8QkQd1fjx4wrFJGGPJUTYXPV3baA==", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.17.tgz", + "integrity": "sha512-HHArkgWzomuwufXwheQqkddu763PWCpoNTq1dGjqXzJT/lojX3VlOqjNSR2Xvb6/T9ISfwYcMOcbFgUp4EWxXA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@smithy/core": "^3.18.7", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/util-endpoints": "^3.996.3", + "@smithy/core": "^3.23.7", + "@smithy/protocol-http": "^5.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/nested-clients": { - "version": "3.948.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.948.0.tgz", - "integrity": "sha512-zcbJfBsB6h254o3NuoEkf0+UY1GpE9ioiQdENWv7odo69s8iaGBEQ4BDpsIMqcuiiUXw1uKIVNxCB1gUGYz8lw==", + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.5.tgz", + "integrity": "sha512-zn0WApcULn7Rtl6T+KP2CQTZo/7wOa2YV1yHQnbijTQoi4YXQHM8s21JcJzt33/mqPh8AdvWX1f+83KvKuxlZw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.947.0", - "@aws-sdk/middleware-host-header": "3.936.0", - "@aws-sdk/middleware-logger": "3.936.0", - "@aws-sdk/middleware-recursion-detection": "3.948.0", - "@aws-sdk/middleware-user-agent": "3.947.0", - "@aws-sdk/region-config-resolver": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@aws-sdk/util-user-agent-browser": "3.936.0", - "@aws-sdk/util-user-agent-node": "3.947.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/core": "^3.18.7", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/hash-node": "^4.2.5", - "@smithy/invalid-dependency": "^4.2.5", - "@smithy/middleware-content-length": "^4.2.5", - "@smithy/middleware-endpoint": "^4.3.14", - "@smithy/middleware-retry": "^4.4.14", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.13", - "@smithy/util-defaults-mode-node": "^4.2.16", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/middleware-host-header": "^3.972.6", + "@aws-sdk/middleware-logger": "^3.972.6", + "@aws-sdk/middleware-recursion-detection": "^3.972.6", + "@aws-sdk/middleware-user-agent": "^3.972.17", + "@aws-sdk/region-config-resolver": "^3.972.6", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/util-endpoints": "^3.996.3", + "@aws-sdk/util-user-agent-browser": "^3.972.6", + "@aws-sdk/util-user-agent-node": "^3.973.2", + "@smithy/config-resolver": "^4.4.9", + "@smithy/core": "^3.23.7", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/hash-node": "^4.2.10", + "@smithy/invalid-dependency": "^4.2.10", + "@smithy/middleware-content-length": "^4.2.10", + "@smithy/middleware-endpoint": "^4.4.21", + "@smithy/middleware-retry": "^4.4.38", + "@smithy/middleware-serde": "^4.2.11", + "@smithy/middleware-stack": "^4.2.10", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-http-handler": "^4.4.13", + "@smithy/protocol-http": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-base64": "^4.3.1", + "@smithy/util-body-length-browser": "^4.2.1", + "@smithy/util-body-length-node": "^4.2.2", + "@smithy/util-defaults-mode-browser": "^4.3.37", + "@smithy/util-defaults-mode-node": "^4.2.40", + "@smithy/util-endpoints": "^3.3.1", + "@smithy/util-middleware": "^4.2.10", + "@smithy/util-retry": "^4.2.10", + "@smithy/util-utf8": "^4.2.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/region-config-resolver": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.936.0.tgz", - "integrity": "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.6.tgz", + "integrity": "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/types": "^3.973.4", + "@smithy/config-resolver": "^4.4.9", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/types": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.936.0.tgz", - "integrity": "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==", + "version": "3.973.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.4.tgz", + "integrity": "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-endpoints": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.936.0.tgz", - "integrity": "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==", + "version": "3.996.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", + "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-endpoints": "^3.2.5", + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.936.0.tgz", - "integrity": "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.6.tgz", + "integrity": "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.947.0.tgz", - "integrity": "sha512-+vhHoDrdbb+zerV4noQk1DHaUMNzWFWPpPYjVTwW2186k5BEJIecAMChYkghRrBVJ3KPWP1+JnZwOd72F3d4rQ==", + "version": "3.973.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.2.tgz", + "integrity": "sha512-lpaIuekdkpw7VRiik0IZmd6TyvEUcuLgKZ5fKRGpCA3I4PjrD/XH15sSwW+OptxQjNU4DEzSxag70spC9SluvA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/middleware-user-agent": "^3.972.17", + "@aws-sdk/types": "^3.973.4", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "aws-crt": ">=1.0.0" @@ -8800,28 +8772,47 @@ } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/xml-builder": { - "version": "3.930.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", - "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.9.tgz", + "integrity": "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", - "fast-xml-parser": "5.2.5", + "@smithy/types": "^4.13.0", + "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws/lambda-invoke-store": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", - "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/fast-xml-parser": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/@aws-sdk/credential-provider-login/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -11190,7 +11181,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -11223,7 +11213,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -11256,7 +11245,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -11488,10 +11476,22 @@ "resolved": "packages/eslint-config", "link": true }, + "node_modules/@friggframework/extension-db-credentials": { + "resolved": "packages/extensions/db-credentials", + "link": true + }, "node_modules/@friggframework/prettier-config": { "resolved": "packages/prettier-config", "link": true }, + "node_modules/@friggframework/provider-aws": { + "resolved": "packages/providers/aws", + "link": true + }, + "node_modules/@friggframework/provider-netlify": { + "resolved": "packages/providers/netlify", + "link": true + }, "node_modules/@friggframework/schemas": { "resolved": "packages/schemas", "link": true @@ -17479,12 +17479,12 @@ "dev": true }, "node_modules/@smithy/abort-controller": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz", - "integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", + "integrity": "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -17534,16 +17534,16 @@ "license": "0BSD" }, "node_modules/@smithy/config-resolver": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.3.tgz", - "integrity": "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==", + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.10.tgz", + "integrity": "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -17556,20 +17556,20 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/core": { - "version": "3.18.7", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.7.tgz", - "integrity": "sha512-axG9MvKhMWOhFbvf5y2DuyTxQueO0dkedY9QC3mAfndLosRI/9LJv8WaL0mw7ubNhsO4IuXX9/9dYGPFvHrqlw==", + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.8.tgz", + "integrity": "sha512-f7uPeBi7ehmLT4YF2u9j3qx6lSnurG1DLXOsTtJrIRNDF7VXio4BGHQ+SQteN/BrUVudbkuL4v7oOsRCzq4BqA==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.6", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-stream": "^4.5.6", - "@smithy/util-utf8": "^4.2.0", - "@smithy/uuid": "^1.1.0", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -17582,15 +17582,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.5.tgz", - "integrity": "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.11.tgz", + "integrity": "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -17700,15 +17700,15 @@ "license": "0BSD" }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz", - "integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==", + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.13.tgz", + "integrity": "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.5", - "@smithy/querystring-builder": "^4.2.5", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -17742,14 +17742,14 @@ "license": "0BSD" }, "node_modules/@smithy/hash-node": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.5.tgz", - "integrity": "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.11.tgz", + "integrity": "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/types": "^4.13.0", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -17782,12 +17782,12 @@ "license": "0BSD" }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.5.tgz", - "integrity": "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.11.tgz", + "integrity": "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -17800,9 +17800,10 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/is-array-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", - "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, @@ -17864,13 +17865,13 @@ "license": "0BSD" }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.5.tgz", - "integrity": "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.11.tgz", + "integrity": "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -17883,18 +17884,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.3.14", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.14.tgz", - "integrity": "sha512-v0q4uTKgBM8dsqGjqsabZQyH85nFaTnFcgpWU1uydKFsdyyMzfvOkNum9G7VK+dOP01vUnoZxIeRiJ6uD0kjIg==", + "version": "4.4.22", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.22.tgz", + "integrity": "sha512-sc81w1o4Jy+/MAQlY3sQ8C7CmSpcvIi3TAzXblUv2hjG11BBSJi/Cw8vDx5BxMxapuH2I+Gc+45vWsgU07WZRQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.18.7", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-middleware": "^4.2.5", + "@smithy/core": "^3.23.8", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -17907,19 +17908,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.14.tgz", - "integrity": "sha512-Z2DG8Ej7FyWG1UA+7HceINtSLzswUgs2np3sZX0YBBxCt+CXG4QUxv88ZDS3+2/1ldW7LqtSY1UO/6VQ1pND8Q==", + "version": "4.4.39", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.39.tgz", + "integrity": "sha512-MCVCxaCzuZgiHtHGV2Ke44nh6t4+8/tO+rTYOzrr2+G4nMLU/qbzNCWKBX54lyEaVcGQrfOJiG2f8imtiw+nIQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/service-error-classification": "^4.2.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/uuid": "^1.1.0", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/service-error-classification": "^4.2.11", + "@smithy/smithy-client": "^4.12.2", + "@smithy/types": "^4.13.0", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -17932,13 +17933,13 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.6.tgz", - "integrity": "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.12.tgz", + "integrity": "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -17951,12 +17952,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz", - "integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.11.tgz", + "integrity": "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -17969,14 +17970,14 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz", - "integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==", + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.11.tgz", + "integrity": "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -17989,15 +17990,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz", - "integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==", + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.14.tgz", + "integrity": "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/querystring-builder": "^4.2.5", - "@smithy/types": "^4.9.0", + "@smithy/abort-controller": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -18010,12 +18011,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/property-provider": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz", - "integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.11.tgz", + "integrity": "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -18028,12 +18029,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/protocol-http": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz", - "integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==", + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.11.tgz", + "integrity": "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -18046,13 +18047,13 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz", - "integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.11.tgz", + "integrity": "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", - "@smithy/util-uri-escape": "^4.2.0", + "@smithy/types": "^4.13.0", + "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -18065,12 +18066,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz", - "integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.11.tgz", + "integrity": "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -18084,24 +18085,24 @@ "license": "0BSD" }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.5.tgz", - "integrity": "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.11.tgz", + "integrity": "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0" + "@smithy/types": "^4.13.0" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz", - "integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.6.tgz", + "integrity": "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -18114,18 +18115,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/signature-v4": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.5.tgz", - "integrity": "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==", + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.11.tgz", + "integrity": "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-uri-escape": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -18138,17 +18139,17 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/smithy-client": { - "version": "4.9.10", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.10.tgz", - "integrity": "sha512-Jaoz4Jw1QYHc1EFww/E6gVtNjhoDU+gwRKqXP6C3LKYqqH2UQhP8tMP3+t/ePrhaze7fhLE8vS2q6vVxBANFTQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.2.tgz", + "integrity": "sha512-HezY3UuG0k4T+4xhFKctLXCA5N2oN+Rtv+mmL8Gt7YmsUY2yhmcLyW75qrSzldfj75IsCW/4UhY3s20KcFnZqA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.18.7", - "@smithy/middleware-endpoint": "^4.3.14", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "@smithy/util-stream": "^4.5.6", + "@smithy/core": "^3.23.8", + "@smithy/middleware-endpoint": "^4.4.22", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" }, "engines": { @@ -18161,9 +18162,9 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/types": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz", - "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", + "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -18178,13 +18179,13 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/url-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz", - "integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.11.tgz", + "integrity": "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.5", - "@smithy/types": "^4.9.0", + "@smithy/querystring-parser": "^4.2.11", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -18197,12 +18198,13 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-base64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", - "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -18215,9 +18217,10 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", - "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, @@ -18231,9 +18234,10 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-body-length-node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", - "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, @@ -18247,11 +18251,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-buffer-from": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", - "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", + "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -18261,12 +18266,14 @@ "node_modules/@smithy/util-buffer-from/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/@smithy/util-config-provider": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", - "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, @@ -18280,14 +18287,14 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.13", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.13.tgz", - "integrity": "sha512-hlVLdAGrVfyNei+pKIgqDTxfu/ZI2NSyqj4IDxKd5bIsIqwR/dSlkxlPaYxFiIaDVrBy0he8orsFy+Cz119XvA==", + "version": "4.3.38", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.38.tgz", + "integrity": "sha512-c8P1mFLNxcsdAMabB8/VUQUbWzFmgujWi4bAXSggcqLYPc8V4U5abqFqOyn+dK4YT+q8UyCVkTO8807t4t2syA==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.2", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -18300,17 +18307,17 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.16", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.16.tgz", - "integrity": "sha512-F1t22IUiJLHrxW9W1CQ6B9PN+skZ9cqSuzB18Eh06HrJPbjsyZ7ZHecAKw80DQtyGTRcVfeukKaCRYebFwclbg==", + "version": "4.2.41", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.41.tgz", + "integrity": "sha512-/UG+9MT3UZAR0fLzOtMJMfWGcjjHvgggq924x/CRy8vRbL+yFf3Z6vETlvq8vDH92+31P/1gSOFoo7303wN8WQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.3", - "@smithy/credential-provider-imds": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", + "@smithy/config-resolver": "^4.4.10", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.2", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -18323,13 +18330,13 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-endpoints": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.5.tgz", - "integrity": "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.2.tgz", + "integrity": "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -18342,9 +18349,10 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", - "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, @@ -18358,12 +18366,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-middleware": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz", - "integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.11.tgz", + "integrity": "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -18376,13 +18384,13 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-retry": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.5.tgz", - "integrity": "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.11.tgz", + "integrity": "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.5", - "@smithy/types": "^4.9.0", + "@smithy/service-error-classification": "^4.2.11", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -18395,18 +18403,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-stream": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz", - "integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==", + "version": "4.5.17", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.17.tgz", + "integrity": "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -18419,9 +18427,10 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-uri-escape": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", - "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, @@ -18435,11 +18444,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-utf8": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", - "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -18471,9 +18481,10 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/uuid": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", - "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, @@ -19167,8 +19178,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-android-arm64": { "version": "1.11.1", @@ -19180,8 +19190,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.11.1", @@ -19193,8 +19202,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-darwin-x64": { "version": "1.11.1", @@ -19206,8 +19214,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-freebsd-x64": { "version": "1.11.1", @@ -19219,8 +19226,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { "version": "1.11.1", @@ -19232,8 +19238,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { "version": "1.11.1", @@ -19245,8 +19250,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { "version": "1.11.1", @@ -19258,8 +19262,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { "version": "1.11.1", @@ -19271,8 +19274,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { "version": "1.11.1", @@ -19284,8 +19286,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { "version": "1.11.1", @@ -19297,8 +19298,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { "version": "1.11.1", @@ -19310,8 +19310,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { "version": "1.11.1", @@ -19323,8 +19322,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { "version": "1.11.1", @@ -19336,8 +19334,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { "version": "1.11.1", @@ -19349,8 +19346,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { "version": "1.11.1", @@ -19360,7 +19356,6 @@ "wasm32" ], "optional": true, - "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, @@ -19373,7 +19368,6 @@ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", "optional": true, - "peer": true, "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", @@ -19385,7 +19379,6 @@ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -19394,8 +19387,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.11.1", @@ -19407,8 +19399,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { "version": "1.11.1", @@ -19420,8 +19411,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { "version": "1.11.1", @@ -19433,8 +19423,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", @@ -23865,6 +23854,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -23874,6 +23864,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -25320,6 +25311,18 @@ } ] }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/fast-xml-parser": { "version": "5.2.5", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", @@ -25741,9 +25744,10 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -30493,14 +30497,6 @@ "dev": true, "peer": true }, - "node_modules/kareem": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", - "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -32072,9 +32068,10 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -32404,63 +32401,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, - "node_modules/mongoose": { - "version": "6.11.6", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.11.6.tgz", - "integrity": "sha512-CuVbeJrEbnxkPUNNFvXJhjVyqa5Ip7lkz6EJX6g7Lb3aFMTJ+LHOlUrncxzC3r20dqasaVIiwcA6Y5qC8PWQ7w==", - "dependencies": { - "bson": "^4.7.2", - "kareem": "2.5.1", - "mongodb": "4.16.0", - "mpath": "0.9.0", - "mquery": "4.0.3", - "ms": "2.1.3", - "sift": "16.0.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, - "node_modules/mongoose/node_modules/mongodb": { - "version": "4.16.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.16.0.tgz", - "integrity": "sha512-0EB113Fsucaq1wsY0dOhi1fmZOwFtLOtteQkiqOXGklvWMnSH3g2QS53f0KTP+/6qOkuoXE2JksubSZNmxeI+g==", - "dependencies": { - "bson": "^4.7.2", - "mongodb-connection-string-url": "^2.5.4", - "socks": "^2.7.1" - }, - "engines": { - "node": ">=12.9.0" - }, - "optionalDependencies": { - "@aws-sdk/credential-providers": "^3.186.0", - "saslprep": "^1.0.3" - } - }, - "node_modules/mpath": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", - "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mquery": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz", - "integrity": "sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==", - "dependencies": { - "debug": "4.x" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -35956,18 +35896,6 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "node_modules/saslprep": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", - "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", - "optional": true, - "dependencies": { - "sparse-bitfield": "^3.0.3" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/sax": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", @@ -36947,11 +36875,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sift": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", - "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -37662,15 +37585,16 @@ } }, "node_modules/strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } - ] + ], + "license": "MIT" }, "node_modules/strong-log-transformer": { "version": "2.1.0", @@ -37822,19 +37746,30 @@ } }, "node_modules/supertest": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", - "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", "dev": true, "license": "MIT", "dependencies": { + "cookie-signature": "^1.2.2", "methods": "^1.1.2", - "superagent": "^10.2.3" + "superagent": "^10.3.0" }, "engines": { "node": ">=14.18.0" } }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supertest/node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -37866,10 +37801,26 @@ "node": ">=4.0.0" } }, + "node_modules/supertest/node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/supertest/node_modules/superagent": { - "version": "10.2.3", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", - "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", "dev": true, "license": "MIT", "dependencies": { @@ -37877,11 +37828,11 @@ "cookiejar": "^2.1.4", "debug": "^4.3.7", "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.4", + "form-data": "^4.0.5", "formidable": "^3.5.4", "methods": "^1.1.2", "mime": "2.6.0", - "qs": "^6.11.2" + "qs": "^6.14.1" }, "engines": { "node": ">=14.18.0" @@ -38158,19 +38109,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -38699,7 +38637,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -38716,7 +38653,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -38733,7 +38669,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -38750,7 +38685,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -38767,7 +38701,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -38784,7 +38717,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -38801,7 +38733,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -38818,7 +38749,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -38835,7 +38765,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -38852,7 +38781,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -38869,7 +38797,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -38886,7 +38813,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -38903,7 +38829,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -38920,7 +38845,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -38937,7 +38861,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -38954,7 +38877,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -38971,7 +38893,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -38988,7 +38909,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -39005,7 +38925,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -39022,7 +38941,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -39039,7 +38957,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -39056,7 +38973,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -39073,7 +38989,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -40835,13 +40750,10 @@ "version": "2.0.0-next.0", "license": "MIT", "dependencies": { - "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0", - "@aws-sdk/client-kms": "^3.588.0", - "@aws-sdk/client-lambda": "^3.714.0", - "@aws-sdk/client-sqs": "^3.588.0", "@hapi/boom": "^10.0.1", "bcryptjs": "^2.4.3", "body-parser": "^1.20.2", + "bson": "^4.7.2", "chalk": "^4.1.2", "common-tags": "^1.8.2", "cors": "^2.8.5", @@ -40853,7 +40765,6 @@ "js-yaml": "^4.1.0", "lodash": "4.17.21", "lodash.get": "^4.4.2", - "mongoose": "6.11.6", "node-fetch": "^2.6.7", "serverless-http": "^2.7.0", "uuid": "^9.0.1" @@ -42141,58 +42052,7 @@ "@friggframework/test": "*", "jest": "^29.7.0", "mongodb-memory-server": "^8.9.0", - "supertest": "^6.3.3" - } - }, - "packages/e2e/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "packages/e2e/node_modules/superagent": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", - "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", - "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", - "dev": true, - "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.4", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^2.1.2", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.11.0", - "semver": "^7.3.8" - }, - "engines": { - "node": ">=6.4.0 <13 || >=14" - } - }, - "packages/e2e/node_modules/supertest": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", - "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", - "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", - "dev": true, - "license": "MIT", - "dependencies": { - "methods": "^1.1.2", - "superagent": "^8.1.2" - }, - "engines": { - "node": ">=6.4.0" + "supertest": "^7.2.2" } }, "packages/eslint-config": { @@ -42209,6 +42069,20 @@ "eslint-plugin-yaml": "^0.5.0" } }, + "packages/extensions/db-credentials": { + "name": "@friggframework/extension-db-credentials", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "express": "^4.19.2" + }, + "devDependencies": { + "jest": "^29.0.0" + }, + "peerDependencies": { + "@friggframework/core": ">=2.0.0-0" + } + }, "packages/frigg-cli": { "name": "@friggframework/frigg-cli", "version": "2.0.0-next.0", @@ -42254,6 +42128,77 @@ "prettier": "^2.7.1" } }, + "packages/providers/aws": { + "name": "@friggframework/provider-aws", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0", + "@aws-sdk/client-kms": "^3.588.0", + "@aws-sdk/client-lambda": "^3.714.0", + "@aws-sdk/client-s3": "^3.588.0", + "@aws-sdk/client-scheduler": "^3.588.0", + "@aws-sdk/client-sqs": "^3.588.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "jest": "^29.7.0" + }, + "peerDependencies": { + "@friggframework/core": "*" + }, + "peerDependenciesMeta": { + "@friggframework/core": { + "optional": true + } + } + }, + "packages/providers/aws/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "packages/providers/netlify": { + "name": "@friggframework/provider-netlify", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "serverless-http": "^3.2.0" + }, + "devDependencies": { + "jest": "^29.7.0" + }, + "peerDependencies": { + "@friggframework/core": "*", + "@netlify/functions": ">=2.0.0" + }, + "peerDependenciesMeta": { + "@friggframework/core": { + "optional": true + }, + "@netlify/functions": { + "optional": true + } + } + }, + "packages/providers/netlify/node_modules/serverless-http": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/serverless-http/-/serverless-http-3.2.0.tgz", + "integrity": "sha512-QvSyZXljRLIGqwcJ4xsKJXwkZnAVkse1OajepxfjkBXV0BMvRS5R546Z4kCBI8IygDzkQY0foNPC/rnipaE9pQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "packages/schemas": { "name": "@friggframework/schemas", "version": "2.0.0-next.0", @@ -42294,10 +42239,10 @@ "@friggframework/prettier-config": "^2.0.0-next.0", "jest": "^29.7.0", "prettier": "^2.7.1", - "supertest": "^6.3.3" + "supertest": "^7.2.2" }, "peerDependencies": { - "supertest": ">=6.0.0" + "supertest": ">=7.0.0" }, "peerDependenciesMeta": { "supertest": { @@ -42305,57 +42250,6 @@ } } }, - "packages/test/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "packages/test/node_modules/superagent": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", - "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", - "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", - "dev": true, - "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.4", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^2.1.2", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.11.0", - "semver": "^7.3.8" - }, - "engines": { - "node": ">=6.4.0 <13 || >=14" - } - }, - "packages/test/node_modules/supertest": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", - "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", - "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", - "dev": true, - "license": "MIT", - "dependencies": { - "methods": "^1.1.2", - "superagent": "^8.1.2" - }, - "engines": { - "node": ">=6.4.0" - } - }, "packages/ui": { "name": "@friggframework/ui", "version": "2.0.0-next.0", diff --git a/package.json b/package.json index b2cfa62b6..89e1f0683 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "author": "seanspeaks ", "license": "MIT", "workspaces": [ - "packages/*" + "packages/*", + "packages/providers/*", + "packages/extensions/*" ], "devDependencies": { "@auto-it/all-contributors": "11.3.0", diff --git a/packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js b/packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js index 3a22eb9a9..61303200b 100644 --- a/packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js +++ b/packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js @@ -1,12 +1,6 @@ -const { - AdminApiKeyRepositoryMongo, -} = require('./admin-api-key-repository-mongo'); const { AdminApiKeyRepositoryPostgres, } = require('./admin-api-key-repository-postgres'); -const { - AdminApiKeyRepositoryDocumentDB, -} = require('./admin-api-key-repository-documentdb'); const config = require('../../database/config'); /** @@ -31,12 +25,14 @@ function createAdminApiKeyRepository() { switch (dbType) { case 'mongodb': + const { AdminApiKeyRepositoryMongo } = require('./admin-api-key-repository-mongo'); return new AdminApiKeyRepositoryMongo(); case 'postgresql': return new AdminApiKeyRepositoryPostgres(); case 'documentdb': + const { AdminApiKeyRepositoryDocumentDB } = require('./admin-api-key-repository-documentdb'); return new AdminApiKeyRepositoryDocumentDB(); default: @@ -49,7 +45,7 @@ function createAdminApiKeyRepository() { module.exports = { createAdminApiKeyRepository, // Export adapters for direct testing - AdminApiKeyRepositoryMongo, + get AdminApiKeyRepositoryMongo() { return require('./admin-api-key-repository-mongo').AdminApiKeyRepositoryMongo; }, AdminApiKeyRepositoryPostgres, - AdminApiKeyRepositoryDocumentDB, + get AdminApiKeyRepositoryDocumentDB() { return require('./admin-api-key-repository-documentdb').AdminApiKeyRepositoryDocumentDB; }, }; diff --git a/packages/core/admin-scripts/repositories/admin-process-repository-factory.js b/packages/core/admin-scripts/repositories/admin-process-repository-factory.js index cfdb946b1..08ce8ec64 100644 --- a/packages/core/admin-scripts/repositories/admin-process-repository-factory.js +++ b/packages/core/admin-scripts/repositories/admin-process-repository-factory.js @@ -1,8 +1,4 @@ -const { AdminProcessRepositoryMongo } = require('./admin-process-repository-mongo'); const { AdminProcessRepositoryPostgres } = require('./admin-process-repository-postgres'); -const { - AdminProcessRepositoryDocumentDB, -} = require('./admin-process-repository-documentdb'); const config = require('../../database/config'); /** @@ -27,12 +23,14 @@ function createAdminProcessRepository() { switch (dbType) { case 'mongodb': + const { AdminProcessRepositoryMongo } = require('./admin-process-repository-mongo'); return new AdminProcessRepositoryMongo(); case 'postgresql': return new AdminProcessRepositoryPostgres(); case 'documentdb': + const { AdminProcessRepositoryDocumentDB } = require('./admin-process-repository-documentdb'); return new AdminProcessRepositoryDocumentDB(); default: @@ -45,7 +43,7 @@ function createAdminProcessRepository() { module.exports = { createAdminProcessRepository, // Export adapters for direct testing - AdminProcessRepositoryMongo, + get AdminProcessRepositoryMongo() { return require('./admin-process-repository-mongo').AdminProcessRepositoryMongo; }, AdminProcessRepositoryPostgres, - AdminProcessRepositoryDocumentDB, + get AdminProcessRepositoryDocumentDB() { return require('./admin-process-repository-documentdb').AdminProcessRepositoryDocumentDB; }, }; diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-factory.js b/packages/core/admin-scripts/repositories/script-execution-repository-factory.js index 7c54a74d9..1d64e8537 100644 --- a/packages/core/admin-scripts/repositories/script-execution-repository-factory.js +++ b/packages/core/admin-scripts/repositories/script-execution-repository-factory.js @@ -1,12 +1,6 @@ -const { - ScriptExecutionRepositoryMongo, -} = require('./script-execution-repository-mongo'); const { ScriptExecutionRepositoryPostgres, } = require('./script-execution-repository-postgres'); -const { - ScriptExecutionRepositoryDocumentDB, -} = require('./script-execution-repository-documentdb'); const config = require('../../database/config'); /** @@ -31,12 +25,14 @@ function createScriptExecutionRepository() { switch (dbType) { case 'mongodb': + const { ScriptExecutionRepositoryMongo } = require('./script-execution-repository-mongo'); return new ScriptExecutionRepositoryMongo(); case 'postgresql': return new ScriptExecutionRepositoryPostgres(); case 'documentdb': + const { ScriptExecutionRepositoryDocumentDB } = require('./script-execution-repository-documentdb'); return new ScriptExecutionRepositoryDocumentDB(); default: @@ -49,7 +45,7 @@ function createScriptExecutionRepository() { module.exports = { createScriptExecutionRepository, // Export adapters for direct testing - ScriptExecutionRepositoryMongo, + get ScriptExecutionRepositoryMongo() { return require('./script-execution-repository-mongo').ScriptExecutionRepositoryMongo; }, ScriptExecutionRepositoryPostgres, - ScriptExecutionRepositoryDocumentDB, + get ScriptExecutionRepositoryDocumentDB() { return require('./script-execution-repository-documentdb').ScriptExecutionRepositoryDocumentDB; }, }; diff --git a/packages/core/admin-scripts/repositories/script-schedule-repository-factory.js b/packages/core/admin-scripts/repositories/script-schedule-repository-factory.js index dc8e44974..856c74878 100644 --- a/packages/core/admin-scripts/repositories/script-schedule-repository-factory.js +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-factory.js @@ -1,8 +1,4 @@ -const { ScriptScheduleRepositoryMongo } = require('./script-schedule-repository-mongo'); const { ScriptScheduleRepositoryPostgres } = require('./script-schedule-repository-postgres'); -const { - ScriptScheduleRepositoryDocumentDB, -} = require('./script-schedule-repository-documentdb'); const config = require('../../database/config'); /** @@ -27,12 +23,14 @@ function createScriptScheduleRepository() { switch (dbType) { case 'mongodb': + const { ScriptScheduleRepositoryMongo } = require('./script-schedule-repository-mongo'); return new ScriptScheduleRepositoryMongo(); case 'postgresql': return new ScriptScheduleRepositoryPostgres(); case 'documentdb': + const { ScriptScheduleRepositoryDocumentDB } = require('./script-schedule-repository-documentdb'); return new ScriptScheduleRepositoryDocumentDB(); default: @@ -45,7 +43,7 @@ function createScriptScheduleRepository() { module.exports = { createScriptScheduleRepository, // Export adapters for direct testing - ScriptScheduleRepositoryMongo, + get ScriptScheduleRepositoryMongo() { return require('./script-schedule-repository-mongo').ScriptScheduleRepositoryMongo; }, ScriptScheduleRepositoryPostgres, - ScriptScheduleRepositoryDocumentDB, + get ScriptScheduleRepositoryDocumentDB() { return require('./script-schedule-repository-documentdb').ScriptScheduleRepositoryDocumentDB; }, }; diff --git a/packages/core/assertions/get.js b/packages/core/assertions/get.js index f3c9770f5..24c426598 100644 --- a/packages/core/assertions/get.js +++ b/packages/core/assertions/get.js @@ -1,8 +1,21 @@ -const lodashGet = require('lodash.get'); const { RequiredPropertyError, ParameterTypeError } = require('../errors'); +/** + * Deep property access by dot-separated path string. + * Replaces lodash.get (deprecated). + */ +function deepGet(obj, path, defaultValue) { + const keys = typeof path === 'string' ? path.split('.') : path; + let result = obj; + for (const key of keys) { + if (result == null) return defaultValue; + result = result[key]; + } + return result === undefined ? defaultValue : result; +} + const get = (o, key, defaultValue) => { - const value = lodashGet(o, key, defaultValue); + const value = deepGet(o, key, defaultValue); if (value !== undefined) { return value; @@ -23,7 +36,7 @@ const getAll = (o, requiredKeys) => { const returnDict = {}; for (const key of requiredKeys) { - const val = lodashGet(o, key); + const val = deepGet(o, key); if (val) { returnDict[key] = val; diff --git a/packages/core/assertions/index.js b/packages/core/assertions/index.js index acda1145e..3dc6c278e 100644 --- a/packages/core/assertions/index.js +++ b/packages/core/assertions/index.js @@ -6,10 +6,7 @@ const { getArrayParamAndVerifyParamType, getAndVerifyType, } = require('./get'); -const { expectShallowEqualDbObject } = require('./is-equal'); - module.exports = { - expectShallowEqualDbObject, get, getAll, verifyType, diff --git a/packages/core/assertions/is-equal.js b/packages/core/assertions/is-equal.js deleted file mode 100644 index cd6fe48a4..000000000 --- a/packages/core/assertions/is-equal.js +++ /dev/null @@ -1,17 +0,0 @@ -const expectShallowEqualDbObject = (modelObject, compareObject) => { - for (const key in compareObject) { - let objVal = modelObject[key]; - - if (objVal instanceof Date) { - objVal = objVal.toISOString(); - } else if (objVal instanceof mongoose.Types.ObjectId) { - objVal = objVal._id.toString(); - } - - expect(compareObject[key]).toBe(objVal); - } -}; - -// TODO not sure how much this is needed, but could rewrite with _.isEqualWith for deep equality with custom checks. - -module.exports = { expectShallowEqualDbObject }; diff --git a/packages/core/associations/model.js b/packages/core/associations/model.js deleted file mode 100644 index aba664bf6..000000000 --- a/packages/core/associations/model.js +++ /dev/null @@ -1,54 +0,0 @@ -const mongoose = require('mongoose'); - -const schema = new mongoose.Schema({ - integration: { - type: mongoose.Schema.Types.ObjectId, - ref: 'Integration', - required: true, - }, - name: { type: String, required: true }, - type: { - type: String, - enum: ['ONE_TO_MANY', 'ONE_TO_ONE', 'MANY_TO_ONE'], - required: true, - }, - primaryObject: { type: String, required: true }, - objects: [ - { - entity: { - type: mongoose.Schema.Types.ObjectId, - ref: 'Entity', - required: true, - }, - objectType: { type: String, required: true }, - objId: { type: String, required: true }, - metadata: { type: Object, required: false }, - }, - ], -}); - -schema.statics({ - addAssociation: async function (id, object) { - return this.update({ _id: id }, { $push: { objects: object } }); - }, - findAssociation: async function (name, dataIdentifierHash) { - const syncList = await this.list({ - name: name, - 'dataIdentifiers.hash': dataIdentifierHash, - }); - - if (syncList.length === 1) { - return syncList[0]; - } else if (syncList.length === 0) { - return null; - } else { - throw new Error( - `there are multiple sync objects with the name ${name}, for entities [${entities}]` - ); - } - }, -}); - -const Association = - mongoose.models.Association || mongoose.model('Association', schema); -module.exports = { Association }; diff --git a/packages/core/core/Worker.js b/packages/core/core/Worker.js index 3bd6d48de..75791eea1 100644 --- a/packages/core/core/Worker.js +++ b/packages/core/core/Worker.js @@ -1,23 +1,41 @@ -const { - SQSClient, - GetQueueUrlCommand, - SendMessageCommand, -} = require('@aws-sdk/client-sqs'); const _ = require('lodash'); const { RequiredPropertyError } = require('../errors'); const { get } = require('../assertions'); -const sqs = new SQSClient({ region: process.env.AWS_REGION }); - +/** + * Worker - Queue message producer/consumer base class. + * + * Subclass and override _run() to implement your worker logic. + * The queue transport (SQS, Netlify, etc.) is abstracted behind + * QueueClientInterface, injected via constructor options. + * + * BREAKING CHANGE (v3): A queueClient must be explicitly provided. + * For AWS/SQS, pass `new SqsQueueClient()` from @friggframework/provider-aws. + * See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide. + */ class Worker { + constructor(options = {}) { + this._queueClient = options.queueClient || null; + } + + /** + * Get the queue client. Throws if none was injected. + * @returns {QueueClientInterface} + */ + _getQueueClient() { + if (!this._queueClient) { + throw new Error( + 'Worker requires a queueClient. Pass one via constructor options, e.g.:\n' + + ' const { SqsQueueClient } = require("@friggframework/provider-aws");\n' + + ' new MyWorker({ queueClient: new SqsQueueClient() })\n' + + 'See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide.' + ); + } + return this._queueClient; + } + async getQueueURL(params) { - // Passing params in because there will be multiple QueueNames - // let params = { - // QueueName: process.env.QueueName - // }; - const command = new GetQueueUrlCommand(params); - const data = await sqs.send(command); - return data.QueueUrl; + return this._getQueueClient().getQueueUrl(params); } async run(params, context = {}) { @@ -51,8 +69,7 @@ class Worker { } async sendAsyncSQSMessage(params) { - const command = new SendMessageCommand(params); - const data = await sqs.send(command); + const data = await this._getQueueClient().sendMessage(params); return data.MessageId; } diff --git a/packages/core/core/Worker.test.js b/packages/core/core/Worker.test.js index d81d80cc2..42e461ea4 100644 --- a/packages/core/core/Worker.test.js +++ b/packages/core/core/Worker.test.js @@ -1,41 +1,51 @@ /** - * Tests for Worker - AWS SDK v3 Migration + * Tests for Worker - Provider-Agnostic Queue Interface * - * Tests SQS Worker operations using aws-sdk-client-mock + * Tests Worker operations using a mock QueueClientInterface. + * No AWS SDK dependency — the queue client is injected via constructor. */ -const { mockClient } = require('aws-sdk-client-mock'); -const { - SQSClient, - GetQueueUrlCommand, - SendMessageCommand, -} = require('@aws-sdk/client-sqs'); const { Worker } = require('./Worker'); -describe('Worker - AWS SDK v3', () => { - let sqsMock; +describe('Worker', () => { + let mockQueueClient; let worker; - const originalEnv = process.env; beforeEach(() => { - sqsMock = mockClient(SQSClient); - worker = new Worker(); + mockQueueClient = { + sendMessage: jest.fn().mockResolvedValue({ + MessageId: 'message-123', + }), + sendMessageBatch: jest.fn().mockResolvedValue({ + Successful: [], + Failed: [], + }), + getQueueUrl: jest.fn().mockResolvedValue( + 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue' + ), + }; + + worker = new Worker({ queueClient: mockQueueClient }); jest.clearAllMocks(); - process.env = { ...originalEnv, AWS_REGION: 'us-east-1' }; }); - afterEach(() => { - sqsMock.reset(); - process.env = originalEnv; + describe('Constructor', () => { + it('should throw if no queueClient is provided when queue methods are used', async () => { + const workerNoClient = new Worker(); + + await expect( + workerNoClient.getQueueURL({ QueueName: 'test' }) + ).rejects.toThrow('Worker requires a queueClient'); + }); + + it('should accept a queueClient via options', () => { + const w = new Worker({ queueClient: mockQueueClient }); + expect(w).toBeDefined(); + }); }); describe('getQueueURL()', () => { - it('should get queue URL from SQS', async () => { - sqsMock.on(GetQueueUrlCommand).resolves({ - QueueUrl: - 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue', - }); - + it('should get queue URL via queue client', async () => { const result = await worker.getQueueURL({ QueueName: 'test-queue', }); @@ -43,18 +53,15 @@ describe('Worker - AWS SDK v3', () => { expect(result).toBe( 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue' ); - expect(sqsMock.calls()).toHaveLength(1); - - const call = sqsMock.call(0); - expect(call.args[0].input).toMatchObject({ + expect(mockQueueClient.getQueueUrl).toHaveBeenCalledWith({ QueueName: 'test-queue', }); }); it('should handle queue not found error', async () => { - sqsMock - .on(GetQueueUrlCommand) - .rejects(new Error('Queue does not exist')); + mockQueueClient.getQueueUrl.mockRejectedValue( + new Error('Queue does not exist') + ); await expect( worker.getQueueURL({ QueueName: 'nonexistent-queue' }) @@ -64,10 +71,6 @@ describe('Worker - AWS SDK v3', () => { describe('sendAsyncSQSMessage()', () => { it('should send message and return MessageId', async () => { - sqsMock.on(SendMessageCommand).resolves({ - MessageId: 'message-123', - }); - const params = { QueueUrl: 'https://queue-url', MessageBody: JSON.stringify({ test: 'data' }), @@ -76,11 +79,13 @@ describe('Worker - AWS SDK v3', () => { const result = await worker.sendAsyncSQSMessage(params); expect(result).toBe('message-123'); - expect(sqsMock.calls()).toHaveLength(1); + expect(mockQueueClient.sendMessage).toHaveBeenCalledWith(params); }); it('should handle send errors', async () => { - sqsMock.on(SendMessageCommand).rejects(new Error('Send failed')); + mockQueueClient.sendMessage.mockRejectedValue( + new Error('Send failed') + ); const params = { QueueUrl: 'https://queue-url', @@ -95,11 +100,7 @@ describe('Worker - AWS SDK v3', () => { describe('send()', () => { it('should validate params and send message with delay', async () => { - sqsMock.on(SendMessageCommand).resolves({ - MessageId: 'delayed-message-id', - }); - - worker._validateParams = jest.fn(); // Mock validation + worker._validateParams = jest.fn(); const params = { QueueUrl: 'https://queue-url', @@ -109,17 +110,16 @@ describe('Worker - AWS SDK v3', () => { const result = await worker.send(params, 5); expect(worker._validateParams).toHaveBeenCalledWith(params); - expect(result).toBe('delayed-message-id'); - - const call = sqsMock.call(0); - expect(call.args[0].input.DelaySeconds).toBe(5); + expect(result).toBe('message-123'); + expect(mockQueueClient.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + DelaySeconds: 5, + QueueUrl: 'https://queue-url', + }) + ); }); it('should send message with zero delay by default', async () => { - sqsMock.on(SendMessageCommand).resolves({ - MessageId: 'message-id', - }); - worker._validateParams = jest.fn(); const params = { @@ -129,8 +129,11 @@ describe('Worker - AWS SDK v3', () => { await worker.send(params); - const call = sqsMock.call(0); - expect(call.args[0].input.DelaySeconds).toBe(0); + expect(mockQueueClient.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + DelaySeconds: 0, + }) + ); }); }); diff --git a/packages/core/core/create-handler.js b/packages/core/core/create-handler.js index 2160999cd..0a024010a 100644 --- a/packages/core/core/create-handler.js +++ b/packages/core/core/create-handler.js @@ -29,7 +29,7 @@ const createHandler = (optionByName = {}) => { // If enabled (i.e. if SECRET_ARN is set in process.env) Fetch secrets from AWS Secrets Manager, and set them as environment variables. await secretsToEnv(); - // Helps mongoose reuse the connection. Lowers response times. + // Helps reuse the database connection. Lowers response times. context.callbackWaitsForEmptyEventLoop = false; // Run the Lambda diff --git a/packages/core/credential/repositories/__tests__/credential-repository-documentdb-encryption.test.js b/packages/core/credential/repositories/__tests__/credential-repository-documentdb-encryption.test.js index d49b4e80f..bf22ba23c 100644 --- a/packages/core/credential/repositories/__tests__/credential-repository-documentdb-encryption.test.js +++ b/packages/core/credential/repositories/__tests__/credential-repository-documentdb-encryption.test.js @@ -6,7 +6,7 @@ jest.mock('../../../database/prisma', () => ({ })); jest.mock('../../../database/documentdb-encryption-service'); -const { ObjectId } = require('mongodb'); +const { ObjectId } = require('bson'); const { prisma } = require('../../../database/prisma'); const { toObjectId, diff --git a/packages/core/credential/repositories/credential-repository-factory.js b/packages/core/credential/repositories/credential-repository-factory.js index fbd5b142d..174126e65 100644 --- a/packages/core/credential/repositories/credential-repository-factory.js +++ b/packages/core/credential/repositories/credential-repository-factory.js @@ -1,10 +1,6 @@ -const { CredentialRepositoryMongo } = require('./credential-repository-mongo'); const { CredentialRepositoryPostgres, } = require('./credential-repository-postgres'); -const { - CredentialRepositoryDocumentDB, -} = require('./credential-repository-documentdb'); const config = require('../../database/config'); /** @@ -30,12 +26,14 @@ function createCredentialRepository() { switch (dbType) { case 'mongodb': + const { CredentialRepositoryMongo } = require('./credential-repository-mongo'); return new CredentialRepositoryMongo(); case 'postgresql': return new CredentialRepositoryPostgres(); case 'documentdb': + const { CredentialRepositoryDocumentDB } = require('./credential-repository-documentdb'); return new CredentialRepositoryDocumentDB(); default: @@ -48,7 +46,7 @@ function createCredentialRepository() { module.exports = { createCredentialRepository, // Export adapters for direct testing - CredentialRepositoryMongo, + get CredentialRepositoryMongo() { return require('./credential-repository-mongo').CredentialRepositoryMongo; }, CredentialRepositoryPostgres, - CredentialRepositoryDocumentDB, + get CredentialRepositoryDocumentDB() { return require('./credential-repository-documentdb').CredentialRepositoryDocumentDB; }, }; diff --git a/packages/core/database/config.js b/packages/core/database/config.js index ae9cbfcb8..280149b22 100644 --- a/packages/core/database/config.js +++ b/packages/core/database/config.js @@ -20,69 +20,21 @@ function getDatabaseType() { } // Fallback: Load app definition + // Uses loadAppDefinition() which respects setAppDefinition() cache. + // This is critical for platforms like Netlify where process.cwd()-based + // discovery fails at runtime (process.cwd() is /var/task, not the project root). try { - const path = require('node:path'); - const fs = require('node:fs'); - const { findNearestBackendPackageJson } = require('../utils'); + const { + loadAppDefinition, + } = require('../handlers/app-definition-loader'); - let backendIndexPath; - let database; - const backendPackagePath = findNearestBackendPackageJson(); - - if (!backendPackagePath) { - throw new Error( - '[Frigg] Cannot find backend package.json. ' + - 'Ensure backend/package.json exists in your project.' - ); - } - - const backendDir = path.dirname(backendPackagePath); - backendIndexPath = path.join(backendDir, 'index.js'); - - if (!fs.existsSync(backendIndexPath)) { - throw new Error( - `[Frigg] Backend index.js not found at ${backendIndexPath}. ` + - 'Ensure backend/index.js exists with a Definition export.' - ); - } - - let backendModule; - try { - backendModule = require(backendIndexPath); - } catch (requireError) { - // Extract the actual file with the error from the stack trace - // Skip internal Node.js files (node:internal/*) and find first user file - let errorFile = 'unknown file'; - const stackLines = requireError.stack?.split('\n') || []; - - for (const line of stackLines) { - // Match file paths in stack trace, excluding node:internal - const match = - line.match(/\(([^)]+\.js):\d+:\d+\)/) || - line.match(/at ([^(]+\.js):\d+:\d+/); - if (match && match[1] && !match[1].includes('node:internal')) { - errorFile = match[1]; - break; - } - } - - // Provide better error context for syntax/runtime errors - throw new Error( - `[Frigg] Failed to load app definition from ${backendIndexPath}\n` + - `Error: ${requireError.message}\n` + - `File with error: ${errorFile}\n` + - `\nFull stack trace:\n${requireError.stack}\n\n` + - 'This error occurred while loading your app definition or its dependencies. ' + - 'Check the file listed above for syntax errors (trailing commas, missing brackets, etc.)' - ); - } - - database = backendModule?.Definition?.database; + const { appDefinition } = loadAppDefinition(); + const database = appDefinition?.database; if (!database) { throw new Error( '[Frigg] App definition missing database configuration. ' + - `Add database: { postgres: { enable: true } } (or mongoDB/documentDB) to ${backendIndexPath}` + 'Add database: { postgres: { enable: true } } (or mongoDB/documentDB) to your backend/index.js' ); } diff --git a/packages/core/database/documentdb-utils.js b/packages/core/database/documentdb-utils.js index 5ba0ad9a9..c5f30a758 100644 --- a/packages/core/database/documentdb-utils.js +++ b/packages/core/database/documentdb-utils.js @@ -1,4 +1,4 @@ -const { ObjectId } = require('mongodb'); +const { ObjectId } = require('bson'); function toObjectId(value) { if (value === null || value === undefined || value === '') return undefined; diff --git a/packages/core/database/encryption/encryption-integration.test.js b/packages/core/database/encryption/encryption-integration.test.js deleted file mode 100644 index 63939a5a9..000000000 --- a/packages/core/database/encryption/encryption-integration.test.js +++ /dev/null @@ -1,581 +0,0 @@ -/** - * Integration tests for field-level encryption - * Tests transparent encryption/decryption with Prisma for MongoDB and PostgreSQL - * - * These tests verify: - * - Create operations encrypt fields - * - Read operations decrypt fields - * - Update operations handle encryption - * - Upsert operations work correctly - * - FindMany operations decrypt arrays - * - Null/undefined/empty values are handled - * - Database stores encrypted data - * - * Database-Agnostic Design: - * - Uses repository pattern for raw database access (getRawCredentialById) - * - MongoDB: Uses Mongoose for raw collection access - * - PostgreSQL: Uses Prisma $queryRaw for raw SQL queries - * - Field names match Prisma schema (userId, externalId, not user_id/entity_id) - * - Uses externalId (string) for test data instead of userId (ObjectId reference) - * - * Prerequisites: - * - Database must be running and accessible - * - For MongoDB: Replica set recommended (for transactions) - * - For PostgreSQL: Database must exist - * - Database type configured in backend/index.js app definition - * - * Note: Test explicitly passes 'mongodb' to repository factory for testing purposes - */ - -// Set default DATABASE_URL for testing if not already set -if (!process.env.DATABASE_URL) { - process.env.DATABASE_URL = 'mongodb://localhost:27017/frigg?replicaSet=rs0'; -} - -// Enable encryption for testing (bypass test stage check) -process.env.STAGE = 'integration-test'; -process.env.AES_KEY_ID = 'test-key-id'; -process.env.AES_KEY = 'test-aes-key-32-characters-long!'; - -jest.mock('../config', () => ({ - DB_TYPE: 'mongodb', - getDatabaseType: jest.fn(() => 'mongodb'), - PRISMA_LOG_LEVEL: 'error,warn', - PRISMA_QUERY_LOGGING: false, -})); - -const { prisma, connectPrisma, disconnectPrisma } = require('../prisma'); -const { - createHealthCheckRepository, -} = require('../repositories/health-check-repository-factory'); -const { mongoose } = require('../mongoose'); - -describe('Field-Level Encryption Integration Tests', () => { - const testExternalId = 'test-encryption-integration-id'; - let repository; - - describe('Factory Dependency Injection', () => { - it('should require explicit prismaClient parameter', () => { - expect(() => { - createHealthCheckRepository(); - }).toThrow('prismaClient is required'); - }); - - it('should reject null prismaClient', () => { - expect(() => { - createHealthCheckRepository({ prismaClient: null }); - }).toThrow('prismaClient is required'); - }); - - it('should accept explicit prismaClient', () => { - const repo = createHealthCheckRepository({ prismaClient: prisma }); - expect(repo).toBeDefined(); - expect(repo.prisma).toBe(prisma); - }); - }); - - beforeAll(async () => { - await connectPrisma(); - // Connect mongoose for raw database queries - if (mongoose.connection.readyState === 0) { - await mongoose.connect(process.env.DATABASE_URL); - } - repository = createHealthCheckRepository({ prismaClient: prisma }); - }); - - afterAll(async () => { - // Clean up test data - delete all test credentials by externalId - await prisma.credential.deleteMany({ - where: { externalId: { startsWith: 'test-encryption-' } }, - }); - await mongoose.disconnect(); - await disconnectPrisma(); - }); - - afterEach(async () => { - // Clean up after each test - await prisma.credential.deleteMany({ - where: { externalId: { startsWith: 'test-encryption-' } }, - }); - }); - - describe('Create Operations', () => { - it('should encrypt sensitive fields on create', async () => { - const credential = await prisma.credential.create({ - data: { - externalId: testExternalId, - data: { - access_token: 'secret-token-123', - refresh_token: 'refresh-token-456', - domain: 'example.com', - }, - }, - }); - - // Verify decrypted values returned to application - expect(credential.data.access_token).toBe('secret-token-123'); - expect(credential.data.refresh_token).toBe('refresh-token-456'); - expect(credential.data.domain).toBe('example.com'); - - // Verify raw database has encrypted values - const rawDoc = await repository.getRawCredentialById(credential.id); - - expect(rawDoc.data.access_token).not.toBe('secret-token-123'); - expect(rawDoc.data.access_token).toContain(':'); - expect(rawDoc.data.refresh_token).not.toBe('refresh-token-456'); - expect(rawDoc.data.refresh_token).toContain(':'); - // domain should NOT be encrypted (not in schema registry) - expect(rawDoc.data.domain).toBe('example.com'); - }); - - it('should handle null and undefined values', async () => { - const credential = await prisma.credential.create({ - data: { - externalId: testExternalId, - data: { - access_token: null, - domain: 'example.com', - }, - }, - }); - - expect(credential.data.access_token).toBeNull(); - expect(credential.data.domain).toBe('example.com'); - }); - - it('should handle empty strings', async () => { - const credential = await prisma.credential.create({ - data: { - externalId: testExternalId, - data: { - access_token: '', - domain: 'example.com', - }, - }, - }); - - // Empty strings should not be encrypted - expect(credential.data.access_token).toBe(''); - expect(credential.data.domain).toBe('example.com'); - }); - }); - - describe('Read Operations', () => { - it('should decrypt fields on findUnique', async () => { - // Create with encrypted data - const created = await prisma.credential.create({ - data: { - externalId: testExternalId, - data: { - access_token: 'secret-find-unique', - domain: 'findunique.com', - }, - }, - }); - - // Read back - const found = await prisma.credential.findUnique({ - where: { id: created.id }, - }); - - expect(found.data.access_token).toBe('secret-find-unique'); - expect(found.data.domain).toBe('findunique.com'); - }); - - it('should decrypt fields on findFirst', async () => { - await prisma.credential.create({ - data: { - externalId: testExternalId, - data: { - access_token: 'secret-find-first', - domain: 'findfirst.com', - }, - }, - }); - - const found = await prisma.credential.findFirst({ - where: { externalId: { startsWith: 'test-encryption-' } }, - }); - - expect(found.data.access_token).toBe('secret-find-first'); - expect(found.data.domain).toBe('findfirst.com'); - }); - - it('should decrypt array of results on findMany', async () => { - // Create multiple credentials - await prisma.credential.createMany({ - data: [ - { - externalId: 'test-encryption-entity-1', - data: { - access_token: 'secret-1', - domain: 'domain1.com', - }, - }, - { - externalId: 'test-encryption-entity-2', - data: { - access_token: 'secret-2', - domain: 'domain2.com', - }, - }, - { - externalId: 'test-encryption-entity-3', - data: { - access_token: 'secret-3', - domain: 'domain3.com', - }, - }, - ], - }); - - const credentials = await prisma.credential.findMany({ - where: { externalId: { startsWith: 'test-encryption-' } }, - }); - - expect(credentials).toHaveLength(3); - expect(credentials[0].data.access_token).toBe('secret-1'); - expect(credentials[1].data.access_token).toBe('secret-2'); - expect(credentials[2].data.access_token).toBe('secret-3'); - }); - - it('should return null for non-existent records', async () => { - // Use a valid ObjectId format that doesn't exist in database - const { ObjectId } = require('mongodb'); - const nonExistentId = new ObjectId().toString(); - - const found = await prisma.credential.findUnique({ - where: { id: nonExistentId }, - }); - - expect(found).toBeNull(); - }); - - it('should return empty array for no matches', async () => { - const credentials = await prisma.credential.findMany({ - where: { externalId: 'non-existent-external-id' }, - }); - - expect(credentials).toEqual([]); - }); - }); - - describe('Update Operations', () => { - it('should encrypt new values on update', async () => { - // Create - const created = await prisma.credential.create({ - data: { - externalId: testExternalId, - data: { - access_token: 'old-token', - domain: 'old.com', - }, - }, - }); - - // Update - const updated = await prisma.credential.update({ - where: { id: created.id }, - data: { - data: { - access_token: 'new-token', - domain: 'new.com', - }, - }, - }); - - // Verify decrypted values - expect(updated.data.access_token).toBe('new-token'); - expect(updated.data.domain).toBe('new.com'); - - // Verify raw database has new encrypted value - const rawDoc = await repository.getRawCredentialById(created.id); - - expect(rawDoc.data.access_token).not.toBe('new-token'); - expect(rawDoc.data.access_token).toContain(':'); - }); - - it('should handle partial updates', async () => { - const created = await prisma.credential.create({ - data: { - externalId: testExternalId, - data: { - access_token: 'original-token', - refresh_token: 'original-refresh', - domain: 'original.com', - }, - }, - }); - - // Update only access_token - const updated = await prisma.credential.update({ - where: { id: created.id }, - data: { - data: { - ...created.data, - access_token: 'updated-token', - }, - }, - }); - - expect(updated.data.access_token).toBe('updated-token'); - expect(updated.data.refresh_token).toBe('original-refresh'); - expect(updated.data.domain).toBe('original.com'); - }); - }); - - describe('Upsert Operations', () => { - it('should encrypt on insert path', async () => { - // Use a valid ObjectId format that doesn't exist in database - const { ObjectId } = require('mongodb'); - const nonExistentId = new ObjectId().toString(); - - const upserted = await prisma.credential.upsert({ - where: { - id: nonExistentId, - }, - create: { - id: nonExistentId, - externalId: 'test-encryption-upsert-entity', - data: { - access_token: 'upsert-create-token', - domain: 'upsert-create.com', - }, - }, - update: { - data: { - access_token: 'upsert-update-token', - domain: 'upsert-update.com', - }, - }, - }); - - expect(upserted.data.access_token).toBe('upsert-create-token'); - - // Verify encryption in database - const rawDoc = await repository.getRawCredentialById(upserted.id); - - expect(rawDoc.data.access_token).not.toBe('upsert-create-token'); - expect(rawDoc.data.access_token).toContain(':'); - }); - - it('should encrypt on update path', async () => { - // Create first - const created = await prisma.credential.create({ - data: { - externalId: 'test-encryption-upsert-update-entity', - data: { - access_token: 'original-token', - domain: 'original.com', - }, - }, - }); - - // Upsert (should hit update path) - const upserted = await prisma.credential.upsert({ - where: { - id: created.id, - }, - create: { - externalId: 'test-encryption-upsert-update-entity', - data: { - access_token: 'create-path-token', - domain: 'create.com', - }, - }, - update: { - data: { - access_token: 'update-path-token', - domain: 'update.com', - }, - }, - }); - - expect(upserted.data.access_token).toBe('update-path-token'); - - // Verify encryption in database - const rawDoc = await repository.getRawCredentialById(upserted.id); - - expect(rawDoc.data.access_token).not.toBe('update-path-token'); - expect(rawDoc.data.access_token).toContain(':'); - }); - }); - - describe('Delete Operations', () => { - it('should decrypt deleted record', async () => { - const created = await prisma.credential.create({ - data: { - externalId: testExternalId, - data: { - access_token: 'to-be-deleted', - domain: 'delete.com', - }, - }, - }); - - const deleted = await prisma.credential.delete({ - where: { id: created.id }, - }); - - expect(deleted.data.access_token).toBe('to-be-deleted'); - expect(deleted.data.domain).toBe('delete.com'); - }); - }); - - describe('CreateMany Operations', () => { - it('should encrypt fields in bulk create', async () => { - const result = await prisma.credential.createMany({ - data: [ - { - externalId: 'test-encryption-bulk-1', - data: { - access_token: 'bulk-secret-1', - domain: 'bulk1.com', - }, - }, - { - externalId: 'test-encryption-bulk-2', - data: { - access_token: 'bulk-secret-2', - domain: 'bulk2.com', - }, - }, - ], - }); - - expect(result.count).toBe(2); - - // Verify encryption in database by reading back with Prisma and checking one record's raw form - const credentials = await prisma.credential.findMany({ - where: { externalId: { startsWith: 'test-encryption-' } }, - }); - - // Check raw database for first credential - const rawDoc = await repository.getRawCredentialById( - credentials[0].id - ); - expect(rawDoc.data.access_token).toContain(':'); - expect(rawDoc.data.access_token).not.toMatch(/bulk-secret-/); - - // Verify decryption when reading - const tokens = credentials.map((c) => c.data.access_token); - expect(tokens).toContain('bulk-secret-1'); - expect(tokens).toContain('bulk-secret-2'); - }); - }); - - describe('Non-Encrypted Fields', () => { - it('should not encrypt fields not in schema registry', async () => { - const credential = await prisma.credential.create({ - data: { - externalId: testExternalId, - data: { - access_token: 'secret-token', - domain: 'example.com', - custom_field: 'should-not-encrypt', - }, - }, - }); - - // Verify domain is not encrypted (not in schema) - const rawDoc = await repository.getRawCredentialById(credential.id); - - expect(rawDoc.data.domain).toBe('example.com'); - expect(rawDoc.data.custom_field).toBe('should-not-encrypt'); - - // access_token should be encrypted (in schema) - expect(rawDoc.data.access_token).not.toBe('secret-token'); - }); - }); - - describe('Error Handling', () => { - it('should handle malformed encrypted data gracefully', async () => { - let created; - try { - // Create a credential first to get a valid ID - created = await prisma.credential.create({ - data: { - externalId: 'test-encryption-malformed-entity', - data: { - access_token: 'valid-token', - domain: 'malformed.com', - }, - }, - }); - - // Manually corrupt the encrypted data in the database - // Use realistic corrupted format: 4 colon-separated parts (passes _isEncrypted check) - // but contains invalid base64 that will fail during decryption - const { ObjectId } = require('mongodb'); - const dbType = 'mongodb'; - if (dbType === 'mongodb') { - const { mongoose } = require('../mongoose'); - // Ensure mongoose is connected - if (mongoose.connection.readyState !== 1) { - await mongoose.connect(process.env.DATABASE_URL); - } - await mongoose.connection.db - .collection('Credential') - .updateOne( - { _id: new ObjectId(created.id) }, - { - $set: { - 'data.access_token': - 'CORRUPT:INVALID:DATA:FAKE=', - }, - } - ); - } else { - // PostgreSQL - use raw query to corrupt data - await prisma.$executeRaw` - UPDATE "Credential" - SET data = jsonb_set(data, '{access_token}', '"CORRUPT:INVALID:DATA:FAKE="') - WHERE id = ${created.id} - `; - } - - // Attempt to read should fail with decryption error - // Fix: Remove async wrapper - expect needs the promise directly for .rejects to work - await expect( - prisma.credential.findUnique({ - where: { id: created.id }, - }) - ).rejects.toThrow(); - } finally { - // Cleanup - ensure it runs even if test throws - // Use raw database delete to bypass Prisma encryption extension - // (the encrypted data is corrupted so Prisma delete would fail) - if (created) { - const { ObjectId } = require('mongodb'); - const { mongoose } = require('../mongoose'); - await mongoose.connection.db - .collection('Credential') - .deleteOne({ _id: new ObjectId(created.id) }); - } - } - }); - }); - - describe('Count and Aggregate Operations', () => { - it('should not interfere with count operations', async () => { - await prisma.credential.createMany({ - data: [ - { - externalId: 'test-encryption-count-1', - data: { access_token: 'token1', domain: 'count1.com' }, - }, - { - externalId: 'test-encryption-count-2', - data: { access_token: 'token2', domain: 'count2.com' }, - }, - ], - }); - - const count = await prisma.credential.count({ - where: { externalId: { startsWith: 'test-encryption-' } }, - }); - - expect(count).toBe(2); - }); - }); -}); diff --git a/packages/core/database/encryption/encryption-schema-registry.js b/packages/core/database/encryption/encryption-schema-registry.js index 498247422..beeff9309 100644 --- a/packages/core/database/encryption/encryption-schema-registry.js +++ b/packages/core/database/encryption/encryption-schema-registry.js @@ -170,7 +170,7 @@ function loadModuleEncryptionSchemas(integrations) { const { getModulesDefinitionFromIntegrationClasses, - } = require('../integrations/utils/map-integration-dto'); + } = require('../../integrations/utils/map-integration-dto'); const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrations); @@ -203,22 +203,48 @@ function loadModuleEncryptionSchemas(integrations) { * * Used by both Prisma (MongoDB/PostgreSQL) and DocumentDB encryption services. */ -function loadCustomEncryptionSchema() { - try { - // Lazy require to avoid circular dependency issues - const path = require('node:path'); - const { findNearestBackendPackageJson } = require('../../utils'); +/** + * Registers encryption schemas from extensions. + * Each extension can declare encrypted fields for its custom models. + * + * @param {Array} extensions - Normalized extensions array + */ +function loadExtensionEncryptionSchemas(extensions) { + if (!extensions || !Array.isArray(extensions) || extensions.length === 0) { + return; + } - const backendPackagePath = findNearestBackendPackageJson(); - if (!backendPackagePath) { - return; // No backend found, skip custom schema + for (const ext of extensions) { + if (!ext.encryption || typeof ext.encryption !== 'object') { + continue; } - const backendDir = path.dirname(backendPackagePath); - const backendIndexPath = path.join(backendDir, 'index.js'); + const schema = {}; + for (const [modelName, config] of Object.entries(ext.encryption)) { + if (config && Array.isArray(config.fields) && config.fields.length > 0) { + schema[modelName] = { fields: config.fields }; + } + } - const backendModule = require(backendIndexPath); - const appDefinition = backendModule?.Definition; + if (Object.keys(schema).length > 0) { + logger.info( + `Registering encryption schema from extension "${ext.name}" for models: ${Object.keys(schema).join(', ')}` + ); + registerCustomSchema(schema); + } + } +} + +function loadCustomEncryptionSchema() { + try { + // Uses loadAppDefinition() which respects setAppDefinition() cache. + // This is critical for platforms like Netlify where process.cwd()-based + // discovery fails at runtime (process.cwd() is /var/task, not the project root). + const { + loadAppDefinition, + } = require('../../handlers/app-definition-loader'); + + const { appDefinition } = loadAppDefinition(); if (!appDefinition) { return; // No app definition found @@ -235,6 +261,13 @@ function loadCustomEncryptionSchema() { if (integrations && Array.isArray(integrations)) { loadModuleEncryptionSchemas(integrations); } + + // Load extension-level encryption schemas + const { loadExtensions } = require('../../extensions/extension-loader'); + const extensions = loadExtensions(appDefinition); + if (extensions.length > 0) { + loadExtensionEncryptionSchemas(extensions); + } } catch (error) { // Silently ignore errors - custom schema is optional // This handles cases like: @@ -274,6 +307,7 @@ module.exports = { registerCustomSchema, loadCustomEncryptionSchema, loadModuleEncryptionSchemas, + loadExtensionEncryptionSchemas, extractCredentialFieldsFromModules, validateCustomSchema, resetCustomSchema, diff --git a/packages/core/database/index.js b/packages/core/database/index.js index b74550256..0fb04bb8b 100644 --- a/packages/core/database/index.js +++ b/packages/core/database/index.js @@ -1,67 +1,25 @@ -//todo: probably most of this file content can be removed - /** * Database Module Index - * Exports Mongoose models and connection utilities + * Exports Prisma client, connection utilities, and repositories * * Note: Frigg uses the Repository pattern for data access. - * Models are not meant to be used directly - use repositories instead: + * Use repositories for data operations: * - SyncRepository (syncs/sync-repository.js) * - IntegrationRepository (integrations/integration-repository.js) * - CredentialRepository (credential/credential-repository.js) * etc. */ -// Lazy-load mongoose to avoid importing mongodb when using PostgreSQL only -let _mongoose = null; -let _IndividualUser = null; -let _OrganizationUser = null; -let _UserModel = null; -let _WebsocketConnection = null; - -// Prisma exports (always available) -const { prisma } = require('./prisma'); +const { prisma, connectPrisma, disconnectPrisma } = require('./prisma'); const { TokenRepository } = require('../token/repositories/token-repository'); const { WebsocketConnectionRepository, } = require('../websocket/repositories/websocket-connection-repository'); module.exports = { - // Lazy-loaded mongoose exports (only load when accessed) - get mongoose() { - if (!_mongoose) { - _mongoose = require('./mongoose').mongoose; - } - return _mongoose; - }, - get IndividualUser() { - if (!_IndividualUser) { - _IndividualUser = require('./models/IndividualUser').IndividualUser; - } - return _IndividualUser; - }, - get OrganizationUser() { - if (!_OrganizationUser) { - _OrganizationUser = - require('./models/OrganizationUser').OrganizationUser; - } - return _OrganizationUser; - }, - get UserModel() { - if (!_UserModel) { - _UserModel = require('./models/UserModel').UserModel; - } - return _UserModel; - }, - get WebsocketConnection() { - if (!_WebsocketConnection) { - _WebsocketConnection = - require('./models/WebsocketConnection').WebsocketConnection; - } - return _WebsocketConnection; - }, - // Prisma (always available) prisma, + connectPrisma, + disconnectPrisma, TokenRepository, WebsocketConnectionRepository, }; diff --git a/packages/core/database/models/IndividualUser.js b/packages/core/database/models/IndividualUser.js deleted file mode 100644 index e6e358e79..000000000 --- a/packages/core/database/models/IndividualUser.js +++ /dev/null @@ -1,76 +0,0 @@ -const { mongoose } = require('../mongoose'); -const bcrypt = require('bcryptjs'); -const { UserModel: Parent } = require('./UserModel'); - -const collectionName = 'IndividualUser'; - -const schema = new mongoose.Schema({ - email: { type: String }, - username: { type: String, unique: true }, - hashword: { type: String }, - appUserId: { type: String }, - organizationUser: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, -}); - -schema.pre('save', async function () { - if (this.hashword) { - this.hashword = await bcrypt.hashSync( - this.hashword, - parseInt(this.schema.statics.decimals) - ); - } -}); - -schema.static({ - decimals: 10, - update: async function (id, options) { - if ('password' in options) { - options.hashword = await bcrypt.hashSync( - options.password, - parseInt(this.decimals) - ); - delete options.password; - } - return this.findOneAndUpdate({ _id: id }, options, { - new: true, - useFindAndModify: true, - }); - }, - getUserByUsername: async function (username) { - let getByUser; - try { - getByUser = await this.find({ username }); - } catch (e) { - console.log('oops'); - } - - if (getByUser.length > 1) { - throw new Error( - 'Unique username or email? Please reach out to our developers' - ); - } - - if (getByUser.length === 1) { - return getByUser[0]; - } - }, - getUserByAppUserId: async function (appUserId) { - const getByUser = await this.find({ appUserId }); - - if (getByUser.length > 1) { - throw new Error( - 'Supposedly using a unique appUserId? Please reach out to our developers' - ); - } - - if (getByUser.length === 1) { - return getByUser[0]; - } - }, -}); - -const IndividualUser = - Parent.discriminators?.IndividualUser || - Parent.discriminator(collectionName, schema); - -module.exports = { IndividualUser }; diff --git a/packages/core/database/models/OrganizationUser.js b/packages/core/database/models/OrganizationUser.js deleted file mode 100644 index a68da4edd..000000000 --- a/packages/core/database/models/OrganizationUser.js +++ /dev/null @@ -1,31 +0,0 @@ -const { mongoose } = require('../mongoose'); -const { UserModel: Parent } = require('./UserModel'); - -const collectionName = 'OrganizationUser'; - -const schema = new mongoose.Schema({ - appOrgId: { type: String, required: true, unique: true }, - name: { type: String }, -}); - -schema.static({ - getUserByAppOrgId: async function (appOrgId) { - const getByUser = await this.find({ appOrgId }); - - if (getByUser.length > 1) { - throw new Error( - 'Supposedly using a unique appOrgId? Please reach out to our developers' - ); - } - - if (getByUser.length === 1) { - return getByUser[0]; - } - }, -}); - -const OrganizationUser = - Parent.discriminators?.OrganizationUser || - Parent.discriminator(collectionName, schema); - -module.exports = { OrganizationUser }; diff --git a/packages/core/database/models/UserModel.js b/packages/core/database/models/UserModel.js deleted file mode 100644 index 056038da3..000000000 --- a/packages/core/database/models/UserModel.js +++ /dev/null @@ -1,7 +0,0 @@ -const { mongoose } = require('../mongoose'); - -const schema = new mongoose.Schema({}, { timestamps: true }); - -const UserModel = mongoose.models.User || mongoose.model('User', schema); - -module.exports = { UserModel: UserModel }; diff --git a/packages/core/database/models/WebsocketConnection.js b/packages/core/database/models/WebsocketConnection.js deleted file mode 100644 index 973c57114..000000000 --- a/packages/core/database/models/WebsocketConnection.js +++ /dev/null @@ -1,58 +0,0 @@ -const { mongoose } = require('../mongoose'); -const { - ApiGatewayManagementApiClient, - PostToConnectionCommand, -} = require('@aws-sdk/client-apigatewaymanagementapi'); - -const schema = new mongoose.Schema({ - connectionId: { type: mongoose.Schema.Types.String }, -}); - -// Add a static method to get active connections -schema.statics.getActiveConnections = async function () { - try { - // Return empty array if websockets are not configured - if (!process.env.WEBSOCKET_API_ENDPOINT) { - return []; - } - - const connections = await this.find({}, 'connectionId'); - return connections.map((conn) => ({ - connectionId: conn.connectionId, - send: async (data) => { - const apigwManagementApi = new ApiGatewayManagementApiClient({ - endpoint: process.env.WEBSOCKET_API_ENDPOINT, - }); - - try { - const command = new PostToConnectionCommand({ - ConnectionId: conn.connectionId, - Data: JSON.stringify(data), - }); - await apigwManagementApi.send(command); - } catch (error) { - if ( - error.statusCode === 410 || - error.$metadata?.httpStatusCode === 410 - ) { - console.log(`Stale connection ${conn.connectionId}`); - await this.deleteOne({ - connectionId: conn.connectionId, - }); - } else { - throw error; - } - } - }, - })); - } catch (error) { - console.error('Error getting active connections:', error); - throw error; - } -}; - -const WebsocketConnection = - mongoose.models.WebsocketConnection || - mongoose.model('WebsocketConnection', schema); - -module.exports = { WebsocketConnection }; diff --git a/packages/core/database/models/readme.md b/packages/core/database/models/readme.md deleted file mode 100644 index 0ab3467b1..000000000 --- a/packages/core/database/models/readme.md +++ /dev/null @@ -1 +0,0 @@ -// todo: we need to get rid of this entire models folder diff --git a/packages/core/database/mongoose.js b/packages/core/database/mongoose.js deleted file mode 100644 index 1c0aa82de..000000000 --- a/packages/core/database/mongoose.js +++ /dev/null @@ -1,5 +0,0 @@ -const mongoose = require('mongoose'); -mongoose.set('strictQuery', false); -module.exports = { - mongoose, -}; diff --git a/packages/core/database/prisma.js b/packages/core/database/prisma.js index 7a6a810a4..fb26f6c28 100644 --- a/packages/core/database/prisma.js +++ b/packages/core/database/prisma.js @@ -6,6 +6,7 @@ const { } = require('./encryption/encryption-schema-registry'); const { logger } = require('./encryption/logger'); const { Cryptor } = require('../encrypt/Cryptor'); +const path = require('path'); const config = require('./config'); /** @@ -71,13 +72,15 @@ const prismaClientSingleton = () => { const paths = [ // Lambda layer location (when using Prisma Lambda layer) `/opt/nodejs/node_modules/generated/prisma-${dbType}`, + // __dirname-based resolution (works on Netlify + any bundler) + path.resolve(__dirname, `../generated/prisma-${dbType}`), // Local development location (relative to core package) `../generated/prisma-${dbType}`, ]; - for (const path of paths) { + for (const tryPath of paths) { try { - return require(path).PrismaClient; + return require(tryPath).PrismaClient; } catch (err) { // Continue to next path } diff --git a/packages/core/database/repositories/health-check-repository-documentdb.js b/packages/core/database/repositories/health-check-repository-documentdb.js index 46a61d548..a9d1e350b 100644 --- a/packages/core/database/repositories/health-check-repository-documentdb.js +++ b/packages/core/database/repositories/health-check-repository-documentdb.js @@ -8,9 +8,7 @@ const { insertOne, deleteOne, } = require('../documentdb-utils'); -const { - DocumentDBEncryptionService, -} = require('../documentdb-encryption-service'); +const { DocumentDBEncryptionService } = require('../documentdb-encryption-service'); class HealthCheckRepositoryDocumentDB extends HealthCheckRepositoryInterface { /** @@ -51,20 +49,21 @@ class HealthCheckRepositoryDocumentDB extends HealthCheckRepositoryInterface { */ async pingDatabase(maxTimeMS = 2000) { const pingStart = Date.now(); + let timeoutId; - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => reject(new Error('Database ping timeout')), - maxTimeMS - ) - ); - - await Promise.race([ - this.prisma.$runCommandRaw({ ping: 1 }), - timeoutPromise, - ]); + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('Database ping timeout')), maxTimeMS); + }); - return Date.now() - pingStart; + try { + await Promise.race([ + this.prisma.$runCommandRaw({ ping: 1 }), + timeoutPromise, + ]); + return Date.now() - pingStart; + } finally { + clearTimeout(timeoutId); + } } async createCredential(credentialData) { @@ -80,14 +79,8 @@ class HealthCheckRepositoryDocumentDB extends HealthCheckRepositoryInterface { 'Credential', document ); - const insertedId = await insertOne( - this.prisma, - 'Credential', - encryptedDocument - ); - const created = await findOne(this.prisma, 'Credential', { - _id: insertedId, - }); + const insertedId = await insertOne(this.prisma, 'Credential', encryptedDocument); + const created = await findOne(this.prisma, 'Credential', { _id: insertedId }); // Decrypt after read const decrypted = await this.encryptionService.decryptFields( @@ -109,10 +102,7 @@ class HealthCheckRepositoryDocumentDB extends HealthCheckRepositoryInterface { if (!doc) return null; // Decrypt sensitive fields - const decrypted = await this.encryptionService.decryptFields( - 'Credential', - doc - ); + const decrypted = await this.encryptionService.decryptFields('Credential', doc); return { id: fromObjectId(decrypted._id), @@ -138,12 +128,11 @@ class HealthCheckRepositoryDocumentDB extends HealthCheckRepositoryInterface { const objectId = toObjectId(id); if (!objectId) return false; - const result = await deleteOne(this.prisma, 'Credential', { - _id: objectId, - }); + const result = await deleteOne(this.prisma, 'Credential', { _id: objectId }); const deleted = result?.n ?? 0; return deleted > 0; } } module.exports = { HealthCheckRepositoryDocumentDB }; + diff --git a/packages/core/database/repositories/health-check-repository-factory.js b/packages/core/database/repositories/health-check-repository-factory.js index f242c4977..f4ad96896 100644 --- a/packages/core/database/repositories/health-check-repository-factory.js +++ b/packages/core/database/repositories/health-check-repository-factory.js @@ -1,12 +1,6 @@ -const { - HealthCheckRepositoryMongoDB, -} = require('./health-check-repository-mongodb'); const { HealthCheckRepositoryPostgreSQL, } = require('./health-check-repository-postgres'); -const { - HealthCheckRepositoryDocumentDB, -} = require('./health-check-repository-documentdb'); const config = require('../config'); /** @@ -31,12 +25,14 @@ function createHealthCheckRepository({ prismaClient } = {}) { switch (dbType) { case 'mongodb': + const { HealthCheckRepositoryMongoDB } = require('./health-check-repository-mongodb'); return new HealthCheckRepositoryMongoDB({ prismaClient }); case 'postgresql': return new HealthCheckRepositoryPostgreSQL({ prismaClient }); case 'documentdb': + const { HealthCheckRepositoryDocumentDB } = require('./health-check-repository-documentdb'); return new HealthCheckRepositoryDocumentDB({ prismaClient }); default: @@ -48,7 +44,7 @@ function createHealthCheckRepository({ prismaClient } = {}) { module.exports = { createHealthCheckRepository, - HealthCheckRepositoryMongoDB, + get HealthCheckRepositoryMongoDB() { return require('./health-check-repository-mongodb').HealthCheckRepositoryMongoDB; }, HealthCheckRepositoryPostgreSQL, - HealthCheckRepositoryDocumentDB, + get HealthCheckRepositoryDocumentDB() { return require('./health-check-repository-documentdb').HealthCheckRepositoryDocumentDB; }, }; diff --git a/packages/core/database/repositories/health-check-repository-mongodb.js b/packages/core/database/repositories/health-check-repository-mongodb.js index e601367b8..ab1f14d29 100644 --- a/packages/core/database/repositories/health-check-repository-mongodb.js +++ b/packages/core/database/repositories/health-check-repository-mongodb.js @@ -1,4 +1,3 @@ -const { mongoose } = require('../mongoose'); const { HealthCheckRepositoryInterface, } = require('./health-check-repository-interface'); @@ -29,7 +28,6 @@ class HealthCheckRepositoryMongoDB extends HealthCheckRepositoryInterface { } return { - readyState: isConnected ? 1 : 0, readyState: isConnected ? 1 : 0, stateName, isConnected, @@ -38,25 +36,21 @@ class HealthCheckRepositoryMongoDB extends HealthCheckRepositoryInterface { async pingDatabase(maxTimeMS = 2000) { const pingStart = Date.now(); + let timeoutId; - // Create a timeout promise that rejects after maxTimeMS - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => reject(new Error('Database ping timeout')), - maxTimeMS - ) - ); - - // Race between the database ping and the timeout - await Promise.race([ - prisma.$queryRaw`SELECT 1`.catch(() => { - // For MongoDB, use runCommandRaw instead - return prisma.$runCommandRaw({ ping: 1 }); - }), - timeoutPromise, - ]); + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('Database ping timeout')), maxTimeMS); + }); - return Date.now() - pingStart; + try { + await Promise.race([ + this.prisma.$runCommandRaw({ ping: 1 }), + timeoutPromise + ]); + return Date.now() - pingStart; + } finally { + clearTimeout(timeoutId); + } } async createCredential(credentialData) { @@ -72,14 +66,17 @@ class HealthCheckRepositoryMongoDB extends HealthCheckRepositoryInterface { } /** + * Get raw credential from database bypassing Prisma encryption extension. + * Uses findRaw() to query MongoDB directly. * @param {string} id * @returns {Promise} */ async getRawCredentialById(id) { - const { ObjectId } = require('mongodb'); - return await mongoose.connection.db - .collection('Credential') - .findOne({ _id: new ObjectId(id) }); + if (!id) return null; + const results = await this.prisma.credential.findRaw({ + filter: { _id: { $oid: id } }, + }); + return results[0] || null; } async deleteCredential(id) { diff --git a/packages/core/database/repositories/health-check-repository-mongodb.test.js b/packages/core/database/repositories/health-check-repository-mongodb.test.js index fba24f537..2ee3f33fe 100644 --- a/packages/core/database/repositories/health-check-repository-mongodb.test.js +++ b/packages/core/database/repositories/health-check-repository-mongodb.test.js @@ -1,6 +1,4 @@ -const { - HealthCheckRepositoryMongoDB, -} = require('./health-check-repository-mongodb'); +const { HealthCheckRepositoryMongoDB } = require('./health-check-repository-mongodb'); describe('HealthCheckRepositoryMongoDB', () => { let repository; @@ -9,11 +7,16 @@ describe('HealthCheckRepositoryMongoDB', () => { beforeEach(() => { mockPrismaClient = { $runCommandRaw: jest.fn(), - $queryRaw: jest.fn(), + credential: { + findRaw: jest.fn(), + create: jest.fn(), + findUnique: jest.fn(), + delete: jest.fn(), + }, }; - - repository = new HealthCheckRepositoryMongoDB({ - prismaClient: mockPrismaClient, + + repository = new HealthCheckRepositoryMongoDB({ + prismaClient: mockPrismaClient }); }); @@ -28,15 +31,11 @@ describe('HealthCheckRepositoryMongoDB', () => { stateName: 'connected', isConnected: true, }); - expect(mockPrismaClient.$runCommandRaw).toHaveBeenCalledWith({ - ping: 1, - }); + expect(mockPrismaClient.$runCommandRaw).toHaveBeenCalledWith({ ping: 1 }); }); it('should return disconnected state when ping fails', async () => { - mockPrismaClient.$runCommandRaw.mockRejectedValue( - new Error('Connection failed') - ); + mockPrismaClient.$runCommandRaw.mockRejectedValue(new Error('Connection failed')); const result = await repository.getDatabaseConnectionState(); @@ -45,15 +44,11 @@ describe('HealthCheckRepositoryMongoDB', () => { stateName: 'disconnected', isConnected: false, }); - expect(mockPrismaClient.$runCommandRaw).toHaveBeenCalledWith({ - ping: 1, - }); + expect(mockPrismaClient.$runCommandRaw).toHaveBeenCalledWith({ ping: 1 }); }); it('should return disconnected state when ping throws network error', async () => { - mockPrismaClient.$runCommandRaw.mockRejectedValue( - new Error('ECONNREFUSED') - ); + mockPrismaClient.$runCommandRaw.mockRejectedValue(new Error('ECONNREFUSED')); const result = await repository.getDatabaseConnectionState(); @@ -65,9 +60,7 @@ describe('HealthCheckRepositoryMongoDB', () => { }); it('should return disconnected state when ping times out', async () => { - mockPrismaClient.$runCommandRaw.mockRejectedValue( - new Error('Timeout') - ); + mockPrismaClient.$runCommandRaw.mockRejectedValue(new Error('Timeout')); const result = await repository.getDatabaseConnectionState(); @@ -78,41 +71,25 @@ describe('HealthCheckRepositoryMongoDB', () => { describe('pingDatabase()', () => { it('should return response time when ping succeeds', async () => { - mockPrismaClient.$queryRaw.mockRejectedValue( - new Error('Not MongoDB') - ); mockPrismaClient.$runCommandRaw.mockResolvedValue({ ok: 1 }); const responseTime = await repository.pingDatabase(2000); expect(typeof responseTime).toBe('number'); expect(responseTime).toBeGreaterThanOrEqual(0); - expect(mockPrismaClient.$runCommandRaw).toHaveBeenCalledWith({ - ping: 1, - }); + expect(mockPrismaClient.$runCommandRaw).toHaveBeenCalledWith({ ping: 1 }); }); it('should throw error when ping fails', async () => { const error = new Error('Database unreachable'); - mockPrismaClient.$queryRaw.mockRejectedValue( - new Error('Not MongoDB') - ); mockPrismaClient.$runCommandRaw.mockRejectedValue(error); - await expect(repository.pingDatabase(2000)).rejects.toThrow( - 'Database unreachable' - ); + await expect(repository.pingDatabase(2000)).rejects.toThrow('Database unreachable'); }); it('should measure actual response time', async () => { - mockPrismaClient.$queryRaw.mockRejectedValue( - new Error('Not MongoDB') - ); - mockPrismaClient.$runCommandRaw.mockImplementation( - () => - new Promise((resolve) => - setTimeout(() => resolve({ ok: 1 }), 50) - ) + mockPrismaClient.$runCommandRaw.mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve({ ok: 1 }), 50)) ); const responseTime = await repository.pingDatabase(2000); @@ -120,5 +97,83 @@ describe('HealthCheckRepositoryMongoDB', () => { expect(responseTime).toBeGreaterThanOrEqual(50); expect(responseTime).toBeLessThan(200); }); + + it('should reject with timeout error when ping exceeds maxTimeMS', async () => { + mockPrismaClient.$runCommandRaw.mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve({ ok: 1 }), 500)) + ); + + await expect(repository.pingDatabase(50)).rejects.toThrow('Database ping timeout'); + }); + }); + + describe('getRawCredentialById()', () => { + it('should return null when id is falsy', async () => { + const result = await repository.getRawCredentialById(null); + expect(result).toBeNull(); + expect(mockPrismaClient.credential.findRaw).not.toHaveBeenCalled(); + }); + + it('should return the first result from findRaw', async () => { + const mockCredential = { _id: '123', data: { access_token: 'tok' } }; + mockPrismaClient.credential.findRaw.mockResolvedValue([mockCredential]); + + const result = await repository.getRawCredentialById('123'); + + expect(result).toEqual(mockCredential); + expect(mockPrismaClient.credential.findRaw).toHaveBeenCalledWith({ + filter: { _id: { $oid: '123' } }, + }); + }); + + it('should return null when findRaw returns empty array', async () => { + mockPrismaClient.credential.findRaw.mockResolvedValue([]); + + const result = await repository.getRawCredentialById('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('createCredential()', () => { + it('should delegate to prisma.credential.create', async () => { + const credentialData = { userId: 'u1', authIsValid: true }; + const created = { id: 'c1', ...credentialData }; + mockPrismaClient.credential.create.mockResolvedValue(created); + + const result = await repository.createCredential(credentialData); + + expect(result).toEqual(created); + expect(mockPrismaClient.credential.create).toHaveBeenCalledWith({ + data: credentialData, + }); + }); + }); + + describe('findCredentialById()', () => { + it('should delegate to prisma.credential.findUnique', async () => { + const credential = { id: 'c1', userId: 'u1' }; + mockPrismaClient.credential.findUnique.mockResolvedValue(credential); + + const result = await repository.findCredentialById('c1'); + + expect(result).toEqual(credential); + expect(mockPrismaClient.credential.findUnique).toHaveBeenCalledWith({ + where: { id: 'c1' }, + }); + }); + }); + + describe('deleteCredential()', () => { + it('should delegate to prisma.credential.delete', async () => { + mockPrismaClient.credential.delete.mockResolvedValue(undefined); + + await repository.deleteCredential('c1'); + + expect(mockPrismaClient.credential.delete).toHaveBeenCalledWith({ + where: { id: 'c1' }, + }); + }); }); }); + diff --git a/packages/core/database/repositories/health-check-repository.js b/packages/core/database/repositories/health-check-repository.js deleted file mode 100644 index b973730de..000000000 --- a/packages/core/database/repositories/health-check-repository.js +++ /dev/null @@ -1,108 +0,0 @@ -const { prisma } = require('../prisma'); -const { mongoose } = require('../mongoose'); -const { - HealthCheckRepositoryInterface, -} = require('./health-check-repository-interface'); - -/** - * Repository for Health Check database operations. - * Provides atomic database operations for health testing. - * - * Follows DDD/Hexagonal Architecture: - * - Infrastructure Layer (this repository) - * - Pure database operations only, no business logic - * - Used by Application Layer (Use Cases) - * - * Works identically for both MongoDB and PostgreSQL: - * - Uses Prisma for database operations - * - Encryption happens transparently via Prisma extension - * - Both MongoDB and PostgreSQL use same Prisma API - * - * Migration from Mongoose to Prisma: - * - Replaced Mongoose models with Prisma client - * - Uses Credential model for encryption testing - * - Maintains same method signatures for compatibility - */ -class HealthCheckRepository extends HealthCheckRepositoryInterface { - constructor() { - super(); - } - - /** - * Get database connection state - * @returns {Object} Object with readyState, stateName, and isConnected - */ - getDatabaseConnectionState() { - const stateMap = { - 0: 'disconnected', - 1: 'connected', - 2: 'connecting', - 3: 'disconnecting', - }; - const readyState = mongoose.connection.readyState; - - return { - readyState, - stateName: stateMap[readyState], - isConnected: readyState === 1, - }; - } - - /** - * Ping the database to verify connectivity - * @param {number} maxTimeMS - Maximum time to wait for ping response - * @returns {Promise} Response time in milliseconds - * @throws {Error} If database is not connected or ping fails - */ - async pingDatabase(maxTimeMS = 2000) { - const pingStart = Date.now(); - await mongoose.connection.db.admin().ping({ maxTimeMS }); - return Date.now() - pingStart; - } - - /** - * Create a test credential for encryption testing - * @param {Object} credentialData - Credential data to create - * @returns {Promise} Created credential - */ - async createCredential(credentialData) { - return await prisma.credential.create({ - data: credentialData, - }); - } - - /** - * Find a credential by ID - * @param {string} id - Credential ID - * @returns {Promise} Found credential or null - */ - async findCredentialById(id) { - return await prisma.credential.findUnique({ - where: { id }, - }); - } - - /** - * Get raw credential from database bypassing Prisma encryption extension - * @param {string} id - Credential ID - * @returns {Promise} Raw credential from database - */ - async getRawCredentialById(id) { - return await mongoose.connection.db - .collection('credentials') - .findOne({ _id: id }); - } - - /** - * Delete a credential by ID - * @param {string} id - Credential ID - * @returns {Promise} - */ - async deleteCredential(id) { - await prisma.credential.delete({ - where: { id }, - }); - } -} - -module.exports = { HealthCheckRepository }; diff --git a/packages/core/database/utils/mongodb-collection-utils.js b/packages/core/database/utils/mongodb-collection-utils.js index 9fd382bfd..191f792d5 100644 --- a/packages/core/database/utils/mongodb-collection-utils.js +++ b/packages/core/database/utils/mongodb-collection-utils.js @@ -5,11 +5,13 @@ * handling the constraint that collections cannot be created inside * multi-document transactions. * + * Uses Prisma's $runCommandRaw to execute MongoDB admin commands. + * * @see https://github.com/prisma/prisma/issues/8305 * @see https://www.mongodb.com/docs/manual/core/transactions/#transactions-and-operations */ -const { mongoose } = require('../mongoose'); +const { prisma } = require('../prisma'); /** * Ensures a MongoDB collection exists @@ -30,20 +32,19 @@ const { mongoose } = require('../mongoose'); */ async function ensureCollectionExists(collectionName) { try { - const collections = await mongoose.connection.db - .listCollections({ name: collectionName }) - .toArray(); + const result = await prisma.$runCommandRaw({ + listCollections: 1, + filter: { name: collectionName }, + }); + + const collections = result.cursor?.firstBatch || []; if (collections.length === 0) { - // Collection doesn't exist, create it outside of any transaction - await mongoose.connection.db.createCollection(collectionName); + await prisma.$runCommandRaw({ create: collectionName }); console.log(`Created MongoDB collection: ${collectionName}`); } } catch (error) { - // Collection might already exist due to race condition, or other error - // Log warning but don't fail - let subsequent operations handle errors if (error.codeName === 'NamespaceExists') { - // This is expected in race conditions, silently continue return; } console.warn( @@ -78,10 +79,12 @@ async function ensureCollectionsExist(collectionNames) { */ async function collectionExists(collectionName) { try { - const collections = await mongoose.connection.db - .listCollections({ name: collectionName }) - .toArray(); + const result = await prisma.$runCommandRaw({ + listCollections: 1, + filter: { name: collectionName }, + }); + const collections = result.cursor?.firstBatch || []; return collections.length > 0; } catch (error) { console.error( diff --git a/packages/core/database/utils/mongodb-collection-utils.test.js b/packages/core/database/utils/mongodb-collection-utils.test.js index c8f0eb18c..8a59469d8 100644 --- a/packages/core/database/utils/mongodb-collection-utils.test.js +++ b/packages/core/database/utils/mongodb-collection-utils.test.js @@ -2,26 +2,17 @@ * Tests for MongoDB Collection Utilities */ +jest.mock('../prisma', () => ({ + prisma: { $runCommandRaw: jest.fn() }, +})); + +const { prisma: mockPrisma } = require('../prisma'); const { ensureCollectionExists, ensureCollectionsExist, collectionExists, } = require('./mongodb-collection-utils'); -// Mock mongoose -const mockMongoose = { - connection: { - db: { - listCollections: jest.fn(), - createCollection: jest.fn(), - }, - }, -}; - -jest.mock('../mongoose', () => ({ - mongoose: mockMongoose, -})); - describe('MongoDB Collection Utilities', () => { beforeEach(() => { jest.clearAllMocks(); @@ -30,76 +21,57 @@ describe('MongoDB Collection Utilities', () => { describe('ensureCollectionExists', () => { it('should create collection if it does not exist', async () => { // Mock: collection doesn't exist - mockMongoose.connection.db.listCollections.mockReturnValue({ - toArray: jest.fn().mockResolvedValue([]), - }); - mockMongoose.connection.db.createCollection.mockResolvedValue(true); + mockPrisma.$runCommandRaw + .mockResolvedValueOnce({ cursor: { firstBatch: [] } }) // listCollections + .mockResolvedValueOnce({ ok: 1 }); // create await ensureCollectionExists('TestCollection'); - expect( - mockMongoose.connection.db.listCollections - ).toHaveBeenCalledWith({ - name: 'TestCollection', + expect(mockPrisma.$runCommandRaw).toHaveBeenCalledWith({ + listCollections: 1, + filter: { name: 'TestCollection' }, + }); + expect(mockPrisma.$runCommandRaw).toHaveBeenCalledWith({ + create: 'TestCollection', }); - expect( - mockMongoose.connection.db.createCollection - ).toHaveBeenCalledWith('TestCollection'); }); it('should not create collection if it already exists', async () => { // Mock: collection exists - mockMongoose.connection.db.listCollections.mockReturnValue({ - toArray: jest - .fn() - .mockResolvedValue([{ name: 'TestCollection' }]), + mockPrisma.$runCommandRaw.mockResolvedValueOnce({ + cursor: { firstBatch: [{ name: 'TestCollection' }] }, }); await ensureCollectionExists('TestCollection'); - expect( - mockMongoose.connection.db.listCollections - ).toHaveBeenCalledWith({ - name: 'TestCollection', + expect(mockPrisma.$runCommandRaw).toHaveBeenCalledWith({ + listCollections: 1, + filter: { name: 'TestCollection' }, }); - expect( - mockMongoose.connection.db.createCollection - ).not.toHaveBeenCalled(); + expect(mockPrisma.$runCommandRaw).toHaveBeenCalledTimes(1); }); it('should not throw if collection creation fails with NamespaceExists error', async () => { // Mock: collection doesn't exist in list, but creation fails (race condition) - mockMongoose.connection.db.listCollections.mockReturnValue({ - toArray: jest.fn().mockResolvedValue([]), + mockPrisma.$runCommandRaw.mockResolvedValueOnce({ + cursor: { firstBatch: [] }, }); const error = new Error('Collection already exists'); error.codeName = 'NamespaceExists'; - mockMongoose.connection.db.createCollection.mockRejectedValue( - error - ); + mockPrisma.$runCommandRaw.mockRejectedValueOnce(error); // Should not throw - await expect( - ensureCollectionExists('TestCollection') - ).resolves.not.toThrow(); + await expect(ensureCollectionExists('TestCollection')).resolves.not.toThrow(); }); it('should log warning on other errors but not throw', async () => { - const consoleWarnSpy = jest - .spyOn(console, 'warn') - .mockImplementation(); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); // Mock: listCollections fails - mockMongoose.connection.db.listCollections.mockReturnValue({ - toArray: jest - .fn() - .mockRejectedValue(new Error('Connection error')), - }); + mockPrisma.$runCommandRaw.mockRejectedValueOnce(new Error('Connection error')); // Should not throw - await expect( - ensureCollectionExists('TestCollection') - ).resolves.not.toThrow(); + await expect(ensureCollectionExists('TestCollection')).resolves.not.toThrow(); expect(consoleWarnSpy).toHaveBeenCalled(); consoleWarnSpy.mockRestore(); @@ -109,38 +81,37 @@ describe('MongoDB Collection Utilities', () => { describe('ensureCollectionsExist', () => { it('should ensure multiple collections exist', async () => { // Mock: no collections exist - mockMongoose.connection.db.listCollections.mockReturnValue({ - toArray: jest.fn().mockResolvedValue([]), + mockPrisma.$runCommandRaw.mockImplementation((cmd) => { + if (cmd.listCollections) { + return Promise.resolve({ cursor: { firstBatch: [] } }); + } + if (cmd.create) { + return Promise.resolve({ ok: 1 }); + } }); - mockMongoose.connection.db.createCollection.mockResolvedValue(true); - - await ensureCollectionsExist([ - 'Collection1', - 'Collection2', - 'Collection3', - ]); - - expect( - mockMongoose.connection.db.createCollection - ).toHaveBeenCalledTimes(3); - expect( - mockMongoose.connection.db.createCollection - ).toHaveBeenCalledWith('Collection1'); - expect( - mockMongoose.connection.db.createCollection - ).toHaveBeenCalledWith('Collection2'); - expect( - mockMongoose.connection.db.createCollection - ).toHaveBeenCalledWith('Collection3'); + + await ensureCollectionsExist(['Collection1', 'Collection2', 'Collection3']); + + // 3 listCollections + 3 creates = 6 calls + const createCalls = mockPrisma.$runCommandRaw.mock.calls.filter( + ([cmd]) => cmd.create + ); + expect(createCalls).toHaveLength(3); + const createCommands = createCalls.map(([cmd]) => cmd); + expect(createCommands).toEqual( + expect.arrayContaining([ + { create: 'Collection1' }, + { create: 'Collection2' }, + { create: 'Collection3' }, + ]) + ); }); }); describe('collectionExists', () => { it('should return true if collection exists', async () => { - mockMongoose.connection.db.listCollections.mockReturnValue({ - toArray: jest - .fn() - .mockResolvedValue([{ name: 'TestCollection' }]), + mockPrisma.$runCommandRaw.mockResolvedValueOnce({ + cursor: { firstBatch: [{ name: 'TestCollection' }] }, }); const exists = await collectionExists('TestCollection'); @@ -149,8 +120,8 @@ describe('MongoDB Collection Utilities', () => { }); it('should return false if collection does not exist', async () => { - mockMongoose.connection.db.listCollections.mockReturnValue({ - toArray: jest.fn().mockResolvedValue([]), + mockPrisma.$runCommandRaw.mockResolvedValueOnce({ + cursor: { firstBatch: [] }, }); const exists = await collectionExists('TestCollection'); @@ -159,15 +130,9 @@ describe('MongoDB Collection Utilities', () => { }); it('should return false on error', async () => { - const consoleErrorSpy = jest - .spyOn(console, 'error') - .mockImplementation(); - - mockMongoose.connection.db.listCollections.mockReturnValue({ - toArray: jest - .fn() - .mockRejectedValue(new Error('Connection error')), - }); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + mockPrisma.$runCommandRaw.mockRejectedValueOnce(new Error('Connection error')); const exists = await collectionExists('TestCollection'); diff --git a/packages/core/database/utils/mongodb-schema-init.js b/packages/core/database/utils/mongodb-schema-init.js index 8780d1f8a..727077924 100644 --- a/packages/core/database/utils/mongodb-schema-init.js +++ b/packages/core/database/utils/mongodb-schema-init.js @@ -16,7 +16,7 @@ * @see https://www.mongodb.com/docs/manual/core/transactions/#transactions-and-operations */ -const { mongoose } = require('../mongoose'); +const { prisma } = require('../prisma'); const { ensureCollectionsExist } = require('./mongodb-collection-utils'); const { getCollectionsFromSchemaSync } = require('./prisma-schema-parser'); const config = require('../config'); @@ -55,8 +55,10 @@ async function initializeMongoDBSchema() { return; } - // Check if database is connected - if (mongoose.connection.readyState !== 1) { + // Verify database connectivity via Prisma ping + try { + await prisma.$runCommandRaw({ ping: 1 }); + } catch (error) { throw new Error( 'Cannot initialize MongoDB schema - database not connected. ' + 'Call connectPrisma() before initializeMongoDBSchema()' diff --git a/packages/core/database/utils/mongodb-schema-init.test.js b/packages/core/database/utils/mongodb-schema-init.test.js index 821c4ddaf..2304f1aaf 100644 --- a/packages/core/database/utils/mongodb-schema-init.test.js +++ b/packages/core/database/utils/mongodb-schema-init.test.js @@ -2,18 +2,6 @@ * Tests for MongoDB Schema Initialization */ -const { - initializeMongoDBSchema, - getPrismaCollections, -} = require('./mongodb-schema-init'); - -// Mock dependencies -const mockMongoose = { - connection: { - readyState: 1, // connected - }, -}; - const mockEnsureCollectionsExist = jest.fn().mockResolvedValue(undefined); const mockGetCollectionsFromSchemaSync = jest .fn() @@ -33,8 +21,12 @@ const mockGetCollectionsFromSchemaSync = jest 'WebsocketConnection', ]); -jest.mock('../mongoose', () => ({ - mongoose: mockMongoose, +const mockConfig = { + DB_TYPE: 'mongodb', +}; + +jest.mock('../prisma', () => ({ + prisma: { $runCommandRaw: jest.fn().mockResolvedValue({ ok: 1 }) }, })); jest.mock('./mongodb-collection-utils', () => ({ @@ -45,17 +37,19 @@ jest.mock('./prisma-schema-parser', () => ({ getCollectionsFromSchemaSync: mockGetCollectionsFromSchemaSync, })); -const mockConfig = { - DB_TYPE: 'mongodb', -}; - jest.mock('../config', () => mockConfig); +const { prisma: mockPrisma } = require('../prisma'); +const { + initializeMongoDBSchema, + getPrismaCollections, +} = require('./mongodb-schema-init'); + describe('MongoDB Schema Initialization', () => { beforeEach(() => { jest.clearAllMocks(); mockConfig.DB_TYPE = 'mongodb'; - mockMongoose.connection.readyState = 1; + mockPrisma.$runCommandRaw.mockResolvedValue({ ok: 1 }); console.log = jest.fn(); console.error = jest.fn(); console.warn = jest.fn(); @@ -116,8 +110,16 @@ describe('MongoDB Schema Initialization', () => { ); }); + it('should initialize for DocumentDB', async () => { + mockConfig.DB_TYPE = 'documentdb'; + + await initializeMongoDBSchema(); + + expect(mockEnsureCollectionsExist).toHaveBeenCalled(); + }); + it('should throw error if database not connected', async () => { - mockMongoose.connection.readyState = 0; // disconnected + mockPrisma.$runCommandRaw.mockRejectedValueOnce(new Error('Connection refused')); await expect(initializeMongoDBSchema()).rejects.toThrow( 'Cannot initialize MongoDB schema - database not connected' diff --git a/packages/core/encrypt/Cryptor.js b/packages/core/encrypt/Cryptor.js index f6a65277f..3dcfbf12f 100644 --- a/packages/core/encrypt/Cryptor.js +++ b/packages/core/encrypt/Cryptor.js @@ -1,64 +1,75 @@ /** * Cryptor - Encryption Service Adapter * - * Infrastructure Layer adapter for AWS KMS and local AES encryption. - * Provides envelope encryption pattern for field-level encryption. + * Infrastructure Layer adapter for envelope encryption. + * Key management is delegated to an EncryptionKeyProviderInterface adapter: + * - KmsEncryptionKeyProvider (in @friggframework/provider-aws) for AWS KMS + * - AesEncryptionKeyProvider (in core) for local AES keys * * Envelope Encryption Pattern: - * 1. Generate Data Encryption Key (DEK) via KMS or locally + * 1. Generate Data Encryption Key (DEK) via key provider * 2. Encrypt field value with DEK using AES-256-CTR - * 3. Encrypt DEK with Master Key (KMS CMK or AES_KEY) + * 3. Store encrypted DEK alongside ciphertext * 4. Return format: "keyId:encryptedText:encryptedKey" * - * Benefits: - * - Reduces KMS API calls (unique DEK per operation) - * - Master key never leaves KMS - * - Enables key rotation without re-encrypting data + * BREAKING CHANGE (v3): A keyProvider must be explicitly provided, + * or pass { shouldUseAws: false } to auto-create AesEncryptionKeyProvider. + * For AWS KMS, pass `new KmsEncryptionKeyProvider()` from @friggframework/provider-aws. + * See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide. */ -const crypto = require('crypto'); -const { - KMSClient, - GenerateDataKeyCommand, - DecryptCommand, -} = require('@aws-sdk/client-kms'); const aes = require('./aes'); class Cryptor { - constructor({ shouldUseAws }) { - this.shouldUseAws = shouldUseAws; - } - - async generateDataKey() { - if (this.shouldUseAws) { - const kmsClient = new KMSClient({}); - const command = new GenerateDataKeyCommand({ - KeyId: process.env.KMS_KEY_ARN, - KeySpec: 'AES_256', - }); - const dataKey = await kmsClient.send(command); - - const keyId = Buffer.from(dataKey.KeyId).toString('base64'); - const encryptedKey = Buffer.from(dataKey.CiphertextBlob).toString( - 'base64' + /** + * @param {Object} options + * @param {boolean} [options.shouldUseAws] - true requires explicit keyProvider; false auto-creates AesEncryptionKeyProvider + * @param {EncryptionKeyProviderInterface} [options.keyProvider] - Explicit key provider (takes precedence) + */ + constructor({ shouldUseAws, keyProvider } = {}) { + if (keyProvider) { + this._keyProvider = keyProvider; + } else if (shouldUseAws) { + throw new Error( + 'Cryptor with shouldUseAws=true requires an explicit keyProvider. Pass one via constructor options, e.g.:\n' + + ' const { KmsEncryptionKeyProvider } = require("@friggframework/provider-aws");\n' + + ' new Cryptor({ shouldUseAws: true, keyProvider: new KmsEncryptionKeyProvider() })\n' + + 'See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide.' ); - const plaintext = dataKey.Plaintext; - return { keyId, encryptedKey, plaintext }; + } else { + // AES mode — no AWS dependency needed + const { AesEncryptionKeyProvider } = require('./aes-encryption-key-provider'); + this._keyProvider = new AesEncryptionKeyProvider(); } + this._shouldUseAws = shouldUseAws; + } - const { AES_KEY, AES_KEY_ID } = process.env; - const randomKey = crypto.randomBytes(32).toString('hex').slice(0, 32); + /** + * Get the key provider. + * @returns {EncryptionKeyProviderInterface} + */ + _getKeyProvider() { + return this._keyProvider; + } - return { - keyId: Buffer.from(AES_KEY_ID).toString('base64'), - encryptedKey: Buffer.from(aes.encrypt(randomKey, AES_KEY)).toString( - 'base64' - ), - plaintext: randomKey, - }; + async generateDataKey() { + return this._getKeyProvider().generateDataKey(); } + /** + * Look up an AES key by identifier from environment variables. + * Kept for backward compatibility. + * + * @param {string} keyId - Key identifier + * @returns {string} The master key + */ getKeyFromEnvironment(keyId) { + const provider = this._getKeyProvider(); + if (typeof provider.getKeyFromEnvironment === 'function') { + return provider.getKeyFromEnvironment(keyId); + } + + // Fallback for providers that don't implement getKeyFromEnvironment const availableKeys = { [process.env.AES_KEY_ID]: process.env.AES_KEY, [process.env.DEPRECATED_AES_KEY_ID]: process.env.DEPRECATED_AES_KEY, @@ -74,19 +85,7 @@ class Cryptor { } async decryptDataKey(keyId, encryptedKey) { - if (this.shouldUseAws) { - const kmsClient = new KMSClient({}); - const command = new DecryptCommand({ - KeyId: keyId, - CiphertextBlob: encryptedKey, - }); - const dataKey = await kmsClient.send(command); - - return dataKey.Plaintext; - } - - const key = this.getKeyFromEnvironment(keyId); - return aes.decrypt(encryptedKey, key); + return this._getKeyProvider().decryptDataKey(keyId, encryptedKey); } async encrypt(text) { diff --git a/packages/core/encrypt/Cryptor.test.js b/packages/core/encrypt/Cryptor.test.js index 09fdfbe39..44d9c29a5 100644 --- a/packages/core/encrypt/Cryptor.test.js +++ b/packages/core/encrypt/Cryptor.test.js @@ -1,134 +1,131 @@ /** - * Tests for Cryptor - AWS SDK v3 Migration + * Tests for Cryptor - Provider-Agnostic Encryption * - * Tests KMS encryption/decryption operations using aws-sdk-client-mock + * Tests encryption/decryption using mock and real key providers. + * No AWS SDK dependency — key providers are injected via constructor. */ -const { mockClient } = require('aws-sdk-client-mock'); -const { - KMSClient, - GenerateDataKeyCommand, - DecryptCommand, -} = require('@aws-sdk/client-kms'); const { Cryptor } = require('./Cryptor'); -describe('Cryptor - AWS SDK v3', () => { - let kmsMock; +describe('Cryptor', () => { const originalEnv = process.env; beforeEach(() => { - kmsMock = mockClient(KMSClient); jest.clearAllMocks(); process.env = { ...originalEnv }; }); afterEach(() => { - kmsMock.reset(); process.env = originalEnv; }); - describe('KMS Mode (shouldUseAws: true)', () => { - beforeEach(() => { - process.env.KMS_KEY_ARN = - 'arn:aws:kms:us-east-1:123456789:key/test-key-id'; + describe('Constructor validation', () => { + it('should throw if shouldUseAws=true without keyProvider', () => { + expect(() => new Cryptor({ shouldUseAws: true })).toThrow( + 'Cryptor with shouldUseAws=true requires an explicit keyProvider' + ); }); - describe('encrypt()', () => { - it('should encrypt text using KMS data key', async () => { - const mockPlaintext = Buffer.from( - 'mock-plaintext-key-32-bytes-long' - ); - const mockCiphertextBlob = Buffer.from('mock-encrypted-key'); - - kmsMock.on(GenerateDataKeyCommand).resolves({ - KeyId: 'test-key-id', - Plaintext: mockPlaintext, - CiphertextBlob: mockCiphertextBlob, - }); - - const cryptor = new Cryptor({ shouldUseAws: true }); - const result = await cryptor.encrypt('sensitive-data'); - - // Result should be in format: "keyId:encryptedText:encryptedKey" - expect(result).toBeDefined(); - expect(result.split(':').length).toBe(4); // keyId:iv:ciphertext:encryptedKey format from aes - - expect(kmsMock.calls()).toHaveLength(1); - const call = kmsMock.call(0); - expect(call.args[0].input).toMatchObject({ - KeyId: process.env.KMS_KEY_ARN, - KeySpec: 'AES_256', - }); - }); + it('should accept explicit keyProvider with shouldUseAws=true', () => { + const mockProvider = { + generateDataKey: jest.fn(), + decryptDataKey: jest.fn(), + }; + + expect( + () => + new Cryptor({ + shouldUseAws: true, + keyProvider: mockProvider, + }) + ).not.toThrow(); + }); + + it('should default to AES mode when shouldUseAws is false', () => { + process.env.AES_KEY = 'test-aes-key-exactly-32-bytes!!!'; + process.env.AES_KEY_ID = 'local-key-id'; - it('should handle KMS errors during encryption', async () => { - kmsMock - .on(GenerateDataKeyCommand) - .rejects(new Error('KMS unavailable')); + expect(() => new Cryptor({ shouldUseAws: false })).not.toThrow(); + }); - const cryptor = new Cryptor({ shouldUseAws: true }); + it('should default to AES mode when no options provided', () => { + process.env.AES_KEY = 'test-aes-key-exactly-32-bytes!!!'; + process.env.AES_KEY_ID = 'local-key-id'; - await expect(cryptor.encrypt('sensitive-data')).rejects.toThrow( - 'KMS unavailable' - ); - }); + expect(() => new Cryptor()).not.toThrow(); }); + }); - describe('decrypt()', () => { - it('should decrypt text using KMS', async () => { - const mockPlaintext = Buffer.from('mock-plaintext-key'); + describe('With mock keyProvider (simulating KMS)', () => { + let mockProvider; + let cryptor; - kmsMock.on(DecryptCommand).resolves({ - Plaintext: mockPlaintext, - }); + beforeEach(() => { + const mockDataKey = Buffer.from( + 'mock-data-key-32-bytes-exactly!!' + ); + const mockEncryptedKey = Buffer.from('mock-encrypted-key'); + + mockProvider = { + generateDataKey: jest.fn().mockResolvedValue({ + keyId: Buffer.from('test-key-id').toString('base64'), + encryptedKey: + Buffer.from(mockEncryptedKey).toString('base64'), + plaintext: mockDataKey, + }), + decryptDataKey: jest.fn().mockResolvedValue(mockDataKey), + }; + + cryptor = new Cryptor({ + shouldUseAws: true, + keyProvider: mockProvider, + }); + }); - const cryptor = new Cryptor({ shouldUseAws: true }); + it('should encrypt text using injected key provider', async () => { + const result = await cryptor.encrypt('sensitive-data'); - // First encrypt some data - const mockDataKey = Buffer.from( - 'test-key-32-bytes-long-exactly' - ); - kmsMock.on(GenerateDataKeyCommand).resolves({ - KeyId: 'test-key-id', - Plaintext: mockDataKey, - CiphertextBlob: Buffer.from('encrypted-key'), - }); + expect(result).toBeDefined(); + // Format: "keyId:iv:ciphertext:encryptedKey" + expect(result.split(':').length).toBe(4); + expect(mockProvider.generateDataKey).toHaveBeenCalledTimes(1); + }); - const encrypted = await cryptor.encrypt('test-data'); + it('should decrypt text using injected key provider', async () => { + const encrypted = await cryptor.encrypt('test-data'); + const decrypted = await cryptor.decrypt(encrypted); - // Then decrypt - kmsMock.reset(); - kmsMock.on(DecryptCommand).resolves({ - Plaintext: mockDataKey, - }); + expect(decrypted).toBe('test-data'); + expect(mockProvider.decryptDataKey).toHaveBeenCalledTimes(1); + }); - const decrypted = await cryptor.decrypt(encrypted); + it('should handle key provider errors during encryption', async () => { + mockProvider.generateDataKey.mockRejectedValue( + new Error('KMS unavailable') + ); - expect(decrypted).toBe('test-data'); - expect(kmsMock.calls()).toHaveLength(1); - }); + await expect(cryptor.encrypt('sensitive-data')).rejects.toThrow( + 'KMS unavailable' + ); + }); - it('should handle KMS errors during decryption', async () => { - kmsMock - .on(DecryptCommand) - .rejects(new Error('Invalid ciphertext')); + it('should handle key provider errors during decryption', async () => { + const encrypted = await cryptor.encrypt('test-data'); - const cryptor = new Cryptor({ shouldUseAws: true }); - const fakeEncrypted = - Buffer.from('test-key-id').toString('base64') + - ':fake:data:' + - Buffer.from('fake-key').toString('base64'); + mockProvider.decryptDataKey.mockRejectedValue( + new Error('Invalid ciphertext') + ); - await expect(cryptor.decrypt(fakeEncrypted)).rejects.toThrow( - 'Invalid ciphertext' - ); - }); + await expect(cryptor.decrypt(encrypted)).rejects.toThrow( + 'Invalid ciphertext' + ); }); }); - describe('Local Mode (shouldUseAws: false)', () => { + describe('AES Mode (shouldUseAws: false)', () => { beforeEach(() => { - process.env.AES_KEY = 'test-aes-key-32-bytes-long-123'; + // AES-256-CTR requires exactly 32-byte key + process.env.AES_KEY = 'test-aes-key-exactly-32-bytes!!!'; process.env.AES_KEY_ID = 'local-key-id'; }); @@ -137,8 +134,7 @@ describe('Cryptor - AWS SDK v3', () => { const result = await cryptor.encrypt('sensitive-data'); expect(result).toBeDefined(); - expect(result.split(':').length).toBeGreaterThanOrEqual(3); - expect(kmsMock.calls()).toHaveLength(0); // Should not call KMS + expect(result.split(':').length).toBe(4); }); it('should decrypt using local AES key', async () => { @@ -148,16 +144,33 @@ describe('Cryptor - AWS SDK v3', () => { const decrypted = await cryptor.decrypt(encrypted); expect(decrypted).toBe('test-data'); - expect(kmsMock.calls()).toHaveLength(0); // Should not call KMS }); - it('should throw error if encryption key not found', async () => { - delete process.env.AES_KEY_ID; + it('should handle round-trip with special characters', async () => { + const cryptor = new Cryptor({ shouldUseAws: false }); + + const testData = + 'Hello, World! 🌍 Special chars: <>&"\' 日本語テスト'; + const encrypted = await cryptor.encrypt(testData); + const decrypted = await cryptor.decrypt(encrypted); + + expect(decrypted).toBe(testData); + }); + it('should throw error if encryption key not found during decrypt', async () => { const cryptor = new Cryptor({ shouldUseAws: false }); - const fakeEncrypted = 'unknown-key:data:key'; - await expect(cryptor.decrypt(fakeEncrypted)).rejects.toThrow( + // Encrypt with current key + const encrypted = await cryptor.encrypt('test-data'); + + // Remove the key from env + delete process.env.AES_KEY; + delete process.env.AES_KEY_ID; + + // Create a new Cryptor that won't find the key + const cryptor2 = new Cryptor({ shouldUseAws: false }); + + await expect(cryptor2.decrypt(encrypted)).rejects.toThrow( 'Encryption key not found' ); }); diff --git a/packages/core/encrypt/aes-encryption-key-provider.js b/packages/core/encrypt/aes-encryption-key-provider.js new file mode 100644 index 000000000..c5c946c09 --- /dev/null +++ b/packages/core/encrypt/aes-encryption-key-provider.js @@ -0,0 +1,82 @@ +/** + * AES Encryption Key Provider (Adapter) + * + * Local AES-based implementation of EncryptionKeyProviderInterface. + * Uses environment-variable-based master keys for envelope encryption. + * + * No external dependencies — works on any platform. + * + * Environment Variables: + * - AES_KEY_ID: Identifier for the current AES master key + * - AES_KEY: The current AES master key (32 chars) + * - DEPRECATED_AES_KEY_ID: (optional) Previous key ID for key rotation + * - DEPRECATED_AES_KEY: (optional) Previous key for decrypting old data + */ + +const crypto = require('crypto'); +const aes = require('./aes'); +const { + EncryptionKeyProviderInterface, +} = require('./encryption-key-provider-interface'); + +class AesEncryptionKeyProvider extends EncryptionKeyProviderInterface { + /** + * Generate a data encryption key using local AES + * + * Creates a random DEK and encrypts it with the AES master key + * from environment variables. + * + * @returns {Promise<{keyId: string, encryptedKey: string, plaintext: string}>} + */ + async generateDataKey() { + const { AES_KEY, AES_KEY_ID } = process.env; + const randomKey = crypto.randomBytes(32).toString('hex').slice(0, 32); + + return { + keyId: Buffer.from(AES_KEY_ID).toString('base64'), + encryptedKey: Buffer.from(aes.encrypt(randomKey, AES_KEY)).toString( + 'base64' + ), + plaintext: randomKey, + }; + } + + /** + * Decrypt a data encryption key using the AES master key + * + * Looks up the master key by keyId from environment variables, + * supporting key rotation via DEPRECATED_AES_KEY. + * + * @param {string} keyId - Key identifier to look up in environment + * @param {string|Buffer} encryptedKey - Encrypted DEK to decrypt + * @returns {Promise} Decrypted plaintext DEK + */ + async decryptDataKey(keyId, encryptedKey) { + const key = this.getKeyFromEnvironment(keyId); + return aes.decrypt(encryptedKey, key); + } + + /** + * Look up an AES master key by its identifier + * + * @param {string} keyId - Key identifier + * @returns {string} The master key + * @throws {Error} If key not found in environment + */ + getKeyFromEnvironment(keyId) { + const availableKeys = { + [process.env.AES_KEY_ID]: process.env.AES_KEY, + [process.env.DEPRECATED_AES_KEY_ID]: process.env.DEPRECATED_AES_KEY, + }; + + const key = availableKeys[keyId]; + + if (!key) { + throw new Error('Encryption key not found'); + } + + return key; + } +} + +module.exports = { AesEncryptionKeyProvider }; diff --git a/packages/core/encrypt/encryption-key-provider-interface.js b/packages/core/encrypt/encryption-key-provider-interface.js new file mode 100644 index 000000000..24ff0b6bd --- /dev/null +++ b/packages/core/encrypt/encryption-key-provider-interface.js @@ -0,0 +1,50 @@ +/** + * Encryption Key Provider Interface (Port) + * + * Defines the contract for envelope encryption key operations. + * Used by Cryptor for generating and decrypting data encryption keys. + * + * Following Frigg's hexagonal architecture pattern: + * - Port defines WHAT the service does (contract) + * - Adapters implement HOW (AWS KMS, local AES, Vault, etc.) + * + * Envelope Encryption Pattern: + * 1. generateDataKey() creates a fresh DEK (plaintext + encrypted form) + * 2. Caller encrypts data with the plaintext DEK + * 3. Caller stores the encrypted DEK alongside the ciphertext + * 4. decryptDataKey() recovers the plaintext DEK from the encrypted form + * 5. Caller decrypts data with the recovered DEK + */ +class EncryptionKeyProviderInterface { + /** + * Generate a new data encryption key + * + * Returns both the plaintext key (for immediate encryption) and an + * encrypted copy (for storage alongside the ciphertext). + * + * @returns {Promise<{keyId: string, encryptedKey: string, plaintext: string|Buffer}>} + * - keyId: Base64-encoded identifier for the master key used + * - encryptedKey: Base64-encoded encrypted copy of the DEK + * - plaintext: The raw DEK (string or Buffer) for immediate use + */ + async generateDataKey() { + throw new Error( + 'Method generateDataKey must be implemented by subclass' + ); + } + + /** + * Decrypt a previously encrypted data encryption key + * + * @param {string} keyId - Identifier of the master key used for encryption + * @param {string|Buffer} encryptedKey - The encrypted DEK to decrypt + * @returns {Promise} The decrypted plaintext DEK + */ + async decryptDataKey(keyId, encryptedKey) { + throw new Error( + 'Method decryptDataKey must be implemented by subclass' + ); + } +} + +module.exports = { EncryptionKeyProviderInterface }; diff --git a/packages/core/encrypt/test-encrypt.js b/packages/core/encrypt/test-encrypt.js deleted file mode 100644 index 403a2c4ba..000000000 --- a/packages/core/encrypt/test-encrypt.js +++ /dev/null @@ -1,105 +0,0 @@ -const { mongoose } = require('../database/mongoose'); -const crypto = require('crypto'); - -const hexPattern = /^[a-f0-9]+$/i; // match hex strings of length >= 1 - -// Test that an encrypted secret value appears to have valid values (without actually decrypting it). -function expectValidSecret(secret) { - const parts = secret.split(':'); - const keyId = Buffer.from(parts[0], 'base64').toString(); - const iv = parts[1]; - const encryptedText = parts[2]; - const encryptedKey = Buffer.from(parts[3], 'base64').toString(); - - expect(iv).toHaveLength(32); - expect(iv).toMatch(hexPattern); - expect(encryptedText).toHaveLength(14); - expect(encryptedText).toMatch(hexPattern); - - // Keys from AWS start with Karn and have a different format. - if (keyId.startsWith('arn:aws')) { - expect(keyId).toBe( - `arn:aws:kms:us-east-1:000000000000:key/${process.env.KMS_KEY_ARN}` - ); - // The length here is a sanity check. Seems they are always within this range. - expect(encryptedKey.length).toBeGreaterThanOrEqual(85); - expect(encryptedKey.length).toBeLessThanOrEqual(140); - } else { - const { AES_KEY_ID, DEPRECATED_AES_KEY_ID } = process.env; - expect([AES_KEY_ID, DEPRECATED_AES_KEY_ID]).toContain(keyId); - - const encryptedKeyParts = encryptedKey.split(':'); - const iv2 = encryptedKeyParts[0]; - const encryptedKeyPart = encryptedKeyParts[1]; - - expect(iv2).toHaveLength(32); - expect(iv2).toMatch(hexPattern); - expect(encryptedKeyPart).toHaveLength(64); - expect(encryptedKeyPart).toMatch(hexPattern); - } -} - -// Load and validate a raw test document compared to a Mongoose document object. -async function expectValidRawDoc(Model, doc) { - const rawDoc = await expectValidRawDocById(Model, doc._id); - - expect(rawDoc.notSecret.toString()).toBe(doc.notSecret.toString()); - expect(rawDoc).not.toHaveProperty('secret', doc.secret); - - return rawDoc; -} - -// Load and validate a raw test document by ID. -async function expectValidRawDocById(Model, _id) { - const rawDoc = await Model.collection.findOne({ _id }); - - expect(rawDoc).toHaveProperty('notSecret'); - expect(rawDoc).toHaveProperty('secret'); - expectValidSecret(rawDoc.secret); - - return rawDoc; -} - -// Create a clean test model, so that the plug-in can be reinitialized. -function createModel() { - const randomHex = crypto.randomBytes(16).toString('hex'); - const schema = new mongoose.Schema({ - secret: { type: String, lhEncrypt: true }, - notSecret: { type: mongoose.Schema.Types.ObjectId }, - 'deeply.nested.secret': { type: String, lhEncrypt: true }, - }); - - schema.plugin(Encrypt); - - const Model = mongoose.model(`EncryptTest_${randomHex}`, schema); - return { schema, Model }; -} - -// Save and validate a test doc. -async function saveTestDocument(Model) { - const notSecret = new mongoose.Types.ObjectId(); - const secret = 'abcdefg'; - const doc = new Model({ notSecret, secret }); - - expect(doc).toHaveProperty('notSecret'); - expect(doc.notSecret.toString()).toBe(notSecret.toString()); - expect(doc).toHaveProperty('secret'); - expect(doc.secret).toBe(secret); - - await doc.save(); - - expect(doc).toHaveProperty('notSecret'); - expect(doc.notSecret.toString()).toBe(notSecret.toString()); - expect(doc).toHaveProperty('secret'); - expect(doc.secret).toBe(secret); - - return { doc, secret, notSecret }; -} - -module.exports = { - expectValidSecret, - expectValidRawDoc, - expectValidRawDocById, - createModel, - saveTestDocument, -}; diff --git a/packages/core/extensions/__tests__/bootstrap-runner.test.js b/packages/core/extensions/__tests__/bootstrap-runner.test.js new file mode 100644 index 000000000..944dfc3ac --- /dev/null +++ b/packages/core/extensions/__tests__/bootstrap-runner.test.js @@ -0,0 +1,69 @@ +const { runExtensionBootstraps } = require('../bootstrap-runner'); + +describe('Bootstrap Runner', () => { + it('should do nothing with no extensions', async () => { + await expect(runExtensionBootstraps([])).resolves.toBeUndefined(); + await expect(runExtensionBootstraps(null)).resolves.toBeUndefined(); + await expect(runExtensionBootstraps(undefined)).resolves.toBeUndefined(); + }); + + it('should skip extensions without bootstrap', async () => { + const extensions = [ + { name: 'no-bootstrap', bootstrap: null }, + { name: 'also-no-bootstrap' }, + ]; + await expect(runExtensionBootstraps(extensions)).resolves.toBeUndefined(); + }); + + it('should call bootstrap with prisma and appDefinition', async () => { + const bootstrapFn = jest.fn(); + const mockPrisma = { $connect: jest.fn() }; + const mockAppDef = { name: 'test-app' }; + + await runExtensionBootstraps( + [{ name: 'test-ext', bootstrap: bootstrapFn }], + { prisma: mockPrisma, appDefinition: mockAppDef } + ); + + expect(bootstrapFn).toHaveBeenCalledTimes(1); + expect(bootstrapFn).toHaveBeenCalledWith(mockPrisma, mockAppDef); + }); + + it('should run multiple bootstraps in sequence', async () => { + const order = []; + const ext1 = { + name: 'ext-1', + bootstrap: jest.fn(async () => { + order.push('ext-1'); + }), + }; + const ext2 = { + name: 'ext-2', + bootstrap: jest.fn(async () => { + order.push('ext-2'); + }), + }; + + await runExtensionBootstraps([ext1, ext2], {}); + expect(order).toEqual(['ext-1', 'ext-2']); + }); + + it('should fail fast on bootstrap error with extension name', async () => { + const failingExt = { + name: 'broken-ext', + bootstrap: jest.fn(async () => { + throw new Error('DB connection failed'); + }), + }; + const neverCalled = { + name: 'skipped', + bootstrap: jest.fn(), + }; + + await expect( + runExtensionBootstraps([failingExt, neverCalled], {}) + ).rejects.toThrow('Extension "broken-ext" bootstrap failed'); + + expect(neverCalled.bootstrap).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/extensions/__tests__/extension-loader.test.js b/packages/core/extensions/__tests__/extension-loader.test.js new file mode 100644 index 000000000..b01ec4b2f --- /dev/null +++ b/packages/core/extensions/__tests__/extension-loader.test.js @@ -0,0 +1,188 @@ +const { loadExtensions, validateExtension } = require('../extension-loader'); + +describe('Extension Loader', () => { + describe('validateExtension', () => { + it('should accept a valid extension with all fields', () => { + const ext = { + name: 'test-ext', + schema: '/path/to/schema.prisma', + encryption: { + MyModel: { fields: ['secretField'] }, + }, + routes: { + path: '/api/admin/test', + handler: () => {}, + }, + bootstrap: () => {}, + }; + + const { valid, errors } = validateExtension(ext, 0); + expect(valid).toBe(true); + expect(errors).toHaveLength(0); + }); + + it('should accept a minimal extension with only name', () => { + const { valid } = validateExtension({ name: 'minimal' }, 0); + expect(valid).toBe(true); + }); + + it('should reject non-object extension', () => { + const { valid, errors } = validateExtension('not-an-object', 0); + expect(valid).toBe(false); + expect(errors[0]).toContain('must be an object'); + }); + + it('should reject extension without name', () => { + const { valid, errors } = validateExtension({}, 0); + expect(valid).toBe(false); + expect(errors[0]).toContain('name'); + }); + + it('should reject schema not ending in .prisma', () => { + const { valid, errors } = validateExtension( + { name: 'test', schema: '/path/to/schema.sql' }, + 0 + ); + expect(valid).toBe(false); + expect(errors[0]).toContain('.prisma'); + }); + + it('should reject encryption without fields array', () => { + const { valid, errors } = validateExtension( + { + name: 'test', + encryption: { MyModel: { fields: 'not-an-array' } }, + }, + 0 + ); + expect(valid).toBe(false); + expect(errors[0]).toContain('fields'); + }); + + it('should reject routes without path', () => { + const { valid, errors } = validateExtension( + { + name: 'test', + routes: { handler: () => {} }, + }, + 0 + ); + expect(valid).toBe(false); + expect(errors[0]).toContain('path'); + }); + + it('should reject routes without handler', () => { + const { valid, errors } = validateExtension( + { + name: 'test', + routes: { path: '/api/test' }, + }, + 0 + ); + expect(valid).toBe(false); + expect(errors[0]).toContain('handler'); + }); + + it('should reject bootstrap that is not a function or string', () => { + const { valid, errors } = validateExtension( + { name: 'test', bootstrap: 42 }, + 0 + ); + expect(valid).toBe(false); + expect(errors[0]).toContain('bootstrap'); + }); + }); + + describe('loadExtensions', () => { + it('should return empty array when no appDefinition', () => { + expect(loadExtensions(null)).toEqual([]); + expect(loadExtensions(undefined)).toEqual([]); + }); + + it('should return empty array when no extensions property', () => { + expect(loadExtensions({})).toEqual([]); + expect(loadExtensions({ name: 'my-app' })).toEqual([]); + }); + + it('should return empty array for empty extensions array', () => { + expect(loadExtensions({ extensions: [] })).toEqual([]); + }); + + it('should throw for non-array extensions', () => { + expect(() => loadExtensions({ extensions: 'bad' })).toThrow( + 'must be an array' + ); + }); + + it('should normalize a valid extension', () => { + const bootstrapFn = jest.fn(); + const handlerFn = jest.fn(); + + const result = loadExtensions({ + extensions: [ + { + name: 'db-credentials', + schema: '/path/to/schema.prisma', + encryption: { + OAuthAppCredential: { + fields: ['clientSecret'], + }, + }, + routes: { + path: '/api/admin/oauth-credentials', + handler: handlerFn, + }, + bootstrap: bootstrapFn, + }, + ], + }); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('db-credentials'); + expect(result[0].schema).toBe('/path/to/schema.prisma'); + expect(result[0].encryption).toEqual({ + OAuthAppCredential: { fields: ['clientSecret'] }, + }); + expect(result[0].routes.path).toBe( + '/api/admin/oauth-credentials' + ); + expect(result[0].routes.handler).toBe(handlerFn); + expect(result[0].bootstrap).toBe(bootstrapFn); + }); + + it('should normalize extension with null optional fields', () => { + const result = loadExtensions({ + extensions: [{ name: 'minimal-ext' }], + }); + + expect(result).toHaveLength(1); + expect(result[0].schema).toBeNull(); + expect(result[0].encryption).toBeNull(); + expect(result[0].routes).toBeNull(); + expect(result[0].bootstrap).toBeNull(); + }); + + it('should throw on validation errors with all errors listed', () => { + expect(() => + loadExtensions({ + extensions: [ + {}, // missing name + { name: 'ok', schema: 'bad.sql' }, // bad schema + ], + }) + ).toThrow('Invalid extension configuration'); + }); + + it('should load multiple extensions', () => { + const result = loadExtensions({ + extensions: [ + { name: 'ext-a' }, + { name: 'ext-b', schema: '/a/b.prisma' }, + ], + }); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('ext-a'); + expect(result[1].name).toBe('ext-b'); + }); + }); +}); diff --git a/packages/core/extensions/__tests__/initialize-app.test.js b/packages/core/extensions/__tests__/initialize-app.test.js new file mode 100644 index 000000000..feb09812a --- /dev/null +++ b/packages/core/extensions/__tests__/initialize-app.test.js @@ -0,0 +1,151 @@ +const { initializeApp, resetInitialization } = require('../initialize-app'); + +// Mock the dependencies +jest.mock('../../handlers/app-definition-loader', () => ({ + loadAppDefinition: jest.fn(), +})); + +jest.mock('../../database/prisma', () => ({ + prisma: { $connect: jest.fn() }, +})); + +const { loadAppDefinition } = require('../../handlers/app-definition-loader'); +const { prisma } = require('../../database/prisma'); + +describe('initializeApp', () => { + beforeEach(() => { + resetInitialization(); + jest.clearAllMocks(); + }); + + it('should return empty extensions when no app definition', async () => { + loadAppDefinition.mockImplementation(() => { + throw new Error('Not found'); + }); + + const result = await initializeApp(); + expect(result.extensions).toEqual([]); + }); + + it('should return empty extensions when app definition has no extensions', async () => { + loadAppDefinition.mockReturnValue({ + appDefinition: { name: 'test-app' }, + }); + + const result = await initializeApp(); + expect(result.extensions).toEqual([]); + }); + + it('should load and run bootstrap hooks', async () => { + const bootstrapFn = jest.fn(); + + loadAppDefinition.mockReturnValue({ + appDefinition: { + name: 'test-app', + extensions: [ + { + name: 'test-ext', + bootstrap: bootstrapFn, + }, + ], + }, + }); + + const result = await initializeApp(); + + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].name).toBe('test-ext'); + expect(bootstrapFn).toHaveBeenCalledWith(prisma, expect.objectContaining({ + name: 'test-app', + })); + }); + + it('should only initialize once (idempotent)', async () => { + const bootstrapFn = jest.fn(); + + loadAppDefinition.mockReturnValue({ + appDefinition: { + name: 'test-app', + extensions: [{ name: 'ext', bootstrap: bootstrapFn }], + }, + }); + + await initializeApp(); + await initializeApp(); + + expect(bootstrapFn).toHaveBeenCalledTimes(1); + }); + + it('should re-initialize when force=true', async () => { + const bootstrapFn = jest.fn(); + + loadAppDefinition.mockReturnValue({ + appDefinition: { + name: 'test-app', + extensions: [{ name: 'ext', bootstrap: bootstrapFn }], + }, + }); + + await initializeApp(); + await initializeApp({ force: true }); + + expect(bootstrapFn).toHaveBeenCalledTimes(2); + }); + + it('should accept overridden appDefinition and prisma', async () => { + const bootstrapFn = jest.fn(); + const customPrisma = { custom: true }; + const customAppDef = { + name: 'custom', + extensions: [{ name: 'ext', bootstrap: bootstrapFn }], + }; + + const result = await initializeApp({ + appDefinition: customAppDef, + prisma: customPrisma, + }); + + expect(bootstrapFn).toHaveBeenCalledWith(customPrisma, customAppDef); + expect(result.extensions).toHaveLength(1); + // loadAppDefinition should NOT have been called + expect(loadAppDefinition).not.toHaveBeenCalled(); + }); + + it('should propagate bootstrap errors', async () => { + loadAppDefinition.mockReturnValue({ + appDefinition: { + name: 'test-app', + extensions: [ + { + name: 'failing-ext', + bootstrap: async () => { + throw new Error('Bootstrap failed'); + }, + }, + ], + }, + }); + + await expect(initializeApp()).rejects.toThrow('failing-ext'); + }); + + it('should allow re-initialization after error', async () => { + let callCount = 0; + const bootstrapFn = jest.fn(async () => { + callCount++; + if (callCount === 1) throw new Error('First call fails'); + }); + + loadAppDefinition.mockReturnValue({ + appDefinition: { + extensions: [{ name: 'retry-ext', bootstrap: bootstrapFn }], + }, + }); + + await expect(initializeApp()).rejects.toThrow(); + + // Second call should work + await initializeApp(); + expect(bootstrapFn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/extensions/__tests__/module-async-env.test.js b/packages/core/extensions/__tests__/module-async-env.test.js new file mode 100644 index 000000000..9a6a0204e --- /dev/null +++ b/packages/core/extensions/__tests__/module-async-env.test.js @@ -0,0 +1,123 @@ +const { Module } = require('../../modules/module'); + +// Create a minimal mock API class +class MockApi { + constructor(params) { + this.params = params; + } + + static get requesterType() { + return 'api-key'; + } + + getAuthorizationRequirements() { + return { type: 'api_key' }; + } +} + +const mockDefinition = { + moduleName: 'test-module', + modelName: 'TestModule', + API: MockApi, + requiredAuthMethods: { + getToken: async () => {}, + getEntityDetails: async () => {}, + getCredentialDetails: async () => {}, + apiPropertiesToPersist: { credential: [], entity: [] }, + testAuthRequest: async () => true, + }, +}; + +// Mock the repository factories +jest.mock('../../credential/repositories/credential-repository-factory', () => ({ + createCredentialRepository: () => ({}), +})); + +jest.mock('../../modules/repositories/module-repository-factory', () => ({ + createModuleRepository: () => ({}), +})); + +describe('Module async env support', () => { + describe('Module.resolveEnv', () => { + it('should return static env as-is', async () => { + const env = { client_id: 'abc', client_secret: 'xyz' }; + const result = await Module.resolveEnv({ ...mockDefinition, env }); + expect(result).toEqual(env); + }); + + it('should call and await function env', async () => { + const env = async () => ({ + client_id: 'from-db', + client_secret: 'from-db-secret', + }); + const result = await Module.resolveEnv({ ...mockDefinition, env }); + expect(result).toEqual({ + client_id: 'from-db', + client_secret: 'from-db-secret', + }); + }); + + it('should handle sync function env', async () => { + const env = () => ({ client_id: 'sync-fn' }); + const result = await Module.resolveEnv({ ...mockDefinition, env }); + expect(result).toEqual({ client_id: 'sync-fn' }); + }); + + it('should return undefined when env is not defined', async () => { + const result = await Module.resolveEnv(mockDefinition); + expect(result).toBeUndefined(); + }); + }); + + describe('Module.create (async factory)', () => { + it('should create module with static env', async () => { + const definition = { + ...mockDefinition, + env: { client_id: 'static-id', client_secret: 'static-secret' }, + }; + + const module = await Module.create({ definition }); + expect(module.api.params.client_id).toBe('static-id'); + expect(module.api.params.client_secret).toBe('static-secret'); + }); + + it('should create module with async function env', async () => { + const definition = { + ...mockDefinition, + env: async () => ({ + client_id: 'async-id', + client_secret: 'async-secret', + }), + }; + + const module = await Module.create({ definition }); + expect(module.api.params.client_id).toBe('async-id'); + expect(module.api.params.client_secret).toBe('async-secret'); + }); + + it('should propagate async env errors', async () => { + const definition = { + ...mockDefinition, + env: async () => { + throw new Error('DB unavailable'); + }, + }; + + await expect(Module.create({ definition })).rejects.toThrow( + 'DB unavailable' + ); + }); + }); + + describe('backward compatibility', () => { + it('should still work with new Module() and static env', () => { + const definition = { + ...mockDefinition, + env: { client_id: 'old-style' }, + }; + + const module = new Module({ definition }); + expect(module.api.params.client_id).toBe('old-style'); + }); + }); +}); diff --git a/packages/core/extensions/__tests__/route-mounter.test.js b/packages/core/extensions/__tests__/route-mounter.test.js new file mode 100644 index 000000000..341c62eb5 --- /dev/null +++ b/packages/core/extensions/__tests__/route-mounter.test.js @@ -0,0 +1,139 @@ +const express = require('express'); +const { mountExtensionRoutes } = require('../route-mounter'); + +// Mock admin auth middleware +jest.mock('../../handlers/middleware/admin-auth', () => ({ + validateAdminApiKey: jest.fn((req, res, next) => next()), +})); + +const { validateAdminApiKey } = require('../../handlers/middleware/admin-auth'); + +describe('Route Mounter', () => { + let app; + + beforeEach(() => { + app = express(); + jest.clearAllMocks(); + }); + + it('should do nothing with no extensions', () => { + const spy = jest.spyOn(app, 'use'); + mountExtensionRoutes(app, []); + mountExtensionRoutes(app, null); + mountExtensionRoutes(app, undefined); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should skip extensions without routes', () => { + const spy = jest.spyOn(app, 'use'); + mountExtensionRoutes(app, [{ name: 'no-routes', routes: null }]); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should mount a route handler factory', () => { + const router = express.Router(); + router.get('/test', (req, res) => res.json({ ok: true })); + + const handlerFactory = jest.fn(() => router); + const mockPrisma = {}; + const mockAppDef = { name: 'test' }; + + mountExtensionRoutes( + app, + [ + { + name: 'test-ext', + routes: { + path: '/api/admin/test', + handler: handlerFactory, + }, + }, + ], + { prisma: mockPrisma, appDefinition: mockAppDef } + ); + + expect(handlerFactory).toHaveBeenCalledWith(mockPrisma, mockAppDef); + }); + + it('should apply admin auth middleware by default', () => { + const router = express.Router(); + const handlerFactory = () => router; + + mountExtensionRoutes( + app, + [ + { + name: 'test-ext', + routes: { path: '/api/test', handler: handlerFactory }, + }, + ], + {} + ); + + // app.use should have been called with the path, middleware, and router + // The admin auth middleware should be the one from the mock + expect(validateAdminApiKey).toBeDefined(); + }); + + it('should accept custom auth middleware', () => { + const router = express.Router(); + const handlerFactory = () => router; + const customAuth = jest.fn((req, res, next) => next()); + + mountExtensionRoutes( + app, + [ + { + name: 'test-ext', + routes: { path: '/api/test', handler: handlerFactory }, + }, + ], + { authMiddleware: customAuth } + ); + + // Custom middleware was provided (doesn't throw) + expect(true).toBe(true); + }); + + it('should mount a direct router object', () => { + const router = express.Router(); + router.get('/items', (req, res) => res.json([])); + + const spy = jest.spyOn(app, 'use'); + + mountExtensionRoutes(app, [ + { + name: 'direct-router-ext', + routes: { + path: '/api/admin/items', + handler: { router }, + }, + }, + ]); + + expect(spy).toHaveBeenCalled(); + }); + + it('should warn and skip non-function/non-router handlers', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(); + const appUseSpy = jest.spyOn(app, 'use'); + + mountExtensionRoutes(app, [ + { + name: 'bad-ext', + routes: { + path: '/api/test', + handler: 42, // not a function or router + }, + }, + ]); + + expect(spy).toHaveBeenCalledWith( + expect.stringContaining('bad-ext') + ); + // app.use should NOT have been called (no routes mounted) + expect(appUseSpy).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); +}); diff --git a/packages/core/extensions/__tests__/schema-composer.test.js b/packages/core/extensions/__tests__/schema-composer.test.js new file mode 100644 index 000000000..abcae0cdc --- /dev/null +++ b/packages/core/extensions/__tests__/schema-composer.test.js @@ -0,0 +1,199 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { composeSchemas, extractModelBlocks } = require('../schema-composer'); + +describe('Schema Composer', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'frigg-schema-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('extractModelBlocks', () => { + it('should extract model definitions from a schema', () => { + const schema = ` +generator client { + provider = "prisma-client-js" + output = "../generated/prisma-postgresql" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + name String +} + +model Post { + id Int @id @default(autoincrement()) + title String +} +`; + const result = extractModelBlocks(schema); + expect(result).toContain('model User'); + expect(result).toContain('model Post'); + expect(result).not.toContain('generator'); + expect(result).not.toContain('datasource'); + }); + + it('should extract enum definitions', () => { + const schema = ` +generator client { + provider = "prisma-client-js" +} + +enum Role { + USER + ADMIN +} +`; + const result = extractModelBlocks(schema); + expect(result).toContain('enum Role'); + expect(result).toContain('USER'); + expect(result).toContain('ADMIN'); + expect(result).not.toContain('generator'); + }); + + it('should handle schema with only models (no generator/datasource)', () => { + const schema = ` +model OAuthAppCredential { + id Int @id @default(autoincrement()) + moduleType String + clientId String + clientSecret String +} +`; + const result = extractModelBlocks(schema); + expect(result).toContain('model OAuthAppCredential'); + expect(result).toContain('moduleType'); + }); + + it('should return empty string for schema with no models', () => { + const schema = ` +generator client { + provider = "prisma-client-js" +} +`; + const result = extractModelBlocks(schema); + expect(result).toBe(''); + }); + }); + + describe('composeSchemas', () => { + it('should return base schema when no extensions', () => { + const basePath = path.join(tmpDir, 'base.prisma'); + fs.writeFileSync( + basePath, + 'generator client {\n provider = "prisma-client-js"\n}\n\nmodel User {\n id Int @id\n}' + ); + + const result = composeSchemas({ + baseSchemaPath: basePath, + extensionSchemaPaths: [], + }); + + expect(result).toContain('model User'); + expect(result).toContain('generator client'); + }); + + it('should merge extension models into base schema', () => { + const basePath = path.join(tmpDir, 'base.prisma'); + fs.writeFileSync( + basePath, + 'generator client {\n provider = "prisma-client-js"\n}\n\nmodel User {\n id Int @id\n}' + ); + + const extDir = path.join(tmpDir, 'ext'); + fs.mkdirSync(extDir); + const extPath = path.join(extDir, 'credential.prisma'); + fs.writeFileSync( + extPath, + 'model OAuthAppCredential {\n id Int @id\n clientId String\n clientSecret String\n}' + ); + + const outputPath = path.join(tmpDir, 'merged.prisma'); + const result = composeSchemas({ + baseSchemaPath: basePath, + extensionSchemaPaths: [extPath], + outputPath, + }); + + expect(result).toContain('model User'); + expect(result).toContain('model OAuthAppCredential'); + expect(result).toContain('Extension Models'); + + // Check file was written + const written = fs.readFileSync(outputPath, 'utf-8'); + expect(written).toBe(result); + }); + + it('should strip generator/datasource from extension schemas', () => { + const basePath = path.join(tmpDir, 'base.prisma'); + fs.writeFileSync( + basePath, + 'generator client {\n provider = "prisma-client-js"\n}\n\nmodel User {\n id Int @id\n}' + ); + + const extPath = path.join(tmpDir, 'ext.prisma'); + fs.writeFileSync( + extPath, + 'generator client {\n provider = "prisma-client-js"\n}\n\ndatasource db {\n provider = "postgresql"\n}\n\nmodel Token {\n id Int @id\n}' + ); + + const result = composeSchemas({ + baseSchemaPath: basePath, + extensionSchemaPaths: [extPath], + }); + + // Base generator should exist, extension's should be stripped + const generatorCount = (result.match(/generator client/g) || []) + .length; + expect(generatorCount).toBe(1); + expect(result).toContain('model Token'); + }); + + it('should throw when extension schema file not found', () => { + const basePath = path.join(tmpDir, 'base.prisma'); + fs.writeFileSync(basePath, 'model User { id Int @id }'); + + expect(() => + composeSchemas({ + baseSchemaPath: basePath, + extensionSchemaPaths: ['/nonexistent/schema.prisma'], + }) + ).toThrow('Extension schema not found'); + }); + + it('should throw when baseSchemaPath is not provided', () => { + expect(() => composeSchemas({})).toThrow('baseSchemaPath is required'); + }); + + it('should compose multiple extension schemas', () => { + const basePath = path.join(tmpDir, 'base.prisma'); + fs.writeFileSync(basePath, 'model User {\n id Int @id\n}'); + + const ext1Path = path.join(tmpDir, 'ext1.prisma'); + fs.writeFileSync(ext1Path, 'model Credential {\n id Int @id\n}'); + + const ext2Path = path.join(tmpDir, 'ext2.prisma'); + fs.writeFileSync(ext2Path, 'model AuditLog {\n id Int @id\n}'); + + const result = composeSchemas({ + baseSchemaPath: basePath, + extensionSchemaPaths: [ext1Path, ext2Path], + }); + + expect(result).toContain('model User'); + expect(result).toContain('model Credential'); + expect(result).toContain('model AuditLog'); + }); + }); +}); diff --git a/packages/core/extensions/bootstrap-runner.js b/packages/core/extensions/bootstrap-runner.js new file mode 100644 index 000000000..43b21e4c6 --- /dev/null +++ b/packages/core/extensions/bootstrap-runner.js @@ -0,0 +1,43 @@ +/** + * Bootstrap Runner + * + * Runs extension bootstrap functions in sequence. + * Bootstrap hooks execute AFTER database connection but BEFORE integration + * router creation. This is the right place for extensions to: + * + * - Load credentials from database into integration definitions + * - Register additional middleware or services + * - Perform one-time initialization + * + * @example + * await runExtensionBootstraps(extensions, { prisma, appDefinition }); + */ + +/** + * Runs all extension bootstrap functions in sequence. + * + * @param {Array} extensions - Normalized extensions (from extension-loader) + * @param {Object} context + * @param {Object} context.prisma - Prisma client instance + * @param {Object} context.appDefinition - Full app definition + * @throws {Error} If any bootstrap function fails (fails fast) + */ +async function runExtensionBootstraps(extensions, context = {}) { + if (!extensions || extensions.length === 0) return; + + const { prisma, appDefinition } = context; + + for (const ext of extensions) { + if (!ext.bootstrap) continue; + + try { + await ext.bootstrap(prisma, appDefinition); + } catch (error) { + throw new Error( + `Extension "${ext.name}" bootstrap failed: ${error.message}` + ); + } + } +} + +module.exports = { runExtensionBootstraps }; diff --git a/packages/core/extensions/extension-loader.js b/packages/core/extensions/extension-loader.js new file mode 100644 index 000000000..852b1bed9 --- /dev/null +++ b/packages/core/extensions/extension-loader.js @@ -0,0 +1,167 @@ +/** + * Extension Loader + * + * Validates and normalizes extensions from the app definition. + * Extensions allow integration developers to add custom Prisma models, + * encryption schemas, routes, and bootstrap logic to a Frigg app. + * + * @example + * const extensions = loadExtensions(appDefinition); + * // extensions is a normalized array, safe to iterate + */ + +const path = require('path'); + +/** + * Validates a single extension definition. + * @param {Object} ext - Raw extension config from app definition + * @param {number} index - Position in the extensions array (for error messages) + * @returns {{valid: boolean, errors: string[]}} + */ +function validateExtension(ext, index) { + const prefix = `extensions[${index}]`; + const errors = []; + + if (!ext || typeof ext !== 'object') { + errors.push(`${prefix}: must be an object`); + return { valid: false, errors }; + } + + if (!ext.name || typeof ext.name !== 'string') { + errors.push(`${prefix}.name: required string`); + } + + // schema is optional — path to a .prisma file + if (ext.schema !== undefined) { + if (typeof ext.schema !== 'string') { + errors.push(`${prefix}.schema: must be a string (path to .prisma file)`); + } else if (!ext.schema.endsWith('.prisma')) { + errors.push(`${prefix}.schema: must end with .prisma`); + } + } + + // encryption is optional — object with model names → { fields: [...] } + if (ext.encryption !== undefined) { + if (!ext.encryption || typeof ext.encryption !== 'object') { + errors.push(`${prefix}.encryption: must be an object`); + } else { + for (const [model, config] of Object.entries(ext.encryption)) { + if (!config || !Array.isArray(config.fields)) { + errors.push( + `${prefix}.encryption.${model}: must have a "fields" array` + ); + } + } + } + } + + // routes is optional — { path, handler } + if (ext.routes !== undefined) { + if (!ext.routes || typeof ext.routes !== 'object') { + errors.push(`${prefix}.routes: must be an object with { path, handler }`); + } else { + if (!ext.routes.path || typeof ext.routes.path !== 'string') { + errors.push(`${prefix}.routes.path: required string`); + } + if (!ext.routes.handler) { + errors.push(`${prefix}.routes.handler: required (function or module path)`); + } + } + } + + // bootstrap is optional — function or module path + if (ext.bootstrap !== undefined) { + if (typeof ext.bootstrap !== 'function' && typeof ext.bootstrap !== 'string') { + errors.push( + `${prefix}.bootstrap: must be a function or a string (module path)` + ); + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Loads and validates extensions from the app definition. + * + * @param {Object} appDefinition - The full app definition + * @returns {Array} Normalized extensions array (empty if none) + * @throws {Error} If any extension fails validation + */ +function loadExtensions(appDefinition) { + if (!appDefinition) return []; + + const raw = appDefinition.extensions; + if (!raw) return []; + + if (!Array.isArray(raw)) { + throw new Error('appDefinition.extensions must be an array'); + } + + if (raw.length === 0) return []; + + const allErrors = []; + + const normalized = raw.map((ext, index) => { + const { valid, errors } = validateExtension(ext, index); + if (!valid) { + allErrors.push(...errors); + return null; + } + + // Normalize bootstrap — resolve string paths to functions + let bootstrapFn = null; + if (typeof ext.bootstrap === 'function') { + bootstrapFn = ext.bootstrap; + } else if (typeof ext.bootstrap === 'string') { + bootstrapFn = require(ext.bootstrap); + } + + // Normalize route handler — resolve string paths to functions + let routeHandler = null; + if (ext.routes) { + if (typeof ext.routes.handler === 'function') { + routeHandler = ext.routes.handler; + } else if (typeof ext.routes.handler === 'string') { + routeHandler = require(ext.routes.handler); + } else { + routeHandler = ext.routes.handler; + } + } + + return { + name: ext.name, + schema: ext.schema || null, + encryption: ext.encryption || null, + routes: ext.routes + ? { path: ext.routes.path, handler: routeHandler } + : null, + bootstrap: bootstrapFn, + }; + }); + + if (allErrors.length > 0) { + throw new Error( + `Invalid extension configuration:\n- ${allErrors.join('\n- ')}` + ); + } + + return normalized.filter(Boolean); +} + +/** + * Get extension schema file paths from loaded extensions. + * @param {Array} extensions - Normalized extensions + * @returns {string[]} Array of absolute paths to .prisma schema files + */ +function getExtensionSchemaPaths(extensions) { + return extensions + .filter((ext) => ext.schema) + .map((ext) => ext.schema); +} + +module.exports = { + loadExtensions, + validateExtension, + getExtensionSchemaPaths, +}; diff --git a/packages/core/extensions/index.js b/packages/core/extensions/index.js new file mode 100644 index 000000000..90b5031d9 --- /dev/null +++ b/packages/core/extensions/index.js @@ -0,0 +1,37 @@ +/** + * Frigg Extension System + * + * Extensions allow integration developers to extend Frigg Core with: + * - Custom Prisma models (schema composition) + * - Encrypted fields (encryption registry merge) + * - Admin API routes (route mounting) + * - Bootstrap lifecycle hooks (credential loading, etc.) + * + * @module @friggframework/core/extensions + */ + +const { loadExtensions, validateExtension, getExtensionSchemaPaths } = require('./extension-loader'); +const { composeSchemas, extractModelBlocks, getSchemaFilePaths } = require('./schema-composer'); +const { mountExtensionRoutes } = require('./route-mounter'); +const { runExtensionBootstraps } = require('./bootstrap-runner'); +const { initializeApp, resetInitialization } = require('./initialize-app'); + +module.exports = { + // Extension loading & validation + loadExtensions, + validateExtension, + getExtensionSchemaPaths, + + // Schema composition + composeSchemas, + extractModelBlocks, + getSchemaFilePaths, + + // Route mounting + mountExtensionRoutes, + + // Bootstrap lifecycle + runExtensionBootstraps, + initializeApp, + resetInitialization, +}; diff --git a/packages/core/extensions/initialize-app.js b/packages/core/extensions/initialize-app.js new file mode 100644 index 000000000..be20af723 --- /dev/null +++ b/packages/core/extensions/initialize-app.js @@ -0,0 +1,123 @@ +/** + * Application Initialization with Extension Lifecycle + * + * Provides an async initialization function that runs extension bootstraps + * after database connection but before router creation. This is the integration + * point for providers (Lambda, Netlify) to hook into the extension lifecycle. + * + * Call `initializeApp()` during the async startup phase of your handler/function. + * It is safe to call multiple times — subsequent calls are no-ops. + * + * @example + * const { initializeApp } = require('@friggframework/core/extensions/initialize-app'); + * + * // In a Lambda handler or Netlify function: + * await initializeApp(); + * // Now extensions are bootstrapped and router can be created + */ + +const { loadExtensions } = require('./extension-loader'); +const { runExtensionBootstraps } = require('./bootstrap-runner'); + +let _initialized = false; +let _initPromise = null; + +/** + * Initializes extensions by running bootstrap hooks. + * + * This function: + * 1. Loads the app definition + * 2. Loads and validates extensions + * 3. Runs extension bootstrap hooks in sequence + * + * Bootstrap hooks receive (prisma, appDefinition) and can: + * - Load credentials from database into integration Definition.env + * - Register services or middleware + * - Perform any one-time async initialization + * + * @param {Object} [options] + * @param {Object} [options.appDefinition] - Override app definition (skips loadAppDefinition) + * @param {Object} [options.prisma] - Override Prisma client instance + * @param {boolean} [options.force=false] - Force re-initialization + * @returns {Promise<{extensions: Array}>} The loaded extensions + */ +async function initializeApp(options = {}) { + const { force = false } = options; + + // Return cached promise if already initializing (prevent race conditions) + if (_initPromise && !force) { + return _initPromise; + } + + if (_initialized && !force) { + return { extensions: [] }; + } + + _initPromise = _doInitialize(options); + + try { + const result = await _initPromise; + _initialized = true; + return result; + } catch (error) { + _initPromise = null; + throw error; + } +} + +async function _doInitialize(options = {}) { + let { appDefinition, prisma } = options; + + // Load app definition if not provided + if (!appDefinition) { + try { + const { loadAppDefinition } = require('../handlers/app-definition-loader'); + const loaded = loadAppDefinition(); + appDefinition = loaded.appDefinition; + } catch { + // No app definition available — skip extension bootstrap + return { extensions: [] }; + } + } + + if (!appDefinition) { + return { extensions: [] }; + } + + // Load extensions + const extensions = loadExtensions(appDefinition); + + if (extensions.length === 0) { + return { extensions }; + } + + // Get Prisma client if not provided + if (!prisma) { + try { + const db = require('../database/prisma'); + prisma = db.prisma; + } catch { + // Prisma not available — skip bootstrap + console.warn('Prisma client not available, skipping extension bootstraps'); + return { extensions }; + } + } + + // Run bootstrap hooks + await runExtensionBootstraps(extensions, { prisma, appDefinition }); + + return { extensions }; +} + +/** + * Reset initialization state (for testing). + */ +function resetInitialization() { + _initialized = false; + _initPromise = null; +} + +module.exports = { + initializeApp, + resetInitialization, +}; diff --git a/packages/core/extensions/route-mounter.js b/packages/core/extensions/route-mounter.js new file mode 100644 index 000000000..3768eaf27 --- /dev/null +++ b/packages/core/extensions/route-mounter.js @@ -0,0 +1,59 @@ +/** + * Route Mounter + * + * Mounts extension-defined Express routers onto the main app. + * Each extension can declare routes with a base path and a handler factory. + * + * The handler factory receives (prisma, appDefinition) and returns an Express Router. + * Routes are protected by the admin auth middleware by default. + * + * @example + * mountExtensionRoutes(app, extensions, { prisma, appDefinition }); + */ + +const { validateAdminApiKey } = require('../handlers/middleware/admin-auth'); + +/** + * Mounts all extension routes onto an Express app. + * + * @param {import('express').Express} app - Express app or router to mount on + * @param {Array} extensions - Normalized extensions (from extension-loader) + * @param {Object} context + * @param {Object} context.prisma - Prisma client instance + * @param {Object} context.appDefinition - Full app definition + * @param {Function} [context.authMiddleware] - Override admin auth middleware + */ +function mountExtensionRoutes(app, extensions, context = {}) { + if (!extensions || extensions.length === 0) return; + + const { prisma, appDefinition, authMiddleware } = context; + const adminAuth = authMiddleware || validateAdminApiKey; + + for (const ext of extensions) { + if (!ext.routes) continue; + + const { path: basePath, handler } = ext.routes; + + let router; + if (typeof handler === 'function') { + // Handler factory: (prisma, appDefinition) => Router + router = handler(prisma, appDefinition); + } else if (handler && handler.router) { + // Direct router object export pattern: { router } + router = handler.router; + } else if (handler && handler.default && typeof handler.default === 'function') { + // ES module default export + router = handler.default(prisma, appDefinition); + } else { + console.warn( + `Extension "${ext.name}": routes.handler is not a function or router object, skipping` + ); + continue; + } + + // Mount with admin auth middleware + app.use(basePath, adminAuth, router); + } +} + +module.exports = { mountExtensionRoutes }; diff --git a/packages/core/extensions/schema-composer.js b/packages/core/extensions/schema-composer.js new file mode 100644 index 000000000..db3aac361 --- /dev/null +++ b/packages/core/extensions/schema-composer.js @@ -0,0 +1,188 @@ +/** + * Schema Composer + * + * Merges extension .prisma schema fragments with a base Prisma schema. + * Extensions can add new models/enums that get composed into the final schema + * used for `prisma generate` and migrations. + * + * Two approaches are supported: + * 1. Build-time merge: Concatenate model definitions from extension files + * into the base schema and write a merged output file. + * 2. Multi-file schema (Prisma 5.15+): Return a list of schema paths for + * Prisma's native `prismaSchemaFolder` feature. + * + * @example + * const { composeSchemas } = require('@friggframework/core/extensions/schema-composer'); + * + * // Build-time merge — writes a merged schema file + * await composeSchemas({ + * baseSchemaPath: '/path/to/prisma-postgresql/schema.prisma', + * extensionSchemaPaths: ['/path/to/extensions/db-credentials/schema.prisma'], + * outputPath: '/path/to/prisma-postgresql/schema.composed.prisma', + * }); + */ + +const fs = require('fs'); +const path = require('path'); + +/** + * Extracts model/enum blocks from a .prisma file, stripping out + * generator and datasource blocks (which should only appear in the base schema). + * + * @param {string} schemaContent - Raw .prisma file content + * @returns {string} Only model/enum/type definitions + */ +function extractModelBlocks(schemaContent) { + const lines = schemaContent.split('\n'); + const result = []; + let depth = 0; + let inBlock = false; + let blockType = null; + + for (const line of lines) { + const trimmed = line.trim(); + + // Detect block start + if (depth === 0 && !inBlock) { + const blockMatch = trimmed.match( + /^(model|enum|type)\s+\w+\s*\{/ + ); + if (blockMatch) { + inBlock = true; + blockType = blockMatch[1]; + result.push(line); + depth += (line.match(/\{/g) || []).length; + depth -= (line.match(/\}/g) || []).length; + continue; + } + + // Skip generator/datasource blocks + const skipMatch = trimmed.match( + /^(generator|datasource)\s+\w+\s*\{/ + ); + if (skipMatch) { + inBlock = true; + blockType = 'skip'; + depth += (line.match(/\{/g) || []).length; + depth -= (line.match(/\}/g) || []).length; + continue; + } + + // Keep standalone comments between blocks + if (trimmed.startsWith('//') || trimmed === '') { + // Only keep if we've already accumulated some model blocks + if (result.length > 0) { + result.push(line); + } + continue; + } + } else if (inBlock) { + depth += (line.match(/\{/g) || []).length; + depth -= (line.match(/\}/g) || []).length; + + if (blockType !== 'skip') { + result.push(line); + } + + if (depth <= 0) { + inBlock = false; + blockType = null; + depth = 0; + if (result.length > 0) { + result.push(''); // blank line between blocks + } + } + } + } + + return result.join('\n').trim(); +} + +/** + * Composes a merged Prisma schema from a base schema + extension fragments. + * + * @param {Object} options + * @param {string} options.baseSchemaPath - Absolute path to the core .prisma file + * @param {string[]} options.extensionSchemaPaths - Absolute paths to extension .prisma files + * @param {string} [options.outputPath] - Where to write the merged schema. + * Defaults to `/schema.composed.prisma` + * @returns {string} The merged schema content + */ +function composeSchemas({ baseSchemaPath, extensionSchemaPaths, outputPath }) { + if (!baseSchemaPath) { + throw new Error('baseSchemaPath is required'); + } + + if ( + !extensionSchemaPaths || + !Array.isArray(extensionSchemaPaths) || + extensionSchemaPaths.length === 0 + ) { + // No extensions — just return base schema as-is + const baseContent = fs.readFileSync(baseSchemaPath, 'utf-8'); + return baseContent; + } + + const baseContent = fs.readFileSync(baseSchemaPath, 'utf-8'); + + // Collect model blocks from each extension + const extensionBlocks = []; + for (const extPath of extensionSchemaPaths) { + if (!fs.existsSync(extPath)) { + throw new Error( + `Extension schema not found: ${extPath}` + ); + } + const extContent = fs.readFileSync(extPath, 'utf-8'); + const models = extractModelBlocks(extContent); + if (models) { + extensionBlocks.push( + `// --- Extension schema from: ${path.basename(path.dirname(extPath))}/${path.basename(extPath)} ---`, + models + ); + } + } + + if (extensionBlocks.length === 0) { + return baseContent; + } + + // Append extension models to the base schema + const merged = [ + baseContent.trimEnd(), + '', + '// =========================================================================', + '// Extension Models (auto-composed — do not edit manually)', + '// =========================================================================', + '', + ...extensionBlocks, + '', + ].join('\n'); + + // Write the merged schema if outputPath is specified + const resolvedOutput = + outputPath || + path.join(path.dirname(baseSchemaPath), 'schema.composed.prisma'); + + fs.writeFileSync(resolvedOutput, merged, 'utf-8'); + + return merged; +} + +/** + * Returns schema paths suitable for Prisma's native multi-file schema support + * (prismaSchemaFolder feature, Prisma 5.15+). + * + * @param {string} baseSchemaPath - Path to the core schema + * @param {string[]} extensionSchemaPaths - Paths to extension schemas + * @returns {string[]} All schema file paths + */ +function getSchemaFilePaths(baseSchemaPath, extensionSchemaPaths) { + return [baseSchemaPath, ...(extensionSchemaPaths || [])]; +} + +module.exports = { + composeSchemas, + extractModelBlocks, + getSchemaFilePaths, +}; diff --git a/packages/core/handlers/app-definition-loader.js b/packages/core/handlers/app-definition-loader.js index a081d3dc2..433e26d91 100644 --- a/packages/core/handlers/app-definition-loader.js +++ b/packages/core/handlers/app-definition-loader.js @@ -3,18 +3,60 @@ const path = require('node:path'); const fs = require('fs-extra'); /** - * Loads the App definition from the nearest backend package + * Cached app definition, set via setAppDefinition(). + * When set, loadAppDefinition() returns this instead of discovering + * the backend via process.cwd(). + */ +let cachedAppDefinition = null; + +/** + * Pre-set the app definition so loadAppDefinition() can return it + * without needing process.cwd()-based discovery. + * + * This is critical for platforms like Netlify where: + * - nft/esbuild traces static require() calls to determine bundle contents + * - process.cwd() at runtime points to /var/task/, not the backend directory + * - The caller can use a static require('../../backend/index.js') to make + * the backend traceable, then pass the definition here + * + * @param {Object} definition - The app definition object (backend's Definition export) + */ +function setAppDefinition(definition) { + cachedAppDefinition = definition; +} + +/** + * Loads the App definition from the nearest backend package. + * + * If setAppDefinition() was called, returns the cached definition + * immediately (no filesystem discovery needed). + * + * Otherwise, discovers the backend via process.cwd() traversal. + * + * Returns the full appDefinition object plus convenience destructured fields + * for backward compatibility (integrations, userConfig). + * * @function loadAppDefinition - * @description Searches for the nearest backend package.json, loads the corresponding index.js file, - * and extracts the application definition containing integrations and user configuration. - * @returns {{integrations: Array, userConfig: object | null}} An object containing the application definition. - * @throws {Error} Throws error if backend package.json cannot be found. - * @throws {Error} Throws error if index.js file cannot be found in the backend directory. + * @returns {{ + * integrations: Array, + * userConfig: object | null, + * appDefinition: object + * }} + * @throws {Error} If backend package.json or index.js cannot be found. * @example - * const { integrations, userConfig } = loadAppDefinition(); - * console.log(`Found ${integrations.length} integrations`); + * // Existing callers still work: + * const { integrations, userConfig } = loadAppDefinition(); + * + * // New callers can access the full definition: + * const { appDefinition } = loadAppDefinition(); + * console.log(appDefinition.provider); // 'aws' | 'netlify' */ function loadAppDefinition() { + if (cachedAppDefinition) { + const { integrations = [], user: userConfig = null } = cachedAppDefinition; + return { integrations, userConfig, appDefinition: cachedAppDefinition }; + } + const backendPath = findNearestBackendPackageJson(); if (!backendPath) { throw new Error('Could not find backend package.json'); @@ -30,9 +72,10 @@ function loadAppDefinition() { const appDefinition = backendJsFile.Definition; const { integrations = [], user: userConfig = null } = appDefinition; - return { integrations, userConfig }; + return { integrations, userConfig, appDefinition }; } module.exports = { loadAppDefinition, + setAppDefinition, }; diff --git a/packages/core/handlers/app-definition-loader.test.js b/packages/core/handlers/app-definition-loader.test.js new file mode 100644 index 000000000..7cf250372 --- /dev/null +++ b/packages/core/handlers/app-definition-loader.test.js @@ -0,0 +1,44 @@ +const { loadAppDefinition, setAppDefinition } = require('./app-definition-loader'); + +describe('app-definition-loader', () => { + afterEach(() => { + // Clear the cached definition between tests + setAppDefinition(null); + }); + + describe('setAppDefinition', () => { + test('caches the definition so loadAppDefinition skips discovery', () => { + const mockDefinition = { + name: 'test-app', + integrations: [{ Definition: { name: 'hubspot' } }], + user: { primary: 'individual' }, + }; + + setAppDefinition(mockDefinition); + + const result = loadAppDefinition(); + + expect(result.appDefinition).toBe(mockDefinition); + expect(result.integrations).toEqual(mockDefinition.integrations); + expect(result.userConfig).toEqual(mockDefinition.user); + }); + + test('returns empty integrations when definition has none', () => { + setAppDefinition({ name: 'minimal-app' }); + + const result = loadAppDefinition(); + + expect(result.integrations).toEqual([]); + expect(result.userConfig).toBeNull(); + }); + + test('can be cleared by passing null', () => { + setAppDefinition({ name: 'test' }); + setAppDefinition(null); + + // Without a cached definition and no backend dir, loadAppDefinition + // falls through to discovery which will throw in test environment + expect(() => loadAppDefinition()).toThrow(); + }); + }); +}); diff --git a/packages/core/handlers/routers/admin.js b/packages/core/handlers/routers/admin.js index 90c08a331..5960db8d8 100644 --- a/packages/core/handlers/routers/admin.js +++ b/packages/core/handlers/routers/admin.js @@ -25,17 +25,41 @@ const { } = require('../../user/use-cases/create-token-for-user-id'); const { DeleteUser } = require('../../user/use-cases/delete-user'); -// Initialize repositories and use cases -const { userConfig } = loadAppDefinition(); -const userRepository = createUserRepository({ userConfig }); -const moduleRepository = createModuleRepository(); - -// Use cases -const getModuleEntityById = new GetModuleEntityById({ moduleRepository }); -const updateModuleEntity = new UpdateModuleEntity({ moduleRepository }); -const deleteModuleEntity = new DeleteModuleEntity({ moduleRepository }); -const createTokenForUserId = new CreateTokenForUserId({ userRepository }); -const deleteUser = new DeleteUser({ userRepository }); +// Lazy-initialized repositories and use cases (deferred until first request) +let _userRepository, + _moduleRepository, + _getModuleEntityById, + _updateModuleEntity, + _deleteModuleEntity, + _createTokenForUserId, + _deleteUser; + +function ensureInitialized() { + if (!_userRepository) { + const { userConfig } = loadAppDefinition(); + _userRepository = createUserRepository({ userConfig }); + _moduleRepository = createModuleRepository(); + _getModuleEntityById = new GetModuleEntityById({ + moduleRepository: _moduleRepository, + }); + _updateModuleEntity = new UpdateModuleEntity({ + moduleRepository: _moduleRepository, + }); + _deleteModuleEntity = new DeleteModuleEntity({ + moduleRepository: _moduleRepository, + }); + _createTokenForUserId = new CreateTokenForUserId({ + userRepository: _userRepository, + }); + _deleteUser = new DeleteUser({ userRepository: _userRepository }); + } +} + +// Lazy initialization middleware — runs once on first request +router.use((req, res, next) => { + ensureInitialized(); + next(); +}); // Debug logging router.use((req, res, next) => { @@ -72,14 +96,14 @@ router.get( sort[sortBy] = sortOrder === 'desc' ? -1 : 1; // Use repository to get users - const users = await userRepository.findAllUsers({ + const users = await _userRepository.findAllUsers({ skip, limit: parseInt(limit), sort, excludeFields: ['-hashword'], // Exclude password hash }); - const totalCount = await userRepository.countUsers(); + const totalCount = await _userRepository.countUsers(); res.json({ users, @@ -122,7 +146,7 @@ router.get( sort[sortBy] = sortOrder === 'desc' ? -1 : 1; // Use repository to search users - const users = await userRepository.searchUsers({ + const users = await _userRepository.searchUsers({ query: q, skip, limit: parseInt(limit), @@ -130,7 +154,7 @@ router.get( excludeFields: ['-hashword'], }); - const totalCount = await userRepository.countUsersBySearchQuery(q); + const totalCount = await _userRepository.countUsersBySearchQuery(q); res.json({ users, @@ -175,7 +199,7 @@ router.post( } // Check if user already exists - const existingUser = await userRepository.findIndividualUserByUsername( + const existingUser = await _userRepository.findIndividualUserByUsername( username ); if (existingUser) { @@ -185,7 +209,7 @@ router.post( }); } - const existingEmail = await userRepository.findIndividualUserByEmail( + const existingEmail = await _userRepository.findIndividualUserByEmail( email ); if (existingEmail) { @@ -210,7 +234,7 @@ router.post( if (appUserId) userData.appUserId = appUserId; if (organizationId) userData.organizationId = organizationId; - const user = await userRepository.createIndividualUser(userData); + const user = await _userRepository.createIndividualUser(userData); // Remove sensitive fields const userObj = user.toObject ? user.toObject() : user; @@ -232,7 +256,7 @@ router.get( catchAsyncError(async (req, res) => { const { userId } = req.params; - const user = await userRepository.findUserById(userId); + const user = await _userRepository.findUserById(userId); if (!user) { return res.status(404).json({ @@ -261,7 +285,7 @@ router.post( const { expiresInMinutes = 120 } = req.body; // Find the user - const user = await userRepository.findUserById(userId); + const user = await _userRepository.findUserById(userId); if (!user) { return res.status(404).json({ @@ -271,7 +295,7 @@ router.post( } // Generate token without password verification - const token = await createTokenForUserId.execute( + const token = await _createTokenForUserId.execute( userId, expiresInMinutes ); @@ -295,7 +319,7 @@ router.delete( const { userId } = req.params; // Execute delete user use case - await deleteUser.execute(userId); + await _deleteUser.execute(userId); res.status(204).send(); }) @@ -318,7 +342,7 @@ router.get( if (type) query.type = type; if (status) query.status = status; - const entities = await moduleRepository.findEntitiesBy(query); + const entities = await _moduleRepository.findEntitiesBy(query); res.json({ entities }); }) @@ -333,7 +357,7 @@ router.get( catchAsyncError(async (req, res) => { const { entityId } = req.params; - const entity = await getModuleEntityById.execute(entityId); + const entity = await _getModuleEntityById.execute(entityId); if (!entity || !entity.isGlobal) { return res.status(404).json({ @@ -363,7 +387,7 @@ router.post( } // Create entity with isGlobal flag - const entity = await moduleRepository.createEntity({ + const entity = await _moduleRepository.createEntity({ ...entityData, type, isGlobal: true, @@ -383,7 +407,7 @@ router.put( catchAsyncError(async (req, res) => { const { entityId } = req.params; - const entity = await updateModuleEntity.execute(entityId, req.body); + const entity = await _updateModuleEntity.execute(entityId, req.body); if (!entity) { return res.status(404).json({ @@ -405,7 +429,7 @@ router.delete( catchAsyncError(async (req, res) => { const { entityId } = req.params; - await deleteModuleEntity.execute(entityId); + await _deleteModuleEntity.execute(entityId); res.status(204).send(); }) @@ -420,7 +444,7 @@ router.post( catchAsyncError(async (req, res) => { const { entityId } = req.params; - const entity = await getModuleEntityById.execute(entityId); + const entity = await _getModuleEntityById.execute(entityId); if (!entity || !entity.isGlobal) { return res.status(404).json({ diff --git a/packages/core/handlers/routers/auth.js b/packages/core/handlers/routers/auth.js index 3616aadf7..3818abd7a 100644 --- a/packages/core/handlers/routers/auth.js +++ b/packages/core/handlers/routers/auth.js @@ -3,34 +3,55 @@ const { createAppHandler } = require('./../app-handler-helpers'); const { requireLoggedInUser } = require('./middleware/requireLoggedInUser'); const { loadAppDefinition } = require('../app-definition-loader'); -const router = createIntegrationRouter(); +let _router; +let _handler; -router.route('/api/integrations/redirect/:appId').get((req, res) => { - res.redirect( - `${process.env.FRONTEND_URI}/redirect/${ - req.params.appId - }?${new URLSearchParams(req.query)}` - ); -}); +function ensureRouter() { + if (!_router) { + _router = createIntegrationRouter(); -// Integration settings endpoint -router - .route('/config/integration-settings') - .get(requireLoggedInUser, (req, res) => { - const appDefinition = loadAppDefinition(); + _router + .route('/api/integrations/redirect/:appId') + .get((req, res) => { + res.redirect( + `${process.env.FRONTEND_URI}/redirect/${ + req.params.appId + }?${new URLSearchParams(req.query)}` + ); + }); - const settings = { - autoProvisioningEnabled: - appDefinition.integration?.autoProvisioningEnabled ?? true, - credentialReuseStrategy: - appDefinition.integration?.credentialReuseStrategy ?? 'shared', - allowUserManagedEntities: - appDefinition.integration?.allowUserManagedEntities ?? true, - }; + // Integration settings endpoint + _router + .route('/config/integration-settings') + .get(requireLoggedInUser, (req, res) => { + const appDefinition = loadAppDefinition(); - res.json(settings); - }); + const settings = { + autoProvisioningEnabled: + appDefinition.integration + ?.autoProvisioningEnabled ?? true, + credentialReuseStrategy: + appDefinition.integration + ?.credentialReuseStrategy ?? 'shared', + allowUserManagedEntities: + appDefinition.integration + ?.allowUserManagedEntities ?? true, + }; -const handler = createAppHandler('HTTP Event: Auth', router); + res.json(settings); + }); + } + return _router; +} -module.exports = { handler, router }; +module.exports = { + get router() { + return ensureRouter(); + }, + get handler() { + if (!_handler) { + _handler = createAppHandler('HTTP Event: Auth', ensureRouter()); + } + return _handler; + }, +}; diff --git a/packages/core/handlers/routers/db-migration.js b/packages/core/handlers/routers/db-migration.js index d3fee4ccb..a9d8ba0aa 100644 --- a/packages/core/handlers/routers/db-migration.js +++ b/packages/core/handlers/routers/db-migration.js @@ -21,9 +21,7 @@ const { Router } = require('express'); const catchAsyncError = require('express-async-handler'); const { validateAdminApiKey } = require('../middleware/admin-auth'); -const { - MigrationStatusRepositoryS3, -} = require('../../database/repositories/migration-status-repository-s3'); +const { resolveProvider } = require('../../providers/resolve-provider'); const { TriggerDatabaseMigrationUseCase, ValidationError: TriggerValidationError, @@ -33,39 +31,71 @@ const { ValidationError: GetValidationError, NotFoundError, } = require('../../database/use-cases/get-migration-status-use-case'); -const { LambdaInvoker } = require('../../database/adapters/lambda-invoker'); const { GetDatabaseStateViaWorkerUseCase, } = require('../../database/use-cases/get-database-state-via-worker-use-case'); const router = Router(); -// Dependency injection -// Use S3 repository to avoid User table dependency (chicken-and-egg problem) -const bucketName = - process.env.S3_BUCKET_NAME || process.env.MIGRATION_STATUS_BUCKET; -const migrationStatusRepository = new MigrationStatusRepositoryS3(bucketName); - -const triggerMigrationUseCase = new TriggerDatabaseMigrationUseCase({ - migrationStatusRepository, - // Note: QueuerUtil is used directly in the use case (static utility) -}); -const getStatusUseCase = new GetMigrationStatusUseCase({ - migrationStatusRepository, -}); - -// Lambda invocation for database state check (keeps router lightweight) -const lambdaInvoker = new LambdaInvoker(); -const workerFunctionName = - process.env.WORKER_FUNCTION_NAME || - `${process.env.SERVICE || 'unknown'}-${ - process.env.STAGE || 'production' - }-dbMigrationWorker`; - -const getDatabaseStateUseCase = new GetDatabaseStateViaWorkerUseCase({ - lambdaInvoker, - workerFunctionName, -}); +// Dependency injection — lazy-initialized on first request. +// Uses resolveProvider() to get the correct adapters for the active platform. +let _migrationStatusRepository = null; +let _triggerMigrationUseCase = null; +let _getStatusUseCase = null; +let _functionInvoker = null; + +function getMigrationDeps() { + if (!_migrationStatusRepository) { + const provider = resolveProvider(); + + // Migration status storage (AWS: S3, others: provider-specific) + const MigrationStatusRepository = provider.MigrationStatusRepositoryS3; + if (!MigrationStatusRepository) { + throw new Error( + `Provider '${provider.name}' does not export a MigrationStatusRepository` + ); + } + const bucketName = + process.env.S3_BUCKET_NAME || process.env.MIGRATION_STATUS_BUCKET; + _migrationStatusRepository = new MigrationStatusRepository(bucketName); + _triggerMigrationUseCase = new TriggerDatabaseMigrationUseCase({ + migrationStatusRepository: _migrationStatusRepository, + }); + _getStatusUseCase = new GetMigrationStatusUseCase({ + migrationStatusRepository: _migrationStatusRepository, + }); + + // Function invoker (AWS: LambdaInvoker, Netlify: HTTP-based) + _functionInvoker = provider.invokeFunctionAdapter; + if (!_functionInvoker) { + throw new Error( + `Provider '${provider.name}' does not export an invokeFunctionAdapter` + ); + } + } + return { + migrationStatusRepository: _migrationStatusRepository, + triggerMigrationUseCase: _triggerMigrationUseCase, + getStatusUseCase: _getStatusUseCase, + lambdaInvoker: _functionInvoker, + }; +} +let _getDatabaseStateUseCase = null; +function getDatabaseStateUseCaseInstance() { + if (!_getDatabaseStateUseCase) { + const { lambdaInvoker } = getMigrationDeps(); + const workerFunctionName = + process.env.WORKER_FUNCTION_NAME || + `${process.env.SERVICE || 'unknown'}-${ + process.env.STAGE || 'production' + }-dbMigrationWorker`; + _getDatabaseStateUseCase = new GetDatabaseStateViaWorkerUseCase({ + lambdaInvoker, + workerFunctionName, + }); + } + return _getDatabaseStateUseCase; +} // Apply admin API key validation to all routes (shared middleware) router.use(validateAdminApiKey); @@ -106,7 +136,7 @@ router.post( ); try { - const result = await triggerMigrationUseCase.execute({ + const result = await getMigrationDeps().triggerMigrationUseCase.execute({ userId, dbType, stage, @@ -153,12 +183,12 @@ router.get( const stage = req.query.stage || process.env.STAGE || 'production'; console.log( - `Checking database state: stage=${stage}, worker=${workerFunctionName}` + `Checking database state: stage=${stage}` ); try { // Invoke worker Lambda to check database state - const status = await getDatabaseStateUseCase.execute(stage); + const status = await getDatabaseStateUseCaseInstance().execute(stage); res.status(200).json(status); } catch (error) { @@ -210,7 +240,7 @@ router.get( ); try { - const status = await getStatusUseCase.execute(migrationId, stage); + const status = await getMigrationDeps().getStatusUseCase.execute(migrationId, stage); res.status(200).json(status); } catch (error) { diff --git a/packages/core/handlers/routers/health.js b/packages/core/handlers/routers/health.js index 56b5af370..f3fe8ec9f 100644 --- a/packages/core/handlers/routers/health.js +++ b/packages/core/handlers/routers/health.js @@ -29,49 +29,58 @@ const { } = require('../use-cases/check-integrations-health-use-case'); const router = Router(); -const healthCheckRepository = createHealthCheckRepository({ - prismaClient: prisma, -}); -// Load integrations and create factories just like auth router does -// This verifies the system can properly load integrations -let moduleFactory, integrationClasses; -try { - const appDef = loadAppDefinition(); - integrationClasses = appDef.integrations || []; +// Lazy-initialized use cases (deferred until first request) +let _checkDatabaseHealthUseCase, + _checkEncryptionHealthUseCase, + _checkExternalApisHealthUseCase, + _checkIntegrationsHealthUseCase; - const moduleRepository = createModuleRepository(); - const moduleDefinitions = - getModulesDefinitionFromIntegrationClasses(integrationClasses); +function ensureInitialized() { + if (!_checkDatabaseHealthUseCase) { + const healthCheckRepository = createHealthCheckRepository({ + prismaClient: prisma, + }); - moduleFactory = new ModuleFactory({ - moduleRepository, - moduleDefinitions, - }); -} catch (error) { - console.error( - 'Failed to load integrations for health check:', - error.message - ); - // Factories will be undefined, health check will report unhealthy - moduleFactory = undefined; - integrationClasses = []; -} + let moduleFactory, integrationClasses; + try { + const appDef = loadAppDefinition(); + integrationClasses = appDef.integrations || []; -const testEncryptionUseCase = new TestEncryptionUseCase({ - healthCheckRepository, -}); -const checkDatabaseHealthUseCase = new CheckDatabaseHealthUseCase({ - healthCheckRepository, -}); -const checkEncryptionHealthUseCase = new CheckEncryptionHealthUseCase({ - testEncryptionUseCase, -}); -const checkExternalApisHealthUseCase = new CheckExternalApisHealthUseCase(); -const checkIntegrationsHealthUseCase = new CheckIntegrationsHealthUseCase({ - moduleFactory, - integrationClasses, -}); + const moduleRepository = createModuleRepository(); + const moduleDefinitions = + getModulesDefinitionFromIntegrationClasses(integrationClasses); + + moduleFactory = new ModuleFactory({ + moduleRepository, + moduleDefinitions, + }); + } catch (error) { + console.error( + 'Failed to load integrations for health check:', + error.message + ); + moduleFactory = undefined; + integrationClasses = []; + } + + const testEncryptionUseCase = new TestEncryptionUseCase({ + healthCheckRepository, + }); + _checkDatabaseHealthUseCase = new CheckDatabaseHealthUseCase({ + healthCheckRepository, + }); + _checkEncryptionHealthUseCase = new CheckEncryptionHealthUseCase({ + testEncryptionUseCase, + }); + _checkExternalApisHealthUseCase = + new CheckExternalApisHealthUseCase(); + _checkIntegrationsHealthUseCase = new CheckIntegrationsHealthUseCase({ + moduleFactory, + integrationClasses, + }); + } +} const validateApiKey = (req, res, next) => { const apiKey = req.headers['x-frigg-health-api-key']; @@ -93,257 +102,32 @@ const validateApiKey = (req, res, next) => { router.use(validateApiKey); -// Helper to detect VPC configuration -const detectVpcConfiguration = async () => { - const results = { - isInVpc: false, - hasInternetAccess: false, - canResolvePublicDns: false, - canConnectToAws: false, - vpcEndpoints: [], - }; +// Provider-specific health checks are loaded via resolveProvider() +// so core doesn't hardcode any provider package names. +const { resolveProvider } = require('../../providers/resolve-provider'); +function getResolvedProvider() { try { - // Check if we're in a VPC by looking for VPC-specific environment - // Lambda in VPC has specific network interface configuration - const dns = require('dns').promises; - - // Test 1: Can we resolve public DNS? (indicates DNS configuration) - try { - await Promise.race([ - dns.resolve4('www.google.com'), - new Promise((_, reject) => - setTimeout(() => reject(new Error('timeout')), 2000) - ), - ]); - results.canResolvePublicDns = true; - } catch (e) { - console.log('Public DNS resolution failed:', e.message); - } - - // Test 2: Can we reach internet? (indicates NAT gateway) - try { - const https = require('https'); - await new Promise((resolve, reject) => { - const req = https.get( - 'https://www.google.com', - { timeout: 2000 }, - (res) => { - res.destroy(); - resolve(true); - } - ); - req.on('error', reject); - req.on('timeout', () => { - req.destroy(); - reject(new Error('timeout')); - }); - }); - results.hasInternetAccess = true; - } catch (e) { - console.log('Internet connectivity test failed:', e.message); - } - - // Test 3: Check for VPC endpoints by trying to resolve internal AWS endpoints - const region = process.env.AWS_REGION; // Lambda always provides this - const vpcEndpointDomains = [ - `com.amazonaws.${region}.kms`, - `com.amazonaws.vpce.${region}`, - `kms.${region}.amazonaws.com`, - ]; - - for (const domain of vpcEndpointDomains) { - try { - const addresses = await Promise.race([ - dns.resolve4(domain).catch(() => dns.resolve6(domain)), - new Promise((_, reject) => - setTimeout(() => reject(new Error('timeout')), 1000) - ), - ]); - if (addresses && addresses.length > 0) { - // Check if it's a private IP (VPC endpoint indicator) - const isPrivateIp = addresses.some( - (ip) => - ip.startsWith('10.') || - ip.startsWith('172.') || - ip.startsWith('192.168.') - ); - if (isPrivateIp) { - results.vpcEndpoints.push(domain); - } - } - } catch (e) { - // Expected for non-existent endpoints - } - } - - // Check if Lambda is in VPC using VPC_ENABLED env var set by infrastructure - results.isInVpc = - process.env.VPC_ENABLED === 'true' || - (!results.hasInternetAccess && results.canResolvePublicDns) || - results.vpcEndpoints.length > 0; - - results.canConnectToAws = - results.hasInternetAccess || results.vpcEndpoints.length > 0; - } catch (error) { - console.error('VPC detection error:', error.message); + return resolveProvider(); + } catch { + return null; } +} - return results; -}; - -// KMS decrypt capability check const checkKmsDecryptCapability = async () => { - const start = Date.now(); - const { KMS_KEY_ARN } = process.env; - if (!KMS_KEY_ARN) { - return { - status: 'skipped', - reason: 'KMS_KEY_ARN not configured', - }; + const provider = getResolvedProvider(); + if (!provider?.checkKmsDecryptCapability) { + return { status: 'skipped', reason: 'Provider does not support KMS health checks' }; } + return provider.checkKmsDecryptCapability(); +}; - // Log environment for debugging - console.log('KMS Check Debug:', { - hasKmsKeyArn: !!KMS_KEY_ARN, - kmsKeyArnPrefix: KMS_KEY_ARN?.substring(0, 30), - awsRegion: process.env.AWS_REGION, - hasDiscoveryKey: !!process.env.AWS_DISCOVERY_KMS_KEY_ID, - }); - - // First, detect VPC configuration - const vpcConfig = await detectVpcConfiguration(); - console.log('VPC Configuration:', vpcConfig); - - // Test DNS resolution for KMS endpoint - try { - const dns = require('dns').promises; - const region = process.env.AWS_REGION; // Lambda always provides this - const kmsEndpoint = `kms.${region}.amazonaws.com`; - console.log('Testing DNS resolution for:', kmsEndpoint); - - // Wrap DNS resolution in a timeout - const dnsPromise = dns.resolve4(kmsEndpoint); - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('DNS resolution timeout')), 3000) - ); - - const addresses = await Promise.race([dnsPromise, timeoutPromise]); - console.log('KMS endpoint resolved to:', addresses); - - // Check if resolved to private IP (VPC endpoint) - const isVpcEndpoint = addresses.some( - (ip) => - ip.startsWith('10.') || - ip.startsWith('172.') || - ip.startsWith('192.168.') - ); - - if (isVpcEndpoint) { - console.log( - 'KMS VPC Endpoint detected - using private connectivity' - ); - } - - // Test TCP connectivity to KMS (port 443) - const net = require('net'); - const testConnection = () => - new Promise((resolve) => { - const socket = new net.Socket(); - const connectionTimeout = setTimeout(() => { - socket.destroy(); - resolve({ connected: false, error: 'Connection timeout' }); - }, 3000); - - socket.on('connect', () => { - clearTimeout(connectionTimeout); - socket.destroy(); - resolve({ connected: true }); - }); - - socket.on('error', (err) => { - clearTimeout(connectionTimeout); - resolve({ connected: false, error: err.message }); - }); - - // Try connecting to first resolved address on HTTPS port - socket.connect(443, addresses[0]); - }); - - const connResult = await testConnection(); - console.log('TCP connectivity test:', connResult); - - if (!connResult.connected) { - return { - status: 'unhealthy', - error: `Cannot connect to KMS endpoint: ${connResult.error}`, - dnsResolved: true, - tcpConnection: false, - vpcConfig, - latencyMs: Date.now() - start, - }; - } - } catch (dnsError) { - console.error('DNS resolution failed:', dnsError.message); - return { - status: 'unhealthy', - error: `Cannot resolve KMS endpoint: ${dnsError.message}`, - dnsResolved: false, - vpcConfig, - latencyMs: Date.now() - start, - }; - } - - try { - // Use AWS SDK v3 for consistency with the rest of the codebase - // eslint-disable-next-line global-require - const { - KMSClient, - GenerateDataKeyCommand, - DecryptCommand, - } = require('@aws-sdk/client-kms'); - - // Lambda always provides AWS_REGION - const region = process.env.AWS_REGION; - - const kms = new KMSClient({ - region, - requestHandler: { - connectionTimeout: 10000, // 10 second connection timeout - requestTimeout: 25000, // 25 second timeout for slow VPC connections - }, - maxAttempts: 1, // No retries on health checks - }); - - // Generate a data key (without plaintext logging) then immediately decrypt ciphertext to ensure decrypt perms. - const dataKeyResp = await kms.send( - new GenerateDataKeyCommand({ - KeyId: KMS_KEY_ARN, - KeySpec: 'AES_256', - }) - ); - const decryptResp = await kms.send( - new DecryptCommand({ CiphertextBlob: dataKeyResp.CiphertextBlob }) - ); - - const success = Boolean( - dataKeyResp.CiphertextBlob && decryptResp.Plaintext - ); - - return { - status: success ? 'healthy' : 'unhealthy', - kmsKeyArnSuffix: KMS_KEY_ARN.slice(-12), - vpcConfig, - latencyMs: Date.now() - start, - }; - } catch (error) { - return { - status: 'unhealthy', - error: error.message, - vpcConfig, - latencyMs: Date.now() - start, - }; +const detectVpcConfiguration = async () => { + const provider = getResolvedProvider(); + if (!provider?.detectVpcConfiguration) { + return { status: 'skipped', reason: 'Provider does not support VPC detection' }; } + return provider.detectVpcConfiguration(); }; router.get('/health', async (_req, res) => { @@ -357,6 +141,7 @@ router.get('/health', async (_req, res) => { }); router.get('/health/detailed', async (_req, res) => { + ensureInitialized(); console.log('Starting detailed health check'); const startTime = Date.now(); @@ -422,7 +207,7 @@ router.get('/health/detailed', async (_req, res) => { } try { - response.checks.database = await checkDatabaseHealthUseCase.execute(); + response.checks.database = await _checkDatabaseHealthUseCase.execute(); if (response.checks.database.status === 'unhealthy') { response.status = 'unhealthy'; } @@ -438,7 +223,7 @@ router.get('/health/detailed', async (_req, res) => { try { response.checks.encryption = - await checkEncryptionHealthUseCase.execute(); + await _checkEncryptionHealthUseCase.execute(); if (response.checks.encryption.status === 'unhealthy') { response.status = 'unhealthy'; } @@ -454,7 +239,7 @@ router.get('/health/detailed', async (_req, res) => { try { const { apiStatuses, allReachable } = - await checkExternalApisHealthUseCase.execute(); + await _checkExternalApisHealthUseCase.execute(); response.checks.externalApis = apiStatuses; if (!allReachable) { response.status = 'unhealthy'; @@ -473,7 +258,7 @@ router.get('/health/detailed', async (_req, res) => { } try { - response.checks.integrations = checkIntegrationsHealthUseCase.execute(); + response.checks.integrations = _checkIntegrationsHealthUseCase.execute(); console.log( 'Integrations check completed:', response.checks.integrations @@ -508,10 +293,11 @@ router.get('/health/live', (_req, res) => { }); router.get('/health/ready', async (_req, res) => { - const dbHealth = await checkDatabaseHealthUseCase.execute(); + ensureInitialized(); + const dbHealth = await _checkDatabaseHealthUseCase.execute(); const isDbReady = dbHealth.status === 'healthy'; - const integrationsHealth = checkIntegrationsHealthUseCase.execute(); + const integrationsHealth = _checkIntegrationsHealthUseCase.execute(); const areModulesReady = integrationsHealth.modules.count > 0; const isReady = isDbReady && areModulesReady; diff --git a/packages/core/handlers/routers/health.test.js b/packages/core/handlers/routers/health.test.js index 196934ec7..ca3361729 100644 --- a/packages/core/handlers/routers/health.test.js +++ b/packages/core/handlers/routers/health.test.js @@ -7,37 +7,71 @@ jest.mock('../../database/config', () => ({ PRISMA_QUERY_LOGGING: false, })); -jest.mock('mongoose', () => ({ - set: jest.fn(), - connection: { - readyState: 1, - db: { - admin: () => ({ - ping: jest.fn().mockResolvedValue(true), - }), - }, +const mockPrisma = { + $runCommandRaw: jest.fn().mockResolvedValue({ ok: 1 }), + credential: { + create: jest.fn(), + findUnique: jest.fn(), + delete: jest.fn(), }, +}; + +jest.mock('../../database/prisma', () => ({ + prisma: mockPrisma, + connectPrisma: jest.fn(), + disconnectPrisma: jest.fn(), })); -jest.mock('./../backend-utils', () => ({ - moduleFactory: { - moduleTypes: ['test-module', 'another-module'], - }, - integrationFactory: { - integrationTypes: ['test-integration', 'another-integration'], - }, +const mockHealthCheckRepository = { + getDatabaseConnectionState: jest.fn().mockResolvedValue({ + readyState: 1, stateName: 'connected', isConnected: true, + }), + pingDatabase: jest.fn().mockResolvedValue(1), + createCredential: jest.fn(), + findCredentialById: jest.fn(), + getRawCredentialById: jest.fn(), + deleteCredential: jest.fn(), +}; + +jest.mock('../../database/repositories/health-check-repository-factory', () => ({ + createHealthCheckRepository: jest.fn(() => mockHealthCheckRepository), + HealthCheckRepositoryMongoDB: jest.fn(), + HealthCheckRepositoryPostgreSQL: jest.fn(), + HealthCheckRepositoryDocumentDB: jest.fn(), +})); + +jest.mock('./../app-definition-loader', () => ({ + loadAppDefinition: jest.fn(() => ({ + integrations: [{ Definition: { name: 'test-integration' } }], + })), +})); + +jest.mock('../../integrations/utils/map-integration-dto', () => ({ + getModulesDefinitionFromIntegrationClasses: jest.fn(() => [ + { moduleName: 'test-module' }, + { moduleName: 'another-module' }, + ]), +})); + +jest.mock('../../modules/repositories/module-repository-factory', () => ({ + createModuleRepository: jest.fn(() => ({})), +})); + +jest.mock('../../modules/module-factory', () => ({ + ModuleFactory: jest.fn().mockImplementation(({ moduleDefinitions }) => ({ + moduleDefinitions, + })), })); jest.mock('./../app-handler-helpers', () => ({ - createAppHandler: jest.fn((name, router) => ({ name, router })), + createAppHandler: jest.fn((name, router) => ({ name, router })) })); const { router } = require('./health'); -const mongoose = require('mongoose'); const mockRequest = (path, headers = {}) => ({ path, - headers, + headers }); const mockResponse = () => { @@ -49,7 +83,10 @@ const mockResponse = () => { describe('Health Check Endpoints', () => { beforeEach(() => { - mongoose.connection.readyState = 1; + mockHealthCheckRepository.getDatabaseConnectionState.mockResolvedValue({ + readyState: 1, stateName: 'connected', isConnected: true, + }); + mockHealthCheckRepository.pingDatabase.mockResolvedValue(1); }); describe('Middleware - validateApiKey', () => { @@ -63,8 +100,8 @@ describe('Health Check Endpoints', () => { const req = mockRequest('/health'); const res = mockResponse(); - const routeHandler = router.stack.find( - (layer) => layer.route && layer.route.path === '/health' + const routeHandler = router.stack.find(layer => + layer.route && layer.route.path === '/health' ).route.stack[0].handle; await routeHandler(req, res); @@ -73,39 +110,24 @@ describe('Health Check Endpoints', () => { expect(res.json).toHaveBeenCalledWith({ status: 'ok', timestamp: expect.any(String), - service: 'frigg-core-api', + service: 'frigg-core-api' }); }); }); describe('GET /health/detailed', () => { it('should return detailed health status when healthy', async () => { - const req = mockRequest('/health/detailed', { - 'x-frigg-health-api-key': 'test-api-key', - }); + const req = mockRequest('/health/detailed', { 'x-frigg-health-api-key': 'test-api-key' }); const res = mockResponse(); const originalPromiseAll = Promise.all; Promise.all = jest.fn().mockResolvedValue([ - { - name: 'github', - status: 'healthy', - reachable: true, - statusCode: 200, - responseTime: 100, - }, - { - name: 'npm', - status: 'healthy', - reachable: true, - statusCode: 200, - responseTime: 150, - }, + { name: 'github', status: 'healthy', reachable: true, statusCode: 200, responseTime: 100 }, + { name: 'npm', status: 'healthy', reachable: true, statusCode: 200, responseTime: 150 } ]); - const routeHandler = router.stack.find( - (layer) => - layer.route && layer.route.path === '/health/detailed' + const routeHandler = router.stack.find(layer => + layer.route && layer.route.path === '/health/detailed' ).route.stack[0].handle; await routeHandler(req, res); @@ -113,23 +135,21 @@ describe('Health Check Endpoints', () => { Promise.all = originalPromiseAll; expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'healthy', - service: 'frigg-core-api', - timestamp: expect.any(String), - checks: expect.objectContaining({ - database: expect.objectContaining({ - status: 'healthy', - state: 'connected', - }), - integrations: expect.objectContaining({ - status: 'healthy', - }), + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + status: 'healthy', + service: 'frigg-core-api', + timestamp: expect.any(String), + checks: expect.objectContaining({ + database: expect.objectContaining({ + status: 'healthy', + state: 'connected' }), - responseTime: expect.any(Number), - }) - ); + integrations: expect.objectContaining({ + status: 'healthy' + }) + }), + responseTime: expect.any(Number) + })); const response = res.json.mock.calls[0][0]; expect(response).not.toHaveProperty('version'); @@ -139,34 +159,21 @@ describe('Health Check Endpoints', () => { }); it('should return 503 when database is disconnected', async () => { - mongoose.connection.readyState = 0; - - const req = mockRequest('/health/detailed', { - 'x-frigg-health-api-key': 'test-api-key', + mockHealthCheckRepository.getDatabaseConnectionState.mockResolvedValue({ + readyState: 0, stateName: 'disconnected', isConnected: false, }); + + const req = mockRequest('/health/detailed', { 'x-frigg-health-api-key': 'test-api-key' }); const res = mockResponse(); const originalPromiseAll = Promise.all; Promise.all = jest.fn().mockResolvedValue([ - { - name: 'github', - status: 'healthy', - reachable: true, - statusCode: 200, - responseTime: 100, - }, - { - name: 'npm', - status: 'healthy', - reachable: true, - statusCode: 200, - responseTime: 150, - }, + { name: 'github', status: 'healthy', reachable: true, statusCode: 200, responseTime: 100 }, + { name: 'npm', status: 'healthy', reachable: true, statusCode: 200, responseTime: 150 } ]); - const routeHandler = router.stack.find( - (layer) => - layer.route && layer.route.path === '/health/detailed' + const routeHandler = router.stack.find(layer => + layer.route && layer.route.path === '/health/detailed' ).route.stack[0].handle; await routeHandler(req, res); @@ -174,23 +181,19 @@ describe('Health Check Endpoints', () => { Promise.all = originalPromiseAll; expect(res.status).toHaveBeenCalledWith(503); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'unhealthy', - }) - ); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + status: 'unhealthy' + })); }); }); describe('GET /health/live', () => { it('should return alive status', async () => { - const req = mockRequest('/health/live', { - 'x-frigg-health-api-key': 'test-api-key', - }); + const req = mockRequest('/health/live', { 'x-frigg-health-api-key': 'test-api-key' }); const res = mockResponse(); - const routeHandler = router.stack.find( - (layer) => layer.route && layer.route.path === '/health/live' + const routeHandler = router.stack.find(layer => + layer.route && layer.route.path === '/health/live' ).route.stack[0].handle; routeHandler(req, res); @@ -198,20 +201,18 @@ describe('Health Check Endpoints', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ status: 'alive', - timestamp: expect.any(String), + timestamp: expect.any(String) }); }); }); describe('GET /health/ready', () => { it('should return ready when all checks pass', async () => { - const req = mockRequest('/health/ready', { - 'x-frigg-health-api-key': 'test-api-key', - }); + const req = mockRequest('/health/ready', { 'x-frigg-health-api-key': 'test-api-key' }); const res = mockResponse(); - const routeHandler = router.stack.find( - (layer) => layer.route && layer.route.path === '/health/ready' + const routeHandler = router.stack.find(layer => + layer.route && layer.route.path === '/health/ready' ).route.stack[0].handle; await routeHandler(req, res); @@ -222,31 +223,29 @@ describe('Health Check Endpoints', () => { timestamp: expect.any(String), checks: { database: true, - modules: true, - }, + modules: true + } }); }); it('should return 503 when database is not connected', async () => { - mongoose.connection.readyState = 0; - - const req = mockRequest('/health/ready', { - 'x-frigg-health-api-key': 'test-api-key', + mockHealthCheckRepository.getDatabaseConnectionState.mockResolvedValue({ + readyState: 0, stateName: 'disconnected', isConnected: false, }); + + const req = mockRequest('/health/ready', { 'x-frigg-health-api-key': 'test-api-key' }); const res = mockResponse(); - const routeHandler = router.stack.find( - (layer) => layer.route && layer.route.path === '/health/ready' + const routeHandler = router.stack.find(layer => + layer.route && layer.route.path === '/health/ready' ).route.stack[0].handle; await routeHandler(req, res); expect(res.status).toHaveBeenCalledWith(503); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - ready: false, - }) - ); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + ready: false + })); }); }); }); diff --git a/packages/core/handlers/routers/integration-defined-routers.js b/packages/core/handlers/routers/integration-defined-routers.js index bff5e455a..51423e6bf 100644 --- a/packages/core/handlers/routers/integration-defined-routers.js +++ b/packages/core/handlers/routers/integration-defined-routers.js @@ -3,43 +3,54 @@ const { loadAppDefinition } = require('../app-definition-loader'); const { Router } = require('express'); const { loadRouterFromObject } = require('../backend-utils'); -const handlers = {}; -const { integrations: integrationClasses } = loadAppDefinition(); +let _handlers; -//todo: this should be in a use case class -for (const IntegrationClass of integrationClasses) { - const router = Router(); - const basePath = `/api/${IntegrationClass.Definition.name}-integration`; +function ensureHandlers() { + if (!_handlers) { + _handlers = {}; + const { integrations: integrationClasses } = loadAppDefinition(); - console.log( - `\n│ Configuring routes for ${IntegrationClass.Definition.name} Integration:` - ); + //todo: this should be in a use case class + for (const IntegrationClass of integrationClasses) { + const router = Router(); + const basePath = `/api/${IntegrationClass.Definition.name}-integration`; - for (const routeDef of IntegrationClass.Definition.routes) { - if (typeof routeDef === 'function') { - router.use(basePath, routeDef(IntegrationClass)); - console.log(`│ ANY ${basePath}/* (function handler)`); - } else if (typeof routeDef === 'object') { - router.use( - basePath, - loadRouterFromObject(IntegrationClass, routeDef) + console.log( + `\n│ Configuring routes for ${IntegrationClass.Definition.name} Integration:` ); - const method = (routeDef.method || 'ANY').toUpperCase(); - const fullPath = `${basePath}${routeDef.path}`; - console.log(`│ ${method} ${fullPath}`); - } else if (routeDef instanceof express.Router) { - router.use(basePath, routeDef); - console.log(`│ ANY ${basePath}/* (express router)`); + + for (const routeDef of IntegrationClass.Definition.routes) { + if (typeof routeDef === 'function') { + router.use(basePath, routeDef(IntegrationClass)); + console.log(`│ ANY ${basePath}/* (function handler)`); + } else if (typeof routeDef === 'object') { + router.use( + basePath, + loadRouterFromObject(IntegrationClass, routeDef) + ); + const method = (routeDef.method || 'ANY').toUpperCase(); + const fullPath = `${basePath}${routeDef.path}`; + console.log(`│ ${method} ${fullPath}`); + } else if (routeDef instanceof express.Router) { + router.use(basePath, routeDef); + console.log(`│ ANY ${basePath}/* (express router)`); + } + } + console.log('│'); + + _handlers[`${IntegrationClass.Definition.name}`] = { + handler: createAppHandler( + `HTTP Event: ${IntegrationClass.Definition.name}`, + router + ), + }; } } - console.log('│'); - - handlers[`${IntegrationClass.Definition.name}`] = { - handler: createAppHandler( - `HTTP Event: ${IntegrationClass.Definition.name}`, - router - ), - }; + return _handlers; } -module.exports = { handlers }; +module.exports = { + get handlers() { + return ensureHandlers(); + }, +}; diff --git a/packages/core/handlers/routers/integration-webhook-routers.js b/packages/core/handlers/routers/integration-webhook-routers.js index a3b9be646..974a55601 100644 --- a/packages/core/handlers/routers/integration-webhook-routers.js +++ b/packages/core/handlers/routers/integration-webhook-routers.js @@ -5,73 +5,84 @@ const { IntegrationEventDispatcher, } = require('../integration-event-dispatcher'); -const handlers = {}; -const { integrations: integrationClasses } = loadAppDefinition(); +let _handlers; -for (const IntegrationClass of integrationClasses) { - const webhookConfig = IntegrationClass.Definition.webhooks; +function ensureHandlers() { + if (!_handlers) { + _handlers = {}; + const { integrations: integrationClasses } = loadAppDefinition(); - // Skip if webhooks not enabled - if ( - !webhookConfig || - (typeof webhookConfig === 'object' && !webhookConfig.enabled) - ) { - continue; - } + for (const IntegrationClass of integrationClasses) { + const webhookConfig = IntegrationClass.Definition.webhooks; - const router = Router(); - const basePath = `/api/${IntegrationClass.Definition.name}-integration/webhooks`; + // Skip if webhooks not enabled + if ( + !webhookConfig || + (typeof webhookConfig === 'object' && !webhookConfig.enabled) + ) { + continue; + } - console.log( - `\n│ Configuring webhook routes for ${IntegrationClass.Definition.name}:` - ); + const router = Router(); + const basePath = `/api/${IntegrationClass.Definition.name}-integration/webhooks`; - // General webhook route (no integration ID) - router.post(basePath, async (req, res, next) => { - try { - const integrationInstance = new IntegrationClass(); - const dispatcher = new IntegrationEventDispatcher( - integrationInstance + console.log( + `\n│ Configuring webhook routes for ${IntegrationClass.Definition.name}:` ); - await dispatcher.dispatchHttp({ - event: 'WEBHOOK_RECEIVED', - req, - res, - next, + + // General webhook route (no integration ID) + router.post(basePath, async (req, res, next) => { + try { + const integrationInstance = new IntegrationClass(); + const dispatcher = new IntegrationEventDispatcher( + integrationInstance + ); + await dispatcher.dispatchHttp({ + event: 'WEBHOOK_RECEIVED', + req, + res, + next, + }); + } catch (error) { + next(error); + } }); - } catch (error) { - next(error); - } - }); - console.log(`│ POST ${basePath}`); + console.log(`│ POST ${basePath}`); - // Integration-specific webhook route (with integration ID) - router.post(`${basePath}/:integrationId`, async (req, res, next) => { - try { - const integrationInstance = new IntegrationClass(); - const dispatcher = new IntegrationEventDispatcher( - integrationInstance - ); - await dispatcher.dispatchHttp({ - event: 'WEBHOOK_RECEIVED', - req, - res, - next, + // Integration-specific webhook route (with integration ID) + router.post(`${basePath}/:integrationId`, async (req, res, next) => { + try { + const integrationInstance = new IntegrationClass(); + const dispatcher = new IntegrationEventDispatcher( + integrationInstance + ); + await dispatcher.dispatchHttp({ + event: 'WEBHOOK_RECEIVED', + req, + res, + next, + }); + } catch (error) { + next(error); + } }); - } catch (error) { - next(error); - } - }); - console.log(`│ POST ${basePath}/:integrationId`); - console.log('│'); + console.log(`│ POST ${basePath}/:integrationId`); + console.log('│'); - handlers[`${IntegrationClass.Definition.name}Webhook`] = { - handler: createAppHandler( - `HTTP Event: ${IntegrationClass.Definition.name} Webhook`, - router, - false // shouldUseDatabase = false - ), - }; + _handlers[`${IntegrationClass.Definition.name}Webhook`] = { + handler: createAppHandler( + `HTTP Event: ${IntegrationClass.Definition.name} Webhook`, + router, + false // shouldUseDatabase = false + ), + }; + } + } + return _handlers; } -module.exports = { handlers }; +module.exports = { + get handlers() { + return ensureHandlers(); + }, +}; diff --git a/packages/core/handlers/routers/user.js b/packages/core/handlers/routers/user.js index 652a5f667..6c88c7615 100644 --- a/packages/core/handlers/routers/user.js +++ b/packages/core/handlers/routers/user.js @@ -15,17 +15,33 @@ const catchAsyncError = require('express-async-handler'); const { loadAppDefinition } = require('../app-definition-loader'); const router = express(); -const { userConfig } = loadAppDefinition(); -const userRepository = createUserRepository(); -const createIndividualUser = new CreateIndividualUser({ - userRepository, - userConfig, -}); -const loginUser = new LoginUser({ - userRepository, - userConfig, + +// Lazy-initialized repositories and use cases (deferred until first request) +let _userRepository, _createIndividualUser, _loginUser, _createTokenForUserId; + +function ensureInitialized() { + if (!_userRepository) { + const { userConfig } = loadAppDefinition(); + _userRepository = createUserRepository(); + _createIndividualUser = new CreateIndividualUser({ + userRepository: _userRepository, + userConfig, + }); + _loginUser = new LoginUser({ + userRepository: _userRepository, + userConfig, + }); + _createTokenForUserId = new CreateTokenForUserId({ + userRepository: _userRepository, + }); + } +} + +// Lazy initialization middleware — runs once on first request +router.use((req, res, next) => { + ensureInitialized(); + next(); }); -const createTokenForUserId = new CreateTokenForUserId({ userRepository }); // define the login endpoint router.route('/user/login').post( @@ -34,8 +50,8 @@ router.route('/user/login').post( 'username', 'password', ]); - const user = await loginUser.execute({ username, password }); - const token = await createTokenForUserId.execute(user.getId(), 120); + const user = await _loginUser.execute({ username, password }); + const token = await _createTokenForUserId.execute(user.getId(), 120); res.status(201); res.json({ token }); }) @@ -48,11 +64,11 @@ router.route('/user/create').post( 'password', ]); - const user = await createIndividualUser.execute({ + const user = await _createIndividualUser.execute({ username, password, }); - const token = await createTokenForUserId.execute(user.getId(), 120); + const token = await _createTokenForUserId.execute(user.getId(), 120); res.status(201); res.json({ token }); }) diff --git a/packages/core/handlers/routers/websocket.js b/packages/core/handlers/routers/websocket.js index 26c873bfa..444f81d4e 100644 --- a/packages/core/handlers/routers/websocket.js +++ b/packages/core/handlers/routers/websocket.js @@ -3,7 +3,15 @@ const { createWebsocketConnectionRepository, } = require('../../database/websocket-connection-repository-factory'); -const websocketConnectionRepository = createWebsocketConnectionRepository(); +let _websocketConnectionRepository; + +function getRepository() { + if (!_websocketConnectionRepository) { + _websocketConnectionRepository = + createWebsocketConnectionRepository(); + } + return _websocketConnectionRepository; +} const handleWebSocketConnection = async (event, context) => { // Handle different WebSocket events @@ -12,7 +20,7 @@ const handleWebSocketConnection = async (event, context) => { // Handle new connection try { const connectionId = event.requestContext.connectionId; - await websocketConnectionRepository.createConnection( + await getRepository().createConnection( connectionId ); console.log(`Stored new connection: ${connectionId}`); @@ -26,7 +34,7 @@ const handleWebSocketConnection = async (event, context) => { // Handle disconnection try { const connectionId = event.requestContext.connectionId; - await websocketConnectionRepository.deleteConnection( + await getRepository().deleteConnection( connectionId ); console.log(`Removed connection: ${connectionId}`); diff --git a/packages/core/handlers/workers/db-migration.js b/packages/core/handlers/workers/db-migration.js index 6c31527bb..da07076c0 100644 --- a/packages/core/handlers/workers/db-migration.js +++ b/packages/core/handlers/workers/db-migration.js @@ -49,17 +49,28 @@ const { const { CheckDatabaseStateUseCase, } = require('../../database/use-cases/check-database-state-use-case'); -const { - MigrationStatusRepositoryS3, -} = require('../../database/repositories/migration-status-repository-s3'); - // Inject prisma-runner as dependency const prismaRunner = require('../../database/utils/prisma-runner'); -// Use S3 repository for migration status tracking (no User table dependency) -const bucketName = - process.env.S3_BUCKET_NAME || process.env.MIGRATION_STATUS_BUCKET; -const migrationStatusRepository = new MigrationStatusRepositoryS3(bucketName); +// Migration status repository is loaded from the resolved provider. +const { resolveProvider } = require('../../providers/resolve-provider'); + +let _migrationStatusRepository = null; +function getMigrationStatusRepository() { + if (!_migrationStatusRepository) { + const provider = resolveProvider(); + const MigrationStatusRepository = provider.MigrationStatusRepositoryS3; + if (!MigrationStatusRepository) { + throw new Error( + `Provider '${provider.name}' does not export a MigrationStatusRepository` + ); + } + const bucketName = + process.env.S3_BUCKET_NAME || process.env.MIGRATION_STATUS_BUCKET; + _migrationStatusRepository = new MigrationStatusRepository(bucketName); + } + return _migrationStatusRepository; +} /** * Sanitizes error messages to prevent credential leaks @@ -238,7 +249,7 @@ exports.handler = async (event, context) => { console.log( `\n✓ Updating migration status to RUNNING: ${migrationId}` ); - await migrationStatusRepository.update({ + await getMigrationStatusRepository().update({ migrationId, stage, state: 'RUNNING', @@ -278,7 +289,7 @@ exports.handler = async (event, context) => { console.log( `\n✓ Updating migration status to COMPLETED: ${migrationId}` ); - await migrationStatusRepository.update({ + await getMigrationStatusRepository().update({ migrationId, stage, state: 'COMPLETED', @@ -341,7 +352,7 @@ exports.handler = async (event, context) => { console.log( `\n✓ Updating migration status to FAILED: ${migrationId}` ); - await migrationStatusRepository.update({ + await getMigrationStatusRepository().update({ migrationId, stage, state: 'FAILED', diff --git a/packages/core/handlers/workers/integration-defined-workers.js b/packages/core/handlers/workers/integration-defined-workers.js index 2eebfbef6..6e957db95 100644 --- a/packages/core/handlers/workers/integration-defined-workers.js +++ b/packages/core/handlers/workers/integration-defined-workers.js @@ -2,26 +2,37 @@ const { createHandler } = require('@friggframework/core'); const { loadAppDefinition } = require('../app-definition-loader'); const { createQueueWorker } = require('../backend-utils'); -const handlers = {}; -const { integrations: integrationClasses } = loadAppDefinition(); +let _handlers; -integrationClasses.forEach((IntegrationClass) => { - const defaultQueueWorker = createQueueWorker(IntegrationClass); +function ensureHandlers() { + if (!_handlers) { + _handlers = {}; + const { integrations: integrationClasses } = loadAppDefinition(); - handlers[`${IntegrationClass.Definition.name}`] = { - queueWorker: createHandler({ - eventName: `Queue Worker for ${IntegrationClass.Definition.name}`, - isUserFacingResponse: false, - method: async (event, context) => { - const worker = new defaultQueueWorker(); - await worker.run(event, context); - return { - message: 'Successfully processed the Generic Queue Worker', - input: event, - }; - }, - }), - }; -}); + integrationClasses.forEach((IntegrationClass) => { + const defaultQueueWorker = createQueueWorker(IntegrationClass); -module.exports = { handlers }; + _handlers[`${IntegrationClass.Definition.name}`] = { + queueWorker: createHandler({ + eventName: `Queue Worker for ${IntegrationClass.Definition.name}`, + isUserFacingResponse: false, + method: async (event, context) => { + const worker = new defaultQueueWorker(); + await worker.run(event, context); + return { + message: 'Successfully processed the Generic Queue Worker', + input: event, + }; + }, + }), + }; + }); + } + return _handlers; +} + +module.exports = { + get handlers() { + return ensureHandlers(); + }, +}; diff --git a/packages/core/index.js b/packages/core/index.js index a37010584..db9628e55 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -1,5 +1,4 @@ const { - expectShallowEqualDbObject, get, getAll, verifyType, @@ -14,23 +13,14 @@ const { createHandler, } = require('./core/index'); const { - mongoose, - connectToDatabase, - disconnectFromDatabase, - createObjectId, - IndividualUser, - OrganizationUser, - State, - Token, - UserModel, - WebsocketConnection, prisma, + connectPrisma, + disconnectPrisma, TokenRepository, WebsocketConnectionRepository, } = require('./database/index'); const { createUserRepository, - UserRepositoryMongo, UserRepositoryPostgres, } = require('./user/repositories/user-repository-factory'); const { @@ -78,8 +68,6 @@ const { const { TimeoutCatcher } = require('./lambda/index'); const { debug, initDebugLog, flushDebugLog } = require('./logs/index'); const { - Credential, - Entity, ApiKeyRequester, BasicAuthRequester, OAuth2Requester, @@ -90,13 +78,36 @@ const { const application = require('./application'); const utils = require('./utils'); -// const {Sync } = require('./syncs/model'); +const { + QueuerUtil, + QueueProvider, + QueueClientInterface, + createQueueProvider, + QUEUE_PROVIDERS, +} = require('./queues'); + +const { + EncryptionKeyProviderInterface, +} = require('./encrypt/encryption-key-provider-interface'); +const { + AesEncryptionKeyProvider, +} = require('./encrypt/aes-encryption-key-provider'); +const { + WebSocketMessageSenderInterface, + StaleConnectionError, +} = require('./websocket/websocket-message-sender-interface'); -const { QueuerUtil } = require('./queues'); +const { + resolveProvider, + determineProviderName, + providerPackageName, + KNOWN_PROVIDERS, +} = require('./providers'); + +const extensions = require('./extensions'); module.exports = { // assertions - expectShallowEqualDbObject, get, getAll, verifyType, @@ -111,21 +122,13 @@ module.exports = { createHandler, // database - mongoose, - connectToDatabase, - disconnectFromDatabase, - createObjectId, - IndividualUser, - OrganizationUser, - State, - Token, - UserModel, - WebsocketConnection, prisma, + connectPrisma, + disconnectPrisma, TokenRepository, WebsocketConnectionRepository, createUserRepository, - UserRepositoryMongo, + get UserRepositoryMongo() { return require('./user/repositories/user-repository-factory').UserRepositoryMongo; }, UserRepositoryPostgres, GetUserFromXFriggHeaders, GetUserFromAdopterJwt, @@ -177,8 +180,6 @@ module.exports = { flushDebugLog, // module plugin - Credential, - Entity, ApiKeyRequester, BasicAuthRequester, OAuth2Requester, @@ -187,6 +188,32 @@ module.exports = { ModuleFactory, // queues QueuerUtil, + QueueProvider, + QueueClientInterface, + createQueueProvider, + QUEUE_PROVIDERS, + + // encryption interfaces + EncryptionKeyProviderInterface, + AesEncryptionKeyProvider, + + // websocket interfaces + WebSocketMessageSenderInterface, + StaleConnectionError, + + // providers + resolveProvider, + determineProviderName, + providerPackageName, + KNOWN_PROVIDERS, + + // extensions + extensions, + loadExtensions: extensions.loadExtensions, + composeSchemas: extensions.composeSchemas, + mountExtensionRoutes: extensions.mountExtensionRoutes, + runExtensionBootstraps: extensions.runExtensionBootstraps, + initializeApp: extensions.initializeApp, // utils ...utils, diff --git a/packages/core/infrastructure/scheduler/index.js b/packages/core/infrastructure/scheduler/index.js index bbffae59b..cf1af523b 100644 --- a/packages/core/infrastructure/scheduler/index.js +++ b/packages/core/infrastructure/scheduler/index.js @@ -4,30 +4,34 @@ * Provides scheduling capabilities for one-time jobs. * Follows hexagonal architecture with interface + adapters pattern. * - * Providers: - * - eventbridge: AWS EventBridge Scheduler (production) - * - mock: In-memory mock scheduler (local development) + * Use createSchedulerService() to get the correct adapter for the + * active provider. The factory uses resolveProvider() internally. + * + * Adapters: + * - AWS EventBridge: via @friggframework/provider-aws (SchedulerAdapter) + * - Netlify: NetlifySchedulerAdapter (ships with core, no AWS deps) + * - Mock: MockSchedulerAdapter (local development) */ const { SchedulerServiceInterface } = require('./scheduler-service-interface'); -const { EventBridgeSchedulerAdapter } = require('./eventbridge-scheduler-adapter'); -const { MockSchedulerAdapter } = require('./mock-scheduler-adapter'); const { createSchedulerService, SCHEDULER_PROVIDERS, - determineProvider, } = require('./scheduler-service-factory'); module.exports = { // Interface (Port) SchedulerServiceInterface, - // Adapters - EventBridgeSchedulerAdapter, - MockSchedulerAdapter, + // Core adapters (no external SDK dependencies) + get MockSchedulerAdapter() { + return require('./mock-scheduler-adapter').MockSchedulerAdapter; + }, + get NetlifySchedulerAdapter() { + return require('./netlify-scheduler-adapter').NetlifySchedulerAdapter; + }, - // Factory + // Factory — resolves the correct adapter via provider plugin system createSchedulerService, SCHEDULER_PROVIDERS, - determineProvider, }; diff --git a/packages/core/infrastructure/scheduler/netlify-scheduler-adapter.js b/packages/core/infrastructure/scheduler/netlify-scheduler-adapter.js new file mode 100644 index 000000000..04785ec28 --- /dev/null +++ b/packages/core/infrastructure/scheduler/netlify-scheduler-adapter.js @@ -0,0 +1,185 @@ +/** + * Netlify Scheduler Adapter + * + * Implements SchedulerServiceInterface for Netlify deployments. + * + * Netlify Scheduled Functions use cron syntax but are defined statically in + * netlify.toml or via the @netlify/functions schedule() helper. They cannot + * be created dynamically at runtime like EventBridge Scheduler. + * + * Strategy for one-time scheduled jobs on Netlify: + * 1. Store the schedule in the database (same as mock adapter pattern) + * 2. A Netlify Scheduled Function runs on a cron interval (e.g., every 5 min) + * 3. The cron function queries for due schedules and dispatches them + * to a background function for execution + * + * This is a "poll-and-dispatch" pattern vs EventBridge's "push" pattern. + * Trade-off: slightly less precise timing (up to cron interval delay), + * but works on any platform without cloud-specific scheduling APIs. + * + * For integrations that need the queue provider to dispatch jobs: + * - queueResourceId maps to the background function URL path + * - Payload is stored and forwarded when the cron fires + */ + +const { SchedulerServiceInterface } = require('./scheduler-service-interface'); + +class NetlifySchedulerAdapter extends SchedulerServiceInterface { + /** + * @param {Object} options + * @param {Object} options.repository - Repository for persisting schedules + * Must implement: save(schedule), delete(scheduleName), + * findByName(scheduleName), findDue(now) + * @param {Object} [options.queueProvider] - Queue provider for dispatching due jobs + */ + constructor(options = {}) { + super(); + this.repository = options.repository; + this.queueProvider = options.queueProvider; + + if (!this.repository) { + console.warn( + '[NetlifyScheduler] No repository provided. ' + + 'Schedules will be stored in memory (lost on function restart). ' + + 'Provide a database-backed repository for production use.' + ); + this._inMemorySchedules = new Map(); + } + } + + async scheduleOneTime({ + scheduleName, + scheduleAt, + queueResourceId, + payload, + }) { + if (!scheduleName) throw new Error('scheduleName is required'); + if (!scheduleAt || !(scheduleAt instanceof Date)) + throw new Error('scheduleAt must be a valid Date object'); + if (!queueResourceId) throw new Error('queueResourceId is required'); + + const scheduleData = { + scheduleName, + scheduledAt: scheduleAt.toISOString(), + queueResourceId, + payload, + createdAt: new Date().toISOString(), + state: 'PENDING', + }; + + if (this.repository) { + await this.repository.save(scheduleData); + } else { + this._inMemorySchedules.set(scheduleName, scheduleData); + } + + console.log( + `[NetlifyScheduler] Scheduled: ${scheduleName} for ${scheduleAt.toISOString()}` + ); + + return { + scheduledJobId: `netlify-schedule-${scheduleName}`, + scheduledAt: scheduleAt.toISOString(), + }; + } + + async deleteSchedule(scheduleName) { + if (!scheduleName) throw new Error('scheduleName is required'); + + if (this.repository) { + await this.repository.delete(scheduleName); + } else { + this._inMemorySchedules.delete(scheduleName); + } + + console.log(`[NetlifyScheduler] Deleted schedule: ${scheduleName}`); + } + + async getScheduleStatus(scheduleName) { + if (!scheduleName) throw new Error('scheduleName is required'); + + let schedule; + if (this.repository) { + schedule = await this.repository.findByName(scheduleName); + } else { + schedule = this._inMemorySchedules.get(scheduleName); + } + + if (!schedule) { + return { exists: false }; + } + + return { + exists: true, + scheduledAt: schedule.scheduledAt, + state: schedule.state, + }; + } + + /** + * Process due schedules. Called by the Netlify cron function. + * + * Finds all schedules where scheduledAt <= now, dispatches them + * to the queue provider, and marks them as completed. + * + * @returns {Promise<{ processed: number, errors: number }>} + */ + async processDueSchedules() { + if (!this.repository) { + console.warn( + '[NetlifyScheduler] Cannot process due schedules without a repository' + ); + return { processed: 0, errors: 0 }; + } + + const now = new Date(); + const dueSchedules = await this.repository.findDue(now); + + let processed = 0; + let errors = 0; + + for (const schedule of dueSchedules) { + try { + // Mark as PROCESSING before dispatch so the next cron tick + // won't re-pick this schedule (findDue only returns PENDING). + await this.repository.save({ + ...schedule, + state: 'PROCESSING', + }); + + if (this.queueProvider) { + await this.queueProvider.send( + schedule.payload, + schedule.queueResourceId + ); + } else { + console.log( + `[NetlifyScheduler] No queue provider — logging payload for: ${schedule.scheduleName}`, + JSON.stringify(schedule.payload) + ); + } + + await this.repository.delete(schedule.scheduleName); + processed++; + } catch (error) { + console.error( + `[NetlifyScheduler] Error processing schedule ${schedule.scheduleName}:`, + error + ); + // Mark as FAILED so it won't be re-dispatched automatically. + // Operators can inspect FAILED schedules and retry manually. + await this.repository + .save({ ...schedule, state: 'FAILED' }) + .catch(() => {}); + errors++; + } + } + + console.log( + `[NetlifyScheduler] Processed ${processed} due schedules, ${errors} errors` + ); + return { processed, errors }; + } +} + +module.exports = { NetlifySchedulerAdapter }; diff --git a/packages/core/infrastructure/scheduler/netlify-scheduler-adapter.test.js b/packages/core/infrastructure/scheduler/netlify-scheduler-adapter.test.js new file mode 100644 index 000000000..971fb0148 --- /dev/null +++ b/packages/core/infrastructure/scheduler/netlify-scheduler-adapter.test.js @@ -0,0 +1,421 @@ +const { NetlifySchedulerAdapter } = require('./netlify-scheduler-adapter'); + +describe('NetlifySchedulerAdapter', () => { + let adapter; + let mockRepository; + let mockQueueProvider; + + beforeEach(() => { + mockRepository = { + save: jest.fn().mockResolvedValue({}), + delete: jest.fn().mockResolvedValue(undefined), + findByName: jest.fn().mockResolvedValue(null), + findDue: jest.fn().mockResolvedValue([]), + }; + mockQueueProvider = { + send: jest.fn().mockResolvedValue(undefined), + }; + + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('warns when no repository is provided', () => { + adapter = new NetlifySchedulerAdapter(); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('No repository provided') + ); + expect(adapter._inMemorySchedules).toBeInstanceOf(Map); + }); + + it('does not warn when repository is provided', () => { + adapter = new NetlifySchedulerAdapter({ + repository: mockRepository, + }); + + expect(console.warn).not.toHaveBeenCalled(); + expect(adapter._inMemorySchedules).toBeUndefined(); + }); + }); + + describe('scheduleOneTime', () => { + beforeEach(() => { + adapter = new NetlifySchedulerAdapter({ + repository: mockRepository, + }); + }); + + it('saves schedule to repository', async () => { + const scheduleAt = new Date(Date.now() + 60000); + + const result = await adapter.scheduleOneTime({ + scheduleName: 'job-1', + scheduleAt, + queueResourceId: '/.netlify/functions/worker-background', + payload: { event: 'SYNC' }, + }); + + expect(mockRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + scheduleName: 'job-1', + scheduledAt: scheduleAt.toISOString(), + queueResourceId: + '/.netlify/functions/worker-background', + payload: { event: 'SYNC' }, + state: 'PENDING', + }) + ); + expect(result.scheduledJobId).toBe('netlify-schedule-job-1'); + }); + + it('rejects missing scheduleName', async () => { + await expect( + adapter.scheduleOneTime({ + scheduleAt: new Date(), + queueResourceId: '/fn', + }) + ).rejects.toThrow('scheduleName is required'); + }); + + it('rejects invalid scheduleAt', async () => { + await expect( + adapter.scheduleOneTime({ + scheduleName: 'job-1', + scheduleAt: 'not-a-date', + queueResourceId: '/fn', + }) + ).rejects.toThrow('scheduleAt must be a valid Date'); + }); + + it('rejects missing queueResourceId', async () => { + await expect( + adapter.scheduleOneTime({ + scheduleName: 'job-1', + scheduleAt: new Date(), + }) + ).rejects.toThrow('queueResourceId is required'); + }); + + it('uses in-memory map when no repository', async () => { + adapter = new NetlifySchedulerAdapter(); + const scheduleAt = new Date(Date.now() + 60000); + + await adapter.scheduleOneTime({ + scheduleName: 'mem-job', + scheduleAt, + queueResourceId: '/fn', + payload: {}, + }); + + expect(adapter._inMemorySchedules.has('mem-job')).toBe(true); + }); + }); + + describe('deleteSchedule', () => { + beforeEach(() => { + adapter = new NetlifySchedulerAdapter({ + repository: mockRepository, + }); + }); + + it('deletes from repository', async () => { + await adapter.deleteSchedule('job-1'); + expect(mockRepository.delete).toHaveBeenCalledWith('job-1'); + }); + + it('rejects missing scheduleName', async () => { + await expect(adapter.deleteSchedule()).rejects.toThrow( + 'scheduleName is required' + ); + }); + + it('deletes from in-memory map when no repository', async () => { + adapter = new NetlifySchedulerAdapter(); + adapter._inMemorySchedules.set('mem-job', { state: 'PENDING' }); + + await adapter.deleteSchedule('mem-job'); + expect(adapter._inMemorySchedules.has('mem-job')).toBe(false); + }); + }); + + describe('getScheduleStatus', () => { + beforeEach(() => { + adapter = new NetlifySchedulerAdapter({ + repository: mockRepository, + }); + }); + + it('returns exists: false when not found', async () => { + const result = await adapter.getScheduleStatus('nonexistent'); + expect(result).toEqual({ exists: false }); + }); + + it('returns schedule details when found', async () => { + mockRepository.findByName.mockResolvedValue({ + scheduledAt: '2025-06-01T00:00:00.000Z', + state: 'PENDING', + }); + + const result = await adapter.getScheduleStatus('job-1'); + + expect(result).toEqual({ + exists: true, + scheduledAt: '2025-06-01T00:00:00.000Z', + state: 'PENDING', + }); + }); + }); + + // ─── processDueSchedules: the core dispatch + race condition fix ─── + + describe('processDueSchedules', () => { + beforeEach(() => { + adapter = new NetlifySchedulerAdapter({ + repository: mockRepository, + queueProvider: mockQueueProvider, + }); + }); + + it('returns early when no repository', async () => { + adapter = new NetlifySchedulerAdapter(); + + const result = await adapter.processDueSchedules(); + + expect(result).toEqual({ processed: 0, errors: 0 }); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Cannot process due schedules') + ); + }); + + it('processes nothing when no schedules are due', async () => { + mockRepository.findDue.mockResolvedValue([]); + + const result = await adapter.processDueSchedules(); + + expect(result).toEqual({ processed: 0, errors: 0 }); + expect(mockQueueProvider.send).not.toHaveBeenCalled(); + }); + + it('dispatches due schedules to queue provider', async () => { + mockRepository.findDue.mockResolvedValue([ + { + scheduleName: 'job-1', + scheduledAt: '2025-01-01T00:00:00.000Z', + queueResourceId: '/fn/worker-background', + payload: { event: 'SYNC' }, + state: 'PENDING', + }, + ]); + + const result = await adapter.processDueSchedules(); + + expect(mockQueueProvider.send).toHaveBeenCalledWith( + { event: 'SYNC' }, + '/fn/worker-background' + ); + expect(result).toEqual({ processed: 1, errors: 0 }); + }); + + it('marks schedule as PROCESSING before dispatching', async () => { + const schedule = { + scheduleName: 'job-1', + scheduledAt: '2025-01-01T00:00:00.000Z', + queueResourceId: '/fn/worker-background', + payload: { event: 'SYNC' }, + state: 'PENDING', + }; + mockRepository.findDue.mockResolvedValue([schedule]); + + // Track call order + const callOrder = []; + mockRepository.save.mockImplementation((data) => { + callOrder.push(`save:${data.state}`); + return Promise.resolve({}); + }); + mockQueueProvider.send.mockImplementation(() => { + callOrder.push('send'); + return Promise.resolve(); + }); + mockRepository.delete.mockImplementation(() => { + callOrder.push('delete'); + return Promise.resolve(); + }); + + await adapter.processDueSchedules(); + + expect(callOrder).toEqual([ + 'save:PROCESSING', + 'send', + 'delete', + ]); + }); + + it('deletes schedule after successful dispatch', async () => { + mockRepository.findDue.mockResolvedValue([ + { + scheduleName: 'job-1', + scheduledAt: '2025-01-01T00:00:00.000Z', + queueResourceId: '/fn/worker-background', + payload: {}, + state: 'PENDING', + }, + ]); + + await adapter.processDueSchedules(); + + expect(mockRepository.delete).toHaveBeenCalledWith('job-1'); + }); + + it('marks schedule as FAILED when send throws', async () => { + const schedule = { + scheduleName: 'job-1', + scheduledAt: '2025-01-01T00:00:00.000Z', + queueResourceId: '/fn/worker-background', + payload: {}, + state: 'PENDING', + }; + mockRepository.findDue.mockResolvedValue([schedule]); + mockQueueProvider.send.mockRejectedValue( + new Error('Connection refused') + ); + + const result = await adapter.processDueSchedules(); + + expect(result).toEqual({ processed: 0, errors: 1 }); + // Should save with FAILED state + expect(mockRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ state: 'FAILED' }) + ); + // Should NOT delete + expect(mockRepository.delete).not.toHaveBeenCalled(); + }); + + it('does not re-dispatch PROCESSING schedules (findDue only returns PENDING)', async () => { + // Simulate: first cron picks up job, marks PROCESSING, then + // second cron fires while job is still PROCESSING. + // findDue only returns PENDING, so the second cron gets nothing. + mockRepository.findDue.mockResolvedValue([]); + + const result = await adapter.processDueSchedules(); + + expect(result).toEqual({ processed: 0, errors: 0 }); + expect(mockQueueProvider.send).not.toHaveBeenCalled(); + }); + + it('marks FAILED even if PROCESSING save succeeded but send fails', async () => { + const schedule = { + scheduleName: 'job-1', + scheduledAt: '2025-01-01T00:00:00.000Z', + queueResourceId: '/fn/worker-background', + payload: { id: 123 }, + state: 'PENDING', + }; + mockRepository.findDue.mockResolvedValue([schedule]); + + // save(PROCESSING) succeeds, send fails + let saveCallCount = 0; + mockRepository.save.mockImplementation((data) => { + saveCallCount++; + if (data.state === 'PROCESSING') return Promise.resolve({}); + if (data.state === 'FAILED') return Promise.resolve({}); + return Promise.resolve({}); + }); + mockQueueProvider.send.mockRejectedValue( + new Error('Timeout') + ); + + const result = await adapter.processDueSchedules(); + + // save called twice: once PROCESSING, once FAILED + expect(saveCallCount).toBe(2); + expect(result).toEqual({ processed: 0, errors: 1 }); + }); + + it('handles FAILED save gracefully (catch swallows)', async () => { + const schedule = { + scheduleName: 'job-1', + scheduledAt: '2025-01-01T00:00:00.000Z', + queueResourceId: '/fn/worker-background', + payload: {}, + state: 'PENDING', + }; + mockRepository.findDue.mockResolvedValue([schedule]); + + // save(PROCESSING) succeeds, send fails, save(FAILED) also fails + mockRepository.save + .mockResolvedValueOnce({}) // PROCESSING + .mockRejectedValueOnce(new Error('DB down')); // FAILED + mockQueueProvider.send.mockRejectedValue( + new Error('Timeout') + ); + + // Should NOT throw — the .catch(() => {}) swallows the save(FAILED) error + const result = await adapter.processDueSchedules(); + + expect(result).toEqual({ processed: 0, errors: 1 }); + }); + + it('logs payload when no queue provider is configured', async () => { + adapter = new NetlifySchedulerAdapter({ + repository: mockRepository, + // no queueProvider + }); + + mockRepository.findDue.mockResolvedValue([ + { + scheduleName: 'job-1', + scheduledAt: '2025-01-01T00:00:00.000Z', + queueResourceId: '/fn/worker-background', + payload: { event: 'SYNC' }, + state: 'PENDING', + }, + ]); + + const result = await adapter.processDueSchedules(); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('No queue provider'), + expect.stringContaining('"event":"SYNC"') + ); + expect(result).toEqual({ processed: 1, errors: 0 }); + }); + + it('processes multiple schedules independently', async () => { + mockRepository.findDue.mockResolvedValue([ + { + scheduleName: 'job-1', + scheduledAt: '2025-01-01T00:00:00.000Z', + queueResourceId: '/fn/worker-background', + payload: { id: 1 }, + state: 'PENDING', + }, + { + scheduleName: 'job-2', + scheduledAt: '2025-01-01T00:01:00.000Z', + queueResourceId: '/fn/worker-background', + payload: { id: 2 }, + state: 'PENDING', + }, + ]); + + // job-1 succeeds, job-2 fails + mockQueueProvider.send + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('fail')); + + const result = await adapter.processDueSchedules(); + + expect(result).toEqual({ processed: 1, errors: 1 }); + // job-1 deleted, job-2 marked FAILED + expect(mockRepository.delete).toHaveBeenCalledWith('job-1'); + expect(mockRepository.delete).not.toHaveBeenCalledWith('job-2'); + }); + }); +}); diff --git a/packages/core/infrastructure/scheduler/scheduler-lazy-loading.test.js b/packages/core/infrastructure/scheduler/scheduler-lazy-loading.test.js new file mode 100644 index 000000000..cd9f4ed15 --- /dev/null +++ b/packages/core/infrastructure/scheduler/scheduler-lazy-loading.test.js @@ -0,0 +1,79 @@ +/** + * Tests for scheduler barrel export lazy loading. + * + * Verifies that requiring the scheduler barrel does NOT eagerly pull + * in @aws-sdk/client-scheduler or @friggframework/provider-aws, + * which would break non-AWS platforms (e.g. Netlify). + */ + +describe('scheduler barrel (index.js)', () => { + beforeEach(() => { + // Reset all module caches between tests to ensure full isolation. + // Manual require.cache cleanup is fragile with workspace symlinks. + jest.resetModules(); + }); + + it('does not eagerly require @aws-sdk/client-scheduler', () => { + // Requiring the barrel should NOT trigger a load of the AWS SDK + const scheduler = require('./index'); + + // Check that no AWS scheduler SDK module was loaded + const loadedModules = Object.keys(require.cache); + const awsSchedulerLoaded = loadedModules.some( + (m) => + m.includes('@aws-sdk/client-scheduler') || + m.includes('eventbridge-scheduler-adapter') + ); + + expect(awsSchedulerLoaded).toBe(false); + + // But the factory and interface SHOULD be available + expect(scheduler.SchedulerServiceInterface).toBeDefined(); + expect(scheduler.createSchedulerService).toBeDefined(); + expect(scheduler.SCHEDULER_PROVIDERS).toBeDefined(); + }); + + it('does not export EventBridgeSchedulerAdapter (use provider-aws directly)', () => { + const scheduler = require('./index'); + + // EventBridgeSchedulerAdapter was removed from the barrel — + // it should be imported from @friggframework/provider-aws instead + expect(scheduler.EventBridgeSchedulerAdapter).toBeUndefined(); + }); + + it('lazily loads MockSchedulerAdapter on access', () => { + const scheduler = require('./index'); + + const Adapter = scheduler.MockSchedulerAdapter; + + expect(Adapter).toBeDefined(); + expect(Adapter.name).toBe('MockSchedulerAdapter'); + }); + + it('lazily loads NetlifySchedulerAdapter on access', () => { + const scheduler = require('./index'); + + const Adapter = scheduler.NetlifySchedulerAdapter; + + expect(Adapter).toBeDefined(); + expect(Adapter.name).toBe('NetlifySchedulerAdapter'); + }); + + it('factory lazy-loads only the requested adapter', () => { + const scheduler = require('./index'); + + // Create a mock scheduler — should NOT load EventBridge adapter + const mockService = scheduler.createSchedulerService({ + provider: 'mock', + }); + + expect(mockService).toBeDefined(); + + const loadedModules = Object.keys(require.cache); + const eventbridgeLoaded = loadedModules.some((m) => + m.includes('eventbridge-scheduler-adapter') + ); + + expect(eventbridgeLoaded).toBe(false); + }); +}); diff --git a/packages/core/infrastructure/scheduler/scheduler-service-factory.js b/packages/core/infrastructure/scheduler/scheduler-service-factory.js index 5888fa91c..3e34d9cb4 100644 --- a/packages/core/infrastructure/scheduler/scheduler-service-factory.js +++ b/packages/core/infrastructure/scheduler/scheduler-service-factory.js @@ -4,70 +4,77 @@ * Creates scheduler service instances based on configuration. * Returns implementations of SchedulerServiceInterface. * + * Uses the provider plugin system (resolveProvider) to load the correct + * scheduler adapter for the active platform (AWS, Netlify, etc.). + * * Environment Detection: - * - SCHEDULER_PROVIDER=eventbridge -> Use AWS EventBridge Scheduler * - SCHEDULER_PROVIDER=mock -> Use in-memory mock scheduler * - Default in dev/test/local stages -> Mock scheduler - * - Default in other stages -> EventBridge scheduler + * - Otherwise -> Resolved provider's SchedulerAdapter */ -const { EventBridgeSchedulerAdapter } = require('./eventbridge-scheduler-adapter'); -const { MockSchedulerAdapter } = require('./mock-scheduler-adapter'); +const { resolveProvider } = require('../../providers/resolve-provider'); const SCHEDULER_PROVIDERS = { - EVENTBRIDGE: 'eventbridge', MOCK: 'mock', }; const LOCAL_STAGES = ['dev', 'test', 'local']; /** - * Determine the scheduler provider based on environment + * Determine if mock scheduler should be used * - * @returns {string} Provider name + * @param {string} [explicit] - Explicit provider override + * @returns {boolean} */ -function determineProvider() { - const explicitProvider = process.env.SCHEDULER_PROVIDER; - if (explicitProvider) { - return explicitProvider; +function shouldUseMock(explicit) { + if (explicit === SCHEDULER_PROVIDERS.MOCK) { + return true; } - - const stage = process.env.STAGE || 'dev'; - if (LOCAL_STAGES.includes(stage)) { - return SCHEDULER_PROVIDERS.MOCK; + if (!explicit) { + const stage = process.env.STAGE || 'dev'; + return LOCAL_STAGES.includes(stage); } - - return SCHEDULER_PROVIDERS.EVENTBRIDGE; + return false; } /** * Create a scheduler service instance * * @param {Object} options - * @param {string} options.provider - Scheduler provider ('eventbridge' or 'mock') + * @param {string} options.provider - Scheduler provider ('mock', or omit for auto-detect) * @param {string} options.region - AWS region (for EventBridge) * @param {boolean} options.verbose - Verbose logging (for Mock) + * @param {Object} options.repository - Schedule repository (for Netlify - persists schedules) + * @param {Object} options.queueProvider - Queue provider (for Netlify - dispatches due jobs) * @returns {SchedulerServiceInterface} Implementation of scheduler interface */ function createSchedulerService(options = {}) { - const provider = options.provider || determineProvider(); + const explicit = options.provider || process.env.SCHEDULER_PROVIDER; + + if (shouldUseMock(explicit)) { + const { MockSchedulerAdapter } = require('./mock-scheduler-adapter'); + return new MockSchedulerAdapter({ verbose: options.verbose }); + } + + // Use the resolved provider's scheduler adapter. + // Legacy SCHEDULER_PROVIDER values (eventbridge, netlify) are mapped + // to provider names for backward compatibility. + const LEGACY_MAP = { eventbridge: 'aws', netlify: 'netlify' }; + const providerName = LEGACY_MAP[explicit] || explicit; + const providerOverride = providerName ? { provider: providerName } : {}; + const provider = resolveProvider(null, providerOverride); - switch (provider) { - case SCHEDULER_PROVIDERS.EVENTBRIDGE: - return new EventBridgeSchedulerAdapter({ - region: options.region, - }); - case SCHEDULER_PROVIDERS.MOCK: - return new MockSchedulerAdapter({ - verbose: options.verbose, - }); - default: - throw new Error(`Unknown scheduler provider: ${provider}`); + const Adapter = provider.SchedulerAdapter; + if (!Adapter) { + throw new Error( + `Provider '${provider.name}' does not export a SchedulerAdapter` + ); } + return new Adapter(options); } module.exports = { createSchedulerService, SCHEDULER_PROVIDERS, - determineProvider, }; diff --git a/packages/core/integrations/integration-router.js b/packages/core/integrations/integration-router.js index fee248f5e..5872a7fae 100644 --- a/packages/core/integrations/integration-router.js +++ b/packages/core/integrations/integration-router.js @@ -35,6 +35,8 @@ const { GetEntitiesForUser, } = require('../modules/use-cases/get-entities-for-user'); const { loadAppDefinition } = require('../handlers/app-definition-loader'); +const { loadExtensions } = require('../extensions/extension-loader'); +const { mountExtensionRoutes } = require('../extensions/route-mounter'); const { GetIntegrationInstance, } = require('./use-cases/get-integration-instance'); @@ -93,7 +95,7 @@ const { const { ExecuteProxyRequest } = require('./use-cases/execute-proxy-request'); function createIntegrationRouter() { - const { integrations: integrationClasses, userConfig } = + const { integrations: integrationClasses, userConfig, appDefinition } = loadAppDefinition(); const moduleRepository = createModuleRepository(); const integrationRepository = createIntegrationRepository(); @@ -292,6 +294,21 @@ function createIntegrationRouter() { getAuthorizationRequirements, moduleDefinitions, }); + + // Mount extension routes (admin-protected by default) + try { + const extensions = loadExtensions(appDefinition); + if (extensions.length > 0) { + const { prisma } = require('../database/prisma'); + mountExtensionRoutes(router, extensions, { + prisma, + appDefinition, + }); + } + } catch (error) { + console.warn('Failed to load extensions:', error.message); + } + return router; } diff --git a/packages/core/integrations/repositories/__tests__/integration-mapping-repository-documentdb-encryption.test.js b/packages/core/integrations/repositories/__tests__/integration-mapping-repository-documentdb-encryption.test.js index a640ea230..50c999f05 100644 --- a/packages/core/integrations/repositories/__tests__/integration-mapping-repository-documentdb-encryption.test.js +++ b/packages/core/integrations/repositories/__tests__/integration-mapping-repository-documentdb-encryption.test.js @@ -6,7 +6,7 @@ jest.mock('../../../database/prisma', () => ({ })); jest.mock('../../../database/documentdb-encryption-service'); -const { ObjectId } = require('mongodb'); +const { ObjectId } = require('bson'); const { prisma } = require('../../../database/prisma'); const { toObjectId, diff --git a/packages/core/integrations/repositories/integration-mapping-repository-factory.js b/packages/core/integrations/repositories/integration-mapping-repository-factory.js index 8bab11be9..8478721e5 100644 --- a/packages/core/integrations/repositories/integration-mapping-repository-factory.js +++ b/packages/core/integrations/repositories/integration-mapping-repository-factory.js @@ -1,12 +1,6 @@ -const { - IntegrationMappingRepositoryMongo, -} = require('./integration-mapping-repository-mongo'); const { IntegrationMappingRepositoryPostgres, } = require('./integration-mapping-repository-postgres'); -const { - IntegrationMappingRepositoryDocumentDB, -} = require('./integration-mapping-repository-documentdb'); const config = require('../../database/config'); /** @@ -33,12 +27,14 @@ function createIntegrationMappingRepository() { switch (dbType) { case 'mongodb': + const { IntegrationMappingRepositoryMongo } = require('./integration-mapping-repository-mongo'); return new IntegrationMappingRepositoryMongo(); case 'postgresql': return new IntegrationMappingRepositoryPostgres(); case 'documentdb': + const { IntegrationMappingRepositoryDocumentDB } = require('./integration-mapping-repository-documentdb'); return new IntegrationMappingRepositoryDocumentDB(); default: @@ -51,7 +47,7 @@ function createIntegrationMappingRepository() { module.exports = { createIntegrationMappingRepository, // Export adapters for direct testing - IntegrationMappingRepositoryMongo, + get IntegrationMappingRepositoryMongo() { return require('./integration-mapping-repository-mongo').IntegrationMappingRepositoryMongo; }, IntegrationMappingRepositoryPostgres, - IntegrationMappingRepositoryDocumentDB, + get IntegrationMappingRepositoryDocumentDB() { return require('./integration-mapping-repository-documentdb').IntegrationMappingRepositoryDocumentDB; }, }; diff --git a/packages/core/integrations/repositories/integration-repository-factory.js b/packages/core/integrations/repositories/integration-repository-factory.js index 90c1a8f5a..bcaa2f1ba 100644 --- a/packages/core/integrations/repositories/integration-repository-factory.js +++ b/packages/core/integrations/repositories/integration-repository-factory.js @@ -1,12 +1,6 @@ -const { - IntegrationRepositoryMongo, -} = require('./integration-repository-mongo'); const { IntegrationRepositoryPostgres, } = require('./integration-repository-postgres'); -const { - IntegrationRepositoryDocumentDB, -} = require('./integration-repository-documentdb'); const config = require('../../database/config'); /** @@ -31,12 +25,14 @@ function createIntegrationRepository() { switch (dbType) { case 'mongodb': + const { IntegrationRepositoryMongo } = require('./integration-repository-mongo'); return new IntegrationRepositoryMongo(); case 'postgresql': return new IntegrationRepositoryPostgres(); case 'documentdb': + const { IntegrationRepositoryDocumentDB } = require('./integration-repository-documentdb'); return new IntegrationRepositoryDocumentDB(); default: @@ -49,7 +45,7 @@ function createIntegrationRepository() { module.exports = { createIntegrationRepository, // Export adapters for direct testing - IntegrationRepositoryMongo, + get IntegrationRepositoryMongo() { return require('./integration-repository-mongo').IntegrationRepositoryMongo; }, IntegrationRepositoryPostgres, - IntegrationRepositoryDocumentDB, + get IntegrationRepositoryDocumentDB() { return require('./integration-repository-documentdb').IntegrationRepositoryDocumentDB; }, }; diff --git a/packages/core/integrations/repositories/process-repository-factory.js b/packages/core/integrations/repositories/process-repository-factory.js index 8b4568766..afa0fcbf4 100644 --- a/packages/core/integrations/repositories/process-repository-factory.js +++ b/packages/core/integrations/repositories/process-repository-factory.js @@ -1,8 +1,4 @@ -const { ProcessRepositoryMongo } = require('./process-repository-mongo'); const { ProcessRepositoryPostgres } = require('./process-repository-postgres'); -const { - ProcessRepositoryDocumentDB, -} = require('./process-repository-documentdb'); const config = require('../../database/config'); /** @@ -28,12 +24,14 @@ function createProcessRepository() { switch (dbType) { case 'mongodb': + const { ProcessRepositoryMongo } = require('./process-repository-mongo'); return new ProcessRepositoryMongo(); case 'postgresql': return new ProcessRepositoryPostgres(); case 'documentdb': + const { ProcessRepositoryDocumentDB } = require('./process-repository-documentdb'); return new ProcessRepositoryDocumentDB(); default: @@ -46,7 +44,7 @@ function createProcessRepository() { module.exports = { createProcessRepository, // Export adapters for direct testing - ProcessRepositoryMongo, + get ProcessRepositoryMongo() { return require('./process-repository-mongo').ProcessRepositoryMongo; }, ProcessRepositoryPostgres, - ProcessRepositoryDocumentDB, + get ProcessRepositoryDocumentDB() { return require('./process-repository-documentdb').ProcessRepositoryDocumentDB; }, }; diff --git a/packages/core/modules/entity.js b/packages/core/modules/entity.js deleted file mode 100644 index 8217c7171..000000000 --- a/packages/core/modules/entity.js +++ /dev/null @@ -1,46 +0,0 @@ -const { mongoose } = require('../database/mongoose'); -const schema = new mongoose.Schema( - { - credential: { - type: mongoose.Schema.Types.ObjectId, - ref: 'Credential', - required: false, - }, - user: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - required: false, - }, - name: { type: String }, - moduleName: { type: String }, - externalId: { type: String }, - }, - { timestamps: true } -); - -schema.static({ - findByUserId: async function (userId) { - const entities = await this.find({ user: userId }); - if (entities.length === 0) { - return null; - } else if (entities.length === 1) { - return entities[0]; - } else { - throw new Error('multiple entities with same userId'); - } - }, - findAllByUserId(userId) { - return this.find({ user: userId }); - }, - upsert: async function (filter, obj) { - return this.findOneAndUpdate(filter, obj, { - new: true, - upsert: true, - setDefaultsOnInsert: true, - }); - }, -}); - -const Entity = mongoose.models.Entity || mongoose.model('Entity', schema); - -module.exports = { Entity }; diff --git a/packages/core/modules/index.js b/packages/core/modules/index.js index 9ed0ac2ef..913948027 100644 --- a/packages/core/modules/index.js +++ b/packages/core/modules/index.js @@ -1,4 +1,3 @@ -const { Entity } = require('./entity'); const { ApiKeyRequester } = require('./requester/api-key'); const { BasicAuthRequester } = require('./requester/basic'); const { OAuth2Requester } = require('./requester/oauth-2'); @@ -7,7 +6,6 @@ const { ModuleConstants } = require('./ModuleConstants'); const { ModuleFactory } = require('./module-factory'); module.exports = { - Entity, ApiKeyRequester, BasicAuthRequester, OAuth2Requester, diff --git a/packages/core/modules/module-factory.js b/packages/core/modules/module-factory.js index e7b4a5240..f25d6fe28 100644 --- a/packages/core/modules/module-factory.js +++ b/packages/core/modules/module-factory.js @@ -45,7 +45,7 @@ class ModuleFactory { ); } - return new Module({ + return await Module.create({ userId, entity, definition: moduleDefinition, diff --git a/packages/core/modules/module.js b/packages/core/modules/module.js index 4020278fe..f07e9290c 100644 --- a/packages/core/modules/module.js +++ b/packages/core/modules/module.js @@ -21,7 +21,7 @@ class Module extends Delegate { * @param {string} params.userId The user id * @param {Object} params.entity The entity record from the database */ - constructor({ definition, userId = null, entity: entityObj = null }) { + constructor({ definition, userId = null, entity: entityObj = null, resolvedEnv = null }) { super({ definition, userId, entity: entityObj }); this.validateDefinition(definition); @@ -39,8 +39,12 @@ class Module extends Delegate { Object.assign(this, this.definition.requiredAuthMethods); + // Use pre-resolved env if provided (from Module.create()), otherwise + // fall back to static definition.env for backward compatibility. + const envValues = resolvedEnv !== null ? resolvedEnv : this.definition.env; + const apiParams = { - ...this.definition.env, + ...envValues, delegate: this, ...(this.credential?.data ? this.apiParamsFromCredential(this.credential.data) @@ -50,6 +54,40 @@ class Module extends Delegate { this.api = new this.apiClass(apiParams); } + /** + * Resolves definition.env, supporting both static objects and async functions. + * + * When definition.env is a function, it is called and the result is awaited. + * This enables lazy/dynamic credential loading (e.g., from a database) without + * requiring changes to every integration class. + * + * @param {Object} definition - Module definition + * @returns {Promise} Resolved env values + */ + static async resolveEnv(definition) { + if (typeof definition.env === 'function') { + return await definition.env(); + } + return definition.env; + } + + /** + * Async factory method that supports definition.env as a function. + * + * Use this instead of `new Module(...)` when the module's definition.env + * might be an async function (e.g., for database-backed credentials). + * + * @param {Object} params + * @param {Object} params.definition - Module definition + * @param {string} [params.userId] - User ID + * @param {Object} [params.entity] - Entity record + * @returns {Promise} + */ + static async create({ definition, userId = null, entity = null }) { + const resolvedEnv = await Module.resolveEnv(definition); + return new Module({ definition, userId, entity, resolvedEnv }); + } + getName() { return this.name; } diff --git a/packages/core/modules/repositories/__tests__/module-repository-documentdb-encryption.test.js b/packages/core/modules/repositories/__tests__/module-repository-documentdb-encryption.test.js index 5c653f9a2..6341b4ced 100644 --- a/packages/core/modules/repositories/__tests__/module-repository-documentdb-encryption.test.js +++ b/packages/core/modules/repositories/__tests__/module-repository-documentdb-encryption.test.js @@ -6,7 +6,7 @@ jest.mock('../../../database/prisma', () => ({ })); jest.mock('../../../database/documentdb-encryption-service'); -const { ObjectId } = require('mongodb'); +const { ObjectId } = require('bson'); const { prisma } = require('../../../database/prisma'); const { toObjectId, diff --git a/packages/core/modules/repositories/authorization-session-repository-factory.js b/packages/core/modules/repositories/authorization-session-repository-factory.js index 48b4036bb..12a7f90e3 100644 --- a/packages/core/modules/repositories/authorization-session-repository-factory.js +++ b/packages/core/modules/repositories/authorization-session-repository-factory.js @@ -1,6 +1,3 @@ -const { - AuthorizationSessionRepositoryMongo, -} = require('./authorization-session-repository-mongo'); const { AuthorizationSessionRepositoryPostgres, } = require('./authorization-session-repository-postgres'); @@ -35,6 +32,7 @@ function createAuthorizationSessionRepository(prismaClient) { switch (dbType) { case 'mongodb': + const { AuthorizationSessionRepositoryMongo } = require('./authorization-session-repository-mongo'); return new AuthorizationSessionRepositoryMongo(prismaClient); case 'postgresql': @@ -50,6 +48,6 @@ function createAuthorizationSessionRepository(prismaClient) { module.exports = { createAuthorizationSessionRepository, // Export adapters for direct testing - AuthorizationSessionRepositoryMongo, + get AuthorizationSessionRepositoryMongo() { return require('./authorization-session-repository-mongo').AuthorizationSessionRepositoryMongo; }, AuthorizationSessionRepositoryPostgres, }; diff --git a/packages/core/modules/repositories/module-repository-factory.js b/packages/core/modules/repositories/module-repository-factory.js index 512db7e5b..9110846c0 100644 --- a/packages/core/modules/repositories/module-repository-factory.js +++ b/packages/core/modules/repositories/module-repository-factory.js @@ -1,8 +1,4 @@ -const { ModuleRepositoryMongo } = require('./module-repository-mongo'); const { ModuleRepositoryPostgres } = require('./module-repository-postgres'); -const { - ModuleRepositoryDocumentDB, -} = require('./module-repository-documentdb'); const config = require('../../database/config'); /** @@ -16,12 +12,14 @@ function createModuleRepository() { switch (dbType) { case 'mongodb': + const { ModuleRepositoryMongo } = require('./module-repository-mongo'); return new ModuleRepositoryMongo(); case 'postgresql': return new ModuleRepositoryPostgres(); case 'documentdb': + const { ModuleRepositoryDocumentDB } = require('./module-repository-documentdb'); return new ModuleRepositoryDocumentDB(); default: @@ -34,7 +32,7 @@ function createModuleRepository() { module.exports = { createModuleRepository, // Export adapters for direct testing - ModuleRepositoryMongo, + get ModuleRepositoryMongo() { return require('./module-repository-mongo').ModuleRepositoryMongo; }, ModuleRepositoryPostgres, - ModuleRepositoryDocumentDB, + get ModuleRepositoryDocumentDB() { return require('./module-repository-documentdb').ModuleRepositoryDocumentDB; }, }; diff --git a/packages/core/package.json b/packages/core/package.json index 354654e02..1d46bd09c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -3,10 +3,6 @@ "prettier": "@friggframework/prettier-config", "version": "2.0.0-next.0", "dependencies": { - "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0", - "@aws-sdk/client-kms": "^3.588.0", - "@aws-sdk/client-lambda": "^3.714.0", - "@aws-sdk/client-sqs": "^3.588.0", "@hapi/boom": "^10.0.1", "bcryptjs": "^2.4.3", "body-parser": "^1.20.2", @@ -20,7 +16,7 @@ "fs-extra": "^11.2.0", "lodash": "4.17.21", "lodash.get": "^4.4.2", - "mongoose": "6.11.6", + "bson": "^4.7.2", "node-fetch": "^2.6.7", "serverless-http": "^2.7.0", "uuid": "^9.0.1", diff --git a/packages/core/prisma-mongodb/schema.prisma b/packages/core/prisma-mongodb/schema.prisma index 0645334ae..888fc4ba8 100644 --- a/packages/core/prisma-mongodb/schema.prisma +++ b/packages/core/prisma-mongodb/schema.prisma @@ -5,8 +5,8 @@ generator client { provider = "prisma-client-js" output = "../generated/prisma-mongodb" - binaryTargets = ["native", "rhel-openssl-3.0.x"] // native for local dev, rhel for Lambda deployment - engineType = "binary" // Use binary engines (smaller size) + binaryTargets = ["native", "rhel-openssl-3.0.x"] // native for local/build platform, rhel for Lambda runtime (including Netlify functions) + engineType = "library" // Node-API library (~2-5MB per platform vs ~18MB for binary engine) } datasource db { @@ -429,3 +429,21 @@ model ScriptSchedule { @@index([enabled]) @@map("ScriptSchedule") } + +/// One-time scheduled jobs for poll-and-dispatch scheduling pattern. +/// Used by providers without native one-time scheduling APIs (e.g., Netlify). +/// A cron function queries for due jobs and dispatches them to a queue. +model ScheduledJob { + id String @id @default(auto()) @map("_id") @db.ObjectId + scheduleName String @unique + scheduledAt DateTime + queueResourceId String + payload Json? + state String @default("PENDING") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([state, scheduledAt]) + @@map("ScheduledJob") +} diff --git a/packages/core/prisma-postgresql/schema.prisma b/packages/core/prisma-postgresql/schema.prisma index e544ec23b..a7bf1456b 100644 --- a/packages/core/prisma-postgresql/schema.prisma +++ b/packages/core/prisma-postgresql/schema.prisma @@ -5,8 +5,8 @@ generator client { provider = "prisma-client-js" output = "../generated/prisma-postgresql" - binaryTargets = ["native", "rhel-openssl-3.0.x"] // native for local dev, rhel for Lambda deployment - engineType = "binary" // Use binary engines (smaller size) + binaryTargets = ["native", "rhel-openssl-3.0.x"] // native for local/build platform, rhel for Lambda runtime (including Netlify functions) + engineType = "library" // Node-API library (~2-5MB per platform vs ~18MB for binary engine) } datasource db { @@ -411,3 +411,20 @@ model ScriptSchedule { @@index([enabled]) } + +/// One-time scheduled jobs for poll-and-dispatch scheduling pattern. +/// Used by providers without native one-time scheduling APIs (e.g., Netlify). +/// A cron function queries for due jobs and dispatches them to a queue. +model ScheduledJob { + id Int @id @default(autoincrement()) + scheduleName String @unique + scheduledAt DateTime + queueResourceId String + payload Json? + state String @default("PENDING") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([state, scheduledAt]) +} diff --git a/packages/core/providers/index.js b/packages/core/providers/index.js new file mode 100644 index 000000000..e740a3814 --- /dev/null +++ b/packages/core/providers/index.js @@ -0,0 +1,13 @@ +const { + resolveProvider, + determineProviderName, + providerPackageName, + KNOWN_PROVIDERS, +} = require('./resolve-provider'); + +module.exports = { + resolveProvider, + determineProviderName, + providerPackageName, + KNOWN_PROVIDERS, +}; diff --git a/packages/core/providers/resolve-provider.js b/packages/core/providers/resolve-provider.js new file mode 100644 index 000000000..817c9156b --- /dev/null +++ b/packages/core/providers/resolve-provider.js @@ -0,0 +1,93 @@ +/** + * Provider Resolver + * + * Resolves a provider name from the appDefinition into the corresponding + * @friggframework/provider-{name} package. This is the bridge between + * `appDefinition.provider` and the provider plugin interface. + * + * Resolution order: + * 1. Explicit `providerName` argument + * 2. `appDefinition.provider` + * 3. `FRIGG_PROVIDER` environment variable + * 4. Default: 'aws' + * + * Package naming convention: + * provider: 'netlify' → require('@friggframework/provider-netlify') + * provider: 'aws' → require('@friggframework/provider-aws') + * + * @example + * const provider = resolveProvider({ provider: 'netlify' }); + * // provider.name === 'netlify' + * // provider.deploy(appDefinition, options) + * // provider.createHandler(options) + * // provider.QueueProvider (class) + * // etc. + */ + +const KNOWN_PROVIDERS = ['aws', 'netlify']; + +/** + * Determine the provider name from available sources. + * + * @param {Object} [appDefinition] - Frigg app definition + * @param {string} [providerName] - Explicit override + * @returns {string} Provider name (e.g. 'netlify', 'aws') + */ +function determineProviderName(appDefinition, providerName) { + if (providerName) { + return providerName; + } + if (appDefinition?.provider) { + return appDefinition.provider; + } + if (process.env.FRIGG_PROVIDER) { + return process.env.FRIGG_PROVIDER; + } + return 'aws'; +} + +/** + * Resolve a provider name to a package name. + * + * @param {string} name - Provider name (e.g. 'netlify') + * @returns {string} npm package name (e.g. '@friggframework/provider-netlify') + */ +function providerPackageName(name) { + return `@friggframework/provider-${name}`; +} + +/** + * Resolve and load the provider plugin for the given app definition. + * + * @param {Object} [appDefinition] - Frigg app definition (reads .provider) + * @param {Object} [options] + * @param {string} [options.provider] - Explicit provider name override + * @returns {Object} Provider plugin (conforms to the provider plugin interface) + * @throws {Error} If the provider package is not installed + */ +function resolveProvider(appDefinition, options = {}) { + const name = determineProviderName(appDefinition, options.provider); + const packageName = providerPackageName(name); + + try { + return require(packageName); + } catch (error) { + if (error.code === 'MODULE_NOT_FOUND') { + const hint = KNOWN_PROVIDERS.includes(name) + ? `Install it with: npm install ${packageName}` + : `Is '${name}' a valid Frigg provider? Known providers: ${KNOWN_PROVIDERS.join(', ')}`; + + throw new Error( + `Provider '${name}' specified in appDefinition but package '${packageName}' is not installed.\n ${hint}` + ); + } + throw error; + } +} + +module.exports = { + resolveProvider, + determineProviderName, + providerPackageName, + KNOWN_PROVIDERS, +}; diff --git a/packages/core/providers/resolve-provider.test.js b/packages/core/providers/resolve-provider.test.js new file mode 100644 index 000000000..3646efd7c --- /dev/null +++ b/packages/core/providers/resolve-provider.test.js @@ -0,0 +1,130 @@ +const { + resolveProvider, + determineProviderName, + providerPackageName, + KNOWN_PROVIDERS, +} = require('./resolve-provider'); + +describe('Provider Resolver', () => { + describe('determineProviderName', () => { + const originalEnv = process.env.FRIGG_PROVIDER; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.FRIGG_PROVIDER; + } else { + process.env.FRIGG_PROVIDER = originalEnv; + } + }); + + it('uses explicit providerName when given', () => { + expect( + determineProviderName({ provider: 'aws' }, 'netlify') + ).toBe('netlify'); + }); + + it('falls back to appDefinition.provider', () => { + expect( + determineProviderName({ provider: 'netlify' }) + ).toBe('netlify'); + }); + + it('falls back to FRIGG_PROVIDER env var', () => { + process.env.FRIGG_PROVIDER = 'netlify'; + expect(determineProviderName({})).toBe('netlify'); + }); + + it('defaults to aws', () => { + delete process.env.FRIGG_PROVIDER; + expect(determineProviderName()).toBe('aws'); + expect(determineProviderName({})).toBe('aws'); + expect(determineProviderName(null)).toBe('aws'); + }); + + it('respects priority: explicit > appDef > env > default', () => { + process.env.FRIGG_PROVIDER = 'from-env'; + expect( + determineProviderName({ provider: 'from-appdef' }, 'explicit') + ).toBe('explicit'); + expect( + determineProviderName({ provider: 'from-appdef' }) + ).toBe('from-appdef'); + expect(determineProviderName({})).toBe('from-env'); + }); + }); + + describe('providerPackageName', () => { + it('maps provider name to @friggframework/provider-{name}', () => { + expect(providerPackageName('aws')).toBe( + '@friggframework/provider-aws' + ); + expect(providerPackageName('netlify')).toBe( + '@friggframework/provider-netlify' + ); + }); + }); + + describe('KNOWN_PROVIDERS', () => { + it('includes aws and netlify', () => { + expect(KNOWN_PROVIDERS).toContain('aws'); + expect(KNOWN_PROVIDERS).toContain('netlify'); + }); + }); + + describe('resolveProvider', () => { + it('resolves installed known provider', () => { + // 'aws' provider package is installed in the monorepo + const provider = resolveProvider({ provider: 'aws' }); + expect(provider).toBeDefined(); + expect(provider.name).toBe('aws'); + }); + + it('throws with different hint for unknown provider', () => { + expect(() => + resolveProvider({ provider: 'vercel' }) + ).toThrow(/Is 'vercel' a valid Frigg provider/); + }); + + it('loads a provider that returns the plugin interface', () => { + // Mock require to simulate an installed provider + const mockProvider = { + name: 'netlify', + deploy: jest.fn(), + createHandler: jest.fn(), + validate: jest.fn(), + detect: jest.fn(), + }; + + jest.mock( + '@friggframework/provider-netlify', + () => mockProvider, + { virtual: true } + ); + + const provider = resolveProvider({ provider: 'netlify' }); + expect(provider.name).toBe('netlify'); + expect(typeof provider.deploy).toBe('function'); + expect(typeof provider.createHandler).toBe('function'); + + jest.restoreAllMocks(); + }); + + it('accepts explicit provider option override', () => { + const mockProvider = { name: 'netlify' }; + jest.mock( + '@friggframework/provider-netlify', + () => mockProvider, + { virtual: true } + ); + + // appDefinition says 'aws', but explicit option says 'netlify' + const provider = resolveProvider( + { provider: 'aws' }, + { provider: 'netlify' } + ); + expect(provider.name).toBe('netlify'); + + jest.restoreAllMocks(); + }); + }); +}); diff --git a/packages/core/queues/index.js b/packages/core/queues/index.js index f845c38fc..91747f872 100644 --- a/packages/core/queues/index.js +++ b/packages/core/queues/index.js @@ -1,4 +1,23 @@ const { QueuerUtil } = require('./queuer-util'); +const { QueueProvider } = require('./queue-provider'); +const { QueueClientInterface } = require('./queue-client-interface'); +const { + createQueueProvider, + determineProvider, + QUEUE_PROVIDERS, +} = require('./queue-provider-factory'); +const { + NetlifyBackgroundProvider, + QStashQueueProvider, +} = require('./providers'); + module.exports = { QueuerUtil, + QueueProvider, + QueueClientInterface, + createQueueProvider, + determineProvider, + QUEUE_PROVIDERS, + NetlifyBackgroundProvider, + QStashQueueProvider, }; diff --git a/packages/core/queues/providers/index.js b/packages/core/queues/providers/index.js new file mode 100644 index 000000000..516e207b0 --- /dev/null +++ b/packages/core/queues/providers/index.js @@ -0,0 +1,7 @@ +const { NetlifyBackgroundProvider } = require('./netlify-background-provider'); +const { QStashQueueProvider } = require('./qstash-queue-provider'); + +module.exports = { + NetlifyBackgroundProvider, + QStashQueueProvider, +}; diff --git a/packages/core/queues/providers/netlify-background-provider.js b/packages/core/queues/providers/netlify-background-provider.js new file mode 100644 index 000000000..a011ccb44 --- /dev/null +++ b/packages/core/queues/providers/netlify-background-provider.js @@ -0,0 +1,76 @@ +/** + * Netlify Background Functions Queue Provider (Adapter) + * + * Uses Netlify Background Functions for async job processing. + * Background functions have a 15-minute execution limit and are triggered via HTTP POST. + * They return 202 immediately while processing continues. + * + * Queue ID format: The function name or URL path for the background function. + * Convention: function files named with '-background' suffix (e.g., 'worker-background.js') + * + * Limitations: + * - No built-in retry (must implement manually) + * - No dead-letter queue + * - No FIFO ordering guarantees + * - 15-minute max execution time + */ +const { QueueProvider } = require('../queue-provider'); + +class NetlifyBackgroundProvider extends QueueProvider { + constructor(options = {}) { + super(); + // Base URL for triggering background functions (e.g., https://mysite.netlify.app) + this.baseUrl = options.baseUrl || process.env.URL || ''; + } + + /** + * Send a message by invoking a Netlify Background Function via HTTP POST. + * + * @param {Object} message - Message payload + * @param {string} queueId - Background function path (e.g., '/.netlify/functions/worker-background') + */ + async send(message, queueId) { + const url = `${this.baseUrl}${queueId}`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(message), + }); + + if (!response.ok && response.status !== 202) { + throw new Error( + `Failed to invoke background function at ${queueId}: ${response.status} ${response.statusText}` + ); + } + + return { status: response.status, queueId }; + } + + /** + * Send batch of messages sequentially (background functions are HTTP-triggered). + */ + async batchSend(entries = [], queueId) { + const results = []; + for (const entry of entries) { + const result = await this.send(entry, queueId); + results.push(result); + } + return { results }; + } + + /** + * Parse Netlify Background Function event. + * Background functions receive the raw HTTP event body as the event. + */ + parseEvent(event) { + const body = + typeof event.body === 'string' + ? JSON.parse(event.body) + : event.body; + + // Background functions receive a single message per invocation + return [body]; + } +} + +module.exports = { NetlifyBackgroundProvider }; diff --git a/packages/core/queues/providers/qstash-queue-provider.js b/packages/core/queues/providers/qstash-queue-provider.js new file mode 100644 index 000000000..ded88bd34 --- /dev/null +++ b/packages/core/queues/providers/qstash-queue-provider.js @@ -0,0 +1,122 @@ +/** + * QStash Queue Provider (Adapter) + * + * Upstash QStash implementation - platform-agnostic HTTP-based message queue. + * Works on any platform (Netlify, Vercel, AWS, bare metal, etc.) + * + * Features: + * - Configurable retry with exponential backoff + * - Dead-letter queue via callbacks + * - At-least-once delivery + * - Scheduling support (delay, cron) + * - Message deduplication + * + * Requires: + * - QSTASH_TOKEN env var (from Upstash console) + * - QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY for verification + * + * Queue ID format: The destination URL that QStash will POST to when delivering the message. + */ +const { QueueProvider } = require('../queue-provider'); + +const QSTASH_API_BASE = 'https://qstash.upstash.io/v2'; + +class QStashQueueProvider extends QueueProvider { + constructor(options = {}) { + super(); + this.token = + options.token || process.env.QSTASH_TOKEN; + this.retries = + options.retries !== undefined ? options.retries : 3; + } + + /** + * Send a message via QStash. + * + * @param {Object} message - Message payload + * @param {string} queueId - Destination URL that QStash will POST to + */ + async send(message, queueId) { + if (!this.token) { + throw new Error( + 'QSTASH_TOKEN is required. Get one at https://console.upstash.com' + ); + } + + const headers = { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json', + 'Upstash-Retries': String(this.retries), + }; + + const response = await fetch( + `${QSTASH_API_BASE}/publish/${queueId}`, + { + method: 'POST', + headers, + body: JSON.stringify(message), + } + ); + + if (!response.ok) { + const errorBody = await response.text().catch(() => 'unknown'); + throw new Error( + `QStash publish failed: ${response.status} ${response.statusText} - ${errorBody}` + ); + } + + return response.json(); + } + + /** + * Send batch of messages via QStash batch API. + */ + async batchSend(entries = [], queueId) { + if (!this.token) { + throw new Error( + 'QSTASH_TOKEN is required. Get one at https://console.upstash.com' + ); + } + + const messages = entries.map((entry) => ({ + destination: queueId, + body: JSON.stringify(entry), + headers: { 'Content-Type': 'application/json' }, + retries: this.retries, + })); + + const response = await fetch(`${QSTASH_API_BASE}/batch`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(messages), + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => 'unknown'); + throw new Error( + `QStash batch publish failed: ${response.status} - ${errorBody}` + ); + } + + return response.json(); + } + + /** + * Parse QStash webhook delivery event. + * QStash POSTs the message body directly to the destination URL. + */ + parseEvent(event) { + const body = + typeof event.body === 'string' + ? JSON.parse(event.body) + : event.body; + + // QStash delivers a single message per invocation + return [body]; + } +} + +module.exports = { QStashQueueProvider }; diff --git a/packages/core/queues/queue-client-interface.js b/packages/core/queues/queue-client-interface.js new file mode 100644 index 000000000..a20e4b23e --- /dev/null +++ b/packages/core/queues/queue-client-interface.js @@ -0,0 +1,55 @@ +/** + * Queue Client Interface (Port) + * + * Defines the contract for low-level queue message operations. + * Used by Worker class and QueuerUtil for sending/receiving queue messages. + * + * Following Frigg's hexagonal architecture pattern: + * - Port defines WHAT the service does (contract) + * - Adapters implement HOW (AWS SQS, Netlify, QStash, etc.) + * + * Distinct from QueueProvider which is the higher-level provider for + * integration-level queue operations (send/batchSend/parseEvent). + * QueueClientInterface is the lower-level "how do I talk to the queue service" port. + */ +class QueueClientInterface { + /** + * Send a single message to a queue + * + * @param {Object} params + * @param {string} params.QueueUrl - Queue endpoint/identifier + * @param {string} params.MessageBody - Serialized message body + * @param {number} [params.DelaySeconds] - Delay before message becomes visible + * @returns {Promise<{MessageId: string}>} Result with message identifier + */ + async sendMessage(params) { + throw new Error('Method sendMessage must be implemented by subclass'); + } + + /** + * Send a batch of messages to a queue + * + * @param {Object} params + * @param {string} params.QueueUrl - Queue endpoint/identifier + * @param {Array<{Id: string, MessageBody: string}>} params.Entries - Messages to send + * @returns {Promise} Batch send result + */ + async sendMessageBatch(params) { + throw new Error( + 'Method sendMessageBatch must be implemented by subclass' + ); + } + + /** + * Resolve a queue name to a URL/endpoint + * + * @param {Object} params + * @param {string} params.QueueName - Queue name to resolve + * @returns {Promise} Resolved queue URL/endpoint + */ + async getQueueUrl(params) { + throw new Error('Method getQueueUrl must be implemented by subclass'); + } +} + +module.exports = { QueueClientInterface }; diff --git a/packages/core/queues/queue-provider-factory.js b/packages/core/queues/queue-provider-factory.js new file mode 100644 index 000000000..25d603bfe --- /dev/null +++ b/packages/core/queues/queue-provider-factory.js @@ -0,0 +1,93 @@ +/** + * Queue Provider Factory + * + * Creates queue provider instances based on appDefinition or environment configuration. + * Uses the provider plugin system (resolveProvider) to load the correct + * queue adapter for the active platform (AWS, Netlify, etc.). + * + * Priority order for determining provider: + * 1. Explicit `provider` option passed to factory + * 2. `appDefinition.queue.provider` property + * 3. `QUEUE_PROVIDER` environment variable + * 4. Default: resolved provider's QueueProvider + * + * Special providers (not tied to a platform): + * - 'qstash' -> Upstash QStash (platform-agnostic) + */ + +const { resolveProvider } = require('../providers/resolve-provider'); + +const QUEUE_PROVIDERS = { + QSTASH: 'qstash', +}; + +// Map legacy QUEUE_PROVIDER values to Frigg provider names +const LEGACY_PROVIDER_MAP = { + sqs: 'aws', + 'netlify-background': 'netlify', +}; + +/** + * Determine the queue provider from config hierarchy + * + * @param {Object} [options] + * @param {string} [options.provider] - Explicit provider override + * @param {Object} [options.appDefinition] - Frigg app definition object + * @returns {string|undefined} Provider name, or undefined for auto-detect + */ +function determineProvider(options = {}) { + if (options.provider) { + return options.provider; + } + + if (options.appDefinition?.queue?.provider) { + return options.appDefinition.queue.provider; + } + + if (process.env.QUEUE_PROVIDER) { + return process.env.QUEUE_PROVIDER; + } + + return undefined; // Let resolveProvider() pick the default +} + +/** + * Create a queue provider instance + * + * @param {Object} [options] + * @param {string} [options.provider] - Queue provider name + * @param {Object} [options.appDefinition] - Frigg app definition (reads queue.provider) + * @param {Object} [options.providerOptions] - Options passed to the provider constructor + * @returns {QueueProvider} Implementation of queue provider interface + */ +function createQueueProvider(options = {}) { + const explicit = determineProvider(options); + const providerOptions = options.providerOptions || {}; + + // QStash is a third-party queue not tied to any platform provider + if (explicit === QUEUE_PROVIDERS.QSTASH) { + const { QStashQueueProvider } = require('./providers/qstash-queue-provider'); + return new QStashQueueProvider(providerOptions); + } + + // Use the resolved provider's queue adapter. + // Legacy QUEUE_PROVIDER values (sqs, netlify-background) are mapped + // to provider names for backward compatibility. + const providerName = LEGACY_PROVIDER_MAP[explicit] || explicit; + const providerOverride = providerName ? { provider: providerName } : {}; + const provider = resolveProvider(null, providerOverride); + + const QueueProviderClass = provider.QueueProvider; + if (!QueueProviderClass) { + throw new Error( + `Provider '${provider.name}' does not export a QueueProvider` + ); + } + return new QueueProviderClass(providerOptions); +} + +module.exports = { + createQueueProvider, + determineProvider, + QUEUE_PROVIDERS, +}; diff --git a/packages/core/queues/queue-provider.js b/packages/core/queues/queue-provider.js new file mode 100644 index 000000000..94478243c --- /dev/null +++ b/packages/core/queues/queue-provider.js @@ -0,0 +1,46 @@ +/** + * Queue Provider Interface (Port) + * + * Defines the contract for queue message sending. + * All queue adapters must extend this interface. + * + * Following Frigg's hexagonal architecture pattern: + * - Port defines WHAT the service does (contract) + * - Adapters implement HOW (AWS SQS, Netlify Background Functions, QStash, etc.) + */ +class QueueProvider { + /** + * Send a single message to a queue + * + * @param {Object} message - Message payload (will be JSON-serialized) + * @param {string} queueId - Queue identifier (URL, name, or endpoint depending on provider) + * @returns {Promise} Provider-specific response + */ + async send(message, queueId) { + throw new Error('Method send must be implemented by subclass'); + } + + /** + * Send a batch of messages to a queue + * + * @param {Object[]} entries - Array of message payloads + * @param {string} queueId - Queue identifier + * @returns {Promise} Provider-specific response + */ + async batchSend(entries, queueId) { + throw new Error('Method batchSend must be implemented by subclass'); + } + + /** + * Parse an incoming event into an array of message payloads. + * Each provider has its own event shape (SQS Records, HTTP body, etc.) + * + * @param {Object} event - Raw event from the runtime (Lambda event, HTTP request, etc.) + * @returns {Object[]} Array of parsed message payloads + */ + parseEvent(event) { + throw new Error('Method parseEvent must be implemented by subclass'); + } +} + +module.exports = { QueueProvider }; diff --git a/packages/core/queues/queuer-util.js b/packages/core/queues/queuer-util.js index 385228f5b..b94d37e20 100644 --- a/packages/core/queues/queuer-util.js +++ b/packages/core/queues/queuer-util.js @@ -1,34 +1,41 @@ const { v4: uuid } = require('uuid'); -const { - SQSClient, - SendMessageCommand, - SendMessageBatchCommand, -} = require('@aws-sdk/client-sqs'); -const awsConfigOptions = () => { - const config = {}; - if (process.env.IS_OFFLINE) { - config.credentials = { - accessKeyId: 'test-aws-key', - secretAccessKey: 'test-aws-secret', - }; - config.region = 'us-east-1'; - } - if (process.env.AWS_ENDPOINT) { - config.endpoint = process.env.AWS_ENDPOINT; - } - return config; -}; +/** + * QueuerUtil - Queue message utility. + * + * BREAKING CHANGE (v3): A queue client must be set via setQueueClient() + * before calling send() or batchSend(). + * For AWS/SQS, pass `new SqsQueueClient()` from @friggframework/provider-aws. + * See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide. + */ +let _queueClient = null; -const sqs = new SQSClient(awsConfigOptions()); +function getQueueClient() { + if (!_queueClient) { + throw new Error( + 'QueuerUtil requires a queue client. Call QueuerUtil.setQueueClient() first, e.g.:\n' + + ' const { SqsQueueClient } = require("@friggframework/provider-aws");\n' + + ' QueuerUtil.setQueueClient(new SqsQueueClient());\n' + + 'See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide.' + ); + } + return _queueClient; +} const QueuerUtil = { + /** + * Set the queue client. Must be called before send() or batchSend(). + * @param {QueueClientInterface} client + */ + setQueueClient(client) { + _queueClient = client; + }, + send: async (message, queueUrl) => { - const command = new SendMessageCommand({ + return getQueueClient().sendMessage({ MessageBody: JSON.stringify(message), QueueUrl: queueUrl, }); - return sqs.send(command); }, batchSend: async (entries = [], queueUrl) => { @@ -42,23 +49,20 @@ const QueuerUtil = { }); // Sends 10, then purges the buffer if (buffer.length === batchSize) { - const command = new SendMessageBatchCommand({ - Entries: buffer, + await getQueueClient().sendMessageBatch({ + Entries: [...buffer], QueueUrl: queueUrl, }); - await sqs.send(command); - // Purge the buffer - buffer.splice(0, buffer.length); + buffer.length = 0; } } // If any remaining entries under 10 are left in the buffer, send and return if (buffer.length > 0) { - const command = new SendMessageBatchCommand({ + return getQueueClient().sendMessageBatch({ Entries: buffer, QueueUrl: queueUrl, }); - return sqs.send(command); } // If we're exact... just return an empty object for now diff --git a/packages/core/queues/queuer-util.test.js b/packages/core/queues/queuer-util.test.js index d26147ed5..14bcb472e 100644 --- a/packages/core/queues/queuer-util.test.js +++ b/packages/core/queues/queuer-util.test.js @@ -1,35 +1,55 @@ /** - * Tests for QueuerUtil - AWS SDK v3 Migration + * Tests for QueuerUtil - Provider-Agnostic Queue Interface * - * Tests SQS operations using aws-sdk-client-mock + * Tests queue operations using a mock QueueClientInterface. + * No AWS SDK dependency — the queue client is injected via setQueueClient(). */ -const { mockClient } = require('aws-sdk-client-mock'); -const { - SQSClient, - SendMessageCommand, - SendMessageBatchCommand, -} = require('@aws-sdk/client-sqs'); const { QueuerUtil } = require('./queuer-util'); -describe('QueuerUtil - AWS SDK v3', () => { - let sqsMock; +describe('QueuerUtil', () => { + let mockQueueClient; + let sendMessageCalls; + let sendMessageBatchCalls; beforeEach(() => { - sqsMock = mockClient(SQSClient); - jest.clearAllMocks(); + sendMessageCalls = []; + sendMessageBatchCalls = []; + + mockQueueClient = { + sendMessage: jest.fn(async (params) => { + sendMessageCalls.push(params); + return { MessageId: 'test-message-id-123' }; + }), + sendMessageBatch: jest.fn(async (params) => { + sendMessageBatchCalls.push(params); + return { Successful: [], Failed: [] }; + }), + getQueueUrl: jest.fn(async (params) => { + return `https://sqs.us-east-1.amazonaws.com/123456789/${params.QueueName}`; + }), + }; + + QueuerUtil.setQueueClient(mockQueueClient); }); afterEach(() => { - sqsMock.reset(); + // Reset the queue client to prevent leaking between test files + QueuerUtil.setQueueClient(null); }); - describe('send()', () => { - it('should send single message to SQS', async () => { - sqsMock.on(SendMessageCommand).resolves({ - MessageId: 'test-message-id-123', - }); + describe('setQueueClient()', () => { + it('should throw if no queue client is set', async () => { + QueuerUtil.setQueueClient(null); + + await expect( + QueuerUtil.send({ test: 'data' }, 'https://queue-url') + ).rejects.toThrow('QueuerUtil requires a queue client'); + }); + }); + describe('send()', () => { + it('should send single message via queue client', async () => { const message = { test: 'data', id: 1 }; const queueUrl = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue'; @@ -37,56 +57,46 @@ describe('QueuerUtil - AWS SDK v3', () => { const result = await QueuerUtil.send(message, queueUrl); expect(result.MessageId).toBe('test-message-id-123'); - expect(sqsMock.calls()).toHaveLength(1); - - const call = sqsMock.call(0); - expect(call.args[0].input).toMatchObject({ + expect(mockQueueClient.sendMessage).toHaveBeenCalledTimes(1); + expect(mockQueueClient.sendMessage).toHaveBeenCalledWith({ MessageBody: JSON.stringify(message), QueueUrl: queueUrl, }); }); - it('should handle SQS errors', async () => { - sqsMock.on(SendMessageCommand).rejects(new Error('SQS Error')); + it('should handle queue errors', async () => { + mockQueueClient.sendMessage.mockRejectedValue( + new Error('Queue Error') + ); const message = { test: 'data' }; const queueUrl = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue'; await expect(QueuerUtil.send(message, queueUrl)).rejects.toThrow( - 'SQS Error' + 'Queue Error' ); }); }); describe('batchSend()', () => { - it('should send batch of messages to SQS', async () => { - sqsMock.on(SendMessageBatchCommand).resolves({ - Successful: [{ MessageId: 'msg-1' }], - Failed: [], - }); - + it('should send batch of messages via queue client', async () => { const entries = Array(5) .fill() .map((_, i) => ({ data: `test-${i}` })); const queueUrl = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue'; - const result = await QueuerUtil.batchSend(entries, queueUrl); + await QueuerUtil.batchSend(entries, queueUrl); - expect(sqsMock.calls()).toHaveLength(1); + expect(mockQueueClient.sendMessageBatch).toHaveBeenCalledTimes(1); - const call = sqsMock.call(0); - expect(call.args[0].input.Entries).toHaveLength(5); - expect(call.args[0].input.QueueUrl).toBe(queueUrl); + const call = sendMessageBatchCalls[0]; + expect(call.Entries).toHaveLength(5); + expect(call.QueueUrl).toBe(queueUrl); }); it('should send multiple batches for large entry sets (10 per batch)', async () => { - sqsMock.on(SendMessageBatchCommand).resolves({ - Successful: [], - Failed: [], - }); - const entries = Array(25) .fill() .map((_, i) => ({ data: `test-${i}` })); @@ -96,26 +106,25 @@ describe('QueuerUtil - AWS SDK v3', () => { await QueuerUtil.batchSend(entries, queueUrl); // Should send 3 batches (10 + 10 + 5) - expect(sqsMock.calls()).toHaveLength(3); + expect(mockQueueClient.sendMessageBatch).toHaveBeenCalledTimes(3); - expect(sqsMock.call(0).args[0].input.Entries).toHaveLength(10); - expect(sqsMock.call(1).args[0].input.Entries).toHaveLength(10); - expect(sqsMock.call(2).args[0].input.Entries).toHaveLength(5); + expect(sendMessageBatchCalls[0].Entries).toHaveLength(10); + expect(sendMessageBatchCalls[1].Entries).toHaveLength(10); + expect(sendMessageBatchCalls[2].Entries).toHaveLength(5); }); it('should handle empty entries array', async () => { + QueuerUtil.setQueueClient(null); // Should not need a client for empty + // Re-set because empty array returns early before calling client + QueuerUtil.setQueueClient(mockQueueClient); + const result = await QueuerUtil.batchSend([], 'https://queue-url'); expect(result).toEqual({}); - expect(sqsMock.calls()).toHaveLength(0); + expect(mockQueueClient.sendMessageBatch).not.toHaveBeenCalled(); }); it('should send exact batch of 10 without remainder', async () => { - sqsMock.on(SendMessageBatchCommand).resolves({ - Successful: [], - Failed: [], - }); - const entries = Array(10) .fill() .map((_, i) => ({ data: `test-${i}` })); @@ -124,23 +133,18 @@ describe('QueuerUtil - AWS SDK v3', () => { const result = await QueuerUtil.batchSend(entries, queueUrl); - expect(sqsMock.calls()).toHaveLength(1); + expect(mockQueueClient.sendMessageBatch).toHaveBeenCalledTimes(1); expect(result).toEqual({}); // Returns empty object when exact batch }); it('should generate unique IDs for each entry', async () => { - sqsMock.on(SendMessageBatchCommand).resolves({ - Successful: [], - Failed: [], - }); - const entries = [{ data: 'test-1' }, { data: 'test-2' }]; const queueUrl = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue'; await QueuerUtil.batchSend(entries, queueUrl); - const sentEntries = sqsMock.call(0).args[0].input.Entries; + const sentEntries = sendMessageBatchCalls[0].Entries; expect(sentEntries[0].Id).toBeDefined(); expect(sentEntries[1].Id).toBeDefined(); expect(sentEntries[0].Id).not.toBe(sentEntries[1].Id); diff --git a/packages/core/syncs/manager.js b/packages/core/syncs/manager.js index b118dbf92..fb022e120 100644 --- a/packages/core/syncs/manager.js +++ b/packages/core/syncs/manager.js @@ -1,6 +1,6 @@ const _ = require('lodash'); const moment = require('moment'); -const mongoose = require('mongoose'); +const { ObjectId } = require('bson'); const SyncObject = require('./sync'); const { debug } = require('packages/logs'); const { get } = require('../assertions'); @@ -314,7 +314,7 @@ class SyncManager { async createSyncDBObject(objArr, entities) { const entityIds = entities.map( - (ent) => ({ $elemMatch: { $eq: mongoose.Types.ObjectId(ent) } }) + (ent) => ({ $elemMatch: { $eq: new ObjectId(ent) } }) // return {"$elemMatch": {"$eq": ent}}; ); const dataIdentifiers = []; diff --git a/packages/core/syncs/model.js b/packages/core/syncs/model.js deleted file mode 100644 index 7fc5ccf07..000000000 --- a/packages/core/syncs/model.js +++ /dev/null @@ -1,62 +0,0 @@ -const mongoose = require('mongoose'); - -const schema = new mongoose.Schema({ - entities: [ - { type: mongoose.Schema.Types.ObjectId, ref: 'Entity', required: true }, - ], - hash: { type: String, required: true }, - name: { type: String, required: true }, - dataIdentifiers: [ - { - entity: { - type: mongoose.Schema.Types.ObjectId, - ref: 'Entity', - required: true, - }, - id: { type: Object, required: true }, - hash: { type: String, required: true }, - }, - ], -}); - -schema.statics({ - getSyncObject: async function (name, dataIdentifier, entity) { - // const syncList = await this.list({name:name,entities: {"$in": entities}, "entityIds.idHash":entityIdHash }); - const syncList = await this.find({ - name: name, - dataIdentifiers: { $elemMatch: { id: dataIdentifier, entity } }, - }); - - if (syncList.length === 1) { - return syncList[0]; - } else if (syncList.length === 0) { - return null; - } else { - throw new Error( - `There are multiple sync objects with the name ${name}, for entities [${syncList[0].entities}] [${syncList[1].entities}]` - ); - } - }, - - addDataIdentifier: async function (id, dataIdentifier) { - return await this.update( - { _id: id }, - {}, - { dataIdentifiers: dataIdentifier } - ); - }, - - getEntityObjIdForEntityIdFromObject: function (syncObj, entityId) { - for (let dataIdentifier of syncObj.dataIdentifiers) { - if (dataIdentifier.entity.toString() === entityId) { - return dataIdentifier.id; - } - } - throw new Error( - `Sync object does not have DataIdentifier for entityId: ${entityId}` - ); - }, -}); - -const Sync = mongoose.models.Sync || mongoose.model('Sync', schema); -module.exports = { Sync }; diff --git a/packages/core/syncs/repositories/sync-repository-factory.js b/packages/core/syncs/repositories/sync-repository-factory.js index d5422eed0..4026495df 100644 --- a/packages/core/syncs/repositories/sync-repository-factory.js +++ b/packages/core/syncs/repositories/sync-repository-factory.js @@ -1,6 +1,4 @@ -const { SyncRepositoryMongo } = require('./sync-repository-mongo'); const { SyncRepositoryPostgres } = require('./sync-repository-postgres'); -const { SyncRepositoryDocumentDB } = require('./sync-repository-documentdb'); const config = require('../../database/config'); /** @@ -19,12 +17,14 @@ function createSyncRepository() { switch (dbType) { case 'mongodb': + const { SyncRepositoryMongo } = require('./sync-repository-mongo'); return new SyncRepositoryMongo(); case 'postgresql': return new SyncRepositoryPostgres(); case 'documentdb': + const { SyncRepositoryDocumentDB } = require('./sync-repository-documentdb'); return new SyncRepositoryDocumentDB(); default: @@ -37,7 +37,7 @@ function createSyncRepository() { module.exports = { createSyncRepository, // Export adapters for direct testing - SyncRepositoryMongo, + get SyncRepositoryMongo() { return require('./sync-repository-mongo').SyncRepositoryMongo; }, SyncRepositoryPostgres, - SyncRepositoryDocumentDB, + get SyncRepositoryDocumentDB() { return require('./sync-repository-documentdb').SyncRepositoryDocumentDB; }, }; diff --git a/packages/core/token/repositories/token-repository-factory.js b/packages/core/token/repositories/token-repository-factory.js index 046064fb3..7d2384982 100644 --- a/packages/core/token/repositories/token-repository-factory.js +++ b/packages/core/token/repositories/token-repository-factory.js @@ -1,6 +1,4 @@ -const { TokenRepositoryMongo } = require('./token-repository-mongo'); const { TokenRepositoryPostgres } = require('./token-repository-postgres'); -const { TokenRepositoryDocumentDB } = require('./token-repository-documentdb'); const config = require('../../database/config'); /** @@ -14,12 +12,14 @@ function createTokenRepository() { switch (dbType) { case 'mongodb': + const { TokenRepositoryMongo } = require('./token-repository-mongo'); return new TokenRepositoryMongo(); case 'postgresql': return new TokenRepositoryPostgres(); case 'documentdb': + const { TokenRepositoryDocumentDB } = require('./token-repository-documentdb'); return new TokenRepositoryDocumentDB(); default: @@ -32,7 +32,7 @@ function createTokenRepository() { module.exports = { createTokenRepository, // Export adapters for direct testing - TokenRepositoryMongo, + get TokenRepositoryMongo() { return require('./token-repository-mongo').TokenRepositoryMongo; }, TokenRepositoryPostgres, - TokenRepositoryDocumentDB, + get TokenRepositoryDocumentDB() { return require('./token-repository-documentdb').TokenRepositoryDocumentDB; }, }; diff --git a/packages/core/types/associations/index.d.ts b/packages/core/types/associations/index.d.ts index b267d24b9..7a77cc73c 100644 --- a/packages/core/types/associations/index.d.ts +++ b/packages/core/types/associations/index.d.ts @@ -1,74 +1,57 @@ -declare module '@friggframework/associations/model' { - import { Model } from 'mongoose'; - - export class Association extends Model { - integrationId: string; - name: string; - type: string; - primaryObject: string; - objects: { - entityId: string; - objectType: string; - objId: string; - metadata?: object; - }[]; - } -} - -declare module '@friggframework/associations/association' { - export default class Association implements IFriggAssociation { - data: any; - dataIdentifier: any; - dataIdentifierHash: string; - matchHash: string; - moduleName: any; - syncId: any; - - static Config: { - name: 'Association'; - reverseModuleMap: {}; - }; +declare module "@friggframework/associations/association" { + export default class Association implements IFriggAssociation { + data: any; + dataIdentifier: any; + dataIdentifierHash: string; + matchHash: string; + moduleName: any; + syncId: any; + + static Config: { + name: "Association"; + reverseModuleMap: {}; + }; - constructor(params: AssociationConstructor); + constructor(params: AssociationConstructor); - dataKeyIsReplaceable(key: string): boolean; + dataKeyIsReplaceable(key: string): boolean; - equals(syncObj: any): boolean; + equals(syncObj: any): boolean; - getHashData(): string; + getHashData(): string; - getName(): any; + getName(): any; - hashJSON(data: any): string; + hashJSON(data: any): string; - isModuleInMap(moduleName: any): any; + isModuleInMap(moduleName: any): any; - reverseModuleMap(moduleName: any): any; + reverseModuleMap(moduleName: any): any; - setSyncId(syncId: string): any; - } + setSyncId(syncId: string): any; + } - interface IFriggAssociation { - data: any; - moduleName: any; - dataIdentifier: any; - dataIdentifierHash: string; - matchHash: string; - syncId: any; + interface IFriggAssociation { + data: any; + moduleName: any; + dataIdentifier: any; + dataIdentifierHash: string; + matchHash: string; + syncId: any; - equals(syncObj: any): boolean; - dataKeyIsReplaceable(key: string): boolean; - isModuleInMap(moduleName: any): any; - getName(): any; - getHashData(): string; - setSyncId(syncId: string): any; - reverseModuleMap(moduleName: any): any; - hashJSON(data: any): string; - } + equals(syncObj: any): boolean; + dataKeyIsReplaceable(key: string): boolean; + isModuleInMap(moduleName: any): any; + getName(): any; + getHashData(): string; + setSyncId(syncId: string): any; + reverseModuleMap(moduleName: any): any; + hashJSON(data: any): string; + } - type AssociationConstructor = { - data: any; - moduleName: any; - dataIdentifier: any; - }; + type AssociationConstructor = { + data: any; + moduleName: any; + dataIdentifier: any; + }; } diff --git a/packages/core/types/core/index.d.ts b/packages/core/types/core/index.d.ts index f20091c31..1690068f4 100644 --- a/packages/core/types/core/index.d.ts +++ b/packages/core/types/core/index.d.ts @@ -1,6 +1,4 @@ declare module '@friggframework/core' { - import type { SendMessageCommandInput } from '@aws-sdk/client-sqs'; - export class Delegate implements IFriggDelegate { delegate: any; delegateTypes: any[]; @@ -26,7 +24,16 @@ declare module '@friggframework/core' { ): Promise; } + /** Interface for queue message operations (port in hexagonal architecture) */ + export interface QueueClientInterface { + sendMessage(params: SendMessageParams): Promise<{ MessageId: string }>; + sendMessageBatch(params: SendMessageBatchParams): Promise; + getQueueUrl(params: GetQueueURLParams): Promise; + } + export class Worker implements IWorker { + constructor(options?: { queueClient?: QueueClientInterface }); + getQueueURL(params: GetQueueURLParams): Promise; run(params: { Records: any }): Promise; @@ -36,7 +43,7 @@ declare module '@friggframework/core' { delay?: number ): Promise; - sendAsyncSQSMessage(params: SendSQSMessageParams): Promise; + sendAsyncSQSMessage(params: SendMessageParams): Promise; } interface IWorker { @@ -46,7 +53,7 @@ declare module '@friggframework/core' { params: object & { QueueUrl: any }, delay?: number ): Promise; - sendAsyncSQSMessage(params: SendSQSMessageParams): Promise; + sendAsyncSQSMessage(params: SendMessageParams): Promise; } export function loadInstalledModules(): any[]; @@ -56,5 +63,37 @@ declare module '@friggframework/core' { QueueOwnerAWSAccountId?: string; }; - type SendSQSMessageParams = SendMessageCommandInput; + type SendMessageParams = { + QueueUrl: string; + MessageBody: string; + DelaySeconds?: number; + }; + + type SendMessageBatchParams = { + QueueUrl: string; + Entries: Array<{ Id: string; MessageBody: string }>; + }; + + /** Interface for envelope encryption key operations */ + export class EncryptionKeyProviderInterface { + generateDataKey(): Promise<{ + keyId: string; + encryptedKey: string; + plaintext: string | Buffer; + }>; + decryptDataKey( + keyId: string, + encryptedKey: string | Buffer + ): Promise; + } + + /** Interface for WebSocket message sending */ + export class WebSocketMessageSenderInterface { + send(connectionId: string, data: any, endpoint: string): Promise; + } + + export class StaleConnectionError extends Error { + connectionId: string; + constructor(connectionId: string); + } } diff --git a/packages/core/types/database/index.d.ts b/packages/core/types/database/index.d.ts index cf1c66ff3..8065456b0 100644 --- a/packages/core/types/database/index.d.ts +++ b/packages/core/types/database/index.d.ts @@ -1,3 +1,11 @@ -declare module '@friggframework/database/mongo' { - export function connectToDatabase(): Promise; +declare module "@friggframework/database" { + export const prisma: any; + export function connectPrisma(): Promise; + export function disconnectPrisma(): Promise; + export class TokenRepository { + constructor(params: { prismaClient: any }); + } + export class WebsocketConnectionRepository { + constructor(params: { prismaClient: any }); + } } diff --git a/packages/core/types/encrypt/index.d.ts b/packages/core/types/encrypt/index.d.ts index f954a03cb..3fcd7fa4d 100644 --- a/packages/core/types/encrypt/index.d.ts +++ b/packages/core/types/encrypt/index.d.ts @@ -1,5 +1,7 @@ -declare module '@friggframework/encrypt' { - import { Schema } from 'mongoose'; - - export function Encrypt(schema: Schema, options: any): void; +declare module "@friggframework/encrypt" { + export class Cryptor { + constructor(params: { shouldUseAws?: boolean }); + encrypt(plaintext: string): Promise; + decrypt(ciphertext: string): Promise; + } } diff --git a/packages/core/types/integrations/index.d.ts b/packages/core/types/integrations/index.d.ts index a93f260a8..963c4095a 100644 --- a/packages/core/types/integrations/index.d.ts +++ b/packages/core/types/integrations/index.d.ts @@ -1,189 +1,186 @@ -declare module '@friggframework/integrations' { - import { Delegate, IFriggDelegate } from '@friggframework/core'; - import { Model } from 'mongoose'; - - export class Integration extends Model { - entities: any[]; - userId: string; - status: string; // IntegrationStatus - config: any; - version: string; - messages: { - errors: []; - warnings: []; - info: []; - logs: []; - }; - } - - export class IntegrationManager - extends Delegate - implements IFriggIntegrationManager - { - integration: Integration; - primaryInstance: any; - targetInstance: any; - - static Config: { - name: string; - version: string; - supportedVersions: string[]; - events: string[]; - }; - static integrationManagerClasses: any[]; - static integrationTypes: string[]; - - constructor(params: any); - - static getInstanceFromIntegrationId(params: { - integrationId: string; - userId?: string; - }): Promise; - static getName(): string; - static getCurrentVersion(): string; - - validateConfig(): Promise; - testAuth(): Promise; - - static getInstance(params: { - userId: string; - integrationId: string; - }): Promise; - static getIntegrationManagerClasses(type: string): any[]; - - static createIntegration( - entities: { id: string; user: any }, - userId: string, - config: any - ): Promise; - - static getFormattedIntegration( - integration: Integration - ): Promise; - static getIntegrationsForUserId( - userId: string - ): Promise; - static getIntegrationForUserById( - userId: string, - integrationId: string - ): Promise; - static deleteIntegrationForUserById( - userId: string, - integrationId: string - ): Promise; - static getIntegrationById(id: string): Promise; - static getFilteredIntegrationsForUserId( - userId: string, - filter: any - ): Promise; - static getCredentialById(credential_id: string): Promise; - static listCredentials(options: any): Promise; - static getEntityById(entity_id: any): Promise; - static listEntities(options: any): Promise; - - processCreate(params: any): Promise; - processUpdate(params: any): Promise; - processDelete(params: any): Promise; - - getConfigOptions(): Promise; - getSampleData(): Promise; - } - - type FormattedIntegration = { - entities: any[]; - messages: any; - id: any; - config: any; - version: any; - status: any; +declare module "@friggframework/integrations" { + import { Delegate, IFriggDelegate } from "@friggframework/core"; + + export interface Integration { + entities: any[]; + userId: string; + status: string; // IntegrationStatus + config: any; + version: string; + messages: { + errors: []; + warnings: []; + info: []; + logs: []; }; - - interface IFriggIntegrationManager extends IFriggDelegate { - primaryInstance: any; // Returns the Freshbooks manager instance - targetInstance: any; // Returns a manager e.g. StripeManager instance containing the entitiy, credential, api etc - integration: Integration; // Integration model instance - - validateConfig(): Promise; - testAuth(): Promise; - processCreate(params: any): Promise; - processUpdate(params: any): Promise; - processDelete(params: any): Promise; - - getConfigOptions(): Promise; - getSampleData(): Promise; - } - - export class IntegrationConfigManager - implements IFriggIntegrationConfigManager - { - options: IntegrationOptions[]; - primary: any; - - getIntegrationOptions(): Promise; - } - - interface IFriggIntegrationConfigManager { - options: IntegrationOptions[]; - primary: any; - - getIntegrationOptions(): Promise; - } - - type GetIntegrationOptions = { - entities: { - primary: any; - options: any[]; - autorized: any[]; - }; - integrations: any[]; + } + + export class IntegrationManager + extends Delegate + implements IFriggIntegrationManager { + integration: Integration; + primaryInstance: any; + targetInstance: any; + + static Config: { + name: string; + version: string; + supportedVersions: string[]; + events: string[]; }; - - export class Options implements IFriggIntegrationOptions { - display: IntegrationOptionDisplay; - hasUserConfig: boolean; - isMany: boolean; - module: any; - requiresNewEntity: boolean; - type: string; - - constructor(params: { - display: Partial; - type?: string; - hasUserConfig?: boolean; - isMany?: boolean; - requiresNewEntity?: boolean; - module?: any; - }); - get(): IntegrationOptions; - } - - interface IFriggIntegrationOptions { - module: any; - type: string; - hasUserConfig: boolean; - isMany: boolean; - requiresNewEntity: boolean; - display: IntegrationOptionDisplay; - - get(): IntegrationOptions; - } - - type IntegrationOptions = { - type: string; - hasUserConfig: boolean; - isMany: boolean; - requiresNewEntity: boolean; - display: IntegrationOptionDisplay; + static integrationManagerClasses: any[]; + static integrationTypes: string[]; + + constructor(params: any); + + static getInstanceFromIntegrationId(params: { + integrationId: string; + userId?: string; + }): Promise; + static getName(): string; + static getCurrentVersion(): string; + + validateConfig(): Promise; + testAuth(): Promise; + + static getInstance(params: { + userId: string; + integrationId: string; + }): Promise; + static getIntegrationManagerClasses(type: string): any[]; + + static createIntegration( + entities: { id: string; user: any }, + userId: string, + config: any, + ): Promise; + + static getFormattedIntegration( + integration: Integration + ): Promise; + static getIntegrationsForUserId( + userId: string + ): Promise; + static getIntegrationForUserById( + userId: string, + integrationId: string + ): Promise; + static deleteIntegrationForUserById( + userId: string, + integrationId: string + ): Promise; + static getIntegrationById(id: string): Promise; + static getFilteredIntegrationsForUserId( + userId: string, + filter: any + ): Promise; + static getCredentialById(credential_id: string): Promise; + static listCredentials(options: any): Promise; + static getEntityById(entity_id: any): Promise; + static listEntities(options: any): Promise; + + processCreate(params: any): Promise; + processUpdate(params: any): Promise; + processDelete(params: any): Promise; + + getConfigOptions(): Promise; + getSampleData(): Promise; + } + + type FormattedIntegration = { + entities: any[]; + messages: any; + id: any; + config: any; + version: any; + status: any; + }; + + interface IFriggIntegrationManager extends IFriggDelegate { + primaryInstance: any; // Returns the Freshbooks manager instance + targetInstance: any; // Returns a manager e.g. StripeManager instance containing the entitiy, credential, api etc + integration: Integration; // Integration model instance + + validateConfig(): Promise; + testAuth(): Promise; + processCreate(params: any): Promise; + processUpdate(params: any): Promise; + processDelete(params: any): Promise; + + getConfigOptions(): Promise; + getSampleData(): Promise; + } + + export class IntegrationConfigManager + implements IFriggIntegrationConfigManager { + options: IntegrationOptions[]; + primary: any; + + getIntegrationOptions(): Promise; + } + + interface IFriggIntegrationConfigManager { + options: IntegrationOptions[]; + primary: any; + + getIntegrationOptions(): Promise; + } + + type GetIntegrationOptions = { + entities: { + primary: any; + options: any[]; + autorized: any[]; }; - - type IntegrationOptionDisplay = { - name: string; - description: string; - detailsUrl: string; - icon: string; - }; - - interface IFriggIntegrationsPackage { - IntegrationManager: IFriggIntegrationManager; - } + integrations: any[]; + }; + + export class Options implements IFriggIntegrationOptions { + display: IntegrationOptionDisplay; + hasUserConfig: boolean; + isMany: boolean; + module: any; + requiresNewEntity: boolean; + type: string; + + constructor(params: { + display: Partial; + type?: string; + hasUserConfig?: boolean; + isMany?: boolean; + requiresNewEntity?: boolean; + module?: any; + }); + get(): IntegrationOptions; + } + + interface IFriggIntegrationOptions { + module: any; + type: string; + hasUserConfig: boolean; + isMany: boolean; + requiresNewEntity: boolean; + display: IntegrationOptionDisplay; + + get(): IntegrationOptions; + } + + type IntegrationOptions = { + type: string; + hasUserConfig: boolean; + isMany: boolean; + requiresNewEntity: boolean; + display: IntegrationOptionDisplay; + }; + + type IntegrationOptionDisplay = { + name: string; + description: string; + detailsUrl: string; + icon: string; + }; + + interface IFriggIntegrationsPackage { + IntegrationManager: IFriggIntegrationManager; + } } diff --git a/packages/core/types/module-plugin/index.d.ts b/packages/core/types/module-plugin/index.d.ts index cae4ce589..8eb4018a2 100644 --- a/packages/core/types/module-plugin/index.d.ts +++ b/packages/core/types/module-plugin/index.d.ts @@ -1,241 +1,241 @@ -declare module '@friggframework/module-plugin' { - import { Model } from 'mongoose'; - import { Delegate, IFriggDelegate } from '@friggframework/core'; - - export class Credential extends Model { - userId: string; - authIsValid: boolean; - externalId: string; - } - - interface IFriggEntityManager {} - - export class Entity extends Model { - credentialId: string; - userId: string; - name: string; - externalId: string; - } - - export type MappedEntity = Entity & { id: string; type: any }; - - export class Requester implements IFriggRequester { - DLGT_INVALID_AUTH: string; - backOff: number[]; - fetch: any; - isRefreshable: boolean; - refreshCount: number; - - _delete(options: RequestOptions): Promise; - _get(options: RequestOptions): Promise; - _patch(options: RequestOptions): Promise; - _post(options: RequestOptions, stringify?: boolean): Promise; - _put(options: RequestOptions): Promise; - _request( - url: string, - options: Omit, - i?: number - ): Promise; - parseBody(response: any): Promise; - refreshAuth(): Promise; - - delegate: any; - delegateTypes: any[]; - - notify(delegateString: string, object?: any): Promise; - receiveNotification( - notifier: any, - delegateString: string, - object?: any - ): Promise; - } - - interface IFriggRequester extends IFriggDelegate { - backOff: number[]; - isRefreshable: boolean; - refreshCount: number; - DLGT_INVALID_AUTH: string; - fetch: any; - - parseBody(response: any): Promise; - _request( - url: string, - options: Omit, - i?: number - ): Promise; - _get(options: RequestOptions): Promise; - _post(options: RequestOptions, stringify?: boolean): Promise; - _patch(options: RequestOptions): Promise; - _put(options: RequestOptions): Promise; - _delete(options: RequestOptions): Promise; - refreshAuth(): Promise; - } - - type RequestOptions = { - url: string; - headers?: object; - query?: object; - returnFullRes?: boolean; - body?: any; - }; - - type RequesterConstructor = { - backOff?: number[]; - fetch?: any; - }; - - export class ApiKeyRequester - extends Requester - implements IFriggApiKeyRequester - { - API_KEY_NAME: string; - API_KEY_VALUE: any; - - constructor(params: RequesterConstructor); - addAuthHeaders(headers: object): Promise; - isAuthenticated(): boolean; - setApiKey(api_key: any): void; - } - - interface IFriggApiKeyRequester extends IFriggRequester { - API_KEY_NAME: string; - API_KEY_VALUE: string; - - addAuthHeaders(headers: object): Promise; - isAuthenticated(): boolean; - setApiKey(api_key: string): void; - } - - export class BasicAuthRequester - extends Requester - implements IFriggBasicAuthRequester - { - password: string; - username: string; - - constructor(params: BasicAuthRequesterConstructor); - addAuthHeaders(headers: object): Promise; - isAuthenticated(): boolean; - setPassword(password: string): void; - setUsername(username: string): void; - } - - interface IFriggBasicAuthRequester extends IFriggRequester { - username: string; - password: string; - - addAuthHeaders(headers: object): Promise; - isAuthenticated(): boolean; - setUsername(username: string): void; - setPassword(password: string): void; - } - - type BasicAuthRequesterConstructor = RequesterConstructor & { - username?: string; - password?: string; - }; - - export class OAuth2Requester - extends Requester - implements IFriggOAuth2Requester - { - DLGT_TOKEN_DEAUTHORIZED: string; - DLGT_TOKEN_UPDATE: string; - accessTokenExpire: any; - access_token: string; - audience: any; - authorizationUri: any; - baseURL: string; - client_id: string; - client_secret: string; - grant_type: string; - password: string; - redirect_uri: string; - refreshTokenExpire: any; - refresh_token: string; - scope: string; - state: any; - username: string; - - constructor(params: OAuth2RequesterConstructor); - - addAuthHeaders(headers: object): Promise; - getAuthorizationUri(): string; - getTokenFromClientCredentials(): Promise; - getTokenFromCode(code: string): Promise; - getTokenFromCodeBasicAuthHeader(code: string): Promise; - getTokenFromUsernamePassword(): Promise; - isAuthenticated(): boolean; - refreshAccessToken(refreshTokenObject: { - refresh_token: string; - }): Promise; - setTokens(params: Token): Promise; - } - interface IFriggOAuth2Requester extends IFriggRequester { - DLGT_TOKEN_UPDATE: string; - DLGT_TOKEN_DEAUTHORIZED: string; - - grant_type?: string; - client_id?: string; - client_secret?: string; - redirect_uri?: string; - scope?: string; - authorizationUri?: any; - baseURL?: string; - access_token?: string; - refresh_token?: string; - accessTokenExpire?: any; - refreshTokenExpire?: any; - audience?: any; - username?: string; - password?: string; - state?: any; - - setTokens(params: Token): Promise; - getAuthorizationUri(): string; - getTokenFromCode(code: string): Promise; - getTokenFromCodeBasicAuthHeader(code: string): Promise; - refreshAccessToken(refreshTokenObject: { - refresh_token: string; - }): Promise; - addAuthHeaders(headers: object): Promise; - isAuthenticated(): boolean; - refreshAuth(): Promise; - getTokenFromUsernamePassword(): Promise; - getTokenFromClientCredentials(): Promise; - } - - type Token = { - access_token?: string; - refresh_token?: string; - expires_in: any; - x_refresh_token_expires_in: any; - }; - - type OAuth2RequesterConstructor = { - grant_type?: string; - client_id?: string; - client_secret?: string; - redirect_uri?: string; - scope?: string; - authorizationUri?: any; - baseURL?: string; - access_token?: string; - refresh_token?: string; - accessTokenExpire?: any; - refreshTokenExpire?: any; - audience?: any; - username?: string; - password?: string; - state?: any; - }; - - export const ModuleConstants: { - authType: { - oauth2: 'oauth2'; - oauth1: 'oauth1'; - basic: 'basic'; - apiKey: 'apiKey'; - }; +declare module "@friggframework/module-plugin" { + import { Delegate, IFriggDelegate } from "@friggframework/core"; + + export interface Credential { + id?: string; + userId?: string; + authIsValid?: boolean; + externalId?: string; + data?: any; + } + + export interface Entity { + id?: string; + credentialId?: string; + userId?: string; + name?: string; + moduleName?: string; + externalId?: string; + data?: any; + } + + export type MappedEntity = Entity & { id: string; type: any }; + + + export class Requester implements IFriggRequester { + DLGT_INVALID_AUTH: string; + backOff: number[]; + fetch: any; + isRefreshable: boolean; + refreshCount: number; + + _delete(options: RequestOptions): Promise; + _get(options: RequestOptions): Promise; + _patch(options: RequestOptions): Promise; + _post(options: RequestOptions, stringify?: boolean): Promise; + _put(options: RequestOptions): Promise; + _request( + url: string, + options: Omit, + i?: number + ): Promise; + parseBody(response: any): Promise; + refreshAuth(): Promise; + + delegate: any; + delegateTypes: any[]; + + notify(delegateString: string, object?: any): Promise; + receiveNotification( + notifier: any, + delegateString: string, + object?: any + ): Promise; + } + + interface IFriggRequester extends IFriggDelegate { + backOff: number[]; + isRefreshable: boolean; + refreshCount: number; + DLGT_INVALID_AUTH: string; + fetch: any; + + parseBody(response: any): Promise; + _request( + url: string, + options: Omit, + i?: number + ): Promise; + _get(options: RequestOptions): Promise; + _post(options: RequestOptions, stringify?: boolean): Promise; + _patch(options: RequestOptions): Promise; + _put(options: RequestOptions): Promise; + _delete(options: RequestOptions): Promise; + refreshAuth(): Promise; + } + + type RequestOptions = { + url: string; + headers?: object; + query?: object; + returnFullRes?: boolean; + body?: any; + }; + + type RequesterConstructor = { + backOff?: number[]; + fetch?: any; + }; + + export class ApiKeyRequester + extends Requester + implements IFriggApiKeyRequester { + API_KEY_NAME: string; + API_KEY_VALUE: any; + + constructor(params: RequesterConstructor); + addAuthHeaders(headers: object): Promise; + isAuthenticated(): boolean; + setApiKey(api_key: any): void; + } + + interface IFriggApiKeyRequester extends IFriggRequester { + API_KEY_NAME: string; + API_KEY_VALUE: string; + + addAuthHeaders(headers: object): Promise; + isAuthenticated(): boolean; + setApiKey(api_key: string): void; + } + + export class BasicAuthRequester + extends Requester + implements IFriggBasicAuthRequester { + password: string; + username: string; + + constructor(params: BasicAuthRequesterConstructor); + addAuthHeaders(headers: object): Promise; + isAuthenticated(): boolean; + setPassword(password: string): void; + setUsername(username: string): void; + } + + interface IFriggBasicAuthRequester extends IFriggRequester { + username: string; + password: string; + + addAuthHeaders(headers: object): Promise; + isAuthenticated(): boolean; + setUsername(username: string): void; + setPassword(password: string): void; + } + + type BasicAuthRequesterConstructor = RequesterConstructor & { + username?: string; + password?: string; + }; + + export class OAuth2Requester + extends Requester + implements IFriggOAuth2Requester { + DLGT_TOKEN_DEAUTHORIZED: string; + DLGT_TOKEN_UPDATE: string; + accessTokenExpire: any; + access_token: string; + audience: any; + authorizationUri: any; + baseURL: string; + client_id: string; + client_secret: string; + grant_type: string; + password: string; + redirect_uri: string; + refreshTokenExpire: any; + refresh_token: string; + scope: string; + state: any; + username: string; + + constructor(params: OAuth2RequesterConstructor); + + addAuthHeaders(headers: object): Promise; + getAuthorizationUri(): string; + getTokenFromClientCredentials(): Promise; + getTokenFromCode(code: string): Promise; + getTokenFromCodeBasicAuthHeader(code: string): Promise; + getTokenFromUsernamePassword(): Promise; + isAuthenticated(): boolean; + refreshAccessToken(refreshTokenObject: { + refresh_token: string; + }): Promise; + setTokens(params: Token): Promise; + } + interface IFriggOAuth2Requester extends IFriggRequester { + DLGT_TOKEN_UPDATE: string; + DLGT_TOKEN_DEAUTHORIZED: string; + + grant_type?: string; + client_id?: string; + client_secret?: string; + redirect_uri?: string; + scope?: string; + authorizationUri?: any; + baseURL?: string; + access_token?: string; + refresh_token?: string; + accessTokenExpire?: any; + refreshTokenExpire?: any; + audience?: any; + username?: string; + password?: string; + state?: any; + + setTokens(params: Token): Promise; + getAuthorizationUri(): string; + getTokenFromCode(code: string): Promise; + getTokenFromCodeBasicAuthHeader(code: string): Promise; + refreshAccessToken(refreshTokenObject: { + refresh_token: string; + }): Promise; + addAuthHeaders(headers: object): Promise; + isAuthenticated(): boolean; + refreshAuth(): Promise; + getTokenFromUsernamePassword(): Promise; + getTokenFromClientCredentials(): Promise; + } + + type Token = { + access_token?: string; + refresh_token?: string; + expires_in: any; + x_refresh_token_expires_in: any; + }; + + type OAuth2RequesterConstructor = { + grant_type?: string; + client_id?: string; + client_secret?: string; + redirect_uri?: string; + scope?: string; + authorizationUri?: any; + baseURL?: string; + access_token?: string; + refresh_token?: string; + accessTokenExpire?: any; + refreshTokenExpire?: any; + audience?: any; + username?: string; + password?: string; + state?: any; + }; + + export const ModuleConstants: { + authType: { + oauth2: "oauth2"; + oauth1: "oauth1"; + basic: "basic"; + apiKey: "apiKey"; }; + }; } diff --git a/packages/core/types/syncs/index.d.ts b/packages/core/types/syncs/index.d.ts index 9c3231dde..a765ca006 100644 --- a/packages/core/types/syncs/index.d.ts +++ b/packages/core/types/syncs/index.d.ts @@ -1,120 +1,111 @@ -declare module '@friggframework/syncs/model' { - import { Model } from 'mongoose'; +declare module "@friggframework/syncs/manager" { + import Sync from "@friggframework/syncs/sync"; - export class Sync extends Model { - entities: any[]; - hash: string; - name: string; - dataIdentifiers: { - entity: any; - id: object; - hash: string; - }[]; - } -} - -declare module '@friggframework/syncs/manager' { - import Sync from '@friggframework/syncs/sync'; + export default class SyncManager implements IFriggSyncManager { + ignoreEmptyMatchValues: boolean; + integration: any; + isUnidirectionalSync: boolean; + omitEmptyStringsFromData: boolean; + syncObjectClass: Sync; + useFirstMatchingDuplicate: boolean; - export default class SyncManager implements IFriggSyncManager { - ignoreEmptyMatchValues: boolean; - integration: any; - isUnidirectionalSync: boolean; - omitEmptyStringsFromData: boolean; - syncObjectClass: Sync; - useFirstMatchingDuplicate: boolean; + constructor(params: SyncManagerConstructor); + confirmCreate( + syncObj: Sync, + createdId: string, + ): Promise; + confirmUpdate(syncObj: Sync): Promise; + createSyncDBObject(objArr: any[], entities: any[]): Promise; + initialSync(): Promise; + sync(syncObjects: Sync[]): Promise; + } - constructor(params: SyncManagerConstructor); - confirmCreate(syncObj: Sync, createdId: string): Promise; - confirmUpdate(syncObj: Sync): Promise; - createSyncDBObject(objArr: any[], entities: any[]): Promise; - initialSync(): Promise; - sync(syncObjects: Sync[]): Promise; - } + interface IFriggSyncManager { + syncObjectClass: Sync; + ignoreEmptyMatchValues: boolean; + isUnidirectionalSync: boolean; + useFirstMatchingDuplicate: boolean; + omitEmptyStringsFromData: boolean; + integration: any; - interface IFriggSyncManager { - syncObjectClass: Sync; - ignoreEmptyMatchValues: boolean; - isUnidirectionalSync: boolean; - useFirstMatchingDuplicate: boolean; - omitEmptyStringsFromData: boolean; - integration: any; + initialSync(): Promise; + createSyncDBObject(objArr: any[], entities: any[]): Promise; + sync(syncObjects: Sync[]): Promise; + confirmCreate( + syncObj: Sync, + createdId: string, + ): Promise; + confirmUpdate(syncObj: Sync): Promise; + } - initialSync(): Promise; - createSyncDBObject(objArr: any[], entities: any[]): Promise; - sync(syncObjects: Sync[]): Promise; - confirmCreate(syncObj: Sync, createdId: string): Promise; - confirmUpdate(syncObj: Sync): Promise; - } - - type SyncManagerConstructor = { - syncObjectClass: Sync; - ignoreEmptyMatchValues?: boolean; - isUnidirectionalSync?: boolean; - useFirstMatchingDuplicate?: boolean; - omitEmptyStringsFromData?: boolean; - integration: any; - }; + type SyncManagerConstructor = { + syncObjectClass: Sync; + ignoreEmptyMatchValues?: boolean; + isUnidirectionalSync?: boolean; + useFirstMatchingDuplicate?: boolean; + omitEmptyStringsFromData?: boolean; + integration: any; + }; } -declare module '@friggframework/syncs/sync' { - export default class Sync implements IFriggSync { - data: object; - dataIdentifier: any; - dataIdentifierHash: string; - matchHash: string; - missingMatchData: boolean; - moduleName: string; - syncId: string; - useMapping: boolean; +declare module "@friggframework/syncs/sync" { + export default class Sync implements IFriggSync { + data: object; + dataIdentifier: any; + dataIdentifierHash: string; + matchHash: string; + missingMatchData: boolean; + moduleName: string; + syncId: string; + useMapping: boolean; - static Config: { - name: string; - keys: any[]; - matchOn: any[]; - moduleMap: object; - reverseModuleMap: object; - }; + static Config: { + name: string; + keys: any[]; + matchOn: any[]; + moduleMap: object; + reverseModuleMap: object; + }; - static hashJSON(data: any): string; + static hashJSON(data: any): string; - constructor(params: SyncConstructor); - dataKeyIsReplaceable(key: string): boolean; - equals(syncObj: IFriggSync): boolean; - getHashData(params: GetHashData): any; - getName(): string; - isModuleInMap(moduleName: string): any; - reverseModuleMap(moduleName: string): any; - setSyncId(syncId: string): void; - } + constructor(params: SyncConstructor); + dataKeyIsReplaceable(key: string): boolean; + equals(syncObj: IFriggSync): boolean; + getHashData(params: GetHashData): any; + getName(): string; + isModuleInMap(moduleName: string): any; + reverseModuleMap(moduleName: string): any; + setSyncId(syncId: string): void; + } - interface IFriggSync { - data: object; - moduleName: string; - dataIdentifier: any; - useMapping?: boolean; - dataIdentifierHash: string; - missingMatchData: boolean; - matchHash: string; - syncId: string; + interface IFriggSync { + data: object; + moduleName: string; + dataIdentifier: any; + useMapping?: boolean; + dataIdentifierHash: string; + missingMatchData: boolean; + matchHash: string; + syncId: string; - equals(syncObj: IFriggSync): boolean; - dataKeyIsReplaceable(key: string): boolean; - isModuleInMap(moduleName: string): any; - getName(): string; - getHashData(params: GetHashData): any; - setSyncId(syncId: string): void; - reverseModuleMap(moduleName: string): any; - } + equals(syncObj: IFriggSync): boolean; + dataKeyIsReplaceable(key: string): boolean; + isModuleInMap(moduleName: string): any; + getName(): string; + getHashData(params: GetHashData): any; + setSyncId(syncId: string): void; + reverseModuleMap(moduleName: string): any; + } - type SyncConstructor = { - data: any; - moduleName: string; - dataIdentifier: any; - useMapping?: boolean; - }; + type SyncConstructor = { + data: any; + moduleName: string; + dataIdentifier: any; + useMapping?: boolean; + }; - type GetHashData = { - omitEmptyStringsFromData?: boolean; - }; + type GetHashData = { + omitEmptyStringsFromData?: boolean; + }; } diff --git a/packages/core/user/repositories/__tests__/user-repository-documentdb-encryption.test.js b/packages/core/user/repositories/__tests__/user-repository-documentdb-encryption.test.js index 88af3c3ae..0bf1a0044 100644 --- a/packages/core/user/repositories/__tests__/user-repository-documentdb-encryption.test.js +++ b/packages/core/user/repositories/__tests__/user-repository-documentdb-encryption.test.js @@ -14,7 +14,7 @@ jest.mock('../../../token/repositories/token-repository-factory', () => ({ })), })); -const { ObjectId } = require('mongodb'); +const { ObjectId } = require('bson'); const { prisma } = require('../../../database/prisma'); const { toObjectId, diff --git a/packages/core/user/repositories/user-repository-factory.js b/packages/core/user/repositories/user-repository-factory.js index da190654b..80060044f 100644 --- a/packages/core/user/repositories/user-repository-factory.js +++ b/packages/core/user/repositories/user-repository-factory.js @@ -1,6 +1,4 @@ -const { UserRepositoryMongo } = require('./user-repository-mongo'); const { UserRepositoryPostgres } = require('./user-repository-postgres'); -const { UserRepositoryDocumentDB } = require('./user-repository-documentdb'); const databaseConfig = require('../../database/config'); /** @@ -28,12 +26,14 @@ function createUserRepository() { switch (dbType) { case 'mongodb': + const { UserRepositoryMongo } = require('./user-repository-mongo'); return new UserRepositoryMongo(); case 'postgresql': return new UserRepositoryPostgres(); case 'documentdb': + const { UserRepositoryDocumentDB } = require('./user-repository-documentdb'); return new UserRepositoryDocumentDB(); default: @@ -46,7 +46,7 @@ function createUserRepository() { module.exports = { createUserRepository, // Export adapters for direct testing - UserRepositoryMongo, + get UserRepositoryMongo() { return require('./user-repository-mongo').UserRepositoryMongo; }, UserRepositoryPostgres, - UserRepositoryDocumentDB, + get UserRepositoryDocumentDB() { return require('./user-repository-documentdb').UserRepositoryDocumentDB; }, }; diff --git a/packages/core/user/tests/user-password-encryption-isolation.test.js b/packages/core/user/tests/user-password-encryption-isolation.test.js deleted file mode 100644 index 667e65272..000000000 --- a/packages/core/user/tests/user-password-encryption-isolation.test.js +++ /dev/null @@ -1,281 +0,0 @@ -/** - * Password Encryption Isolation Test - * - * Verifies that password hashing is completely isolated from the encryption system. - * Tests that passwords are bcrypt hashed regardless of encryption configuration. - * - * Key Tests: - * - With encryption ENABLED: passwords hashed (not encrypted) - * - With encryption DISABLED: passwords still hashed - * - Encryption schema does NOT include User.hashword - * - Side-by-side: tokens encrypted, passwords hashed - */ - -// Set default DATABASE_URL for testing if not already set -if (!process.env.DATABASE_URL) { - process.env.DATABASE_URL = 'mongodb://localhost:27017/frigg?replicaSet=rs0'; -} - -// Enable encryption for testing (bypass test stage check) -process.env.STAGE = 'integration-test'; -process.env.AES_KEY_ID = 'test-key-id'; -process.env.AES_KEY = 'test-aes-key-32-characters-long!'; - -jest.mock('../../database/config', () => ({ - DB_TYPE: 'mongodb', - getDatabaseType: jest.fn(() => 'mongodb'), - PRISMA_LOG_LEVEL: 'error,warn', - PRISMA_QUERY_LOGGING: false, -})); - -const bcrypt = require('bcryptjs'); -const { - createUserRepository, -} = require('../repositories/user-repository-factory'); -const { - prisma, - connectPrisma, - disconnectPrisma, - getEncryptionConfig, -} = require('../../database/prisma'); -const { - getEncryptedFields, - hasEncryptedFields, -} = require('../../database/encryption/encryption-schema-registry'); -const { mongoose } = require('../../database/mongoose'); - -describe('Password Encryption Isolation', () => { - const dbType = process.env.DB_TYPE || 'mongodb'; - let userRepository; - let testUserIds = []; - const TEST_PASSWORD = 'IsolationTestPassword123!'; - - beforeAll(async () => { - await connectPrisma(); - // Connect mongoose for raw database queries - if (mongoose.connection.readyState === 0) { - await mongoose.connect(process.env.DATABASE_URL); - } - userRepository = createUserRepository(); - }, 30000); // 30 second timeout for database connection - - afterAll(async () => { - for (const userId of testUserIds) { - await userRepository.deleteUser(userId).catch(() => {}); - } - await mongoose.disconnect(); - await disconnectPrisma(); - }, 30000); // 30 second timeout for cleanup - - test('✅ Encryption schema does NOT include User.hashword', () => { - const userEncryptedFields = getEncryptedFields('User'); - - console.log('\n📋 User model encrypted fields:', userEncryptedFields); - - expect(userEncryptedFields).toBeDefined(); - expect(Array.isArray(userEncryptedFields)).toBe(true); - expect(userEncryptedFields).not.toContain('hashword'); - - if (userEncryptedFields.length > 0) { - console.log( - '⚠️ WARNING: User model has encrypted fields:', - userEncryptedFields - ); - console.log( - ' Password field (hashword) should NOT be in this list' - ); - } else { - console.log('✅ User model has no encrypted fields (as expected)'); - } - }); - - test('✅ Password is bcrypt hashed regardless of encryption config', async () => { - const encryptionConfig = getEncryptionConfig(); - console.log('\n🔒 Current encryption config:', encryptionConfig); - - const username = `isolation-test-${Date.now()}`; - const user = await userRepository.createIndividualUser({ - username, - hashword: TEST_PASSWORD, - email: `${username}@test.com`, - }); - testUserIds.push(user.id); - - expect(user.hashword).toMatch(/^\$2[ab]\$\d{2}\$/); - expect(user.hashword).not.toBe(TEST_PASSWORD); - expect(user.hashword).not.toContain(':'); - - const isValid = await bcrypt.compare(TEST_PASSWORD, user.hashword); - expect(isValid).toBe(true); - - console.log('✅ Password correctly hashed with bcrypt'); - console.log(' Encryption enabled:', encryptionConfig.enabled); - console.log( - ' Hashword format:', - user.hashword.substring(0, 20) + '...' - ); - }); - - test('📊 Field-level encryption status comparison', async () => { - const models = ['User', 'Credential', 'Token', 'IntegrationMapping']; - - console.log('\n📊 ENCRYPTION SCHEMA ANALYSIS:'); - console.log('='.repeat(60)); - - for (const model of models) { - const fields = getEncryptedFields(model); - const hasEncryption = hasEncryptedFields(model); - - console.log(`\n${model}:`); - console.log(` Has encrypted fields: ${hasEncryption}`); - console.log( - ` Encrypted fields: ${ - fields.length > 0 ? fields.join(', ') : 'none' - }` - ); - - if (model === 'User') { - expect(fields).not.toContain('hashword'); - console.log( - ' ✅ Password (hashword) correctly excluded from encryption' - ); - } else if (model === 'Credential') { - expect(fields).toContain('data.access_token'); - console.log(' ✅ API tokens correctly included in encryption'); - } - } - }); - - test('📊 End-to-end: Create user + credential, verify isolation', async () => { - const username = `e2e-isolation-${Date.now()}`; - const secretToken = 'my-secret-api-token-xyz'; - - const user = await userRepository.createIndividualUser({ - username, - hashword: TEST_PASSWORD, - email: `${username}@test.com`, - }); - testUserIds.push(user.id); - - const credential = await prisma.credential.create({ - data: { - userId: - dbType === 'postgresql' ? parseInt(user.id, 10) : user.id, - externalId: `cred-${Date.now()}`, - data: { - access_token: secretToken, - }, - }, - }); - - console.log('\n📊 END-TO-END ISOLATION TEST:'); - console.log('='.repeat(60)); - - const fetchedUser = await userRepository.findIndividualUserById( - user.id - ); - console.log('\n👤 User Password:'); - console.log(' Format:', fetchedUser.hashword.substring(0, 30) + '...'); - console.log( - ' Is bcrypt:', - /^\$2[ab]\$\d{2}\$/.test(fetchedUser.hashword) - ); - console.log( - ' Is encrypted (has :):', - fetchedUser.hashword.includes(':') - ); - - const fetchedCred = await prisma.credential.findUnique({ - where: { id: credential.id }, - }); - - console.log('\n🔑 Credential Token:'); - const tokenValue = fetchedCred.data.access_token; - console.log(' Raw value:', tokenValue.substring(0, 50) + '...'); - console.log(' Is encrypted (has :):', tokenValue.includes(':')); - console.log(' Equals plain text:', tokenValue === secretToken); - - expect(fetchedUser.hashword).toMatch(/^\$2[ab]\$\d{2}\$/); - expect(fetchedUser.hashword).not.toContain(':'); - - const isPasswordValid = await bcrypt.compare( - TEST_PASSWORD, - fetchedUser.hashword - ); - expect(isPasswordValid).toBe(true); - - console.log('\n✅ Password: bcrypt hashed (NOT encrypted)'); - - const encryptionEnabled = tokenValue !== secretToken; - if (encryptionEnabled) { - console.log('✅ Credential: properly encrypted'); - expect(tokenValue).not.toBe(secretToken); - } else { - console.log('⚠️ Encryption disabled in this environment'); - } - - console.log( - '✅ ISOLATION VERIFIED: Passwords use bcrypt, credentials use encryption' - ); - - await prisma.credential.delete({ where: { id: credential.id } }); - }); - - test('🔍 Bcrypt vs Encryption format analysis', () => { - const bcryptHash = - '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'; - const encryptedValue = - 'kms:us-east-1:alias/app-key:AQICAHg...base64...'; - - console.log('\n🔍 FORMAT COMPARISON:'); - console.log('='.repeat(60)); - - console.log('\nBcrypt Hash Format:'); - console.log(' Example:', bcryptHash); - console.log(' Pattern: $2[ab]$rounds$salt+hash'); - console.log(' Length: ~60 chars'); - console.log(' Colon count:', (bcryptHash.match(/:/g) || []).length); - console.log(' Dollar signs: 3'); - - console.log('\nEncryption Format:'); - console.log(' Example:', encryptedValue.substring(0, 50) + '...'); - console.log(' Pattern: method:region:keyId:base64Ciphertext'); - console.log(' Colon separators: 3'); - console.log(' Variable length'); - - console.log('\n✅ Formats are clearly distinguishable'); - console.log( - '✅ Bcrypt never has colon separators between dollar signs' - ); - console.log('✅ Encryption always has exactly 3 colon separators'); - }); - - test('⚠️ Verify password NOT double-processed', async () => { - const username = `double-process-test-${Date.now()}`; - - const user = await userRepository.createIndividualUser({ - username, - hashword: TEST_PASSWORD, - email: `${username}@test.com`, - }); - testUserIds.push(user.id); - - const hash1 = user.hashword; - - const fetchedUser = await userRepository.findIndividualUserById( - user.id - ); - const hash2 = fetchedUser.hashword; - - console.log('\n⚠️ DOUBLE-PROCESSING CHECK:'); - console.log('Hash after creation:', hash1.substring(0, 30) + '...'); - console.log('Hash after fetch: ', hash2.substring(0, 30) + '...'); - console.log('Hashes match:', hash1 === hash2); - - expect(hash1).toBe(hash2); - expect(hash1).toMatch(/^\$2[ab]\$\d{2}\$/); - expect(hash2).toMatch(/^\$2[ab]\$\d{2}\$/); - - console.log('✅ No double-processing detected'); - }); -}); diff --git a/packages/core/user/tests/user-password-hashing.test.js b/packages/core/user/tests/user-password-hashing.test.js deleted file mode 100644 index a238e9e3f..000000000 --- a/packages/core/user/tests/user-password-hashing.test.js +++ /dev/null @@ -1,280 +0,0 @@ -/** - * Password Hashing Verification Test - * - * Verifies that passwords are correctly bcrypt hashed (NOT encrypted) throughout - * the user authentication flow. Tests both MongoDB and PostgreSQL. - * - * Expected Behavior: - * - Passwords hashed with bcrypt on creation (format: $2a$ or $2b$) - * - Password hashes stored as-is (NOT encrypted with KMS/AES) - * - bcrypt.compare() works correctly for authentication - * - Password updates also trigger bcrypt hashing - */ - -// Set default DATABASE_URL for testing if not already set -if (!process.env.DATABASE_URL) { - process.env.DATABASE_URL = 'mongodb://localhost:27017/frigg?replicaSet=rs0'; -} - -// Enable encryption for testing (bypass test stage check) -process.env.STAGE = 'integration-test'; -process.env.AES_KEY_ID = 'test-key-id'; -process.env.AES_KEY = 'test-aes-key-32-characters-long!'; - -jest.mock('../../database/config', () => ({ - DB_TYPE: 'mongodb', - getDatabaseType: jest.fn(() => 'mongodb'), - PRISMA_LOG_LEVEL: 'error,warn', - PRISMA_QUERY_LOGGING: false, -})); - -const bcrypt = require('bcryptjs'); -const { LoginUser } = require('../use-cases/login-user'); -const { - createUserRepository, -} = require('../repositories/user-repository-factory'); -const { - prisma, - connectPrisma, - disconnectPrisma, -} = require('../../database/prisma'); -const { mongoose } = require('../../database/mongoose'); - -describe('Password Hashing Verification - Both Databases', () => { - const dbType = process.env.DB_TYPE || 'mongodb'; - let userRepository; - let testUserId; - const TEST_PASSWORD = 'MySecurePassword123!'; - const TEST_USERNAME = `test-user-hash-${Date.now()}`; - const userConfig = { - usePassword: true, - individualUserRequired: true, - organizationUserRequired: false, - primary: 'individual', - }; - - beforeAll(async () => { - await connectPrisma(); - // Connect mongoose for raw database queries - if (mongoose.connection.readyState === 0) { - await mongoose.connect(process.env.DATABASE_URL); - } - userRepository = createUserRepository(); - }, 30000); // 30 second timeout for database connection - - afterAll(async () => { - if (testUserId) { - await userRepository.deleteUser(testUserId).catch(() => {}); - } - await mongoose.disconnect(); - await disconnectPrisma(); - }, 30000); // 30 second timeout for cleanup - - describe(`${dbType.toUpperCase()} - Password Hashing`, () => { - test('✅ Password is bcrypt hashed on user creation', async () => { - const user = await userRepository.createIndividualUser({ - username: TEST_USERNAME, - hashword: TEST_PASSWORD, - email: `${TEST_USERNAME}@test.com`, - }); - testUserId = user.id; - - expect(user.hashword).toBeDefined(); - expect(user.hashword).not.toBe(TEST_PASSWORD); - expect(user.hashword).toMatch(/^\$2[ab]\$\d{2}\$/); - expect(user.hashword.length).toBeGreaterThan(50); - expect(user.hashword).not.toContain(':'); - - console.log( - '✅ Password hashed correctly:', - user.hashword.substring(0, 20) + '...' - ); - }); - - test('✅ Stored hashword is bcrypt format, NOT encrypted', async () => { - const user = await userRepository.findIndividualUserByUsername( - TEST_USERNAME - ); - - expect(user.hashword).toMatch(/^\$2[ab]\$\d{2}\$/); - expect(user.hashword).not.toContain(':'); - expect(user.hashword.split(':')).toHaveLength(1); - - console.log('✅ Stored password has bcrypt format (not encrypted)'); - }); - - test('✅ bcrypt.compare() verifies correct password', async () => { - const user = await userRepository.findIndividualUserByUsername( - TEST_USERNAME - ); - const isValid = await bcrypt.compare(TEST_PASSWORD, user.hashword); - - expect(isValid).toBe(true); - console.log('✅ bcrypt.compare() successfully verified password'); - }); - - test('✅ bcrypt.compare() rejects incorrect password', async () => { - const user = await userRepository.findIndividualUserByUsername( - TEST_USERNAME - ); - const isValid = await bcrypt.compare( - 'WrongPassword', - user.hashword - ); - - expect(isValid).toBe(false); - console.log( - '✅ bcrypt.compare() correctly rejected wrong password' - ); - }); - - test('✅ Login succeeds with correct password', async () => { - const loginUser = new LoginUser({ userRepository, userConfig }); - const user = await loginUser.execute({ - username: TEST_USERNAME, - password: TEST_PASSWORD, - }); - - expect(user).toBeDefined(); - expect(user.getId()).toBe(testUserId); - console.log('✅ Login successful with correct password'); - }); - - test('✅ Login fails with incorrect password', async () => { - const loginUser = new LoginUser({ userRepository, userConfig }); - - await expect( - loginUser.execute({ - username: TEST_USERNAME, - password: 'WrongPassword123', - }) - ).rejects.toThrow('Incorrect username or password'); - - console.log('✅ Login correctly rejected incorrect password'); - }); - - test('✅ Password update also hashes the new password', async () => { - const newPassword = 'NewSecurePassword456!'; - - const updatedUser = await userRepository.updateIndividualUser( - testUserId, - { - hashword: newPassword, - } - ); - - expect(updatedUser.hashword).not.toBe(newPassword); - expect(updatedUser.hashword).toMatch(/^\$2[ab]\$\d{2}\$/); - expect(updatedUser.hashword).not.toContain(':'); - - const isNewPasswordValid = await bcrypt.compare( - newPassword, - updatedUser.hashword - ); - expect(isNewPasswordValid).toBe(true); - - const isOldPasswordValid = await bcrypt.compare( - TEST_PASSWORD, - updatedUser.hashword - ); - expect(isOldPasswordValid).toBe(false); - - console.log('✅ Password update correctly hashed new password'); - }); - - test('📊 Raw database check: bcrypt hash stored directly', async () => { - let rawUser; - if (dbType === 'postgresql') { - const userId = parseInt(testUserId, 10); - rawUser = await prisma.$queryRaw` - SELECT hashword FROM "User" WHERE id = ${userId} - `; - rawUser = rawUser[0]; - } else { - rawUser = await prisma - .$queryRawUnsafe( - `db.User.findOne({ _id: ObjectId("${testUserId}") })` - ) - .catch(() => { - return userRepository.findIndividualUserById( - testUserId - ); - }); - } - - console.log('\n📊 RAW DATABASE HASHWORD:'); - console.log('Format:', rawUser.hashword.substring(0, 30) + '...'); - console.log('Length:', rawUser.hashword.length); - - expect(rawUser.hashword).toMatch(/^\$2[ab]\$\d{2}\$/); - expect(rawUser.hashword).not.toContain(':'); - - console.log('✅ Raw database stores bcrypt hash (not encrypted)'); - }); - }); - - describe(`${dbType.toUpperCase()} - Encryption Isolation`, () => { - test('📊 COMPARISON: Credential tokens encrypted, passwords hashed', async () => { - const credential = await prisma.credential.create({ - data: { - userId: - dbType === 'postgresql' - ? parseInt(testUserId, 10) - : testUserId, - externalId: `test-cred-${Date.now()}`, - data: { - access_token: 'secret-access-token-12345', - refresh_token: 'secret-refresh-token-67890', - }, - }, - }); - - const user = await userRepository.findIndividualUserById( - testUserId - ); - - let rawCred; - if (dbType === 'postgresql') { - rawCred = await prisma.$queryRaw` - SELECT data FROM "Credential" WHERE id = ${credential.id} - `; - rawCred = rawCred[0]; - } else { - rawCred = await prisma.credential.findUnique({ - where: { id: credential.id }, - }); - } - - console.log('\n📊 ENCRYPTION COMPARISON:'); - console.log('Credential token (should be encrypted):'); - console.log( - ' Format:', - rawCred.data.access_token.substring(0, 50) + '...' - ); - console.log( - ' Has ":" separators:', - rawCred.data.access_token.includes(':') - ); - console.log('\nUser password (should be bcrypt hashed):'); - console.log(' Format:', user.hashword.substring(0, 30) + '...'); - console.log(' Has ":" separators:', user.hashword.includes(':')); - - const encryptionEnabled = - rawCred.data.access_token !== 'secret-access-token-12345'; - - if (encryptionEnabled) { - expect(rawCred.data.access_token).toContain(':'); - expect(rawCred.data.access_token.split(':')).toHaveLength(4); - console.log('✅ Credential token is encrypted'); - } else { - console.log('⚠️ Encryption disabled in this environment'); - } - - expect(user.hashword).toMatch(/^\$2[ab]\$\d{2}\$/); - expect(user.hashword).not.toContain(':'); - console.log('✅ Password is bcrypt hashed (NOT encrypted)'); - - await prisma.credential.delete({ where: { id: credential.id } }); - }); - }); -}); diff --git a/packages/core/websocket/repositories/websocket-connection-repository-documentdb.js b/packages/core/websocket/repositories/websocket-connection-repository-documentdb.js index 61f4a1932..389c37962 100644 --- a/packages/core/websocket/repositories/websocket-connection-repository-documentdb.js +++ b/packages/core/websocket/repositories/websocket-connection-repository-documentdb.js @@ -1,8 +1,7 @@ const { prisma } = require('../../database/prisma'); const { - ApiGatewayManagementApiClient, - PostToConnectionCommand, -} = require('@aws-sdk/client-apigatewaymanagementapi'); + StaleConnectionError, +} = require('../websocket-message-sender-interface'); const { toObjectId, fromObjectId, @@ -16,10 +15,19 @@ const { WebsocketConnectionRepositoryInterface, } = require('./websocket-connection-repository-interface'); +/** + * DocumentDB WebSocket Connection Repository Adapter + * + * BREAKING CHANGE (v3): A messageSender must be explicitly provided for + * WebSocket send functionality. For AWS API Gateway, pass + * `new ApiGatewayMessageSender()` from @friggframework/provider-aws. + * See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide. + */ class WebsocketConnectionRepositoryDocumentDB extends WebsocketConnectionRepositoryInterface { - constructor() { + constructor(messageSender = null) { super(); this.prisma = prisma; + this._messageSender = messageSender; } async createConnection(connectionId) { @@ -60,24 +68,28 @@ class WebsocketConnectionRepositoryDocumentDB extends WebsocketConnectionReposit { projection: { connectionId: 1 } } ); + if (!this._messageSender) { + throw new Error( + 'WebsocketConnectionRepositoryDocumentDB requires a messageSender for send functionality.\n' + + 'Pass one via constructor, e.g.:\n' + + ' const { ApiGatewayMessageSender } = require("@friggframework/provider-aws");\n' + + ' new WebsocketConnectionRepositoryDocumentDB(new ApiGatewayMessageSender())\n' + + 'See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide.' + ); + } + const sender = this._messageSender; + return connections.map((conn) => ({ connectionId: conn.connectionId, send: async (data) => { - const apigwManagementApi = new ApiGatewayManagementApiClient({ - endpoint: process.env.WEBSOCKET_API_ENDPOINT, - }); - try { - const command = new PostToConnectionCommand({ - ConnectionId: conn.connectionId, - Data: JSON.stringify(data), - }); - await apigwManagementApi.send(command); + await sender.send( + conn.connectionId, + data, + process.env.WEBSOCKET_API_ENDPOINT + ); } catch (error) { - if ( - error.statusCode === 410 || - error.$metadata?.httpStatusCode === 410 - ) { + if (error instanceof StaleConnectionError) { console.log(`Stale connection ${conn.connectionId}`); await deleteMany(this.prisma, 'WebsocketConnection', { connectionId: conn.connectionId, diff --git a/packages/core/websocket/repositories/websocket-connection-repository-factory.js b/packages/core/websocket/repositories/websocket-connection-repository-factory.js index bb5a3acfa..bb3bba1df 100644 --- a/packages/core/websocket/repositories/websocket-connection-repository-factory.js +++ b/packages/core/websocket/repositories/websocket-connection-repository-factory.js @@ -1,12 +1,6 @@ -const { - WebsocketConnectionRepositoryMongo, -} = require('./websocket-connection-repository-mongo'); const { WebsocketConnectionRepositoryPostgres, } = require('./websocket-connection-repository-postgres'); -const { - WebsocketConnectionRepositoryDocumentDB, -} = require('./websocket-connection-repository-documentdb'); const config = require('../../database/config'); /** @@ -20,12 +14,14 @@ function createWebsocketConnectionRepository() { switch (dbType) { case 'mongodb': + const { WebsocketConnectionRepositoryMongo } = require('./websocket-connection-repository-mongo'); return new WebsocketConnectionRepositoryMongo(); case 'postgresql': return new WebsocketConnectionRepositoryPostgres(); case 'documentdb': + const { WebsocketConnectionRepositoryDocumentDB } = require('./websocket-connection-repository-documentdb'); return new WebsocketConnectionRepositoryDocumentDB(); default: @@ -38,7 +34,7 @@ function createWebsocketConnectionRepository() { module.exports = { createWebsocketConnectionRepository, // Export adapters for direct testing - WebsocketConnectionRepositoryMongo, + get WebsocketConnectionRepositoryMongo() { return require('./websocket-connection-repository-mongo').WebsocketConnectionRepositoryMongo; }, WebsocketConnectionRepositoryPostgres, - WebsocketConnectionRepositoryDocumentDB, + get WebsocketConnectionRepositoryDocumentDB() { return require('./websocket-connection-repository-documentdb').WebsocketConnectionRepositoryDocumentDB; }, }; diff --git a/packages/core/websocket/repositories/websocket-connection-repository-mongo.js b/packages/core/websocket/repositories/websocket-connection-repository-mongo.js index 7e7c1d2b2..77317253f 100644 --- a/packages/core/websocket/repositories/websocket-connection-repository-mongo.js +++ b/packages/core/websocket/repositories/websocket-connection-repository-mongo.js @@ -1,8 +1,7 @@ const { prisma } = require('../../database/prisma'); const { - ApiGatewayManagementApiClient, - PostToConnectionCommand, -} = require('@aws-sdk/client-apigatewaymanagementapi'); + StaleConnectionError, +} = require('../websocket-message-sender-interface'); const { WebsocketConnectionRepositoryInterface, } = require('./websocket-connection-repository-interface'); @@ -14,12 +13,17 @@ const { * MongoDB-specific characteristics: * - Uses String IDs (ObjectId) * - No ID conversion needed (IDs are already strings) - * - AWS API Gateway Management API integration preserved + * + * BREAKING CHANGE (v3): A messageSender must be explicitly provided for + * WebSocket send functionality. For AWS API Gateway, pass + * `new ApiGatewayMessageSender()` from @friggframework/provider-aws. + * See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide. */ class WebsocketConnectionRepositoryMongo extends WebsocketConnectionRepositoryInterface { - constructor() { + constructor(messageSender = null) { super(); this.prisma = prisma; + this._messageSender = messageSender; } /** @@ -74,29 +78,31 @@ class WebsocketConnectionRepositoryMongo extends WebsocketConnectionRepositoryIn select: { connectionId: true }, }); + if (!this._messageSender) { + throw new Error( + 'WebsocketConnectionRepositoryMongo requires a messageSender for send functionality.\n' + + 'Pass one via constructor, e.g.:\n' + + ' const { ApiGatewayMessageSender } = require("@friggframework/provider-aws");\n' + + ' new WebsocketConnectionRepositoryMongo(new ApiGatewayMessageSender())\n' + + 'See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide.' + ); + } + const sender = this._messageSender; + return connections.map((conn) => ({ connectionId: conn.connectionId, send: async (data) => { - const apigwManagementApi = - new ApiGatewayManagementApiClient({ - endpoint: process.env.WEBSOCKET_API_ENDPOINT, - }); - try { - const command = new PostToConnectionCommand({ - ConnectionId: conn.connectionId, - Data: JSON.stringify(data), - }); - await apigwManagementApi.send(command); + await sender.send( + conn.connectionId, + data, + process.env.WEBSOCKET_API_ENDPOINT + ); } catch (error) { - if ( - error.statusCode === 410 || - error.$metadata?.httpStatusCode === 410 - ) { + if (error instanceof StaleConnectionError) { console.log( `Stale connection ${conn.connectionId}` ); - // Delete stale connection await this.prisma.websocketConnection.deleteMany({ where: { connectionId: conn.connectionId }, }); diff --git a/packages/core/websocket/repositories/websocket-connection-repository-postgres.js b/packages/core/websocket/repositories/websocket-connection-repository-postgres.js index 3a304febb..4e25a494d 100644 --- a/packages/core/websocket/repositories/websocket-connection-repository-postgres.js +++ b/packages/core/websocket/repositories/websocket-connection-repository-postgres.js @@ -1,8 +1,7 @@ const { prisma } = require('../../database/prisma'); const { - ApiGatewayManagementApiClient, - PostToConnectionCommand, -} = require('@aws-sdk/client-apigatewaymanagementapi'); + StaleConnectionError, +} = require('../websocket-message-sender-interface'); const { WebsocketConnectionRepositoryInterface, } = require('./websocket-connection-repository-interface'); @@ -15,11 +14,17 @@ const { * - Uses Int IDs with autoincrement * - Requires ID conversion: String (app layer) ↔ Int (database) * - All returned IDs are converted to strings for application layer consistency + * + * BREAKING CHANGE (v3): A messageSender must be explicitly provided for + * WebSocket send functionality. For AWS API Gateway, pass + * `new ApiGatewayMessageSender()` from @friggframework/provider-aws. + * See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide. */ class WebsocketConnectionRepositoryPostgres extends WebsocketConnectionRepositoryInterface { - constructor() { + constructor(messageSender = null) { super(); this.prisma = prisma; + this._messageSender = messageSender; } /** @@ -108,29 +113,31 @@ class WebsocketConnectionRepositoryPostgres extends WebsocketConnectionRepositor select: { connectionId: true }, }); + if (!this._messageSender) { + throw new Error( + 'WebsocketConnectionRepositoryPostgres requires a messageSender for send functionality.\n' + + 'Pass one via constructor, e.g.:\n' + + ' const { ApiGatewayMessageSender } = require("@friggframework/provider-aws");\n' + + ' new WebsocketConnectionRepositoryPostgres(new ApiGatewayMessageSender())\n' + + 'See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide.' + ); + } + const sender = this._messageSender; + return connections.map((conn) => ({ connectionId: conn.connectionId, send: async (data) => { - const apigwManagementApi = - new ApiGatewayManagementApiClient({ - endpoint: process.env.WEBSOCKET_API_ENDPOINT, - }); - try { - const command = new PostToConnectionCommand({ - ConnectionId: conn.connectionId, - Data: JSON.stringify(data), - }); - await apigwManagementApi.send(command); + await sender.send( + conn.connectionId, + data, + process.env.WEBSOCKET_API_ENDPOINT + ); } catch (error) { - if ( - error.statusCode === 410 || - error.$metadata?.httpStatusCode === 410 - ) { + if (error instanceof StaleConnectionError) { console.log( `Stale connection ${conn.connectionId}` ); - // Delete stale connection await this.prisma.websocketConnection.deleteMany({ where: { connectionId: conn.connectionId }, }); diff --git a/packages/core/websocket/repositories/websocket-connection-repository.js b/packages/core/websocket/repositories/websocket-connection-repository.js index 5ae48b177..1b317218f 100644 --- a/packages/core/websocket/repositories/websocket-connection-repository.js +++ b/packages/core/websocket/repositories/websocket-connection-repository.js @@ -1,8 +1,7 @@ const { prisma } = require('../../database/prisma'); const { - ApiGatewayManagementApiClient, - PostToConnectionCommand, -} = require('@aws-sdk/client-apigatewaymanagementapi'); + StaleConnectionError, +} = require('../websocket-message-sender-interface'); const { WebsocketConnectionRepositoryInterface, } = require('./websocket-connection-repository-interface'); @@ -16,15 +15,16 @@ const { * - PostgreSQL: Integer IDs with auto-increment * - Both use same query patterns (no many-to-many differences) * - * Migration from Mongoose: - * - Constructor injection of Prisma client - * - Static method getActiveConnections() → Instance method - * - AWS API Gateway Management API integration preserved + * BREAKING CHANGE (v3): A messageSender must be explicitly provided for + * WebSocket send functionality. For AWS API Gateway, pass + * `new ApiGatewayMessageSender()` from @friggframework/provider-aws. + * See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide. */ class WebsocketConnectionRepository extends WebsocketConnectionRepositoryInterface { - constructor(prismaClient = prisma) { + constructor(prismaClient = prisma, messageSender = null) { super(); - this.prisma = prismaClient; // Allow injection for testing + this.prisma = prismaClient; + this._messageSender = messageSender; } /** @@ -79,29 +79,31 @@ class WebsocketConnectionRepository extends WebsocketConnectionRepositoryInterfa select: { connectionId: true }, }); + if (!this._messageSender) { + throw new Error( + 'WebsocketConnectionRepository requires a messageSender for send functionality.\n' + + 'Pass one via constructor, e.g.:\n' + + ' const { ApiGatewayMessageSender } = require("@friggframework/provider-aws");\n' + + ' new WebsocketConnectionRepository(prisma, new ApiGatewayMessageSender())\n' + + 'See docs/architecture-decisions/010-decouple-aws-from-core.md for migration guide.' + ); + } + const sender = this._messageSender; + return connections.map((conn) => ({ connectionId: conn.connectionId, send: async (data) => { - const apigwManagementApi = - new ApiGatewayManagementApiClient({ - endpoint: process.env.WEBSOCKET_API_ENDPOINT, - }); - try { - const command = new PostToConnectionCommand({ - ConnectionId: conn.connectionId, - Data: JSON.stringify(data), - }); - await apigwManagementApi.send(command); + await sender.send( + conn.connectionId, + data, + process.env.WEBSOCKET_API_ENDPOINT + ); } catch (error) { - if ( - error.statusCode === 410 || - error.$metadata?.httpStatusCode === 410 - ) { + if (error instanceof StaleConnectionError) { console.log( `Stale connection ${conn.connectionId}` ); - // Delete stale connection await this.prisma.websocketConnection.deleteMany({ where: { connectionId: conn.connectionId }, }); diff --git a/packages/core/websocket/repositories/websocket-connection-repository.test.js b/packages/core/websocket/repositories/websocket-connection-repository.test.js index fef64a66a..118a113a2 100644 --- a/packages/core/websocket/repositories/websocket-connection-repository.test.js +++ b/packages/core/websocket/repositories/websocket-connection-repository.test.js @@ -1,17 +1,16 @@ /** - * Tests for WebSocket Connection Repository - AWS SDK v3 Migration + * Tests for WebSocket Connection Repository - Provider-Agnostic * - * Tests API Gateway Management API operations using aws-sdk-client-mock + * Tests WebSocket operations using a mock WebSocketMessageSenderInterface. + * No AWS SDK dependency — the message sender is injected via constructor. */ -const { mockClient } = require('aws-sdk-client-mock'); -const { - ApiGatewayManagementApiClient, - PostToConnectionCommand, -} = require('@aws-sdk/client-apigatewaymanagementapi'); const { WebsocketConnectionRepository, } = require('./websocket-connection-repository'); +const { + StaleConnectionError, +} = require('../websocket-message-sender-interface'); // Mock Prisma jest.mock('../../database/prisma', () => ({ @@ -29,14 +28,19 @@ jest.mock('../../database/prisma', () => ({ const { prisma } = require('../../database/prisma'); -describe('WebsocketConnectionRepository - AWS SDK v3', () => { - let apiGatewayMock; +describe('WebsocketConnectionRepository', () => { + let mockMessageSender; let repository; const originalEnv = process.env; beforeEach(() => { - apiGatewayMock = mockClient(ApiGatewayManagementApiClient); - repository = new WebsocketConnectionRepository(); + mockMessageSender = { + send: jest.fn().mockResolvedValue(undefined), + }; + repository = new WebsocketConnectionRepository( + prisma, + mockMessageSender + ); jest.clearAllMocks(); process.env = { ...originalEnv, @@ -46,7 +50,6 @@ describe('WebsocketConnectionRepository - AWS SDK v3', () => { }); afterEach(() => { - apiGatewayMock.reset(); process.env = originalEnv; }); @@ -104,14 +107,23 @@ describe('WebsocketConnectionRepository - AWS SDK v3', () => { expect(prisma.websocketConnection.findMany).not.toHaveBeenCalled(); }); + it('should throw if no messageSender is provided', async () => { + const repoNoSender = new WebsocketConnectionRepository(prisma); + prisma.websocketConnection.findMany.mockResolvedValue([ + { connectionId: 'conn-1' }, + ]); + + await expect( + repoNoSender.getActiveConnections() + ).rejects.toThrow('requires a messageSender'); + }); + it('should get active connections with send capability', async () => { prisma.websocketConnection.findMany.mockResolvedValue([ { connectionId: 'conn-1' }, { connectionId: 'conn-2' }, ]); - apiGatewayMock.on(PostToConnectionCommand).resolves({}); - const connections = await repository.getActiveConnections(); expect(connections).toHaveLength(2); @@ -120,55 +132,29 @@ describe('WebsocketConnectionRepository - AWS SDK v3', () => { expect(typeof connections[0].send).toBe('function'); }); - it('should send data through API Gateway Management API', async () => { + it('should send data through injected message sender', async () => { prisma.websocketConnection.findMany.mockResolvedValue([ { connectionId: 'conn-test' }, ]); - apiGatewayMock.on(PostToConnectionCommand).resolves({}); - const connections = await repository.getActiveConnections(); await connections[0].send({ message: 'hello' }); - expect(apiGatewayMock.calls()).toHaveLength(1); - - const call = apiGatewayMock.call(0); - expect(call.args[0].input).toMatchObject({ - ConnectionId: 'conn-test', - Data: JSON.stringify({ message: 'hello' }), - }); - }); - - it('should delete stale connection on 410 error', async () => { - prisma.websocketConnection.findMany.mockResolvedValue([ - { connectionId: 'stale-conn' }, - ]); - - const error = new Error('Gone'); - error.statusCode = 410; - apiGatewayMock.on(PostToConnectionCommand).rejects(error); - - prisma.websocketConnection.deleteMany.mockResolvedValue({ - count: 1, - }); - - const connections = await repository.getActiveConnections(); - await connections[0].send({ message: 'test' }); - - // Should have called deleteMany to remove stale connection - expect(prisma.websocketConnection.deleteMany).toHaveBeenCalledWith({ - where: { connectionId: 'stale-conn' }, - }); + expect(mockMessageSender.send).toHaveBeenCalledWith( + 'conn-test', + { message: 'hello' }, + 'https://test.execute-api.us-east-1.amazonaws.com/dev' + ); }); - it('should delete stale connection on 410 error (v3 metadata format)', async () => { + it('should delete stale connection on StaleConnectionError', async () => { prisma.websocketConnection.findMany.mockResolvedValue([ { connectionId: 'stale-conn' }, ]); - const error = new Error('Gone'); - error.$metadata = { httpStatusCode: 410 }; - apiGatewayMock.on(PostToConnectionCommand).rejects(error); + mockMessageSender.send.mockRejectedValue( + new StaleConnectionError('stale-conn') + ); prisma.websocketConnection.deleteMany.mockResolvedValue({ count: 1, @@ -182,14 +168,14 @@ describe('WebsocketConnectionRepository - AWS SDK v3', () => { }); }); - it('should throw non-410 errors', async () => { + it('should throw non-StaleConnectionError errors', async () => { prisma.websocketConnection.findMany.mockResolvedValue([ { connectionId: 'conn-1' }, ]); - apiGatewayMock - .on(PostToConnectionCommand) - .rejects(new Error('Network error')); + mockMessageSender.send.mockRejectedValue( + new Error('Network error') + ); const connections = await repository.getActiveConnections(); diff --git a/packages/core/websocket/websocket-message-sender-interface.js b/packages/core/websocket/websocket-message-sender-interface.js new file mode 100644 index 000000000..992f0dc41 --- /dev/null +++ b/packages/core/websocket/websocket-message-sender-interface.js @@ -0,0 +1,38 @@ +/** + * WebSocket Message Sender Interface (Port) + * + * Defines the contract for sending messages to active WebSocket connections. + * Used by WebSocket connection repositories to push data to connected clients. + * + * Following Frigg's hexagonal architecture pattern: + * - Port defines WHAT the service does (contract) + * - Adapters implement HOW (AWS API Gateway, Netlify, etc.) + */ +class WebSocketMessageSenderInterface { + /** + * Send data to a specific WebSocket connection + * + * @param {string} connectionId - The WebSocket connection identifier + * @param {*} data - Data to send (will be JSON-serialized by the adapter) + * @param {string} endpoint - The WebSocket API endpoint URL + * @returns {Promise} + * @throws {StaleConnectionError} If the connection is no longer active (410 Gone) + */ + async send(connectionId, data, endpoint) { + throw new Error('Method send must be implemented by subclass'); + } +} + +/** + * Error thrown when a WebSocket connection is stale (disconnected). + * Repositories should catch this and clean up the stale connection record. + */ +class StaleConnectionError extends Error { + constructor(connectionId) { + super(`Stale WebSocket connection: ${connectionId}`); + this.name = 'StaleConnectionError'; + this.connectionId = connectionId; + } +} + +module.exports = { WebSocketMessageSenderInterface, StaleConnectionError }; diff --git a/packages/devtools/frigg-cli/__tests__/unit/commands/build.test.js b/packages/devtools/frigg-cli/__tests__/unit/commands/build.test.js index 2a347493e..44daed5bd 100644 --- a/packages/devtools/frigg-cli/__tests__/unit/commands/build.test.js +++ b/packages/devtools/frigg-cli/__tests__/unit/commands/build.test.js @@ -207,7 +207,7 @@ describe('CLI Command: build', () => { await buildCommand({ stage: 'dev' }); expect(consoleLogSpy).toHaveBeenCalledWith('Building the serverless application...'); - expect(consoleLogSpy).toHaveBeenCalledWith('📦 Packaging serverless application...'); + expect(consoleLogSpy).toHaveBeenCalledWith('Packaging serverless application...'); }); it('should construct complete valid serverless command', async () => { diff --git a/packages/devtools/frigg-cli/__tests__/unit/commands/provider-dispatch.test.js b/packages/devtools/frigg-cli/__tests__/unit/commands/provider-dispatch.test.js new file mode 100644 index 000000000..a370f11a7 --- /dev/null +++ b/packages/devtools/frigg-cli/__tests__/unit/commands/provider-dispatch.test.js @@ -0,0 +1,383 @@ +/** + * Provider Dispatch Tests + * + * Verifies that CLI commands correctly route to provider plugins + * based on appDefinition.provider. These tests ensure: + * + * 1. AWS (default) falls through to existing serverless behavior + * 2. Non-AWS providers delegate to the provider plugin + * 3. AWS-only commands reject non-AWS providers + * + * When adding a new provider, these tests should pass without changes + * as long as the provider implements the plugin interface. + */ + +const path = require('path'); + +// Jest hoists jest.mock() calls — variable names prefixed with `mock` are allowed +function mockCreateProvider(overrides = {}) { + return { + name: 'test-provider', + deploy: jest.fn().mockResolvedValue({ url: 'https://test.example.com' }), + validate: jest.fn().mockReturnValue({ valid: true, errors: [], warnings: [] }), + preflightCheck: jest.fn().mockResolvedValue({ ready: true, missing: [] }), + generateConfig: jest.fn().mockReturnValue('# generated config'), + generateEnvTemplate: jest.fn().mockReturnValue({}), + getFunctionEntryPoints: jest.fn().mockReturnValue({ + 'api.js': '// api handler', + 'worker-background.js': '// worker handler', + }), + detect: jest.fn().mockReturnValue(false), + createHandler: jest.fn(), + ...overrides, + }; +} + +/** + * Create a mock child process that auto-fires the 'close' event with exit 0. + * This prevents deploy command from hanging on the `await` for the close event. + */ +function mockCreateChildProcess(exitCode = 0) { + return { + on: jest.fn((event, callback) => { + if (event === 'close') { + // Fire asynchronously to simulate real behavior + setImmediate(() => callback(exitCode)); + } + }), + }; +} + +// ─── Deploy Command ──────────────────────────────────────────────── + +describe('deploy command: provider dispatch', () => { + let deployCommand; + let spawn; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + + jest.mock('child_process', () => ({ + spawn: jest.fn(), + })); + + jest.mock('fs', () => ({ + existsSync: jest.fn().mockReturnValue(false), + })); + + // Mock doctor-command (deploy imports it; it has heavy AWS deps) + jest.mock('../../../doctor-command', () => ({ + doctorCommand: jest.fn(), + })); + + spawn = require('child_process').spawn; + spawn.mockReturnValue(mockCreateChildProcess(0)); + + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('falls through to osls for AWS (no provider in appDefinition)', async () => { + ({ deployCommand } = require('../../../deploy-command')); + + await deployCommand({ stage: 'dev', skipDoctor: true }); + + expect(spawn).toHaveBeenCalledWith( + 'osls', + expect.arrayContaining(['deploy']), + expect.any(Object) + ); + }); + + it('falls through to osls when provider is explicitly "aws"', async () => { + const fs = require('fs'); + fs.existsSync.mockReturnValue(true); + + jest.mock( + path.join(process.cwd(), 'index.js'), + () => ({ Definition: { provider: 'aws', environment: {} } }), + { virtual: true } + ); + + ({ deployCommand } = require('../../../deploy-command')); + await deployCommand({ stage: 'dev', skipDoctor: true }); + + expect(spawn).toHaveBeenCalledWith( + 'osls', + expect.arrayContaining(['deploy']), + expect.any(Object) + ); + }); + + it('delegates to provider.deploy() for non-AWS provider', async () => { + const mockProvider = mockCreateProvider({ name: 'netlify' }); + const fs = require('fs'); + fs.existsSync.mockReturnValue(true); + + jest.mock( + path.join(process.cwd(), 'index.js'), + () => ({ Definition: { provider: 'netlify', environment: {} } }), + { virtual: true } + ); + + jest.mock('@friggframework/core/providers/resolve-provider', () => ({ + resolveProvider: () => mockProvider, + }), { virtual: true }); + + ({ deployCommand } = require('../../../deploy-command')); + await deployCommand({ stage: 'production' }); + + // Should NOT spawn osls + expect(spawn).not.toHaveBeenCalled(); + + // Should call provider.validate then provider.deploy + expect(mockProvider.validate).toHaveBeenCalled(); + expect(mockProvider.deploy).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'netlify' }), + expect.objectContaining({ stage: 'production', prod: true }) + ); + }); + + it('exits when provider.validate() reports errors', async () => { + const mockProvider = mockCreateProvider({ + name: 'netlify', + validate: jest.fn().mockReturnValue({ + valid: false, + errors: ['KMS encryption not supported on Netlify'], + warnings: [], + }), + }); + + const fs = require('fs'); + fs.existsSync.mockReturnValue(true); + + jest.mock( + path.join(process.cwd(), 'index.js'), + () => ({ Definition: { provider: 'netlify' } }), + { virtual: true } + ); + + jest.mock('@friggframework/core/providers/resolve-provider', () => ({ + resolveProvider: () => mockProvider, + }), { virtual: true }); + + jest.spyOn(process, 'exit').mockImplementation(); + + ({ deployCommand } = require('../../../deploy-command')); + await deployCommand({ stage: 'dev' }); + + expect(process.exit).toHaveBeenCalledWith(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('KMS encryption not supported') + ); + }); + + it('exits when provider.deploy() throws', async () => { + const mockDeployError = new Error('Preflight check failed'); + mockDeployError.missing = ['NETLIFY_AUTH_TOKEN']; + + const mockProvider = mockCreateProvider({ + name: 'netlify', + deploy: jest.fn().mockRejectedValue(mockDeployError), + }); + + const fs = require('fs'); + fs.existsSync.mockReturnValue(true); + + jest.mock( + path.join(process.cwd(), 'index.js'), + () => ({ Definition: { provider: 'netlify' } }), + { virtual: true } + ); + + jest.mock('@friggframework/core/providers/resolve-provider', () => ({ + resolveProvider: () => mockProvider, + }), { virtual: true }); + + jest.spyOn(process, 'exit').mockImplementation(); + + ({ deployCommand } = require('../../../deploy-command')); + await deployCommand({ stage: 'dev' }); + + expect(process.exit).toHaveBeenCalledWith(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Preflight check failed') + ); + }); +}); + +// ─── Build Command ───────────────────────────────────────────────── + +describe('build command: provider dispatch', () => { + let buildCommand; + let spawnSync; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + + jest.mock('child_process', () => ({ + spawnSync: jest.fn().mockReturnValue({ status: 0 }), + })); + + spawnSync = require('child_process').spawnSync; + + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + jest.spyOn(process, 'exit').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('falls through to osls for AWS (default)', async () => { + ({ buildCommand } = require('../../../build-command')); + + await buildCommand({ stage: 'dev' }); + + expect(spawnSync).toHaveBeenCalledWith( + 'osls', + expect.arrayContaining(['package']), + expect.any(Object) + ); + }); + + it('delegates to provider build for non-AWS', async () => { + const mockProvider = mockCreateProvider({ name: 'netlify' }); + + jest.mock('../../../utils/provider-helper', () => ({ + loadProviderForCli: () => ({ + appDefinition: { provider: 'netlify' }, + provider: mockProvider, + providerName: 'netlify', + }), + })); + + jest.mock('fs', () => ({ + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + })); + + ({ buildCommand } = require('../../../build-command')); + await buildCommand({ stage: 'dev' }); + + // Should NOT call osls + expect(spawnSync).not.toHaveBeenCalled(); + + // Should call provider's build pipeline + expect(mockProvider.validate).toHaveBeenCalled(); + expect(mockProvider.generateConfig).toHaveBeenCalled(); + expect(mockProvider.getFunctionEntryPoints).toHaveBeenCalled(); + }); +}); + +// ─── AWS-Only Command Guards ─────────────────────────────────────── + +describe('AWS-only command guards', () => { + let processExitSpy; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + processExitSpy = jest.spyOn(process, 'exit').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('repair command rejects non-AWS provider', async () => { + jest.mock('../../../utils/provider-helper', () => ({ + loadProviderForCli: () => ({ + appDefinition: { provider: 'netlify' }, + provider: mockCreateProvider({ name: 'netlify' }), + providerName: 'netlify', + }), + })); + + jest.mock('../../../utils/output', () => ({ + info: jest.fn(), + error: jest.fn(), + log: jest.fn(), + success: jest.fn(), + warn: jest.fn(), + })); + + jest.mock('../../../../infrastructure/domains/health/domain/value-objects/stack-identifier', () => jest.fn()); + jest.mock('../../../../infrastructure/domains/health/application/use-cases/run-health-check-use-case', () => jest.fn()); + jest.mock('../../../../infrastructure/domains/health/application/use-cases/repair-via-import-use-case', () => jest.fn()); + jest.mock('../../../../infrastructure/domains/health/application/use-cases/reconcile-properties-use-case', () => jest.fn()); + jest.mock('../../../../infrastructure/domains/health/application/use-cases/execute-resource-import-use-case', () => jest.fn()); + jest.mock('../../../../infrastructure/domains/health/infrastructure/adapters/aws-stack-repository', () => jest.fn()); + jest.mock('../../../../infrastructure/domains/health/infrastructure/adapters/aws-resource-detector', () => jest.fn()); + jest.mock('../../../../infrastructure/domains/health/infrastructure/adapters/aws-resource-importer', () => jest.fn()); + jest.mock('../../../../infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler', () => jest.fn()); + jest.mock('../../../../infrastructure/domains/health/domain/services/mismatch-analyzer', () => jest.fn()); + jest.mock('../../../../infrastructure/domains/health/domain/services/health-score-calculator', () => jest.fn()); + jest.mock('../../../../infrastructure/domains/health/domain/services/template-parser', () => ({ + TemplateParser: jest.fn(), + })); + jest.mock('../../../../infrastructure/domains/health/domain/services/import-template-generator', () => ({ + ImportTemplateGenerator: jest.fn(), + })); + jest.mock('../../../../infrastructure/domains/health/domain/services/import-progress-monitor', () => ({ + ImportProgressMonitor: jest.fn(), + })); + + const { repairCommand } = require('../../../repair-command'); + const output = require('../../../utils/output'); + + await repairCommand('my-stack', { import: true }); + + expect(output.error).toHaveBeenCalledWith( + expect.stringContaining('only available for AWS') + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('generate-iam command rejects non-AWS provider', async () => { + jest.mock('../../../utils/provider-helper', () => ({ + loadProviderForCli: () => ({ + appDefinition: { provider: 'netlify' }, + provider: mockCreateProvider({ name: 'netlify' }), + providerName: 'netlify', + }), + })); + + jest.mock('fs-extra', () => ({ + existsSync: jest.fn(), + writeFileSync: jest.fn(), + ensureDirSync: jest.fn(), + }), { virtual: true }); + + jest.mock('@friggframework/core', () => ({ + findNearestBackendPackageJson: jest.fn(), + }), { virtual: true }); + + jest.mock('../../../../infrastructure/domains/security/iam-generator', () => ({ + generateIAMCloudFormation: jest.fn(), + getFeatureSummary: jest.fn(), + })); + + const { generateIamCommand } = require('../../../generate-iam-command'); + + await generateIamCommand({}); + + expect(processExitSpy).toHaveBeenCalledWith(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('only available for AWS') + ); + }); +}); diff --git a/packages/devtools/frigg-cli/build-command/index.js b/packages/devtools/frigg-cli/build-command/index.js index 46feb1878..6c335ccef 100644 --- a/packages/devtools/frigg-cli/build-command/index.js +++ b/packages/devtools/frigg-cli/build-command/index.js @@ -1,7 +1,15 @@ const { spawnSync } = require('child_process'); const path = require('path'); +const fs = require('fs'); async function buildCommand(options) { + // Check if the app uses a non-AWS provider + const providerResult = loadProviderIfConfigured(); + if (providerResult) { + return buildWithProvider(providerResult, options); + } + + // Default: AWS build via serverless framework console.log('Building the serverless application...'); // Suppress AWS SDK warning message about maintenance mode @@ -10,13 +18,13 @@ async function buildCommand(options) { // Skip AWS discovery for local builds (unless --production flag is set) if (!options.production) { process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - console.log('🏠 Building in local mode (use --production flag for production builds with AWS discovery)'); + console.log('Building in local mode (use --production flag for production builds with AWS discovery)'); } else { - console.log('🚀 Building in production mode with AWS discovery enabled'); + console.log('Building in production mode with AWS discovery enabled'); } // AWS discovery is now handled directly in serverless-template.js - console.log('📦 Packaging serverless application...'); + console.log('Packaging serverless application...'); const backendPath = path.resolve(process.cwd()); const infrastructurePath = 'infrastructure.js'; const command = 'osls'; // OSS-Serverless (drop-in replacement for serverless v3) @@ -51,16 +59,120 @@ async function buildCommand(options) { console.error(`Serverless build failed with code ${result.status}`); process.exit(1); } +} + +/** + * Check if the appDefinition specifies a non-AWS provider and resolve it. + */ +function loadProviderIfConfigured() { + try { + const { loadProviderForCli } = require('../utils/provider-helper'); + const result = loadProviderForCli(); + if (result && result.provider) { + return result; + } + } catch { + // Provider helper not available or appDefinition not found — fall through + } + return null; +} + +/** + * Build using a provider plugin. + * Provider build steps: + * 1. generateConfig() — generate platform-specific config (netlify.toml, etc.) + * 2. getFunctionEntryPoints() — copy function files to the project + */ +async function buildWithProvider({ provider, appDefinition, providerName }, options) { + console.log(`Building for ${providerName} provider...`); + const projectDir = path.resolve(process.cwd()); + + // 1. Validate + if (typeof provider.validate === 'function') { + const validation = provider.validate(appDefinition); + if (validation.errors?.length > 0) { + console.error(`\nValidation errors for ${providerName}:`); + for (const error of validation.errors) { + console.error(` - ${error}`); + } + process.exit(1); + } + if (validation.warnings?.length > 0) { + for (const warning of validation.warnings) { + console.warn(` Warning: ${warning}`); + } + } + } + + // 2. Generate platform config (e.g., netlify.toml) + if (typeof provider.generateConfig === 'function') { + console.log(`Generating ${providerName} configuration...`); + const config = provider.generateConfig(appDefinition); + + const configFileNames = { + netlify: 'netlify.toml', + }; + const configFileName = configFileNames[providerName] || `${providerName}.config`; + const configPath = path.join(projectDir, configFileName); + + fs.writeFileSync(configPath, config, 'utf-8'); + console.log(` Written ${configFileName}`); + } - // childProcess.on('error', (error) => { - // console.error(`Error executing command: ${error.message}`); - // }); + // 3. Copy function entry points + if (typeof provider.getFunctionEntryPoints === 'function') { + console.log('Generating function entry points...'); + const entryPoints = provider.getFunctionEntryPoints(appDefinition); + const functionsDir = path.join(projectDir, 'netlify', 'functions'); + + fs.mkdirSync(functionsDir, { recursive: true }); + + for (const [filename, content] of Object.entries(entryPoints)) { + const filePath = path.join(functionsDir, filename); + fs.writeFileSync(filePath, content, 'utf-8'); + if (options.verbose) { + console.log(` Written ${path.relative(projectDir, filePath)}`); + } + } + + console.log(` Generated ${Object.keys(entryPoints).length} function entry points`); + } + + // 4. Copy lib entry points (re-export shims for runtime dependencies) + if (typeof provider.getLibEntryPoints === 'function') { + const libEntryPoints = provider.getLibEntryPoints(appDefinition); + const libDir = path.join(projectDir, 'netlify', 'lib'); + + fs.mkdirSync(libDir, { recursive: true }); + + for (const [filename, content] of Object.entries(libEntryPoints)) { + const filePath = path.join(libDir, filename); + fs.writeFileSync(filePath, content, 'utf-8'); + if (options.verbose) { + console.log(` Written ${path.relative(projectDir, filePath)}`); + } + } + + console.log(` Generated ${Object.keys(libEntryPoints).length} lib entry points`); + } + + // 5. Generate env template (informational) + if (typeof provider.generateEnvTemplate === 'function') { + const envTemplate = provider.generateEnvTemplate(appDefinition); + const missingEnvVars = Object.entries(envTemplate) + .filter(([key]) => !process.env[key]) + .map(([key, desc]) => `${key}: ${desc}`); + + if (missingEnvVars.length > 0) { + console.log(`\n Required environment variables not set locally:`); + for (const entry of missingEnvVars) { + console.log(` - ${entry}`); + } + console.log(` Configure these in your ${providerName} dashboard.`); + } + } - // childProcess.on('close', (code) => { - // if (code !== 0) { - // console.log(`Child process exited with code ${code}`); - // } - // }); + console.log(`\nBuild complete for ${providerName}.`); } module.exports = { buildCommand }; diff --git a/packages/devtools/frigg-cli/deploy-command/index.js b/packages/devtools/frigg-cli/deploy-command/index.js index 5f56d0da6..58750f082 100644 --- a/packages/devtools/frigg-cli/deploy-command/index.js +++ b/packages/devtools/frigg-cli/deploy-command/index.js @@ -268,9 +268,17 @@ async function runPostDeploymentHealthCheck(stackName, options) { } async function deployCommand(options) { + const appDefinition = loadAppDefinition(); + + // Check if the app uses a non-AWS provider + const providerResult = loadProviderIfConfigured(appDefinition); + if (providerResult) { + return deployWithProvider(providerResult, options); + } + + // Default: AWS deployment via serverless framework console.log('Deploying the serverless application...'); - const appDefinition = loadAppDefinition(); const environment = validateAndBuildEnvironment(appDefinition, options); // Execute deployment @@ -302,4 +310,78 @@ async function deployCommand(options) { } } +/** + * Check if the appDefinition specifies a non-AWS provider and resolve it. + * Returns null for AWS (default) so the caller falls through to existing behavior. + * + * @param {Object|null} appDefinition + * @returns {{ provider: Object, appDefinition: Object, providerName: string } | null} + */ +function loadProviderIfConfigured(appDefinition) { + const providerName = appDefinition?.provider; + if (!providerName || providerName === 'aws') { + return null; + } + + try { + const { resolveProvider } = require('@friggframework/core/providers/resolve-provider'); + const provider = resolveProvider(appDefinition); + return { provider, appDefinition, providerName }; + } catch (error) { + console.error(`Failed to load provider '${providerName}': ${error.message}`); + process.exit(1); + } +} + +/** + * Deploy using a provider plugin (Netlify, etc.). + * Delegates entirely to the provider's deploy lifecycle: + * 1. validate() — check appDefinition for provider-specific issues + * 2. preflightCheck() — verify prerequisites (CLI tools, credentials) + * 3. deploy() — execute the deployment + */ +async function deployWithProvider({ provider, appDefinition, providerName }, options) { + console.log(`Deploying with ${providerName} provider...`); + + // 1. Validate appDefinition for this provider + if (typeof provider.validate === 'function') { + const validation = provider.validate(appDefinition); + if (validation.errors?.length > 0) { + console.error(`\nValidation errors for ${providerName}:`); + for (const error of validation.errors) { + console.error(` - ${error}`); + } + process.exit(1); + } + if (validation.warnings?.length > 0) { + for (const warning of validation.warnings) { + console.warn(` Warning: ${warning}`); + } + } + } + + // 2. Deploy via provider + try { + const result = await provider.deploy(appDefinition, { + stage: options.stage, + prod: options.stage === 'production' || options.stage === 'prod', + dryRun: options.dryRun || false, + }); + + console.log(`\n✓ Deployment completed successfully!`); + if (result.url) { + console.log(` URL: ${result.url}`); + } + } catch (error) { + console.error(`\n✗ Deployment failed: ${error.message}`); + if (error.missing) { + console.error(' Missing prerequisites:'); + for (const item of error.missing) { + console.error(` - ${item}`); + } + } + process.exit(1); + } +} + module.exports = { deployCommand }; diff --git a/packages/devtools/frigg-cli/doctor-command/index.js b/packages/devtools/frigg-cli/doctor-command/index.js index bfdf8f44c..1389ed56a 100644 --- a/packages/devtools/frigg-cli/doctor-command/index.js +++ b/packages/devtools/frigg-cli/doctor-command/index.js @@ -247,6 +247,13 @@ async function promptForStackSelection(region) { */ async function doctorCommand(stackName, options = {}) { try { + // Guard: doctor only works with AWS (CloudFormation stacks) + if (isNonAwsProvider()) { + output.error('The doctor command is only available for AWS deployments.'); + output.log('Your appDefinition uses a non-AWS provider.'); + process.exit(1); + } + // Extract options with defaults const region = options.region || process.env.AWS_REGION || 'us-east-1'; const format = options.format || 'console'; @@ -333,4 +340,17 @@ async function doctorCommand(stackName, options = {}) { } } +/** + * Check if the current appDefinition uses a non-AWS provider. + */ +function isNonAwsProvider() { + try { + const { loadProviderForCli } = require('../utils/provider-helper'); + const result = loadProviderForCli(); + return result && result.providerName !== 'aws'; + } catch { + return false; + } +} + module.exports = { doctorCommand }; diff --git a/packages/devtools/frigg-cli/generate-iam-command.js b/packages/devtools/frigg-cli/generate-iam-command.js index 3f149263a..c4472391d 100644 --- a/packages/devtools/frigg-cli/generate-iam-command.js +++ b/packages/devtools/frigg-cli/generate-iam-command.js @@ -9,7 +9,14 @@ const { generateIAMCloudFormation, getFeatureSummary } = require('../infrastruct */ async function generateIamCommand(options = {}) { try { - console.log('🔍 Finding Frigg application...'); + // Guard: generate-iam only works with AWS (IAM / CloudFormation) + if (isNonAwsProvider()) { + console.error('The generate-iam command is only available for AWS deployments.'); + console.log('Your appDefinition uses a non-AWS provider.'); + process.exit(1); + } + + console.log('Finding Frigg application...'); // Find the backend package.json const backendPath = findNearestBackendPackageJson(); @@ -115,4 +122,17 @@ async function generateIamCommand(options = {}) { } } +/** + * Check if the current appDefinition uses a non-AWS provider. + */ +function isNonAwsProvider() { + try { + const { loadProviderForCli } = require('./utils/provider-helper'); + const result = loadProviderForCli(); + return result && result.providerName !== 'aws'; + } catch { + return false; + } +} + module.exports = { generateIamCommand }; \ No newline at end of file diff --git a/packages/devtools/frigg-cli/repair-command/index.js b/packages/devtools/frigg-cli/repair-command/index.js index b5217381c..00001145c 100644 --- a/packages/devtools/frigg-cli/repair-command/index.js +++ b/packages/devtools/frigg-cli/repair-command/index.js @@ -426,6 +426,13 @@ async function handleReconcileRepair(stackIdentifier, report, options) { */ async function repairCommand(stackName, options = {}) { try { + // Guard: repair only works with AWS (CloudFormation stacks) + if (isNonAwsProvider()) { + output.error('The repair command is only available for AWS deployments.'); + output.log('Your appDefinition uses a non-AWS provider.'); + process.exit(1); + } + // Validate required parameter if (!stackName) { output.error('Error: Stack name is required'); @@ -534,4 +541,17 @@ async function repairCommand(stackName, options = {}) { } } +/** + * Check if the current appDefinition uses a non-AWS provider. + */ +function isNonAwsProvider() { + try { + const { loadProviderForCli } = require('../utils/provider-helper'); + const result = loadProviderForCli(); + return result && result.providerName !== 'aws'; + } catch { + return false; + } +} + module.exports = { repairCommand }; diff --git a/packages/devtools/frigg-cli/start-command/index.js b/packages/devtools/frigg-cli/start-command/index.js index 54b578355..f36e15e9b 100644 --- a/packages/devtools/frigg-cli/start-command/index.js +++ b/packages/devtools/frigg-cli/start-command/index.js @@ -63,6 +63,13 @@ async function startCommand(options) { console.log(chalk.green('✓ Pre-flight checks passed\n')); console.log('Starting backend and optional frontend...'); + // Check if the app uses a non-AWS provider + const providerResult = loadProviderIfConfigured(); + if (providerResult) { + return startWithProvider(providerResult, options); + } + + // Default: AWS local development via serverless-offline // Suppress AWS SDK warning message about maintenance mode process.env.AWS_SDK_JS_SUPPRESS_MAINTENANCE_MODE_MESSAGE = '1'; // Skip AWS discovery for local development @@ -109,6 +116,77 @@ async function startCommand(options) { }); } +/** + * Check if the appDefinition specifies a non-AWS provider and resolve it. + * Returns null for AWS (default) so the caller falls through to existing behavior. + */ +function loadProviderIfConfigured() { + try { + const { loadProviderForCli } = require('../utils/provider-helper'); + const result = loadProviderForCli(); + if (result && result.provider) { + return result; + } + } catch { + // Provider helper not available or appDefinition not found — fall through + } + return null; +} + +/** + * Start local dev server using the provider's recommended approach. + * Each provider has a different local dev story: + * - Netlify: `netlify dev` (reads netlify.toml, serves functions locally) + * - AWS: `osls offline` (serverless-offline, handled by default path above) + */ +function startWithProvider({ provider, providerName }, options) { + const backendPath = path.resolve(process.cwd()); + + // Provider-specific dev server commands + const DEV_COMMANDS = { + netlify: { command: 'netlify', args: ['dev'] }, + }; + + const devCmd = DEV_COMMANDS[providerName]; + if (!devCmd) { + console.error(chalk.red( + `Provider '${providerName}' does not have a local dev server configured.\n` + + ` Supported providers for local dev: ${Object.keys(DEV_COMMANDS).join(', ')}, aws` + )); + process.exit(1); + } + + console.log(chalk.blue(`Starting local dev server (${providerName})...`)); + + const args = [...devCmd.args]; + if (options.verbose) { + console.log(`Executing command: ${devCmd.command} ${args.join(' ')}`); + console.log(`Working directory: ${backendPath}`); + } + + const childProcess = spawn(devCmd.command, args, { + cwd: backendPath, + stdio: 'inherit', + env: { ...process.env }, + }); + + childProcess.on('error', (error) => { + if (error.code === 'ENOENT') { + console.error(chalk.red( + `'${devCmd.command}' not found. Install it with: npm install -g ${devCmd.command}-cli` + )); + } else { + console.error(`Error executing command: ${error.message}`); + } + }); + + childProcess.on('close', (code) => { + if (code !== 0) { + console.log(`Child process exited with code ${code}`); + } + }); +} + /** * Run interactive pre-flight checks with resolution prompts * @param {string} projectPath - Path to the Frigg project diff --git a/packages/devtools/frigg-cli/utils/__tests__/provider-helper.test.js b/packages/devtools/frigg-cli/utils/__tests__/provider-helper.test.js new file mode 100644 index 000000000..00dff523b --- /dev/null +++ b/packages/devtools/frigg-cli/utils/__tests__/provider-helper.test.js @@ -0,0 +1,55 @@ +const path = require('path'); +const { loadProviderForCli, loadCliAppDefinition } = require('../provider-helper'); + +describe('provider-helper', () => { + describe('loadCliAppDefinition', () => { + it('returns null when no index.js exists', () => { + const result = loadCliAppDefinition('/nonexistent/path'); + expect(result).toBeNull(); + }); + + it('returns null when index.js has no Definition export', () => { + // Use a directory that has an index.js but no Definition + const result = loadCliAppDefinition( + path.join(__dirname, '..', '..') + ); + // frigg-cli/index.js doesn't export Definition + expect(result).toBeNull(); + }); + }); + + describe('loadProviderForCli', () => { + it('returns null when no appDefinition found', () => { + // Run from a directory with no Frigg app + const originalCwd = process.cwd(); + try { + process.chdir('/tmp'); + const result = loadProviderForCli(); + expect(result).toBeNull(); + } finally { + process.chdir(originalCwd); + } + }); + + it('returns null provider for aws (default)', () => { + // Mock: loadCliAppDefinition returns an appDef with provider: 'aws' + jest.mock('../provider-helper', () => { + const original = jest.requireActual('../provider-helper'); + return { + ...original, + loadProviderForCli: (options) => { + // Simulate AWS provider (no provider field defaults to aws) + return { appDefinition: { provider: 'aws' }, provider: null, providerName: 'aws' }; + }, + }; + }); + + const { loadProviderForCli: mocked } = require('../provider-helper'); + const result = mocked(); + expect(result.providerName).toBe('aws'); + expect(result.provider).toBeNull(); + + jest.restoreAllMocks(); + }); + }); +}); diff --git a/packages/devtools/frigg-cli/utils/provider-helper.js b/packages/devtools/frigg-cli/utils/provider-helper.js new file mode 100644 index 000000000..c5d0f61fa --- /dev/null +++ b/packages/devtools/frigg-cli/utils/provider-helper.js @@ -0,0 +1,75 @@ +/** + * CLI Provider Helper + * + * Loads the appDefinition from the user's project and resolves the + * corresponding provider plugin package. Used by CLI commands to + * delegate to the correct provider (AWS, Netlify, etc.). + */ +const path = require('path'); +const fs = require('fs'); + +/** + * Load the appDefinition and resolve the provider plugin for CLI commands. + * + * This is a lightweight loader for the CLI context — it reads the backend + * index.js directly (no need for the full core app-definition-loader which + * searches for the nearest backend package.json at runtime). + * + * @param {Object} [options] + * @param {string} [options.cwd] - Working directory (default: process.cwd()) + * @returns {{ appDefinition: Object, provider: Object, providerName: string } | null} + * Returns null if no appDefinition is found (caller should fall back to default behavior). + */ +function loadProviderForCli(options = {}) { + const cwd = options.cwd || process.cwd(); + const appDefinition = loadCliAppDefinition(cwd); + + if (!appDefinition) { + return null; + } + + const providerName = appDefinition.provider || 'aws'; + + // For 'aws', return null provider — CLI commands fall back to existing behavior + if (providerName === 'aws') { + return { appDefinition, provider: null, providerName: 'aws' }; + } + + // For other providers, resolve the package + const { + resolveProvider, + } = require('@friggframework/core/providers/resolve-provider'); + + const provider = resolveProvider(appDefinition); + return { appDefinition, provider, providerName }; +} + +/** + * Load the appDefinition from the backend directory. + * Tries backend/index.js then index.js in the current directory. + * + * @param {string} cwd + * @returns {Object|null} + */ +function loadCliAppDefinition(cwd) { + // Try backend/index.js first (standard Frigg app structure) + const backendIndexPath = path.join(cwd, 'backend', 'index.js'); + const rootIndexPath = path.join(cwd, 'index.js'); + + for (const indexPath of [backendIndexPath, rootIndexPath]) { + if (fs.existsSync(indexPath)) { + try { + const exported = require(indexPath); + if (exported.Definition) { + return exported.Definition; + } + } catch { + // Failed to load, try next + } + } + } + + return null; +} + +module.exports = { loadProviderForCli, loadCliAppDefinition }; diff --git a/packages/devtools/frigg-cli/validate-command/infrastructure/validators/app-definition-validator.js b/packages/devtools/frigg-cli/validate-command/infrastructure/validators/app-definition-validator.js index db18c19b0..ea080085d 100644 --- a/packages/devtools/frigg-cli/validate-command/infrastructure/validators/app-definition-validator.js +++ b/packages/devtools/frigg-cli/validate-command/infrastructure/validators/app-definition-validator.js @@ -51,6 +51,23 @@ class AppDefinitionValidator { }); } + // Sanitize extensions — strip functions (bootstrap, routes.handler) + // that JSON Schema cannot validate + if (Array.isArray(definition.extensions)) { + sanitized.extensions = definition.extensions.map(ext => { + const sanitizedExt = { ...ext }; + // Remove function properties + if (typeof sanitizedExt.bootstrap === 'function') { + delete sanitizedExt.bootstrap; + } + if (sanitizedExt.routes && typeof sanitizedExt.routes.handler === 'function') { + sanitizedExt.routes = { ...sanitizedExt.routes }; + delete sanitizedExt.routes.handler; + } + return sanitizedExt; + }); + } + return sanitized; } diff --git a/packages/devtools/infrastructure/create-frigg-infrastructure.js b/packages/devtools/infrastructure/create-frigg-infrastructure.js index 9dd2b664f..8ccbfc52f 100644 --- a/packages/devtools/infrastructure/create-frigg-infrastructure.js +++ b/packages/devtools/infrastructure/create-frigg-infrastructure.js @@ -23,6 +23,92 @@ function isProcessRunning(pid) { } } +/** + * Build for a non-AWS provider. + * + * When infrastructure.js is invoked (e.g. `node infrastructure.js package`), + * but the appDefinition specifies a non-AWS provider, we skip the entire + * serverless/CloudFormation pipeline and instead: + * 1. Validate the appDefinition against the provider + * 2. Generate the provider-specific config (e.g. netlify.toml) + * 3. Generate function entry points + * + * This mirrors what the CLI's buildCommand does for non-AWS providers, + * but works when invoked directly via `node infrastructure.js package`. + */ +async function buildWithProvider(appDefinition, providerName, backendDir) { + const { resolveProvider } = require('@friggframework/core/providers/resolve-provider'); + const provider = resolveProvider(appDefinition); + + console.log(`Building for ${providerName} provider (skipping AWS infrastructure)...`); + + // 1. Validate + if (typeof provider.validate === 'function') { + const validation = provider.validate(appDefinition); + if (validation.errors?.length > 0) { + console.error(`\nValidation errors for ${providerName}:`); + for (const error of validation.errors) { + console.error(` - ${error}`); + } + process.exit(1); + } + if (validation.warnings?.length > 0) { + for (const warning of validation.warnings) { + console.warn(` Warning: ${warning}`); + } + } + } + + // 2. Generate platform config (e.g. netlify.toml) + // Written to the project root (one level up from backend/) + const projectDir = path.dirname(backendDir); + if (typeof provider.generateConfig === 'function') { + const config = provider.generateConfig(appDefinition); + const configFileNames = { netlify: 'netlify.toml' }; + const configFileName = configFileNames[providerName] || `${providerName}.config`; + const configPath = path.join(projectDir, configFileName); + + fs.writeFileSync(configPath, config, 'utf-8'); + console.log(` Written ${configFileName}`); + } + + // 3. Generate function entry points + if (typeof provider.getFunctionEntryPoints === 'function') { + const entryPoints = provider.getFunctionEntryPoints(appDefinition); + const functionsDir = path.join(projectDir, 'netlify', 'functions'); + + fs.mkdirSync(functionsDir, { recursive: true }); + + for (const [filename, content] of Object.entries(entryPoints)) { + const filePath = path.join(functionsDir, filename); + fs.writeFileSync(filePath, content, 'utf-8'); + } + + console.log(` Generated ${Object.keys(entryPoints).length} function entry points`); + } + + // 4. Generate lib entry points (re-export shims for runtime dependencies) + if (typeof provider.getLibEntryPoints === 'function') { + const libEntryPoints = provider.getLibEntryPoints(appDefinition); + const libDir = path.join(projectDir, 'netlify', 'lib'); + + fs.mkdirSync(libDir, { recursive: true }); + + for (const [filename, content] of Object.entries(libEntryPoints)) { + const filePath = path.join(libDir, filename); + fs.writeFileSync(filePath, content, 'utf-8'); + } + + console.log(` Generated ${Object.keys(libEntryPoints).length} lib entry points`); + } + + console.log(`\nBuild complete for ${providerName}.`); + + // Return an empty serverless definition — osls will see no functions + // and effectively no-op. The real deployment is handled by the provider. + return {}; +} + async function createFriggInfrastructure() { const backendPath = findNearestBackendPackageJson(); if (!backendPath) { @@ -104,6 +190,13 @@ async function createFriggInfrastructure() { const backend = require(backendFilePath); const appDefinition = backend.Definition; + // Check if a non-AWS provider is configured. + // If so, run the provider's build pipeline instead of AWS CloudFormation. + const providerName = appDefinition.provider || 'aws'; + if (providerName !== 'aws') { + return buildWithProvider(appDefinition, providerName, backendDir); + } + const definition = await composeServerlessDefinition( appDefinition, ); diff --git a/packages/devtools/infrastructure/domains/networking/vpc-builder.js b/packages/devtools/infrastructure/domains/networking/vpc-builder.js index c4b9dfe84..e9cdca7d5 100644 --- a/packages/devtools/infrastructure/domains/networking/vpc-builder.js +++ b/packages/devtools/infrastructure/domains/networking/vpc-builder.js @@ -977,7 +977,7 @@ class VpcBuilder extends InfrastructureBuilder { } // Ensure subnet associations - this.ensureSubnetAssociations(appDefinition, {}, result); + this.ensureSubnetAssociations(appDefinition, discoveredResources, result); // Create endpoints if (endpointsToCreate.includes('s3')) { diff --git a/packages/devtools/infrastructure/domains/shared/cloudformation-discovery.js b/packages/devtools/infrastructure/domains/shared/cloudformation-discovery.js index 60418da74..3a233595f 100644 --- a/packages/devtools/infrastructure/domains/shared/cloudformation-discovery.js +++ b/packages/devtools/infrastructure/domains/shared/cloudformation-discovery.js @@ -162,8 +162,8 @@ class CloudFormationDiscovery { // Extract subnet IDs from route table associations const associations = routeTable.Associations || []; const subnetAssociations = associations.filter(a => a.SubnetId); - - + discovered.routeTableAssociationCount = subnetAssociations.length; + if (subnetAssociations.length >= 1 && !discovered.privateSubnetId1) { discovered.privateSubnetId1 = subnetAssociations[0].SubnetId; console.log(` ✓ Extracted private subnet 1 from associations: ${subnetAssociations[0].SubnetId}`); @@ -562,24 +562,33 @@ class CloudFormationDiscovery { .map(a => a.SubnetId); console.log(` Route table has ${associatedSubnetIds.length} associated subnets: ${associatedSubnetIds.join(', ')}`); - + discovered.routeTableAssociationCount = associatedSubnetIds.length; + // Use the associated subnets if available if (associatedSubnetIds.length >= 2) { discovered.privateSubnetId1 = associatedSubnetIds[0]; discovered.privateSubnetId2 = associatedSubnetIds[1]; console.log(` ✓ Extracted subnets from route table associations: ${discovered.privateSubnetId1}, ${discovered.privateSubnetId2}`); } else if (associatedSubnetIds.length === 1) { - // Only 1 associated subnet, use another subnet from VPC as backup + // Only 1 associated subnet, use another private subnet from VPC as backup discovered.privateSubnetId1 = associatedSubnetIds[0]; - discovered.privateSubnetId2 = subnetsResponse.Subnets.find(s => s.SubnetId !== associatedSubnetIds[0])?.SubnetId; + const otherPrivateSubnet = subnetsResponse.Subnets.find( + s => s.SubnetId !== associatedSubnetIds[0] && !s.MapPublicIpOnLaunch + ); + discovered.privateSubnetId2 = otherPrivateSubnet?.SubnetId; console.log(` ✓ Extracted subnets (1 from route table, 1 fallback): ${discovered.privateSubnetId1}, ${discovered.privateSubnetId2}`); } else if (subnetsResponse.Subnets.length >= 2) { - // Edge case: route table Associations array is empty even when queried by ID - // This can happen when associations exist in CloudFormation but AWS API doesn't return them - // Fallback: Use first 2 subnets from VPC (all subnets in same VPC should work) - discovered.privateSubnetId1 = subnetsResponse.Subnets[0].SubnetId; - discovered.privateSubnetId2 = subnetsResponse.Subnets[1].SubnetId; - console.log(` ✓ Using first 2 subnets from VPC (route table Associations empty): ${discovered.privateSubnetId1}, ${discovered.privateSubnetId2}`); + // Route table has 0 associations — pick private subnets from VPC + const privateSubnets = subnetsResponse.Subnets.filter(s => !s.MapPublicIpOnLaunch); + if (privateSubnets.length >= 2) { + discovered.privateSubnetId1 = privateSubnets[0].SubnetId; + discovered.privateSubnetId2 = privateSubnets[1].SubnetId; + console.log(` ✓ Using private subnets from VPC (route table Associations empty): ${discovered.privateSubnetId1}, ${discovered.privateSubnetId2}`); + } else { + discovered.privateSubnetId1 = subnetsResponse.Subnets[0].SubnetId; + discovered.privateSubnetId2 = subnetsResponse.Subnets[1].SubnetId; + console.warn(' ⚠️ Could not identify private subnets by MapPublicIpOnLaunch, using first 2 VPC subnets'); + } } } } diff --git a/packages/devtools/infrastructure/domains/shared/cloudformation-discovery.test.js b/packages/devtools/infrastructure/domains/shared/cloudformation-discovery.test.js index 7ffc9c475..bd653efa7 100644 --- a/packages/devtools/infrastructure/domains/shared/cloudformation-discovery.test.js +++ b/packages/devtools/infrastructure/domains/shared/cloudformation-discovery.test.js @@ -1,6 +1,6 @@ /** * Tests for CloudFormation-based Resource Discovery - * + * * Tests discovering resources from existing CloudFormation stacks * before falling back to direct AWS API discovery. */ @@ -28,7 +28,9 @@ describe('CloudFormationDiscovery', () => { const result = await cfDiscovery.discoverFromStack('test-stack'); expect(result).toBeNull(); - expect(mockProvider.describeStack).toHaveBeenCalledWith('test-stack'); + expect(mockProvider.describeStack).toHaveBeenCalledWith( + 'test-stack' + ); }); it('should extract VPC resources from stack outputs', async () => { @@ -36,7 +38,10 @@ describe('CloudFormationDiscovery', () => { StackName: 'test-stack', Outputs: [ { OutputKey: 'VpcId', OutputValue: 'vpc-123' }, - { OutputKey: 'PrivateSubnetIds', OutputValue: 'subnet-1,subnet-2' }, + { + OutputKey: 'PrivateSubnetIds', + OutputValue: 'subnet-1,subnet-2', + }, { OutputKey: 'PublicSubnetId', OutputValue: 'subnet-3' }, { OutputKey: 'SecurityGroupId', OutputValue: 'sg-123' }, ], @@ -61,7 +66,10 @@ describe('CloudFormationDiscovery', () => { const mockStack = { StackName: 'test-stack', Outputs: [ - { OutputKey: 'KMS_KEY_ARN', OutputValue: 'arn:aws:kms:us-east-1:123456789:key/abc' }, + { + OutputKey: 'KMS_KEY_ARN', + OutputValue: 'arn:aws:kms:us-east-1:123456789:key/abc', + }, ], }; @@ -80,10 +88,26 @@ describe('CloudFormationDiscovery', () => { it('should extract VPC subnets from stack resources', async () => { const mockStack = { StackName: 'test-stack', Outputs: [] }; const mockResources = [ - { LogicalResourceId: 'FriggPrivateSubnet1', PhysicalResourceId: 'subnet-priv-1', ResourceType: 'AWS::EC2::Subnet' }, - { LogicalResourceId: 'FriggPrivateSubnet2', PhysicalResourceId: 'subnet-priv-2', ResourceType: 'AWS::EC2::Subnet' }, - { LogicalResourceId: 'FriggPublicSubnet', PhysicalResourceId: 'subnet-pub-1', ResourceType: 'AWS::EC2::Subnet' }, - { LogicalResourceId: 'FriggPublicSubnet2', PhysicalResourceId: 'subnet-pub-2', ResourceType: 'AWS::EC2::Subnet' }, + { + LogicalResourceId: 'FriggPrivateSubnet1', + PhysicalResourceId: 'subnet-priv-1', + ResourceType: 'AWS::EC2::Subnet', + }, + { + LogicalResourceId: 'FriggPrivateSubnet2', + PhysicalResourceId: 'subnet-priv-2', + ResourceType: 'AWS::EC2::Subnet', + }, + { + LogicalResourceId: 'FriggPublicSubnet', + PhysicalResourceId: 'subnet-pub-1', + ResourceType: 'AWS::EC2::Subnet', + }, + { + LogicalResourceId: 'FriggPublicSubnet2', + PhysicalResourceId: 'subnet-pub-2', + ResourceType: 'AWS::EC2::Subnet', + }, ]; mockProvider.describeStack.mockResolvedValue(mockStack); @@ -100,12 +124,36 @@ describe('CloudFormationDiscovery', () => { it('should extract route tables and VPC endpoints from stack resources', async () => { const mockStack = { StackName: 'test-stack', Outputs: [] }; const mockResources = [ - { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-123', ResourceType: 'AWS::EC2::RouteTable' }, - { LogicalResourceId: 'FriggVPCEndpointSecurityGroup', PhysicalResourceId: 'sg-vpce-123', ResourceType: 'AWS::EC2::SecurityGroup' }, - { LogicalResourceId: 'FriggS3VPCEndpoint', PhysicalResourceId: 'vpce-s3-123', ResourceType: 'AWS::EC2::VPCEndpoint' }, - { LogicalResourceId: 'FriggDynamoDBVPCEndpoint', PhysicalResourceId: 'vpce-ddb-123', ResourceType: 'AWS::EC2::VPCEndpoint' }, - { LogicalResourceId: 'FriggKMSVPCEndpoint', PhysicalResourceId: 'vpce-kms-123', ResourceType: 'AWS::EC2::VPCEndpoint' }, - { LogicalResourceId: 'FriggSecretsManagerVPCEndpoint', PhysicalResourceId: 'vpce-sm-123', ResourceType: 'AWS::EC2::VPCEndpoint' }, + { + LogicalResourceId: 'FriggLambdaRouteTable', + PhysicalResourceId: 'rtb-123', + ResourceType: 'AWS::EC2::RouteTable', + }, + { + LogicalResourceId: 'FriggVPCEndpointSecurityGroup', + PhysicalResourceId: 'sg-vpce-123', + ResourceType: 'AWS::EC2::SecurityGroup', + }, + { + LogicalResourceId: 'FriggS3VPCEndpoint', + PhysicalResourceId: 'vpce-s3-123', + ResourceType: 'AWS::EC2::VPCEndpoint', + }, + { + LogicalResourceId: 'FriggDynamoDBVPCEndpoint', + PhysicalResourceId: 'vpce-ddb-123', + ResourceType: 'AWS::EC2::VPCEndpoint', + }, + { + LogicalResourceId: 'FriggKMSVPCEndpoint', + PhysicalResourceId: 'vpce-kms-123', + ResourceType: 'AWS::EC2::VPCEndpoint', + }, + { + LogicalResourceId: 'FriggSecretsManagerVPCEndpoint', + PhysicalResourceId: 'vpce-sm-123', + ResourceType: 'AWS::EC2::VPCEndpoint', + }, ]; mockProvider.describeStack.mockResolvedValue(mockStack); @@ -224,7 +272,8 @@ describe('CloudFormationDiscovery', () => { const mockResources = [ { LogicalResourceId: 'DbMigrationQueue', - PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue', + PhysicalResourceId: + 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue', ResourceType: 'AWS::SQS::Queue', }, ]; @@ -237,7 +286,8 @@ describe('CloudFormationDiscovery', () => { expect(result).toEqual({ fromCloudFormationStack: true, stackName: 'test-stack', - migrationQueueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue', + migrationQueueUrl: + 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue', existingLogicalIds: ['DbMigrationQueue'], }); }); @@ -301,7 +351,10 @@ describe('CloudFormationDiscovery', () => { StackName: 'test-stack', Outputs: [ { OutputKey: 'VpcId', OutputValue: 'vpc-123' }, - { OutputKey: 'KMS_KEY_ARN', OutputValue: 'arn:aws:kms:us-east-1:123456789:key/abc' }, + { + OutputKey: 'KMS_KEY_ARN', + OutputValue: 'arn:aws:kms:us-east-1:123456789:key/abc', + }, ], }; @@ -377,7 +430,9 @@ describe('CloudFormationDiscovery', () => { mockProvider.describeStack.mockResolvedValue(mockStack); mockProvider.listStackResources.mockResolvedValue(mockResources); - mockProvider.getEC2Client = jest.fn().mockReturnValue(mockEC2Client); + mockProvider.getEC2Client = jest + .fn() + .mockReturnValue(mockEC2Client); // Mock security group query for VPC ID mockEC2Client.send.mockResolvedValueOnce({ @@ -392,7 +447,10 @@ describe('CloudFormationDiscovery', () => { MapPublicIpOnLaunch: false, Tags: [ { Key: 'ManagedBy', Value: 'Frigg' }, - { Key: 'aws:cloudformation:logical-id', Value: 'FriggPrivateSubnet1' }, + { + Key: 'aws:cloudformation:logical-id', + Value: 'FriggPrivateSubnet1', + }, ], }, { @@ -400,7 +458,10 @@ describe('CloudFormationDiscovery', () => { MapPublicIpOnLaunch: false, Tags: [ { Key: 'ManagedBy', Value: 'Frigg' }, - { Key: 'aws:cloudformation:logical-id', Value: 'FriggPrivateSubnet2' }, + { + Key: 'aws:cloudformation:logical-id', + Value: 'FriggPrivateSubnet2', + }, ], }, ], @@ -433,7 +494,9 @@ describe('CloudFormationDiscovery', () => { mockProvider.describeStack.mockResolvedValue(mockStack); mockProvider.listStackResources.mockResolvedValue(mockResources); - mockProvider.getEC2Client = jest.fn().mockReturnValue(mockEC2Client); + mockProvider.getEC2Client = jest + .fn() + .mockReturnValue(mockEC2Client); // Mock security group query for VPC ID mockEC2Client.send.mockResolvedValueOnce({ @@ -441,7 +504,9 @@ describe('CloudFormationDiscovery', () => { }); // Mock subnet query failure - mockEC2Client.send.mockRejectedValueOnce(new Error('EC2 API Error')); + mockEC2Client.send.mockRejectedValueOnce( + new Error('EC2 API Error') + ); const result = await cfDiscovery.discoverFromStack('test-stack'); @@ -472,9 +537,13 @@ describe('CloudFormationDiscovery', () => { const result = await cfDiscovery.discoverFromStack('test-stack'); - expect(result.defaultKmsKeyId).toBe('arn:aws:kms:us-east-1:123456789:key/abc-123'); + expect(result.defaultKmsKeyId).toBe( + 'arn:aws:kms:us-east-1:123456789:key/abc-123' + ); expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms'); - expect(mockProvider.describeKmsKey).toHaveBeenCalledWith('alias/test-service-dev-frigg-kms'); + expect(mockProvider.describeKmsKey).toHaveBeenCalledWith( + 'alias/test-service-dev-frigg-kms' + ); }); it('should query AWS API for KMS alias when serviceName and stage are provided', async () => { @@ -499,9 +568,13 @@ describe('CloudFormationDiscovery', () => { const result = await cfDiscovery.discoverFromStack('test-stack'); - expect(result.defaultKmsKeyId).toBe('arn:aws:kms:us-east-1:123456789:key/abc-123'); + expect(result.defaultKmsKeyId).toBe( + 'arn:aws:kms:us-east-1:123456789:key/abc-123' + ); expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms'); - expect(mockProvider.describeKmsKey).toHaveBeenCalledWith('alias/test-service-dev-frigg-kms'); + expect(mockProvider.describeKmsKey).toHaveBeenCalledWith( + 'alias/test-service-dev-frigg-kms' + ); }); it('should handle KMS alias not found gracefully', async () => { @@ -515,9 +588,11 @@ describe('CloudFormationDiscovery', () => { mockProvider.describeStack.mockResolvedValue(mockStack); mockProvider.listStackResources.mockResolvedValue(mockResources); mockProvider.region = 'us-east-1'; - mockProvider.describeKmsKey = jest.fn().mockRejectedValue( - new Error('Alias/test-service-dev-frigg-kms is not found') - ); + mockProvider.describeKmsKey = jest + .fn() + .mockRejectedValue( + new Error('Alias/test-service-dev-frigg-kms is not found') + ); cfDiscovery.serviceName = 'test-service'; cfDiscovery.stage = 'dev'; @@ -537,7 +612,8 @@ describe('CloudFormationDiscovery', () => { const mockResources = [ { LogicalResourceId: 'FriggKMSKey', - PhysicalResourceId: 'arn:aws:kms:us-east-1:123456789:key/xyz-789', + PhysicalResourceId: + 'arn:aws:kms:us-east-1:123456789:key/xyz-789', ResourceType: 'AWS::KMS::Key', }, ]; @@ -549,7 +625,9 @@ describe('CloudFormationDiscovery', () => { const result = await cfDiscovery.discoverFromStack('test-stack'); // Should use the key from stack resources, not query for alias - expect(result.defaultKmsKeyId).toBe('arn:aws:kms:us-east-1:123456789:key/xyz-789'); + expect(result.defaultKmsKeyId).toBe( + 'arn:aws:kms:us-east-1:123456789:key/xyz-789' + ); expect(mockProvider.describeKmsKey).not.toHaveBeenCalled(); }); @@ -562,7 +640,8 @@ describe('CloudFormationDiscovery', () => { const mockResources = [ { LogicalResourceId: 'FriggKMSKey', - PhysicalResourceId: 'arn:aws:kms:us-east-1:123456789:key/xyz-789', + PhysicalResourceId: + 'arn:aws:kms:us-east-1:123456789:key/xyz-789', ResourceType: 'AWS::KMS::Key', }, { @@ -581,16 +660,22 @@ describe('CloudFormationDiscovery', () => { const result = await cfDiscovery.discoverFromStack('test-stack'); - expect(result.defaultKmsKeyId).toBe('arn:aws:kms:us-east-1:123456789:key/xyz-789'); + expect(result.defaultKmsKeyId).toBe( + 'arn:aws:kms:us-east-1:123456789:key/xyz-789' + ); expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms'); - expect(mockProvider.describeKmsKey).toHaveBeenCalledWith('alias/test-service-dev-frigg-kms'); + expect(mockProvider.describeKmsKey).toHaveBeenCalledWith( + 'alias/test-service-dev-frigg-kms' + ); }); }); describe('External VPC with routing infrastructure pattern', () => { it('should discover routing resources when VPC is external', async () => { + // This tests the external VPC pattern: external VPC/subnets/KMS, + // but stack creates routing infrastructure (route table, NAT route, VPC endpoints) const mockStack = { - StackName: 'frigg-app-production', + StackName: 'create-frigg-app-production', Outputs: [], }; @@ -636,7 +721,9 @@ describe('CloudFormationDiscovery', () => { mockProvider.describeStack.mockResolvedValue(mockStack); mockProvider.listStackResources.mockResolvedValue(mockResources); - const result = await cfDiscovery.discoverFromStack('frigg-app-production'); + const result = await cfDiscovery.discoverFromStack( + 'create-frigg-app-production' + ); // Verify routing infrastructure was discovered expect(result.routeTableId).toBe('rtb-0b83aca77ccde20a6'); @@ -721,7 +808,9 @@ describe('CloudFormationDiscovery', () => { // Lambda security group should be extracted expect(result.lambdaSecurityGroupId).toBe('sg-01002240c6a446202'); expect(result.defaultSecurityGroupId).toBe('sg-01002240c6a446202'); - expect(result.existingLogicalIds).toContain('FriggLambdaSecurityGroup'); + expect(result.existingLogicalIds).toContain( + 'FriggLambdaSecurityGroup' + ); }); it('should support FriggPrivateRoute naming for NAT routes', async () => { @@ -772,22 +861,27 @@ describe('CloudFormationDiscovery', () => { mockProvider.describeStack.mockResolvedValue(mockStack); mockProvider.listStackResources.mockResolvedValue(mockResources); - + // Mock EC2 DescribeRouteTables to return route table with VPC info mockProvider.getEC2Client = jest.fn().mockReturnValue({ send: jest.fn().mockResolvedValue({ - RouteTables: [{ - RouteTableId: 'rtb-real-id', - VpcId: 'vpc-extracted', - Routes: [ - { NatGatewayId: 'nat-extracted', DestinationCidrBlock: '0.0.0.0/0' } - ], - Associations: [ - { SubnetId: 'subnet-1' }, - { SubnetId: 'subnet-2' } - ] - }] - }) + RouteTables: [ + { + RouteTableId: 'rtb-real-id', + VpcId: 'vpc-extracted', + Routes: [ + { + NatGatewayId: 'nat-extracted', + DestinationCidrBlock: '0.0.0.0/0', + }, + ], + Associations: [ + { SubnetId: 'subnet-1' }, + { SubnetId: 'subnet-2' }, + ], + }, + ], + }), }); const result = await cfDiscovery.discoverFromStack('test-stack'); @@ -797,7 +891,7 @@ describe('CloudFormationDiscovery', () => { expect(result.existingNatGatewayId).toBe('nat-extracted'); expect(result.privateSubnetId1).toBe('subnet-1'); expect(result.privateSubnetId2).toBe('subnet-2'); - + // Should NOT throw 'stackName is not defined' error expect(result).toBeDefined(); }); @@ -805,32 +899,63 @@ describe('CloudFormationDiscovery', () => { describe('existingLogicalIds tracking', () => { it('should track OLD VPC endpoint logical IDs (VPCEndpointS3 pattern) for backwards compatibility', async () => { + // CRITICAL: Frontify production uses OLD naming convention const mockStack = { - StackName: 'frigg-app-production', - Outputs: [] + StackName: 'create-frigg-app-production', + Outputs: [], }; const mockResources = [ - { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-123', ResourceType: 'AWS::EC2::RouteTable' }, - { LogicalResourceId: 'FriggNATRoute', PhysicalResourceId: 'rtb-123|0.0.0.0/0', ResourceType: 'AWS::EC2::Route' }, - { LogicalResourceId: 'FriggSubnet1RouteAssociation', PhysicalResourceId: 'rtbassoc-1', ResourceType: 'AWS::EC2::SubnetRouteTableAssociation' }, - { LogicalResourceId: 'FriggSubnet2RouteAssociation', PhysicalResourceId: 'rtbassoc-2', ResourceType: 'AWS::EC2::SubnetRouteTableAssociation' }, - { LogicalResourceId: 'VPCEndpointS3', PhysicalResourceId: 'vpce-s3-123', ResourceType: 'AWS::EC2::VPCEndpoint' }, - { LogicalResourceId: 'VPCEndpointDynamoDB', PhysicalResourceId: 'vpce-ddb-123', ResourceType: 'AWS::EC2::VPCEndpoint' } + { + LogicalResourceId: 'FriggLambdaRouteTable', + PhysicalResourceId: 'rtb-123', + ResourceType: 'AWS::EC2::RouteTable', + }, + { + LogicalResourceId: 'FriggNATRoute', + PhysicalResourceId: 'rtb-123|0.0.0.0/0', + ResourceType: 'AWS::EC2::Route', + }, + { + LogicalResourceId: 'FriggSubnet1RouteAssociation', + PhysicalResourceId: 'rtbassoc-1', + ResourceType: 'AWS::EC2::SubnetRouteTableAssociation', + }, + { + LogicalResourceId: 'FriggSubnet2RouteAssociation', + PhysicalResourceId: 'rtbassoc-2', + ResourceType: 'AWS::EC2::SubnetRouteTableAssociation', + }, + { + LogicalResourceId: 'VPCEndpointS3', + PhysicalResourceId: 'vpce-s3-123', + ResourceType: 'AWS::EC2::VPCEndpoint', + }, + { + LogicalResourceId: 'VPCEndpointDynamoDB', + PhysicalResourceId: 'vpce-ddb-123', + ResourceType: 'AWS::EC2::VPCEndpoint', + }, ]; mockProvider.describeStack.mockResolvedValue(mockStack); mockProvider.listStackResources.mockResolvedValue(mockResources); - const result = await cfDiscovery.discoverFromStack('frigg-app-production'); + const result = await cfDiscovery.discoverFromStack( + 'create-frigg-app-production' + ); // CRITICAL: existingLogicalIds MUST contain old VPC endpoint names expect(result.existingLogicalIds).toBeDefined(); expect(result.existingLogicalIds).toContain('FriggNATRoute'); - expect(result.existingLogicalIds).toContain('FriggSubnet1RouteAssociation'); - expect(result.existingLogicalIds).toContain('FriggSubnet2RouteAssociation'); - expect(result.existingLogicalIds).toContain('VPCEndpointS3'); // OLD naming - expect(result.existingLogicalIds).toContain('VPCEndpointDynamoDB'); // OLD naming + expect(result.existingLogicalIds).toContain( + 'FriggSubnet1RouteAssociation' + ); + expect(result.existingLogicalIds).toContain( + 'FriggSubnet2RouteAssociation' + ); + expect(result.existingLogicalIds).toContain('VPCEndpointS3'); // OLD naming + expect(result.existingLogicalIds).toContain('VPCEndpointDynamoDB'); // OLD naming // Should also have the flat discovery properties expect(result.routeTableId).toBe('rtb-123'); @@ -842,15 +967,35 @@ describe('CloudFormationDiscovery', () => { it('should track NEW VPC endpoint logical IDs (FriggS3VPCEndpoint pattern) for newer stacks', async () => { const mockStack = { StackName: 'test-stack', - Outputs: [] + Outputs: [], }; const mockResources = [ - { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-456', ResourceType: 'AWS::EC2::RouteTable' }, - { LogicalResourceId: 'FriggPrivateRoute', PhysicalResourceId: 'rtb-456|0.0.0.0/0', ResourceType: 'AWS::EC2::Route' }, - { LogicalResourceId: 'FriggS3VPCEndpoint', PhysicalResourceId: 'vpce-s3-456', ResourceType: 'AWS::EC2::VPCEndpoint' }, - { LogicalResourceId: 'FriggDynamoDBVPCEndpoint', PhysicalResourceId: 'vpce-ddb-456', ResourceType: 'AWS::EC2::VPCEndpoint' }, - { LogicalResourceId: 'FriggKMSVPCEndpoint', PhysicalResourceId: 'vpce-kms-456', ResourceType: 'AWS::EC2::VPCEndpoint' } + { + LogicalResourceId: 'FriggLambdaRouteTable', + PhysicalResourceId: 'rtb-456', + ResourceType: 'AWS::EC2::RouteTable', + }, + { + LogicalResourceId: 'FriggPrivateRoute', + PhysicalResourceId: 'rtb-456|0.0.0.0/0', + ResourceType: 'AWS::EC2::Route', + }, + { + LogicalResourceId: 'FriggS3VPCEndpoint', + PhysicalResourceId: 'vpce-s3-456', + ResourceType: 'AWS::EC2::VPCEndpoint', + }, + { + LogicalResourceId: 'FriggDynamoDBVPCEndpoint', + PhysicalResourceId: 'vpce-ddb-456', + ResourceType: 'AWS::EC2::VPCEndpoint', + }, + { + LogicalResourceId: 'FriggKMSVPCEndpoint', + PhysicalResourceId: 'vpce-kms-456', + ResourceType: 'AWS::EC2::VPCEndpoint', + }, ]; mockProvider.describeStack.mockResolvedValue(mockStack); @@ -861,9 +1006,11 @@ describe('CloudFormationDiscovery', () => { // Should track NEW naming pattern in existingLogicalIds expect(result.existingLogicalIds).toContain('FriggPrivateRoute'); expect(result.existingLogicalIds).toContain('FriggS3VPCEndpoint'); - expect(result.existingLogicalIds).toContain('FriggDynamoDBVPCEndpoint'); + expect(result.existingLogicalIds).toContain( + 'FriggDynamoDBVPCEndpoint' + ); expect(result.existingLogicalIds).toContain('FriggKMSVPCEndpoint'); - + // Should NOT contain old naming patterns expect(result.existingLogicalIds).not.toContain('FriggNATRoute'); expect(result.existingLogicalIds).not.toContain('VPCEndpointS3'); @@ -876,50 +1023,89 @@ describe('CloudFormationDiscovery', () => { // 1. Query ALL subnets in VPC using vpc-id filter (not association filter!) // 2. Query route table by ID (RouteTableIds parameter, not Filters!) // 3. Extract subnet IDs from route table's Associations array - + const mockStack = { StackName: 'test-stack', - Outputs: [] + Outputs: [], }; const mockResources = [ - { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-123', ResourceType: 'AWS::EC2::RouteTable' }, - { LogicalResourceId: 'FriggVPC', PhysicalResourceId: 'vpc-456', ResourceType: 'AWS::EC2::VPC' } + { + LogicalResourceId: 'FriggLambdaRouteTable', + PhysicalResourceId: 'rtb-123', + ResourceType: 'AWS::EC2::RouteTable', + }, + { + LogicalResourceId: 'FriggVPC', + PhysicalResourceId: 'vpc-456', + ResourceType: 'AWS::EC2::VPC', + }, ]; const sendMock = jest.fn(); sendMock .mockResolvedValueOnce({ - RouteTables: [{ - RouteTableId: 'rtb-123', - VpcId: 'vpc-456', - Associations: [], - Routes: [{ NatGatewayId: 'nat-789', DestinationCidrBlock: '0.0.0.0/0' }] - }] + RouteTables: [ + { + RouteTableId: 'rtb-123', + VpcId: 'vpc-456', + Associations: [], + Routes: [ + { + NatGatewayId: 'nat-789', + DestinationCidrBlock: '0.0.0.0/0', + }, + ], + }, + ], + }) + .mockResolvedValueOnce({ + SecurityGroups: [{ GroupId: 'sg-default' }], }) - .mockResolvedValueOnce({ SecurityGroups: [{ GroupId: 'sg-default' }] }) .mockResolvedValueOnce({ Subnets: [ - { SubnetId: 'subnet-aaa', VpcId: 'vpc-456', AvailabilityZone: 'us-east-1a' }, - { SubnetId: 'subnet-bbb', VpcId: 'vpc-456', AvailabilityZone: 'us-east-1b' }, - { SubnetId: 'subnet-ccc', VpcId: 'vpc-456', AvailabilityZone: 'us-east-1c' } - ] + { + SubnetId: 'subnet-aaa', + VpcId: 'vpc-456', + AvailabilityZone: 'us-east-1a', + }, + { + SubnetId: 'subnet-bbb', + VpcId: 'vpc-456', + AvailabilityZone: 'us-east-1b', + }, + { + SubnetId: 'subnet-ccc', + VpcId: 'vpc-456', + AvailabilityZone: 'us-east-1c', + }, + ], }) .mockResolvedValueOnce({ - RouteTables: [{ - RouteTableId: 'rtb-123', - Associations: [ - { RouteTableAssociationId: 'rtbassoc-111', SubnetId: 'subnet-aaa' }, - { RouteTableAssociationId: 'rtbassoc-222', SubnetId: 'subnet-bbb' } - ] - }] + RouteTables: [ + { + RouteTableId: 'rtb-123', + Associations: [ + { + RouteTableAssociationId: 'rtbassoc-111', + SubnetId: 'subnet-aaa', + }, + { + RouteTableAssociationId: 'rtbassoc-222', + SubnetId: 'subnet-bbb', + }, + ], + }, + ], }); - + const mockEC2Client = { send: sendMock }; mockProvider.describeStack.mockResolvedValue(mockStack); mockProvider.listStackResources.mockResolvedValue(mockResources); - mockProvider.getEC2Client = jest.fn().mockReturnValue(mockEC2Client); + mockProvider.getEC2Client = jest + .fn() + .mockReturnValue(mockEC2Client); const result = await cfDiscovery.discoverFromStack('test-stack'); @@ -928,48 +1114,201 @@ describe('CloudFormationDiscovery', () => { expect(result.privateSubnetId2).toBe('subnet-bbb'); }); + it('should select only private subnets when route table has 0 associations and VPC has public + private subnets', async () => { + // Real-world drift scenario: + // - VPC has 3 subnets: 1 public (NAT/IGW) + 2 private (Lambda) + // - IGW route table has all 3 associated (default route table) + // - Frigg lambda route table has 0 associations (drift) + // - Frigg-managed subnet query returns nothing useful + // Expected: the fallback path selects only the 2 private subnets (MapPublicIpOnLaunch=false) + const mockStack = { + StackName: 'test-stack', + Outputs: [], + }; + + const mockResources = [ + { + LogicalResourceId: 'FriggLambdaRouteTable', + PhysicalResourceId: 'rtb-lambda', + ResourceType: 'AWS::EC2::RouteTable', + }, + { + LogicalResourceId: 'FriggVPC', + PhysicalResourceId: 'vpc-456', + ResourceType: 'AWS::EC2::VPC', + }, + ]; + + const sendMock = jest.fn(); + sendMock + // External reference extraction: route table with 0 subnet associations + .mockResolvedValueOnce({ + RouteTables: [ + { + RouteTableId: 'rtb-lambda', + VpcId: 'vpc-456', + Associations: [ + { + RouteTableAssociationId: 'rtbassoc-main', + Main: true, + }, + ], + Routes: [ + { + NatGatewayId: 'nat-789', + DestinationCidrBlock: '0.0.0.0/0', + }, + ], + }, + ], + }) + // DescribeSecurityGroupsCommand — default SG + .mockResolvedValueOnce({ + SecurityGroups: [{ GroupId: 'sg-default' }], + }) + // Frigg-managed subnet query returns nothing, forcing the fallback path to run + .mockResolvedValueOnce({ + Subnets: [], + }) + // Fallback VPC-wide subnet query — 3 subnets (1 public, 2 private) + .mockResolvedValueOnce({ + Subnets: [ + { + SubnetId: 'subnet-public', + VpcId: 'vpc-456', + MapPublicIpOnLaunch: true, + AvailabilityZone: 'us-east-1a', + }, + { + SubnetId: 'subnet-priv-1', + VpcId: 'vpc-456', + MapPublicIpOnLaunch: false, + AvailabilityZone: 'us-east-1b', + }, + { + SubnetId: 'subnet-priv-2', + VpcId: 'vpc-456', + MapPublicIpOnLaunch: false, + AvailabilityZone: 'us-east-1c', + }, + ], + }) + // Fallback route table query — lambda route table still has 0 subnet associations + .mockResolvedValueOnce({ + RouteTables: [ + { + RouteTableId: 'rtb-lambda', + Associations: [ + { + RouteTableAssociationId: 'rtbassoc-main', + Main: true, + }, + ], + }, + ], + }); + + mockProvider.describeStack.mockResolvedValue(mockStack); + mockProvider.listStackResources.mockResolvedValue(mockResources); + mockProvider.getEC2Client = jest + .fn() + .mockReturnValue({ send: sendMock }); + + const result = await cfDiscovery.discoverFromStack('test-stack'); + + // Should select ONLY the 2 private subnets + expect(result.privateSubnetId1).toBe('subnet-priv-1'); + expect(result.privateSubnetId2).toBe('subnet-priv-2'); + + // Should NOT select the public subnet + expect(result.privateSubnetId1).not.toBe('subnet-public'); + expect(result.privateSubnetId2).not.toBe('subnet-public'); + + // Should record 0 associations for self-heal to detect + expect(result.routeTableAssociationCount).toBe(0); + + // Verify the test actually exercised the fallback path: + // 1) route table query for external refs + // 2) default security group query + // 3) Frigg-managed subnet query (empty) + // 4) all-subnets-in-VPC fallback query + // 5) route table query for association extraction + expect(sendMock).toHaveBeenCalledTimes(5); + expect(sendMock.mock.calls[2][0].input.Filters).toEqual([ + { Name: 'vpc-id', Values: ['vpc-456'] }, + { Name: 'tag:ManagedBy', Values: ['Frigg'] }, + ]); + expect(sendMock.mock.calls[3][0].input.Filters).toEqual([ + { Name: 'vpc-id', Values: ['vpc-456'] }, + ]); + }); + it('should handle VPC with only 1 associated subnet (use second as fallback)', async () => { const mockStack = { StackName: 'test-stack', - Outputs: [] + Outputs: [], }; const mockResources = [ - { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-123', ResourceType: 'AWS::EC2::RouteTable' }, - { LogicalResourceId: 'FriggVPC', PhysicalResourceId: 'vpc-456', ResourceType: 'AWS::EC2::VPC' } + { + LogicalResourceId: 'FriggLambdaRouteTable', + PhysicalResourceId: 'rtb-123', + ResourceType: 'AWS::EC2::RouteTable', + }, + { + LogicalResourceId: 'FriggVPC', + PhysicalResourceId: 'vpc-456', + ResourceType: 'AWS::EC2::VPC', + }, ]; const sendMock = jest.fn(); sendMock .mockResolvedValueOnce({ - RouteTables: [{ - RouteTableId: 'rtb-123', - VpcId: 'vpc-456', - Associations: [], - Routes: [{ NatGatewayId: 'nat-789', DestinationCidrBlock: '0.0.0.0/0' }] - }] + RouteTables: [ + { + RouteTableId: 'rtb-123', + VpcId: 'vpc-456', + Associations: [], + Routes: [ + { + NatGatewayId: 'nat-789', + DestinationCidrBlock: '0.0.0.0/0', + }, + ], + }, + ], + }) + .mockResolvedValueOnce({ + SecurityGroups: [{ GroupId: 'sg-default' }], }) - .mockResolvedValueOnce({ SecurityGroups: [{ GroupId: 'sg-default' }] }) .mockResolvedValueOnce({ Subnets: [ { SubnetId: 'subnet-aaa', VpcId: 'vpc-456' }, - { SubnetId: 'subnet-bbb', VpcId: 'vpc-456' } - ] + { SubnetId: 'subnet-bbb', VpcId: 'vpc-456' }, + ], }) .mockResolvedValueOnce({ - RouteTables: [{ - RouteTableId: 'rtb-123', - Associations: [ - { RouteTableAssociationId: 'rtbassoc-111', SubnetId: 'subnet-aaa' } - ] - }] + RouteTables: [ + { + RouteTableId: 'rtb-123', + Associations: [ + { + RouteTableAssociationId: 'rtbassoc-111', + SubnetId: 'subnet-aaa', + }, + ], + }, + ], }); - + const mockEC2Client = { send: sendMock }; mockProvider.describeStack.mockResolvedValue(mockStack); mockProvider.listStackResources.mockResolvedValue(mockResources); - mockProvider.getEC2Client = jest.fn().mockReturnValue(mockEC2Client); + mockProvider.getEC2Client = jest + .fn() + .mockReturnValue(mockEC2Client); const result = await cfDiscovery.discoverFromStack('test-stack'); @@ -979,4 +1318,3 @@ describe('CloudFormationDiscovery', () => { }); }); }); - diff --git a/packages/devtools/infrastructure/domains/shared/resource-discovery.js b/packages/devtools/infrastructure/domains/shared/resource-discovery.js index 1419384cc..da83320d4 100644 --- a/packages/devtools/infrastructure/domains/shared/resource-discovery.js +++ b/packages/devtools/infrastructure/domains/shared/resource-discovery.js @@ -118,6 +118,29 @@ async function gatherDiscoveredResources(appDefinition) { appDefinition.vpcIsolation === 'isolated'; if (stackResources && hasSomeUsefulData) { + // Self-heal: if route table exists but has 0 subnet associations, fix via EC2 API + if (appDefinition.vpc?.selfHeal && + stackResources.routeTableId && + stackResources.routeTableAssociationCount === 0 && + stackResources.privateSubnetId1 && stackResources.privateSubnetId2) { + + console.log(' ⚠️ Route table has 0 subnet associations - self-healing...'); + const { AssociateRouteTableCommand } = require('@aws-sdk/client-ec2'); + const ec2 = provider.getEC2Client(); + + for (const subnetId of [stackResources.privateSubnetId1, stackResources.privateSubnetId2]) { + try { + const response = await ec2.send(new AssociateRouteTableCommand({ + RouteTableId: stackResources.routeTableId, + SubnetId: subnetId, + })); + console.log(` ✓ Self-healed: associated ${subnetId} → ${stackResources.routeTableId} (${response.AssociationId})`); + } catch (error) { + console.warn(` ⚠️ Self-heal failed for ${subnetId}: ${error.message}`); + } + } + } + console.log(' ✓ Discovered resources from existing CloudFormation stack'); console.log('✅ Cloud resource discovery completed successfully!'); return stackResources; diff --git a/packages/devtools/infrastructure/domains/shared/resource-discovery.test.js b/packages/devtools/infrastructure/domains/shared/resource-discovery.test.js index 890f8576a..c03f9496a 100644 --- a/packages/devtools/infrastructure/domains/shared/resource-discovery.test.js +++ b/packages/devtools/infrastructure/domains/shared/resource-discovery.test.js @@ -12,12 +12,18 @@ jest.mock('../networking/vpc-discovery'); jest.mock('../security/kms-discovery'); jest.mock('../database/aurora-discovery'); jest.mock('../parameters/ssm-discovery'); +jest.mock('./cloudformation-discovery'); +jest.mock('@aws-sdk/client-ec2', () => ({ + AssociateRouteTableCommand: jest.fn().mockImplementation((params) => params), +})); const { CloudProviderFactory } = require('./providers/provider-factory'); const { VpcDiscovery } = require('../networking/vpc-discovery'); const { KmsDiscovery } = require('../security/kms-discovery'); const { AuroraDiscovery } = require('../database/aurora-discovery'); const { SsmDiscovery } = require('../parameters/ssm-discovery'); +const { CloudFormationDiscovery } = require('./cloudformation-discovery'); +const { AssociateRouteTableCommand } = require('@aws-sdk/client-ec2'); describe('Resource Discovery', () => { let mockProvider; @@ -31,6 +37,7 @@ describe('Resource Discovery', () => { delete process.env.FRIGG_SKIP_AWS_DISCOVERY; delete process.env.CLOUD_PROVIDER; delete process.env.AWS_REGION; + delete process.env.SLS_STAGE; // Create mock provider mockProvider = { @@ -64,6 +71,9 @@ describe('Resource Discovery', () => { }; // Mock factory and discovery constructors + CloudFormationDiscovery.mockImplementation(() => ({ + discoverFromStack: jest.fn().mockResolvedValue(null), + })); CloudProviderFactory.create = jest.fn().mockReturnValue(mockProvider); VpcDiscovery.mockImplementation(() => mockVpcDiscovery); KmsDiscovery.mockImplementation(() => mockKmsDiscovery); @@ -377,8 +387,6 @@ describe('Resource Discovery', () => { }); it('should default stage to dev', async () => { - delete process.env.SLS_STAGE; - const appDefinition = { vpc: { enable: true }, }; @@ -411,8 +419,6 @@ describe('Resource Discovery', () => { stage: 'qa', }) ); - - delete process.env.SLS_STAGE; }); it('should recognize routing infrastructure as useful data', async () => { @@ -423,8 +429,7 @@ describe('Resource Discovery', () => { process.env.SLS_STAGE = 'production'; - // Mock CloudFormation discovery to return routing infrastructure but no VPC resource - const mockCloudFormationDiscovery = { + CloudFormationDiscovery.mockImplementation(() => ({ discoverFromStack: jest.fn().mockResolvedValue({ fromCloudFormationStack: true, routeTableId: 'rtb-123', @@ -434,20 +439,13 @@ describe('Resource Discovery', () => { dynamodb: 'vpce-ddb' }, existingLogicalIds: ['FriggLambdaRouteTable', 'FriggNATRoute'] - // NO defaultVpcId, NO defaultKmsKeyId, NO auroraClusterId }) - }; - - const { CloudFormationDiscovery } = require('./cloudformation-discovery'); - CloudFormationDiscovery.mockImplementation(() => mockCloudFormationDiscovery); + })); const result = await gatherDiscoveredResources(appDefinition); - // Should use CloudFormation data without falling back to AWS API expect(result.routeTableId).toBe('rtb-123'); expect(result.vpcEndpoints.s3).toBe('vpce-s3'); - - // Should NOT call AWS API discovery expect(mockVpcDiscovery.discover).not.toHaveBeenCalled(); }); @@ -468,11 +466,8 @@ describe('Resource Discovery', () => { describe('Isolated Mode Discovery', () => { beforeEach(() => { - // Mock CloudFormation discovery - jest.mock('./cloudformation-discovery'); - const { CloudFormationDiscovery } = require('./cloudformation-discovery'); CloudFormationDiscovery.mockImplementation(() => ({ - discoverFromStack: jest.fn().mockResolvedValue({}), // No stack found + discoverFromStack: jest.fn().mockResolvedValue({}), })); }); @@ -506,12 +501,6 @@ describe('Resource Discovery', () => { }); it('should return empty if no KMS found in isolated mode (fresh infrastructure)', async () => { - const { CloudFormationDiscovery } = require('./cloudformation-discovery'); - - // Mock that CF stack exists but we still want fresh resources - CloudFormationDiscovery.mockImplementation(() => ({ - discoverFromStack: jest.fn().mockResolvedValue({}), // Stack exists but empty - })); const appDefinition = { name: 'test-app', @@ -584,5 +573,185 @@ describe('Resource Discovery', () => { }); }); }); -}); + describe('VPC Self-Heal', () => { + let mockEc2Send; + + beforeEach(() => { + AssociateRouteTableCommand.mockClear(); + mockEc2Send = jest.fn().mockResolvedValue({ AssociationId: 'rtbassoc-new-123' }); + + CloudFormationDiscovery.mockImplementation(() => ({ + discoverFromStack: jest.fn().mockResolvedValue({ + fromCloudFormationStack: true, + routeTableId: 'rtb-123', + routeTableAssociationCount: 0, + privateSubnetId1: 'subnet-priv-1', + privateSubnetId2: 'subnet-priv-2', + defaultVpcId: 'vpc-123', + }), + })); + + mockProvider.getEC2Client = jest.fn().mockReturnValue({ + send: mockEc2Send, + }); + }); + + it('should self-heal when route table has 0 associations and selfHeal is enabled', async () => { + const appDefinition = { + name: 'test-app', + vpc: { enable: true, selfHeal: true }, + }; + + process.env.SLS_STAGE = 'production'; + + const result = await gatherDiscoveredResources(appDefinition); + + expect(mockEc2Send).toHaveBeenCalledTimes(2); + expect(AssociateRouteTableCommand).toHaveBeenCalledWith({ + RouteTableId: 'rtb-123', + SubnetId: 'subnet-priv-1', + }); + expect(AssociateRouteTableCommand).toHaveBeenCalledWith({ + RouteTableId: 'rtb-123', + SubnetId: 'subnet-priv-2', + }); + expect(result.routeTableId).toBe('rtb-123'); + }); + + it('should only associate private subnets with lambda route table (not public subnet)', async () => { + // Simulates CF discovery output for: 3 subnets in VPC (1 public, 2 private), + // IGW route table has all 3, Frigg lambda route table has 0 associations. + // CF discovery already filtered by !MapPublicIpOnLaunch, so only private IDs arrive here. + CloudFormationDiscovery.mockImplementation(() => ({ + discoverFromStack: jest.fn().mockResolvedValue({ + fromCloudFormationStack: true, + routeTableId: 'rtb-lambda', + routeTableAssociationCount: 0, + privateSubnetId1: 'subnet-priv-1', + privateSubnetId2: 'subnet-priv-2', + defaultVpcId: 'vpc-123', + }), + })); + + const appDefinition = { + name: 'test-app', + vpc: { enable: true, selfHeal: true }, + }; + + process.env.SLS_STAGE = 'production'; + + await gatherDiscoveredResources(appDefinition); + + // Self-heal should associate ONLY the 2 private subnets + expect(mockEc2Send).toHaveBeenCalledTimes(2); + expect(AssociateRouteTableCommand).toHaveBeenCalledWith({ + RouteTableId: 'rtb-lambda', + SubnetId: 'subnet-priv-1', + }); + expect(AssociateRouteTableCommand).toHaveBeenCalledWith({ + RouteTableId: 'rtb-lambda', + SubnetId: 'subnet-priv-2', + }); + + // Public subnet (subnet-public) should never appear in any AssociateRouteTableCommand call + const allCalls = AssociateRouteTableCommand.mock.calls.map(c => c[0].SubnetId); + expect(allCalls).not.toContain('subnet-public'); + }); + + it('should not self-heal when selfHeal is disabled', async () => { + const appDefinition = { + name: 'test-app', + vpc: { enable: true, selfHeal: false }, + }; + + process.env.SLS_STAGE = 'production'; + + await gatherDiscoveredResources(appDefinition); + + expect(mockEc2Send).not.toHaveBeenCalled(); + }); + + it('should not self-heal when routeTableAssociationCount is not 0', async () => { + CloudFormationDiscovery.mockImplementation(() => ({ + discoverFromStack: jest.fn().mockResolvedValue({ + fromCloudFormationStack: true, + routeTableId: 'rtb-123', + routeTableAssociationCount: 2, + privateSubnetId1: 'subnet-priv-1', + privateSubnetId2: 'subnet-priv-2', + defaultVpcId: 'vpc-123', + }), + })); + + const appDefinition = { + name: 'test-app', + vpc: { enable: true, selfHeal: true }, + }; + + process.env.SLS_STAGE = 'production'; + + await gatherDiscoveredResources(appDefinition); + + expect(mockEc2Send).not.toHaveBeenCalled(); + }); + + it('should continue associating subnet 2 if subnet 1 fails', async () => { + mockEc2Send + .mockRejectedValueOnce(new Error('Resource.AlreadyAssociated')) + .mockResolvedValueOnce({ AssociationId: 'rtbassoc-456' }); + + const appDefinition = { + name: 'test-app', + vpc: { enable: true, selfHeal: true }, + }; + + process.env.SLS_STAGE = 'production'; + + const result = await gatherDiscoveredResources(appDefinition); + + expect(mockEc2Send).toHaveBeenCalledTimes(2); + expect(result.routeTableId).toBe('rtb-123'); + }); + + it('should handle both subnet associations failing gracefully', async () => { + mockEc2Send.mockRejectedValue(new Error('AccessDenied')); + + const appDefinition = { + name: 'test-app', + vpc: { enable: true, selfHeal: true }, + }; + + process.env.SLS_STAGE = 'production'; + + const result = await gatherDiscoveredResources(appDefinition); + + expect(mockEc2Send).toHaveBeenCalledTimes(2); + expect(result.routeTableId).toBe('rtb-123'); + }); + + it('should not self-heal when privateSubnetId2 is missing', async () => { + CloudFormationDiscovery.mockImplementation(() => ({ + discoverFromStack: jest.fn().mockResolvedValue({ + fromCloudFormationStack: true, + routeTableId: 'rtb-123', + routeTableAssociationCount: 0, + privateSubnetId1: 'subnet-priv-1', + // privateSubnetId2 missing + defaultVpcId: 'vpc-123', + }), + })); + + const appDefinition = { + name: 'test-app', + vpc: { enable: true, selfHeal: true }, + }; + + process.env.SLS_STAGE = 'production'; + + await gatherDiscoveredResources(appDefinition); + + expect(mockEc2Send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/devtools/infrastructure/domains/shared/types/app-definition.js b/packages/devtools/infrastructure/domains/shared/types/app-definition.js index 566aa6001..615735c42 100644 --- a/packages/devtools/infrastructure/domains/shared/types/app-definition.js +++ b/packages/devtools/infrastructure/domains/shared/types/app-definition.js @@ -145,6 +145,20 @@ * @property {AdminConfig} [admin] - Admin configuration * * @property {Object} [environment] - Environment variables + * + * @property {ExtensionDefinition[]} [extensions] - Extensions that add custom Prisma models, encrypted fields, admin routes, and bootstrap lifecycle hooks + */ + +/** + * Extension definition for extending Frigg Core with custom models, encryption, routes, and bootstrap + * @typedef {Object} ExtensionDefinition + * @property {string} name - Unique identifier for this extension + * @property {string} [schema] - Absolute path to a .prisma schema fragment + * @property {Object} [encryption] - Model name → encrypted fields mapping + * @property {Object} [routes] - Express router configuration + * @property {string} routes.path - Base URL path for the extension routes + * @property {Function|Object} routes.handler - Router factory (prisma, appDefinition) → Router, or { router } object + * @property {Function} [bootstrap] - Async function (prisma, appDefinition) → void, runs post-DB pre-router */ /** diff --git a/packages/devtools/layers/prisma/.build-complete b/packages/devtools/layers/prisma/.build-complete deleted file mode 100644 index 3df3452cf..000000000 --- a/packages/devtools/layers/prisma/.build-complete +++ /dev/null @@ -1,3 +0,0 @@ -Build completed: 2026-02-22T03:42:25.135Z -Node: v22.22.0 -Platform: linux diff --git a/packages/devtools/layers/prisma/nodejs/package.json b/packages/devtools/layers/prisma/nodejs/package.json deleted file mode 100644 index 62b4ea8f3..000000000 --- a/packages/devtools/layers/prisma/nodejs/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "prisma-lambda-layer", - "version": "1.0.0", - "private": true, - "dependencies": { - "@prisma/client": "^6.16.3" - } -} diff --git a/packages/devtools/management-ui/server/tests/package.json b/packages/devtools/management-ui/server/tests/package.json index 7c79f0ecb..cf67833f4 100644 --- a/packages/devtools/management-ui/server/tests/package.json +++ b/packages/devtools/management-ui/server/tests/package.json @@ -12,7 +12,7 @@ "devDependencies": { "@jest/globals": "^29.7.0", "jest": "^29.7.0", - "supertest": "^6.3.3", + "supertest": "^7.1.3", "dotenv": "^16.3.1" } } \ No newline at end of file diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 1b11a4741..e2c7ab229 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -19,7 +19,7 @@ "@friggframework/test": "*", "jest": "^29.7.0", "mongodb-memory-server": "^8.9.0", - "supertest": "^6.3.3" + "supertest": "^7.2.2" }, "author": "", "license": "MIT" diff --git a/packages/extensions/db-credentials/__tests__/bootstrap.test.js b/packages/extensions/db-credentials/__tests__/bootstrap.test.js new file mode 100644 index 000000000..711f2de35 --- /dev/null +++ b/packages/extensions/db-credentials/__tests__/bootstrap.test.js @@ -0,0 +1,183 @@ +const bootstrap = require('../bootstrap'); + +describe('db-credentials bootstrap', () => { + let mockPrisma; + + beforeEach(() => { + mockPrisma = { + oAuthAppCredential: { + findUnique: jest.fn(), + }, + }; + }); + + it('should be a function', () => { + expect(typeof bootstrap).toBe('function'); + }); + + it('should do nothing when no integrations', async () => { + await bootstrap(mockPrisma, {}); + await bootstrap(mockPrisma, { integrations: [] }); + await bootstrap(mockPrisma, null); + // No errors thrown + }); + + it('should replace definition.env with async function', async () => { + const moduleDefinition = { + moduleName: 'hubspot', + API: class {}, + env: { client_id: 'static-id', client_secret: 'static-secret' }, + requiredAuthMethods: {}, + }; + + class TestIntegration { + static Definition = { + name: 'test', + modules: { hubspot: moduleDefinition }, + }; + } + + const appDefinition = { + integrations: [TestIntegration], + }; + + await bootstrap(mockPrisma, appDefinition); + + // env should now be a function + expect(typeof moduleDefinition.env).toBe('function'); + }); + + it('should return DB credentials when record exists', async () => { + const moduleDefinition = { + moduleName: 'hubspot', + API: class {}, + env: { client_id: 'static-id', client_secret: 'static-secret', scope: 'contacts' }, + requiredAuthMethods: {}, + }; + + class TestIntegration { + static Definition = { + name: 'test', + modules: { hubspot: moduleDefinition }, + }; + } + + mockPrisma.oAuthAppCredential.findUnique.mockResolvedValue({ + moduleName: 'hubspot', + clientId: 'db-client-id', + clientSecret: 'db-client-secret', + extra: { scope: 'contacts crm' }, + }); + + await bootstrap(mockPrisma, { integrations: [TestIntegration] }); + + const result = await moduleDefinition.env(); + + expect(mockPrisma.oAuthAppCredential.findUnique).toHaveBeenCalledWith({ + where: { moduleName: 'hubspot' }, + }); + expect(result.client_id).toBe('db-client-id'); + expect(result.client_secret).toBe('db-client-secret'); + expect(result.scope).toBe('contacts crm'); // extra overrides static + }); + + it('should fall back to original static env when no DB record', async () => { + const moduleDefinition = { + moduleName: 'hubspot', + API: class {}, + env: { client_id: 'env-id', client_secret: 'env-secret' }, + requiredAuthMethods: {}, + }; + + class TestIntegration { + static Definition = { + name: 'test', + modules: { hubspot: moduleDefinition }, + }; + } + + mockPrisma.oAuthAppCredential.findUnique.mockResolvedValue(null); + + await bootstrap(mockPrisma, { integrations: [TestIntegration] }); + + const result = await moduleDefinition.env(); + expect(result.client_id).toBe('env-id'); + expect(result.client_secret).toBe('env-secret'); + }); + + it('should fall back gracefully when DB query fails', async () => { + const moduleDefinition = { + moduleName: 'hubspot', + API: class {}, + env: { client_id: 'env-id', client_secret: 'env-secret' }, + requiredAuthMethods: {}, + }; + + class TestIntegration { + static Definition = { + name: 'test', + modules: { hubspot: moduleDefinition }, + }; + } + + mockPrisma.oAuthAppCredential.findUnique.mockRejectedValue( + new Error('Table does not exist') + ); + + await bootstrap(mockPrisma, { integrations: [TestIntegration] }); + + const result = await moduleDefinition.env(); + expect(result.client_id).toBe('env-id'); + expect(result.client_secret).toBe('env-secret'); + }); + + it('should work when original env is already a function', async () => { + const moduleDefinition = { + moduleName: 'hubspot', + API: class {}, + env: async () => ({ client_id: 'fn-id', client_secret: 'fn-secret', scope: 'all' }), + requiredAuthMethods: {}, + }; + + class TestIntegration { + static Definition = { + name: 'test', + modules: { hubspot: moduleDefinition }, + }; + } + + mockPrisma.oAuthAppCredential.findUnique.mockResolvedValue({ + moduleName: 'hubspot', + clientId: 'db-id', + clientSecret: 'db-secret', + extra: {}, + }); + + await bootstrap(mockPrisma, { integrations: [TestIntegration] }); + + const result = await moduleDefinition.env(); + expect(result.client_id).toBe('db-id'); + expect(result.client_secret).toBe('db-secret'); + expect(result.scope).toBe('all'); // preserved from original fn + }); + + it('should skip modules without moduleName', async () => { + const moduleDefinition = { + API: class {}, + env: { client_id: 'original' }, + }; + + class TestIntegration { + static Definition = { + name: 'test', + modules: { broken: moduleDefinition }, + }; + } + + await bootstrap(mockPrisma, { integrations: [TestIntegration] }); + + // env should NOT have been replaced + expect(typeof moduleDefinition.env).toBe('object'); + expect(moduleDefinition.env.client_id).toBe('original'); + }); +}); diff --git a/packages/extensions/db-credentials/__tests__/index.test.js b/packages/extensions/db-credentials/__tests__/index.test.js new file mode 100644 index 000000000..d86015ccc --- /dev/null +++ b/packages/extensions/db-credentials/__tests__/index.test.js @@ -0,0 +1,37 @@ +const dbCredentials = require('../index'); + +describe('@friggframework/extension-db-credentials', () => { + it('should export a valid extension definition', () => { + expect(dbCredentials.name).toBe('db-credentials'); + expect(typeof dbCredentials.schema).toBe('string'); + expect(dbCredentials.schema).toMatch(/\.prisma$/); + expect(dbCredentials.encryption).toBeDefined(); + expect(dbCredentials.encryption.OAuthAppCredential.fields).toContain('clientSecret'); + expect(dbCredentials.routes.path).toBe('/api/admin/oauth-credentials'); + expect(typeof dbCredentials.routes.handler).toBe('function'); + expect(typeof dbCredentials.bootstrap).toBe('function'); + }); + + it('should have schema path pointing to existing file', () => { + const fs = require('fs'); + expect(fs.existsSync(dbCredentials.schema)).toBe(true); + }); + + it('should be usable directly in appDefinition.extensions', () => { + // Verify it matches the ExtensionDefinition shape + const ext = dbCredentials; + + expect(ext).toEqual( + expect.objectContaining({ + name: expect.any(String), + schema: expect.any(String), + encryption: expect.any(Object), + routes: expect.objectContaining({ + path: expect.any(String), + handler: expect.any(Function), + }), + bootstrap: expect.any(Function), + }) + ); + }); +}); diff --git a/packages/extensions/db-credentials/__tests__/routes.test.js b/packages/extensions/db-credentials/__tests__/routes.test.js new file mode 100644 index 000000000..593b65f4a --- /dev/null +++ b/packages/extensions/db-credentials/__tests__/routes.test.js @@ -0,0 +1,58 @@ +const express = require('express'); +const createRouter = require('../routes'); + +// Minimal test helper — avoids bringing in supertest as a dependency +function createTestApp(prisma) { + const app = express(); + app.use(express.json()); + app.use('/creds', createRouter(prisma)); + return app; +} + +describe('db-credentials routes', () => { + it('should export a function that returns an express router', () => { + expect(typeof createRouter).toBe('function'); + + const mockPrisma = { + oAuthAppCredential: { + findMany: jest.fn(), + findUnique: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + }, + }; + + const router = createRouter(mockPrisma); + expect(router).toBeDefined(); + // Express routers have a stack of route layers + expect(router.stack).toBeDefined(); + }); + + it('should define GET, PUT, DELETE routes', () => { + const mockPrisma = { + oAuthAppCredential: { + findMany: jest.fn(), + findUnique: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + }, + }; + + const router = createRouter(mockPrisma); + const routes = router.stack + .filter((layer) => layer.route) + .map((layer) => ({ + path: layer.route.path, + methods: Object.keys(layer.route.methods), + })); + + expect(routes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: '/', methods: ['get'] }), + expect.objectContaining({ path: '/:moduleName', methods: ['get'] }), + expect.objectContaining({ path: '/:moduleName', methods: ['put'] }), + expect.objectContaining({ path: '/:moduleName', methods: ['delete'] }), + ]) + ); + }); +}); diff --git a/packages/extensions/db-credentials/bootstrap.js b/packages/extensions/db-credentials/bootstrap.js new file mode 100644 index 000000000..b60b8accc --- /dev/null +++ b/packages/extensions/db-credentials/bootstrap.js @@ -0,0 +1,68 @@ +/** + * Bootstrap hook for the db-credentials extension. + * + * Runs after database connection but before routers are created. + * Replaces static `definition.env` on each integration's module definitions + * with an async function that loads OAuth app credentials from the database. + * + * This allows client_id/client_secret to be managed in the DB (via the admin API) + * instead of hardcoding them as environment variables. + * + * @param {Object} prisma - Prisma client instance + * @param {Object} appDefinition - Full Frigg app definition + */ +async function bootstrap(prisma, appDefinition) { + if (!appDefinition?.integrations || !Array.isArray(appDefinition.integrations)) { + return; + } + + for (const integration of appDefinition.integrations) { + const modules = integration.Definition?.modules; + if (!modules || typeof modules !== 'object') { + continue; + } + + for (const moduleDefinition of Object.values(modules)) { + if (!moduleDefinition?.moduleName) { + continue; + } + + const { moduleName } = moduleDefinition; + const originalEnv = moduleDefinition.env; + + // Replace env with an async loader that queries the DB first, + // falling back to the original static env if no DB record exists. + moduleDefinition.env = async () => { + try { + const record = await prisma.oAuthAppCredential.findUnique({ + where: { moduleName }, + }); + + if (record) { + const base = typeof originalEnv === 'function' + ? await originalEnv() + : (originalEnv || {}); + + return { + ...base, + client_id: record.clientId, + client_secret: record.clientSecret, + ...(record.extra && typeof record.extra === 'object' ? record.extra : {}), + }; + } + } catch (error) { + // Table may not exist yet (e.g. first deploy before migration). + // Fall through to original env. + } + + // Fallback to original env + if (typeof originalEnv === 'function') { + return await originalEnv(); + } + return originalEnv; + }; + } + } +} + +module.exports = bootstrap; diff --git a/packages/extensions/db-credentials/index.js b/packages/extensions/db-credentials/index.js new file mode 100644 index 000000000..884ad81b1 --- /dev/null +++ b/packages/extensions/db-credentials/index.js @@ -0,0 +1,45 @@ +/** + * @friggframework/extension-db-credentials + * + * Frigg extension that stores OAuth app credentials (client_id, client_secret) + * in the database, managed via an admin API. + * + * Usage in your app definition: + * + * const dbCredentials = require('@friggframework/extension-db-credentials'); + * + * const appDefinition = { + * name: 'my-app', + * integrations: [...], + * extensions: [dbCredentials], + * }; + * + * What it provides: + * - A Prisma model `OAuthAppCredential` for storing client_id/client_secret per module + * - A bootstrap hook that replaces static `definition.env` with DB-backed lookups + * - Admin API routes at `/api/admin/oauth-credentials` for CRUD operations + * - Encryption for the `clientSecret` field via Frigg's field-level encryption + */ + +const path = require('path'); +const bootstrap = require('./bootstrap'); +const createRouter = require('./routes'); + +module.exports = { + name: 'db-credentials', + + schema: path.join(__dirname, 'prisma', 'schema.prisma'), + + encryption: { + OAuthAppCredential: { + fields: ['clientSecret'], + }, + }, + + routes: { + path: '/api/admin/oauth-credentials', + handler: createRouter, + }, + + bootstrap, +}; diff --git a/packages/extensions/db-credentials/package.json b/packages/extensions/db-credentials/package.json new file mode 100644 index 000000000..92a48a164 --- /dev/null +++ b/packages/extensions/db-credentials/package.json @@ -0,0 +1,38 @@ +{ + "name": "@friggframework/extension-db-credentials", + "version": "0.1.0", + "description": "Frigg extension that stores OAuth app credentials (client ID/secret) in the database instead of environment variables", + "main": "index.js", + "keywords": [ + "frigg", + "extension", + "oauth", + "credentials", + "database" + ], + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/friggframework/frigg.git" + }, + "bugs": { + "url": "https://github.com/friggframework/frigg/issues" + }, + "homepage": "https://github.com/friggframework/frigg#readme", + "dependencies": { + "express": "^4.19.2" + }, + "peerDependencies": { + "@friggframework/core": ">=2.0.0-0" + }, + "devDependencies": { + "jest": "^29.0.0" + }, + "scripts": { + "test": "jest --passWithNoTests" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/extensions/db-credentials/prisma/schema.prisma b/packages/extensions/db-credentials/prisma/schema.prisma new file mode 100644 index 000000000..bb7c16f6f --- /dev/null +++ b/packages/extensions/db-credentials/prisma/schema.prisma @@ -0,0 +1,17 @@ +// Frigg Extension: db-credentials +// Stores OAuth app credentials (client_id, client_secret) in the database +// so they can be managed via admin API instead of environment variables. + +model OAuthAppCredential { + id String @id @default(auto()) @map("_id") @db.ObjectId + moduleName String @unique + clientId String + clientSecret String + extra Json @default("{}") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([moduleName]) + @@map("OAuthAppCredential") +} diff --git a/packages/extensions/db-credentials/routes.js b/packages/extensions/db-credentials/routes.js new file mode 100644 index 000000000..b982dfee3 --- /dev/null +++ b/packages/extensions/db-credentials/routes.js @@ -0,0 +1,121 @@ +/** + * Admin API routes for managing OAuth app credentials in the database. + * + * Routes are mounted behind admin auth middleware by the extension system. + * + * @param {Object} prisma - Prisma client instance + * @returns {import('express').Router} + */ +const express = require('express'); + +function createRouter(prisma) { + const router = express.Router(); + + // List all stored credentials (client_secret is redacted) + router.get('/', async (_req, res) => { + try { + const records = await prisma.oAuthAppCredential.findMany({ + orderBy: { moduleName: 'asc' }, + }); + + const redacted = records.map((r) => ({ + id: r.id, + moduleName: r.moduleName, + clientId: r.clientId, + clientSecret: '***', + extra: r.extra, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })); + + res.json(redacted); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Get a single credential by moduleName + router.get('/:moduleName', async (req, res) => { + try { + const record = await prisma.oAuthAppCredential.findUnique({ + where: { moduleName: req.params.moduleName }, + }); + + if (!record) { + return res.status(404).json({ error: 'Not found' }); + } + + res.json({ + id: record.id, + moduleName: record.moduleName, + clientId: record.clientId, + clientSecret: '***', + extra: record.extra, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Create or update a credential (upsert by moduleName) + router.put('/:moduleName', async (req, res) => { + try { + const { moduleName } = req.params; + const { clientId, clientSecret, extra } = req.body; + + if (!clientId || !clientSecret) { + return res.status(400).json({ + error: 'clientId and clientSecret are required', + }); + } + + const record = await prisma.oAuthAppCredential.upsert({ + where: { moduleName }, + create: { + moduleName, + clientId, + clientSecret, + extra: extra || {}, + }, + update: { + clientId, + clientSecret, + ...(extra !== undefined ? { extra } : {}), + }, + }); + + res.json({ + id: record.id, + moduleName: record.moduleName, + clientId: record.clientId, + clientSecret: '***', + extra: record.extra, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + // Delete a credential + router.delete('/:moduleName', async (req, res) => { + try { + await prisma.oAuthAppCredential.delete({ + where: { moduleName: req.params.moduleName }, + }); + res.status(204).end(); + } catch (error) { + if (error.code === 'P2025') { + return res.status(404).json({ error: 'Not found' }); + } + res.status(500).json({ error: error.message }); + } + }); + + return router; +} + +module.exports = createRouter; diff --git a/packages/providers/aws/encryption/kms-encryption-key-provider.js b/packages/providers/aws/encryption/kms-encryption-key-provider.js new file mode 100644 index 000000000..0500c8ab8 --- /dev/null +++ b/packages/providers/aws/encryption/kms-encryption-key-provider.js @@ -0,0 +1,70 @@ +/** + * KMS Encryption Key Provider (Adapter) + * + * AWS KMS implementation of EncryptionKeyProviderInterface. + * Uses AWS Key Management Service for envelope encryption. + * + * Environment Variables: + * - KMS_KEY_ARN: ARN of the KMS Customer Master Key + * - AWS_REGION: AWS region for KMS client + */ + +const { + EncryptionKeyProviderInterface, +} = require('@friggframework/core/encrypt/encryption-key-provider-interface'); + +let _kmsClient = null; + +function getKmsClient() { + if (!_kmsClient) { + const { KMSClient } = require('@aws-sdk/client-kms'); + _kmsClient = new KMSClient({}); + } + return _kmsClient; +} + +class KmsEncryptionKeyProvider extends EncryptionKeyProviderInterface { + /** + * Generate a data encryption key via KMS GenerateDataKeyCommand + * + * KMS generates the DEK and returns both plaintext and encrypted forms. + * The master key never leaves KMS. + * + * @returns {Promise<{keyId: string, encryptedKey: string, plaintext: Buffer}>} + */ + async generateDataKey() { + const { GenerateDataKeyCommand } = require('@aws-sdk/client-kms'); + const command = new GenerateDataKeyCommand({ + KeyId: process.env.KMS_KEY_ARN, + KeySpec: 'AES_256', + }); + const dataKey = await getKmsClient().send(command); + + const keyId = Buffer.from(dataKey.KeyId).toString('base64'); + const encryptedKey = Buffer.from(dataKey.CiphertextBlob).toString( + 'base64' + ); + const plaintext = dataKey.Plaintext; + return { keyId, encryptedKey, plaintext }; + } + + /** + * Decrypt a data encryption key via KMS DecryptCommand + * + * @param {string} keyId - KMS key identifier + * @param {Buffer} encryptedKey - Encrypted DEK ciphertext blob + * @returns {Promise} Decrypted plaintext DEK + */ + async decryptDataKey(keyId, encryptedKey) { + const { DecryptCommand } = require('@aws-sdk/client-kms'); + const command = new DecryptCommand({ + KeyId: keyId, + CiphertextBlob: encryptedKey, + }); + const dataKey = await getKmsClient().send(command); + + return dataKey.Plaintext; + } +} + +module.exports = { KmsEncryptionKeyProvider }; diff --git a/packages/providers/aws/health/kms-health-check.js b/packages/providers/aws/health/kms-health-check.js new file mode 100644 index 000000000..c282ed9cc --- /dev/null +++ b/packages/providers/aws/health/kms-health-check.js @@ -0,0 +1,260 @@ +/** + * KMS Health Check (AWS-specific) + * + * Extracted from health.js router — tests KMS decrypt capability + * including VPC detection, DNS resolution, and TCP connectivity. + * + * This is deeply AWS-specific and belongs in the provider-aws package. + */ + +/** + * Detect VPC configuration by testing network connectivity + * @returns {Promise} VPC configuration details + */ +async function detectVpcConfiguration() { + const results = { + isInVpc: false, + hasInternetAccess: false, + canResolvePublicDns: false, + canConnectToAws: false, + vpcEndpoints: [], + }; + + try { + const dns = require('dns').promises; + + // Test 1: Can we resolve public DNS? + try { + await Promise.race([ + dns.resolve4('www.google.com'), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), 2000) + ), + ]); + results.canResolvePublicDns = true; + } catch (e) { + console.log('Public DNS resolution failed:', e.message); + } + + // Test 2: Can we reach internet? (indicates NAT gateway) + try { + const https = require('https'); + await new Promise((resolve, reject) => { + const req = https.get( + 'https://www.google.com', + { timeout: 2000 }, + (res) => { + res.destroy(); + resolve(true); + } + ); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('timeout')); + }); + }); + results.hasInternetAccess = true; + } catch (e) { + console.log('Internet connectivity test failed:', e.message); + } + + // Test 3: Check for VPC endpoints + const region = process.env.AWS_REGION; + const vpcEndpointDomains = [ + `com.amazonaws.${region}.kms`, + `com.amazonaws.vpce.${region}`, + `kms.${region}.amazonaws.com`, + ]; + + for (const domain of vpcEndpointDomains) { + try { + const addresses = await Promise.race([ + dns.resolve4(domain).catch(() => dns.resolve6(domain)), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), 1000) + ), + ]); + if (addresses && addresses.length > 0) { + const isPrivateIp = addresses.some( + (ip) => + ip.startsWith('10.') || + ip.startsWith('172.') || + ip.startsWith('192.168.') + ); + if (isPrivateIp) { + results.vpcEndpoints.push(domain); + } + } + } catch (e) { + // Expected for non-existent endpoints + } + } + + results.isInVpc = + process.env.VPC_ENABLED === 'true' || + (!results.hasInternetAccess && results.canResolvePublicDns) || + results.vpcEndpoints.length > 0; + + results.canConnectToAws = + results.hasInternetAccess || results.vpcEndpoints.length > 0; + } catch (error) { + console.error('VPC detection error:', error.message); + } + + return results; +} + +/** + * Check KMS decrypt capability + * + * Tests DNS resolution, TCP connectivity, and actual KMS operations + * to verify the AWS KMS service is accessible and functional. + * + * @returns {Promise} Health check result with status, latency, and VPC info + */ +async function checkKmsDecryptCapability() { + const start = Date.now(); + const { KMS_KEY_ARN } = process.env; + if (!KMS_KEY_ARN) { + return { + status: 'skipped', + reason: 'KMS_KEY_ARN not configured', + }; + } + + console.log('KMS Check Debug:', { + hasKmsKeyArn: !!KMS_KEY_ARN, + kmsKeyArnPrefix: KMS_KEY_ARN?.substring(0, 30), + awsRegion: process.env.AWS_REGION, + hasDiscoveryKey: !!process.env.AWS_DISCOVERY_KMS_KEY_ID, + }); + + const vpcConfig = await detectVpcConfiguration(); + console.log('VPC Configuration:', vpcConfig); + + // Test DNS resolution for KMS endpoint + try { + const dns = require('dns').promises; + const region = process.env.AWS_REGION; + const kmsEndpoint = `kms.${region}.amazonaws.com`; + console.log('Testing DNS resolution for:', kmsEndpoint); + + const dnsPromise = dns.resolve4(kmsEndpoint); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('DNS resolution timeout')), 3000) + ); + + const addresses = await Promise.race([dnsPromise, timeoutPromise]); + console.log('KMS endpoint resolved to:', addresses); + + const isVpcEndpoint = addresses.some( + (ip) => + ip.startsWith('10.') || + ip.startsWith('172.') || + ip.startsWith('192.168.') + ); + + if (isVpcEndpoint) { + console.log( + 'KMS VPC Endpoint detected - using private connectivity' + ); + } + + // Test TCP connectivity to KMS (port 443) + const net = require('net'); + const testConnection = () => + new Promise((resolve) => { + const socket = new net.Socket(); + const connectionTimeout = setTimeout(() => { + socket.destroy(); + resolve({ connected: false, error: 'Connection timeout' }); + }, 3000); + + socket.on('connect', () => { + clearTimeout(connectionTimeout); + socket.destroy(); + resolve({ connected: true }); + }); + + socket.on('error', (err) => { + clearTimeout(connectionTimeout); + resolve({ connected: false, error: err.message }); + }); + + socket.connect(443, addresses[0]); + }); + + const connResult = await testConnection(); + console.log('TCP connectivity test:', connResult); + + if (!connResult.connected) { + return { + status: 'unhealthy', + error: `Cannot connect to KMS endpoint: ${connResult.error}`, + dnsResolved: true, + tcpConnection: false, + vpcConfig, + latencyMs: Date.now() - start, + }; + } + } catch (dnsError) { + console.error('DNS resolution failed:', dnsError.message); + return { + status: 'unhealthy', + error: `Cannot resolve KMS endpoint: ${dnsError.message}`, + dnsResolved: false, + vpcConfig, + latencyMs: Date.now() - start, + }; + } + + try { + const { + KMSClient, + GenerateDataKeyCommand, + DecryptCommand, + } = require('@aws-sdk/client-kms'); + + const region = process.env.AWS_REGION; + + const kms = new KMSClient({ + region, + requestHandler: { + connectionTimeout: 10000, + requestTimeout: 25000, + }, + maxAttempts: 1, + }); + + const dataKeyResp = await kms.send( + new GenerateDataKeyCommand({ + KeyId: KMS_KEY_ARN, + KeySpec: 'AES_256', + }) + ); + const decryptResp = await kms.send( + new DecryptCommand({ CiphertextBlob: dataKeyResp.CiphertextBlob }) + ); + + const success = Boolean( + dataKeyResp.CiphertextBlob && decryptResp.Plaintext + ); + + return { + status: success ? 'healthy' : 'unhealthy', + kmsKeyArnSuffix: KMS_KEY_ARN.slice(-12), + vpcConfig, + latencyMs: Date.now() - start, + }; + } catch (error) { + return { + status: 'unhealthy', + error: error.message, + vpcConfig, + latencyMs: Date.now() - start, + }; + } +} + +module.exports = { checkKmsDecryptCapability, detectVpcConfiguration }; diff --git a/packages/providers/aws/index.js b/packages/providers/aws/index.js new file mode 100644 index 000000000..4880c8996 --- /dev/null +++ b/packages/providers/aws/index.js @@ -0,0 +1,115 @@ +/** + * @friggframework/provider-aws + * + * AWS provider plugin for the Frigg Framework. + * + * Conforms to the provider plugin interface (same shape as provider-netlify). + * Core resolves this package via `resolveProvider()` and accesses standard + * interface names (QueueProvider, SchedulerAdapter, etc.). + * + * Usage via provider system (preferred): + * const provider = resolveProvider(); // returns this module when provider=aws + * new provider.QueueProvider(options); + * new provider.SchedulerAdapter(options); + * + * Direct usage (still supported): + * const { SqsQueueProvider } = require('@friggframework/provider-aws'); + */ + +// Lazy getters — each adapter is only loaded when accessed, avoiding +// pulling in all AWS SDK clients at once. + +// Singleton for invokeFunctionAdapter (pre-instantiated, matching Netlify's shape) +let _invoker; + +module.exports = { + name: 'aws', + + // ═══════════════════════════════════════════════════════════════════ + // Standard Provider Plugin Interface + // (same property names as @friggframework/provider-netlify) + // ═══════════════════════════════════════════════════════════════════ + + /** @type {typeof SqsQueueProvider} */ + get QueueProvider() { + return require('./queues/sqs-queue-provider').SqsQueueProvider; + }, + /** @type {typeof EventBridgeSchedulerAdapter} */ + get SchedulerAdapter() { + return require('./scheduler/eventbridge-scheduler-adapter') + .EventBridgeSchedulerAdapter; + }, + /** @type {typeof KmsEncryptionKeyProvider} */ + get CryptorAdapter() { + return require('./encryption/kms-encryption-key-provider') + .KmsEncryptionKeyProvider; + }, + /** @type {typeof ApiGatewayMessageSender} */ + get WebSocketAdapter() { + return require('./websocket/api-gateway-message-sender') + .ApiGatewayMessageSender; + }, + /** Pre-instantiated function invoker: { invoke(name, payload) } */ + get invokeFunctionAdapter() { + if (!_invoker) { + const { LambdaInvoker } = require('./lambda/lambda-invoker'); + _invoker = new LambdaInvoker(); + } + return _invoker; + }, + get loadSecrets() { + return require('@friggframework/core/core/secrets-to-env').secretsToEnv; + }, + + // ── Health Checks ───────────────────────────────────────────────── + get checkKmsDecryptCapability() { + return require('./health/kms-health-check').checkKmsDecryptCapability; + }, + get detectVpcConfiguration() { + return require('./health/kms-health-check').detectVpcConfiguration; + }, + + // ═══════════════════════════════════════════════════════════════════ + // AWS-Specific Exports (backward compatibility) + // ═══════════════════════════════════════════════════════════════════ + + // ── Scheduler ──────────────────────────────────────────────────── + get EventBridgeSchedulerAdapter() { + return require('./scheduler/eventbridge-scheduler-adapter') + .EventBridgeSchedulerAdapter; + }, + + // ── Queues ─────────────────────────────────────────────────────── + get SqsQueueProvider() { + return require('./queues/sqs-queue-provider').SqsQueueProvider; + }, + get SqsQueueClient() { + return require('./queues/sqs-queue-client').SqsQueueClient; + }, + + // ── Encryption ──────────────────────────────────────────────────── + get KmsEncryptionKeyProvider() { + return require('./encryption/kms-encryption-key-provider') + .KmsEncryptionKeyProvider; + }, + + // ── WebSocket ───────────────────────────────────────────────────── + get ApiGatewayMessageSender() { + return require('./websocket/api-gateway-message-sender') + .ApiGatewayMessageSender; + }, + + // ── Lambda ─────────────────────────────────────────────────────── + get LambdaInvoker() { + return require('./lambda/lambda-invoker').LambdaInvoker; + }, + get LambdaInvocationError() { + return require('./lambda/lambda-invoker').LambdaInvocationError; + }, + + // ── Storage ────────────────────────────────────────────────────── + get MigrationStatusRepositoryS3() { + return require('./storage/migration-status-repository-s3') + .MigrationStatusRepositoryS3; + }, +}; diff --git a/packages/core/database/adapters/lambda-invoker.js b/packages/providers/aws/lambda/lambda-invoker.js similarity index 100% rename from packages/core/database/adapters/lambda-invoker.js rename to packages/providers/aws/lambda/lambda-invoker.js diff --git a/packages/core/database/adapters/lambda-invoker.test.js b/packages/providers/aws/lambda/lambda-invoker.test.js similarity index 100% rename from packages/core/database/adapters/lambda-invoker.test.js rename to packages/providers/aws/lambda/lambda-invoker.test.js diff --git a/packages/providers/aws/package.json b/packages/providers/aws/package.json new file mode 100644 index 000000000..a535ee791 --- /dev/null +++ b/packages/providers/aws/package.json @@ -0,0 +1,39 @@ +{ + "name": "@friggframework/provider-aws", + "version": "0.1.0", + "description": "AWS provider adapters for the Frigg Framework (SQS, EventBridge, KMS, Lambda, S3)", + "main": "index.js", + "scripts": { + "test": "jest --passWithNoTests" + }, + "keywords": [ + "frigg", + "aws", + "serverless", + "adapter" + ], + "license": "MIT", + "dependencies": { + "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0", + "@aws-sdk/client-kms": "^3.588.0", + "@aws-sdk/client-lambda": "^3.714.0", + "@aws-sdk/client-s3": "^3.588.0", + "@aws-sdk/client-scheduler": "^3.588.0", + "@aws-sdk/client-sqs": "^3.588.0", + "uuid": "^9.0.0" + }, + "peerDependencies": { + "@friggframework/core": "*" + }, + "peerDependenciesMeta": { + "@friggframework/core": { + "optional": true + } + }, + "devDependencies": { + "jest": "^29.7.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/providers/aws/queues/sqs-queue-client.js b/packages/providers/aws/queues/sqs-queue-client.js new file mode 100644 index 000000000..c71f68886 --- /dev/null +++ b/packages/providers/aws/queues/sqs-queue-client.js @@ -0,0 +1,95 @@ +/** + * SQS Queue Client (Adapter) + * + * AWS SQS implementation of QueueClientInterface. + * Used by Worker and QueuerUtil for queue message operations. + * + * Supports offline/local development via IS_OFFLINE and AWS_ENDPOINT env vars. + */ + +const { + QueueClientInterface, +} = require('@friggframework/core/queues/queue-client-interface'); + +let _sqsModule = null; +let _sqs = null; + +function getSqsModule() { + if (!_sqsModule) { + _sqsModule = require('@aws-sdk/client-sqs'); + } + return _sqsModule; +} + +function getSqs() { + if (!_sqs) { + const { SQSClient } = getSqsModule(); + const config = {}; + + if (process.env.IS_OFFLINE) { + config.credentials = { + accessKeyId: 'test-aws-key', + secretAccessKey: 'test-aws-secret', + }; + config.region = 'us-east-1'; + } + + if (!config.region) { + config.region = process.env.AWS_REGION; + } + + if (process.env.AWS_ENDPOINT) { + config.endpoint = process.env.AWS_ENDPOINT; + } + + _sqs = new SQSClient(config); + } + return _sqs; +} + +class SqsQueueClient extends QueueClientInterface { + /** + * Send a message via SQS SendMessageCommand + * + * @param {Object} params - SQS SendMessageCommand input + * @param {string} params.QueueUrl + * @param {string} params.MessageBody + * @param {number} [params.DelaySeconds] + * @returns {Promise<{MessageId: string}>} + */ + async sendMessage(params) { + const { SendMessageCommand } = getSqsModule(); + const command = new SendMessageCommand(params); + return getSqs().send(command); + } + + /** + * Send a batch of messages via SQS SendMessageBatchCommand + * + * @param {Object} params - SQS SendMessageBatchCommand input + * @param {string} params.QueueUrl + * @param {Array<{Id: string, MessageBody: string}>} params.Entries + * @returns {Promise} + */ + async sendMessageBatch(params) { + const { SendMessageBatchCommand } = getSqsModule(); + const command = new SendMessageBatchCommand(params); + return getSqs().send(command); + } + + /** + * Resolve a queue name to URL via SQS GetQueueUrlCommand + * + * @param {Object} params + * @param {string} params.QueueName + * @returns {Promise} Queue URL + */ + async getQueueUrl(params) { + const { GetQueueUrlCommand } = getSqsModule(); + const command = new GetQueueUrlCommand(params); + const data = await getSqs().send(command); + return data.QueueUrl; + } +} + +module.exports = { SqsQueueClient }; diff --git a/packages/providers/aws/queues/sqs-queue-provider.js b/packages/providers/aws/queues/sqs-queue-provider.js new file mode 100644 index 000000000..585847326 --- /dev/null +++ b/packages/providers/aws/queues/sqs-queue-provider.js @@ -0,0 +1,84 @@ +/** + * SQS Queue Provider (Adapter) + * + * AWS SQS implementation of the QueueProvider interface. + * Extracted from the original queuer-util.js to support multi-provider architecture. + */ +const { v4: uuid } = require('uuid'); +const { + SQSClient, + SendMessageCommand, + SendMessageBatchCommand, +} = require('@aws-sdk/client-sqs'); +const { QueueProvider } = require('@friggframework/core/queues/queue-provider'); + +const awsConfigOptions = () => { + const config = {}; + if (process.env.IS_OFFLINE) { + config.credentials = { + accessKeyId: 'test-aws-key', + secretAccessKey: 'test-aws-secret', + }; + config.region = 'us-east-1'; + } + if (process.env.AWS_ENDPOINT) { + config.endpoint = process.env.AWS_ENDPOINT; + } + return config; +}; + +class SqsQueueProvider extends QueueProvider { + constructor() { + super(); + this.sqs = new SQSClient(awsConfigOptions()); + } + + async send(message, queueUrl) { + const command = new SendMessageCommand({ + MessageBody: JSON.stringify(message), + QueueUrl: queueUrl, + }); + return this.sqs.send(command); + } + + async batchSend(entries = [], queueUrl) { + const buffer = []; + const batchSize = 10; + + for (const entry of entries) { + buffer.push({ + Id: uuid(), + MessageBody: JSON.stringify(entry), + }); + + if (buffer.length === batchSize) { + const command = new SendMessageBatchCommand({ + Entries: buffer, + QueueUrl: queueUrl, + }); + await this.sqs.send(command); + buffer.splice(0, buffer.length); + } + } + + if (buffer.length > 0) { + const command = new SendMessageBatchCommand({ + Entries: buffer, + QueueUrl: queueUrl, + }); + return this.sqs.send(command); + } + + return {}; + } + + /** + * Parse SQS event format: event.Records[].body (JSON string) + */ + parseEvent(event) { + const records = event?.Records || []; + return records.map((record) => JSON.parse(record.body)); + } +} + +module.exports = { SqsQueueProvider }; diff --git a/packages/core/infrastructure/scheduler/eventbridge-scheduler-adapter.js b/packages/providers/aws/scheduler/eventbridge-scheduler-adapter.js similarity index 97% rename from packages/core/infrastructure/scheduler/eventbridge-scheduler-adapter.js rename to packages/providers/aws/scheduler/eventbridge-scheduler-adapter.js index c06c46375..d4463bde8 100644 --- a/packages/core/infrastructure/scheduler/eventbridge-scheduler-adapter.js +++ b/packages/providers/aws/scheduler/eventbridge-scheduler-adapter.js @@ -19,7 +19,7 @@ const { ResourceNotFoundException, } = require('@aws-sdk/client-scheduler'); -const { SchedulerServiceInterface } = require('./scheduler-service-interface'); +const { SchedulerServiceInterface } = require('@friggframework/core/infrastructure/scheduler/scheduler-service-interface'); class EventBridgeSchedulerAdapter extends SchedulerServiceInterface { constructor({ region } = {}) { diff --git a/packages/core/database/repositories/migration-status-repository-s3.js b/packages/providers/aws/storage/migration-status-repository-s3.js similarity index 100% rename from packages/core/database/repositories/migration-status-repository-s3.js rename to packages/providers/aws/storage/migration-status-repository-s3.js diff --git a/packages/core/database/repositories/migration-status-repository-s3.test.js b/packages/providers/aws/storage/migration-status-repository-s3.test.js similarity index 100% rename from packages/core/database/repositories/migration-status-repository-s3.test.js rename to packages/providers/aws/storage/migration-status-repository-s3.test.js diff --git a/packages/providers/aws/websocket/api-gateway-message-sender.js b/packages/providers/aws/websocket/api-gateway-message-sender.js new file mode 100644 index 000000000..c72cf57aa --- /dev/null +++ b/packages/providers/aws/websocket/api-gateway-message-sender.js @@ -0,0 +1,64 @@ +/** + * API Gateway WebSocket Message Sender (Adapter) + * + * AWS API Gateway Management API implementation of WebSocketMessageSenderInterface. + * Sends messages to active WebSocket connections via API Gateway. + */ + +const { + WebSocketMessageSenderInterface, + StaleConnectionError, +} = require('@friggframework/core/websocket/websocket-message-sender-interface'); + +// Cache clients per endpoint to avoid recreating on every send() +const _clientCache = new Map(); + +function getApiGatewayClient(endpoint) { + if (!_clientCache.has(endpoint)) { + const { + ApiGatewayManagementApiClient, + } = require('@aws-sdk/client-apigatewaymanagementapi'); + _clientCache.set( + endpoint, + new ApiGatewayManagementApiClient({ endpoint }) + ); + } + return _clientCache.get(endpoint); +} + +class ApiGatewayMessageSender extends WebSocketMessageSenderInterface { + /** + * Send data to a WebSocket connection via API Gateway Management API + * + * @param {string} connectionId - The WebSocket connection ID + * @param {*} data - Data to send (will be JSON-serialized) + * @param {string} endpoint - The WebSocket API endpoint URL + * @returns {Promise} + * @throws {StaleConnectionError} If connection returns 410 Gone + */ + async send(connectionId, data, endpoint) { + const { + PostToConnectionCommand, + } = require('@aws-sdk/client-apigatewaymanagementapi'); + + const client = getApiGatewayClient(endpoint); + + try { + const command = new PostToConnectionCommand({ + ConnectionId: connectionId, + Data: JSON.stringify(data), + }); + await client.send(command); + } catch (error) { + if ( + error.statusCode === 410 || + error.$metadata?.httpStatusCode === 410 + ) { + throw new StaleConnectionError(connectionId); + } + throw error; + } + } +} + +module.exports = { ApiGatewayMessageSender }; diff --git a/packages/providers/netlify/__tests__/deploy.test.js b/packages/providers/netlify/__tests__/deploy.test.js new file mode 100644 index 000000000..ad7edd348 --- /dev/null +++ b/packages/providers/netlify/__tests__/deploy.test.js @@ -0,0 +1,71 @@ +const { preflightCheck } = require('../lib/deploy'); + +describe('preflightCheck', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + test('reports missing Netlify CLI and auth token', async () => { + delete process.env.NETLIFY_AUTH_TOKEN; + // CLI is likely not installed in test env + const result = await preflightCheck({ + name: 'test', + database: { postgres: { enable: true } }, + }); + + // Should report at least missing DATABASE_URL + expect(result).toHaveProperty('ready'); + expect(result).toHaveProperty('missing'); + expect(Array.isArray(result.missing)).toBe(true); + }); + + test('reports missing DATABASE_URL', async () => { + delete process.env.DATABASE_URL; + process.env.NETLIFY_AUTH_TOKEN = 'test-token'; + + const result = await preflightCheck({ + name: 'test', + database: { postgres: { enable: true } }, + }); + + expect(result.missing.some((m) => m.includes('DATABASE_URL'))).toBe( + true + ); + }); + + test('reports missing QSTASH_TOKEN when qstash configured', async () => { + delete process.env.QSTASH_TOKEN; + process.env.NETLIFY_AUTH_TOKEN = 'test-token'; + process.env.DATABASE_URL = 'postgresql://test'; + + const result = await preflightCheck({ + name: 'test', + database: { postgres: { enable: true } }, + queue: { provider: 'qstash' }, + }); + + expect(result.missing.some((m) => m.includes('QSTASH_TOKEN'))).toBe( + true + ); + }); + + test('passes when all prerequisites met', async () => { + process.env.NETLIFY_AUTH_TOKEN = 'test-token'; + process.env.DATABASE_URL = 'postgresql://test'; + + const result = await preflightCheck({ + name: 'test', + database: { postgres: { enable: true } }, + integrations: [{ Definition: { name: 'test' } }], + }); + + expect(result.ready).toBe(true); + expect(result.missing).toHaveLength(0); + }); +}); diff --git a/packages/providers/netlify/__tests__/detect.test.js b/packages/providers/netlify/__tests__/detect.test.js new file mode 100644 index 000000000..ca30c193f --- /dev/null +++ b/packages/providers/netlify/__tests__/detect.test.js @@ -0,0 +1,28 @@ +const { detect } = require('../lib/detect'); + +describe('detect', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + test('returns true when NETLIFY env var is set', () => { + process.env.NETLIFY = 'true'; + expect(detect()).toBe(true); + }); + + test('returns false when NETLIFY env var is not set', () => { + delete process.env.NETLIFY; + expect(detect()).toBe(false); + }); + + test('returns true for any truthy NETLIFY value', () => { + process.env.NETLIFY = '1'; + expect(detect()).toBe(true); + }); +}); diff --git a/packages/providers/netlify/__tests__/generate-netlify-config.test.js b/packages/providers/netlify/__tests__/generate-netlify-config.test.js new file mode 100644 index 000000000..b29fe1543 --- /dev/null +++ b/packages/providers/netlify/__tests__/generate-netlify-config.test.js @@ -0,0 +1,237 @@ +const { + generateNetlifyToml, + generateNetlifyEnvTemplate, + resolveDbTypes, +} = require('../lib/generate-netlify-config'); + +describe('generateNetlifyToml', () => { + const baseAppDefinition = { + name: 'test-app', + integrations: [ + { Definition: { name: 'hubspot' } }, + { Definition: { name: 'salesforce' } }, + ], + }; + + test('generates valid TOML with build section', () => { + const toml = generateNetlifyToml(baseAppDefinition); + + expect(toml).toContain('[build]'); + expect(toml).toContain('command = "npm run build"'); + expect(toml).toContain('functions = "netlify/functions"'); + }); + + test('generates Node.js version in build.environment', () => { + const toml = generateNetlifyToml(baseAppDefinition); + + expect(toml).toContain('[build.environment]'); + expect(toml).toContain('NODE_VERSION = "18"'); + }); + + test('generates esbuild bundler configuration', () => { + const toml = generateNetlifyToml(baseAppDefinition); + + expect(toml).toContain('[functions]'); + expect(toml).toContain('node_bundler = "esbuild"'); + expect(toml).toContain('node_modules/.prisma/**'); + // Without database config, both generated clients are included + expect(toml).toContain('prisma-postgresql/**'); + expect(toml).toContain('prisma-mongodb/**'); + expect(toml).toContain('external_node_modules = ["express"'); + // Frigg packages must be external — they use dynamic requires that break esbuild + expect(toml).toContain('@friggframework/core'); + expect(toml).toContain('@friggframework/provider-netlify'); + // AWS SDKs must be external — they are only used by provider-aws + expect(toml).toContain('aws-sdk'); + expect(toml).toContain('@aws-sdk/*'); + }); + + test('includes only postgresql generated client when postgres is configured', () => { + const appDef = { + ...baseAppDefinition, + database: { postgres: { enable: true } }, + }; + const toml = generateNetlifyToml(appDef); + + expect(toml).toContain('prisma-postgresql/**'); + expect(toml).not.toContain('prisma-mongodb/**'); + }); + + test('includes only mongodb generated client when mongoDB is configured', () => { + const appDef = { + ...baseAppDefinition, + database: { mongoDB: { enable: true } }, + }; + const toml = generateNetlifyToml(appDef); + + expect(toml).toContain('prisma-mongodb/**'); + expect(toml).not.toContain('prisma-postgresql/**'); + }); + + test('includes both generated clients when both databases are configured', () => { + const appDef = { + ...baseAppDefinition, + database: { postgres: { enable: true }, mongoDB: { enable: true } }, + }; + const toml = generateNetlifyToml(appDef); + + expect(toml).toContain('prisma-postgresql/**'); + expect(toml).toContain('prisma-mongodb/**'); + }); + + test('does not include backend/** in included_files (nft traces it via static require)', () => { + const toml = generateNetlifyToml(baseAppDefinition); + + expect(toml).not.toContain('backend/**'); + }); + + test('generates redirect for v2 API routes', () => { + const toml = generateNetlifyToml(baseAppDefinition); + + expect(toml).toContain('from = "/api/v2/*"'); + expect(toml).toContain('to = "/.netlify/functions/auth"'); + }); + + test('generates redirect for user routes', () => { + const toml = generateNetlifyToml(baseAppDefinition); + + expect(toml).toContain('from = "/user/*"'); + expect(toml).toContain('to = "/.netlify/functions/user"'); + }); + + test('generates redirect for health routes', () => { + const toml = generateNetlifyToml(baseAppDefinition); + + expect(toml).toContain('from = "/health/*"'); + expect(toml).toContain('to = "/.netlify/functions/health"'); + }); + + test('generates integration-specific webhook and route redirects', () => { + const toml = generateNetlifyToml(baseAppDefinition); + + expect(toml).toContain( + 'from = "/api/hubspot-integration/webhooks/*"' + ); + expect(toml).toContain('from = "/api/hubspot-integration/*"'); + expect(toml).toContain( + 'from = "/api/salesforce-integration/webhooks/*"' + ); + expect(toml).toContain('from = "/api/salesforce-integration/*"'); + }); + + test('generates queue worker redirect', () => { + const toml = generateNetlifyToml(baseAppDefinition); + + expect(toml).toContain('from = "/api/queue"'); + expect(toml).toContain( + 'to = "/.netlify/functions/worker-background"' + ); + }); + + test('generates scheduled-sync cron schedule', () => { + const toml = generateNetlifyToml(baseAppDefinition); + + expect(toml).toContain('[functions."scheduled-sync"]'); + expect(toml).toContain('schedule = "*/5 * * * *"'); + }); + + test('accepts custom cron schedule', () => { + const toml = generateNetlifyToml(baseAppDefinition, { + cronSchedule: '*/10 * * * *', + }); + + expect(toml).toContain('schedule = "*/10 * * * *"'); + }); + + test('accepts custom functions directory', () => { + const toml = generateNetlifyToml(baseAppDefinition, { + functionsDir: 'functions', + }); + + expect(toml).toContain('functions = "functions"'); + }); + + test('handles empty integrations array', () => { + const toml = generateNetlifyToml({ name: 'test' }); + + // Should still have core redirects + expect(toml).toContain('from = "/api/v2/*"'); + // Should not have integration-specific routes + expect(toml).not.toContain('-integration/webhooks'); + }); +}); + +describe('generateNetlifyEnvTemplate', () => { + test('includes core Frigg env vars', () => { + const envVars = generateNetlifyEnvTemplate({ name: 'test' }); + + expect(envVars).toHaveProperty('BASE_URL'); + expect(envVars).toHaveProperty('DATABASE_URL'); + expect(envVars).toHaveProperty('FRIGG_API_KEY'); + }); + + test('includes AES env vars when encryption is configured', () => { + const envVars = generateNetlifyEnvTemplate({ + name: 'test', + encryption: { fieldLevelEncryptionMethod: 'aes' }, + }); + + expect(envVars).toHaveProperty('AES_KEY_ID'); + expect(envVars).toHaveProperty('AES_KEY'); + }); + + test('excludes AES env vars when encryption is none', () => { + const envVars = generateNetlifyEnvTemplate({ + name: 'test', + encryption: { fieldLevelEncryptionMethod: 'none' }, + }); + + expect(envVars).not.toHaveProperty('AES_KEY_ID'); + expect(envVars).not.toHaveProperty('AES_KEY'); + }); + + test('includes QStash env vars when qstash queue is configured', () => { + const envVars = generateNetlifyEnvTemplate({ + name: 'test', + queue: { provider: 'qstash' }, + }); + + expect(envVars).toHaveProperty('QSTASH_TOKEN'); + expect(envVars).toHaveProperty('QSTASH_CURRENT_SIGNING_KEY'); + }); + + test('includes app-defined environment variables', () => { + const envVars = generateNetlifyEnvTemplate({ + name: 'test', + environment: { + MY_CUSTOM_VAR: 'Description of custom var', + }, + }); + + expect(envVars).toHaveProperty('MY_CUSTOM_VAR'); + }); +}); + +describe('resolveDbTypes', () => { + test('returns both types when no database config present', () => { + expect(resolveDbTypes({ name: 'test' })).toEqual(['postgresql', 'mongodb']); + }); + + test('returns postgresql when postgres is enabled', () => { + expect(resolveDbTypes({ database: { postgres: { enable: true } } })).toEqual(['postgresql']); + }); + + test('returns mongodb when mongoDB is enabled', () => { + expect(resolveDbTypes({ database: { mongoDB: { enable: true } } })).toEqual(['mongodb']); + }); + + test('returns both when both are enabled', () => { + expect(resolveDbTypes({ + database: { postgres: { enable: true }, mongoDB: { enable: true } }, + })).toEqual(['postgresql', 'mongodb']); + }); + + test('falls back to both when database config exists but nothing enabled', () => { + expect(resolveDbTypes({ database: {} })).toEqual(['postgresql', 'mongodb']); + }); +}); diff --git a/packages/providers/netlify/__tests__/get-function-entry-points.test.js b/packages/providers/netlify/__tests__/get-function-entry-points.test.js new file mode 100644 index 000000000..3553e13c7 --- /dev/null +++ b/packages/providers/netlify/__tests__/get-function-entry-points.test.js @@ -0,0 +1,163 @@ +const { getFunctionEntryPoints, getFunctionEntryPointsDir, getLibEntryPoints } = require('../lib/get-function-entry-points'); +const path = require('path'); +const fs = require('fs'); + +describe('getFunctionEntryPoints', () => { + test('returns all 9 standard function files', () => { + const entryPoints = getFunctionEntryPoints({ name: 'test' }); + const filenames = Object.keys(entryPoints); + + expect(filenames).toHaveLength(9); + expect(filenames).toContain('auth.js'); + expect(filenames).toContain('user.js'); + expect(filenames).toContain('health.js'); + expect(filenames).toContain('admin.js'); + expect(filenames).toContain('docs.js'); + expect(filenames).toContain('integration-routes.js'); + expect(filenames).toContain('webhooks.js'); + expect(filenames).toContain('worker-background.js'); + expect(filenames).toContain('scheduled-sync.js'); + }); + + test('returns non-empty file contents', () => { + const entryPoints = getFunctionEntryPoints({ name: 'test' }); + + for (const [filename, content] of Object.entries(entryPoints)) { + expect(content.length).toBeGreaterThan(0); + expect(content).toContain('module.exports'); + } + }); + + test('auth.js references the auth router', () => { + const entryPoints = getFunctionEntryPoints({ name: 'test' }); + + expect(entryPoints['auth.js']).toContain('auth'); + expect(entryPoints['auth.js']).toContain('handler'); + }); + + test('every entry point includes the app definition preamble with relative backend path', () => { + const entryPoints = getFunctionEntryPoints({ name: 'test' }); + + for (const content of Object.values(entryPoints)) { + expect(content).toContain('setAppDefinition'); + expect(content).toContain("require('../../backend/index.js')"); + // Preamble uses relative path through backend's node_modules + expect(content).toContain('../../backend/node_modules/@friggframework/core/handlers/app-definition-loader'); + } + }); + + test('rewrites @friggframework/core requires to relative paths through backend', () => { + const entryPoints = getFunctionEntryPoints({ name: 'test' }); + + for (const content of Object.values(entryPoints)) { + // Should NOT contain bare @friggframework/core requires + const bareRequires = content.match(/require\(['"]@friggframework\/core/g) || []; + expect(bareRequires).toHaveLength(0); + + // All @friggframework/core requires should go through backend/node_modules + const relativeRequires = content.match(/require\(['"]\.\.\/\.\.\/backend\/node_modules\/@friggframework\/core/g) || []; + // At least the preamble has one + expect(relativeRequires.length).toBeGreaterThanOrEqual(1); + } + }); + + test('accepts custom backendPath option', () => { + const entryPoints = getFunctionEntryPoints({ name: 'test' }, { backendPath: '../app' }); + + for (const content of Object.values(entryPoints)) { + expect(content).toContain("require('../app/index.js')"); + expect(content).toContain('../app/node_modules/@friggframework/core'); + } + }); + + test('preamble appears before the template content', () => { + const entryPoints = getFunctionEntryPoints({ name: 'test' }); + + // The preamble should be at the very start, before the JSDoc comment + for (const content of Object.values(entryPoints)) { + const preambleIdx = content.indexOf('setAppDefinition'); + const firstJsdocIdx = content.indexOf('/**'); + expect(preambleIdx).toBeLessThan(firstJsdocIdx); + } + }); +}); + +describe('getLibEntryPoints', () => { + test('returns re-export shims for all lib files referenced by functions', () => { + const libEntryPoints = getLibEntryPoints({ name: 'test' }); + const filenames = Object.keys(libEntryPoints); + + // Functions reference these 3 lib files + expect(filenames).toContain('create-netlify-app-handler.js'); + expect(filenames).toContain('create-netlify-handler.js'); + expect(filenames).toContain('scheduled-job-repository.js'); + }); + + test('each shim re-exports from the provider package', () => { + const libEntryPoints = getLibEntryPoints({ name: 'test' }); + + for (const [filename, content] of Object.entries(libEntryPoints)) { + const moduleName = filename.replace(/\.js$/, ''); + expect(content).toContain('@friggframework/provider-netlify/lib/'); + expect(content).toContain(moduleName); + expect(content).toContain('module.exports'); + } + }); + + test('does not include build-time-only lib files', () => { + const libEntryPoints = getLibEntryPoints({ name: 'test' }); + const filenames = Object.keys(libEntryPoints); + + // These are build-time only, not referenced by functions + expect(filenames).not.toContain('deploy.js'); + expect(filenames).not.toContain('validate.js'); + expect(filenames).not.toContain('generate-netlify-config.js'); + expect(filenames).not.toContain('detect.js'); + expect(filenames).not.toContain('get-function-entry-points.js'); + }); + + test('stays in sync with function entry points', () => { + // If a function references a new lib file, getLibEntryPoints should pick it up + const functionEntryPoints = getFunctionEntryPoints({ name: 'test' }); + const libEntryPoints = getLibEntryPoints({ name: 'test' }); + + // Collect all ../lib/ references from functions + const referencedLibs = new Set(); + const pattern = /require\(['"]\.\.\/lib\/([^'"]+)['"]\)/g; + for (const content of Object.values(functionEntryPoints)) { + let match; + pattern.lastIndex = 0; + while ((match = pattern.exec(content)) !== null) { + const modulePath = match[1]; + const filename = modulePath.endsWith('.js') ? modulePath : `${modulePath}.js`; + referencedLibs.add(filename); + } + } + + // Every referenced lib should have a shim + for (const libFile of referencedLibs) { + expect(Object.keys(libEntryPoints)).toContain(libFile); + } + + // No extra shims beyond what's referenced + expect(Object.keys(libEntryPoints).sort()).toEqual([...referencedLibs].sort()); + }); +}); + +describe('getFunctionEntryPointsDir', () => { + test('returns a valid directory path', () => { + const dir = getFunctionEntryPointsDir(); + + expect(fs.existsSync(dir)).toBe(true); + expect(fs.statSync(dir).isDirectory()).toBe(true); + }); + + test('directory contains all function files', () => { + const dir = getFunctionEntryPointsDir(); + const files = fs.readdirSync(dir); + + expect(files).toContain('auth.js'); + expect(files).toContain('worker-background.js'); + expect(files).toContain('scheduled-sync.js'); + }); +}); diff --git a/packages/providers/netlify/__tests__/provider-plugin-interface.test.js b/packages/providers/netlify/__tests__/provider-plugin-interface.test.js new file mode 100644 index 000000000..22afdbb94 --- /dev/null +++ b/packages/providers/netlify/__tests__/provider-plugin-interface.test.js @@ -0,0 +1,101 @@ +/** + * Tests that the Netlify adapter exports conform to the provider plugin interface. + * + * This verifies the shape defined in plan.md § Provider Plugin Interface. + * Modules that depend on @friggframework/core are tested separately (they + * need the full monorepo context to resolve). Here we test the modules + * that can run in isolation. + */ +const { loadSecrets } = require('../lib/load-secrets'); +const { invokeFunctionAdapter } = require('../lib/invoke-function-adapter'); +const { + generateNetlifyToml, + generateNetlifyEnvTemplate, +} = require('../lib/generate-netlify-config'); +const { validateNetlifyConfig } = require('../lib/validate'); +const { deploy, preflightCheck, teardown } = require('../lib/deploy'); +const { detect } = require('../lib/detect'); +const { + getFunctionEntryPoints, +} = require('../lib/get-function-entry-points'); +const { + ScheduledJobRepository, +} = require('../lib/scheduled-job-repository'); + +describe('Provider Plugin Interface Shape', () => { + // Runtime Adapters (that can be tested in isolation) + test('loadSecrets is an async no-op', async () => { + expect(typeof loadSecrets).toBe('function'); + await expect(loadSecrets()).resolves.toBeUndefined(); + }); + + test('invokeFunctionAdapter has invoke method', () => { + expect(invokeFunctionAdapter).toHaveProperty('invoke'); + expect(typeof invokeFunctionAdapter.invoke).toBe('function'); + }); + + // Build-Time + test('generateConfig is a function that returns a string', () => { + expect(typeof generateNetlifyToml).toBe('function'); + const result = generateNetlifyToml({ name: 'test' }); + expect(typeof result).toBe('string'); + }); + + test('generateEnvTemplate is a function that returns an object', () => { + expect(typeof generateNetlifyEnvTemplate).toBe('function'); + const result = generateNetlifyEnvTemplate({ name: 'test' }); + expect(typeof result).toBe('object'); + }); + + // Deploy + test('deploy is a function', () => { + expect(typeof deploy).toBe('function'); + }); + + test('preflightCheck is a function that returns { ready, missing }', async () => { + expect(typeof preflightCheck).toBe('function'); + const result = await preflightCheck({ name: 'test', database: { postgres: { enable: true } } }); + expect(result).toHaveProperty('ready'); + expect(result).toHaveProperty('missing'); + }); + + test('teardown is a function', () => { + expect(typeof teardown).toBe('function'); + }); + + // Validate + test('validate returns { valid, errors, warnings }', () => { + expect(typeof validateNetlifyConfig).toBe('function'); + const result = validateNetlifyConfig({ name: 'test', database: { postgres: { enable: true } } }); + expect(result).toHaveProperty('valid'); + expect(result).toHaveProperty('errors'); + expect(result).toHaveProperty('warnings'); + }); + + // Function entry points + test('getFunctionEntryPoints returns a map of filenames to content', () => { + expect(typeof getFunctionEntryPoints).toBe('function'); + const result = getFunctionEntryPoints({ name: 'test' }); + expect(Object.keys(result).length).toBeGreaterThan(0); + for (const content of Object.values(result)) { + expect(typeof content).toBe('string'); + } + }); + + // Detection + test('detect returns a boolean', () => { + expect(typeof detect).toBe('function'); + expect(typeof detect()).toBe('boolean'); + }); + + // Utilities + test('ScheduledJobRepository is a constructor', () => { + expect(typeof ScheduledJobRepository).toBe('function'); + }); + + // Metadata shape (constants — tested as part of the interface) + test('provider name is "netlify"', () => { + // Can't import index.js in isolation, but we verify the expected value + expect('netlify').toBe('netlify'); + }); +}); diff --git a/packages/providers/netlify/__tests__/scheduled-job-repository.test.js b/packages/providers/netlify/__tests__/scheduled-job-repository.test.js new file mode 100644 index 000000000..c47c6f4af --- /dev/null +++ b/packages/providers/netlify/__tests__/scheduled-job-repository.test.js @@ -0,0 +1,105 @@ +const { ScheduledJobRepository } = require('../lib/scheduled-job-repository'); + +describe('ScheduledJobRepository', () => { + test('requires prismaClient in constructor', () => { + expect(() => new ScheduledJobRepository({})).toThrow( + 'prismaClient' + ); + }); + + test('accepts a prismaClient instance', () => { + const mockPrisma = { scheduledJob: {} }; + const repo = new ScheduledJobRepository({ prismaClient: mockPrisma }); + + expect(repo.prisma).toBe(mockPrisma); + }); + + describe('with mock Prisma client', () => { + let repo; + let mockPrisma; + + beforeEach(() => { + mockPrisma = { + scheduledJob: { + upsert: jest.fn().mockResolvedValue({ id: 1 }), + delete: jest.fn().mockResolvedValue({}), + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + }, + }; + repo = new ScheduledJobRepository({ prismaClient: mockPrisma }); + }); + + test('save calls upsert with correct params', async () => { + const scheduleData = { + scheduleName: 'test-job', + scheduledAt: new Date().toISOString(), + queueResourceId: '/.netlify/functions/worker-background', + payload: { event: 'REFRESH_WEBHOOK' }, + state: 'PENDING', + }; + + await repo.save(scheduleData); + + expect(mockPrisma.scheduledJob.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { scheduleName: 'test-job' }, + create: expect.objectContaining({ + scheduleName: 'test-job', + queueResourceId: + '/.netlify/functions/worker-background', + state: 'PENDING', + }), + }) + ); + }); + + test('delete calls prisma delete', async () => { + await repo.delete('test-job'); + + expect(mockPrisma.scheduledJob.delete).toHaveBeenCalledWith({ + where: { scheduleName: 'test-job' }, + }); + }); + + test('delete ignores P2025 (not found) errors', async () => { + const notFoundError = new Error('Not found'); + notFoundError.code = 'P2025'; + mockPrisma.scheduledJob.delete.mockRejectedValue(notFoundError); + + // Should not throw + await expect(repo.delete('nonexistent')).resolves.not.toThrow(); + }); + + test('delete rethrows other errors', async () => { + mockPrisma.scheduledJob.delete.mockRejectedValue( + new Error('Connection failed') + ); + + await expect(repo.delete('test-job')).rejects.toThrow( + 'Connection failed' + ); + }); + + test('findByName calls findUnique', async () => { + await repo.findByName('test-job'); + + expect(mockPrisma.scheduledJob.findUnique).toHaveBeenCalledWith({ + where: { scheduleName: 'test-job' }, + }); + }); + + test('findDue queries PENDING jobs before cutoff', async () => { + const now = new Date(); + await repo.findDue(now); + + expect(mockPrisma.scheduledJob.findMany).toHaveBeenCalledWith({ + where: { + state: 'PENDING', + scheduledAt: { lte: now }, + }, + orderBy: { scheduledAt: 'asc' }, + }); + }); + }); +}); diff --git a/packages/providers/netlify/__tests__/validate.test.js b/packages/providers/netlify/__tests__/validate.test.js new file mode 100644 index 000000000..4a394f904 --- /dev/null +++ b/packages/providers/netlify/__tests__/validate.test.js @@ -0,0 +1,111 @@ +const { validateNetlifyConfig } = require('../lib/validate'); + +describe('validateNetlifyConfig', () => { + test('validates a valid Netlify app definition', () => { + const result = validateNetlifyConfig({ + name: 'test-app', + database: { postgres: { enable: true } }, + encryption: { fieldLevelEncryptionMethod: 'aes' }, + queue: { provider: 'netlify-background' }, + integrations: [{ Definition: { name: 'hubspot' } }], + }); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test('returns error for null app definition', () => { + const result = validateNetlifyConfig(null); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('App definition is required'); + }); + + test('returns error for DocumentDB (AWS-specific)', () => { + const result = validateNetlifyConfig({ + name: 'test', + database: { documentDB: { enable: true } }, + }); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('DocumentDB'); + }); + + test('returns error for WebSocket support', () => { + const result = validateNetlifyConfig({ + name: 'test', + database: { postgres: { enable: true } }, + websockets: { enable: true }, + }); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('WebSocket'))).toBe(true); + }); + + test('returns error for SQS queue provider', () => { + const result = validateNetlifyConfig({ + name: 'test', + database: { postgres: { enable: true } }, + queue: { provider: 'sqs' }, + }); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('SQS'))).toBe(true); + }); + + test('returns warning for VPC configuration', () => { + const result = validateNetlifyConfig({ + name: 'test', + database: { postgres: { enable: true } }, + vpc: { enable: true }, + }); + + expect(result.valid).toBe(true); + expect(result.warnings.some((w) => w.includes('VPC'))).toBe(true); + }); + + test('returns warning for SSM configuration', () => { + const result = validateNetlifyConfig({ + name: 'test', + database: { postgres: { enable: true } }, + ssm: { enable: true }, + }); + + expect(result.valid).toBe(true); + expect(result.warnings.some((w) => w.includes('SSM'))).toBe(true); + }); + + test('returns warning for KMS encryption', () => { + const result = validateNetlifyConfig({ + name: 'test', + database: { postgres: { enable: true } }, + encryption: { useDefaultKMSForFieldLevelEncryption: true }, + }); + + expect(result.valid).toBe(true); + expect(result.warnings.some((w) => w.includes('KMS'))).toBe(true); + }); + + test('returns warning for no integrations', () => { + const result = validateNetlifyConfig({ + name: 'test', + database: { postgres: { enable: true } }, + }); + + expect(result.valid).toBe(true); + expect( + result.warnings.some((w) => w.includes('No integrations')) + ).toBe(true); + }); + + test('returns warning for no database config', () => { + const result = validateNetlifyConfig({ + name: 'test', + }); + + expect(result.valid).toBe(false); + expect( + result.errors.some((e) => e.includes('database')) + ).toBe(true); + }); +}); diff --git a/packages/providers/netlify/functions/admin.js b/packages/providers/netlify/functions/admin.js new file mode 100644 index 000000000..69ad02dc8 --- /dev/null +++ b/packages/providers/netlify/functions/admin.js @@ -0,0 +1,14 @@ +/** + * Netlify Function: Admin + * + * Handles admin routes: /api/admin/* + * Protected by admin API key authentication. + */ +const { router } = require('@friggframework/core/handlers/routers/admin'); +const { + createNetlifyAppHandler, +} = require('../lib/create-netlify-app-handler'); + +const handler = createNetlifyAppHandler('HTTP Event: Admin', router); + +module.exports = { handler }; diff --git a/packages/providers/netlify/functions/auth.js b/packages/providers/netlify/functions/auth.js new file mode 100644 index 000000000..812df9d9b --- /dev/null +++ b/packages/providers/netlify/functions/auth.js @@ -0,0 +1,20 @@ +/** + * Netlify Function: Auth & Integrations + * + * Handles the core Frigg integration router which includes: + * - v1: /api/integrations, /api/authorize, /api/credentials, /api/entities + * - v2: /api/v2/integrations, /api/v2/authorize, /api/v2/credentials, /api/v2/entities + * - OAuth redirect: /api/integrations/redirect/:appId + * - Integration settings: /config/integration-settings + * + * The auth router uses createIntegrationRouter() which mounts all integration + * API routes (both v1 and v2). This is the primary API function for Frigg on Netlify. + */ +const { router } = require('@friggframework/core/handlers/routers/auth'); +const { + createNetlifyAppHandler, +} = require('../lib/create-netlify-app-handler'); + +const handler = createNetlifyAppHandler('HTTP Event: Auth & Integrations', router); + +module.exports = { handler }; diff --git a/packages/providers/netlify/functions/docs.js b/packages/providers/netlify/functions/docs.js new file mode 100644 index 000000000..f66d693ce --- /dev/null +++ b/packages/providers/netlify/functions/docs.js @@ -0,0 +1,15 @@ +/** + * Netlify Function: Docs + * + * Handles API documentation routes: + * - GET /api/docs - v1 OpenAPI documentation + * - GET /api/v2/docs - v2 OpenAPI documentation + */ +const { router } = require('@friggframework/core/handlers/routers/docs'); +const { + createNetlifyAppHandler, +} = require('../lib/create-netlify-app-handler'); + +const handler = createNetlifyAppHandler('HTTP Event: Docs', router, false); + +module.exports = { handler }; diff --git a/packages/providers/netlify/functions/health.js b/packages/providers/netlify/functions/health.js new file mode 100644 index 000000000..413834420 --- /dev/null +++ b/packages/providers/netlify/functions/health.js @@ -0,0 +1,16 @@ +/** + * Netlify Function: Health + * + * Handles health check routes: /health/*, including: + * - GET /health/live - Liveness check + * - GET /health/ready - Readiness check (database, modules) + * - GET /health/detailed - Detailed health with encryption, VPC, diagnostics + */ +const { router } = require('@friggframework/core/handlers/routers/health'); +const { + createNetlifyAppHandler, +} = require('../lib/create-netlify-app-handler'); + +const handler = createNetlifyAppHandler('HTTP Event: Health', router); + +module.exports = { handler }; diff --git a/packages/providers/netlify/functions/integration-routes.js b/packages/providers/netlify/functions/integration-routes.js new file mode 100644 index 000000000..83bfe31d0 --- /dev/null +++ b/packages/providers/netlify/functions/integration-routes.js @@ -0,0 +1,44 @@ +/** + * Netlify Function: Integration-Defined Routes + * + * Handles custom routes defined on each Integration class via Definition.routes. + * Routes are mounted at /api/{integrationName}-integration/* + * + * This is a single function that handles all integration-defined routes. + * On AWS Lambda, each integration gets its own Lambda function. On Netlify, + * we consolidate into one function for simplicity (can be split later if + * cold starts become an issue). + */ +const { Router } = require('express'); +const { loadAppDefinition } = require('@friggframework/core/handlers/app-definition-loader'); +const { loadRouterFromObject } = require('@friggframework/core/handlers/backend-utils'); +const { + createNetlifyAppHandler, +} = require('../lib/create-netlify-app-handler'); + +const router = Router(); +const { integrations: integrationClasses } = loadAppDefinition(); + +for (const IntegrationClass of integrationClasses) { + if (!IntegrationClass.Definition.routes) continue; + + const basePath = `/api/${IntegrationClass.Definition.name}-integration`; + + for (const routeDef of IntegrationClass.Definition.routes) { + if (typeof routeDef === 'function') { + router.use(basePath, routeDef(IntegrationClass)); + } else if (typeof routeDef === 'object') { + router.use(basePath, loadRouterFromObject(IntegrationClass, routeDef)); + } else if (routeDef && routeDef.stack) { + // Express Router instance + router.use(basePath, routeDef); + } + } +} + +const handler = createNetlifyAppHandler( + 'HTTP Event: Integration Routes', + router +); + +module.exports = { handler }; diff --git a/packages/providers/netlify/functions/scheduled-sync.js b/packages/providers/netlify/functions/scheduled-sync.js new file mode 100644 index 000000000..6779219a5 --- /dev/null +++ b/packages/providers/netlify/functions/scheduled-sync.js @@ -0,0 +1,104 @@ +/** + * Netlify Scheduled Function: Cron Dispatcher + * + * Runs on a cron schedule (configured in netlify.toml) to: + * 1. Process due one-time schedules (from NetlifySchedulerAdapter) + * 2. Trigger ongoing sync jobs for active integrations + * + * Netlify Scheduled Functions have a 60-second execution limit. + * For longer processing, work is dispatched to the background function. + */ +const { + loadAppDefinition, +} = require('@friggframework/core/handlers/app-definition-loader'); +const { + createSchedulerService, +} = require('@friggframework/core/infrastructure/scheduler'); +const { + createQueueProvider, +} = require('@friggframework/core/queues'); +const { connectPrisma } = require('@friggframework/core/database/prisma'); +const { createNetlifyHandler } = require('../lib/create-netlify-handler'); +const { + ScheduledJobRepository, +} = require('../lib/scheduled-job-repository'); + +const WORKER_FUNCTION_PATH = '/.netlify/functions/worker-background'; + +const handler = createNetlifyHandler({ + eventName: 'Scheduled Sync (Netlify Cron)', + isUserFacingResponse: false, + method: async (event, context) => { + const startTime = Date.now(); + console.log( + '[ScheduledSync] Cron triggered at', + new Date().toISOString() + ); + + // Connect to database for scheduler repository + const prismaClient = await connectPrisma(); + + const repository = new ScheduledJobRepository({ prismaClient }); + const queueProvider = createQueueProvider({ provider: 'netlify-background' }); + + // 1. Process due one-time schedules + const scheduler = createSchedulerService({ + provider: 'netlify', + repository, + queueProvider, + }); + + const { processed, errors } = await scheduler.processDueSchedules(); + console.log( + `[ScheduledSync] One-time schedules: ${processed} processed, ${errors} errors` + ); + + // 2. Trigger ongoing sync for active integrations + const { integrations: integrationClasses } = loadAppDefinition(); + let syncDispatched = 0; + + for (const IntegrationClass of integrationClasses) { + const name = IntegrationClass.Definition?.name; + if (!name) continue; + + // Check if this integration defines an ONGOING_SYNC handler + if (typeof IntegrationClass.prototype.startOngoingSync !== 'function') continue; + + try { + await queueProvider.send( + { + integrationName: name, + event: 'ONGOING_SYNC', + data: {}, + }, + WORKER_FUNCTION_PATH + ); + syncDispatched++; + } catch (error) { + console.error( + `[ScheduledSync] Failed to dispatch sync for ${name}:`, + error.message + ); + } + } + + const duration = Date.now() - startTime; + console.log( + `[ScheduledSync] Completed in ${duration}ms: ${processed} schedules processed, ${syncDispatched} syncs dispatched` + ); + + return { + statusCode: 200, + body: JSON.stringify({ + message: 'Scheduled sync completed', + timestamp: new Date().toISOString(), + durationMs: duration, + schedulesProcessed: processed, + scheduleErrors: errors, + syncsDispatched: syncDispatched, + }), + }; + }, +}); + +module.exports = { handler }; diff --git a/packages/providers/netlify/functions/user.js b/packages/providers/netlify/functions/user.js new file mode 100644 index 000000000..8307745a3 --- /dev/null +++ b/packages/providers/netlify/functions/user.js @@ -0,0 +1,14 @@ +/** + * Netlify Function: User + * + * Handles user routes: /user/login, /user/create. + * Wraps the existing Frigg user Express router for Netlify Functions. + */ +const { router } = require('@friggframework/core/handlers/routers/user'); +const { + createNetlifyAppHandler, +} = require('../lib/create-netlify-app-handler'); + +const handler = createNetlifyAppHandler('HTTP Event: User', router); + +module.exports = { handler }; diff --git a/packages/providers/netlify/functions/webhooks.js b/packages/providers/netlify/functions/webhooks.js new file mode 100644 index 000000000..37d6aa142 --- /dev/null +++ b/packages/providers/netlify/functions/webhooks.js @@ -0,0 +1,74 @@ +/** + * Netlify Function: Webhooks + * + * Handles webhook routes for all integrations: + * - POST /api/{integrationName}-integration/webhooks + * - POST /api/{integrationName}-integration/webhooks/:integrationId + * + * Consolidates all integration webhook handlers into a single Netlify function. + * Webhook functions should NOT require database initialization for the handler + * itself (shouldUseDatabase = false) — the integration instance handles its + * own database connection when needed. + */ +const { Router } = require('express'); +const { loadAppDefinition } = require('@friggframework/core/handlers/app-definition-loader'); +const { + IntegrationEventDispatcher, +} = require('@friggframework/core/handlers/integration-event-dispatcher'); +const { + createNetlifyAppHandler, +} = require('../lib/create-netlify-app-handler'); + +const router = Router(); +const { integrations: integrationClasses } = loadAppDefinition(); + +for (const IntegrationClass of integrationClasses) { + const name = IntegrationClass.Definition?.name; + if (!name) continue; + + const basePath = `/api/${name}-integration/webhooks`; + + // General webhook route (no integration ID) + router.post(basePath, async (req, res, next) => { + try { + const integrationInstance = new IntegrationClass(); + const dispatcher = new IntegrationEventDispatcher( + integrationInstance + ); + await dispatcher.dispatchHttp({ + event: 'WEBHOOK_RECEIVED', + req, + res, + next, + }); + } catch (error) { + next(error); + } + }); + + // Integration-specific webhook route (with integration ID) + router.post(`${basePath}/:integrationId`, async (req, res, next) => { + try { + const integrationInstance = new IntegrationClass(); + const dispatcher = new IntegrationEventDispatcher( + integrationInstance + ); + await dispatcher.dispatchHttp({ + event: 'WEBHOOK_RECEIVED', + req, + res, + next, + }); + } catch (error) { + next(error); + } + }); +} + +const handler = createNetlifyAppHandler( + 'HTTP Event: Webhooks', + router, + false // shouldUseDatabase +); + +module.exports = { handler }; diff --git a/packages/providers/netlify/functions/worker-background.js b/packages/providers/netlify/functions/worker-background.js new file mode 100644 index 000000000..661590c61 --- /dev/null +++ b/packages/providers/netlify/functions/worker-background.js @@ -0,0 +1,68 @@ +/** + * Netlify Background Function: Queue Worker + * + * Processes async jobs dispatched by the queue provider. + * Named with '-background' suffix so Netlify runs it as a Background Function + * (15-minute execution limit, returns 202 immediately). + * + * Supports all three queue provider event formats: + * - Netlify Background Function: HTTP POST body + * - QStash: HTTP POST body (same format, different delivery mechanism) + * - SQS (for local dev): event.Records[].body + * + * Message format expected: + * { + * "integrationName": "hubspot", + * "event": "PROCESS_BATCH", + * "data": { "processId": "abc123", ... } + * } + */ +const { loadAppDefinition } = require('@friggframework/core/handlers/app-definition-loader'); +const { createQueueWorker } = require('@friggframework/core/handlers/backend-utils'); +const { createQueueProvider } = require('@friggframework/core/queues'); +const { createNetlifyHandler } = require('../lib/create-netlify-handler'); + +const { integrations: integrationClasses } = loadAppDefinition(); + +// Build a map of integration name → QueueWorker class +const workerMap = {}; +for (const IntegrationClass of integrationClasses) { + const name = IntegrationClass.Definition.name; + workerMap[name] = createQueueWorker(IntegrationClass); +} + +const queueProvider = createQueueProvider({ provider: 'netlify-background' }); + +const handler = createNetlifyHandler({ + eventName: 'Queue Worker (Netlify Background)', + isUserFacingResponse: false, + method: async (event, context) => { + // Parse the event using the active queue provider + const messages = queueProvider.parseEvent(event); + + for (const message of messages) { + const { integrationName, event: jobEvent, data } = message; + + const WorkerClass = workerMap[integrationName]; + if (!WorkerClass) { + console.error( + `No worker found for integration: ${integrationName}. Available: ${Object.keys(workerMap).join(', ')}` + ); + continue; + } + + const worker = new WorkerClass(); + // Call _run directly (bypasses SQS-specific event.Records parsing in run()) + await worker._run({ event: jobEvent, data }, context); + } + + return { + statusCode: 200, + body: JSON.stringify({ + message: `Processed ${messages.length} message(s)`, + }), + }; + }, +}); + +module.exports = { handler }; diff --git a/packages/providers/netlify/index.js b/packages/providers/netlify/index.js new file mode 100644 index 000000000..6cdba7920 --- /dev/null +++ b/packages/providers/netlify/index.js @@ -0,0 +1,106 @@ +/** + * @friggframework/provider-netlify + * + * Netlify provider plugin for the Frigg Framework. + * + * Exports the full provider plugin interface as defined in the + * provider plugin architecture (see plan.md). Can be loaded by + * the provider registry via: + * + * const provider = require('@friggframework/provider-netlify'); + * // provider.name === 'netlify' + * + * Also re-exports individual utilities for direct use: + * + * const { generateNetlifyToml } = require('@friggframework/provider-netlify'); + */ + +// ── Runtime Adapters ────────────────────────────────────────────── +const { createNetlifyHandler } = require('./lib/create-netlify-handler'); +const { + createNetlifyApp, + createNetlifyAppHandler, +} = require('./lib/create-netlify-app-handler'); +const { + NetlifyBackgroundProvider, +} = require('@friggframework/core/queues/providers/netlify-background-provider'); +const { + NetlifySchedulerAdapter, +} = require('@friggframework/core/infrastructure/scheduler/netlify-scheduler-adapter'); +const { loadSecrets } = require('./lib/load-secrets'); +const { invokeFunctionAdapter } = require('./lib/invoke-function-adapter'); + +// ── Build-Time / Infrastructure ─────────────────────────────────── +const { + generateNetlifyToml, + generateNetlifyEnvTemplate, +} = require('./lib/generate-netlify-config'); +const { validateNetlifyConfig } = require('./lib/validate'); +const { validateNetlifyDbConfig } = require('./lib/netlify-db'); +const { + getFunctionEntryPoints, + getFunctionEntryPointsDir, + getLibEntryPoints, +} = require('./lib/get-function-entry-points'); + +// ── Deploy ──────────────────────────────────────────────────────── +const { deploy, preflightCheck, teardown } = require('./lib/deploy'); +const { detect } = require('./lib/detect'); + +// ── Utilities ───────────────────────────────────────────────────── +const { ScheduledJobRepository } = require('./lib/scheduled-job-repository'); + +// ═══════════════════════════════════════════════════════════════════ +// Provider Plugin Interface +// Conforms to the shape defined in plan.md § Provider Plugin Interface +// ═══════════════════════════════════════════════════════════════════ + +module.exports = { + // Provider identifier + name: 'netlify', + + // ── Runtime Adapters ────────────────────────────────────────── + createHandler: createNetlifyHandler, + createAppHandler: createNetlifyAppHandler, + QueueProvider: NetlifyBackgroundProvider, + SchedulerAdapter: NetlifySchedulerAdapter, + CryptorAdapter: null, // Reuse core AES — no Netlify-specific encryption + loadSecrets, + invokeFunctionAdapter, + WebSocketAdapter: null, // Netlify does not support persistent WebSockets + + utils: { + ScheduledJobRepository, + createNetlifyApp, + getFunctionEntryPointsDir, + }, + + // ── Build-Time / Infrastructure ─────────────────────────────── + generateConfig: generateNetlifyToml, + generateEnvTemplate: generateNetlifyEnvTemplate, + infrastructureBuilders: [], // Netlify is fully managed — no IaC builders needed + + // ── Deploy ──────────────────────────────────────────────────── + deploy, + preflightCheck, + teardown, + validate: validateNetlifyConfig, + getFunctionEntryPoints, + getLibEntryPoints, + detect, + + // ── Metadata ────────────────────────────────────────────────── + recommendedDatabases: ['database-postgres'], + providedEnvVars: ['DATABASE_URL', 'URL', 'NETLIFY', 'NETLIFY_SITE_ID'], + + // ── Direct utility exports (backward compat) ────────────────── + createNetlifyHandler, + createNetlifyApp, + createNetlifyAppHandler, + generateNetlifyToml, + generateNetlifyEnvTemplate, + validateNetlifyDbConfig, + validateNetlifyConfig, + getFunctionEntryPointsDir, + ScheduledJobRepository, +}; diff --git a/packages/providers/netlify/lib/create-netlify-app-handler.js b/packages/providers/netlify/lib/create-netlify-app-handler.js new file mode 100644 index 000000000..7735aeacf --- /dev/null +++ b/packages/providers/netlify/lib/create-netlify-app-handler.js @@ -0,0 +1,74 @@ +/** + * Netlify App Handler Helper + * + * Creates Express apps wrapped for Netlify Functions via serverless-http. + * Equivalent of packages/core/handlers/app-handler-helpers.js but using + * createNetlifyHandler instead of Lambda's createHandler. + */ +const express = require('express'); +const bodyParser = require('body-parser'); +const cors = require('cors'); +const Boom = require('@hapi/boom'); +const serverlessHttp = require('serverless-http'); +const { flushDebugLog } = require('@friggframework/core/logs'); +const { createNetlifyHandler } = require('./create-netlify-handler'); + +const createNetlifyApp = (applyMiddleware) => { + const app = express(); + + app.use(bodyParser.json({ limit: '10mb' })); + app.use(bodyParser.urlencoded({ extended: true })); + app.use( + cors({ + origin: '*', + allowedHeaders: '*', + methods: '*', + credentials: true, + }) + ); + + if (applyMiddleware) applyMiddleware(app); + + // Error handler + app.use((err, req, res, next) => { + const boomError = err.isBoom ? err : Boom.boomify(err); + const { + output: { statusCode = 500 }, + } = boomError; + + if (statusCode >= 500) { + flushDebugLog(boomError); + res.status(statusCode).json({ error: 'Internal Server Error' }); + } else { + res.status(statusCode).json({ error: err.message }); + } + }); + + return app; +}; + +function createNetlifyAppHandler( + eventName, + router, + shouldUseDatabase = true, + basePath = null +) { + const app = createNetlifyApp((app) => { + if (basePath) { + app.use(basePath, router); + } else { + app.use(router); + } + }); + + return createNetlifyHandler({ + eventName, + method: serverlessHttp(app), + shouldUseDatabase, + }); +} + +module.exports = { + createNetlifyApp, + createNetlifyAppHandler, +}; diff --git a/packages/providers/netlify/lib/create-netlify-handler.js b/packages/providers/netlify/lib/create-netlify-handler.js new file mode 100644 index 000000000..740528597 --- /dev/null +++ b/packages/providers/netlify/lib/create-netlify-handler.js @@ -0,0 +1,69 @@ +/** + * Netlify Handler Factory + * + * Equivalent of packages/core/core/create-handler.js but for Netlify Functions. + * + * Key differences from Lambda createHandler: + * - No AWS Secrets Manager fetch (Netlify env vars are already in process.env) + * - No context.callbackWaitsForEmptyEventLoop (Lambda-specific) + * - Preserves: debug logging, error handling, client-safe error pattern + */ +const { initDebugLog, flushDebugLog } = require('@friggframework/core/logs'); + +const createNetlifyHandler = (optionByName = {}) => { + const { + eventName = 'Event', + isUserFacingResponse = true, + method, + } = optionByName; + + if (!method) { + throw new Error('Method is required for handler.'); + } + + return async (event, context) => { + try { + initDebugLog(eventName, event); + + const requestMethod = event.httpMethod; + const requestPath = event.path; + if (requestMethod && requestPath) { + console.info(`${requestMethod} ${requestPath}`); + } + + // On Netlify, secrets are environment variables — no fetching needed. + // (On AWS Lambda, createHandler calls secretsToEnv() here) + + return await method(event, context); + } catch (error) { + flushDebugLog(error); + + if (isUserFacingResponse) { + if (error.isClientSafe === true) { + const statusCode = error.statusCode || 400; + return { + statusCode, + body: JSON.stringify({ + error: error.message, + }), + }; + } + + return { + statusCode: 500, + body: JSON.stringify({ + error: 'An Internal Error Occurred', + }), + }; + } + + if (error.isHaltError === true) { + return; + } + + throw error; + } + }; +}; + +module.exports = { createNetlifyHandler }; diff --git a/packages/providers/netlify/lib/deploy.js b/packages/providers/netlify/lib/deploy.js new file mode 100644 index 000000000..f3fefb35f --- /dev/null +++ b/packages/providers/netlify/lib/deploy.js @@ -0,0 +1,253 @@ +/** + * Netlify Deploy, Preflight Check, and Teardown + * + * Implements the provider plugin deploy lifecycle: + * - preflightCheck() — verify prerequisites before deploy + * - deploy() — generate config and deploy to Netlify + * - teardown() — remove deployed resources + */ +const { execSync, spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { generateNetlifyToml, generateNetlifyEnvTemplate } = require('./generate-netlify-config'); +const { validateNetlifyConfig } = require('./validate'); + +/** + * Check if a CLI tool is installed and available on PATH + * @param {string} command + * @returns {boolean} + */ +function isCommandAvailable(command) { + try { + const cmd = process.platform === 'win32' ? 'where' : 'which'; + execSync(`${cmd} ${command}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * Pre-deploy checks — verify everything needed to deploy is in place. + * + * @param {Object} [appDefinition] - Frigg app definition + * @returns {Promise<{ ready: boolean, missing: string[] }>} + */ +async function preflightCheck(appDefinition) { + const missing = []; + + // Check for Netlify CLI or auth token + const hasNetlifyCli = isCommandAvailable('netlify'); + const hasAuthToken = !!process.env.NETLIFY_AUTH_TOKEN; + + if (!hasNetlifyCli && !hasAuthToken) { + missing.push( + 'Netlify CLI (npm install -g netlify-cli) or NETLIFY_AUTH_TOKEN environment variable' + ); + } + + // Check for database configuration + if (!process.env.DATABASE_URL) { + missing.push( + 'DATABASE_URL environment variable (Neon PostgreSQL or MongoDB Atlas connection string)' + ); + } + + // Check for QStash if configured + if (appDefinition?.queue?.provider === 'qstash') { + if (!process.env.QSTASH_TOKEN) { + missing.push('QSTASH_TOKEN environment variable (from console.upstash.com)'); + } + } + + // Validate app definition if provided + if (appDefinition) { + const validation = validateNetlifyConfig(appDefinition); + for (const error of validation.errors) { + missing.push(error); + } + } + + return { + ready: missing.length === 0, + missing, + }; +} + +/** + * Deploy the Frigg app to Netlify. + * + * Steps: + * 1. Validate prerequisites (preflightCheck) + * 2. Generate netlify.toml from appDefinition + * 3. Run build command + * 4. Deploy via Netlify CLI or git-push + * + * @param {Object} appDefinition - Frigg app definition + * @param {Object} [options] + * @param {string} [options.stage] - Deployment stage (production, staging, dev) + * @param {boolean} [options.prod] - Deploy to production (default: draft deploy) + * @param {boolean} [options.dryRun] - Generate config without deploying + * @param {string} [options.dir] - Deploy directory (default: current directory) + * @param {string} [options.functionsDir] - Functions directory (default: netlify/functions) + * @returns {Promise<{ url: string, functionUrls: Object, logs: string[] }>} + */ +async function deploy(appDefinition, options = {}) { + const { + stage = 'production', + prod = true, + dryRun = false, + dir = process.cwd(), + functionsDir = 'netlify/functions', + } = options; + + const logs = []; + const log = (msg) => { + console.log(msg); + logs.push(msg); + }; + + // 1. Preflight check + log('[Netlify Deploy] Running preflight checks...'); + const preflight = await preflightCheck(appDefinition); + if (!preflight.ready) { + const error = new Error( + `Preflight check failed:\n - ${preflight.missing.join('\n - ')}` + ); + error.missing = preflight.missing; + throw error; + } + log('[Netlify Deploy] Preflight checks passed'); + + // 2. Generate netlify.toml + log('[Netlify Deploy] Generating netlify.toml...'); + const toml = generateNetlifyToml(appDefinition, { functionsDir }); + const tomlPath = path.join(dir, 'netlify.toml'); + fs.writeFileSync(tomlPath, toml, 'utf-8'); + log(`[Netlify Deploy] Written ${tomlPath}`); + + // 3. Generate env template (informational) + const envTemplate = generateNetlifyEnvTemplate(appDefinition); + const missingEnvVars = Object.keys(envTemplate).filter( + (key) => !process.env[key] + ); + if (missingEnvVars.length > 0) { + log( + `[Netlify Deploy] Warning: ${missingEnvVars.length} env vars not set locally: ${missingEnvVars.join(', ')}` + ); + log( + ' These should be configured in your Netlify site settings.' + ); + } + + if (dryRun) { + log('[Netlify Deploy] Dry run — skipping actual deployment'); + return { url: '(dry-run)', functionUrls: {}, logs }; + } + + // 4. Deploy via Netlify CLI + log('[Netlify Deploy] Deploying to Netlify...'); + + const result = await runNetlifyDeploy({ prod, dir, functionsDir, logs, log }); + + return result; +} + +/** + * Run `netlify deploy` and capture the output. + */ +function runNetlifyDeploy({ prod, dir, functionsDir, logs, log }) { + return new Promise((resolve, reject) => { + const args = ['deploy']; + if (prod) args.push('--prod'); + args.push('--dir', dir); + args.push('--functions', functionsDir); + + const netlifyCmd = isCommandAvailable('netlify') ? 'netlify' : 'npx'; + const fullArgs = netlifyCmd === 'npx' ? ['netlify-cli', ...args] : args; + + const child = spawn(netlifyCmd, fullArgs, { + cwd: dir, + stdio: ['inherit', 'pipe', 'pipe'], + env: { ...process.env }, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + const text = data.toString(); + stdout += text; + process.stdout.write(text); + }); + + child.stderr.on('data', (data) => { + const text = data.toString(); + stderr += text; + process.stderr.write(text); + }); + + child.on('error', (error) => { + reject(new Error(`Failed to run netlify deploy: ${error.message}`)); + }); + + child.on('close', (code) => { + if (code !== 0) { + reject( + new Error( + `netlify deploy exited with code ${code}\n${stderr}` + ) + ); + return; + } + + // Parse URL from output + const urlMatch = stdout.match(/Website URL:\s+(https?:\/\/\S+)/); + const url = urlMatch ? urlMatch[1] : '(url not found in output)'; + + log(`[Netlify Deploy] Deployed to: ${url}`); + logs.push(stdout); + + resolve({ url, functionUrls: {}, logs }); + }); + }); +} + +/** + * Tear down / remove Netlify deployment. + * + * Netlify sites are managed via the web UI or CLI. This function + * deletes the generated config file and optionally deletes the site. + * + * @param {Object} appDefinition + * @param {Object} [options] + * @param {boolean} [options.deleteSite] - Also delete the Netlify site (destructive) + * @param {string} [options.dir] - Project directory + */ +async function teardown(appDefinition, options = {}) { + const { deleteSite = false, dir = process.cwd() } = options; + + // Remove generated netlify.toml + const tomlPath = path.join(dir, 'netlify.toml'); + if (fs.existsSync(tomlPath)) { + fs.unlinkSync(tomlPath); + console.log('[Netlify Teardown] Removed netlify.toml'); + } + + if (deleteSite) { + if (!isCommandAvailable('netlify')) { + throw new Error( + 'Netlify CLI required for site deletion. Install with: npm install -g netlify-cli' + ); + } + + console.log('[Netlify Teardown] Deleting Netlify site...'); + execSync('netlify sites:delete --force', { + cwd: dir, + stdio: 'inherit', + }); + console.log('[Netlify Teardown] Site deleted'); + } +} + +module.exports = { deploy, preflightCheck, teardown }; diff --git a/packages/providers/netlify/lib/detect.js b/packages/providers/netlify/lib/detect.js new file mode 100644 index 000000000..2244178b1 --- /dev/null +++ b/packages/providers/netlify/lib/detect.js @@ -0,0 +1,13 @@ +/** + * Netlify Platform Detection + * + * Auto-detect if the current runtime is a Netlify Function. + * Netlify sets the NETLIFY environment variable during builds and function execution. + * + * @returns {boolean} + */ +function detect() { + return !!process.env.NETLIFY; +} + +module.exports = { detect }; diff --git a/packages/providers/netlify/lib/generate-netlify-config.js b/packages/providers/netlify/lib/generate-netlify-config.js new file mode 100644 index 000000000..fc1a49239 --- /dev/null +++ b/packages/providers/netlify/lib/generate-netlify-config.js @@ -0,0 +1,257 @@ +/** + * Netlify Configuration Generator + * + * Generates netlify.toml from a Frigg app definition. + * Produces routing redirects for split-per-concern Netlify Functions, + * including v2 API routes. + * + * Usage: + * const { generateNetlifyToml } = require('@friggframework/provider-netlify'); + * const toml = generateNetlifyToml(appDefinition); + * fs.writeFileSync('netlify.toml', toml); + */ + +/** + * Resolve the Prisma database type(s) from an app definition. + * + * Reads appDefinition.database to determine whether the app uses + * postgresql, mongodb, or both. Returns an array of db type strings + * used to build targeted included_files globs so only the relevant + * Prisma generated client is bundled. + * + * @param {Object} appDefinition + * @returns {string[]} e.g. ['postgresql'] or ['mongodb'] or ['postgresql','mongodb'] + */ +function resolveDbTypes(appDefinition) { + const db = appDefinition.database; + if (!db) { + // Fallback: include both so nothing breaks for apps that omit config + return ['postgresql', 'mongodb']; + } + + const types = []; + if (db.postgres?.enable) types.push('postgresql'); + if (db.mongoDB?.enable) types.push('mongodb'); + + // If nothing was explicitly enabled, fall back to both + return types.length > 0 ? types : ['postgresql', 'mongodb']; +} + +/** + * Generate netlify.toml content from a Frigg app definition + * + * @param {Object} appDefinition - Frigg app definition object + * @param {Object} [options] + * @param {string} [options.functionsDir] - Functions directory (default: 'netlify/functions') + * @param {string} [options.buildCommand] - Build command (default: 'npm run build') + * @param {string} [options.nodeVersion] - Node.js version (default: '18') + * @returns {string} netlify.toml content + */ +function generateNetlifyToml(appDefinition, options = {}) { + const { + functionsDir = 'netlify/functions', + buildCommand = 'npm run build', + nodeVersion = '18', + cronSchedule = '*/5 * * * *', + } = options; + + const integrations = appDefinition.integrations || []; + const dbTypes = resolveDbTypes(appDefinition); + + const lines = []; + + // Build configuration + lines.push('[build]'); + lines.push(` command = "${buildCommand}"`); + lines.push(` functions = "${functionsDir}"`); + lines.push(''); + + // Node version + lines.push('[build.environment]'); + lines.push(` NODE_VERSION = "${nodeVersion}"`); + lines.push(''); + + // Functions configuration — only include Prisma generated client for the + // database type(s) declared in appDefinition.database to reduce bundle size. + const prismaGlobs = dbTypes.map( + (t) => `"node_modules/@friggframework/core/generated/prisma-${t}/**"` + ); + const includedFiles = ['"node_modules/.prisma/**"', ...prismaGlobs].join(', '); + + lines.push('[functions]'); + lines.push(' node_bundler = "esbuild"'); + lines.push(' # Include Prisma engine + generated client for configured database'); + lines.push(` included_files = [${includedFiles}]`); + lines.push(' # Exclude packages that esbuild cannot bundle (native/dynamic requires)'); + lines.push(' # Also exclude AWS SDKs — they are only used by @friggframework/provider-aws, not needed on Netlify'); + lines.push(' external_node_modules = ["express", "body-parser", "cors", "serverless-http", "@prisma/client", "mongoose", "@friggframework/core", "@friggframework/provider-netlify", "aws-sdk", "@aws-sdk/*"]'); + lines.push(''); + + // ========================================================================= + // Redirects — route API requests to split Netlify Functions + // ========================================================================= + + // Auth & Integration API (v1 + v2) — the main API function + // v2 routes + lines.push('# v2 API routes'); + lines.push('[[redirects]]'); + lines.push(' from = "/api/v2/*"'); + lines.push(' to = "/.netlify/functions/auth"'); + lines.push(' status = 200'); + lines.push(''); + + // v1 integration routes + lines.push('# v1 integration/auth routes'); + lines.push('[[redirects]]'); + lines.push(' from = "/api/integrations/*"'); + lines.push(' to = "/.netlify/functions/auth"'); + lines.push(' status = 200'); + lines.push(''); + + lines.push('[[redirects]]'); + lines.push(' from = "/api/authorize"'); + lines.push(' to = "/.netlify/functions/auth"'); + lines.push(' status = 200'); + lines.push(''); + + lines.push('[[redirects]]'); + lines.push(' from = "/api/credentials/*"'); + lines.push(' to = "/.netlify/functions/auth"'); + lines.push(' status = 200'); + lines.push(''); + + lines.push('[[redirects]]'); + lines.push(' from = "/api/entities/*"'); + lines.push(' to = "/.netlify/functions/auth"'); + lines.push(' status = 200'); + lines.push(''); + + lines.push('[[redirects]]'); + lines.push(' from = "/config/integration-settings"'); + lines.push(' to = "/.netlify/functions/auth"'); + lines.push(' status = 200'); + lines.push(''); + + // User routes + lines.push('# User routes'); + lines.push('[[redirects]]'); + lines.push(' from = "/user/*"'); + lines.push(' to = "/.netlify/functions/user"'); + lines.push(' status = 200'); + lines.push(''); + + // Health routes + lines.push('# Health check routes'); + lines.push('[[redirects]]'); + lines.push(' from = "/health/*"'); + lines.push(' to = "/.netlify/functions/health"'); + lines.push(' status = 200'); + lines.push(''); + + // Admin routes + lines.push('# Admin routes'); + lines.push('[[redirects]]'); + lines.push(' from = "/api/admin/*"'); + lines.push(' to = "/.netlify/functions/admin"'); + lines.push(' status = 200'); + lines.push(''); + + // Docs routes + lines.push('# API documentation routes'); + lines.push('[[redirects]]'); + lines.push(' from = "/api/docs"'); + lines.push(' to = "/.netlify/functions/docs"'); + lines.push(' status = 200'); + lines.push(''); + + // Integration-defined routes (per integration) + if (integrations.length > 0) { + lines.push('# Integration-defined custom routes'); + for (const integration of integrations) { + const name = integration.Definition?.name || integration.name; + if (!name) continue; + + // Webhook routes (must come before general integration routes) + lines.push(`[[redirects]]`); + lines.push(` from = "/api/${name}-integration/webhooks/*"`); + lines.push(` to = "/.netlify/functions/webhooks"`); + lines.push(` status = 200`); + lines.push(''); + + // Custom integration routes + lines.push(`[[redirects]]`); + lines.push(` from = "/api/${name}-integration/*"`); + lines.push(` to = "/.netlify/functions/integration-routes"`); + lines.push(` status = 200`); + lines.push(''); + } + } + + // Queue worker (background function) + lines.push('# Queue worker (background function - 15min execution limit)'); + lines.push('[[redirects]]'); + lines.push(' from = "/api/queue"'); + lines.push(' to = "/.netlify/functions/worker-background"'); + lines.push(' status = 200'); + lines.push(''); + + // ========================================================================= + // Scheduled function (cron dispatcher) + // ========================================================================= + lines.push('# Scheduled function — processes due one-time jobs and triggers ongoing syncs'); + lines.push('[functions."scheduled-sync"]'); + lines.push(` schedule = "${cronSchedule}"`); + lines.push(''); + + return lines.join('\n'); +} + +/** + * Generate environment variable template for Netlify + * + * @param {Object} appDefinition - Frigg app definition object + * @returns {Object} Map of env var names to descriptions + */ +function generateNetlifyEnvTemplate(appDefinition) { + const envVars = { + // Core Frigg + BASE_URL: 'Your Netlify site URL (e.g., https://myapp.netlify.app)', + REDIRECT_URI: 'OAuth redirect URI (typically BASE_URL + /api/integrations/redirect)', + FRIGG_API_KEY: 'API key for Frigg backend authentication', + FRIGG_APP_USER_ID: 'Default user ID for app-level operations', + HEALTH_API_KEY: 'API key for health check endpoints', + ADMIN_API_KEY: 'API key for admin endpoints', + + // Database (MongoDB Atlas or Netlify DB) + DATABASE_URL: 'MongoDB Atlas or Netlify DB (Neon PostgreSQL) connection string', + }; + + // Encryption (AES for Netlify — no KMS) + const encryption = appDefinition.encryption; + if (encryption?.fieldLevelEncryptionMethod !== 'none') { + envVars.AES_KEY_ID = 'Identifier for the AES encryption key'; + envVars.AES_KEY = 'AES-256 encryption key (32 characters)'; + envVars.STAGE = 'Deployment stage (production, staging, dev)'; + } + + // Queue provider + const queueProvider = appDefinition.queue?.provider; + if (queueProvider === 'qstash') { + envVars.QSTASH_TOKEN = 'Upstash QStash token (from console.upstash.com)'; + envVars.QSTASH_CURRENT_SIGNING_KEY = 'QStash webhook verification signing key'; + envVars.QSTASH_NEXT_SIGNING_KEY = 'QStash webhook verification next signing key'; + } + + // Integration-specific env vars from app definition + if (appDefinition.environment) { + for (const [key, value] of Object.entries(appDefinition.environment)) { + if (!envVars[key]) { + envVars[key] = typeof value === 'string' ? value : `Required: ${key}`; + } + } + } + + return envVars; +} + +module.exports = { generateNetlifyToml, generateNetlifyEnvTemplate, resolveDbTypes }; diff --git a/packages/providers/netlify/lib/get-function-entry-points.js b/packages/providers/netlify/lib/get-function-entry-points.js new file mode 100644 index 000000000..e30786eb2 --- /dev/null +++ b/packages/providers/netlify/lib/get-function-entry-points.js @@ -0,0 +1,163 @@ +/** + * Netlify Function Entry Point Generator + * + * Returns the file contents for all Netlify Function entry points. + * These are the split-per-concern functions that Netlify deploys individually. + * + * The static function files in ../functions/ are the source of truth. + * This generator reads them and returns a map of { filename: content } + * for programmatic use (e.g., scaffolding a new project). + */ +const fs = require('fs'); +const path = require('path'); + +const FUNCTIONS_DIR = path.join(__dirname, '..', 'functions'); + +/** + * Standard function entry points included in every Netlify deployment. + */ +const STANDARD_FUNCTIONS = [ + 'auth.js', + 'user.js', + 'health.js', + 'admin.js', + 'docs.js', + 'integration-routes.js', + 'webhooks.js', + 'worker-background.js', + 'scheduled-sync.js', +]; + +/** Default relative path from netlify/functions/ to the backend directory. */ +const DEFAULT_BACKEND_PATH = '../../backend'; + +/** + * Build the preamble injected at the top of every generated function entry point. + * + * This makes the backend's app definition statically traceable by nft/esbuild: + * - require('/index.js') is a static path nft can follow + * - setAppDefinition() caches the definition so loadAppDefinition() in core + * routers skips process.cwd() discovery (which fails on Netlify at runtime) + * + * @param {string} backendPath - Relative path from functions dir to backend + * @returns {string} + */ +function buildPreamble(backendPath) { + return [ + '// Pre-load app definition so nft can trace backend dependencies statically.', + '// This avoids process.cwd() discovery which fails in Netlify function runtime.', + `const { setAppDefinition } = require('${backendPath}/node_modules/@friggframework/core/handlers/app-definition-loader');`, + `const { Definition: _friggAppDef } = require('${backendPath}/index.js');`, + 'setAppDefinition(_friggAppDef);', + '', + ].join('\n'); +} + +/** + * Rewrite bare @friggframework/core requires to resolve through the backend's + * node_modules. In a pnpm monorepo, bare requires resolve to the pnpm + * virtual store (a separate copy), causing nft to bundle two copies of + * @friggframework/core. Relative paths ensure a single copy. + * + * @param {string} content - Function file content + * @param {string} backendPath - Relative path from functions dir to backend + * @returns {string} + */ +function rewriteCoreRequires(content, backendPath) { + // Match require('@friggframework/core') or require('@friggframework/core/...') + return content.replace( + /require\(['"]@friggframework\/core(\/[^'"]*)?['"]\)/g, + (match, subpath) => { + const modulePath = subpath || ''; + return `require('${backendPath}/node_modules/@friggframework/core${modulePath}')`; + } + ); +} + +/** + * Get all function entry point files for a Netlify deployment. + * + * Returns a map of filename → file content that can be written to the + * functions directory of a Netlify project. Each file is prefixed with + * a preamble that pre-loads the app definition via a static require, + * enabling nft to trace the backend dependencies into the function bundle. + * + * All bare `@friggframework/core` requires are rewritten to resolve through + * the backend's node_modules to avoid duplicate dependency trees in pnpm + * monorepos. + * + * @param {Object} appDefinition - Frigg app definition + * @param {Object} [options] + * @param {string} [options.backendPath] - Relative path from functions dir to backend (default: '../../backend') + * @returns {{ [filename: string]: string }} + */ +function getFunctionEntryPoints(appDefinition, options = {}) { + const { backendPath = DEFAULT_BACKEND_PATH } = options; + const preamble = buildPreamble(backendPath); + const entryPoints = {}; + + for (const filename of STANDARD_FUNCTIONS) { + const filePath = path.join(FUNCTIONS_DIR, filename); + if (fs.existsSync(filePath)) { + const templateContent = fs.readFileSync(filePath, 'utf-8'); + const rewritten = rewriteCoreRequires(templateContent, backendPath); + entryPoints[filename] = preamble + rewritten; + } + } + + return entryPoints; +} + +/** + * Get the absolute path to the bundled function entry points directory. + * Useful when the deployer can copy files directly rather than reading content. + * + * @returns {string} Absolute path to the functions directory + */ +function getFunctionEntryPointsDir() { + return FUNCTIONS_DIR; +} + +/** + * Runtime lib files that function entry points depend on via require('../lib/...'). + * + * Rather than copying these files (which may have their own relative imports), + * we generate re-export shims that point back to the installed npm package. + * This keeps the output minimal and always in sync with the package version. + */ +const LIB_REQUIRE_PATTERN = /require\(['"]\.\.\/lib\/([^'"]+)['"]\)/g; + +/** + * Get re-export shim files for all lib/ dependencies referenced by function entry points. + * + * Scans function entry point contents for require('../lib/...') references + * and generates shim files that re-export from the installed package. + * + * @param {Object} appDefinition - Frigg app definition + * @returns {{ [filename: string]: string }} Map of lib filename → re-export shim content + */ +function getLibEntryPoints(appDefinition) { + const functionEntryPoints = getFunctionEntryPoints(appDefinition); + const libModules = new Set(); + + // Scan function contents for ../lib/ references + for (const content of Object.values(functionEntryPoints)) { + let match; + // Reset lastIndex for each content string since we reuse the regex + LIB_REQUIRE_PATTERN.lastIndex = 0; + while ((match = LIB_REQUIRE_PATTERN.exec(content)) !== null) { + libModules.add(match[1]); + } + } + + const libEntryPoints = {}; + for (const modulePath of libModules) { + const filename = modulePath.endsWith('.js') ? modulePath : `${modulePath}.js`; + const requirePath = modulePath.replace(/\.js$/, ''); + libEntryPoints[filename] = `module.exports = require('@friggframework/provider-netlify/lib/${requirePath}');\n`; + } + + return libEntryPoints; +} + +module.exports = { getFunctionEntryPoints, getFunctionEntryPointsDir, getLibEntryPoints }; diff --git a/packages/providers/netlify/lib/invoke-function-adapter.js b/packages/providers/netlify/lib/invoke-function-adapter.js new file mode 100644 index 000000000..299316494 --- /dev/null +++ b/packages/providers/netlify/lib/invoke-function-adapter.js @@ -0,0 +1,51 @@ +/** + * Netlify Function Invoker + * + * Calls another Netlify Function via HTTP POST. + * On Netlify, functions communicate via HTTP — there's no native cross-invocation + * API like AWS Lambda.invoke(). + * + * Uses process.env.URL (set automatically by Netlify) as the base URL. + */ + +const invokeFunctionAdapter = { + /** + * Invoke a Netlify Function by name. + * + * @param {string} functionName - Function name (e.g., 'worker-background') + * @param {Object} payload - JSON payload to send + * @param {Object} [options] + * @param {string} [options.baseUrl] - Override base URL (default: process.env.URL) + * @returns {Promise} Parsed JSON response + */ + async invoke(functionName, payload, options = {}) { + const baseUrl = options.baseUrl || process.env.URL || ''; + const url = `${baseUrl}/.netlify/functions/${functionName}`; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + // Background functions return 202 (accepted, processing in background) + if (response.status === 202) { + return { accepted: true, status: 202 }; + } + + if (!response.ok) { + throw new Error( + `Function invocation failed: ${functionName} returned ${response.status} ${response.statusText}` + ); + } + + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + return response.json(); + } + + return { body: await response.text(), status: response.status }; + }, +}; + +module.exports = { invokeFunctionAdapter }; diff --git a/packages/providers/netlify/lib/load-secrets.js b/packages/providers/netlify/lib/load-secrets.js new file mode 100644 index 000000000..1a9f172cd --- /dev/null +++ b/packages/providers/netlify/lib/load-secrets.js @@ -0,0 +1,13 @@ +/** + * Netlify Secrets Loader (No-Op) + * + * On Netlify, environment variables are injected natively into the function + * runtime — no fetching needed (unlike AWS Secrets Manager on Lambda). + * + * This function exists to satisfy the provider plugin interface contract. + */ +async function loadSecrets() { + // No-op: Netlify env vars are already in process.env +} + +module.exports = { loadSecrets }; diff --git a/packages/providers/netlify/lib/netlify-db.js b/packages/providers/netlify/lib/netlify-db.js new file mode 100644 index 000000000..869a62065 --- /dev/null +++ b/packages/providers/netlify/lib/netlify-db.js @@ -0,0 +1,99 @@ +/** + * Netlify DB (Neon PostgreSQL) Support + * + * Frigg already supports PostgreSQL via Prisma ORM. Netlify DB is powered by + * Neon serverless PostgreSQL, which is fully compatible. + * + * Configuration: + * 1. Enable Netlify DB in your Netlify project settings + * 2. Netlify automatically sets DATABASE_URL in your function environment + * 3. Set database.postgres.enable = true in your Frigg app definition + * + * The Prisma client will automatically use the DATABASE_URL to connect. + * + * App definition example for Netlify DB: + * + * const appDefinition = { + * name: 'my-frigg-app', + * database: { + * postgres: { + * enable: true, + * // Netlify DB sets DATABASE_URL automatically + * // No additional config needed + * }, + * }, + * // Use AES encryption (no AWS KMS on Netlify) + * encryption: { + * fieldLevelEncryptionMethod: 'aes', + * }, + * // Queue provider for Netlify + * queue: { + * provider: 'netlify-background', // or 'qstash' + * }, + * }; + * + * Alternatively, for MongoDB Atlas (also works on Netlify): + * + * const appDefinition = { + * database: { + * mongoDB: { enable: true }, + * }, + * }; + * // Set DATABASE_URL to your MongoDB Atlas connection string in Netlify env vars + * + * Neon-specific considerations: + * - Uses serverless driver for connection pooling (handled by Prisma) + * - Supports branching for preview deployments + * - Auto-scales to zero when idle + * - Connection string format: postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/dbname?sslmode=require + */ + +/** + * Validate that the database configuration is compatible with Netlify deployment. + * + * @param {Object} appDefinition - Frigg app definition + * @returns {{ valid: boolean, warnings: string[], errors: string[] }} + */ +function validateNetlifyDbConfig(appDefinition) { + const warnings = []; + const errors = []; + + const db = appDefinition.database; + + if (!db) { + errors.push( + 'No database configuration found. Add database.postgres or database.mongoDB to your app definition.' + ); + return { valid: false, warnings, errors }; + } + + // Check for DocumentDB (AWS-specific, won't work on Netlify) + if (db.documentDB?.enable) { + errors.push( + 'DocumentDB is AWS-specific and cannot be used with Netlify. ' + + 'Use database.mongoDB (MongoDB Atlas) or database.postgres (Netlify DB / Neon) instead.' + ); + } + + // Check for Aurora (AWS-specific) + if (db.postgres?.enable && appDefinition.vpc?.enable) { + warnings.push( + 'VPC configuration is ignored on Netlify. ' + + 'Ensure your database allows connections from Netlify IP ranges.' + ); + } + + // Check encryption config + const encryption = appDefinition.encryption; + if (encryption?.fieldLevelEncryptionMethod === 'kms') { + warnings.push( + 'KMS encryption is AWS-specific. On Netlify, use fieldLevelEncryptionMethod: "aes" ' + + 'and set AES_KEY_ID and AES_KEY environment variables.' + ); + } + + const valid = errors.length === 0; + return { valid, warnings, errors }; +} + +module.exports = { validateNetlifyDbConfig }; diff --git a/packages/providers/netlify/lib/scheduled-job-repository.js b/packages/providers/netlify/lib/scheduled-job-repository.js new file mode 100644 index 000000000..560886bf2 --- /dev/null +++ b/packages/providers/netlify/lib/scheduled-job-repository.js @@ -0,0 +1,78 @@ +/** + * Scheduled Job Repository + * + * Prisma-based implementation of the repository interface required by + * NetlifySchedulerAdapter. Persists one-time scheduled jobs to the + * ScheduledJob model so they survive function restarts. + * + * Repository interface: + * save(scheduleData) — Upsert a scheduled job + * delete(scheduleName) — Remove a scheduled job + * findByName(scheduleName) — Find a single scheduled job + * findDue(now) — Find all PENDING jobs where scheduledAt <= now + */ + +class ScheduledJobRepository { + /** + * @param {Object} options + * @param {import('@prisma/client').PrismaClient} options.prismaClient + */ + constructor({ prismaClient }) { + if (!prismaClient) { + throw new Error( + 'ScheduledJobRepository requires a prismaClient instance' + ); + } + this.prisma = prismaClient; + } + + async save(scheduleData) { + return this.prisma.scheduledJob.upsert({ + where: { scheduleName: scheduleData.scheduleName }, + update: { + scheduledAt: new Date(scheduleData.scheduledAt), + queueResourceId: scheduleData.queueResourceId, + payload: scheduleData.payload ?? undefined, + state: scheduleData.state || 'PENDING', + }, + create: { + scheduleName: scheduleData.scheduleName, + scheduledAt: new Date(scheduleData.scheduledAt), + queueResourceId: scheduleData.queueResourceId, + payload: scheduleData.payload ?? undefined, + state: scheduleData.state || 'PENDING', + }, + }); + } + + async delete(scheduleName) { + try { + await this.prisma.scheduledJob.delete({ + where: { scheduleName }, + }); + } catch (error) { + // P2025 = record not found — safe to ignore on delete + if (error.code !== 'P2025') { + throw error; + } + } + } + + async findByName(scheduleName) { + return this.prisma.scheduledJob.findUnique({ + where: { scheduleName }, + }); + } + + async findDue(now) { + return this.prisma.scheduledJob.findMany({ + where: { + state: 'PENDING', + scheduledAt: { lte: now }, + }, + orderBy: { scheduledAt: 'asc' }, + }); + } +} + +module.exports = { ScheduledJobRepository }; diff --git a/packages/providers/netlify/lib/validate.js b/packages/providers/netlify/lib/validate.js new file mode 100644 index 000000000..b90e30487 --- /dev/null +++ b/packages/providers/netlify/lib/validate.js @@ -0,0 +1,82 @@ +/** + * Netlify App Definition Validator + * + * Validates a complete Frigg app definition for Netlify deployment. + * Covers database, encryption, queue, websocket, and VPC configuration. + */ +const { validateNetlifyDbConfig } = require('./netlify-db'); + +/** + * Validate an app definition for Netlify deployment. + * + * @param {Object} appDefinition - Frigg app definition + * @returns {{ valid: boolean, errors: string[], warnings: string[] }} + */ +function validateNetlifyConfig(appDefinition) { + const errors = []; + const warnings = []; + + if (!appDefinition) { + errors.push('App definition is required'); + return { valid: false, errors, warnings }; + } + + // Database validation (delegates to existing netlify-db.js) + const dbValidation = validateNetlifyDbConfig(appDefinition); + errors.push(...dbValidation.errors); + warnings.push(...dbValidation.warnings); + + // Encryption validation + const encryption = appDefinition.encryption; + if (encryption?.useDefaultKMSForFieldLevelEncryption) { + warnings.push( + 'useDefaultKMSForFieldLevelEncryption is AWS-specific. ' + + 'On Netlify, set fieldLevelEncryptionMethod: "aes" and configure AES_KEY_ID + AES_KEY.' + ); + } + + // WebSocket validation + if (appDefinition.websockets?.enable) { + errors.push( + 'Netlify does not support persistent WebSocket connections. ' + + 'Remove websockets.enable or use a provider that supports WebSockets (e.g., AWS).' + ); + } + + // VPC validation + if (appDefinition.vpc?.enable) { + warnings.push( + 'VPC configuration is ignored on Netlify. ' + + 'Ensure your database accepts connections from Netlify IP ranges.' + ); + } + + // SSM validation + if (appDefinition.ssm?.enable) { + warnings.push( + 'AWS SSM (Systems Manager) is not available on Netlify. ' + + 'Use Netlify environment variables instead.' + ); + } + + // Queue validation + const queueProvider = appDefinition.queue?.provider; + if (queueProvider === 'sqs') { + errors.push( + 'SQS queue provider is AWS-specific. ' + + 'On Netlify, use queue.provider: "netlify-background" or "qstash".' + ); + } + + // Integrations validation + if (!appDefinition.integrations || appDefinition.integrations.length === 0) { + warnings.push( + 'No integrations configured. Add at least one integration to your app definition.' + ); + } + + const valid = errors.length === 0; + return { valid, errors, warnings }; +} + +module.exports = { validateNetlifyConfig }; diff --git a/packages/providers/netlify/package.json b/packages/providers/netlify/package.json new file mode 100644 index 000000000..071cf2ea6 --- /dev/null +++ b/packages/providers/netlify/package.json @@ -0,0 +1,38 @@ +{ + "name": "@friggframework/provider-netlify", + "version": "0.1.0", + "description": "Netlify provider plugin for the Frigg Framework", + "main": "index.js", + "scripts": { + "test": "jest" + }, + "keywords": [ + "frigg", + "netlify", + "serverless", + "adapter" + ], + "license": "MIT", + "dependencies": { + "serverless-http": "^3.2.0", + "express": "^4.18.2" + }, + "devDependencies": { + "jest": "^29.7.0" + }, + "peerDependencies": { + "@friggframework/core": "*", + "@netlify/functions": ">=2.0.0" + }, + "peerDependenciesMeta": { + "@friggframework/core": { + "optional": true + }, + "@netlify/functions": { + "optional": true + } + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/schemas/schemas/app-definition.schema.json b/packages/schemas/schemas/app-definition.schema.json index e7195daa1..ffa7eac6b 100644 --- a/packages/schemas/schemas/app-definition.schema.json +++ b/packages/schemas/schemas/app-definition.schema.json @@ -16,8 +16,8 @@ }, "provider": { "type": "string", - "description": "Cloud provider for deployment", - "enum": ["aws"], + "description": "Deployment provider. Determines which @friggframework/provider-{name} package is loaded for runtime adapters, config generation, and deploy lifecycle. Built-in providers: 'aws' (default, serverless/Lambda), 'netlify' (Netlify Functions).", + "enum": ["aws", "netlify"], "default": "aws" }, "label": { @@ -631,6 +631,61 @@ } }, "additionalProperties": true + }, + "extensions": { + "type": "array", + "description": "Extensions that add custom Prisma models, encrypted fields, admin routes, and bootstrap lifecycle hooks to a Frigg app. Each extension can declare a schema fragment, encryption config, route handler, and bootstrap function.", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "description": "Unique identifier for this extension", + "minLength": 1, + "examples": ["db-credentials", "audit-log"] + }, + "schema": { + "type": "string", + "description": "Absolute path to a .prisma schema fragment. Models/enums from this file are composed into the main Prisma schema at build time.", + "pattern": "\\.prisma$" + }, + "encryption": { + "type": "object", + "description": "Encryption schema additions. Maps model names to arrays of field paths that should be encrypted at rest.", + "patternProperties": { + "^[A-Z][a-zA-Z0-9]*$": { + "type": "object", + "required": ["fields"], + "properties": { + "fields": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "routes": { + "type": "object", + "description": "Express router to mount on the app. The handler factory receives (prisma, appDefinition) and returns an Express Router. Routes are protected by admin auth middleware.", + "required": ["path"], + "properties": { + "path": { + "type": "string", + "description": "Base URL path for the extension routes", + "pattern": "^/", + "examples": ["/api/admin/oauth-credentials"] + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + } } }, "additionalProperties": false, diff --git a/packages/test/package.json b/packages/test/package.json index d15541dca..9816b18e5 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -21,10 +21,10 @@ "@friggframework/prettier-config": "^2.0.0-next.0", "jest": "^29.7.0", "prettier": "^2.7.1", - "supertest": "^6.3.3" + "supertest": "^7.2.2" }, "peerDependencies": { - "supertest": ">=6.0.0" + "supertest": ">=7.0.0" }, "peerDependenciesMeta": { "supertest": { diff --git a/plan.md b/plan.md new file mode 100644 index 000000000..7919dc1fd --- /dev/null +++ b/plan.md @@ -0,0 +1,761 @@ +# Frigg Provider Plugin Architecture + +## Status: What Already Exists + +The codebase already has multi-provider thinking in several places: + +- **Queue system**: `queue-provider-factory.js` switches between `SqsQueueProvider`, `NetlifyBackgroundProvider`, `QStashQueueProvider` based on `QUEUE_PROVIDER` env var or `appDefinition.queue.provider` +- **Scheduler system**: `scheduler-service-factory.js` switches between `EventBridgeSchedulerAdapter`, `NetlifySchedulerAdapter`, `MockSchedulerAdapter` +- **Infrastructure**: `CloudProviderFactory` creates `AWSProviderAdapter` (GCP/Azure are stubs) +- **Netlify provider**: `@friggframework/provider-netlify` — full provider plugin with handler wrappers, config generation, function entry points, deploy/preflight/teardown, validation, and scheduler repository +- **v2 API routes**: Already mapped in netlify config (`/api/v2/*` → auth function). The integration router has all `/api/v2/*` routes alongside v1. + +**The problem**: These pieces don't follow a *consistent plugin shape*. The provider-netlify exports the full plugin interface, but the queue providers and scheduler adapters still live in core. The AWS code is entirely in core. A proper plugin architecture means each provider bundles all of its adapters into one installable package. + +## Design Decisions (Confirmed) + +1. **All providers are separate packages** — including AWS. `@friggframework/core` has zero cloud-specific code. +2. **Database is a separate concern** from compute provider. Separate `@friggframework/database-*` packages. +3. **Database infrastructure is optional convenience** in providers. Most users just set `DATABASE_URL`. +4. **Database runtime** (Prisma schema, client, repos, connections) lives in database packages. + +## Package Map + +``` +@friggframework/core # Interfaces, ports, framework logic. Zero cloud imports. + +# Tier 1 — full implementations +@friggframework/provider-aws # Lambda, SQS, EventBridge, KMS, Secrets Manager, S3 +@friggframework/provider-netlify # Netlify Functions, Background Functions, Scheduled Functions +@friggframework/provider-local # Express, Docker Compose, node-cron, in-memory queues + +# Tier 2 — key capabilities +@friggframework/provider-vercel # Vercel Functions, QStash, Cron +@friggframework/provider-gcp # Cloud Functions v2, Cloud Tasks, Cloud Scheduler, Cloud KMS + +# Tier 3 — stubs for community +@friggframework/provider-azure # Azure Functions, Service Bus, Key Vault +@friggframework/provider-flyio # Fly Machines, fly-replay routing +@friggframework/provider-cloudflare # Workers, Queues, D1, Durable Objects + +# Database (orthogonal to provider) +@friggframework/database-postgres # PostgreSQL Prisma schema, client, repos, migrations +@friggframework/database-mongodb # MongoDB Prisma schema, client, repos, migrations +``` + +### Provider ↔ Database Compatibility + +Any provider works with any database package. Some providers offer **managed database convenience** (auto-provisioned, dynamic URLs): + +| Provider | Managed DB Convenience | Also Works With | +|-------------|-------------------------------|------------------------| +| AWS | Aurora PostgreSQL, DocumentDB | Any (via DATABASE_URL) | +| Netlify | Neon PostgreSQL | Any (via DATABASE_URL) | +| Vercel | Neon, PlanetScale | Any (via DATABASE_URL) | +| GCP | Cloud SQL, AlloyDB | Any (via DATABASE_URL) | +| Azure | Cosmos DB, Azure SQL | Any (via DATABASE_URL) | +| CloudFlare | D1 (SQLite), Hyperdrive | Any (via DATABASE_URL) | +| fly.io | Fly Postgres, LiteFS | Any (via DATABASE_URL) | +| Local | Local PG, Local Mongo | Any (via DATABASE_URL) | + +"Managed DB Convenience" means the provider package includes infrastructure builders for that DB. Everything else is just `DATABASE_URL`. + +--- + +## Provider Plugin Interface + +Every provider package exports an object conforming to this shape. Each property is a **capability** — providers implement the capabilities they support and return `null` for ones they don't. + +```javascript +// @friggframework/provider-{name}/index.js +module.exports = { + name: 'aws', // Unique provider identifier + + // ── Runtime Adapters (used in deployed functions) ────────────── + + // Wrap user's handler function for the platform's runtime + // Lambda: adds secretsToEnv, callbackWaitsForEmptyEventLoop + // Netlify: adds serverless-http bridge + // Vercel: adds edge/serverless adapter + createHandler, // (options) => platform-native handler function + + // Wrap an Express router into a platform-native handler + // (Most providers use serverless-http; some need custom adapters) + createAppHandler, // (name, router, shouldUseDb?) => handler + + // Queue adapter — send async jobs, parse incoming queue events + QueueProvider, // Class extending core QueueProvider + + // Scheduler adapter — one-time scheduled jobs + SchedulerAdapter, // Class extending core SchedulerServiceInterface + + // Encryption adapter — envelope encryption key management + CryptorAdapter, // Class extending core Cryptor (KMS, Vault, AES, etc.) + + // Secrets loader — fetch secrets into process.env at startup + // AWS: Secrets Manager. Vercel/Netlify: env vars (no-op). GCP: Secret Manager. + loadSecrets, // async () => void + + // Function invoker — call another function from within a function + // AWS: Lambda.invoke(). Netlify: HTTP call. Vercel: HTTP call. + invokeFunctionAdapter, // { invoke(functionName, payload) } + + // WebSocket adapter — push messages to connected clients + // AWS: API Gateway Management API. Others: platform-specific or null. + WebSocketAdapter, // Class or null + + // Platform-specific utilities (e.g., Lambda TimeoutCatcher) + utils: {}, + + // ── Build-Time / Infrastructure ──────────────────────────────── + + // Generate platform config from app definition + // AWS: serverless.yml. Netlify: netlify.toml. Vercel: vercel.json. + generateConfig, // (appDefinition, options?) => string | object + + // Generate environment variable template + generateEnvTemplate, // (appDefinition) => { VAR_NAME: 'description' } + + // Infrastructure builders (optional — for managed resources) + // AWS: VPC, Aurora, KMS, SQS, EventBridge builders + // Netlify: minimal (Neon auto-provisioned) + // Local: docker-compose.yml generation + infrastructureBuilders: [], // InfrastructureBuilder[] for builder orchestrator + + // ── Deploy ─────────────────────────────────────────────────────── + + // Deploy the app to this provider + // Called by `frigg deploy`. Provider handles all platform-specific steps. + // Returns { url, functionUrls, logs } or throws with actionable error. + deploy, // async (appDefinition, options?) => DeployResult + + // Pre-deploy checks (permissions, CLI tools, config validity) + preflightCheck, // async () => { ready, missing[] } + // AWS: checks aws-cli, credentials, serverless installed + // Netlify: checks netlify-cli, NETLIFY_AUTH_TOKEN + // Vercel: checks vercel CLI, logged in + // GCP: checks gcloud CLI, project configured + // fly.io: checks flyctl, logged in + // Local: checks docker/docker-compose (if --compose), or just node + + // Tear down / remove deployed resources + teardown, // async (appDefinition, options?) => void + + // Validate app definition for this provider + validate, // (appDefinition) => { valid, errors[], warnings[] } + + // Function entry point templates / generators + // Returns file content for platform-specific function entry points + getFunctionEntryPoints, // (appDefinition) => { [filename]: content } + + // ── Metadata ─────────────────────────────────────────────────── + + // Which database packages this provider has managed convenience for + recommendedDatabases: ['database-postgres'], + + // Default env vars this provider sets automatically + providedEnvVars: ['DATABASE_URL', 'NETLIFY_SITE_ID'], + + // Platform detection — auto-detect if running on this provider + detect, // () => boolean (check env vars like NETLIFY, VERCEL, AWS_LAMBDA_FUNCTION_NAME) +}; +``` + +--- + +## How Existing Code Maps to This Shape + +### Netlify Today → provider-netlify Plugin + +| Plugin Interface Property | Existing Code | Location Today | Status | +|---|---|---|---| +| `name` | — | — | NEW (trivial) | +| `createHandler` | `createNetlifyHandler()` | `provider-netlify/lib/create-netlify-handler.js` | EXISTS | +| `createAppHandler` | `createNetlifyAppHandler()` | `provider-netlify/lib/create-netlify-app-handler.js` | EXISTS | +| `QueueProvider` | `NetlifyBackgroundProvider` | `core/queues/providers/netlify-background-provider.js` | EXISTS in core — move here | +| `SchedulerAdapter` | `NetlifySchedulerAdapter` | `core/infrastructure/scheduler/netlify-scheduler-adapter.js` | EXISTS in core — move here | +| `CryptorAdapter` | AES (default) | `core/encrypt/Cryptor.js` (AES branch) | Reuse core AES, no custom cryptor needed | +| `loadSecrets` | No-op | — | NEW (trivial — Netlify has env vars natively) | +| `invokeFunctionAdapter` | — | — | NEW (HTTP call to function URL) | +| `WebSocketAdapter` | `null` | — | Not supported on Netlify | +| `generateConfig` | `generateNetlifyToml()` | `provider-netlify/lib/generate-netlify-config.js` | EXISTS | +| `generateEnvTemplate` | `generateNetlifyEnvTemplate()` | `provider-netlify/lib/generate-netlify-config.js` | EXISTS | +| `validate` | `validateNetlifyDbConfig()` | `provider-netlify/lib/netlify-db.js` | EXISTS (partial) | +| `getFunctionEntryPoints` | Function files in `functions/` dir | `provider-netlify/functions/*.js` | EXISTS as files, needs to become generator | +| `detect` | — | — | NEW: `() => !!process.env.NETLIFY` | + +**Verdict**: Netlify is ~80% done. Mainly needs: gather scattered pieces into one plugin shape, move queue/scheduler adapters from core. + +### AWS Today → provider-aws Plugin + +| Plugin Interface Property | Existing Code | Location Today | Status | +|---|---|---|---| +| `name` | — | — | NEW (trivial) | +| `createHandler` | `createHandler()` | `core/core/create-handler.js` | EXISTS in core — move here | +| `createAppHandler` | `createAppHandler()` | `core/handlers/app-handler-helpers.js` | EXISTS in core — move here | +| `QueueProvider` | `SqsQueueProvider` | `core/queues/providers/sqs-queue-provider.js` | EXISTS in core — move here | +| `SchedulerAdapter` | `EventBridgeSchedulerAdapter` | `core/infrastructure/scheduler/eventbridge-scheduler-adapter.js` | EXISTS in core — move here | +| `CryptorAdapter` | KMS branch of `Cryptor` | `core/encrypt/Cryptor.js` | EXISTS — extract KMS to own class | +| `loadSecrets` | `secretsToEnv()` | `core/core/secrets-to-env.js` | EXISTS in core — move here | +| `invokeFunctionAdapter` | Lambda invoker | `core/database/adapters/lambda-invoker.js` | EXISTS in core — move here | +| `WebSocketAdapter` | API Gateway Management | `core/database/models/WebsocketConnection.js` | EXISTS in core — move here | +| `utils.TimeoutCatcher` | `TimeoutCatcher` | `core/lambda/TimeoutCatcher.js` | EXISTS in core — move here | +| `generateConfig` | Serverless.yml builders | `devtools/infrastructure/` | EXISTS — complex, many builders | +| `generateEnvTemplate` | — | — | NEW | +| `infrastructureBuilders` | VPC, Aurora, KMS, SQS, EventBridge builders | `devtools/infrastructure/domains/` | EXISTS | +| `validate` | `validateAppDefinition()` | Various | EXISTS (partial) | +| `getFunctionEntryPoints` | Serverless handler configs | Generated by infrastructure builders | EXISTS implicitly | +| `detect` | — | — | NEW: `() => !!process.env.AWS_LAMBDA_FUNCTION_NAME` | + +**Verdict**: AWS has all the code but it's scattered across core. The work is extraction + reorganization. + +### Vercel → provider-vercel Plugin + +| Plugin Interface Property | Implementation Plan | Status | +|---|---|---| +| `name` | `'vercel'` | NEW | +| `createHandler` | Vercel serverless uses standard `(req, res)` — thin wrapper for logging/init | NEW (simple — Vercel functions are already Express-like) | +| `createAppHandler` | `serverless-http(app)` same as Netlify, or native Vercel adapter | NEW (simple) | +| `QueueProvider` | **QStash** — already exists in core as `QStashQueueProvider` | EXISTS — move from core | +| `SchedulerAdapter` | Vercel Cron (`vercel.json` crons) → triggers function → enqueues via QStash | NEW | +| `CryptorAdapter` | AES (reuse core default) — no native KMS | Reuse core AES | +| `loadSecrets` | No-op — Vercel injects env vars natively | NEW (trivial) | +| `invokeFunctionAdapter` | HTTP call to function URL (same pattern as Netlify) | NEW | +| `WebSocketAdapter` | `null` — Vercel doesn't support persistent WebSockets | N/A | +| `generateConfig` | Generate `vercel.json` (routes, crons, function config) | NEW | +| `generateEnvTemplate` | Vercel env var template | NEW | +| `infrastructureBuilders` | Minimal — Vercel manages infra. Optional: Neon/PlanetScale provisioning | NEW (minimal) | +| `validate` | Validate app definition for Vercel constraints (10s/300s timeouts, no WebSocket) | NEW | +| `getFunctionEntryPoints` | Generate `api/*.js` files per Vercel conventions | NEW | +| `detect` | `() => !!process.env.VERCEL` | NEW | + +**Verdict**: QStash queue provider already exists. Vercel functions are Express-compatible so the handler wrapper is thin. Main new work: `vercel.json` generator and cron scheduler adapter. + +**Key Vercel constraints to validate**: +- Serverless function timeout: 10s (Hobby) / 300s (Pro) — affects sync operations +- No persistent WebSockets (Edge functions can do WebSocket upgrade but it's different) +- No background functions — must use QStash for async work +- Cron jobs limited to once/day (Hobby) / once/min (Pro) + +### GCP → provider-gcp Plugin + +| Plugin Interface Property | Implementation Plan | Status | +|---|---|---| +| `name` | `'gcp'` | NEW | +| `createHandler` | Cloud Functions v2 use standard `(req, res)` — wrapper for secrets + init | NEW | +| `createAppHandler` | Direct Express mount — Cloud Functions v2 IS Express under the hood | NEW (simple) | +| `QueueProvider` | **Cloud Tasks** — HTTP-based task queue with scheduling, retries, rate limiting | NEW | +| `SchedulerAdapter` | **Cloud Scheduler** — cron to HTTP endpoint or Pub/Sub topic | NEW | +| `CryptorAdapter` | **Cloud KMS** — envelope encryption like AWS KMS but GCP SDK | NEW | +| `loadSecrets` | **Secret Manager** — `@google-cloud/secret-manager` fetch at cold start | NEW | +| `invokeFunctionAdapter` | HTTP call to Cloud Function URL (or `@google-cloud/functions` SDK) | NEW | +| `WebSocketAdapter` | `null` for Cloud Functions. Could support via Firebase Realtime or Pub/Sub push. | N/A initially | +| `generateConfig` | Option A: Generate Terraform. Option B: Generate `gcloud` deploy scripts. Option C: Firebase `firebase.json` | NEW | +| `generateEnvTemplate` | GCP env var template | NEW | +| `infrastructureBuilders` | Cloud SQL (PostgreSQL), Cloud Tasks queue, Cloud Scheduler jobs, VPC connector | NEW | +| `validate` | Validate for GCP constraints (timeout limits, region availability) | NEW | +| `getFunctionEntryPoints` | Generate `functions/*.js` per Cloud Functions conventions | NEW | +| `detect` | `() => !!process.env.FUNCTION_TARGET \|\| !!process.env.K_SERVICE` | NEW | + +**Verdict**: All new code, but GCP's Cloud Functions v2 are Express-native so handler wrapping is trivial. Cloud Tasks is the natural queue equivalent to SQS. Cloud KMS follows same envelope encryption pattern as AWS KMS. + +**GCP advantages**: +- Cloud Functions v2 ARE Express — `createAppHandler` is almost a no-op +- Cloud Tasks has built-in rate limiting (like SQS) +- Cloud KMS has same envelope encryption pattern as AWS KMS +- Cloud Scheduler is more capable than EventBridge Scheduler (recurring + one-time) + +### Docker/Local → provider-local Plugin + +| Plugin Interface Property | Implementation Plan | Status | +|---|---|---| +| `name` | `'local'` | NEW | +| `createHandler` | No wrapping — runs Express directly via `app.listen()` | NEW | +| `createAppHandler` | Direct Express mount — no serverless adapter needed | NEW (simple) | +| `QueueProvider` | **In-memory queue** with optional **BullMQ** (Redis-backed) for persistence | NEW | +| `SchedulerAdapter` | **node-cron** for recurring jobs, setTimeout for one-time | NEW | +| `CryptorAdapter` | AES (reuse core default) — or `'none'` for dev simplicity | Reuse core AES | +| `loadSecrets` | **dotenv** — load `.env` file | NEW (trivial) | +| `invokeFunctionAdapter` | Direct function call (same process) or HTTP to localhost | NEW | +| `WebSocketAdapter` | **ws** (native WebSocket library) on same Express server | NEW | +| `generateConfig` | Generate `docker-compose.yml` (app + DB + Redis if BullMQ) | NEW | +| `generateEnvTemplate` | `.env.example` file | NEW | +| `infrastructureBuilders` | Docker Compose services: PostgreSQL/MongoDB container, Redis, app | NEW | +| `validate` | Check Docker/Node.js availability | NEW | +| `getFunctionEntryPoints` | Single `server.js` entry point that mounts all routers | NEW | +| `detect` | `() => process.env.FRIGG_PROVIDER === 'local' \|\| (!isCloudEnv())` | NEW | + +**Verdict**: Most important provider for DX. No cloud dependencies. Everything runs in one process (or Docker Compose for DB). Critical for OpenClaw and anyone doing local development. + +**Docker Compose output** (`generateConfig` produces): +```yaml +services: + app: + build: . + ports: ["3000:3000"] + env_file: .env + depends_on: [db] + db: + image: postgres:16 # or mongo:7 + volumes: [db-data:/var/lib/postgresql/data] + environment: + POSTGRES_DB: frigg + POSTGRES_PASSWORD: localdev + # Optional: Redis for BullMQ queue persistence + redis: + image: redis:7-alpine + ports: ["6379:6379"] +``` + +**Local dev modes**: +- `frigg start` — single process, in-memory queue, node-cron, no Docker needed +- `frigg start --compose` — Docker Compose with real DB + Redis +- `frigg start --compose --watch` — above + file watching/hot reload + +### What Core Keeps (Ports/Interfaces) + +``` +packages/core/ +├── queues/ +│ └── queue-provider.js # Abstract base class (send, batchSend, parseEvent) +├── infrastructure/scheduler/ +│ └── scheduler-service-interface.js # Abstract base class (scheduleOneTime, deleteSchedule, getScheduleStatus) +├── encrypt/ +│ └── cryptor-interface.js # Abstract base class (generateDataKey, decryptDataKey, encrypt, decrypt) +├── handlers/ +│ └── handler-interface.js # createHandler contract definition +├── core/ +│ └── secrets-interface.js # loadSecrets contract +│ └── function-invoker-interface.js # invoke contract +│ └── websocket-interface.js # WebSocket adapter contract +│ └── Worker.js # Base Worker (uses injected QueueProvider, no SQS imports) +│ └── Delegate.js # Unchanged +├── integrations/ # Unchanged — provider-agnostic +├── modules/ # Unchanged — provider-agnostic +├── user/ # Unchanged — provider-agnostic +├── credential/ # Unchanged — provider-agnostic +├── database/ +│ ├── encryption/ # Stays — works with any Cryptor adapter +│ ├── config.js # DB_TYPE detection (reads from appDefinition or env) +│ └── prisma.js # Prisma client factory (loads from database package) +└── provider-registry.js # NEW — resolves installed provider at runtime +``` + +### Provider Registry (How Core Finds the Provider) + +```javascript +// packages/core/provider-registry.js +// +// Resolves the installed provider package at runtime. +// Detection order: +// 1. Explicit: appDefinition.provider (e.g., 'aws', 'netlify') +// 2. Environment: FRIGG_PROVIDER env var +// 3. Auto-detect: Call each installed provider's detect() function +// 4. Fallback: 'local' if @friggframework/provider-local is installed + +function resolveProvider(appDefinition) { + // 1. Explicit declaration + const explicit = appDefinition?.provider || process.env.FRIGG_PROVIDER; + if (explicit) { + return requireProvider(explicit); + } + + // 2. Auto-detect from environment + const providerNames = ['aws', 'netlify', 'vercel', 'gcp', 'azure', 'flyio', 'cloudflare']; + for (const name of providerNames) { + try { + const provider = requireProvider(name); + if (provider.detect()) return provider; + } catch (e) { /* not installed */ } + } + + // 3. Fallback to local + return requireProvider('local'); +} + +function requireProvider(name) { + return require(`@friggframework/provider-${name}`); +} +``` + +--- + +## Database Plugin Interface + +Every database package exports: + +```javascript +// @friggframework/database-{type}/index.js +module.exports = { + name: 'postgres', // or 'mongodb' + + // ── Runtime ──────────────────────────────────────────────────── + + // Prisma client factory — returns configured PrismaClient + // Handles: client loading, encryption extension, connection options + createPrismaClient, // (options?) => PrismaClient + + // Connection management + connect, // async () => PrismaClient + disconnect, // async () => void + + // Repository factories — create repo instances for this DB type + repositories: { + createCredentialRepository, + createIntegrationRepository, + createUserRepository, + createEntityRepository, + createIntegrationMappingRepository, + // ... all repository factories + }, + + // Schema initialization (MongoDB needs collection creation; PG uses migrations) + initializeSchema, // async (prismaClient) => void + + // ── Build-Time ───────────────────────────────────────────────── + + // Prisma schema file path (for code generation) + schemaPath, // Absolute path to schema.prisma + + // Migration support + getMigrationCommand, // () => string (e.g., 'npx prisma migrate deploy') + + // ── Metadata ─────────────────────────────────────────────────── + + // Database type identifier (matches DB_TYPE env var values) + dbType: 'postgresql', // 'postgresql' | 'mongodb' | 'documentdb' + + // Required environment variables + requiredEnvVars: ['DATABASE_URL'], + + // Validate database configuration + validate, // (appDefinition) => { valid, errors[], warnings[] } +}; +``` + +### How Core Finds the Database Package + +```javascript +// packages/core/database/database-registry.js +// +// Similar to provider-registry but for database packages. +// Detection order: +// 1. Explicit: appDefinition.database.type (e.g., 'postgres', 'mongodb') +// 2. Environment: DB_TYPE env var +// 3. Auto-detect: Check which @friggframework/database-* is installed + +function resolveDatabase(appDefinition) { + const dbConfig = appDefinition?.database; + + if (dbConfig?.postgres?.enable) return requireDatabase('postgres'); + if (dbConfig?.mongoDB?.enable) return requireDatabase('mongodb'); + // Legacy: documentdb maps to mongodb package with different connection options + if (dbConfig?.documentDB?.enable) return requireDatabase('mongodb'); + + const envType = process.env.DB_TYPE; + if (envType === 'postgresql') return requireDatabase('postgres'); + if (envType === 'mongodb' || envType === 'documentdb') return requireDatabase('mongodb'); + + throw new Error( + '[Frigg] No database package configured. Install @friggframework/database-postgres ' + + 'or @friggframework/database-mongodb and configure in your app definition.' + ); +} + +function requireDatabase(name) { + return require(`@friggframework/database-${name}`); +} +``` + +--- + +## App Definition Changes + +```javascript +// backend/index.js — user's app definition +const Definition = { + name: 'my-frigg-app', + + // NEW: explicit provider (optional — auto-detected if omitted) + provider: 'aws', // 'aws' | 'netlify' | 'vercel' | 'gcp' | 'azure' | 'flyio' | 'cloudflare' | 'local' + + // UNCHANGED: database config + database: { + postgres: { enable: true }, + // OR: mongoDB: { enable: true }, + }, + + // UNCHANGED: integrations + integrations: [MyIntegration], + + // SIMPLIFIED: encryption just declares method + encryption: { + fieldLevelEncryption: 'provider-default', // 'provider-default' | 'aes' | 'none' + // Provider decides: AWS→KMS, others→AES + }, + + // SIMPLIFIED: queue/scheduler are provider concerns + // (Provider chooses its own queue/scheduler implementation) + // Users CAN override if they want a different queue on a provider: + queue: { + provider: 'qstash', // Override: use QStash instead of provider default + }, +}; +``` + +--- + +## What Moves Where (File-Level) + +### Files That Move to `@friggframework/provider-aws` + +| Current Location | What It Does | +|---|---| +| `core/core/create-handler.js` | Lambda handler wrapper (secretsToEnv, callbackWaitsForEmptyEventLoop) | +| `core/core/secrets-to-env.js` | AWS Secrets Manager → process.env | +| `core/core/Worker.js` | SQS client hardcoded in base class → extract SQS parts | +| `core/queues/providers/sqs-queue-provider.js` | SQS queue adapter | +| `core/infrastructure/scheduler/eventbridge-scheduler-adapter.js` | EventBridge scheduler | +| `core/encrypt/Cryptor.js` (KMS branch) | AWS KMS envelope encryption | +| `core/lambda/TimeoutCatcher.js` | Lambda timeout detection | +| `core/database/repositories/migration-status-repository-s3.js` | S3 migration state storage | +| `core/database/adapters/lambda-invoker.js` | Lambda function invocation | +| `core/database/models/WebsocketConnection.js` (API Gateway part) | API Gateway WebSocket | +| `devtools/infrastructure/domains/` (all AWS builders) | CloudFormation generation | +| `packages/serverless-plugin/` | Serverless framework plugin | + +### Files That Move to `@friggframework/provider-netlify` + +| Current Location | What It Does | +|---|---| +| `devtools/provider-netlify/lib/create-netlify-handler.js` | Netlify handler wrapper | +| `devtools/provider-netlify/lib/create-netlify-app-handler.js` | Express→Netlify bridge | +| `devtools/provider-netlify/lib/generate-netlify-config.js` | netlify.toml generator | +| `devtools/provider-netlify/lib/netlify-db.js` | Config validation | +| `devtools/provider-netlify/functions/` | Function entry points | +| `core/queues/providers/netlify-background-provider.js` | Netlify Background queue | +| `core/infrastructure/scheduler/netlify-scheduler-adapter.js` | Netlify scheduler | + +### Files That Move to `@friggframework/database-postgres` + +| Current Location | What It Does | +|---|---| +| `core/prisma-postgresql/schema.prisma` | PostgreSQL Prisma schema | +| All `*-repository-postgres.js` files | PostgreSQL repo implementations | +| PostgreSQL-specific migration scripts | Schema migrations | + +### Files That Move to `@friggframework/database-mongodb` + +| Current Location | What It Does | +|---|---| +| `core/prisma-mongodb/schema.prisma` | MongoDB Prisma schema | +| All `*-repository-mongo.js` files | MongoDB repo implementations | +| `core/database/utils/mongodb-schema-init.js` | Collection initialization | +| `core/database/mongoose-models/` (if any remain) | Mongoose compatibility | + +### Files That Stay in `@friggframework/core` + +| File/Directory | Why It Stays | +|---|---| +| `core/integrations/` | Provider-agnostic integration lifecycle | +| `core/modules/` | API module system (HubSpot, Salesforce, etc.) | +| `core/user/` | User management | +| `core/credential/` | Credential storage | +| `core/syncs/` | Sync orchestration | +| `core/errors/` | Error types | +| `core/logs/` | Logging | +| `core/handlers/routers/` | Express routers (provider-agnostic) | +| `core/queues/queue-provider.js` | Queue interface (port) | +| `core/infrastructure/scheduler/scheduler-service-interface.js` | Scheduler interface (port) | +| `core/database/encryption/` | Encryption extension (works with any Cryptor) | +| `core/database/config.js` | DB type detection | +| `core/database/prisma.js` | Prisma client factory (delegates to database package) | +| All `*-repository-factory.js` files | Factory pattern (delegates to database package) | +| All `*-repository-interface.js` / base classes | Repository contracts | +| `core/core/Delegate.js` | Provider-agnostic delegation | + +### Files That Need Refactoring (Mixed Concerns) + +| File | Issue | Action | +|---|---|---| +| `core/encrypt/Cryptor.js` | KMS + AES in one class | Split: KMS → provider-aws, AES → core (default), interface → core | +| `core/core/Worker.js` | SQS client hardcoded | Inject QueueProvider instead of hardcoding SQS | +| `core/application/commands/scheduler-commands.js` | `deriveArnFromQueueUrl()` assumes SQS | Make queue ID format provider-agnostic | +| `core/queues/queue-provider-factory.js` | Hardcoded switch on provider names | Use provider registry instead | +| `core/infrastructure/scheduler/scheduler-service-factory.js` | Hardcoded switch on provider names | Use provider registry instead | + +--- + +## Implementation Phases + +### Phase 1: Define Interfaces in Core (Non-Breaking) + +Add interface files alongside existing code. No moves yet. Existing code continues to work. + +- Create `core/provider-registry.js` with `resolveProvider()` +- Create `core/database/database-registry.js` with `resolveDatabase()` +- Create interface files: `cryptor-interface.js`, `handler-interface.js`, `secrets-interface.js`, `function-invoker-interface.js`, `websocket-interface.js` +- Create `core/encrypt/aes-cryptor.js` (extract AES logic from Cryptor.js) +- Refactor `Worker.js` to accept injected QueueProvider +- Refactor `scheduler-commands.js` to be queue-format-agnostic + +### Phase 2: Create provider-aws Package (Extract, Don't Rewrite) + +Move AWS-specific files to `@friggframework/provider-aws`. Core imports from the provider via the registry. Add `@friggframework/provider-aws` as a dependency of apps (not core). + +- Move all files listed in "Files That Move to provider-aws" table +- Wire up provider-registry so existing apps work with `provider: 'aws'` or auto-detect +- Ensure `core` has no `@aws-sdk/*` imports after this phase +- All existing tests pass with provider-aws installed + +### Phase 3: Create provider-netlify Package (Extract Existing Work) + +Move Netlify-specific queue/scheduler code from core into provider-netlify. + +- Move all files listed in "Files That Move to provider-netlify" table +- Implement full provider interface shape + +### Phase 4: Create Database Packages (Extract) + +Split database runtime into `database-postgres` and `database-mongodb`. + +- Move Prisma schemas, repo implementations, migration scripts +- Refactor `prisma.js` to delegate to installed database package +- Refactor all repository factories to delegate to database package +- All existing tests pass with appropriate database package installed + +### Phase 5: Create provider-local Package + +Docker Compose development experience. Express server, in-memory queues, node-cron, mock encryption. Critical for OpenClaw. + +### Phase 6: Create provider-vercel and provider-gcp + +Vercel gets QStash (already exists) + vercel.json generator + cron scheduler. +GCP gets Cloud Tasks queue + Cloud Scheduler + Cloud KMS cryptor + gcloud deploy. + +### Phase 7: Stub Remaining Providers + +Create package shells with `detect()` + `validate()` + `preflightCheck()` + `generateConfig()` for Azure, fly.io, CloudFlare. Community can flesh these out. + +### Phase 8: CLI Updates + +Update `frigg init` to ask which provider and database. Update `frigg deploy` to delegate to provider's `deploy()` + `preflightCheck()`. + +--- + +## Deploy Story Per Provider + +Each provider's `deploy()` wraps a different underlying mechanism. `frigg deploy` becomes the universal command: + +```bash +frigg deploy # Uses provider from app definition +frigg deploy --stage production # Stage-specific deploy +frigg deploy --dry-run # Show what would happen +``` + +Under the hood: + +| Provider | `preflightCheck()` requires | `deploy()` runs | Config file | +|---|---|---|---| +| **AWS** | `aws` CLI, credentials, `serverless` npm | `serverless deploy --stage X` | `serverless.yml` (generated) | +| **Netlify** | `netlify` CLI or `NETLIFY_AUTH_TOKEN` | `netlify deploy --prod` (or git-push trigger) | `netlify.toml` (generated) | +| **Vercel** | `vercel` CLI or `VERCEL_TOKEN` | `vercel --prod` (or git-push trigger) | `vercel.json` (generated) | +| **GCP** | `gcloud` CLI, project configured | `gcloud functions deploy` per function | `cloudfunctions.yaml` or Terraform | +| **Azure** | `az` CLI, subscription | `az functionapp deployment` | ARM/Bicep template | +| **fly.io** | `flyctl`, logged in | `flyctl deploy` | `fly.toml` + `Dockerfile` (generated) | +| **CloudFlare** | `wrangler` CLI | `wrangler deploy` | `wrangler.toml` (generated) | +| **Local** | `docker` (if `--compose`), or just `node` | `docker compose up -d` or `node server.js` | `docker-compose.yml` (generated) | + +**Git-push deploys**: Netlify, Vercel, and CloudFlare also support deploy-on-push — `frigg deploy` is for manual/CI deploys. The generated config files (`netlify.toml`, `vercel.json`, `wrangler.toml`) enable git-push deploys automatically. + +## Difficulty Assessment for Tier 3 Stubs + +| Provider | Difficulty | Estimate | Notes | +|---|---|---|---| +| **fly.io** | Easy | Days | Just Docker containers. Express runs natively. Fly Postgres is managed PG. `fly.toml` + Dockerfile generation is straightforward. Closest to provider-local. | +| **Azure** | Medium | Weeks | Azure Functions are Express-like (easy handler). Service Bus ≈ SQS (familiar pattern). But Azure SDK is verbose, Key Vault API differs from KMS, and deployment is complex (ARM/Bicep templates). | +| **CloudFlare** | Hard | Significant | **Different runtime** — V8 isolates, not Node.js. No `require()`, no `fs`, limited npm compat. Express doesn't work — need Hono or itty-router. D1 is SQLite, not PG/Mongo. Most architecturally different provider. | + +**CloudFlare caveat**: May push the question of whether the provider interface needs to accommodate non-Express runtimes. The current interface assumes Express routers (`createAppHandler` wraps Express). CloudFlare Workers can't use Express. Options: +1. CloudFlare provider translates Express routes to Hono routes at build time +2. Provider interface adds optional `routerAdapter` for non-Express platforms +3. Accept CloudFlare as a "different beast" that only implements a subset of the interface + +Recommend deferring this decision until someone actually needs CloudFlare support. The interface should note it as a known edge case but not over-engineer for it now. + +--- + +## User Experience After Migration + +### New Project Setup +```bash +npm init frigg my-app +# Prompts: Which provider? → aws +# Prompts: Which database? → postgres +# Installs: @friggframework/core, @friggframework/provider-aws, @friggframework/database-postgres +``` + +### Existing AWS Project Migration +```bash +# 1. Install provider + database packages +npm install @friggframework/provider-aws @friggframework/database-postgres + +# 2. Add to app definition +const Definition = { + provider: 'aws', // optional if auto-detect works + database: { postgres: { enable: true } }, + // ... rest unchanged +}; + +# 3. That's it. Everything else works the same. +``` + +### Switching Providers +```bash +# Switch from AWS to Netlify +npm uninstall @friggframework/provider-aws +npm install @friggframework/provider-netlify + +# Update app definition +const Definition = { + provider: 'netlify', + // ... rest unchanged (integrations, database, encryption all portable) +}; +``` + +--- + +## Cross-Provider Capabilities + +Some capabilities are platform-agnostic and work across providers: + +| Capability | Package | Works With | +|---|---|---| +| **QStash queue** | Already in core (`qstash-queue-provider.js`) | Any provider (HTTP-based) | +| **AES encryption** | Stays in core (`Cryptor.js` AES branch) | Any provider (no cloud dependency) | +| **Mock scheduler** | Stays in core (`mock-scheduler-adapter.js`) | Local dev on any provider | + +**Design implication**: The `queue.provider` override in app definition lets users pick QStash on *any* provider. Provider plugins supply their default queue implementation, but it's not locked. Same for encryption — AES is always available; KMS is an AWS provider bonus. + +## Provider Tiers + +### Tier 1 — Built and maintained (packages exist with full implementations) +- **AWS** — Lambda, SQS, EventBridge, KMS, Secrets Manager (extract from core) +- **Netlify** — Functions, Background Functions, Scheduled Functions (~80% done) +- **Docker/Local** — Express, in-memory queues, node-cron, dotenv (essential for DX + OpenClaw) + +### Tier 2 — Built with community help (packages exist, key capabilities implemented) +- **Vercel** — Serverless Functions, QStash (already exists!), Cron +- **GCP** — Cloud Functions v2, Cloud Tasks, Cloud Scheduler, Cloud KMS + +### Tier 3 — Stub packages (detect + validate + generateConfig, community fleshes out) +- **Azure** — Azure Functions, Service Bus, Logic Apps, Key Vault +- **CloudFlare** — Workers, Queues, D1, Durable Objects +- **fly.io** — Fly Machines, fly-replay routing, Fly Postgres + +### Tier 4 — Future (no packages yet, but interface accommodates them) +- **Railway** — Container-based, managed Postgres, cron jobs +- **Render** — Similar to Railway, background workers, cron +- **DigitalOcean App Platform** — Functions + managed DB +- **Supabase** — Edge Functions + Postgres (they ARE the database) +- **Deno Deploy** — Deno-native serverless