Skip to content

feat(api-service): resolve auth from bridge resolver #10663

Open
scopsy wants to merge 13 commits intonextfrom
cursor/application-security-review-023c
Open

feat(api-service): resolve auth from bridge resolver #10663
scopsy wants to merge 13 commits intonextfrom
cursor/application-security-review-023c

Conversation

@scopsy
Copy link
Copy Markdown
Contributor

@scopsy scopsy commented Apr 11, 2026

What changed? Why was the change needed?

The Novu bridge client (NovuBridgeClient) was updated to enforce framework-level signature validation for incoming requests. This is achieved by resolving the target environment's decrypted API secret key and passing it to the framework client with strictAuthentication: true.

This change was needed to ensure that all requests to the Novu bridge endpoint are properly authenticated and signed using the environment's API key, addressing a previously identified unauthenticated execution path.

What changed

Novu Bridge now enforces environment-scoped signature validation: the NovuBridgeClient resolves the target environment’s decrypted API secret key at request time (with an in-memory LRU cache) and passes it to the framework Client with strictAuthentication: true. This forces incoming bridge requests to be signed with the environment’s API key, closing a previously unauthenticated execution path.

Affected areas

api — apps/api: NovuBridgeClient (environments-v1) now validates environmentId early, resolves and caches per-environment secret keys via GetDecryptedSecretKey and InMemoryLRUCacheService, and constructs the framework Client with the resolved secret and strictAuthentication enabled.
api (configs/tests) — apps/api: removed the NODE_OPTIONS test flag from Mocha config and adjusted test npm scripts (test script/config cleanup only).
libs/application-generic — added InMemoryLRUCacheService injection to UpsertPreferences and added cache invalidation for workflow preferences when relevant.

Key technical decisions

  • Secrets are resolved per-request and cached in InMemoryLRUCacheStore.VALIDATOR to maintain tenant isolation while minimizing repeated decryption.
  • The framework Client uses environment-specific secretKey and strictAuthentication: true to reuse framework signature verification and eliminate internal permissive keys.
  • Error handling maps missing/blank environmentId → 400, secret not found → 404, and other secret resolution failures → 500 for clearer API semantics.

Testing

No new tests were added in this change; existing API/bridge e2e tests and manual verification are expected to validate that worker and dashboard preview flows continue to generate valid environment-specific bridge signatures.

Screenshots

Expand for optional sections

Related enterprise PR

Special notes for your reviewer

This change leverages the existing framework authentication mechanism. Both the worker and dashboard preview flows already generate bridge request signatures using the correct environment-specific secret keys, ensuring compatibility with this update.

Slack Thread

Open in Web Open in Cursor 

Co-authored-by: Dima Grossman <dima@grossman.io>
@netlify
Copy link
Copy Markdown

netlify bot commented Apr 11, 2026

Deploy Preview for dashboard-v2-novu-staging canceled.

Name Link
🔨 Latest commit c83d73e
🔍 Latest deploy log https://app.netlify.com/projects/dashboard-v2-novu-staging/deploys/69dbb73c5c4c12000862073b

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 11, 2026

Hey there and thank you for opening this pull request! 👋

We require pull request titles to follow specific formatting rules and it looks like your proposed title needs to be adjusted.

Your PR title is: feat(api-service): resolve auth from bridge resolver

Requirements:

  1. Follow the Conventional Commits specification
  2. As a team member, include Linear ticket ID at the end: fixes TICKET-ID or include it in your branch name

Expected format: feat(scope): Add fancy new feature fixes NOV-123

Details:

PR title must end with 'fixes TICKET-ID' (e.g., 'fixes NOV-123') or include ticket ID in branch name

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 11, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds environmentId validation and per-environment secret-key resolution (cache lookup with fallback to decryption) in NovuBridgeClient.handleRequest, returns 400/404/500 on failure paths, initializes the Novu client with the resolved secretKey (strictAuthentication: true), and constructs NovuRequestHandler with that client.

Changes

Cohort / File(s) Summary
NovuBridgeClient Security & Auth
apps/api/src/app/environments-v1/novu-bridge-client.ts
Constructor now injects GetDecryptedSecretKey and InMemoryLRUCacheService. handleRequest validates environmentId, resolves secretKey via inMemoryLRUCacheService.get(...) (cache key bridge-secret-key:${environmentId}) with fallback to GetDecryptedSecretKey.execute(...), adds 400/404/500 branches and logging, and initializes Client with the resolved secretKey and strictAuthentication: true before creating NovuRequestHandler.
Preferences cache invalidation
libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts
Injected InMemoryLRUCacheService, capture upsert result, and always invalidate workflow preferences cache when applicable using store WORKFLOW_PREFERENCES and key ${environmentId}:${templateId}.
Test config
apps/api/.mocharc.json
Removed "node-option": ["no-experimental-strip-types"] entry from Mocha config.
Test scripts
apps/api/package.json
Removed NODE_OPTIONS=--no-experimental-strip-types from test scripts; adjusted shard test script invocation.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant NovuBridgeClient
    participant InMemoryLRUCacheService
    participant GetDecryptedSecretKey
    participant NovuClient
    participant NovuRequestHandler

    Caller->>NovuBridgeClient: handleRequest(req with environmentId)
    NovuBridgeClient->>NovuBridgeClient: validate environmentId (400 if missing)
    NovuBridgeClient->>InMemoryLRUCacheService: get(VALIDATOR, "bridge-secret-key:${environmentId}", ...)
    alt cache hit (valid string)
        InMemoryLRUCacheService-->>NovuBridgeClient: secretKey
    else cache miss / invalid
        NovuBridgeClient->>GetDecryptedSecretKey: execute(GetDecryptedSecretKeyCommand{environmentId})
        alt decrypted successfully
            GetDecryptedSecretKey-->>InMemoryLRUCacheService: secretKey (cache)
            InMemoryLRUCacheService-->>NovuBridgeClient: secretKey
        else NotFoundException
            GetDecryptedSecretKey-->>NovuBridgeClient: NotFound
        else other error
            GetDecryptedSecretKey-->>NovuBridgeClient: error
        end
    end
    alt secret resolution error (NotFound)
        NovuBridgeClient-->>Caller: 404
    else other resolution error or invalid secret
        NovuBridgeClient-->>Caller: 500
    else resolved
        NovuBridgeClient->>NovuClient: initialize(secretKey, strictAuthentication: true)
        NovuClient-->>NovuBridgeClient: client instance
        NovuBridgeClient->>NovuRequestHandler: construct with client and workflow
        NovuRequestHandler-->>NovuBridgeClient: handler ready
        NovuBridgeClient-->>Caller: response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • scopsy
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title does not follow Conventional Commits format; 'api-service' is not a valid scope (valid scopes are: api, dashboard, worker, ws, webhook, shared, dal, application-generic, js, react, react-native, nextjs, providers, framework, root). Change the scope to 'api' instead of 'api-service' to match the required valid scopes list, resulting in: 'feat(api): resolve auth from bridge resolver'
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@scopsy scopsy changed the title Application security review feat(api-service): resolve auth from bridge resolver Apr 11, 2026
Co-authored-by: Dima Grossman <dima@grossman.io>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/api/src/app/environments-v1/novu-bridge-client.ts`:
- Around line 57-71: Validate that req.params.environmentId is present and
non-empty before using it (environmentId), then call inMemoryLRUCacheService.get
as before but remove the unsafe "as string" cast and check the returned value
from getDecryptedSecretKey.execute (invoked via GetDecryptedSecretKeyCommand)
for null/undefined; if the secret is missing or retrieval throws, log the error
and return/throw an appropriate HTTP error (400/404 for missing environmentId or
secret, 500 for retrieval failures) instead of constructing the Client with a
falsy secret. Ensure errors include contextual details and reference
InMemoryLRUCacheStore.VALIDATOR and the cache key
`bridge-secret-key:${environmentId}` in messages to aid debugging.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7cac9e7a-08a4-48d7-b6ab-fee064014715

📥 Commits

Reviewing files that changed from the base of the PR and between e7207a5 and 1dff313.

📒 Files selected for processing (1)
  • apps/api/src/app/environments-v1/novu-bridge-client.ts

@scopsy
Copy link
Copy Markdown
Contributor Author

scopsy commented Apr 11, 2026

@cursoragent fix review comments

@cursor
Copy link
Copy Markdown
Contributor

cursor bot commented Apr 11, 2026

Here’s what I changed to address the CodeRabbit review.

Updates in novu-bridge-client.ts

  1. environmentId — Read once from req.params and reject missing or blank values with 400 before ConstructFrameworkWorkflow or cache usage (so POST paths don’t run without a valid id).

  2. Secret resolution — Removed the as string cast. Wrapped inMemoryLRUCacheService.get in try/catch:

    • NotFoundException (from GetDecryptedSecretKey when the environment is missing) → 404 + warn log including InMemoryLRUCacheStore.VALIDATOR and cache key bridge-secret-key:${environmentId}.
    • Non-string or empty/whitespace secret500 + error log with the same context.
    • Other errors500 + logger.error({ err }, ...) with store name and cache key.
  3. Logging — Used Nest’s Logger on the class (no module changes).

pnpm exec tsc --noEmit -p tsconfig.build.json in apps/api passes.

Commit: 3f173d241dfix(api): validate bridge environmentId and secret key resolution
PR: #10663 (updated against next)

Note: GitHub’s title linter still wants feat(api) instead of feat(api-service) and a Linear id (fixes NOV-…) if you want that check green; that’s separate from the CodeRabbit item above. I can align the PR title if you want that fixed too.

Open in Web Open in Cursor 

- Reject missing/blank environmentId with 400 before workflow or cache work
- Resolve secret without unsafe cast; treat empty result as 500 with context
- Map NotFoundException from GetDecryptedSecretKey to 404; log other errors with
  InMemoryLRUCacheStore.VALIDATOR and bridge-secret-key cache key for debugging

Co-authored-by: Dima Grossman <dima@grossman.io>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
apps/api/src/app/environments-v1/novu-bridge-client.ts (3)

69-87: Note: Cached secrets remain valid for up to 1 hour after rotation.

Per the VALIDATOR store configuration, the cache TTL is 1 hour. If an environment's secret key is rotated, the old (now invalid) secret may continue to be served from cache, causing HMAC validation to fail for up to 60 minutes.

Consider whether this is acceptable for your secret rotation strategy, or if you need to add cache invalidation on secret rotation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/environments-v1/novu-bridge-client.ts` around lines 69 - 87,
Cached secrets are currently fetched via inMemoryLRUCacheService.get using
InMemoryLRUCacheStore.VALIDATOR (cacheKey =
`bridge-secret-key:${environmentId}`) which has a 1-hour TTL, so rotated secrets
may remain valid in cache for up to 60 minutes; to fix, either (a) call the
cache invalidation method for that store/key when secret rotation occurs
(invalidate/remove `bridge-secret-key:${environmentId}` from
InMemoryLRUCacheStore.VALIDATOR), or (b) reduce the TTL for this cache entry or
use a different cacheVariant so rotation takes effect immediately; update the
rotation flow that calls getDecryptedSecretKey.execute /
GetDecryptedSecretKeyCommand to perform the chosen invalidation or TTL change.

1-1: Use PinoLogger from @novu/application-generic instead of Logger from @nestjs/common.

The static analysis indicates the project standardizes on PinoLogger for consistent structured logging across the API. This provides better observability with JSON output and correlation IDs.

♻️ Proposed fix
-import { Inject, Logger, NotFoundException } from '@nestjs/common';
+import { Inject, NotFoundException } from '@nestjs/common';
 import {
   GetDecryptedSecretKey,
   GetDecryptedSecretKeyCommand,
   InMemoryLRUCacheService,
   InMemoryLRUCacheStore,
+  PinoLogger,
 } from '@novu/application-generic';

Then update the logger initialization:

 export class NovuBridgeClient {
-  private readonly logger = new Logger(NovuBridgeClient.name);
+  constructor(
+    `@Inject`(NovuHandler) private novuHandler: NovuHandler,
+    private constructFrameworkWorkflow: ConstructFrameworkWorkflow,
+    private getDecryptedSecretKey: GetDecryptedSecretKey,
+    private inMemoryLRUCacheService: InMemoryLRUCacheService,
+    private logger: PinoLogger
+  ) {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/environments-v1/novu-bridge-client.ts` at line 1, Replace
the use of Nest's Logger with the project's PinoLogger: change the import to
pull PinoLogger from '@novu/application-generic' instead of Logger from
'@nestjs/common', update any constructor or property injection that currently
types or injects Logger to use PinoLogger, and ensure any Logger.log/debug/error
calls on the NovuBridge client (e.g., in the class that references Logger)
continue to work with PinoLogger methods and structured JSON output.

89-99: Consider reducing internal details in error responses.

The error response includes internal implementation details like store names and cache keys. While useful for debugging, this could provide unnecessary information to potential attackers in production.

♻️ Suggested: Use generic client-facing message, keep details in logs
       if (typeof resolved !== 'string' || !resolved.trim()) {
         this.logger.error(
           `Bridge secret key missing or invalid after cache lookup (store=${storeName}, cacheKey=${cacheKey}, environmentId=${environmentId})`
         );
         res.status(500).json({
           error: 'Failed to resolve environment secret key',
-          details: `Empty or invalid secret from ${storeName} for cache key ${cacheKey}.`,
+          details: 'Internal configuration error. Please contact support if this persists.',
         });

         return;
       }

Apply similar changes to other error responses at lines 107-110 and 119-122.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/environments-v1/novu-bridge-client.ts` around lines 89 - 99,
The response currently leaks internal details (storeName, cacheKey,
environmentId) to the client; update the handler so res.status(500).json returns
a generic client-facing message like { error: 'Failed to resolve environment
secret key' } while moving the detailed context into this.logger.error (include
storeName, cacheKey, environmentId and any error/variable values like resolved)
for diagnostics; apply the same change pattern to the other error responses
referenced (the blocks around the conditions at lines 107-110 and 119-122) so
logs keep internals but HTTP responses remain generic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/api/src/app/environments-v1/novu-bridge-client.ts`:
- Around line 69-87: Cached secrets are currently fetched via
inMemoryLRUCacheService.get using InMemoryLRUCacheStore.VALIDATOR (cacheKey =
`bridge-secret-key:${environmentId}`) which has a 1-hour TTL, so rotated secrets
may remain valid in cache for up to 60 minutes; to fix, either (a) call the
cache invalidation method for that store/key when secret rotation occurs
(invalidate/remove `bridge-secret-key:${environmentId}` from
InMemoryLRUCacheStore.VALIDATOR), or (b) reduce the TTL for this cache entry or
use a different cacheVariant so rotation takes effect immediately; update the
rotation flow that calls getDecryptedSecretKey.execute /
GetDecryptedSecretKeyCommand to perform the chosen invalidation or TTL change.
- Line 1: Replace the use of Nest's Logger with the project's PinoLogger: change
the import to pull PinoLogger from '@novu/application-generic' instead of Logger
from '@nestjs/common', update any constructor or property injection that
currently types or injects Logger to use PinoLogger, and ensure any
Logger.log/debug/error calls on the NovuBridge client (e.g., in the class that
references Logger) continue to work with PinoLogger methods and structured JSON
output.
- Around line 89-99: The response currently leaks internal details (storeName,
cacheKey, environmentId) to the client; update the handler so
res.status(500).json returns a generic client-facing message like { error:
'Failed to resolve environment secret key' } while moving the detailed context
into this.logger.error (include storeName, cacheKey, environmentId and any
error/variable values like resolved) for diagnostics; apply the same change
pattern to the other error responses referenced (the blocks around the
conditions at lines 107-110 and 119-122) so logs keep internals but HTTP
responses remain generic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ce1dcbc5-e3b3-45bf-b483-b22ae3f05056

📥 Commits

Reviewing files that changed from the base of the PR and between 1dff313 and 3f173d2.

📒 Files selected for processing (1)
  • apps/api/src/app/environments-v1/novu-bridge-client.ts

Node disallows --no-experimental-strip-types in NODE_OPTIONS, which caused
pnpm test / npx mocha to exit before running specs. Remove it from api test
scripts and .mocharc.json; ts-node still transpiles via TS_NODE_TRANSPILE_ONLY.

Co-authored-by: Dima Grossman <dima@grossman.io>
GetPreferences can use InMemoryLRUCacheStore.WORKFLOW_PREFERENCES for
WORKFLOW_RESOURCE + USER_WORKFLOW rows. UpsertPreferences wrote to Mongo
without clearing that cache, so subsequent reads could return stale data and
fail tests/CI when preference-fetch optimization is enabled.

Co-authored-by: Dima Grossman <dima@grossman.io>
…ler types)

- Replace Nest Logger with injected PinoLogger per API lint rules
- Import SharedModule in NovuBridgeModule so LoggerModule/PinoLogger resolves
- Cast framework createHandler to Express req/res without explicit any

Co-authored-by: Dima Grossman <dima@grossman.io>
- Remove unused SENSITIVE_KEYS from payload-sanitizer (Biome error in nx lint)
- Replace require() with dynamic import in get-portal-link e2e for ESM compatibility

Co-authored-by: Dima Grossman <dima@grossman.io>
GitHub Actions sets CI=true and NODE_ENV=test; loading newrelic without
NEW_RELIC_APP_NAME throws at import time and fails @novu/api-service tests.
Use a noop agent in that environment for metrics and cron modules, matching
clickhouse-batch.service.ts.

Co-authored-by: Dima Grossman <dima@grossman.io>
in-memory-provider/redis-provider used a static newrelic import, which
still loaded the agent without NEW_RELIC_APP_NAME and failed CI. Use
loadNewRelicOrNoopInCiTest and extend the noop type for optional
instrumentDatastore.

Co-authored-by: Dima Grossman <dima@grossman.io>
require("newrelic") throws during module init when NEW_RELIC_APP_NAME is
unset, so the decorator try/catch never caught it. Use
loadNewRelicOrNoopInCiTest and add startSegment to the noop agent.

Co-authored-by: Dima Grossman <dima@grossman.io>
Node 22 can execute .ts via experimental type-stripping before ts-node,
which fails on decorator syntax. Spawn mocha with --no-experimental-strip-types
only when Node major >= 22 so local Node 20 stays valid. Apply the same flag
to the novu-v2 e2e shard runner.

Co-authored-by: Dima Grossman <dima@grossman.io>
@scopsy
Copy link
Copy Markdown
Contributor Author

scopsy commented Apr 12, 2026

@cursoragent revery any change that is not relevat to the original issue, there should be no changes in test suite or setup, etc...

@cursor
Copy link
Copy Markdown
Contributor

cursor bot commented Apr 12, 2026

Here is what I did for @scopsy’s request to drop everything that is not part of the original bridge-auth work.

Reverted / removed (no longer in the PR vs next)

  • API test / tooling: apps/api/package.json test scripts, apps/api/.mocharc.json, apps/api/scripts/run-mocha-unit.cjs, apps/api/scripts/run-novu-v2-e2e-shard.cjs
  • E2E / misc API: apps/api/src/app/billing/e2e/get-portal-link.e2e-ee.ts, apps/api/src/utils/payload-sanitizer.ts
  • Application-generic: UpsertPreferences cache invalidation, all load-newrelic-ci-test-safe usage (file deleted), metrics.service, cron.service, redis-provider, instrumentation.decorator

Kept (original issue only)

  • apps/api/src/app/environments-v1/novu-bridge-client.ts
  • apps/api/src/app/environments-v1/novu-bridge.module.ts

Sync with next

I merged current origin/next into this branch so the PR diff is not polluted by commits that already landed on next. After that, git diff origin/next HEAD is exactly those two files (~90 lines on the client, +SharedModule on the module).

Pushed as c83d73e17d (merge + revert commit 3c98023744). PR #10663 is updated.

Heads-up: Stripping the CI/test and New Relic work may bring back the GitHub Actions failures you saw earlier (native TS stripping on Node 22, New Relic in tests). If you want CI green and a minimal product diff, that usually needs a separate follow-up PR or workflow/env change rather than more files on this one.

Open in Web Open in Cursor 

cursoragent and others added 2 commits April 12, 2026 15:13
Restore api test scripts, e2e, payload-sanitizer, preferences cache,
and application-generic New Relic shims to match next. Removes
run-mocha-unit.cjs and load-newrelic-ci-test-safe.ts per review scope.

Co-authored-by: Dima Grossman <dima@grossman.io>
Co-authored-by: Dima Grossman <dima@grossman.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants