Skip to content

integrations gcs and s3#403

Open
lindesvard wants to merge 8 commits into
mainfrom
feature/integrations-s3
Open

integrations gcs and s3#403
lindesvard wants to merge 8 commits into
mainfrom
feature/integrations-s3

Conversation

@lindesvard

@lindesvard lindesvard commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Added project-scoped integrations pages, tabs, and navigation.
    • Added S3/GCS export integrations with connection testing and export configuration.
    • Introduced export cursor tracking and a scheduled “flush exports” job.
    • Added at-rest encryption for stored secrets and export credentials.
  • Bug Fixes

    • Updated integration/redirect and listing flows to consistently use project context.
    • Improved notification/rule validation and integration dispatch behavior.
  • Chores

    • Updated environment examples and dependency declarations.
    • Added SSRF safety checks and encryption/integration validation tests.

Export a project's analytics events to the user's own S3 or GCS bucket.

ClickHouse is the single source of truth for the export — there is no Redis
buffer and no per-event ingestion hook, so a stalled/failing bucket can never
touch the ingestion path (in either the Kafka or GroupMQ mode).

How it works:
- A new `inserted_at DateTime64(3)` column on the events table records ingestion
  time. It DEFAULTs to `created_at` (deterministic for pre-migration parts — a
  DEFAULT now() would be evaluated lazily at read time and never settle) and is
  set explicitly at insert time (createEvent + the import production-move), so
  backdated events (server-side, offline, imports) still get a real insert time.
  A minmax skip index keeps the windowed scan cheap since inserted_at isn't in
  the primary key.
- The flushExports cron job (every 60s) windows the events table by inserted_at
  for each (project, integration), batches rows into gzipped JSONL + a manifest,
  and uploads them. A per-(project, integration) ExportWatermark (Postgres) tracks
  progress with a composite (inserted_at, id) cursor — the id tie-breaker is
  required because an import stamps one inserted_at across its whole batch. A
  safety lag behind now() avoids reading in-flight inserts; the watermark only
  advances after a successful upload (at-least-once; the manifest is the commit
  marker). First run starts from now(), so connecting an export doesn't dump all
  history (historical backfill = reset the watermark).

Integrations are organization-scoped, so every active project in the org exports
independently and gets its own watermark + object path (layout keyed by
project_id). Destinations are a pluggable object-store sink (S3 + GCS adapters)
so future targets are small additions.

Credentials (S3 secret keys, GCS service-account keys) are encrypted at rest with
CREDENTIALS_ENCRYPTION_KEY and decrypted inside the adapters. Export batching and
the safety lag are tunable via EXPORT_* env vars.

Rebased onto main after the Redpanda/Kafka migration.

Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
Phase 1 of the integrations rework. Adds a nullable Integration.projectId:
set = project-scoped, null = legacy org-wide. Existing org integrations keep
working untouched (additive migration, no backfill).

- schema: Integration.projectId (nullable FK) + indexes; Project reverse relation.
- export cron: project-scoped integrations export only their project; legacy
  org-wide rows still fan out across the org's projects.
- validation/tRPC: create inputs take projectId (org derived from project);
  added in-handler getProjectAccess to list + createOrUpdate* (closing a
  pre-existing authz gap that trusted client-supplied scope); get/delete use
  project-or-org access by scope. list also surfaces legacy org-wide rows.
- notification rules: connected integrations must belong to the same project
  (or be org-wide in the same org); also added the missing access check on the
  rule create branch.
- Slack OAuth carries projectId through install metadata + callback redirect.
- dashboard: integrations moved to project-scoped routes + sidebar; org route
  and org-level "add integration" CTA removed.

typecheck + tests green (657).

Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
Phase 2 of the integrations rework. Restructures integrations as three
registries keyed by the same `type` literal so adding one is additive — no
edits to central switches — while keeping types bulletproof.

Type safety (the priority): IIntegrationConfig stays the explicit, hand-written
discriminated union (source of truth, feeds Prisma's IPrismaIntegrationConfig).
It is NOT derived from the registry. The registries are forced to MATCH it via
`satisfies Record<IIntegrationConfig['type'], …>`, and two compile-time guards
in validation fail the build if any union member loses its literal `type`
discriminant or if the descriptor set drifts from the union.

- core (packages/validation/src/integrations.ts): per-type zod schemas + an
  INTEGRATION_DESCRIPTORS registry (kinds/setup/configSchema/catalog),
  getDescriptor/isKind, a runtime zIntegrationConfig, a generic
  zCreateIntegration, and the type-level guards.
- server (packages/integrations/src/registry.ts): IServerIntegration<T> +
  SERVER_INTEGRATIONS with notification.deliver / export.createAdapter /
  validateConfig / testConnection / encryptCredentials hooks. The bespoke
  slack/discord/webhook send bodies and s3/gcs adapter+encrypt logic moved here.
  getServerIntegration<T> holds the one contained cast.
- client (apps/start integrations.tsx): CLIENT_INTEGRATIONS (icon + Form) keyed
  by type; the catalog is derived from descriptors. add-integration.tsx renders
  via registry lookup instead of a switch.
- dispatch made generic: worker notification.ts and cron.flush-exports.ts look
  up the plugin; the tRPC router collapses to a generic createOrUpdate +
  testConnection delegating to plugin hooks (export/slack procedures kept as
  thin aliases for one release).
- fixed the discord form's server value-import (browser-bundle leak) by routing
  its test through trpc.integration.testConnection.

Deferred (demand-driven): the generic /oauth/:type route only benefits a second
OAuth integration and carries external Slack-redirect-URI risk, so slack OAuth
is unchanged. Legacy zCreate* schemas + tRPC aliases kept until the dashboard
drops them.

typecheck + tests green (657).

Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
The flushExports cron lists every integration and filters by
isKind(config, 'export'). A Slack integration that hasn't completed OAuth has
an empty config ({} with no type), and the registry refactor made isKind throw
"Unknown integration type: undefined" on it, failing the whole cron run.

isKind now returns false for unknown/undefined types instead of throwing (it's
a filter predicate, not a guaranteed-known lookup). Also guard the notification
worker against delivering to an unconfigured integration. Adds an isKind
regression test.

Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
There were two AES-256-GCM modules with two env keys for the same purpose:
db/src/encryption.ts (ENCRYPTION_KEY, for TOTP/GSC) and
common/server/encryption.ts (CREDENTIALS_ENCRYPTION_KEY, for integration creds,
needed in @openpanel/integrations which can't import db).

Consolidate to one implementation in @openpanel/common/server keyed solely by
ENCRYPTION_KEY. It hosts both the plain encrypt/decrypt (unchanged format, so
existing TOTP/GSC ciphertext still decrypts) and the prefixed
encryptCredential/decryptCredential (idempotent, plaintext-passthrough for the
test-connection flow). db/src/encryption.ts now re-exports encrypt/decrypt, so
@openpanel/db importers are unchanged.

Drops CREDENTIALS_ENCRYPTION_KEY from .env.example. Adds an encryption
round-trip test. Note: any integration credentials encrypted on this branch
with the old key must be re-saved (no prod data — feature is unmerged).

Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
PR-review cleanup: getDescriptor, descriptorsByKind, zCreateIntegration and
ICreateIntegration in validation/src/integrations.ts had zero callers (the
client derives the catalog from INTEGRATION_DESCRIPTORS directly, isKind uses
the type map directly, and the tRPC create procedure inlines its own
config-required input). The type-safety guards and isKind are unaffected.

Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds shared encryption and integration validation, project-scoped integration routes and forms, database support for export cursors and watermarks, and a worker cron job that flushes export batches.

Changes

Project-scoped integrations and export pipeline

Layer / File(s) Summary
Shared contracts and encryption
.env.example, packages/common/server/*, packages/validation/src/*, packages/db/src/encryption.ts
ENCRYPTION_KEY documentation expands to cover at-rest secrets, and shared encryption plus integration schemas and SSRF checks are added.
Export cursor storage
packages/db/code-migrations/*, packages/db/prisma/*, packages/db/src/services/*, packages/db/src/exports/*, packages/db/index.ts
Events gain inserted_at, export watermarks are added, and batch/manifest helpers serialize ClickHouse events for export.
Server integration registry and API
apps/api/package.json, apps/api/tsdown.config.ts, apps/api/src/controllers/*, packages/integrations/package.json, packages/integrations/src/object-store/*, packages/integrations/src/registry.ts, packages/integrations/src/slack.ts, packages/trpc/src/routers/*
Object-store adapters and the server registry back project-scoped integration CRUD, testing, and Slack install redirects.
Project route hierarchy
apps/start/src/routes/_app.$organizationId.$projectId.integrations*, apps/start/src/routeTree.gen.ts, apps/start/src/components/sidebar-*
Project-scoped integrations routes are added, the route tree is regenerated, and sidebar links move to the new hierarchy.
Integration catalog and forms
apps/start/src/components/integrations/*, apps/start/src/modals/*
The integrations catalog, provider forms, and modal/list consumers use shared descriptors and project-scoped defaults.
Worker cron and notifications
apps/worker/package.json, apps/worker/tsdown.config.ts, apps/worker/src/boot-*, apps/worker/src/jobs/*, packages/queue/src/queues.ts
The worker adds flushExports scheduling and dispatch, exports ClickHouse batches to object storage, and routes notifications through the registry.

Sequence Diagram(s)

sequenceDiagram
  participant bootCron
  participant cronJob
  participant flushExportsJob
  participant db
  participant ClickHouse
  participant IObjectStoreAdapter

  bootCron->>cronJob: schedule flushExports every minute
  cronJob->>flushExportsJob: dispatch CronQueuePayloadFlushExports
  flushExportsJob->>db: load integrations and exportWatermark rows
  flushExportsJob->>ClickHouse: queryWindow(projectId, inserted_at, id)
  flushExportsJob->>IObjectStoreAdapter: upload batch files
  flushExportsJob->>IObjectStoreAdapter: upload manifest.json
  flushExportsJob->>db: saveCursor(exportWatermark)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90+ minutes

Possibly related PRs

  • Openpanel-dev/openpanel#350: Adds the ENCRYPTION_KEY entry in .env.example, which this PR expands for broader at-rest encryption coverage.

Poem

I thumped through routes by moonlit clover,
Hid secrets in AES and hopped right over.
Projects, exports, batches—hop, hop, hooray,
My whiskers found the manifest today.
🐇✨ The garden hums with fresh integrations!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and references the main additions around GCS and S3 integrations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/integrations-s3

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 OpenGrep (1.23.0)
packages/common/server/index.ts

┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

[00.15][ERROR]: unable to find a config; path .coderabbit-opengrep-fallback.yml does not exist

packages/common/server/ssrf.ts

┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

[00.15][ERROR]: unable to find a config; path .coderabbit-opengrep-fallback.yml does not exist

packages/trpc/src/routers/integration.ts

┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

[00.12][ERROR]: unable to find a config; path .coderabbit-opengrep-fallback.yml does not exist

  • 4 others

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/start/src/components/integrations/forms/discord-integration.tsx (1)

57-74: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

mutateAsync failure is unhandled — wrap the test call in try/catch.

testConnection.mutationOptions() has no onError, and handleTest awaits mutateAsync without a try/catch. If the request rejects (network error, server-side validation failure), the promise rejection is unhandled and the user gets no feedback — the Failed to send test notification toast only covers the res.success === false path.

🛡️ Proposed fix
   const handleTest = async () => {
     const url = form.getValues('config.url');
     if (!url) {
       return toast.error('Webhook URL is required');
     }
-    const res = await testMutation.mutateAsync({
-      config: { type: 'discord', url },
-    });
-    if (res.success) {
-      toast.success('Test notification sent');
-    } else {
-      toast.error('Failed to send test notification');
-    }
+    try {
+      const res = await testMutation.mutateAsync({
+        config: { type: 'discord', url },
+      });
+      if (res.success) {
+        toast.success('Test notification sent');
+      } else {
+        toast.error('Failed to send test notification');
+      }
+    } catch (error) {
+      toast.error('Failed to send test notification');
+    }
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/start/src/components/integrations/forms/discord-integration.tsx` around
lines 57 - 74, The Discord integration test flow in handleTest currently awaits
testMutation.mutateAsync without handling rejected promises, so add a try/catch
around the call and surface an error toast from the catch path. Keep the
existing success/failure handling for a resolved response, but ensure any
network or server rejection from
trpc.integration.testConnection.mutationOptions() and testMutation is caught and
reported so the user always gets feedback.
🧹 Nitpick comments (3)
apps/start/src/components/integrations/integrations.tsx (1)

36-43: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Doc comment references satisfies but the code uses an explicit type annotation.

The JSDoc says the registry is "forced to cover every type via satisfies Record<IIntegrationType, …>", but CLIENT_INTEGRATIONS is declared with an explicit : Record<IIntegrationType, IClientIntegration> annotation. Both enforce exhaustiveness, so behavior is correct; only the comment is inaccurate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/start/src/components/integrations/integrations.tsx` around lines 36 -
43, The JSDoc for CLIENT_INTEGRATIONS is inaccurate because it mentions being
forced via satisfies while the declaration actually uses an explicit
Record<IIntegrationType, IClientIntegration> annotation. Update the comment to
match the current implementation by describing the registry as exhaustively
typed with the Record annotation, or change the declaration to use satisfies if
that was the intended pattern, keeping the wording aligned with the
CLIENT_INTEGRATIONS symbol.
packages/integrations/src/object-store/gcs-adapter.ts (1)

137-157: 🩺 Stability & Availability | 🔵 Trivial

testConnection can fail for write-only service accounts. bucket.exists() requires storage.buckets.get, so credentials that can upload objects but can’t read bucket metadata will be reported as “does not exist or is not accessible.” Consider a connection check aligned with the upload path, such as a minimal write/delete probe or a softer metadata check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/integrations/src/object-store/gcs-adapter.ts` around lines 137 -
157, The testConnection method in gcs-adapter.ts is too strict because
bucket.exists() depends on metadata read permissions and can falsely fail for
write-only credentials. Update GCSAdapter.testConnection to validate access
using a check aligned with the upload path, such as a minimal write-and-delete
probe or a less restrictive bucket check, while keeping the existing error
handling and return shape intact.
packages/integrations/src/object-store/s3-adapter.ts (1)

57-91: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid casting the cached Promise<S3Client> as a raw S3Client.

getClientWithAccessKeys() is declared to return S3Client, but the reuse path returns this.clientPromise as unknown as S3Client, which is actually a Promise. It only works because the caller getClient() is async and transparently unwraps the returned thenable. This double-casting is brittle: any future synchronous caller of this method would receive a Promise where an S3Client is expected. Cache the resolved client (or make the method consistently async) instead.

♻️ Suggested approach
-  private getClientWithAccessKeys(): S3Client {
-    // Access key clients don't expire, reuse if available
-    if (this.clientPromise && this.clientExpiresAt === 0) {
-      return this.clientPromise as unknown as S3Client;
-    }
+  private client: S3Client | null = null;
+
+  private getClientWithAccessKeys(): S3Client {
+    // Access key clients don't expire, reuse if available
+    if (this.client) {
+      return this.client;
+    }
@@
-    this.clientExpiresAt = 0;
-    this.clientPromise = Promise.resolve(client);
-
-    return client;
+    this.client = client;
+    return client;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/integrations/src/object-store/s3-adapter.ts` around lines 57 - 91,
The reuse path in getClientWithAccessKeys is returning this.clientPromise via a
double cast, which hides that it is actually a Promise rather than an S3Client.
Update the caching approach so the method always returns a real S3Client, either
by storing the resolved client instance alongside the promise or by making the
access-key client creation path consistently async and awaiting the cached
promise in getClient() and related callers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/controllers/webhook.controller.ts`:
- Around line 106-111: The redirect in webhook.controller’s installed-flow
currently falls back to an organization-only destination that does not exist, so
older Slack callbacks without a projectId will hit a 404. Update the redirect
logic around the reply.redirect path to always target a valid route, either by
routing to the project-scoped installed page using the available projectId or by
adding a matching org-level route; use the existing dashboardUrl,
organizationId, and projectId handling in this block to locate the fix.

In `@apps/start/src/components/integrations/forms/gcs-export-integration.tsx`:
- Around line 82-88: The GCS test flow in handleTest is missing the same
required name check as the S3 form, so it can call testExportConnection with an
invalid config and trigger a generic schema failure toast. Update handleTest in
gcs-export-integration.tsx to validate name along with bucket and
serviceAccountKey before calling testMutation.mutate, using the same
required-field gating pattern used by the S3 form and aligned with
zCreateGCSExportIntegration.

In `@apps/start/src/components/integrations/forms/s3-export-integration.tsx`:
- Around line 109-123: The S3 test handler currently validates only
bucket/region and auth fields, but the test mutation still uses the full create
payload, so missing Name causes `testExportConnection` to fail before connection
validation. Update `handleTest` in `s3-export-integration.tsx` to also gate on
the `name` field, or change `testMutation`/`testExportConnection` to accept
config-only input. Use the existing `form.getValues()`, `testMutation.mutate`,
and `zCreateS3ExportIntegration` flow to locate the fix.

In `@apps/worker/src/jobs/cron.flush-exports.ts`:
- Around line 33-41: The env-driven numeric constants in cron.flush-exports need
validation because Number.parseInt can produce NaN for malformed values, which
can silently break runWithConcurrency and the export query. Update the parsing
logic for LAG_SECONDS, BATCH_SIZE, MAX_BATCHES_PER_RUN, and CONCURRENCY to use a
validated numeric helper (or equivalent fallback) that rejects NaN and falls
back to safe defaults, then keep the existing symbols so the fix is localized
and easy to find.
- Around line 247-269: The first-run branch in loadCursor is racy because it
does a findUnique followed by create, so overlapping cron executions can hit the
same (projectId, integrationId) pair and throw on the unique constraint. Replace
that TOCTOU flow with a single atomic upsert on exportWatermark for the
existing/new watermark path, keeping the existing Cursor return shape from
loadCursor. Also add a job-level concurrency guard for cron.flush-exports so one
run cannot overlap the next minute’s trigger and reprocess the same watermark
window.

In `@packages/db/prisma/schema.prisma`:
- Around line 611-618: The ExportWatermark model currently allows a projectId
and integrationId pair that may not belong together, so the watermark can point
across projects. Update the ExportWatermark definition in the Prisma schema to
enforce project-scoped consistency by adding a composite relation/constraint
tied to the project and integration ownership, or add an equivalent guard in the
watermark create/update path. Use the ExportWatermark fields projectId and
integrationId and the Integration relation as the key symbols to locate the fix.

In `@packages/integrations/package.json`:
- Around line 19-24: The package manifest currently places `@openpanel/logger`
under devDependencies even though it is used at runtime by s3-adapter.ts via
createLogger. Move `@openpanel/logger` from devDependencies to dependencies in the
package.json for packages/integrations so consumers resolving this package can
load the runtime import correctly.

In `@packages/integrations/src/registry.ts`:
- Around line 133-140: Webhook dispatch in the registry still sends requests
straight to fetch(config.url), so add SSRF protection in the webhook path before
calling it. In the code around the request construction in registry.ts, reject
any config.url that is not HTTP or HTTPS and block private, loopback,
link-local, and metadata IP targets before dispatching. Reuse or add a dedicated
validation helper in the webhook/registry flow so the safety check happens
immediately before fetch and applies to all outgoing webhook requests.
- Around line 119-122: The webhook template execution in registry.ts is still
using executeJavaScriptTemplate with new Function, which allows
computed-property constructor escapes to break out of the validator. Replace
this in-process execution path with a real sandbox/isolate for
config.javascriptTemplate, or add strict blocking for constructor/prototype
access before calling executeJavaScriptTemplate, and make sure payload.event is
only evaluated inside the hardened boundary.

In `@packages/trpc/src/routers/integration.ts`:
- Around line 144-155: The Slack integration update path in integration.ts only
checks organizationId before clearing config, but it also needs to be scoped to
the existing integration’s project. In the input.id branch of the update logic,
load or validate the current integration’s projectId and include it in the
authorization check alongside organizationId before calling
db.integration.update, so only the same-project integration can be updated.
- Around line 64-68: The generic update path in integration.ts currently updates
by id and organizationId only, which can allow cross-project updates within the
same org. In the integration update logic inside the existing `if (input.id)`
branch, first load the current record (for example via the same `db.integration`
model) and verify `existing.projectId === input.projectId` before calling
`db.integration.update`; if it does not match, reject the request. Keep the fix
localized to the update flow around `input.id`, `organizationId`, and
`input.projectId` so the authorization check is enforced for generic updates.

In `@packages/validation/src/integrations.ts`:
- Line 68: The custom S3 endpoint validation in integrations should be tightened
because endpoint is tenant-controlled and can be used for server-side requests.
Update the zod validation around endpoint to only accept HTTPS public endpoints
or an explicit allowlist, and make sure the object-store adapter re-checks the
resolved host/IP before connecting. Use the endpoint schema and the downstream
S3/adapter connection path to locate the fix.
- Around line 111-112: The serviceAccountKey schema in integrations validation
is too permissive because it only checks for a non-empty string, so update the
validator to require parsed service-account JSON with the expected fields. In
the integrations schema for serviceAccountKey, add validation that ensures the
value is valid JSON and contains required keys like project_id, client_email,
and private_key, so malformed credentials are rejected before reaching
gcs-adapter and its JSON.parse path.

---

Outside diff comments:
In `@apps/start/src/components/integrations/forms/discord-integration.tsx`:
- Around line 57-74: The Discord integration test flow in handleTest currently
awaits testMutation.mutateAsync without handling rejected promises, so add a
try/catch around the call and surface an error toast from the catch path. Keep
the existing success/failure handling for a resolved response, but ensure any
network or server rejection from
trpc.integration.testConnection.mutationOptions() and testMutation is caught and
reported so the user always gets feedback.

---

Nitpick comments:
In `@apps/start/src/components/integrations/integrations.tsx`:
- Around line 36-43: The JSDoc for CLIENT_INTEGRATIONS is inaccurate because it
mentions being forced via satisfies while the declaration actually uses an
explicit Record<IIntegrationType, IClientIntegration> annotation. Update the
comment to match the current implementation by describing the registry as
exhaustively typed with the Record annotation, or change the declaration to use
satisfies if that was the intended pattern, keeping the wording aligned with the
CLIENT_INTEGRATIONS symbol.

In `@packages/integrations/src/object-store/gcs-adapter.ts`:
- Around line 137-157: The testConnection method in gcs-adapter.ts is too strict
because bucket.exists() depends on metadata read permissions and can falsely
fail for write-only credentials. Update GCSAdapter.testConnection to validate
access using a check aligned with the upload path, such as a minimal
write-and-delete probe or a less restrictive bucket check, while keeping the
existing error handling and return shape intact.

In `@packages/integrations/src/object-store/s3-adapter.ts`:
- Around line 57-91: The reuse path in getClientWithAccessKeys is returning
this.clientPromise via a double cast, which hides that it is actually a Promise
rather than an S3Client. Update the caching approach so the method always
returns a real S3Client, either by storing the resolved client instance
alongside the promise or by making the access-key client creation path
consistently async and awaiting the cached promise in getClient() and related
callers.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: f9ec9155-ebb9-40a8-8d8a-0a4a31aae9ef

📥 Commits

Reviewing files that changed from the base of the PR and between 4ed4d39 and 8c1de48.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (57)
  • .env.example
  • apps/api/package.json
  • apps/api/src/controllers/webhook.controller.ts
  • apps/api/tsdown.config.ts
  • apps/start/src/components/integrations/active-integrations.tsx
  • apps/start/src/components/integrations/forms/discord-integration.tsx
  • apps/start/src/components/integrations/forms/gcs-export-integration.tsx
  • apps/start/src/components/integrations/forms/s3-export-integration.tsx
  • apps/start/src/components/integrations/forms/slack-integration.tsx
  • apps/start/src/components/integrations/forms/webhook-integration.tsx
  • apps/start/src/components/integrations/integrations.tsx
  • apps/start/src/components/sidebar-organization-menu.tsx
  • apps/start/src/components/sidebar-project-menu.tsx
  • apps/start/src/modals/add-integration.tsx
  • apps/start/src/modals/add-notification-rule.tsx
  • apps/start/src/routeTree.gen.ts
  • apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.available.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.index.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.installed.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.tsx
  • apps/start/src/routes/_app.$organizationId.integrations._tabs.index.tsx
  • apps/worker/package.json
  • apps/worker/src/boot-cron.ts
  • apps/worker/src/boot-debug.ts
  • apps/worker/src/jobs/cron.flush-exports.ts
  • apps/worker/src/jobs/cron.ts
  • apps/worker/src/jobs/notification.ts
  • apps/worker/tsdown.config.ts
  • packages/common/server/encryption.test.ts
  • packages/common/server/encryption.ts
  • packages/common/server/index.ts
  • packages/db/code-migrations/18-add-events-inserted-at.ts
  • packages/db/index.ts
  • packages/db/prisma/migrations/20260622204749_export_watermarks/migration.sql
  • packages/db/prisma/migrations/20260623092702_integration_project_scope/migration.sql
  • packages/db/prisma/schema.prisma
  • packages/db/src/encryption.ts
  • packages/db/src/exports/batch-creator.ts
  • packages/db/src/exports/export-event.ts
  • packages/db/src/exports/index.ts
  • packages/db/src/exports/manifest.ts
  • packages/db/src/services/event.service.ts
  • packages/db/src/services/import.service.ts
  • packages/db/src/services/notification.service.ts
  • packages/integrations/package.json
  • packages/integrations/src/object-store/gcs-adapter.ts
  • packages/integrations/src/object-store/index.ts
  • packages/integrations/src/object-store/s3-adapter.ts
  • packages/integrations/src/object-store/types.ts
  • packages/integrations/src/registry.ts
  • packages/integrations/src/slack.ts
  • packages/queue/src/queues.ts
  • packages/trpc/src/routers/integration.ts
  • packages/trpc/src/routers/notification.ts
  • packages/validation/src/index.ts
  • packages/validation/src/integrations.test.ts
  • packages/validation/src/integrations.ts
💤 Files with no reviewable changes (1)
  • apps/start/src/routes/_app.$organizationId.integrations._tabs.index.tsx

Comment thread apps/api/src/controllers/webhook.controller.ts Outdated
Comment on lines +82 to +88
const handleTest = () => {
const values = form.getValues();
if (!values.config.bucket || !values.config.serviceAccountKey) {
return toast.error('Bucket and Service Account Key are required');
}
testMutation.mutate(values);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Same name validation gap as the S3 form.

handleTest validates bucket/serviceAccountKey but not name, while testExportConnection validates the full zCreateGCSExportIntegration schema (which requires name.min(1)). Testing before entering a Name yields a generic validation failure toast instead of a connection result. See the S3 form comment for the suggested fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/start/src/components/integrations/forms/gcs-export-integration.tsx`
around lines 82 - 88, The GCS test flow in handleTest is missing the same
required name check as the S3 form, so it can call testExportConnection with an
invalid config and trigger a generic schema failure toast. Update handleTest in
gcs-export-integration.tsx to validate name along with bucket and
serviceAccountKey before calling testMutation.mutate, using the same
required-field gating pattern used by the S3 form and aligned with
zCreateGCSExportIntegration.

Comment on lines +109 to +123
const handleTest = () => {
const values = form.getValues();
if (!values.config.bucket || !values.config.region) {
return toast.error('Bucket and Region are required');
}
if (values.config.authMode === 'iam_role' && !values.config.roleArn) {
return toast.error('IAM Role ARN is required');
}
if (values.config.authMode === 'access_key') {
if (!values.config.accessKeyId || !values.config.secretAccessKey) {
return toast.error('Access Key ID and Secret Access Key are required');
}
}
testMutation.mutate(values);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm test endpoints validate the full create schema (name/projectId required)
fd -t f 'integration.ts' packages/trpc/src/routers --exec sed -n '180,215p' {}
# Confirm base schema requires non-empty name
rg -nP -C2 'zCreateIntegrationBase|name:\s*z\.string\(\)\.min' packages/validation/src/integrations.ts

Repository: Openpanel-dev/openpanel

Length of output: 2549


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the S3 export form around handleTest and the form schema/fields.
sed -n '1,220p' apps/start/src/components/integrations/forms/s3-export-integration.tsx

# Inspect the form schema used by this component, if defined nearby or imported.
rg -n "useForm|zod|name|projectId|handleTest|testMutation|createOrUpdateExport|testExportConnection" apps/start/src/components/integrations/forms/s3-export-integration.tsx apps/start/src/components/integrations/forms -g '*.tsx' -g '*.ts'

Repository: Openpanel-dev/openpanel

Length of output: 19409


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the S3 export form around handleTest and the form schema/fields.
sed -n '1,220p' apps/start/src/components/integrations/forms/s3-export-integration.tsx

# Inspect the form schema used by this component, if defined nearby or imported.
rg -n "useForm|zod|name|projectId|handleTest|testMutation|createOrUpdateExport|testExportConnection" apps/start/src/components/integrations/forms/s3-export-integration.tsx apps/start/src/components/integrations/forms -g '*.tsx' -g '*.ts'

Repository: Openpanel-dev/openpanel

Length of output: 19409


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the S3 export form around handleTest and the form schema/fields.
sed -n '1,220p' apps/start/src/components/integrations/forms/s3-export-integration.tsx

# Inspect the form schema used by this component, if defined nearby or imported.
rg -n "useForm|zod|name|projectId|handleTest|testMutation|createOrUpdateExport|testExportConnection" apps/start/src/components/integrations/forms/s3-export-integration.tsx apps/start/src/components/integrations/forms -g '*.tsx' -g '*.ts'

Repository: Openpanel-dev/openpanel

Length of output: 19409


Gate the S3 test on name too. handleTest sends the full create payload, and testExportConnection still requires name/projectId via zCreateS3ExportIntegration. Leaving Name blank makes the test fail before any connection check runs; either block the test until Name is filled or switch the test mutation to a config-only input.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/start/src/components/integrations/forms/s3-export-integration.tsx`
around lines 109 - 123, The S3 test handler currently validates only
bucket/region and auth fields, but the test mutation still uses the full create
payload, so missing Name causes `testExportConnection` to fail before connection
validation. Update `handleTest` in `s3-export-integration.tsx` to also gate on
the `name` field, or change `testMutation`/`testExportConnection` to accept
config-only input. Use the existing `form.getValues()`, `testMutation.mutate`,
and `zCreateS3ExportIntegration` flow to locate the fix.

Comment on lines +33 to +41
const LAG_SECONDS = Number.parseInt(process.env.EXPORT_LAG_SECONDS || '60', 10);
// Max rows per object/batch and max batches drained per (project, integration)
// per run. A backlog drains over subsequent ticks rather than in one giant pass.
const BATCH_SIZE = Number.parseInt(process.env.EXPORT_BATCH_SIZE || '50000', 10);
const MAX_BATCHES_PER_RUN = Number.parseInt(
process.env.EXPORT_MAX_BATCHES_PER_RUN || '20',
10
);
const CONCURRENCY = Number.parseInt(process.env.EXPORT_CONCURRENCY || '4', 10);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard env-driven numeric config against NaN.

Number.parseInt returns NaN for malformed values (e.g. EXPORT_CONCURRENCY=abc); the || '...' fallback only covers empty strings, not garbage. A NaN CONCURRENCY is especially dangerous: in runWithConcurrency, Math.min(NaN, queue.length)NaNArray.from({ length: NaN }) yields zero workers, so the job silently exports nothing. A NaN BATCH_SIZE/LAG_SECONDS also breaks the query.

🛡️ Proposed fix: validated numeric env parsing
-const LAG_SECONDS = Number.parseInt(process.env.EXPORT_LAG_SECONDS || '60', 10);
+const numEnv = (val: string | undefined, fallback: number): number => {
+  const n = Number.parseInt(val ?? '', 10);
+  return Number.isFinite(n) && n > 0 ? n : fallback;
+};
+const LAG_SECONDS = numEnv(process.env.EXPORT_LAG_SECONDS, 60);
-const BATCH_SIZE = Number.parseInt(process.env.EXPORT_BATCH_SIZE || '50000', 10);
-const MAX_BATCHES_PER_RUN = Number.parseInt(
-  process.env.EXPORT_MAX_BATCHES_PER_RUN || '20',
-  10
-);
-const CONCURRENCY = Number.parseInt(process.env.EXPORT_CONCURRENCY || '4', 10);
+const BATCH_SIZE = numEnv(process.env.EXPORT_BATCH_SIZE, 50000);
+const MAX_BATCHES_PER_RUN = numEnv(process.env.EXPORT_MAX_BATCHES_PER_RUN, 20);
+const CONCURRENCY = numEnv(process.env.EXPORT_CONCURRENCY, 4);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const LAG_SECONDS = Number.parseInt(process.env.EXPORT_LAG_SECONDS || '60', 10);
// Max rows per object/batch and max batches drained per (project, integration)
// per run. A backlog drains over subsequent ticks rather than in one giant pass.
const BATCH_SIZE = Number.parseInt(process.env.EXPORT_BATCH_SIZE || '50000', 10);
const MAX_BATCHES_PER_RUN = Number.parseInt(
process.env.EXPORT_MAX_BATCHES_PER_RUN || '20',
10
);
const CONCURRENCY = Number.parseInt(process.env.EXPORT_CONCURRENCY || '4', 10);
const numEnv = (val: string | undefined, fallback: number): number => {
const n = Number.parseInt(val ?? '', 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
};
const LAG_SECONDS = numEnv(process.env.EXPORT_LAG_SECONDS, 60);
// Max rows per object/batch and max batches drained per (project, integration)
// per run. A backlog drains over subsequent ticks rather than in one giant pass.
const BATCH_SIZE = numEnv(process.env.EXPORT_BATCH_SIZE, 50000);
const MAX_BATCHES_PER_RUN = numEnv(process.env.EXPORT_MAX_BATCHES_PER_RUN, 20);
const CONCURRENCY = numEnv(process.env.EXPORT_CONCURRENCY, 4);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/jobs/cron.flush-exports.ts` around lines 33 - 41, The
env-driven numeric constants in cron.flush-exports need validation because
Number.parseInt can produce NaN for malformed values, which can silently break
runWithConcurrency and the export query. Update the parsing logic for
LAG_SECONDS, BATCH_SIZE, MAX_BATCHES_PER_RUN, and CONCURRENCY to use a validated
numeric helper (or equivalent fallback) that rejects NaN and falls back to safe
defaults, then keep the existing symbols so the fix is localized and easy to
find.

Comment on lines +247 to +269
async function loadCursor(
projectId: string,
integrationId: string
): Promise<Cursor> {
const existing = await db.exportWatermark.findUnique({
where: { projectId_integrationId: { projectId, integrationId } },
});
if (existing) {
return {
insertedAt: formatCh(existing.lastInsertedAt),
eventId: existing.lastEventId,
};
}

// First run for this pair: start from now, so connecting an export doesn't
// dump the project's entire history. Historical backfill is a separate,
// explicit operation (reset the watermark).
const now = new Date();
await db.exportWatermark.create({
data: { projectId, integrationId, lastInsertedAt: now, lastEventId: NIL_UUID },
});
return { insertedAt: formatCh(now), eventId: NIL_UUID };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

loadCursor has a create race; combined with overlapping cron runs it can duplicate work or throw.

findUnique followed by create is a TOCTOU: if the same (projectId, integrationId) first-run is processed concurrently (this cron fires every 60s with no job-level concurrency guard, so a run exceeding the interval overlaps the next), both branches reach create and the second hits a unique-constraint error. More broadly, overlapping runs read the same watermark and re-export the same window. The at-least-once design tolerates duplicate uploads, but the create collision will surface as an unhandled error per pair. Prefer an upsert for the first-run path and consider a concurrency=1 guard / advisory lock on the job.

🛡️ Proposed fix for the first-run race
-  const now = new Date();
-  await db.exportWatermark.create({
-    data: { projectId, integrationId, lastInsertedAt: now, lastEventId: NIL_UUID },
-  });
-  return { insertedAt: formatCh(now), eventId: NIL_UUID };
+  const now = new Date();
+  const created = await db.exportWatermark.upsert({
+    where: { projectId_integrationId: { projectId, integrationId } },
+    create: { projectId, integrationId, lastInsertedAt: now, lastEventId: NIL_UUID },
+    update: {},
+  });
+  return {
+    insertedAt: formatCh(created.lastInsertedAt),
+    eventId: created.lastEventId,
+  };

Consider setting the worker concurrency for this job to 1 (or a Redis lock keyed per pair) so a slow run cannot overlap the next minute's trigger.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function loadCursor(
projectId: string,
integrationId: string
): Promise<Cursor> {
const existing = await db.exportWatermark.findUnique({
where: { projectId_integrationId: { projectId, integrationId } },
});
if (existing) {
return {
insertedAt: formatCh(existing.lastInsertedAt),
eventId: existing.lastEventId,
};
}
// First run for this pair: start from now, so connecting an export doesn't
// dump the project's entire history. Historical backfill is a separate,
// explicit operation (reset the watermark).
const now = new Date();
await db.exportWatermark.create({
data: { projectId, integrationId, lastInsertedAt: now, lastEventId: NIL_UUID },
});
return { insertedAt: formatCh(now), eventId: NIL_UUID };
}
async function loadCursor(
projectId: string,
integrationId: string
): Promise<Cursor> {
const existing = await db.exportWatermark.findUnique({
where: { projectId_integrationId: { projectId, integrationId } },
});
if (existing) {
return {
insertedAt: formatCh(existing.lastInsertedAt),
eventId: existing.lastEventId,
};
}
// First run for this pair: start from now, so connecting an export doesn't
// dump the project's entire history. Historical backfill is a separate,
// explicit operation (reset the watermark).
const now = new Date();
const created = await db.exportWatermark.upsert({
where: { projectId_integrationId: { projectId, integrationId } },
create: { projectId, integrationId, lastInsertedAt: now, lastEventId: NIL_UUID },
update: {},
});
return {
insertedAt: formatCh(created.lastInsertedAt),
eventId: created.lastEventId,
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/jobs/cron.flush-exports.ts` around lines 247 - 269, The
first-run branch in loadCursor is racy because it does a findUnique followed by
create, so overlapping cron executions can hit the same (projectId,
integrationId) pair and throw on the unique constraint. Replace that TOCTOU flow
with a single atomic upsert on exportWatermark for the existing/new watermark
path, keeping the existing Cursor return shape from loadCursor. Also add a
job-level concurrency guard for cron.flush-exports so one run cannot overlap the
next minute’s trigger and reprocess the same watermark window.

Comment thread packages/integrations/src/registry.ts
Comment thread packages/trpc/src/routers/integration.ts
Comment thread packages/trpc/src/routers/integration.ts
bucket: z.string().min(1, 'Bucket name is required'),
prefix: z.string().default('openpanel-exports'),
region: z.string().min(1, 'Region is required'),
endpoint: z.string().url().optional(), // For R2, MinIO, etc.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Guard custom S3 endpoints against SSRF.

endpoint is tenant-controlled and will drive server-side object-store requests. Accepting any URL allows private/link-local/internal hosts; restrict to HTTPS public endpoints or an explicit allowlist, and re-check resolved addresses in the adapter before connecting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/validation/src/integrations.ts` at line 68, The custom S3 endpoint
validation in integrations should be tightened because endpoint is
tenant-controlled and can be used for server-side requests. Update the zod
validation around endpoint to only accept HTTPS public endpoints or an explicit
allowlist, and make sure the object-store adapter re-checks the resolved host/IP
before connecting. Use the endpoint schema and the downstream S3/adapter
connection path to locate the fix.

Comment on lines +111 to +112
// Service account credentials (JSON key as string)
serviceAccountKey: z.string().min(1, 'Service account key is required'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== integrations.ts relevant section =="
nl -ba packages/validation/src/integrations.ts | sed -n '90,140p'

echo
echo "== serviceAccountKey references =="
rg -n "serviceAccountKey" -S .

echo
echo "== json parse / service account validation references =="
rg -n "client_email|private_key|project_id|service account|service-account|JSON key" packages . -S

Repository: Openpanel-dev/openpanel

Length of output: 240


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== integrations.ts relevant section =="
nl -ba packages/validation/src/integrations.ts | sed -n '90,140p'

echo
echo "== serviceAccountKey references =="
rg -n "serviceAccountKey" -S .

echo
echo "== json parse / service account validation references =="
rg -n "client_email|private_key|project_id|service account|service-account|JSON key" packages . -S

Repository: Openpanel-dev/openpanel

Length of output: 240


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== integrations.ts relevant section =="
nl -ba packages/validation/src/integrations.ts | sed -n '90,140p'

echo
echo "== serviceAccountKey references =="
rg -n "serviceAccountKey" -S .

echo
echo "== json parse / service account validation references =="
rg -n "client_email|private_key|project_id|service account|service-account|JSON key" packages . -S

Repository: Openpanel-dev/openpanel

Length of output: 240


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== integrations.ts relevant section =="
sed -n '90,140p' packages/validation/src/integrations.ts | cat -n

echo
echo "== serviceAccountKey references =="
rg -n "serviceAccountKey" -S .

echo
echo "== json parse / service account validation references =="
rg -n "client_email|private_key|project_id|service account|service-account|JSON key" packages . -S

Repository: Openpanel-dev/openpanel

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== integrations.ts relevant section =="
sed -n '90,140p' packages/validation/src/integrations.ts | cat -n

echo
echo "== serviceAccountKey references =="
rg -n "serviceAccountKey" -S .

echo
echo "== json parse / service account validation references =="
rg -n "client_email|private_key|project_id|service account|service-account|JSON key" packages . -S

Repository: Openpanel-dev/openpanel

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path("packages/validation/src/integrations.ts")
text = p.read_text()
lines = text.splitlines()
for i in range(95, 141):
    if i <= len(lines):
        print(f"{i:4d}: {lines[i-1]}")
PY

echo
echo "== serviceAccountKey references =="
python3 - <<'PY'
from pathlib import Path
for path in Path(".").rglob("*"):
    if path.is_file() and path.suffix in {".ts",".tsx",".js",".jsx",".json",".md"}:
        try:
            text = path.read_text(errors="ignore")
        except Exception:
            continue
        if "serviceAccountKey" in text:
            for i, line in enumerate(text.splitlines(), 1):
                if "serviceAccountKey" in line:
                    print(f"{path}:{i}:{line}")
PY

echo
echo "== json parse / service account validation references =="
python3 - <<'PY'
from pathlib import Path
terms = ["client_email", "private_key", "project_id", "service account", "service-account", "JSON key"]
for path in Path(".").rglob("*"):
    if path.is_file() and path.suffix in {".ts",".tsx",".js",".jsx",".json",".md"}:
        try:
            text = path.read_text(errors="ignore")
        except Exception:
            continue
        if any(term in text for term in terms):
            for i, line in enumerate(text.splitlines(), 1):
                if any(term in line for term in terms):
                    print(f"{path}:{i}:{line}")
PY

Repository: Openpanel-dev/openpanel

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path("packages/validation/src/integrations.ts")
text = p.read_text()
lines = text.splitlines()
for i in range(95, 141):
    if i <= len(lines):
        print(f"{i:4d}: {lines[i-1]}")
PY

echo
echo "== serviceAccountKey references =="
python3 - <<'PY'
from pathlib import Path
for path in Path(".").rglob("*"):
    if path.is_file() and path.suffix in {".ts",".tsx",".js",".jsx",".json",".md"}:
        try:
            text = path.read_text(errors="ignore")
        except Exception:
            continue
        if "serviceAccountKey" in text:
            for i, line in enumerate(text.splitlines(), 1):
                if "serviceAccountKey" in line:
                    print(f"{path}:{i}:{line}")
PY

echo
echo "== json parse / service account validation references =="
python3 - <<'PY'
from pathlib import Path
terms = ["client_email", "private_key", "project_id", "service account", "service-account", "JSON key"]
for path in Path(".").rglob("*"):
    if path.is_file() and path.suffix in {".ts",".tsx",".js",".jsx",".json",".md"}:
        try:
            text = path.read_text(errors="ignore")
        except Exception:
            continue
        if any(term in text for term in terms):
            for i, line in enumerate(text.splitlines(), 1):
                if any(term in line for term in terms):
                    print(f"{path}:{i}:{line}")
PY

Repository: Openpanel-dev/openpanel

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== gcs-adapter.ts relevant section =="
python3 - <<'PY'
from pathlib import Path
p = Path("packages/integrations/src/object-store/gcs-adapter.ts")
lines = p.read_text().splitlines()
for i in range(1, min(len(lines), 120) + 1):
    print(f"{i:4d}: {lines[i-1]}")
PY

echo
echo "== registry.ts relevant section =="
python3 - <<'PY'
from pathlib import Path
p = Path("packages/integrations/src/registry.ts")
lines = p.read_text().splitlines()
for i in range(145, 185):
    if i <= len(lines):
        print(f"{i:4d}: {lines[i-1]}")
PY

echo
echo "== GCS credential validation references =="
rg -n "client_email|private_key|project_id|JSON.parse\\(this\\.config\\.serviceAccountKey\\)|Invalid service account key JSON" packages/integrations packages/validation apps/start -S

Repository: Openpanel-dev/openpanel

Length of output: 6107


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== gcs-adapter.ts relevant section =="
python3 - <<'PY'
from pathlib import Path
p = Path("packages/integrations/src/object-store/gcs-adapter.ts")
lines = p.read_text().splitlines()
for i in range(1, min(len(lines), 120) + 1):
    print(f"{i:4d}: {lines[i-1]}")
PY

echo
echo "== registry.ts relevant section =="
python3 - <<'PY'
from pathlib import Path
p = Path("packages/integrations/src/registry.ts")
lines = p.read_text().splitlines()
for i in range(145, 185):
    if i <= len(lines):
        print(f"{i:4d}: {lines[i-1]}")
PY

echo
echo "== GCS credential validation references =="
rg -n "client_email|private_key|project_id|JSON.parse\\(this\\.config\\.serviceAccountKey\\)|Invalid service account key JSON" packages/integrations packages/validation apps/start -S

Repository: Openpanel-dev/openpanel

Length of output: 6107


Validate serviceAccountKey as parsed service-account JSON.
z.string().min(1) still accepts malformed payloads that only fail later in packages/integrations/src/object-store/gcs-adapter.ts when JSON.parse runs; require the expected service-account fields here, including project_id, client_email, and private_key.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/validation/src/integrations.ts` around lines 111 - 112, The
serviceAccountKey schema in integrations validation is too permissive because it
only checks for a non-empty string, so update the validator to require parsed
service-account JSON with the expected fields. In the integrations schema for
serviceAccountKey, add validation that ensures the value is valid JSON and
contains required keys like project_id, client_email, and private_key, so
malformed credentials are rejected before reaching gcs-adapter and its
JSON.parse path.

Integrations became project-scoped, so the org-level
/$organizationId/integrations/installed route was removed. The slack callback's
projectId-absent fallback still pointed there, 404-ing older in-flight installs
(metadata without projectId). Fall back to the org landing page instead.

Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj
Security-review findings (all verified against current code):

- Cross-project update (integration.ts): the generic upsert and the Slack
  create/update authorized against the client-supplied input.projectId, so a
  user with access to one project could update another project's integration in
  the same org. Updates now load the existing row and authorize against ITS
  scope via assertIntegrationAccess (project access, or org access for legacy
  org-wide rows).

- Webhook SSRF (registry.ts): user-configured webhook URLs were fetched
  directly. Now guarded by assertSafeUrl before dispatch.

- S3 custom-endpoint SSRF (s3-adapter.ts): a tenant-controlled endpoint could
  point at internal services. The resolved host is now SSRF-checked in
  getClient() before connecting (covers upload + testConnection).

assertSafeUrl is a new shared helper in @openpanel/common/server: rejects
non-http(s) schemes and hosts resolving to loopback/private/CGNAT/link-local
(incl. 169.254.169.254 metadata). Skipped on SELF_HOSTED, where the single
operator already controls the network and internal targets are legitimate
(blocking them would regress existing behavior). Adds unit tests.

Skipped: tightening the S3 endpoint zod to https-only — it would break
legitimate self-hosted http MinIO/internal stores, and the runtime guard
(scheme + resolved-IP, cloud-gated) is the actual SSRF protection.

Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/common/server/ssrf.ts`:
- Around line 66-92: The webhook delivery flow still has a redirect-based SSRF
gap because assertSafeUrl only validates the initial URL while fetch follows
redirects automatically. Update the webhook request path to either disable
automatic redirects in fetch(config.url, ...) or explicitly validate each
redirect target before following it, using assertSafeUrl and the surrounding
webhook delivery logic to keep private/metadata targets blocked.
- Around line 70-73: The SSRF bypass in assertSafeUrl is currently using raw
truthiness for SELF_HOSTED, which can incorrectly disable the guard for values
like false or 0. Update the SELF_HOSTED check in assertSafeUrl to only return
early for explicit enabled values, preserving support for both
SELF_HOSTED='true' and SELF_HOSTED='1' while keeping the URL safety validation
active for all other values.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 24dc1166-860d-452e-a7a8-4c66dbfa6131

📥 Commits

Reviewing files that changed from the base of the PR and between 8c1de48 and f8ea00d.

📒 Files selected for processing (7)
  • apps/api/src/controllers/webhook.controller.ts
  • packages/common/server/index.ts
  • packages/common/server/ssrf.test.ts
  • packages/common/server/ssrf.ts
  • packages/integrations/src/object-store/s3-adapter.ts
  • packages/integrations/src/registry.ts
  • packages/trpc/src/routers/integration.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/common/server/ssrf.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/common/server/index.ts
  • packages/integrations/src/registry.ts
  • packages/integrations/src/object-store/s3-adapter.ts
  • packages/trpc/src/routers/integration.ts

Comment on lines +66 to +92
* Note: DNS is resolved here and again by the client, so a deliberate DNS-rebind
* between the two is not covered; this stops the common cases (literal
* private/metadata URLs and hostnames pointing at internal IPs).
*/
export async function assertSafeUrl(rawUrl: string): Promise<void> {
if (process.env.SELF_HOSTED) {
return;
}

let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error('Invalid URL');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('URL must use http or https');
}

const resolved = await lookup(url.hostname, { all: true });
if (
resolved.length === 0 ||
resolved.some((r) => isBlockedAddress(r.address))
) {
throw new Error('URL resolves to a disallowed address');
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify all guarded outbound clients define an explicit redirect policy or revalidate redirects.
rg -n -C6 '\bassertSafeUrl\s*\(' packages apps
rg -n -C8 '\bfetch\s*\(' packages apps

Repository: Openpanel-dev/openpanel

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' packages/integrations/src/registry.ts
printf '\n--- ssrf.ts ---\n'
sed -n '1,220p' packages/common/server/ssrf.ts
printf '\n--- redirect policies ---\n'
rg -n -C2 "redirect:\s*'manual'|redirect:\s*'error'|redirect:\s*'follow'" packages/integrations packages/common packages/sdks apps

Repository: Openpanel-dev/openpanel

Length of output: 10296


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' packages/integrations/src/registry.ts
printf '\n--- ssrf.ts ---\n'
sed -n '1,220p' packages/common/server/ssrf.ts
printf '\n--- redirect policies ---\n'
rg -n -C2 "redirect:\s*'manual'|redirect:\s*'error'|redirect:\s*'follow'" packages/integrations packages/common packages/sdks apps

Repository: Openpanel-dev/openpanel

Length of output: 10296


Close the redirect-based SSRF gap in webhook delivery.

assertSafeUrl(config.url) only checks the initial URL, but fetch(config.url, ...) follows redirects by default. A public webhook URL can still 30x to a private/metadata host after validation. Set redirect: 'manual' or revalidate each redirect target before following it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/common/server/ssrf.ts` around lines 66 - 92, The webhook delivery
flow still has a redirect-based SSRF gap because assertSafeUrl only validates
the initial URL while fetch follows redirects automatically. Update the webhook
request path to either disable automatic redirects in fetch(config.url, ...) or
explicitly validate each redirect target before following it, using
assertSafeUrl and the surrounding webhook delivery logic to keep
private/metadata targets blocked.

Comment on lines +70 to +73
export async function assertSafeUrl(rawUrl: string): Promise<void> {
if (process.env.SELF_HOSTED) {
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C2 '\bSELF_HOSTED\b' .env.example packages apps

Repository: Openpanel-dev/openpanel

Length of output: 11770


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## ssrf.ts\n'
sed -n '1,140p' packages/common/server/ssrf.ts

printf '\n## ssrf.test.ts\n'
sed -n '1,140p' packages/common/server/ssrf.test.ts

printf '\n## startup env check\n'
sed -n '1,80p' apps/start/src/server/get-envs.ts

printf '\n## Dockerfile env\n'
sed -n '1,40p' apps/start/Dockerfile

printf '\n## docs snippet\n'
sed -n '540,556p' apps/public/content/docs/self-hosting/environment-variables.mdx

Repository: Openpanel-dev/openpanel

Length of output: 7368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## other SELF_HOSTED contracts\n'
rg -n -C1 "SELF_HOSTED === 'true'|SELF_HOSTED !== undefined|SELF_HOSTED=1|SELF_HOSTED=true|SELF_HOSTED=false" \
  packages apps | sed -n '1,220p'

Repository: Openpanel-dev/openpanel

Length of output: 3758


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## get-envs.ts\n'
sed -n '1,40p' apps/start/src/server/get-envs.ts

printf '\n## code-migrations/helpers.ts\n'
sed -n '1,60p' packages/db/code-migrations/helpers.ts

printf '\n## clickhouse client self-hosted gate\n'
sed -n '70,95p' packages/db/src/clickhouse/client.ts

Repository: Openpanel-dev/openpanel

Length of output: 2455


Gate the SSRF bypass on explicit self-hosted values packages/common/server/ssrf.ts:71 should treat only SELF_HOSTED='true' or SELF_HOSTED='1' as enabled; raw truthiness lets false/0 skip the guard, and === 'true' alone would break the existing SELF_HOSTED=1 self-hosted setup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/common/server/ssrf.ts` around lines 70 - 73, The SSRF bypass in
assertSafeUrl is currently using raw truthiness for SELF_HOSTED, which can
incorrectly disable the guard for values like false or 0. Update the SELF_HOSTED
check in assertSafeUrl to only return early for explicit enabled values,
preserving support for both SELF_HOSTED='true' and SELF_HOSTED='1' while keeping
the URL safety validation active for all other values.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant