window.scrollTo(0, 0)}>
+
);
diff --git a/apps/envsync-landing/vite.config.ts b/apps/envsync-landing/vite.config.ts
index 40102b78..61bce5e3 100644
--- a/apps/envsync-landing/vite.config.ts
+++ b/apps/envsync-landing/vite.config.ts
@@ -23,5 +23,6 @@ export default defineConfig(({ mode }) => ({
alias: {
"@": path.resolve(__dirname, "./src"),
},
+ dedupe: ['react', 'react-dom'],
},
}));
diff --git a/apps/envsync-management-web/package.json b/apps/envsync-management-web/package.json
index 55d6031d..06a79636 100644
--- a/apps/envsync-management-web/package.json
+++ b/apps/envsync-management-web/package.json
@@ -1,7 +1,7 @@
{
"name": "envsync-management-web",
"private": true,
- "version": "0.8.7",
+ "version": "0.10.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/apps/envsync-web/e2e/helpers/project-flows.ts b/apps/envsync-web/e2e/helpers/project-flows.ts
index 2b89bc46..9de9ec29 100644
--- a/apps/envsync-web/e2e/helpers/project-flows.ts
+++ b/apps/envsync-web/e2e/helpers/project-flows.ts
@@ -108,7 +108,8 @@ async function ensureEnvironmentTypeExists(page: Page, appId: string, envName: s
await page.goto(`/applications/${appId}/manage-environments`, { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Manage Environment Types" })
- .or(page.getByRole("heading", { name: "Manage Environments" })),
+ .or(page.getByRole("heading", { name: "Manage Environments" }))
+ .or(page.getByRole("heading", { name: "Environments" })),
).toBeVisible();
await expect(page.getByTestId(`env-type-card-${matchingEnvTypes[0]!.id}`)).toBeVisible();
return;
@@ -117,14 +118,15 @@ async function ensureEnvironmentTypeExists(page: Page, appId: string, envName: s
await page.goto(`/applications/${appId}/manage-environments`, { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Manage Environment Types" })
- .or(page.getByRole("heading", { name: "Manage Environments" })),
+ .or(page.getByRole("heading", { name: "Manage Environments" }))
+ .or(page.getByRole("heading", { name: "Environments" })),
).toBeVisible();
- await page.getByRole("button", { name: "Add Environment Type" }).click();
+ await page.getByRole("button", { name: /Add Environment|Add Environment Type/ }).click();
const dialog = page.getByRole("dialog").last();
- await expect(dialog.getByRole("heading", { name: "Create Environment Type" })).toBeVisible();
+ await expect(dialog.getByRole("heading", { name: /Create Environment|Add Environment|Create Environment Type/ })).toBeVisible();
await dialog.locator("#create-env-name").fill(envName);
- await dialog.getByRole("button", { name: "Create Environment Type" }).click();
+ await dialog.getByRole("button", { name: /Create|Create Environment Type/ }).click();
const envTypeId = await waitForEnvironmentTypeId(page, appId, envName);
await expect(page.getByTestId(`env-type-card-${envTypeId}`)).toBeVisible();
}
@@ -250,7 +252,7 @@ export async function createProject(page: Page, projectName: string) {
}
export async function openProjectCardActions(page: Page, projectName: string) {
- const card = page.locator('[class*="group cursor-pointer"]').filter({ hasText: projectName }).first();
+ const card = page.locator('[class*="cursor-pointer"][class*="group"]').filter({ hasText: projectName }).first();
await card.waitFor({ state: "visible" });
await card.hover();
const actionButton = card.getByRole("button").last();
@@ -291,7 +293,7 @@ export async function setEnvironmentProtected(page: Page, appId: string, envName
await page.getByTestId(`env-type-edit-${envType!.id}`).click();
const dialog = page.getByRole("dialog").last();
- await expect(dialog.getByRole("heading", { name: "Edit Environment Type" })).toBeVisible();
+ await expect(dialog.getByRole("heading", { name: /Edit Environment|Edit Environment Type/ })).toBeVisible();
const checkbox = dialog.getByTestId("env-type-protected-checkbox");
const checked = await checkbox.isChecked();
const updateResponse = waitForTrackedResponse(page, {
@@ -303,7 +305,7 @@ export async function setEnvironmentProtected(page: Page, appId: string, envName
if (checked !== isProtected) {
await checkbox.click();
}
- await dialog.getByRole("button", { name: "Update Environment Type" }).click();
+ await dialog.getByRole("button", { name: /Update|Save|Update Environment Type/ }).click();
const trackedResponse = await updateResponse;
expectObjectBody(trackedResponse.requestBody, "Update environment type");
expect(trackedResponse.requestBody.id).toBeTruthy();
@@ -329,11 +331,11 @@ export async function setEnvironmentProtected(page: Page, appId: string, envName
export async function createEnvironmentType(page: Page, appId: string, envName: string) {
await page.goto(`/applications/${appId}/manage-environments`, { waitUntil: "domcontentloaded" });
- await page.getByRole("button", { name: "Add Environment Type" }).click();
+ await page.getByRole("button", { name: /Add Environment|Add Environment Type/ }).click();
const dialog = page.getByRole("dialog").last();
- await expect(dialog.getByRole("heading", { name: "Create Environment Type" })).toBeVisible();
+ await expect(dialog.getByRole("heading", { name: /Create Environment|Add Environment|Create Environment Type/ })).toBeVisible();
await dialog.locator("#create-env-name").fill(envName);
- await dialog.getByRole("button", { name: "Create Environment Type" }).click();
+ await dialog.getByRole("button", { name: /Create|Create Environment Type/ }).click();
const envTypeId = await waitForEnvironmentTypeId(page, appId, envName);
await expect(page.getByTestId(`env-type-card-${envTypeId}`)).toBeVisible();
}
@@ -346,9 +348,9 @@ export async function deleteEnvironmentType(page: Page, appId: string, envName:
await page.getByTestId(`env-type-card-${envType!.id}`).waitFor({ state: "visible" });
await page.getByTestId(`env-type-delete-${envType!.id}`).click();
const dialog = page.getByRole("dialog").last();
- await expect(dialog.getByRole("heading", { name: "Delete Environment Type" })).toBeVisible();
+ await expect(dialog.getByRole("heading", { name: /Delete Environment|Delete Environment Type/ })).toBeVisible();
await dialog.locator("#delete-confirm-text").fill(envName);
- await dialog.getByRole("button", { name: "Delete Environment Type" }).click();
+ await dialog.getByRole("button", { name: /Delete|Delete Environment Type/ }).click();
await expect(page.getByText(envName)).toHaveCount(0);
}
diff --git a/apps/envsync-web/e2e/specs/regression/routes.spec.ts b/apps/envsync-web/e2e/specs/regression/routes.spec.ts
index 82b025ff..a62843fe 100644
--- a/apps/envsync-web/e2e/specs/regression/routes.spec.ts
+++ b/apps/envsync-web/e2e/specs/regression/routes.spec.ts
@@ -12,7 +12,7 @@ test.describe("route surface", () => {
{ path: "/applications/create", heading: "Create New Project" },
{ path: `/applications/${seededApp!.id}`, heading: "Core Platform" },
{ path: `/applications/${seededApp!.id}/secrets`, heading: "Core Platform" },
- { path: `/applications/${seededApp!.id}/manage-environments`, heading: "Manage Environments" },
+ { path: `/applications/${seededApp!.id}/manage-environments`, heading: /Manage Environments|Environments/ },
{ path: `/applications/${seededApp!.id}/access`, heading: "Project Access" },
{ path: `/applications/pit/${seededApp!.id}`, heading: "Core Platform" },
{ path: "/roles", heading: "Roles" },
diff --git a/apps/envsync-web/e2e/specs/smoke/core.spec.ts b/apps/envsync-web/e2e/specs/smoke/core.spec.ts
index 3e716364..643f2dd5 100644
--- a/apps/envsync-web/e2e/specs/smoke/core.spec.ts
+++ b/apps/envsync-web/e2e/specs/smoke/core.spec.ts
@@ -14,8 +14,8 @@ test.describe("UI smoke", () => {
test("covers dashboard, project create, variable CRUD, secret CRUD, certificates and settings", async ({ page, makeName }) => {
await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
- await expect(page.getByText("Quick Actions")).toBeVisible();
- await expect(page.getByTestId("dashboard-stat-config-items")).toBeVisible();
+ await expect(page.getByText("Projects").first()).toBeVisible();
+ await expect(page.getByText("Security").first()).toBeVisible();
const projectName = makeName("UI_SMOKE_APP");
const { appId } = await createProject(page, projectName);
diff --git a/apps/envsync-web/package.json b/apps/envsync-web/package.json
index 6454b4be..fc14cd72 100644
--- a/apps/envsync-web/package.json
+++ b/apps/envsync-web/package.json
@@ -1,7 +1,7 @@
{
"name": "envsync-web",
"private": true,
- "version": "0.9.1",
+ "version": "0.10.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/apps/envsync-web/public/runtime-config.js b/apps/envsync-web/public/runtime-config.js
index 7a24aeb4..60d1ce7c 100644
--- a/apps/envsync-web/public/runtime-config.js
+++ b/apps/envsync-web/public/runtime-config.js
@@ -2,7 +2,7 @@ window.__ENVSYNC_RUNTIME_CONFIG__ = {
apiBaseUrl: "http://api.lvh.me:4000",
appBaseUrl: "http://app.lvh.me:8001",
authBaseUrl: "http://auth.lvh.me:8080",
- managementApiUrl: "http://localhost:4001",
+ managementApiUrl: "http://manage-api.lvh.me:4001",
keycloakRealm: "envsync",
webClientId: "envsync-web",
apiDocsUrl: "http://api.lvh.me:4000/docs",
diff --git a/apps/envsync-web/src/api/enterprise/types.ts b/apps/envsync-web/src/api/enterprise/types.ts
index e33b7d41..be2d22ca 100644
--- a/apps/envsync-web/src/api/enterprise/types.ts
+++ b/apps/envsync-web/src/api/enterprise/types.ts
@@ -12,7 +12,30 @@ export type EnterpriseProvider =
| "gitlab"
| "aws-ssm"
| "vercel"
- | "google-secret-manager";
+ | "google-secret-manager"
+ | "circleci"
+ | "jenkins"
+ | "azure-devops"
+ | "bitbucket"
+ | "travisci"
+ | "netlify"
+ | "railway"
+ | "fly-io"
+ | "render"
+ | "supabase"
+ | "digitalocean-app-platform"
+ | "azure-key-vault"
+ | "aws-secrets-manager"
+ | "cloudflare-workers"
+ | "azure-app-service"
+ | "codefresh"
+ | "deno-deploy"
+ | "harness"
+ | "hasura-cloud"
+ | "heroku"
+ | "laravel-forge"
+ | "qovery"
+ | "terraform-cloud";
export type {
EnvTypeMapping,
diff --git a/apps/envsync-web/src/components/ProjectEnvironments.tsx b/apps/envsync-web/src/components/ProjectEnvironments.tsx
index 1f58a99b..c91a2e12 100644
--- a/apps/envsync-web/src/components/ProjectEnvironments.tsx
+++ b/apps/envsync-web/src/components/ProjectEnvironments.tsx
@@ -162,7 +162,6 @@ export const ProjectEnvironments = ({
// Set initial selected environment when project data loads
useEffect(() => {
- console.log("Project data loaded:", projectData);
if (projectData?.environments.length > 0 && !selectedEnv) {
setSelectedEnv(projectData.environments[0].id);
}
diff --git a/apps/envsync-web/src/components/applications/ApplicationCard.tsx b/apps/envsync-web/src/components/applications/ApplicationCard.tsx
index f3d03907..6315022a 100644
--- a/apps/envsync-web/src/components/applications/ApplicationCard.tsx
+++ b/apps/envsync-web/src/components/applications/ApplicationCard.tsx
@@ -1,6 +1,5 @@
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
-import { MoreHorizontal, Eye, Edit, Trash2, Key } from "lucide-react";
+import { MoreHorizontal, Eye, Edit, Trash2, ChevronRight, Database } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
@@ -49,98 +48,73 @@ export const ApplicationCard = ({
};
return (
-
-
- navigate(appDetailPath(app.id))}
- className="flex items-start justify-between"
- >
-
-
-
- {app.name.charAt(0).toUpperCase()}
-
-
-
-
- {app.name}
-
-
-
-
- {canEdit && (
-
-
- e.stopPropagation()}
- >
-
-
-
-
- {
- e.stopPropagation();
- onView(app);
- }}
- >
-
- View Details
-
- {
- e.stopPropagation();
- onEdit(app);
- }}
- >
-
- Edit Project
-
- {
- e.stopPropagation();
- onDelete(app);
- }}
- >
-
- Delete Project
-
-
-
- )}
+
navigate(appDetailPath(app.id))}
+ >
+
+
+
-
-
-
navigate(appDetailPath(app.id))}
- >
-
- {app.description || "No description provided"}
-
-
-
-
-
-
- {configItemCount} vars / secrets
-
-
-
-
- {getRelativeTime(app.updated_at)}
-
+
+
+ {app.name}
+
+
+ {app.description || "No description"} · {configItemCount} config items · Updated {getRelativeTime(app.updated_at)}
+
-
-
+
+
+
+ {canEdit && (
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+ {
+ e.stopPropagation();
+ onView(app);
+ }}
+ >
+
+ View Details
+
+ {
+ e.stopPropagation();
+ onEdit(app);
+ }}
+ >
+
+ Edit Project
+
+ {
+ e.stopPropagation();
+ onDelete(app);
+ }}
+ >
+
+ Delete Project
+
+
+
+ )}
+
+
+
);
};
diff --git a/apps/envsync-web/src/components/applications/ApplicationsGrid.tsx b/apps/envsync-web/src/components/applications/ApplicationsGrid.tsx
index 16c4a6f0..587580d3 100644
--- a/apps/envsync-web/src/components/applications/ApplicationsGrid.tsx
+++ b/apps/envsync-web/src/components/applications/ApplicationsGrid.tsx
@@ -43,7 +43,7 @@ export const ApplicationsGrid = ({
}
return (
-
+
{apps.map((app) => (
void;
+ providerFilter?: EnterpriseProvider;
+}
+
+export function CreateOrgSecretModal({
+ open,
+ onOpenChange,
+ providerFilter,
+}: CreateOrgSecretModalProps) {
+ const createOrgSecret = useCreateOrgSecret();
+
+ const [form, setForm] = useState({
+ key: "",
+ value: "",
+ description: "",
+ providerRefs: providerFilter ?? "",
+ rotationPolicy: "manual",
+ metadataRaw: "{}",
+ });
+
+ useEffect(() => {
+ if (!open) return;
+ setForm({
+ key: "",
+ value: "",
+ description: "",
+ providerRefs: providerFilter ?? "",
+ rotationPolicy: "manual",
+ metadataRaw: "{}",
+ });
+ }, [open, providerFilter]);
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ try {
+ const providerRefs = form.providerRefs
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean);
+ await createOrgSecret.mutateAsync({
+ key: form.key,
+ value: form.value,
+ description: form.description || null,
+ metadata: {
+ ...JSON.parse(form.metadataRaw) as Record,
+ ...(providerRefs.length > 0 ? { provider_refs: providerRefs } : {}),
+ ...(form.rotationPolicy ? { rotation_policy: form.rotationPolicy } : {}),
+ },
+ });
+ toast.success("Org secret created.");
+ onOpenChange(false);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to create org secret.");
+ }
+ };
+
+ return (
+
+
+
+ Create Org Secret
+
+ Create reusable secret material once, then reference it from provider connections and project mappings.
+
+
+
+
+
+
+ );
+}
diff --git a/apps/envsync-web/src/components/enterprise/CreateProviderConnectionModal.tsx b/apps/envsync-web/src/components/enterprise/CreateProviderConnectionModal.tsx
new file mode 100644
index 00000000..a07ed439
--- /dev/null
+++ b/apps/envsync-web/src/components/enterprise/CreateProviderConnectionModal.tsx
@@ -0,0 +1,294 @@
+import { useState, useEffect } from "react";
+import { toast } from "sonner";
+
+import {
+ useCreateProviderConnection,
+ useOrgSecrets,
+} from "@/api/enterprise/hooks";
+import type { EnterpriseProvider } from "@/api/enterprise/types";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import {
+ emptyFieldValues,
+ enterpriseProviderUi,
+ mergeFieldValuesIntoRecord,
+ type ProviderFieldConfig,
+} from "@/lib/enterprise-provider-ui";
+
+type FieldState = Record;
+
+function FieldHint({ text }: { text?: string }) {
+ if (!text) return null;
+ return {text}
;
+}
+
+function ProviderFieldEditor({
+ field,
+ value,
+ onChange,
+ secretOptions = [],
+}: {
+ field: ProviderFieldConfig;
+ value: string;
+ onChange: (value: string) => void;
+ secretOptions?: string[];
+}) {
+ const commonClassName = "flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white";
+ const listId = `${field.key}-secret-options`;
+
+ return (
+
+ {field.label}
+ {field.kind === "select" ? (
+ onChange(event.target.value)}
+ className={commonClassName}
+ >
+ {(field.options ?? []).map((option) => (
+
+ {option.label}
+
+ ))}
+
+ ) : (
+ <>
+ onChange(event.target.value)}
+ placeholder={field.placeholder}
+ list={field.kind === "secret-ref" ? listId : undefined}
+ />
+ {field.kind === "secret-ref" && secretOptions.length > 0 && (
+
+ {secretOptions.map((option) => (
+
+ ))}
+
+ )}
+ >
+ )}
+
+
+ );
+}
+
+interface CreateProviderConnectionModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ providerFilter?: EnterpriseProvider;
+}
+
+export function CreateProviderConnectionModal({
+ open,
+ onOpenChange,
+ providerFilter,
+}: CreateProviderConnectionModalProps) {
+ const createProviderConnection = useCreateProviderConnection();
+ const { data: orgSecrets = [] } = useOrgSecrets();
+ const orgSecretKeys = orgSecrets.map((secret) => secret.key);
+
+ const [form, setForm] = useState({
+ provider_type: providerFilter ?? ("github" as EnterpriseProvider),
+ name: "",
+ status: "active" as "active" | "inactive" | "error",
+ authFields: emptyFieldValues(enterpriseProviderUi[providerFilter ?? "github"].connectionAuthFields),
+ metadataFields: emptyFieldValues(enterpriseProviderUi[providerFilter ?? "github"].connectionMetadataFields),
+ authRaw: "{}",
+ metadataRaw: "{}",
+ });
+
+ const providerConfig = enterpriseProviderUi[form.provider_type];
+
+ useEffect(() => {
+ if (!open) return;
+ const defaultProvider = providerFilter ?? "github";
+ setForm({
+ provider_type: defaultProvider,
+ name: "",
+ status: "active",
+ authFields: emptyFieldValues(enterpriseProviderUi[defaultProvider].connectionAuthFields),
+ metadataFields: emptyFieldValues(enterpriseProviderUi[defaultProvider].connectionMetadataFields),
+ authRaw: "{}",
+ metadataRaw: "{}",
+ });
+ }, [open, providerFilter]);
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ try {
+ await createProviderConnection.mutateAsync({
+ provider_type: form.provider_type,
+ name: form.name,
+ status: form.status,
+ auth_config: mergeFieldValuesIntoRecord(
+ providerConfig.connectionAuthFields,
+ form.authFields,
+ JSON.parse(form.authRaw) as Record,
+ ),
+ metadata: mergeFieldValuesIntoRecord(
+ providerConfig.connectionMetadataFields,
+ form.metadataFields,
+ JSON.parse(form.metadataRaw) as Record,
+ ),
+ });
+ toast.success("Provider connection created.");
+ onOpenChange(false);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to create provider connection.");
+ }
+ };
+
+ return (
+
+
+
+ Create Provider Connection
+
+ {providerConfig.providerHeadline}
+
+
+
+
+ {!providerFilter && (
+
+ Provider
+
+ setForm({
+ provider_type: event.target.value as EnterpriseProvider,
+ name: "",
+ status: "active",
+ authFields: emptyFieldValues(enterpriseProviderUi[event.target.value as EnterpriseProvider].connectionAuthFields),
+ metadataFields: emptyFieldValues(enterpriseProviderUi[event.target.value as EnterpriseProvider].connectionMetadataFields),
+ authRaw: "{}",
+ metadataRaw: "{}",
+ })
+ }
+ className="flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white"
+ >
+ {Object.values(enterpriseProviderUi).map((provider) => (
+
+ {provider.name}
+
+ ))}
+
+
+ )}
+
+
+ Connection name
+ setForm((prev) => ({ ...prev, name: event.target.value }))}
+ placeholder={`${providerConfig.name} production`}
+ className="border-zinc-700 bg-zinc-900 text-zinc-100"
+ />
+
+
+
+ Status
+ setForm((prev) => ({ ...prev, status: event.target.value as "active" | "inactive" | "error" }))}
+ className="flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white"
+ >
+ active
+ inactive
+ error
+
+
+
+
+
Auth fields
+ {providerConfig.connectionAuthFields.map((field) => (
+
+ setForm((prev) => ({
+ ...prev,
+ authFields: { ...prev.authFields, [field.key]: value },
+ }))
+ }
+ secretOptions={field.kind === "secret-ref" ? orgSecretKeys : []}
+ />
+ ))}
+
+
+
+
Metadata fields
+ {providerConfig.connectionMetadataFields.map((field) => (
+
+ setForm((prev) => ({
+ ...prev,
+ metadataFields: { ...prev.metadataFields, [field.key]: value },
+ }))
+ }
+ />
+ ))}
+
+
+
+ Advanced JSON
+
+
+ Additional auth config
+ setForm((prev) => ({ ...prev, authRaw: event.target.value }))}
+ className="min-h-[80px] border-zinc-700 bg-zinc-900 text-zinc-100 font-mono text-xs"
+ />
+
+
+ Additional metadata
+ setForm((prev) => ({ ...prev, metadataRaw: event.target.value }))}
+ className="min-h-[80px] border-zinc-700 bg-zinc-900 text-zinc-100 font-mono text-xs"
+ />
+
+
+
+
+
+ onOpenChange(false)}
+ className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
+ >
+ Cancel
+
+
+ {createProviderConnection.isPending ? "Creating..." : "Create"}
+
+
+
+
+
+ );
+}
diff --git a/apps/envsync-web/src/components/enterprise/EnterpriseOrgAssetsPanel.tsx b/apps/envsync-web/src/components/enterprise/EnterpriseOrgAssetsPanel.tsx
index 53e17600..685f2f28 100644
--- a/apps/envsync-web/src/components/enterprise/EnterpriseOrgAssetsPanel.tsx
+++ b/apps/envsync-web/src/components/enterprise/EnterpriseOrgAssetsPanel.tsx
@@ -1,22 +1,15 @@
import { useEffect, useMemo, useState } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
-import { toast } from "sonner";
import { sdk } from "@/api";
import {
listIntegrationBindings,
- useCreateOrgSecret,
- useCreateProviderConnection,
useOrgSecrets,
useProviderConnections,
- useUpdateOrgSecret,
- useUpdateProviderConnection,
} from "@/api/enterprise/hooks";
import type { EnterpriseProvider, OrgSecret, ProviderConnection } from "@/api/enterprise/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Textarea } from "@/components/ui/textarea";
import {
emptyFieldValues,
enterpriseProviderUi,
@@ -45,11 +38,6 @@ type OrgSecretDraft = {
metadataRaw: string;
};
-function parseRecord(text: string) {
- if (!text.trim()) return {};
- return JSON.parse(text) as Record;
-}
-
function stringifyRecord(value: Record) {
return JSON.stringify(value ?? {}, null, 2);
}
@@ -73,62 +61,6 @@ function isSecretRelevant(secret: OrgSecret, providerFilter?: EnterpriseProvider
return refs.length === 0 || refs.includes(providerFilter);
}
-function FieldHint({ text }: { text?: string }) {
- if (!text) return null;
- return {text}
;
-}
-
-function ProviderFieldEditor({
- field,
- value,
- onChange,
- secretOptions = [],
-}: {
- field: ProviderFieldConfig;
- value: string;
- onChange: (value: string) => void;
- secretOptions?: string[];
-}) {
- const commonClassName = "flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white";
- const listId = `${field.key}-secret-options`;
-
- return (
-
- {field.label}
- {field.kind === "select" ? (
- onChange(event.target.value)}
- className={commonClassName}
- >
- {(field.options ?? []).map((option) => (
-
- {option.label}
-
- ))}
-
- ) : (
- <>
- onChange(event.target.value)}
- placeholder={field.placeholder}
- list={field.kind === "secret-ref" ? listId : undefined}
- />
- {field.kind === "secret-ref" && secretOptions.length > 0 && (
-
- {secretOptions.map((option) => (
-
- ))}
-
- )}
- >
- )}
-
-
- );
-}
-
export function EnterpriseOrgAssetsPanel({
providerFilter,
compact = false,
@@ -138,34 +70,11 @@ export function EnterpriseOrgAssetsPanel({
compact?: boolean;
showUsage?: boolean;
}) {
- const [providerForm, setProviderForm] = useState({
- provider_type: providerFilter ?? ("github" as EnterpriseProvider),
- name: "",
- status: "active" as "active" | "inactive" | "error",
- authFields: emptyFieldValues(enterpriseProviderUi[providerFilter ?? "github"].connectionAuthFields),
- metadataFields: emptyFieldValues(enterpriseProviderUi[providerFilter ?? "github"].connectionMetadataFields),
- authRaw: "{}",
- metadataRaw: "{}",
- });
- const [orgSecretForm, setOrgSecretForm] = useState({
- key: "",
- value: "",
- description: "",
- providerRefs: providerFilter ?? "",
- rotationPolicy: "manual",
- metadataRaw: "{}",
- });
const [connectionDrafts, setConnectionDrafts] = useState>({});
const [secretDrafts, setSecretDrafts] = useState>({});
- const providerConfig = enterpriseProviderUi[providerForm.provider_type];
const { data: providerConnections = [] } = useProviderConnections();
const { data: orgSecrets = [] } = useOrgSecrets();
- const createProviderConnection = useCreateProviderConnection();
- const updateProviderConnection = useUpdateProviderConnection();
- const createOrgSecret = useCreateOrgSecret();
- const updateOrgSecret = useUpdateOrgSecret();
- const orgSecretKeys = useMemo(() => orgSecrets.map((secret) => secret.key), [orgSecrets]);
const filteredConnections = useMemo(
() => providerConnections.filter((connection) => !providerFilter || connection.provider_type === providerFilter),
@@ -176,33 +85,6 @@ export function EnterpriseOrgAssetsPanel({
[orgSecrets, providerFilter],
);
- useEffect(() => {
- if (!providerFilter) return;
- setProviderForm((prev) => ({
- ...prev,
- provider_type: providerFilter,
- authFields: emptyFieldValues(enterpriseProviderUi[providerFilter].connectionAuthFields),
- metadataFields: emptyFieldValues(enterpriseProviderUi[providerFilter].connectionMetadataFields),
- authRaw: "{}",
- metadataRaw: "{}",
- }));
- setOrgSecretForm((prev) => ({
- ...prev,
- providerRefs: providerFilter,
- }));
- }, [providerFilter]);
-
- useEffect(() => {
- if (providerFilter) return;
- setProviderForm((prev) => ({
- ...prev,
- authFields: emptyFieldValues(providerConfig.connectionAuthFields),
- metadataFields: emptyFieldValues(providerConfig.connectionMetadataFields),
- authRaw: "{}",
- metadataRaw: "{}",
- }));
- }, [providerConfig, providerFilter]);
-
useEffect(() => {
setConnectionDrafts(
Object.fromEntries(
@@ -272,635 +154,77 @@ export function EnterpriseOrgAssetsPanel({
return usage;
}, [apps, bindingQueries, showUsage]);
- const handleCreateProviderConnection = async (event: React.FormEvent) => {
- event.preventDefault();
- try {
- await createProviderConnection.mutateAsync({
- provider_type: providerForm.provider_type,
- name: providerForm.name,
- status: providerForm.status,
- auth_config: mergeFieldValuesIntoRecord(
- providerConfig.connectionAuthFields,
- providerForm.authFields,
- parseRecord(providerForm.authRaw),
- ),
- metadata: mergeFieldValuesIntoRecord(
- providerConfig.connectionMetadataFields,
- providerForm.metadataFields,
- parseRecord(providerForm.metadataRaw),
- ),
- });
- setProviderForm({
- provider_type: providerFilter ?? providerForm.provider_type,
- name: "",
- status: "active",
- authFields: emptyFieldValues(providerConfig.connectionAuthFields),
- metadataFields: emptyFieldValues(providerConfig.connectionMetadataFields),
- authRaw: "{}",
- metadataRaw: "{}",
- });
- toast.success("Provider connection created.");
- } catch (error) {
- toast.error(error instanceof Error ? error.message : "Failed to create provider connection.");
- }
- };
-
- const handleUpdateProviderConnection = async (connection: ProviderConnection) => {
- const draft = connectionDrafts[connection.id];
- if (!draft) return;
- const config = enterpriseProviderUi[connection.provider_type];
-
- try {
- await updateProviderConnection.mutateAsync({
- id: connection.id,
- name: draft.name,
- status: draft.status,
- auth_config: mergeFieldValuesIntoRecord(
- config.connectionAuthFields,
- draft.authFields,
- parseRecord(draft.authRaw),
- ),
- metadata: mergeFieldValuesIntoRecord(
- config.connectionMetadataFields,
- draft.metadataFields,
- parseRecord(draft.metadataRaw),
- ),
- });
- toast.success("Provider connection updated.");
- } catch (error) {
- toast.error(error instanceof Error ? error.message : "Failed to update provider connection.");
- }
- };
-
- const handleCreateOrgSecret = async (event: React.FormEvent) => {
- event.preventDefault();
- try {
- const providerRefs = orgSecretForm.providerRefs
- .split(",")
- .map((value) => value.trim())
- .filter(Boolean);
- await createOrgSecret.mutateAsync({
- key: orgSecretForm.key,
- value: orgSecretForm.value,
- description: orgSecretForm.description || null,
- metadata: {
- ...parseRecord(orgSecretForm.metadataRaw),
- ...(providerRefs.length > 0 ? { provider_refs: providerRefs } : {}),
- ...(orgSecretForm.rotationPolicy ? { rotation_policy: orgSecretForm.rotationPolicy } : {}),
- },
- });
- setOrgSecretForm({
- key: "",
- value: "",
- description: "",
- providerRefs: providerFilter ?? "",
- rotationPolicy: "manual",
- metadataRaw: "{}",
- });
- toast.success("Org secret created.");
- } catch (error) {
- toast.error(error instanceof Error ? error.message : "Failed to create org secret.");
- }
- };
-
- const handleUpdateOrgSecret = async (secret: OrgSecret) => {
- const draft = secretDrafts[secret.id];
- if (!draft) return;
- try {
- const providerRefs = draft.providerRefs
- .split(",")
- .map((value) => value.trim())
- .filter(Boolean);
- await updateOrgSecret.mutateAsync({
- id: secret.id,
- value: draft.value,
- description: draft.description || null,
- metadata: {
- ...parseRecord(draft.metadataRaw),
- ...(providerRefs.length > 0 ? { provider_refs: providerRefs } : {}),
- ...(draft.rotationPolicy ? { rotation_policy: draft.rotationPolicy } : {}),
- },
- });
- toast.success("Org secret updated.");
- } catch (error) {
- toast.error(error instanceof Error ? error.message : "Failed to update org secret.");
- }
- };
-
return (
-
-
-
- Create provider connection
- {providerConfig.providerHeadline}
-
-
- {!providerFilter && (
-
- Provider
-
- setProviderForm({
- provider_type: event.target.value as EnterpriseProvider,
- name: "",
- status: "active",
- authFields: emptyFieldValues(enterpriseProviderUi[event.target.value as EnterpriseProvider].connectionAuthFields),
- metadataFields: emptyFieldValues(enterpriseProviderUi[event.target.value as EnterpriseProvider].connectionMetadataFields),
- authRaw: "{}",
- metadataRaw: "{}",
- })
- }
- className="flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white"
- >
- {Object.values(enterpriseProviderUi).map((provider) => (
-
- {provider.name}
-
- ))}
-
-
- )}
-
-
- Connection name
- setProviderForm((prev) => ({ ...prev, name: event.target.value }))}
- placeholder={`${providerConfig.name} production`}
- />
-
-
-
- Status
- setProviderForm((prev) => ({ ...prev, status: event.target.value as ProviderConnectionDraft["status"] }))}
- className="flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white"
- >
- active
- inactive
- error
-
-
-
-
- {providerConfig.connectionAuthFields.map((field) => (
-
- setProviderForm((prev) => ({
- ...prev,
- authFields: {
- ...prev.authFields,
- [field.key]: value,
- },
- }))
- }
- secretOptions={field.kind === "secret-ref" ? orgSecretKeys : []}
- />
- ))}
-
-
-
- {providerConfig.connectionMetadataFields.map((field) => (
-
- setProviderForm((prev) => ({
- ...prev,
- metadataFields: {
- ...prev.metadataFields,
- [field.key]: value,
- },
- }))
- }
- />
- ))}
-
-
-
- Advanced auth and metadata JSON
-
-
- Additional auth config
- setProviderForm((prev) => ({ ...prev, authRaw: event.target.value }))}
- className="min-h-[110px]"
- />
-
-
- Additional metadata
- setProviderForm((prev) => ({ ...prev, metadataRaw: event.target.value }))}
- className="min-h-[110px]"
- />
-
-
-
-
-
- Create provider connection
-
-
-
-
-
-
-
-
-
-
-
-
Provider connections
-
- {providerFilter ? `Connections available for ${enterpriseProviderUi[providerFilter].name}.` : "Reusable enterprise provider credentials for this organization."}
-
-
+
+
+
+
+
Provider Connections
+
+ {providerFilter ? `${enterpriseProviderUi[providerFilter].name} connections.` : "Org-level credentials."}
+
-
-
- {filteredConnections.slice(0, compact ? 3 : filteredConnections.length).map((connection) => {
- const config = enterpriseProviderUi[connection.provider_type];
- const draft = connectionDrafts[connection.id];
- if (!draft) return null;
- const usage = connectionUsage.get(connection.id) ?? [];
-
- return (
-
-
-
-
{connection.name}
-
- {connection.provider_type} · updated {new Date(connection.updated_at).toLocaleDateString()}
-
- {showUsage && usage.length > 0 && (
-
- Used by {usage.map((entry) => entry.name).join(", ")}
-
- )}
-
-
- {draft.status}
-
-
-
- {!compact && (
- <>
-
-
- Connection name
-
- setConnectionDrafts((prev) => ({
- ...prev,
- [connection.id]: {
- ...draft,
- name: event.target.value,
- },
- }))
- }
- />
-
-
- Status
-
- setConnectionDrafts((prev) => ({
- ...prev,
- [connection.id]: {
- ...draft,
- status: event.target.value as ProviderConnectionDraft["status"],
- },
- }))
- }
- className="flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white"
- >
- active
- inactive
- error
-
-
-
-
-
- {config.connectionAuthFields.map((field) => (
-
- setConnectionDrafts((prev) => ({
- ...prev,
- [connection.id]: {
- ...draft,
- authFields: {
- ...draft.authFields,
- [field.key]: value,
- },
- },
- }))
- }
- secretOptions={field.kind === "secret-ref" ? orgSecretKeys : []}
- />
- ))}
-
-
-
- {config.connectionMetadataFields.map((field) => (
-
- setConnectionDrafts((prev) => ({
- ...prev,
- [connection.id]: {
- ...draft,
- metadataFields: {
- ...draft.metadataFields,
- [field.key]: value,
- },
- },
- }))
- }
- />
- ))}
-
-
-
- Advanced auth and metadata JSON
-
-
- setConnectionDrafts((prev) => ({
- ...prev,
- [connection.id]: {
- ...draft,
- authRaw: event.target.value,
- },
- }))
- }
- className="min-h-[110px]"
- />
-
- setConnectionDrafts((prev) => ({
- ...prev,
- [connection.id]: {
- ...draft,
- metadataRaw: event.target.value,
- },
- }))
- }
- className="min-h-[110px]"
- />
-
-
-
-
- void handleUpdateProviderConnection(connection)}
- >
- Save connection
-
-
- >
- )}
+
+
+
+ {filteredConnections.slice(0, compact ? 3 : filteredConnections.length).map((connection) => {
+ const draft = connectionDrafts[connection.id];
+ if (!draft) return null;
+ const usage = connectionUsage.get(connection.id) ?? [];
+
+ return (
+
+
+
{connection.name}
+
+ {connection.provider_type} · {draft.status}
+ {showUsage && usage.length > 0 && ` · Used by ${usage.map((entry) => entry.name).join(", ")}`}
+
- );
- })}
- {filteredConnections.length === 0 && (
-
- No provider connections yet.
+
+ {new Date(connection.updated_at).toLocaleDateString()}
+
- )}
+ );
+ })}
+ {filteredConnections.length === 0 && (
+
No connections yet.
+ )}
+
+
+
+
+
+
+
Org Secrets
+
+ Reusable secret references for sync flows.
+
-
-
-
- Org secrets
-
- Reusable secret references available to enterprise sync flows.
-
-
-
- {filteredSecrets.slice(0, compact ? 4 : filteredSecrets.length).map((secret) => {
- const draft = secretDrafts[secret.id];
- if (!draft) return null;
- return (
-
-
-
-
{secret.key}
-
{draft.description || "No description"}
- {draft.providerRefs && (
-
Providers: {draft.providerRefs}
- )}
-
-
- {draft.rotationPolicy}
-
-
-
- {!compact && (
- <>
-
-
- Value
-
- setSecretDrafts((prev) => ({
- ...prev,
- [secret.id]: {
- ...draft,
- value: event.target.value,
- },
- }))
- }
- className="min-h-[110px]"
- />
-
-
-
- Description
-
- setSecretDrafts((prev) => ({
- ...prev,
- [secret.id]: {
- ...draft,
- description: event.target.value,
- },
- }))
- }
- />
-
-
- Provider refs
-
- setSecretDrafts((prev) => ({
- ...prev,
- [secret.id]: {
- ...draft,
- providerRefs: event.target.value,
- },
- }))
- }
- />
-
-
-
- Rotation policy
-
- setSecretDrafts((prev) => ({
- ...prev,
- [secret.id]: {
- ...draft,
- rotationPolicy: event.target.value,
- },
- }))
- }
- className="flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white"
- >
- manual
- scheduled
- external
-
-
-
-
-
- Advanced metadata JSON
-
-
- setSecretDrafts((prev) => ({
- ...prev,
- [secret.id]: {
- ...draft,
- metadataRaw: event.target.value,
- },
- }))
- }
- className="min-h-[110px]"
- />
-
-
-
-
- void handleUpdateOrgSecret(secret)}
- >
- Save secret
-
-
- >
- )}
+
+
+
+ {filteredSecrets.slice(0, compact ? 4 : filteredSecrets.length).map((secret) => {
+ const draft = secretDrafts[secret.id];
+ if (!draft) return null;
+ return (
+
+
+
{secret.key}
+
+ {draft.description || "No description"}
+ {draft.providerRefs && ` · ${draft.providerRefs}`}
+
- );
- })}
- {filteredSecrets.length === 0 && (
-
- No org secrets yet.
+ {draft.rotationPolicy}
- )}
-
-
-
+ );
+ })}
+ {filteredSecrets.length === 0 && (
+
No secrets yet.
+ )}
+
+
);
}
diff --git a/apps/envsync-web/src/components/enterprise/SyncRunsList.tsx b/apps/envsync-web/src/components/enterprise/SyncRunsList.tsx
new file mode 100644
index 00000000..49d8c34e
--- /dev/null
+++ b/apps/envsync-web/src/components/enterprise/SyncRunsList.tsx
@@ -0,0 +1,180 @@
+import { useMemo } from "react";
+import { Link } from "react-router-dom";
+import { Play, RefreshCw } from "lucide-react";
+
+import type { SyncRun } from "@/api/enterprise/types";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { cn, formatLastUsed } from "@/lib/utils";
+
+type SyncRunsListProps = {
+ syncRuns: SyncRun[];
+ onTriggerSync?: () => void;
+ isTriggering?: boolean;
+ basePath?: string;
+};
+
+const statusConfig: Record<
+ string,
+ { label: string; className: string }
+> = {
+ succeeded: {
+ label: "Success",
+ className:
+ "border-emerald-500/30 bg-emerald-500/10 text-emerald-400",
+ },
+ failed: {
+ label: "Failed",
+ className:
+ "border-red-500/30 bg-red-500/10 text-red-400",
+ },
+ pending: {
+ label: "Pending",
+ className:
+ "border-amber-500/30 bg-amber-500/10 text-amber-400",
+ },
+ running: {
+ label: "Running",
+ className:
+ "border-blue-500/30 bg-blue-500/10 text-blue-400",
+ },
+};
+
+function computeDuration(startedAt: string, completedAt?: string | null): string {
+ const start = new Date(startedAt).getTime();
+ const end = completedAt ? new Date(completedAt).getTime() : Date.now();
+ const diffMs = end - start;
+
+ if (diffMs < 1000) return `${diffMs}ms`;
+ const diffSec = Math.floor(diffMs / 1000);
+ if (diffSec < 60) return `${diffSec}s`;
+ const diffMin = Math.floor(diffSec / 60);
+ const remainingSec = diffSec % 60;
+ return `${diffMin}m ${remainingSec}s`;
+}
+
+const providerLabels: Record = {
+ github: "GitHub",
+ gitlab: "GitLab",
+ "aws-ssm": "AWS SSM",
+ vercel: "Vercel",
+ "google-secret-manager": "GCP Secret Manager",
+};
+
+function formatProviderType(providerType: string): string {
+ return providerLabels[providerType] ?? providerType;
+}
+
+export function SyncRunsList({
+ syncRuns,
+ onTriggerSync,
+ isTriggering = false,
+ basePath = "/enterprise/sync-runs",
+}: SyncRunsListProps) {
+ const sortedRuns = useMemo(
+ () =>
+ [...syncRuns].sort(
+ (a, b) =>
+ new Date(b.started_at).getTime() - new Date(a.started_at).getTime(),
+ ),
+ [syncRuns],
+ );
+
+ return (
+
+
+
+
Sync Runs
+
+ History of provider synchronization executions.
+
+
+ {onTriggerSync && (
+
+ {isTriggering ? (
+
+ ) : (
+
+ )}
+ Trigger Sync
+
+ )}
+
+
+ {sortedRuns.length === 0 ? (
+
+
+
No sync runs yet.
+
+ Trigger a sync to see execution history here.
+
+
+ ) : (
+
+
+
+
+ Provider
+ Status
+ Started
+ Duration
+ Actions
+
+
+
+ {sortedRuns.map((run) => {
+ const config = statusConfig[run.status];
+ const duration = computeDuration(
+ run.started_at,
+ run.completed_at,
+ );
+
+ return (
+
+
+
+ {formatProviderType(run.provider_type)}
+
+
+
+
+ {config.label}
+
+
+
+ {formatLastUsed(run.started_at)}
+
+
+ {duration}
+
+
+
+
+ View
+
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/envsync-web/src/hooks/useDashboard.ts b/apps/envsync-web/src/hooks/useDashboard.ts
index 14a5d23c..0a789c0e 100644
--- a/apps/envsync-web/src/hooks/useDashboard.ts
+++ b/apps/envsync-web/src/hooks/useDashboard.ts
@@ -9,6 +9,7 @@ export function useDashboard() {
const {
data: apps = [],
isLoading: appsLoading,
+ error: appsError,
} = Api.applications.allApplications({ enabled: authEnabled });
const {
@@ -64,6 +65,7 @@ export function useDashboard() {
};
const isLoading = appsLoading || usersLoading || apiKeysLoading;
+ const error = appsError ?? null;
const recentProjects = [...apps]
.sort(
@@ -79,5 +81,6 @@ export function useDashboard() {
isLoading,
auditLoading,
apps,
+ error,
};
}
diff --git a/apps/envsync-web/src/hooks/useInvitations.ts b/apps/envsync-web/src/hooks/useInvitations.ts
index 616628e7..b1f14353 100644
--- a/apps/envsync-web/src/hooks/useInvitations.ts
+++ b/apps/envsync-web/src/hooks/useInvitations.ts
@@ -115,7 +115,6 @@ export const useInvitations = () => {
queryClient.invalidateQueries({ queryKey: ["invitationsData"] });
queryClient.invalidateQueries({ queryKey: [API_KEYS.ALL_USERS] });
setActionLoading(inviteId, false);
- console.log("Invitation deleted successfully");
},
onError: (error, inviteId) => {
console.error("Error deleting invitation:", error);
@@ -135,7 +134,6 @@ export const useInvitations = () => {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["invitationsData"] });
- console.log("Invitation role updated successfully");
},
onError: (error) => {
console.error("Error updating invitation role:", error);
diff --git a/apps/envsync-web/src/hooks/useUserSettings.ts b/apps/envsync-web/src/hooks/useUserSettings.ts
index cd11d419..99ac463b 100644
--- a/apps/envsync-web/src/hooks/useUserSettings.ts
+++ b/apps/envsync-web/src/hooks/useUserSettings.ts
@@ -186,7 +186,6 @@ export const useUserSettings = () => {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["userInfo"] });
setHasUnsavedChanges(false);
- console.log("User settings updated successfully");
},
onError: (error) => {
console.error("Failed to update user settings:", error);
@@ -200,7 +199,6 @@ export const useUserSettings = () => {
return await sdk.users.updatePassword(userData.id);
},
onSuccess: () => {
- console.log("Password reset successfully");
setIsPasswordResetDialogOpen(false);
},
onError: (error) => {
@@ -215,7 +213,6 @@ export const useUserSettings = () => {
return await sdk.users.deleteUser(userData.id);
},
onSuccess: () => {
- console.log("Organization membership deleted successfully");
window.location.href = "/login";
},
onError: (error) => {
diff --git a/apps/envsync-web/src/hooks/useUsers.ts b/apps/envsync-web/src/hooks/useUsers.ts
index 7ea8dadf..9d42b65d 100644
--- a/apps/envsync-web/src/hooks/useUsers.ts
+++ b/apps/envsync-web/src/hooks/useUsers.ts
@@ -132,7 +132,6 @@ export const useUsers = () => {
queryClient.invalidateQueries({ queryKey: [API_KEYS.USERS_PAGE] }),
]);
resetInviteForm();
- console.log("User invited successfully");
trackAction("user_invited", {
"envsync.event_name": "user_invited",
"envsync.event_category": "team",
@@ -158,7 +157,6 @@ export const useUsers = () => {
queryClient.invalidateQueries({ queryKey: [API_KEYS.USERS_PAGE] }),
]);
setActionLoading(userId, false);
- console.log("User deleted successfully");
},
onError: (error, userId) => {
console.error("Error deleting user:", error);
@@ -183,7 +181,6 @@ export const useUsers = () => {
queryClient.invalidateQueries({ queryKey: [API_KEYS.USERS_PAGE] }),
]);
setActionLoading(userId, false);
- console.log("User role updated successfully");
},
onError: (error, { userId }) => {
console.error("Error editing user role:", error);
diff --git a/apps/envsync-web/src/index.css b/apps/envsync-web/src/index.css
index 32502eeb..7ba088b4 100644
--- a/apps/envsync-web/src/index.css
+++ b/apps/envsync-web/src/index.css
@@ -168,3 +168,34 @@ img {
transform: translateY(0);
}
}
+
+/* Cloudflare-inspired smooth transitions */
+.transition-smooth {
+ transition-property: color, background-color, border-color, opacity;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+
+/* List row hover effect */
+.list-row-hover {
+ transition: background-color 150ms ease;
+}
+.list-row-hover:hover {
+ background-color: hsl(240 4% 16% / 0.5);
+}
+
+/* Card hover with subtle glow */
+.card-glow {
+ transition: border-color 200ms ease, box-shadow 200ms ease;
+}
+.card-glow:hover {
+ border-color: hsl(160 84% 39% / 0.3);
+ box-shadow: 0 0 20px hsl(160 84% 39% / 0.05);
+}
+
+/* Smooth focus styles */
+*:focus-visible {
+ outline: 2px solid hsl(160 84% 39% / 0.5);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
diff --git a/apps/envsync-web/src/lib/enterprise-provider-ui.ts b/apps/envsync-web/src/lib/enterprise-provider-ui.ts
index c9e465e5..b1a5c34c 100644
--- a/apps/envsync-web/src/lib/enterprise-provider-ui.ts
+++ b/apps/envsync-web/src/lib/enterprise-provider-ui.ts
@@ -261,6 +261,698 @@ export const enterpriseProviderUi: Record
{ key: "labels_csv", label: "Labels", kind: "text", placeholder: "team=platform,source=envsync" },
],
},
+ circleci: {
+ id: "circleci",
+ name: "CircleCI",
+ description: "Sync environment variables to CircleCI projects and contexts.",
+ title: "CircleCI environment mapping",
+ providerHeadline: "Store the CircleCI API token and organization context for this connection.",
+ targetLabel: "Project slug",
+ helper: "Choose the CircleCI project context, then map each environment type to the correct CircleCI environment.",
+ branchLabel: "Branch",
+ targetPlaceholder: "gh/org/repo",
+ branchPlaceholder: "main",
+ pathLabel: "Context path",
+ pathPlaceholder: "Not used for CircleCI",
+ usesBranch: false,
+ usesPath: false,
+ connectionAuthFields: [
+ { key: "api_token", label: "API token", kind: "secret-ref", placeholder: "circleci-api-token" },
+ { key: "org_id", label: "Organization ID", kind: "text", placeholder: "org-uuid" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "project_slug", label: "Project slug", kind: "text", placeholder: "gh/org/repo" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ { key: "context_name", label: "Context name", kind: "text", placeholder: "production" },
+ ],
+ },
+ jenkins: {
+ id: "jenkins",
+ name: "Jenkins",
+ description: "Sync credentials to Jenkins credential stores.",
+ title: "Jenkins credential mapping",
+ providerHeadline: "Store the Jenkins URL and API token for this connection.",
+ targetLabel: "Credential store",
+ helper: "Choose the Jenkins credential store, then map each environment type to the correct credential scope.",
+ branchLabel: "Branch",
+ targetPlaceholder: "_",
+ branchPlaceholder: "main",
+ pathLabel: "Credential prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "jenkins_url", label: "Jenkins URL", kind: "text", placeholder: "https://jenkins.example.com" },
+ { key: "api_token", label: "API token", kind: "secret-ref", placeholder: "jenkins-api-token" },
+ { key: "username", label: "Username", kind: "text", placeholder: "admin" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "credential_store", label: "Credential store", kind: "text", placeholder: "_" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ { key: "credential_scope", label: "Credential scope", kind: "text", placeholder: "GLOBAL" },
+ ],
+ },
+ "azure-devops": {
+ id: "azure-devops",
+ name: "Azure DevOps",
+ description: "Sync variables to Azure DevOps variable groups.",
+ title: "Azure DevOps variable mapping",
+ providerHeadline: "Store the Azure DevOps organization and PAT for this connection.",
+ targetLabel: "Variable group",
+ helper: "Choose the Azure DevOps variable group, then map each environment type to the correct variable scope.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-variable-group",
+ branchPlaceholder: "main",
+ pathLabel: "Variable prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "org_url", label: "Organization URL", kind: "text", placeholder: "https://dev.azure.com/myorg" },
+ { key: "pat", label: "Personal access token", kind: "secret-ref", placeholder: "azure-devops-pat" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "project", label: "Project name", kind: "text", placeholder: "my-project" },
+ { key: "variable_group_id", label: "Variable group ID", kind: "text", placeholder: "123" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ { key: "variable_scope", label: "Variable scope", kind: "text", placeholder: "release" },
+ ],
+ },
+ bitbucket: {
+ id: "bitbucket",
+ name: "Bitbucket",
+ description: "Sync deployment variables to Bitbucket repositories.",
+ title: "Bitbucket deployment mapping",
+ providerHeadline: "Store the Bitbucket workspace and app password for this connection.",
+ targetLabel: "Repository",
+ helper: "Choose the Bitbucket repository, then map each environment type to the correct deployment environment.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-org/my-repo",
+ branchPlaceholder: "main",
+ pathLabel: "Variable prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "workspace", label: "Workspace", kind: "text", placeholder: "my-org" },
+ { key: "app_password", label: "App password", kind: "secret-ref", placeholder: "bitbucket-app-password" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "repo_slug", label: "Repository slug", kind: "text", placeholder: "my-repo" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ { key: "environment", label: "Deployment environment", kind: "text", placeholder: "production" },
+ ],
+ },
+ travisci: {
+ id: "travisci",
+ name: "Travis CI",
+ description: "Sync environment variables to Travis CI repositories.",
+ title: "Travis CI environment mapping",
+ providerHeadline: "Store the Travis CI API token and organization for this connection.",
+ targetLabel: "Repository",
+ helper: "Choose the Travis CI repository, then map each environment type to the correct environment.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-org/my-repo",
+ branchPlaceholder: "main",
+ pathLabel: "Variable prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "api_token", label: "API token", kind: "secret-ref", placeholder: "travis-api-token" },
+ { key: "org_id", label: "Organization", kind: "text", placeholder: "my-org" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "repo_slug", label: "Repository slug", kind: "text", placeholder: "my-org/my-repo" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ { key: "environment", label: "Environment", kind: "text", placeholder: "production" },
+ ],
+ },
+ netlify: {
+ id: "netlify",
+ name: "Netlify",
+ description: "Sync environment variables to Netlify sites.",
+ title: "Netlify environment mapping",
+ providerHeadline: "Store the Netlify API token and site context for this connection.",
+ targetLabel: "Site",
+ helper: "Choose the Netlify site, then map each environment type to the correct environment.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-site",
+ branchPlaceholder: "main",
+ pathLabel: "Variable prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "api_token", label: "API token", kind: "secret-ref", placeholder: "netlify-api-token" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "site_id", label: "Site ID", kind: "text", placeholder: "my-site-id" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ { key: "context", label: "Deploy context", kind: "text", placeholder: "production" },
+ ],
+ },
+ railway: {
+ id: "railway",
+ name: "Railway",
+ description: "Sync variables to Railway services.",
+ title: "Railway variable mapping",
+ providerHeadline: "Store the Railway API token and project context for this connection.",
+ targetLabel: "Service",
+ helper: "Choose the Railway service, then map each environment type to the correct environment.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-service",
+ branchPlaceholder: "main",
+ pathLabel: "Variable prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "api_token", label: "API token", kind: "secret-ref", placeholder: "railway-api-token" },
+ { key: "project_id", label: "Project ID", kind: "text", placeholder: "project-uuid" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "service_id", label: "Service ID", kind: "text", placeholder: "service-uuid" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ { key: "environment_id", label: "Environment ID", kind: "text", placeholder: "env-uuid" },
+ ],
+ },
+ "fly-io": {
+ id: "fly-io",
+ name: "Fly.io",
+ description: "Sync secrets to Fly.io applications.",
+ title: "Fly.io secrets mapping",
+ providerHeadline: "Store the Fly.io API token and app context for this connection.",
+ targetLabel: "App",
+ helper: "Choose the Fly.io app, then map each environment type to the correct secrets scope.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-app",
+ branchPlaceholder: "main",
+ pathLabel: "Secret prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "api_token", label: "API token", kind: "secret-ref", placeholder: "flyio-api-token" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "app_name", label: "App name", kind: "text", placeholder: "my-app" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ render: {
+ id: "render",
+ name: "Render",
+ description: "Sync environment variables to Render services.",
+ title: "Render environment mapping",
+ providerHeadline: "Store the Render API key and service context for this connection.",
+ targetLabel: "Service",
+ helper: "Choose the Render service, then map each environment type to the correct environment.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-service",
+ branchPlaceholder: "main",
+ pathLabel: "Variable prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "api_key", label: "API key", kind: "secret-ref", placeholder: "render-api-key" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "service_id", label: "Service ID", kind: "text", placeholder: "srv-xxx" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ supabase: {
+ id: "supabase",
+ name: "Supabase",
+ description: "Sync secrets to Supabase projects.",
+ title: "Supabase secrets mapping",
+ providerHeadline: "Store the Supabase access token and project context for this connection.",
+ targetLabel: "Project",
+ helper: "Choose the Supabase project, then map each environment type to the correct secrets scope.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-project",
+ branchPlaceholder: "main",
+ pathLabel: "Secret prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "access_token", label: "Access token", kind: "secret-ref", placeholder: "supabase-access-token" },
+ { key: "project_ref", label: "Project ref", kind: "text", placeholder: "my-project-ref" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "project_ref", label: "Project ref", kind: "text", placeholder: "my-project-ref" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ "digitalocean-app-platform": {
+ id: "digitalocean-app-platform",
+ name: "DigitalOcean",
+ description: "Sync environment variables to DigitalOcean App Platform apps.",
+ title: "DigitalOcean app mapping",
+ providerHeadline: "Store the DigitalOcean API token and app context for this connection.",
+ targetLabel: "App",
+ helper: "Choose the DigitalOcean app, then map each environment type to the correct environment.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-app",
+ branchPlaceholder: "main",
+ pathLabel: "Variable prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "api_token", label: "API token", kind: "secret-ref", placeholder: "do-api-token" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "app_id", label: "App ID", kind: "text", placeholder: "app-uuid" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ { key: "environment", label: "Environment", kind: "text", placeholder: "production" },
+ ],
+ },
+ "azure-key-vault": {
+ id: "azure-key-vault",
+ name: "Azure Key Vault",
+ description: "Sync secrets to Azure Key Vault.",
+ title: "Azure Key Vault mapping",
+ providerHeadline: "Store the Azure credentials and vault context for this connection.",
+ targetLabel: "Vault",
+ helper: "Choose the Azure Key Vault, then map each environment type to the correct secret scope.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-vault",
+ branchPlaceholder: "main",
+ pathLabel: "Secret prefix",
+ pathPlaceholder: "ENVSYNC/",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "tenant_id", label: "Tenant ID", kind: "text", placeholder: "azure-tenant-id" },
+ { key: "client_id", label: "Client ID", kind: "text", placeholder: "azure-client-id" },
+ { key: "client_secret", label: "Client secret", kind: "secret-ref", placeholder: "azure-client-secret" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "vault_url", label: "Vault URL", kind: "text", placeholder: "https://my-vault.vault.azure.net" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ "aws-secrets-manager": {
+ id: "aws-secrets-manager",
+ name: "AWS Secrets Manager",
+ description: "Sync secrets to AWS Secrets Manager.",
+ title: "AWS Secrets Manager mapping",
+ providerHeadline: "Store the AWS credentials and region for this connection.",
+ targetLabel: "Secret path",
+ helper: "Choose the AWS Secrets Manager path, then map each environment type to the correct secret scope.",
+ branchLabel: "Branch",
+ targetPlaceholder: "/myapp/production",
+ branchPlaceholder: "main",
+ pathLabel: "Secret prefix",
+ pathPlaceholder: "/envsync/",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "access_key_id", label: "Access key ID", kind: "text", placeholder: "AKIAIOSFODNN7EXAMPLE" },
+ { key: "secret_access_key", label: "Secret access key", kind: "secret-ref", placeholder: "aws-secret-key" },
+ { key: "region", label: "Region", kind: "text", placeholder: "us-east-1" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "secret_path", label: "Secret path", kind: "text", placeholder: "/myapp/production" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ "cloudflare-workers": {
+ id: "cloudflare-workers",
+ name: "Cloudflare Workers",
+ description: "Sync secrets to Cloudflare Workers.",
+ title: "Cloudflare Workers secrets mapping",
+ providerHeadline: "Store the Cloudflare API token and account context for this connection.",
+ targetLabel: "Worker",
+ helper: "Choose the Cloudflare Worker, then map each environment type to the correct secrets scope.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-worker",
+ branchPlaceholder: "main",
+ pathLabel: "Secret prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "api_token", label: "API token", kind: "secret-ref", placeholder: "cf-api-token" },
+ { key: "account_id", label: "Account ID", kind: "text", placeholder: "cf-account-id" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "worker_name", label: "Worker name", kind: "text", placeholder: "my-worker" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ "azure-app-service": {
+ id: "azure-app-service",
+ name: "Azure App Service",
+ description: "Sync environment variables to Azure App Service application settings.",
+ title: "Azure App Service mapping",
+ providerHeadline: "Store the Azure credentials and app context for this connection.",
+ targetLabel: "App name",
+ helper: "Choose the Azure App Service, then map each environment type to the correct application settings.",
+ branchLabel: "Slot",
+ targetPlaceholder: "my-app",
+ branchPlaceholder: "production",
+ pathLabel: "Setting prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "tenant_id", label: "Tenant ID", kind: "text", placeholder: "azure-tenant-id" },
+ { key: "client_id", label: "Client ID", kind: "text", placeholder: "azure-client-id" },
+ { key: "client_secret", label: "Client secret", kind: "secret-ref", placeholder: "azure-client-secret" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "subscription_id", label: "Subscription ID", kind: "text", placeholder: "azure-subscription-id" },
+ { key: "resource_group", label: "Resource group", kind: "text", placeholder: "my-resource-group" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ codefresh: {
+ id: "codefresh",
+ name: "Codefresh",
+ description: "Sync variables to Codefresh pipelines.",
+ title: "Codefresh pipeline mapping",
+ providerHeadline: "Store the Codefresh API token for this connection.",
+ targetLabel: "Project",
+ helper: "Choose the Codefresh project, then map each environment type to the correct pipeline variables.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-project",
+ branchPlaceholder: "main",
+ pathLabel: "Variable prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "api_token", label: "API token", kind: "secret-ref", placeholder: "codefresh-api-token" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "project_id", label: "Project ID", kind: "text", placeholder: "my-project-id" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ "deno-deploy": {
+ id: "deno-deploy",
+ name: "Deno Deploy",
+ description: "Sync environment variables to Deno Deploy projects.",
+ title: "Deno Deploy mapping",
+ providerHeadline: "Store the Deno Deploy access token for this connection.",
+ targetLabel: "Project",
+ helper: "Choose the Deno Deploy project, then map each environment type to the correct environment variables.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-project",
+ branchPlaceholder: "main",
+ pathLabel: "Variable prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "token", label: "Access token", kind: "secret-ref", placeholder: "deno-deploy-token" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "project_id", label: "Project ID", kind: "text", placeholder: "my-project-id" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ harness: {
+ id: "harness",
+ name: "Harness",
+ description: "Sync secrets to Harness connectors.",
+ title: "Harness connector mapping",
+ providerHeadline: "Store the Harness API key for this connection.",
+ targetLabel: "Connector",
+ helper: "Choose the Harness connector, then map each environment type to the correct secrets.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-connector",
+ branchPlaceholder: "main",
+ pathLabel: "Secret prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "api_key", label: "API key", kind: "secret-ref", placeholder: "harness-api-key" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "connector_id", label: "Connector ID", kind: "text", placeholder: "my-connector-id" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ "hasura-cloud": {
+ id: "hasura-cloud",
+ name: "Hasura Cloud",
+ description: "Sync environment variables to Hasura Cloud projects.",
+ title: "Hasura Cloud mapping",
+ providerHeadline: "Store the Hasura Cloud API token for this connection.",
+ targetLabel: "Project",
+ helper: "Choose the Hasura Cloud project, then map each environment type to the correct environment variables.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-project",
+ branchPlaceholder: "main",
+ pathLabel: "Variable prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "api_token", label: "API token", kind: "secret-ref", placeholder: "hasura-api-token" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "project_id", label: "Project ID", kind: "text", placeholder: "my-project-id" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ heroku: {
+ id: "heroku",
+ name: "Heroku",
+ description: "Sync config vars to Heroku apps.",
+ title: "Heroku config mapping",
+ providerHeadline: "Store the Heroku API key for this connection.",
+ targetLabel: "App",
+ helper: "Choose the Heroku app, then map each environment type to the correct config vars.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-app",
+ branchPlaceholder: "main",
+ pathLabel: "Config prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "api_key", label: "API key", kind: "secret-ref", placeholder: "heroku-api-key" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "app_name", label: "App name", kind: "text", placeholder: "my-app" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ "laravel-forge": {
+ id: "laravel-forge",
+ name: "Laravel Forge",
+ description: "Sync environment variables to Laravel Forge servers.",
+ title: "Laravel Forge mapping",
+ providerHeadline: "Store the Laravel Forge API token for this connection.",
+ targetLabel: "Server",
+ helper: "Choose the Forge server, then map each environment type to the correct environment variables.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-server",
+ branchPlaceholder: "main",
+ pathLabel: "Variable prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "api_token", label: "API token", kind: "secret-ref", placeholder: "forge-api-token" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "server_id", label: "Server ID", kind: "text", placeholder: "my-server-id" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ qovery: {
+ id: "qovery",
+ name: "Qovery",
+ description: "Sync environment variables to Qovery environments.",
+ title: "Qovery environment mapping",
+ providerHeadline: "Store the Qovery API token for this connection.",
+ targetLabel: "Environment",
+ helper: "Choose the Qovery environment, then map each environment type to the correct variables.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-environment",
+ branchPlaceholder: "main",
+ pathLabel: "Variable prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "token", label: "API token", kind: "secret-ref", placeholder: "qovery-token" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "environment_id", label: "Environment ID", kind: "text", placeholder: "my-environment-id" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
+ "terraform-cloud": {
+ id: "terraform-cloud",
+ name: "Terraform Cloud",
+ description: "Sync variables to Terraform Cloud workspaces.",
+ title: "Terraform Cloud mapping",
+ providerHeadline: "Store the Terraform Cloud API token for this connection.",
+ targetLabel: "Workspace",
+ helper: "Choose the Terraform Cloud workspace, then map each environment type to the correct variables.",
+ branchLabel: "Branch",
+ targetPlaceholder: "my-workspace",
+ branchPlaceholder: "main",
+ pathLabel: "Variable prefix",
+ pathPlaceholder: "ENVSYNC_",
+ usesBranch: false,
+ usesPath: true,
+ connectionAuthFields: [
+ { key: "token", label: "API token", kind: "secret-ref", placeholder: "tfc-token" },
+ ],
+ connectionMetadataFields: [
+ secretPrefixField,
+ ],
+ bindingFields: [
+ { key: "workspace_id", label: "Workspace ID", kind: "text", placeholder: "my-workspace-id" },
+ secretPrefixField,
+ ],
+ mappingFields: [
+ secretPrefixField,
+ ],
+ },
};
export function emptyFieldValues(fields: ProviderFieldConfig[]) {
diff --git a/apps/envsync-web/src/pages/ApiKeys/index.tsx b/apps/envsync-web/src/pages/ApiKeys/index.tsx
index 6f403823..cc05e45c 100644
--- a/apps/envsync-web/src/pages/ApiKeys/index.tsx
+++ b/apps/envsync-web/src/pages/ApiKeys/index.tsx
@@ -19,6 +19,8 @@ import { useCopy } from "@/hooks/useClipboard";
import { Count } from "@/components/ui/count";
import { ApiKeyRow } from "@/components/api-keys/row";
import { EmptyApiKeys } from "./empty";
+import { PageShell } from "@/components/PageShell";
+import { PageError } from "@/components/ui/page-error";
export const ApiKeys = () => {
const copy = useCopy();
@@ -36,7 +38,7 @@ export const ApiKeys = () => {
setActionLoadingStates((prev) => ({ ...prev, [keyId]: loading }));
}, []);
- const { data: apiKeys, isLoading } = api.apiKeys.getApiKeys();
+ const { data: apiKeys, isLoading, error: apiKeysError, refetch: refetchApiKeys } = api.apiKeys.getApiKeys();
const createApiKey = api.apiKeys.createApiKey({
onSuccess: ({ data }) => {
@@ -133,23 +135,25 @@ export const ApiKeys = () => {
[actionLoadingStates, updateApiKey]
);
- const isEmpty = !isLoading && apiKeys.length === 0;
+ const isEmpty = !isLoading && !apiKeysError && apiKeys.length === 0;
+
+ if (apiKeysError) {
+ return (
+ refetchApiKeys()}
+ />
+ );
+ }
return (
-
-
-
-
-
-
-
API Keys
-
- Manage your API keys for accessing EnvSync services
-
-
-
-
+
{/* Created Key Modal */}
{
-
@@ -357,6 +360,7 @@ export const ApiKeys = () => {
)}
+
);
};
diff --git a/apps/envsync-web/src/pages/Dashboard/index.tsx b/apps/envsync-web/src/pages/Dashboard/index.tsx
index 36bb61c5..0d2349cc 100644
--- a/apps/envsync-web/src/pages/Dashboard/index.tsx
+++ b/apps/envsync-web/src/pages/Dashboard/index.tsx
@@ -1,17 +1,26 @@
-import { LayoutDashboard } from "lucide-react";
+import { LayoutDashboard, Plus, UserPlus, Key, Activity, ArrowRight, Database, Variable, Users, Shield, Clock, ChevronRight, KeyRound, ShieldCheck } from "lucide-react";
import { Link } from "react-router-dom";
import { PageShell } from "@/components/PageShell";
+import { PageError } from "@/components/ui/page-error";
import { OnboardingBanner } from "@/components/OnboardingBanner";
-import { BentoGrid, BentoGridItem } from "@/components/ui/bento-grid";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
import { useDashboard } from "@/hooks/useDashboard";
-import { BentoStatCard, statCardConfigs } from "./StatsCards";
-import { QuickActions } from "./QuickActions";
-import { RecentActivity } from "./RecentActivity";
-import { ProjectsOverview } from "./ProjectsOverview";
+import { formatLastUsed } from "@/lib/utils";
+import { appDetailPath } from "@/lib/app-routes";
export default function Dashboard() {
- const { stats, recentProjects, auditLogs, isLoading, auditLoading } =
- useDashboard();
+ const { stats, recentProjects, auditLogs, isLoading, auditLoading, error } = useDashboard();
+
+ if (error) {
+ return (
+ window.location.reload()}
+ />
+ );
+ }
return (
- {/* Onboarding banner */}
0}
hasTeamMembers={stats.teamMembersCount > 1}
hasApiKeys={stats.apiKeysCount > 0}
/>
- {/* Bento grid dashboard */}
-
- {/* Row 1: 4 stat cards across 3 columns (first 3 stats) */}
- {statCardConfigs.slice(0, 3).map((card) => (
-
-
-
- ))}
+ {/* Quick actions bar */}
+
+
+
+
+ Create Project
+
+
+
+
+
+ Invite Member
+
+
+
+
+
+ API Keys
+
+
+
- {/* Row 2: API Keys stat + Quick Actions (wide) */}
-
-
-
+ {/* 3-column quick access grid — Cloudflare style */}
+
+ {/* Column 1: Projects */}
+
+
+
+
+
Projects
+
+ {stats.projectsCount}
+
+
+
+ View all
+
+
+
+ {recentProjects.length === 0 ? (
+
+
No projects yet
+
+ Create your first project →
+
+
+ ) : (
+ recentProjects.map((project) => (
+
+
+
+
+ {project.name.charAt(0).toUpperCase()}
+
+
+
{project.name}
+
+
+
+ {(project.env_count ?? 0) + (project.secret_count ?? 0)}
+
+
+
+
+ ))
+ )}
+
+
-
-
-
+ {/* Column 2: Security */}
+
+
+
+
+
+
+ {stats.apiKeysCount}
+
+
+
+
+
+
+
+
+
+
+
+
+
- {/* Row 3-4: Recent Projects (wide+tall) + Recent Activity (tall) */}
-
- Recent Projects
-
- View All
-
+ {/* Column 3: Recents */}
+
+
+
+
+
Recent Activity
- }
- >
-
-
+
+ View all
+
+
+
+ {auditLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : auditLogs.length === 0 ? (
+
+ ) : (
+ auditLogs.slice(0, 6).map((log, idx) => (
+
+
+
+
+
+ {log.action?.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) || "Unknown"}
+
+
+
+ {log.message || log.details || "No details"}
+ {log.created_at && (
+ · {formatLastUsed(log.created_at)}
+ )}
+
+
+
+ ))
+ )}
+
+
+
-
-
-
-
+ {/* Stats overview */}
+
+
+
+
{stats.projectsCount}
+
+
+
+
{stats.variablesCount}
+
+
+
+
{stats.teamMembersCount}
+
+
+
+
{stats.apiKeysCount}
+
+
);
}
diff --git a/apps/envsync-web/src/pages/ManageEnvironment/index.tsx b/apps/envsync-web/src/pages/ManageEnvironment/index.tsx
index bb12498c..2fca197d 100644
--- a/apps/envsync-web/src/pages/ManageEnvironment/index.tsx
+++ b/apps/envsync-web/src/pages/ManageEnvironment/index.tsx
@@ -4,24 +4,22 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
- FolderKanban,
+ ChevronRight,
Layers3,
Plus,
Settings,
- ShieldAlert,
- Sparkles,
+ Shield,
Trash2,
Edit3,
+ Check,
} from "lucide-react";
import { toast } from "sonner";
import { sdk } from "@/api";
-import { PageShell } from "@/components/PageShell";
import { useAuthContext } from "@/contexts/auth";
import { appDetailPath } from "@/lib/app-routes";
-import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
@@ -318,40 +316,34 @@ export const ManageEnvironment = () => {
}, [resetForm]);
const renderEnvironmentForm = (mode: "create" | "edit") => (
-
-
- {mode === "create"
- ? "Create a lane. Protect it only when changes should require review."
- : "Rename the lane, update its color, or adjust default and protection rules."}
-
-
+
-
- Environment Name *
+
+ Name
handleInputChange("name", event.target.value)}
placeholder="e.g. Production"
- className="border-zinc-700 bg-zinc-950 text-white"
+ className="border-zinc-700 bg-zinc-900 text-zinc-100"
/>
{formErrors.name && (
- {formErrors.name}
+ {formErrors.name}
)}
-
Color
-
+
Color
+
{PRESET_COLORS.map((color) => (
handleInputChange("color", color)}
@@ -360,16 +352,16 @@ export const ManageEnvironment = () => {
))}
{formErrors.color && (
-
{formErrors.color}
+
{formErrors.color}
)}
-
-
+
+
-
Default environment
-
- New workflows land here first when no environment is specified.
+
Default
+
+ New workflows land here first
{
onCheckedChange={(checked) =>
handleInputChange("is_default", checked)
}
- data-testid="env-type-default-checkbox"
/>
-
+
+
-
Protected environment
-
- Direct changes are blocked and move into a reviewable change-request flow.
+
Protected
+
+ Changes require review before apply
{
return (
-
-
Loading environment types...
+
+
Loading...
);
@@ -414,28 +406,30 @@ export const ManageEnvironment = () => {
return (
-
+
-
- Failed to load project
+
+ Failed to load
-
- The requested project could not be found or you do not have access.
+
+ Project not found or access denied.
-
+
refetch()}
- className="bg-emerald-500 text-white hover:bg-emerald-600"
+ size="sm"
+ className="bg-emerald-600 text-white hover:bg-emerald-700"
>
- Try Again
+ Retry
-
- Go Back
+
+ Back
@@ -445,232 +439,165 @@ export const ManageEnvironment = () => {
}
const { project, environmentTypes } = data;
- const protectedCount = environmentTypes.filter((env) => env.is_protected).length;
- const defaultEnvironment = environmentTypes.find((env) => env.is_default);
- const totalVariables = environmentTypes.reduce(
- (sum, env) => sum + (env.variable_count || 0),
- 0
- );
return (
-
-
-
-
- Back to Project
-
+
+ {/* Header */}
+
+
+
+
+ {project.name}
+
+
/
+
Environments
+
+
+
+ Add Environment
+
+
+
+ {/* Stats */}
+
+ {environmentTypes.length} environment{environmentTypes.length !== 1 ? 's' : ''}
+ {environmentTypes.filter(e => e.is_protected).length} protected
+
+
+ {/* Environment list */}
+
+ {/* Table header */}
+
+ Name
+ Variables
+ Policy
+ Status
+
+
+
+ {/* Rows */}
+ {environmentTypes.map((envType) => (
+
+
+
+
+
+ {envType.variable_count || 0}
+
+
+
+
+ {envType.is_protected ? (
+ Reviewed
+ ) : (
+ Direct
+ )}
+
+
+
+ {envType.is_default && (
+
+ Default
+
+ )}
+ {envType.is_protected && (
+
+ Protected
+
+ )}
+
+
+
+ openEditSheet(envType)}
+ variant="ghost"
+ size="icon"
+ data-testid={`env-type-edit-${envType.id}`}
+ className="size-7 text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800"
+ >
+
+
+ {
+ setSelectedEnvironment(envType);
+ setShowDeleteDialog(true);
+ }}
+ variant="ghost"
+ size="icon"
+ data-testid={`env-type-delete-${envType.id}`}
+ className="size-7 text-zinc-500 hover:text-red-400 hover:bg-red-500/10"
+ >
+
+
+ navigate(`${appDetailPath(appId ?? "")}?env=${envType.id}&selected=${envType.id}`)}
+ variant="ghost"
+ size="icon"
+ className="size-7 text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800"
+ >
+
+
+
+
+ ))}
+
+ {environmentTypes.length === 0 && (
+
+
+
No environments yet
-
- Add Environment Type
+
+ Create your first environment
- >
- }
- stats={[
- {
- label: "Environment Types",
- value:
{environmentTypes.length} ,
- hint: "Runtime lanes",
- },
- {
- label: "Protected",
- value:
{protectedCount} ,
- hint: "Review required",
- tone: protectedCount > 0 ? "warning" : "default",
- },
- {
- label: "Default",
- value:
{defaultEnvironment?.name || "None"} ,
- hint: "Starting lane",
- },
- {
- label: "Variables Indexed",
- value:
{totalVariables} ,
- hint: "Across all lanes",
- tone: totalVariables > 0 ? "success" : "default",
- },
- ]}
- >
-
-
- {environmentTypes.map((envType) => (
-
-
-
-
-
- {envType.is_default && (
-
- Default
-
- )}
- {envType.is_protected && (
-
- Protected
-
- )}
-
-
-
-
-
-
- Indexed Variables
-
-
- {envType.variable_count || 0}
-
-
-
-
- Mutation Policy
-
-
- {envType.is_protected
- ? "Reviewed only"
- : "Direct edits enabled"}
-
-
-
-
-
-
-
- {envType.is_protected ? (
-
-
- Changes go through Change Requests.
-
- ) : (
-
-
- Direct edits are allowed.
-
- )}
-
-
-
-
- navigate(
- `${appDetailPath(appId ?? "")}?env=${envType.id}&selected=${envType.id}`
- )
- }
- data-testid={`env-type-open-workspace-${envType.id}`}
- variant="ghost"
- size="sm"
- className="text-zinc-400 hover:bg-zinc-800 hover:text-white"
- >
-
- Open Workspace
-
-
- openEditSheet(envType)}
- variant="ghost"
- size="sm"
- data-testid={`env-type-edit-${envType.id}`}
- className="text-zinc-400 hover:bg-zinc-800 hover:text-white"
- >
-
-
- openDeleteDialog(envType)}
- variant="ghost"
- size="sm"
- data-testid={`env-type-delete-${envType.id}`}
- className="text-zinc-400 hover:bg-red-900/20 hover:text-red-400"
- disabled={envType.is_protected}
- >
-
-
-
-
-
-
- ))}
-
- {environmentTypes.length === 0 && (
-
-
-
-
-
- No Environment Types
-
-
- Create your first environment type to start organizing variables. Common types include Development, Staging, and Production.
-
-
-
- Create Environment Type
-
-
-
-
- )}
+ )}
+
-
-
- Quick Rules
-
-
-
-
Keep daily lanes editable
-
Development and preview usually stay direct-edit.
-
-
-
Choose one default lane
-
It becomes the starting context for operators.
-
-
-
Protect production-like lanes
-
Use protection when changes should be reviewed first.
-
-
-
-
-
-
+ {/* Create Sheet */}
-
- Create Environment Type
+
+ Add Environment
-
- Add a new environment lane for this project.
+
+ Create a new environment lane for {project.name}.
{renderEnvironmentForm("create")}
@@ -678,32 +605,31 @@ export const ManageEnvironment = () => {
setShowCreateSheet(false)}
variant="outline"
- className="border-zinc-700 text-white hover:bg-zinc-800"
+ className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
>
Cancel
- {createEnvironmentType.isPending
- ? "Creating..."
- : "Create Environment Type"}
+ {createEnvironmentType.isPending ? "Creating..." : "Create"}
+ {/* Edit Sheet */}
- Edit Environment Type
-
- Update naming, color, and workflow rules.
+ Edit Environment
+
+ Update {selectedEnvironment?.name} settings.
{renderEnvironmentForm("edit")}
@@ -711,60 +637,49 @@ export const ManageEnvironment = () => {
Cancel
- {updateEnvironmentType.isPending
- ? "Updating..."
- : "Update Environment Type"}
+ {updateEnvironmentType.isPending ? "Saving..." : "Save"}
+ {/* Delete Dialog */}
-
+
-
- Delete Environment Type
+
+ Delete Environment
-
- This action cannot be undone. Type the environment name to confirm deletion.
+
+ Type {selectedEnvironment?.name} to confirm.
-
-
-
-
- Deleting {selectedEnvironment?.name} removes that environment type from this project.
-
-
-
-
-
- Confirm Environment Name
-
-
setDeleteConfirmText(event.target.value)}
- placeholder={selectedEnvironment?.name}
- className="border-zinc-700 bg-zinc-950 text-white"
- />
+
+ This will remove all variables in this environment.
+
setDeleteConfirmText(event.target.value)}
+ placeholder={selectedEnvironment?.name}
+ className="border-zinc-700 bg-zinc-900 text-zinc-100"
+ />
setShowDeleteDialog(false)}
variant="outline"
- className="border-zinc-700 text-white hover:bg-zinc-800"
+ className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
>
Cancel
@@ -774,9 +689,9 @@ export const ManageEnvironment = () => {
deleteConfirmText !== selectedEnvironment?.name ||
deleteEnvironmentType.isPending
}
- variant="destructive"
+ className="bg-red-600 text-white hover:bg-red-700"
>
- {deleteEnvironmentType.isPending ? "Deleting..." : "Delete Environment Type"}
+ {deleteEnvironmentType.isPending ? "Deleting..." : "Delete"}
diff --git a/apps/envsync-web/src/pages/ProjectIntegrationProvider.tsx b/apps/envsync-web/src/pages/ProjectIntegrationProvider.tsx
index bd363226..3e580ce5 100644
--- a/apps/envsync-web/src/pages/ProjectIntegrationProvider.tsx
+++ b/apps/envsync-web/src/pages/ProjectIntegrationProvider.tsx
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
+import { ArrowLeft, ChevronRight, Plus, RefreshCw } from "lucide-react";
import { sdk } from "@/api";
import {
@@ -13,14 +14,16 @@ import {
useIntegrationBindings,
useOrgSecrets,
useProviderConnections,
+ useSyncAuditEvents,
useSyncRuns,
useUpdateEnvTypeMapping,
useUpdateIntegrationBinding,
} from "@/api/enterprise/hooks";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { EnterpriseOrgAssetsPanel } from "@/components/enterprise/EnterpriseOrgAssetsPanel";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { appIntegrationsPath, orgIntegrationsPath } from "@/lib/app-routes";
import {
@@ -157,7 +160,6 @@ export default function ProjectIntegrationProvider() {
[mappings, providerBindings],
);
const providerSyncRuns = syncRuns.filter((run) => run.provider_type === providerId);
- const recentProviderSyncRuns = providerSyncRuns.slice(0, 8);
const [selectedSyncRunId, setSelectedSyncRunId] = useState
(null);
const orgSecretKeys = useMemo(() => orgSecrets.map((secret) => secret.key), [orgSecrets]);
@@ -194,17 +196,6 @@ export default function ProjectIntegrationProvider() {
[providerSyncRuns, selectedSyncRunId],
);
const { data: selectedSyncAuditEvents = [] } = useSyncAuditEvents(selectedSyncRunId ?? undefined);
- const syncSummary = useMemo(() => ({
- total: providerSyncRuns.length,
- succeeded: providerSyncRuns.filter((run) => run.status === "succeeded").length,
- failed: providerSyncRuns.filter((run) => run.status === "failed").length,
- running: providerSyncRuns.filter((run) => run.status === "running" || run.status === "pending").length,
- }), [providerSyncRuns]);
- const auditSummary = useMemo(() => ({
- info: selectedSyncAuditEvents.filter((event) => event.result === "info").length,
- success: selectedSyncAuditEvents.filter((event) => event.result === "success").length,
- error: selectedSyncAuditEvents.filter((event) => event.result === "error").length,
- }), [selectedSyncAuditEvents]);
useEffect(() => {
setBindingForm((prev) => ({
@@ -328,9 +319,7 @@ export default function ProjectIntegrationProvider() {
await createManualSyncRun.mutateAsync({
app_id: appId,
provider_type: providerId,
- metadata: {
- source: "dashboard",
- },
+ metadata: { source: "dashboard" },
});
toast.success("Manual sync requested.");
} catch (error) {
@@ -338,23 +327,6 @@ export default function ProjectIntegrationProvider() {
}
};
- const onRetrySync = async (run: { id: string; app_id?: string | null; provider_type: EnterpriseProvider; metadata?: Record }) => {
- try {
- await createManualSyncRun.mutateAsync({
- app_id: run.app_id ?? appId,
- provider_type: run.provider_type,
- metadata: {
- ...(run.metadata ?? {}),
- source: "dashboard-retry",
- retry_of: run.id,
- },
- });
- toast.success("Retry sync requested.");
- } catch (error) {
- toast.error(error instanceof Error ? error.message : "Failed to retry sync.");
- }
- };
-
const onSaveBinding = async (bindingId: string) => {
const draft = bindingDrafts[bindingId];
if (!draft) return;
@@ -389,617 +361,427 @@ export default function ProjectIntegrationProvider() {
}
};
+ const handleBack = () => {
+ window.location.href = appIntegrationsPath(appId);
+ };
+
return (
-
-
-
- Back to integrations
-
-
- {copy.title}{project?.name ? ` for ${project.name}` : ""}
-
-
- Configure provider connections, org secrets, app bindings, and environment routing rules in-context for this project.
- Shared asset cleanup still lives in{" "}
-
- organization integrations
- .
-
+
+ {/* Header */}
+
+
+
+
+ Integrations
+
+
/
+
+ {copy.name} {project?.name ? `— ${project.name}` : ""}
+
+
+
+
+
+ Trigger Sync
+
+
-
-
-
Connections
-
{eligibleConnections.length}
-
Org-level {providerId} connections available for binding.
+ {/* Stats row */}
+
+
+
Connections
+
{eligibleConnections.length}
-
-
Bindings
-
{providerBindings.length}
-
Provider links already attached to this application.
+
+
Bindings
+
{providerBindings.length}
-
-
Env mappings
-
{providerMappings.length}
-
Environment-type rules currently registered for this provider.
+
+
Mappings
+
{providerMappings.length}
-
-
Org secrets
-
{orgSecrets.length}
-
Reusable secret references available to this organization.
+
+
Sync Runs
+
{providerSyncRuns.length}
-
-
-
-
-
App binding
-
- Choose the org-level provider credential this app should use, then capture the provider-specific binding details.
-
+ {/* Tabbed content */}
+
+
+
+ Bindings
+
+
+ Mappings
+
+
+ Sync Runs
+
+
+
+ {/* Bindings tab */}
+
+ {/* Create binding form */}
+
+
+
Create Binding
-
- Trigger manual sync
-
-
+
+
+ Provider Connection
+ setBindingForm((prev) => ({ ...prev, provider_connection_id: event.target.value }))}
+ className="flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white"
+ >
+ Select connection
+ {eligibleConnections.map((connection) => (
+
+ {connection.name} ({connection.status})
+
+ ))}
+
+
-
-
-
Provider connection
-
setBindingForm((prev) => ({ ...prev, provider_connection_id: event.target.value }))}
- className="flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white"
- >
- Select connection
- {eligibleConnections.map((connection) => (
-
- {connection.name} ({connection.status})
-
+
+ {copy.bindingFields.map((field) => (
+
+ setBindingForm((prev) => ({
+ ...prev,
+ metadataFields: { ...prev.metadataFields, [field.key]: value },
+ }))
+ }
+ secretOptions={field.kind === "secret-ref" ? orgSecretKeys : undefined}
+ />
))}
-
-
-
-
- {copy.bindingFields.map((field) => (
-
- setBindingForm((prev) => ({
- ...prev,
- metadataFields: {
- ...prev.metadataFields,
- [field.key]: value,
- },
- }))
- }
- secretOptions={field.kind === "secret-ref" ? orgSecretKeys : undefined}
- />
- ))}
-
-
-
- Advanced binding metadata JSON
-
- Additional metadata
- setBindingForm((prev) => ({ ...prev, metadataRaw: event.target.value }))}
- className="min-h-[120px]"
- />
-
-
-
- Create binding
-
-
-
-
- {providerBindings.map((binding) => {
- const draft = bindingDrafts[binding.id] ?? {
- is_enabled: binding.is_enabled,
- metadataFields: extractKnownFieldValues(binding.metadata, copy.bindingFields),
- metadataRaw: stringifyRecord(omitKnownFields(binding.metadata, copy.bindingFields)),
- };
- const connection = providerConnectionById[binding.provider_connection_id];
-
- return (
-
-
-
-
{connection?.name ?? binding.provider_connection_id}
-
- {binding.provider_type} · created {new Date(binding.created_at).toLocaleString()}
-
-
-
- {draft.is_enabled ? "enabled" : "disabled"}
-
-
-
-
- Enabled
-
- setBindingDrafts((prev) => ({
- ...prev,
- [binding.id]: {
- ...draft,
- is_enabled: event.target.value === "enabled",
- },
- }))
- }
- className="flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white"
- >
- enabled
- disabled
-
-
-
- {copy.bindingFields.map((field) => (
-
- setBindingDrafts((prev) => ({
- ...prev,
- [binding.id]: {
- ...draft,
- metadataFields: {
- ...draft.metadataFields,
- [field.key]: value,
- },
- },
- }))
- }
- secretOptions={field.kind === "secret-ref" ? orgSecretKeys : undefined}
- />
- ))}
-
-
+
+ {createBinding.isPending ? "Creating..." : "Create Binding"}
+
+
+
-
- Advanced binding metadata JSON
-
-
- setBindingDrafts((prev) => ({
- ...prev,
- [binding.id]: {
- ...draft,
- metadataRaw: event.target.value,
- },
- }))
- }
- className="min-h-[110px]"
- />
-
-
-
-
- void onSaveBinding(binding.id)}
- >
- Save binding
-
-
-
- );
- })}
- {providerBindings.length === 0 && (
-
- No bindings for this provider yet.
+ {/* Existing bindings */}
+
+
+
Existing Bindings
+
+ {providerBindings.length === 0 ? (
+
- )}
-
-
-
-
- Environment mappings
- {copy.helper}
-
-
-
-
Binding
-
setMappingForm((prev) => ({ ...prev, integration_binding_id: event.target.value }))}
- className="flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white"
- >
- Select binding
+ ) : (
+
{providerBindings.map((binding) => {
+ const draft = bindingDrafts[binding.id] ?? {
+ is_enabled: binding.is_enabled,
+ metadataFields: extractKnownFieldValues(binding.metadata, copy.bindingFields),
+ metadataRaw: stringifyRecord(omitKnownFields(binding.metadata, copy.bindingFields)),
+ };
const connection = providerConnectionById[binding.provider_connection_id];
+
return (
-
- {connection?.name ?? `${binding.id.slice(0, 8)}...`}
-
+
+
+
+
+
+ {connection?.name ?? binding.provider_connection_id}
+
+
+ {binding.provider_type} · {draft.is_enabled ? "enabled" : "disabled"}
+
+
+
+
void onSaveBinding(binding.id)}
+ className="text-zinc-500 hover:text-zinc-200"
+ >
+ Save
+
+
+
);
})}
-
-
-
- Environment type
- setMappingForm((prev) => ({ ...prev, env_type_id: event.target.value }))}
- className="flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white"
- >
- Select environment type
- {envTypes.map((envType) => (
-
- {envType.name}
-
- ))}
-
-
-
- {copy.targetLabel}
- setMappingForm((prev) => ({ ...prev, target_identifier: event.target.value }))}
- placeholder={copy.targetPlaceholder}
- />
-
- {(copy.usesBranch || copy.usesPath) && (
-
)}
-
-
- {copy.mappingFields.map((field) => (
-
- setMappingForm((prev) => ({
- ...prev,
- metadataFields: {
- ...prev.metadataFields,
- [field.key]: value,
- },
- }))
- }
- secretOptions={field.kind === "secret-ref" ? orgSecretKeys : undefined}
- />
- ))}
+
+
+
+ {/* Mappings tab */}
+
+ {/* Create mapping form */}
+
+
+
Create Mapping
+
+
+
+ Binding
+ setMappingForm((prev) => ({ ...prev, integration_binding_id: event.target.value }))}
+ className="flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white"
+ >
+ Select binding
+ {providerBindings.map((binding) => {
+ const connection = providerConnectionById[binding.provider_connection_id];
+ return (
+
+ {connection?.name ?? `${binding.id.slice(0, 8)}...`}
+
+ );
+ })}
+
+
+
+ Environment Type
+ setMappingForm((prev) => ({ ...prev, env_type_id: event.target.value }))}
+ className="flex h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white"
+ >
+ Select environment type
+ {envTypes.map((envType) => (
+
+ {envType.name}
+
+ ))}
+
+
+
-
- Advanced mapping metadata JSON
-
-
Additional metadata
-
setMappingForm((prev) => ({ ...prev, metadataRaw: event.target.value }))}
- className="min-h-[120px]"
+
+ {copy.targetLabel}
+ setMappingForm((prev) => ({ ...prev, target_identifier: event.target.value }))}
+ placeholder={copy.targetPlaceholder}
/>
-
-
-
- Create mapping
-
-
-
-
-
-
- Shared provider assets
-
- Create and edit the organization-level provider credentials and secret references used by this project without leaving the current flow.
-
-
-
-
-
-
-
-
- Current mappings and recent sync runs
-
-
- {providerMappings.map((mapping) => {
- const envType = envTypes.find((entry) => entry.id === mapping.env_type_id);
- const binding = providerBindings.find((entry) => entry.id === mapping.integration_binding_id);
- const connection = binding ? providerConnectionById[binding.provider_connection_id] : null;
- const draft = mappingDrafts[mapping.id] ?? {
- target_identifier: mapping.target_identifier,
- branch_ref: mapping.branch_ref ?? "",
- path_prefix: mapping.path_prefix ?? "",
- metadataFields: extractKnownFieldValues(mapping.metadata, copy.mappingFields),
- metadataRaw: stringifyRecord(omitKnownFields(mapping.metadata, copy.mappingFields)),
- };
-
- return (
-
-
-
-
{envType?.name ?? mapping.env_type_id}
-
- binding: {connection?.name ?? binding?.provider_connection_id ?? mapping.integration_binding_id}
-
+ {(copy.usesBranch || copy.usesPath) && (
+
+ {copy.usesBranch && (
+
+ {copy.branchLabel}
+ setMappingForm((prev) => ({ ...prev, branch_ref: event.target.value }))}
+ placeholder={copy.branchPlaceholder}
+ />
-
- updated {new Date(mapping.updated_at).toLocaleDateString()}
-
-
-
-
-
- {copy.targetLabel}
+ )}
+ {copy.usesPath && (
+
+
{copy.pathLabel}
- setMappingDrafts((prev) => ({
- ...prev,
- [mapping.id]: {
- ...draft,
- target_identifier: event.target.value,
- },
- }))
- }
- placeholder={copy.targetPlaceholder}
+ id="path-prefix"
+ value={mappingForm.path_prefix}
+ onChange={(event) => setMappingForm((prev) => ({ ...prev, path_prefix: event.target.value }))}
+ placeholder={copy.pathPlaceholder}
/>
-
-
- {(copy.usesBranch || copy.usesPath) && (
-
- {copy.usesBranch && (
-
- {copy.branchLabel}
-
- setMappingDrafts((prev) => ({
- ...prev,
- [mapping.id]: {
- ...draft,
- branch_ref: event.target.value,
- },
- }))
- }
- placeholder={copy.branchPlaceholder}
- />
-
- )}
- {copy.usesPath && (
-
- {copy.pathLabel}
-
- setMappingDrafts((prev) => ({
- ...prev,
- [mapping.id]: {
- ...draft,
- path_prefix: event.target.value,
- },
- }))
- }
- placeholder={copy.pathPlaceholder}
- />
-
- )}
-
- )}
-
-
- {copy.mappingFields.map((field) => (
-
- setMappingDrafts((prev) => ({
- ...prev,
- [mapping.id]: {
- ...draft,
- metadataFields: {
- ...draft.metadataFields,
- [field.key]: value,
- },
- },
- }))
- }
- secretOptions={field.kind === "secret-ref" ? orgSecretKeys : undefined}
- />
- ))}
+ )}
+
+ )}
-
- Advanced mapping metadata JSON
-
-
- setMappingDrafts((prev) => ({
- ...prev,
- [mapping.id]: {
- ...draft,
- metadataRaw: event.target.value,
- },
- }))
- }
- className="min-h-[120px]"
- />
-
-
-
+
+ {createMapping.isPending ? "Creating..." : "Create Mapping"}
+
+
+
-
- void onSaveMapping(mapping.id)}
- >
- Save mapping
-
-
-
- );
- })}
- {providerMappings.length === 0 && (
-
- No env mappings for this provider yet.
+ {/* Existing mappings */}
+
+
+
Existing Mappings
+
+ {providerMappings.length === 0 ? (
+
+ ) : (
+
+ {providerMappings.map((mapping) => {
+ const envType = envTypes.find((entry) => entry.id === mapping.env_type_id);
+ const binding = providerBindings.find((entry) => entry.id === mapping.integration_binding_id);
+ const connection = binding ? providerConnectionById[binding.provider_connection_id] : null;
+ const draft = mappingDrafts[mapping.id] ?? {
+ target_identifier: mapping.target_identifier,
+ branch_ref: mapping.branch_ref ?? "",
+ path_prefix: mapping.path_prefix ?? "",
+ metadataFields: extractKnownFieldValues(mapping.metadata, copy.mappingFields),
+ metadataRaw: stringifyRecord(omitKnownFields(mapping.metadata, copy.mappingFields)),
+ };
+
+ return (
+
+
+
+
+
+ {envType?.name ?? mapping.env_type_id}
+
+
+ {connection?.name ?? binding?.provider_connection_id ?? mapping.integration_binding_id}
+
+
+
+
void onSaveMapping(mapping.id)}
+ className="text-zinc-500 hover:text-zinc-200"
+ >
+ Save
+
+
+
+ );
+ })}
)}
+
-
-
-
-
Total runs
-
{syncSummary.total}
-
-
-
Succeeded
-
{syncSummary.succeeded}
-
-
-
Failed
-
{syncSummary.failed}
+ {/* Sync Runs tab */}
+
+
+
+
Sync Runs
+
+ {providerSyncRuns.length === 0 ? (
+
-
-
Running
-
{syncSummary.running}
+ ) : (
+
+ {providerSyncRuns.slice(0, 10).map((run) => (
+
setSelectedSyncRunId(run.id)}
+ >
+
+
+
+
+ {run.id.slice(0, 8)}...
+
+
+ {new Date(run.started_at).toLocaleString()}
+ {run.completed_at ? ` · ${new Date(run.completed_at).toLocaleString()}` : ""}
+
+
+
+
+ {run.status}
+
+
+ {run.error_message && (
+
{run.error_message}
+ )}
+
+ ))}
-
+ )}
+
-
-
-
-
Selected sync run
-
- {selectedSyncRun ? `${selectedSyncRun.id} · ${selectedSyncRun.status}` : "Select a sync run below."}
-
-
- {selectedSyncRun && (
+ {/* Selected sync run details */}
+ {selectedSyncRun && (
+
+
+
+
Sync Run Details
void onRetrySync(selectedSyncRun)}
+ onClick={() => {
+ createManualSyncRun.mutateAsync({
+ app_id: appId,
+ provider_type: providerId,
+ metadata: { source: "dashboard-retry", retry_of: selectedSyncRun.id },
+ });
+ }}
+ className="text-zinc-400 border-zinc-700 hover:bg-zinc-800"
>
- Retry run
+ Retry
- )}
+
-
- {selectedSyncRun && (
- <>
-
-
-
Info
-
{auditSummary.info}
-
-
-
Success
-
{auditSummary.success}
-
-
-
Error
-
{auditSummary.error}
-
-
-
-
-
- Started {new Date(selectedSyncRun.started_at).toLocaleString()}
- {selectedSyncRun.completed_at ? ` · completed ${new Date(selectedSyncRun.completed_at).toLocaleString()}` : ""}
-
- {selectedSyncRun.error_message && (
-
- {selectedSyncRun.error_message}
+
+ {selectedSyncAuditEvents.length > 0 ? (
+ selectedSyncAuditEvents.map((event) => (
+
+
+ {event.result}
+
+
+
{event.action}
+
{new Date(event.created_at).toLocaleString()}
- )}
-
- {selectedSyncAuditEvents.length > 0 ? selectedSyncAuditEvents.map((event) => (
-
-
-
{event.action}
-
{event.result}
-
-
{new Date(event.created_at).toLocaleString()}
-
{JSON.stringify(event.details, null, 2)}
-
- )) : (
-
- No audit events recorded for this run yet.
-
- )}
-
- >
- )}
-
-
- {recentProviderSyncRuns.map((run) => (
-
setSelectedSyncRunId(run.id)}
- className={`w-full rounded-2xl border bg-black/20 p-4 text-left transition-colors ${
- selectedSyncRunId === run.id ? "border-emerald-500/40 bg-emerald-500/10" : "border-white/10"
- }`}
- >
-
-
{run.id.slice(0, 8)}...
-
{run.status}
-
- {new Date(run.started_at).toLocaleString()}
- {run.error_message && {run.error_message}
}
-
- ))}
- {recentProviderSyncRuns.length === 0 && (
-
- No sync runs recorded for this provider yet.
+ ))
+ ) : (
+
No audit events recorded for this run.
+ )}
- )}
-
-
-
+
+ )}
+
+
);
}
diff --git a/apps/envsync-web/src/pages/ProjectIntegrations.tsx b/apps/envsync-web/src/pages/ProjectIntegrations.tsx
index 42207dfa..7ca934b1 100644
--- a/apps/envsync-web/src/pages/ProjectIntegrations.tsx
+++ b/apps/envsync-web/src/pages/ProjectIntegrations.tsx
@@ -1,11 +1,16 @@
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
+import { ArrowLeft, ChevronRight, Layers3, Link2, Plus, RefreshCw, Workflow } from "lucide-react";
import { sdk } from "@/api";
-import { useEnvTypeMappings, useIntegrationBindings, useProviderConnections, useSyncRuns, type EnterpriseProvider } from "@/api/enterprise/hooks";
-import { EnterpriseOrgAssetsPanel } from "@/components/enterprise/EnterpriseOrgAssetsPanel";
-import { appIntegrationProviderPath, orgIntegrationsPath } from "@/lib/app-routes";
+import { useCreateManualSyncRun, useEnvTypeMappings, useIntegrationBindings, useOrgSecrets, useProviderConnections, useSyncRuns, type EnterpriseProvider } from "@/api/enterprise/hooks";
+import { CreateOrgSecretModal } from "@/components/enterprise/CreateOrgSecretModal";
+import { CreateProviderConnectionModal } from "@/components/enterprise/CreateProviderConnectionModal";
+import { SyncRunsList } from "@/components/enterprise/SyncRunsList";
+import { Button } from "@/components/ui/button";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { appDetailPath, appIntegrationProviderPath, orgIntegrationsPath } from "@/lib/app-routes";
import { enterpriseProviderUi } from "@/lib/enterprise-provider-ui";
const providers = Object.values(enterpriseProviderUi) as Array<{
@@ -16,6 +21,9 @@ const providers = Object.values(enterpriseProviderUi) as Array<{
export default function ProjectIntegrations() {
const { appId = "" } = useParams();
+ const [showCreateConnection, setShowCreateConnection] = useState(false);
+ const [showCreateSecret, setShowCreateSecret] = useState(false);
+
const { data: project } = useQuery({
queryKey: ["app", appId],
queryFn: () => sdk.applications.getApp(appId),
@@ -25,6 +33,8 @@ export default function ProjectIntegrations() {
const { data: bindings = [] } = useIntegrationBindings(appId);
const { data: mappings = [] } = useEnvTypeMappings(appId);
const { data: syncRuns = [] } = useSyncRuns(appId);
+ const { data: orgSecrets = [] } = useOrgSecrets();
+ const createManualSyncRun = useCreateManualSyncRun();
const providerSummary = useMemo(() => {
return providers.map((provider) => {
@@ -45,81 +55,243 @@ export default function ProjectIntegrations() {
});
}, [bindings, mappings, providerConnections, syncRuns]);
+ const handleBack = () => {
+ window.location.href = appDetailPath(appId);
+ };
+
return (
-
-
-
Enterprise Integrations
-
- {project?.name ? `${project.name} integration control` : "App-level provider mappings"}
-
-
- Keep provider setup, app bindings, and sync operations in one place. Create shared org assets here, or open{" "}
-
- organization integrations
- {" "}
- for a cross-project view.
-
+
+ {/* Header */}
+
+
+
+
+ {project?.name || "Project"}
+
+
/
+
Integrations
+
+
+
setShowCreateConnection(true)}
+ size="sm"
+ className="bg-emerald-600 text-white hover:bg-emerald-700"
+ >
+
+ Add Connection
+
+
setShowCreateSecret(true)}
+ size="sm"
+ variant="outline"
+ className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
+ >
+
+ Add Secret
+
+
+
+ Org Integrations
+
+
+
-
-
-
Provider connections
-
{providerConnections.length}
-
Available org-level connections that can be bound to this app.
+ {/* Stats row */}
+
+
+
+
{providerConnections.length}
-
-
Bindings
-
{bindings.length}
-
App to provider-connection associations currently registered.
+
-
-
Env mappings
-
{mappings.length}
-
Environment type routing rules across all connected providers.
+
-
-
Sync runs
-
{syncRuns.length}
-
Recent provider sync activity for this application.
+
-
- {providerSummary.map((provider) => (
-
+
+
-
-
-
{provider.name}
-
{provider.description}
-
-
- {provider.bindingCount} bindings
-
+ Connections
+
+
+ Syncs
+
+
+ Org Secrets
+
+
+
+ {/* Connections tab */}
+
+
+ {/* Table header */}
+
+ Provider
+ Connections
+ Bindings
+ Mappings
+ Last Sync
+
-
-
-
Connections
-
{provider.connectionCount}
+ {/* Rows */}
+ {providerSummary.map((provider) => (
+
+
+
+
+ {provider.name.charAt(0)}
+
+
+
+
{provider.name}
+
{provider.description}
+
+
+
+
+ {provider.connectionCount}
+
+
+
+ {provider.bindingCount}
+
+
+
+ {provider.mappingCount}
+
+
+
+ {provider.latestSync ? (
+ {provider.latestSync.status}
+ ) : (
+ —
+ )}
+
+
+
+
+
+
+ ))}
+
+ {providerSummary.length === 0 && (
+
+
+
No integrations available
-
-
Mappings
-
{provider.mappingCount}
+ )}
+
+
+
+ {/* Syncs tab */}
+
+ {
+ if (providers.length > 0) {
+ createManualSyncRun.mutate({
+ app_id: appId || undefined,
+ provider_type: providers[0].id,
+ });
+ }
+ }}
+ isTriggering={createManualSyncRun.isPending}
+ basePath={`/apps/${appId}/integrations/sync-runs`}
+ />
+
+
+ {/* Org Secrets tab */}
+
+
+
+ Key
+ Description
+ Created
+
+
+ {orgSecrets.map((secret) => (
+
+
+ {secret.key}
+
+
+ {secret.description || "—"}
+
+
+
+ {secret.created_at ? new Date(secret.created_at).toLocaleDateString() : "—"}
+
+
-
-
Latest sync
-
{provider.latestSync?.status ?? "none"}
+ ))}
+
+ {orgSecrets.length === 0 && (
+
+
+
No organization secrets configured
-
-
- ))}
-
+ )}
+
+
+
-
+ {/* Modals */}
+
+
);
}
diff --git a/apps/envsync-web/src/pages/Teams/index.tsx b/apps/envsync-web/src/pages/Teams/index.tsx
index d923b17d..8809a70e 100644
--- a/apps/envsync-web/src/pages/Teams/index.tsx
+++ b/apps/envsync-web/src/pages/Teams/index.tsx
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import { api } from "@/api";
import { useAuthContext } from "@/contexts/auth";
import { PageShell } from "@/components/PageShell";
+import { PageError } from "@/components/ui/page-error";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -23,7 +24,7 @@ const Teams = () => {
const canManage = Boolean(user?.role?.is_admin || user?.role?.is_master);
const authEnabled = !isAuthLoading && isAuthenticated;
- const { data: teams = [] } = api.teams.getTeams({ enabled: authEnabled });
+ const { data: teams = [], isLoading: teamsLoading, error: teamsError, refetch: refetchTeams } = api.teams.getTeams({ enabled: authEnabled });
const { data: roles = [] } = api.roles.getAllRoles({ enabled: authEnabled });
const { data: users = [] } = api.users.getAllUsers({ enabled: authEnabled });
@@ -139,12 +140,23 @@ const Teams = () => {
setEditorOpen(false);
};
+ if (teamsError) {
+ return (
+
refetchTeams()}
+ />
+ );
+ }
+
return (
{
users,
roles,
isLoading,
+ error: usersError,
+ refetch: refetchUsers,
selectedUserId,
selectedUserName,
emailAddress,
@@ -148,6 +151,16 @@ export const Users = () => {
[users]
);
+ if (usersError) {
+ return (
+ refetchUsers()}
+ />
+ );
+ }
+
return (
=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-XJZuT0LWsFCW1C8dEpPAXSa7h6Pb3krr2y//1X0Zidpcl0vmgY5nL/X0JuBZlntpBzaN3+U4hvKjuijyiiR8zw=="],
- "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.4", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.3.4", "tslib": "^2.6.2" } }, "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q=="],
+ "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.34", "", { "dependencies": { "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA=="],
- "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
+ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
@@ -518,8 +509,6 @@
"@envsync-cloud/envsync-ts-sdk": ["@envsync-cloud/envsync-ts-sdk@workspace:sdks/envsync-ts-sdk"],
- "@envsync-cloud/license-server": ["@envsync-cloud/license-server@workspace:packages/license-server"],
-
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
@@ -696,34 +685,6 @@
"@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="],
- "@libsql/client": ["@libsql/client@0.17.3", "", { "dependencies": { "@libsql/core": "^0.17.3", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA=="],
-
- "@libsql/core": ["@libsql/core@0.17.3", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg=="],
-
- "@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.29", "", { "os": "darwin", "cpu": "arm64" }, "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A=="],
-
- "@libsql/darwin-x64": ["@libsql/darwin-x64@0.5.29", "", { "os": "darwin", "cpu": "x64" }, "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ=="],
-
- "@libsql/hrana-client": ["@libsql/hrana-client@0.10.0", "", { "dependencies": { "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5" } }, "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw=="],
-
- "@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="],
-
- "@libsql/linux-arm-gnueabihf": ["@libsql/linux-arm-gnueabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ=="],
-
- "@libsql/linux-arm-musleabihf": ["@libsql/linux-arm-musleabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg=="],
-
- "@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w=="],
-
- "@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg=="],
-
- "@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg=="],
-
- "@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w=="],
-
- "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.29", "", { "os": "win32", "cpu": "x64" }, "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg=="],
-
- "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
-
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -1064,9 +1025,9 @@
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.6", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ=="],
- "@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
+ "@smithy/core": ["@smithy/core@3.29.2", "", { "dependencies": { "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw=="],
- "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw=="],
+ "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.7", "", { "dependencies": { "@smithy/core": "^3.29.2", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-UEMLOoA0Fl4uYBxh6l0uN0H6EJe/A89OGeDNTteQeXpJ20BcpfIr4wlCY9pel1jEAUHAxaYwuqrYlrKdXE1GKQ=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
@@ -1078,7 +1039,7 @@
"@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.8", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ=="],
- "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA=="],
+ "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.4", "", { "dependencies": { "@smithy/core": "^3.29.2", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-psnst7NZWdAEvJvyW8YZEE7xNVMyLrQFfHtyrVFrxNyy+dKWkQ+rqC6oI5ZhxThpUy9RSfEshgm34zqbOxzsRw=="],
"@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.9", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.0", "@smithy/chunked-blob-reader-native": "^4.2.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg=="],
@@ -1104,7 +1065,7 @@
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.8", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg=="],
- "@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.10", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA=="],
+ "@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.4", "", { "dependencies": { "@smithy/core": "^3.29.2", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA=="],
"@smithy/property-provider": ["@smithy/property-provider@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w=="],
@@ -1118,11 +1079,11 @@
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.3", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg=="],
- "@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="],
+ "@smithy/signature-v4": ["@smithy/signature-v4@5.6.3", "", { "dependencies": { "@smithy/core": "^3.29.2", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.11.3", "", { "dependencies": { "@smithy/core": "^3.23.0", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" } }, "sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg=="],
- "@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+ "@smithy/types": ["@smithy/types@4.16.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw=="],
"@smithy/url-parser": ["@smithy/url-parser@4.2.8", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA=="],
@@ -1244,8 +1205,6 @@
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
- "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
-
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/type-utils": "8.56.0", "@typescript-eslint/utils": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg=="],
@@ -1318,6 +1277,8 @@
"autoprefixer": ["autoprefixer@10.4.24", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw=="],
+ "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
+
"axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
@@ -1444,7 +1405,9 @@
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
- "detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
+ "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
@@ -1576,6 +1539,8 @@
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+ "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
+
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-func-name": ["get-func-name@2.0.2", "", {}, "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ=="],
@@ -1616,6 +1581,8 @@
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
+ "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
+
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -1650,6 +1617,8 @@
"is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
+ "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
+
"is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -1662,8 +1631,6 @@
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
- "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
-
"js-sha3": ["js-sha3@0.8.0", "", {}, "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@@ -1696,8 +1663,6 @@
"libsodium-wrappers": ["libsodium-wrappers@0.8.4", "", { "dependencies": { "libsodium": "^0.8.0" } }, "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw=="],
- "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="],
-
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -1724,6 +1689,8 @@
"lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
+ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
+
"lucide-react": ["lucide-react@0.462.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -1760,8 +1727,6 @@
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
- "motion": ["motion@12.35.1", "", { "dependencies": { "framer-motion": "^12.35.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yEt/49kWC0VU/IEduDfeZw82eDemlPwa1cyo/gcEEUCN4WgpSJpUcxz6BUwakGabvJiTzLQ58J73515I5tfykQ=="],
-
"motion-dom": ["motion-dom@12.35.1", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-7n6r7TtNOsH2UFSAXzTkfzOeO5616v9B178qBIjmu/WgEyJK0uqwytCEhwKBTuM/HJA40ptAw7hLFpxtPAMRZQ=="],
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
@@ -1770,8 +1735,12 @@
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
+ "mysql2": ["mysql2@3.22.6", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.4.0" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-fPKmeDGUzvFP7bMD5SASlJ5zIgvCC4hbanTmhbUlEmhyrY1hR4Hi3xLOWgTd3luYjLVifx6uGvMNJ2m/LlqEpg=="],
+
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
+ "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="],
+
"nano-time": ["nano-time@1.0.0", "", { "dependencies": { "big-integer": "^1.6.16" } }, "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -1926,8 +1895,6 @@
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
- "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
-
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
@@ -2016,6 +1983,8 @@
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
+ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -2048,6 +2017,8 @@
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
+ "sql-escaper": ["sql-escaper@1.4.0", "", {}, "sha512-Ti/Zx9J3aITMYUMFhCKbkplQjyi7Kk2SyYXp+rzrkyKZetIy19XbQtVZ+0ZR5aVm/178tIJ6+aVvBqXkH+Xs7w=="],
+
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
@@ -2222,12 +2193,38 @@
"zone.js": ["zone.js@0.16.1", "", {}, "sha512-dpvY17vxYIW3+bNrP0ClUlaiY0CiIRK3tnoLaGoQsQcY9/I/NpzIWQ7tQNhbV7LacQMpCII6wVzuL3tuWOyfuA=="],
+ "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-crypto/sha1-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
+ "@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
+ "@aws-crypto/sha256-js/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
+ "@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.4", "@smithy/core": "^3.23.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.9", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.8", "@aws-sdk/credential-provider-http": "^3.972.10", "@aws-sdk/credential-provider-ini": "^3.972.8", "@aws-sdk/credential-provider-process": "^3.972.8", "@aws-sdk/credential-provider-sso": "^3.972.8", "@aws-sdk/credential-provider-web-identity": "^3.972.8", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-LfJfO0ClRAq2WsSnA9JuUsNyIicD2eyputxSlSL0EiMrtxOxELLRG6ZVYDf/a1HCepaYPXeakH4y8D5OLCauag=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/client-s3/@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
+
+ "@aws-sdk/client-s3/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA=="],
+
+ "@aws-sdk/client-s3/@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.10", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA=="],
+
+ "@aws-sdk/client-s3/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
"@aws-sdk/client-ssm/@aws-sdk/core": ["@aws-sdk/core@3.974.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.19", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-lMPlYlYfQdNZhlkJgnkmESwrY+hNh3PljmZ+37oAqLNdJ6rnILAwFSyc6B3bJeDOtMORNnMQIej0aTRuOlDyhQ=="],
"@aws-sdk/client-ssm/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.36", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.31", "@aws-sdk/credential-provider-http": "^3.972.33", "@aws-sdk/credential-provider-ini": "^3.972.35", "@aws-sdk/credential-provider-process": "^3.972.31", "@aws-sdk/credential-provider-sso": "^3.972.35", "@aws-sdk/credential-provider-web-identity": "^3.972.35", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-4nT2T8Z7vH8KE9EdjEsuIlHpZSlcaK2PrKbQBjuUGU46BCCzF3WvP0u0Uiosni3Ykmmn4rWLVawoOCLotUtCbg=="],
@@ -2302,16 +2299,108 @@
"@aws-sdk/client-ssm/@smithy/util-waiter": ["@smithy/util-waiter@4.2.16", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-GtclrKoZ3Lt7jPQ7aTIYKfjY92OgceScftVnkTsG8e1KV8rkvZgN+ny6YSRhd9hxB8rZtwVbmln7NTvE5O3GmQ=="],
+ "@aws-sdk/client-sso/@aws-sdk/core": ["@aws-sdk/core@3.973.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.4", "@smithy/core": "^3.23.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg=="],
+
+ "@aws-sdk/client-sso/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
"@aws-sdk/client-sso/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.990.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg=="],
+ "@aws-sdk/client-sso/@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
+
+ "@aws-sdk/client-sso/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA=="],
+
+ "@aws-sdk/client-sso/@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.10", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA=="],
+
+ "@aws-sdk/client-sso/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/crc64-nvme/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/middleware-bucket-endpoint/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/middleware-bucket-endpoint/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/middleware-expect-continue/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/middleware-expect-continue/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/middleware-flexible-checksums/@aws-sdk/core": ["@aws-sdk/core@3.973.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.4", "@smithy/core": "^3.23.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg=="],
+
+ "@aws-sdk/middleware-flexible-checksums/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/middleware-flexible-checksums/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/middleware-host-header/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/middleware-host-header/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/middleware-location-constraint/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/middleware-location-constraint/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/middleware-logger/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/middleware-logger/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/middleware-recursion-detection/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/middleware-recursion-detection/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
+
+ "@aws-sdk/middleware-recursion-detection/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/middleware-sdk-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.4", "@smithy/core": "^3.23.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg=="],
+
+ "@aws-sdk/middleware-sdk-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/middleware-sdk-s3/@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
+
+ "@aws-sdk/middleware-sdk-s3/@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="],
+
+ "@aws-sdk/middleware-sdk-s3/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/middleware-ssec/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/middleware-ssec/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/middleware-user-agent/@aws-sdk/core": ["@aws-sdk/core@3.973.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.4", "@smithy/core": "^3.23.0", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg=="],
+
+ "@aws-sdk/middleware-user-agent/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
"@aws-sdk/middleware-user-agent/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.990.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg=="],
- "@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.990.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg=="],
+ "@aws-sdk/middleware-user-agent/@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
+
+ "@aws-sdk/middleware-user-agent/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.39", "", { "dependencies": { "@aws-sdk/types": "^3.974.0", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.0", "tslib": "^2.6.2" } }, "sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA=="],
+
+ "@aws-sdk/region-config-resolver/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/region-config-resolver/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/signature-v4-multi-region/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="],
+
+ "@aws-sdk/signature-v4-multi-region/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/util-endpoints/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/util-endpoints/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/util-user-agent-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/util-user-agent-browser/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-sdk/util-user-agent-node/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+
+ "@aws-sdk/util-user-agent-node/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@envsync-cloud/deploy-cli/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
+ "@envsync-cloud/envsync-ts-sdk/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
+
"@eslint/eslintrc/globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="],
"@hyperdx/instrumentation-exception/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.51.1", "", { "dependencies": { "@opentelemetry/api-logs": "0.51.1", "@types/shimmer": "^1.0.2", "import-in-the-middle": "1.7.4", "require-in-the-middle": "^7.1.1", "semver": "^7.5.2", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-JIrvhpgqY6437QIqToyozrUG1h5UhwHkaGK/WAX+fkrpyPtc+RO5FkRtUd9BH0MibabHHvqsnBGKfKVijbmp8w=="],
@@ -2406,6 +2495,82 @@
"@scalar/types/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+ "@smithy/abort-controller/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/config-resolver/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/eventstream-serde-browser/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/eventstream-serde-config-resolver/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/eventstream-serde-node/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/eventstream-serde-universal/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/hash-blob-browser/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/hash-node/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/hash-stream-node/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/invalid-dependency/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/md5-js/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/middleware-content-length/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/middleware-endpoint/@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
+
+ "@smithy/middleware-endpoint/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/middleware-retry/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/middleware-serde/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/middleware-stack/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/node-config-provider/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/property-provider/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/protocol-http/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/querystring-builder/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/querystring-parser/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/service-error-classification/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/shared-ini-file-loader/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/smithy-client/@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
+
+ "@smithy/smithy-client/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/url-parser/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/util-defaults-mode-browser/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/util-defaults-mode-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw=="],
+
+ "@smithy/util-defaults-mode-node/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/util-endpoints/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/util-middleware/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/util-retry/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/util-stream/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA=="],
+
+ "@smithy/util-stream/@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.10", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA=="],
+
+ "@smithy/util-stream/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@smithy/util-waiter/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -2502,8 +2667,6 @@
"rrweb/fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="],
- "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
-
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
@@ -2524,12 +2687,42 @@
"wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
+ "@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-crypto/crc32c/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-crypto/sha1-browser/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
+ "@aws-crypto/sha256-browser/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
+ "@aws-crypto/sha256-js/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
+ "@aws-crypto/util/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
+
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
+ "@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.4", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.3.4", "tslib": "^2.6.2" } }, "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.8", "", { "dependencies": { "@aws-sdk/core": "^3.973.10", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-r91OOPAcHnLCSxaeu/lzZAVRCZ/CtTNuwmJkUwpwSDshUrP7bkX1OmFn2nUMWd9kN53Q4cEo8b7226G4olt2Mg=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.10", "", { "dependencies": { "@aws-sdk/core": "^3.973.10", "@aws-sdk/types": "^3.973.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.10", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" } }, "sha512-DTtuyXSWB+KetzLcWaSahLJCtTUe/3SXtlGp4ik9PCe9xD6swHEkG8n8/BNsQ9dsihb9nhFvuUB4DpdBGDcvVg=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.8", "", { "dependencies": { "@aws-sdk/core": "^3.973.10", "@aws-sdk/credential-provider-env": "^3.972.8", "@aws-sdk/credential-provider-http": "^3.972.10", "@aws-sdk/credential-provider-login": "^3.972.8", "@aws-sdk/credential-provider-process": "^3.972.8", "@aws-sdk/credential-provider-sso": "^3.972.8", "@aws-sdk/credential-provider-web-identity": "^3.972.8", "@aws-sdk/nested-clients": "3.990.0", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-n2dMn21gvbBIEh00E8Nb+j01U/9rSqFIamWRdGm/mE5e+vHQ9g0cBNdrYFlM6AAiryKVHZmShWT9D1JAWJ3ISw=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.8", "", { "dependencies": { "@aws-sdk/core": "^3.973.10", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-6cg26ffFltxM51OOS8NH7oE41EccaYiNlbd5VgUYwhiGCySLfHoGuGrLm2rMB4zhy+IO5nWIIG0HiodX8zdvHA=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.8", "", { "dependencies": { "@aws-sdk/client-sso": "3.990.0", "@aws-sdk/core": "^3.973.10", "@aws-sdk/token-providers": "3.990.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-35kqmFOVU1n26SNv+U37sM8b2TzG8LyqAcd6iM9gprqxyHEh/8IM3gzN4Jzufs3qM6IrH8e43ryZWYdvfVzzKQ=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.8", "", { "dependencies": { "@aws-sdk/core": "^3.973.10", "@aws-sdk/nested-clients": "3.990.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-CZhN1bOc1J3ubQPqbmr5b4KaMJBgdDvYsmEIZuX++wFlzmZsKj1bwkaiTEb5U2V7kXuzLlpF5HJSOM9eY/6nGA=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw=="],
+
"@aws-sdk/client-ssm/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.19", "", { "dependencies": { "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.1", "tslib": "^2.6.2" } }, "sha512-Cw8IOMdBUEIl8ZlhRC3Dc/E64D5B5/8JhV6vhPLiPfJwcRC84S6F8aBOIi/N4vR9ZyA4I5Cc0Ateb/9EHaJXeQ=="],
"@aws-sdk/client-ssm/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ=="],
@@ -2554,6 +2747,8 @@
"@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.9", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ=="],
+ "@aws-sdk/client-ssm/@aws-sdk/middleware-recursion-detection/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
+
"@aws-sdk/client-ssm/@aws-sdk/util-user-agent-node/@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="],
"@aws-sdk/client-ssm/@smithy/config-resolver/@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="],
@@ -2594,6 +2789,24 @@
"@aws-sdk/client-ssm/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
+ "@aws-sdk/client-sso/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.4", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.3.4", "tslib": "^2.6.2" } }, "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q=="],
+
+ "@aws-sdk/client-sso/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="],
+
+ "@aws-sdk/middleware-flexible-checksums/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.4", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.3.4", "tslib": "^2.6.2" } }, "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q=="],
+
+ "@aws-sdk/middleware-flexible-checksums/@aws-sdk/core/@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
+
+ "@aws-sdk/middleware-flexible-checksums/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="],
+
+ "@aws-sdk/middleware-sdk-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.4", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.3.4", "tslib": "^2.6.2" } }, "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q=="],
+
+ "@aws-sdk/middleware-user-agent/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.4", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.3.4", "tslib": "^2.6.2" } }, "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q=="],
+
+ "@aws-sdk/middleware-user-agent/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="],
+
+ "@envsync-cloud/envsync-ts-sdk/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
+
"@hyperdx/instrumentation-exception/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.51.1", "", { "dependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-E3skn949Pk1z2XtXu/lxf6QAZpawuTM/IUEXcAzpiUkTd73Hmvw26FiN3cJuTmkpM5hZzHwkomVdtrh/n/zzwA=="],
"@hyperdx/instrumentation-exception/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@1.7.4", "", { "dependencies": { "acorn": "^8.8.2", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-Lk+qzWmiQuRPPulGQeK5qq0v32k2bHnWrRPFgqyvhw7Kkov5L6MOLOIU3pcWeujc9W4q54Cp3Q2WV16eQkc7Bg=="],
@@ -2902,6 +3115,14 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.8", "", { "dependencies": { "@aws-sdk/core": "^3.973.10", "@aws-sdk/nested-clients": "3.990.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-rMFuVids8ICge/X9DF5pRdGMIvkVhDV9IQFQ8aTYk6iF0rl9jOUa1C3kjepxiXUlpgJQT++sLZkT9n0TMLHhQw=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.990.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.10", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.10", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.990.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.8", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.23.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/middleware-retry": "^4.4.31", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.10", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.30", "@smithy/util-defaults-mode-node": "^4.2.33", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.990.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.10", "@aws-sdk/nested-clients": "3.990.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-L3BtUb2v9XmYgQdfGBzbBtKMXaP5fV973y3Qdxeevs6oUTVXFmi/mV1+LnScA/1wVPJC9/hlK+1o5vbt7cG7EQ=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.990.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.10", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.10", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.990.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.8", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.23.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/middleware-retry": "^4.4.31", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.10", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.30", "@smithy/util-defaults-mode-node": "^4.2.33", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w=="],
+
"@aws-sdk/client-ssm/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.7.1", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA=="],
"@aws-sdk/client-ssm/@aws-sdk/core/@smithy/signature-v4/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
@@ -3076,6 +3297,12 @@
"vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.990.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.990.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.10", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.10", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.990.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.8", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.23.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/middleware-retry": "^4.4.31", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.10", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.30", "@smithy/util-defaults-mode-node": "^4.2.33", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w=="],
+
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.990.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg=="],
+
"@aws-sdk/client-ssm/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
"@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
@@ -3092,6 +3319,8 @@
"@aws-sdk/client-ssm/@smithy/smithy-client/@smithy/util-stream/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
+ "@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.990.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg=="],
+
"@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
"@aws-sdk/client-ssm/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region/@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.34", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-/UL96JKjsjdodcRRMKl99tLQvK6Oi9ptLC9iU1yiTF/ruaDX0mtBBtnLNZDxIZRJOCVOtB49ed1YaTadqygk8Q=="],
diff --git a/docker-compose.yaml b/docker-compose.yaml
index a0c03951..5387bc2e 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -12,6 +12,16 @@ services:
- postgres_data:/var/lib/postgresql/data
networks: [envsync_network]
+ dynamic_secret_db:
+ image: postgres:17
+ ports:
+ - "${DYNAMIC_SECRET_PG_PORT:-5433}:5432"
+ environment:
+ POSTGRES_USER: ${DYNAMIC_SECRET_PG_USER:-dynroot}
+ POSTGRES_PASSWORD: ${DYNAMIC_SECRET_PG_PASSWORD:-dynrootpass}
+ POSTGRES_DB: ${DYNAMIC_SECRET_PG_DATABASE:-dynamic_secrets}
+ networks: [envsync_network]
+
redis:
image: redis:7
ports:
diff --git a/package.json b/package.json
index 7ba51db0..ca456755 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "monorepo",
- "version": "0.9.1",
+ "version": "0.10.0",
"private": true,
"workspaces": [
"packages/*",
diff --git a/packages/deploy-cli/package.json b/packages/deploy-cli/package.json
index 9f1900fd..2726b507 100644
--- a/packages/deploy-cli/package.json
+++ b/packages/deploy-cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@envsync-cloud/deploy-cli",
- "version": "0.9.1",
+ "version": "0.10.0",
"description": "CLI for self-hosted EnvSync deployment on Docker Swarm",
"type": "module",
"bin": {
diff --git a/packages/deploy-core/package.json b/packages/deploy-core/package.json
index b6f4db63..1e11bd83 100644
--- a/packages/deploy-core/package.json
+++ b/packages/deploy-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@envsync-cloud/deploy-core",
- "version": "0.9.1",
+ "version": "0.10.0",
"type": "module",
"module": "src/index.ts",
"exports": {
diff --git a/packages/deploy/package.json b/packages/deploy/package.json
index 2d626931..a9aa7f89 100644
--- a/packages/deploy/package.json
+++ b/packages/deploy/package.json
@@ -1,6 +1,6 @@
{
"name": "@envsync-cloud/deploy",
- "version": "0.9.1",
+ "version": "0.10.0",
"type": "module",
"bin": {
"envsync-deploy": "dist/index.js"
diff --git a/packages/envsync-api/package.json b/packages/envsync-api/package.json
index ba993aca..20934eb4 100644
--- a/packages/envsync-api/package.json
+++ b/packages/envsync-api/package.json
@@ -1,6 +1,6 @@
{
"name": "envsync-api",
- "version": "0.9.1",
+ "version": "0.10.0",
"type": "module",
"module": "index.ts",
"scripts": {
@@ -18,6 +18,7 @@
"sim": "bun run scripts/sim.ts"
},
"dependencies": {
+ "@aws-sdk/client-iam": "^3.1084.0",
"@aws-sdk/client-s3": "^3.821.0",
"@aws-sdk/client-ssm": "^3.1037.0",
"@aws-sdk/lib-storage": "^3.821.0",
@@ -49,6 +50,7 @@
"kysely": "^0.28.2",
"libsodium-wrappers": "^0.8.4",
"mustache": "^4.2.0",
+ "mysql2": "^3.22.6",
"node-cache": "^5.1.2",
"nodemailer": "^8.0.1",
"openid-client": "^6.5.0",
diff --git a/packages/envsync-api/src/controllers/dynamic_secret.controller.ts b/packages/envsync-api/src/controllers/dynamic_secret.controller.ts
new file mode 100644
index 00000000..47bd4c4f
--- /dev/null
+++ b/packages/envsync-api/src/controllers/dynamic_secret.controller.ts
@@ -0,0 +1,169 @@
+import type { Context } from "hono";
+
+import { assertEnterprise } from "@/helpers/enterprise-guard";
+import { DynamicSecretService } from "@/services/dynamic_secret.service";
+import { AuditLogService } from "@/services/audit_log.service";
+
+export class DynamicSecretController {
+ // ── Engines ────────────────────────────────────────────────────────────
+
+ public static readonly createEngine = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const { engine_type, name, config, enabled } = await c.req.json();
+
+ const engine = await DynamicSecretService.createEngine({
+ org_id,
+ engine_type,
+ name,
+ config,
+ enabled,
+ });
+
+ await AuditLogService.notifyAuditSystem({
+ action: "dynamic_secret_engine_created",
+ org_id,
+ user_id,
+ details: { engine_id: engine.id, engine_type, name },
+ message: `Dynamic secret engine "${name}" (${engine_type}) created.`,
+ });
+
+ return c.json(engine, 201);
+ };
+
+ public static readonly getEngine = async (c: Context) => {
+ const org_id = c.get("org_id");
+ const id = c.req.param("id");
+
+ const engine = await DynamicSecretService.getEngine(id, org_id);
+ return c.json(engine);
+ };
+
+ public static readonly getAllEngines = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+
+ const engines = await DynamicSecretService.getAllEngines(org_id);
+ return c.json(engines);
+ };
+
+ public static readonly updateEngine = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const id = c.req.param("id");
+ const { name, config, enabled } = await c.req.json();
+
+ const engine = await DynamicSecretService.updateEngine({
+ id,
+ org_id,
+ name,
+ config,
+ enabled,
+ });
+
+ await AuditLogService.notifyAuditSystem({
+ action: "dynamic_secret_engine_updated",
+ org_id,
+ user_id,
+ details: { engine_id: id, name, enabled },
+ message: `Dynamic secret engine "${engine.name}" updated.`,
+ });
+
+ return c.json(engine);
+ };
+
+ public static readonly deleteEngine = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const id = c.req.param("id");
+
+ await DynamicSecretService.deleteEngine(id, org_id);
+
+ await AuditLogService.notifyAuditSystem({
+ action: "dynamic_secret_engine_deleted",
+ org_id,
+ user_id,
+ details: { engine_id: id },
+ message: `Dynamic secret engine deleted.`,
+ });
+
+ return c.json({ message: "Dynamic secret engine deleted successfully" });
+ };
+
+ // ── Leases ─────────────────────────────────────────────────────────────
+
+ public static readonly createLease = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const engine_id = c.req.param("id");
+ const { app_id, env_type_id, variable_key, ttl_seconds } = await c.req.json();
+
+ const lease = await DynamicSecretService.createLease({
+ engine_id,
+ app_id,
+ env_type_id,
+ variable_key,
+ ttl_seconds,
+ org_id,
+ });
+
+ await AuditLogService.notifyAuditSystem({
+ action: "dynamic_secret_lease_created",
+ org_id,
+ user_id,
+ details: { lease_id: lease.id, engine_id, app_id, env_type_id, variable_key },
+ message: `Dynamic secret lease created for variable "${variable_key}".`,
+ });
+
+ return c.json(lease, 201);
+ };
+
+ public static readonly getLease = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const id = c.req.param("leaseId");
+
+ const lease = await DynamicSecretService.getLease(id, org_id);
+ return c.json(lease);
+ };
+
+ public static readonly getLeasesByEngine = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const engine_id = c.req.param("id");
+
+ const leases = await DynamicSecretService.getLeasesByEngine(engine_id, org_id);
+ return c.json(leases);
+ };
+
+ public static readonly revokeLease = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const lease_id = c.req.param("leaseId");
+
+ const result = await DynamicSecretService.revokeLease(lease_id, org_id);
+
+ await AuditLogService.notifyAuditSystem({
+ action: "dynamic_secret_lease_revoked",
+ org_id,
+ user_id,
+ details: { lease_id },
+ message: `Dynamic secret lease revoked.`,
+ });
+
+ return c.json(result);
+ };
+
+ public static readonly cleanupExpired = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+
+ const result = await DynamicSecretService.cleanupExpiredLeases();
+ return c.json(result);
+ };
+}
diff --git a/packages/envsync-api/src/controllers/enterprise.controller.ts b/packages/envsync-api/src/controllers/enterprise.controller.ts
index f2df8d58..590fa726 100644
--- a/packages/envsync-api/src/controllers/enterprise.controller.ts
+++ b/packages/envsync-api/src/controllers/enterprise.controller.ts
@@ -1,17 +1,20 @@
import type { Context } from "hono";
+import { assertEnterprise } from "@/helpers/enterprise-guard";
import { AuditLogService } from "@/services/audit_log.service";
import { EnterpriseIntegrationService } from "@/services/enterprise-integration.service";
import { EnterpriseProviderService } from "@/services/enterprise-provider.service";
export class EnterpriseController {
public static readonly getProviders = async (c: Context) => {
+ assertEnterprise();
return c.json({
providers: EnterpriseProviderService.listProviders(),
});
};
public static readonly getOrgSecretModel = async (c: Context) => {
+ assertEnterprise();
return c.json({
resource: "org_secret",
description: "Org-level reusable secret material for enterprise integrations.",
@@ -20,10 +23,12 @@ export class EnterpriseController {
};
public static readonly listProviderConnections = async (c: Context) => {
+ assertEnterprise();
return c.json(await EnterpriseIntegrationService.listProviderConnections(c.get("org_id")));
};
public static readonly createProviderConnection = async (c: Context) => {
+ assertEnterprise();
const org_id = c.get("org_id");
const payload = c.req.valid("json" as never) as Omit<
Parameters[0],
@@ -48,6 +53,7 @@ export class EnterpriseController {
};
public static readonly updateProviderConnection = async (c: Context) => {
+ assertEnterprise();
const org_id = c.get("org_id");
const payload = c.req.valid("json" as never);
const updated = await EnterpriseIntegrationService.updateProviderConnection(
@@ -70,10 +76,12 @@ export class EnterpriseController {
};
public static readonly listOrgSecrets = async (c: Context) => {
+ assertEnterprise();
return c.json(await EnterpriseIntegrationService.listOrgSecrets(c.get("org_id")));
};
public static readonly createOrgSecret = async (c: Context) => {
+ assertEnterprise();
const org_id = c.get("org_id");
const payload = c.req.valid("json" as never) as Omit<
Parameters[0],
@@ -97,6 +105,7 @@ export class EnterpriseController {
};
public static readonly updateOrgSecret = async (c: Context) => {
+ assertEnterprise();
const org_id = c.get("org_id");
const payload = c.req.valid("json" as never);
const updated = await EnterpriseIntegrationService.updateOrgSecret(c.req.param("id"), org_id, payload);
@@ -114,10 +123,12 @@ export class EnterpriseController {
};
public static readonly listBindings = async (c: Context) => {
+ assertEnterprise();
return c.json(await EnterpriseIntegrationService.listBindings(c.get("org_id"), c.req.param("app_id")));
};
public static readonly createBinding = async (c: Context) => {
+ assertEnterprise();
const org_id = c.get("org_id");
const app_id = c.req.param("app_id");
const payload = c.req.valid("json" as never) as Omit<
@@ -144,6 +155,7 @@ export class EnterpriseController {
};
public static readonly updateBinding = async (c: Context) => {
+ assertEnterprise();
const org_id = c.get("org_id");
const app_id = c.req.param("app_id");
const payload = c.req.valid("json" as never);
@@ -163,10 +175,12 @@ export class EnterpriseController {
};
public static readonly listMappings = async (c: Context) => {
+ assertEnterprise();
return c.json(await EnterpriseIntegrationService.listMappings(c.get("org_id"), c.req.param("app_id")));
};
public static readonly createMapping = async (c: Context) => {
+ assertEnterprise();
const org_id = c.get("org_id");
const app_id = c.req.param("app_id");
const payload = c.req.valid("json" as never) as Omit<
@@ -194,6 +208,7 @@ export class EnterpriseController {
};
public static readonly updateMapping = async (c: Context) => {
+ assertEnterprise();
const org_id = c.get("org_id");
const app_id = c.req.param("app_id");
const payload = c.req.valid("json" as never);
@@ -214,11 +229,13 @@ export class EnterpriseController {
};
public static readonly listSyncRuns = async (c: Context) => {
+ assertEnterprise();
const app_id = c.req.query("app_id");
return c.json(await EnterpriseIntegrationService.listSyncRuns(c.get("org_id"), app_id));
};
public static readonly createManualSyncRun = async (c: Context) => {
+ assertEnterprise();
const org_id = c.get("org_id");
const payload = c.req.valid("json" as never) as Omit<
Parameters[0],
@@ -244,6 +261,7 @@ export class EnterpriseController {
};
public static readonly listSyncAuditEvents = async (c: Context) => {
+ assertEnterprise();
return c.json(await EnterpriseIntegrationService.listSyncAuditEvents(c.get("org_id"), c.req.param("sync_run_id")));
};
}
diff --git a/packages/envsync-api/src/controllers/log-forwarding.controller.ts b/packages/envsync-api/src/controllers/log-forwarding.controller.ts
new file mode 100644
index 00000000..3540e73d
--- /dev/null
+++ b/packages/envsync-api/src/controllers/log-forwarding.controller.ts
@@ -0,0 +1,79 @@
+import { type Context } from "hono";
+
+import { assertEnterprise } from "@/helpers/enterprise-guard";
+import { AuditLogService } from "@/services/audit_log.service";
+import { LogForwardingService } from "@/services/log-forwarding.service";
+
+export class LogForwardingController {
+ public static readonly createConfig = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const { name, provider_type, config, enabled } = await c.req.json();
+
+ const id = await LogForwardingService.createConfig({
+ org_id,
+ name,
+ provider_type,
+ config: config as Record,
+ enabled,
+ });
+
+ await AuditLogService.notifyAuditSystem({
+ action: "webhook_created",
+ org_id,
+ user_id,
+ details: { log_forwarding_id: id, name, provider_type },
+ message: `Log forwarding config ${name} created.`,
+ });
+
+ const created = await LogForwardingService.getConfigById(id);
+ return c.json(created, 201);
+ };
+
+ public static readonly getConfigs = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const configs = await LogForwardingService.getConfigsByOrgId(org_id);
+ return c.json(configs, 200);
+ };
+
+ public static readonly getConfig = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const id = c.req.param("id");
+
+ const config = await LogForwardingService.getConfigById(id);
+
+ if (config.org_id !== org_id) {
+ return c.json({ error: "Log forwarding config does not belong to your organization" }, 403);
+ }
+
+ return c.json(config, 200);
+ };
+
+ public static readonly deleteConfig = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const id = c.req.param("id");
+
+ const config = await LogForwardingService.getConfigById(id);
+
+ if (config.org_id !== org_id) {
+ return c.json({ error: "Log forwarding config does not belong to your organization" }, 403);
+ }
+
+ await LogForwardingService.deleteConfig(id);
+
+ await AuditLogService.notifyAuditSystem({
+ action: "webhook_deleted",
+ org_id,
+ user_id,
+ details: { log_forwarding_id: id, name: config.name },
+ message: `Log forwarding config ${config.name} deleted.`,
+ });
+
+ return c.json({ message: "Log forwarding config deleted successfully" }, 200);
+ };
+}
diff --git a/packages/envsync-api/src/controllers/oidc.controller.ts b/packages/envsync-api/src/controllers/oidc.controller.ts
new file mode 100644
index 00000000..964fb465
--- /dev/null
+++ b/packages/envsync-api/src/controllers/oidc.controller.ts
@@ -0,0 +1,114 @@
+import { type Context } from "hono";
+
+import { assertEnterprise } from "@/helpers/enterprise-guard";
+import { OidcService } from "@/services/oidc.service";
+import { AuditLogService } from "@/services/audit_log.service";
+
+export class OidcController {
+ public static readonly createProvider = async (c: Context) => {
+ assertEnterprise();
+
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const body = await c.req.json();
+
+ const provider = await OidcService.createProvider({
+ org_id,
+ provider_type: body.provider_type,
+ issuer_url: body.issuer_url,
+ audience: body.audience,
+ allowed_subjects: body.allowed_subjects,
+ });
+
+ await AuditLogService.notifyAuditSystem({
+ action: "oidc_provider_created",
+ org_id,
+ user_id,
+ message: `OIDC provider created: ${body.provider_type} (${body.issuer_url})`,
+ details: {
+ provider_id: provider.id,
+ provider_type: body.provider_type,
+ issuer_url: body.issuer_url,
+ },
+ });
+
+ return c.json(provider, 201);
+ };
+
+ public static readonly getProvider = async (c: Context) => {
+ assertEnterprise();
+ const id = c.req.param("id");
+ const org_id = c.get("org_id");
+
+ const provider = await OidcService.getProvider(id);
+
+ if (provider.org_id !== org_id) {
+ return c.json({ error: "OIDC provider not found" }, 404);
+ }
+
+ return c.json(provider, 200);
+ };
+
+ public static readonly getAllProviders = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const providers = await OidcService.getProvidersByOrg(org_id);
+ return c.json(providers, 200);
+ };
+
+ public static readonly updateProvider = async (c: Context) => {
+ assertEnterprise();
+
+ const id = c.req.param("id");
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const body = await c.req.json();
+
+ const existing = await OidcService.getProvider(id);
+
+ if (existing.org_id !== org_id) {
+ return c.json({ error: "OIDC provider not found" }, 404);
+ }
+
+ await OidcService.updateProvider(id, {
+ audience: body.audience,
+ enabled: body.enabled,
+ allowed_subjects: body.allowed_subjects,
+ });
+
+ await AuditLogService.notifyAuditSystem({
+ action: "oidc_provider_updated",
+ org_id,
+ user_id,
+ message: `OIDC provider updated: ${id}`,
+ details: { provider_id: id },
+ });
+
+ return c.json({ message: "OIDC provider updated successfully." }, 200);
+ };
+
+ public static readonly deleteProvider = async (c: Context) => {
+ assertEnterprise();
+ const id = c.req.param("id");
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+
+ const existing = await OidcService.getProvider(id);
+
+ if (existing.org_id !== org_id) {
+ return c.json({ error: "OIDC provider not found" }, 404);
+ }
+
+ await OidcService.deleteProvider(id);
+
+ await AuditLogService.notifyAuditSystem({
+ action: "oidc_provider_deleted",
+ org_id,
+ user_id,
+ message: `OIDC provider deleted: ${id}`,
+ details: { provider_id: id, issuer_url: existing.issuer_url },
+ });
+
+ return c.json({ message: "OIDC provider deleted successfully." }, 200);
+ };
+}
diff --git a/packages/envsync-api/src/controllers/rotation.controller.ts b/packages/envsync-api/src/controllers/rotation.controller.ts
new file mode 100644
index 00000000..85901f8c
--- /dev/null
+++ b/packages/envsync-api/src/controllers/rotation.controller.ts
@@ -0,0 +1,210 @@
+import { type Context } from "hono";
+import { assertEnterprise } from "@/helpers/enterprise-guard";
+import { RotationService } from "@/services/rotation.service";
+import { AuditLogService } from "@/services/audit_log.service";
+import { AuthorizationService } from "@/services/authorization.service";
+import { EnvTypeService } from "@/services/env_type.service";
+
+export class RotationController {
+ public static readonly createPolicy = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const body = await c.req.json();
+
+ const { app_id, env_type_id, variable_key, engine_type, schedule_cron, dual_window_minutes, enabled, connection_config } = body;
+
+ if (!app_id || !env_type_id || !variable_key || !engine_type || !schedule_cron) {
+ return c.json(
+ { error: "app_id, env_type_id, variable_key, engine_type, and schedule_cron are required." },
+ 400,
+ );
+ }
+
+ // Check permissions on the env type
+ const canEdit = await AuthorizationService.check(user_id, "can_edit", "env_type", env_type_id);
+ if (!canEdit) {
+ return c.json({ error: "You do not have permission to manage rotation for this environment." }, 403);
+ }
+
+ const policy = await RotationService.createPolicy({
+ org_id,
+ app_id,
+ env_type_id,
+ variable_key,
+ engine_type,
+ schedule_cron,
+ dual_window_minutes: dual_window_minutes ?? 60,
+ enabled: enabled ?? true,
+ connection_config: connection_config ?? {},
+ });
+
+ await AuditLogService.notifyAuditSystem({
+ action: "rotation_policy_created",
+ org_id,
+ user_id,
+ message: `Rotation policy created for variable ${variable_key} with engine ${engine_type}.`,
+ details: {
+ policy_id: policy.id,
+ app_id,
+ env_type_id,
+ engine_type,
+ schedule_cron,
+ },
+ });
+
+ return c.json(policy, 201);
+ };
+
+ public static readonly getPolicies = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const app_id = c.req.query("app_id");
+ const env_type_id = c.req.query("env_type_id");
+ const enabledStr = c.req.query("enabled");
+
+ const enabled = enabledStr === "true" ? true : enabledStr === "false" ? false : undefined;
+
+ const policies = await RotationService.getPolicies(org_id, {
+ app_id,
+ env_type_id,
+ enabled,
+ });
+
+ return c.json(policies);
+ };
+
+ public static readonly getPolicy = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const { id } = c.req.param();
+
+ const policy = await RotationService.getPolicyById(id, org_id);
+ return c.json(policy);
+ };
+
+ public static readonly updatePolicy = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const { id } = c.req.param();
+ const body = await c.req.json();
+
+ // Verify the policy exists first to check permissions
+ const existing = await RotationService.getPolicyById(id, org_id);
+
+ const canEdit = await AuthorizationService.check(user_id, "can_edit", "env_type", existing.env_type_id);
+ if (!canEdit) {
+ return c.json({ error: "You do not have permission to manage rotation for this environment." }, 403);
+ }
+
+ const updated = await RotationService.updatePolicy(id, org_id, {
+ schedule_cron: body.schedule_cron,
+ dual_window_minutes: body.dual_window_minutes,
+ enabled: body.enabled,
+ connection_config: body.connection_config,
+ });
+
+ await AuditLogService.notifyAuditSystem({
+ action: "rotation_policy_updated",
+ org_id,
+ user_id,
+ message: `Rotation policy ${id} updated.`,
+ details: { policy_id: id },
+ });
+
+ return c.json(updated);
+ };
+
+ public static readonly deletePolicy = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const { id } = c.req.param();
+
+ // Verify exists and check permissions
+ const existing = await RotationService.getPolicyById(id, org_id);
+
+ const canEdit = await AuthorizationService.check(user_id, "can_edit", "env_type", existing.env_type_id);
+ if (!canEdit) {
+ return c.json({ error: "You do not have permission to manage rotation for this environment." }, 403);
+ }
+
+ await RotationService.deletePolicy(id, org_id);
+
+ await AuditLogService.notifyAuditSystem({
+ action: "rotation_policy_deleted",
+ org_id,
+ user_id,
+ message: `Rotation policy deleted for variable ${existing.variable_key}.`,
+ details: { policy_id: id, variable_key: existing.variable_key },
+ });
+
+ return c.json({ message: "Rotation policy deleted successfully" });
+ };
+
+ public static readonly triggerRotation = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const { id } = c.req.param();
+
+ // Verify exists and check permissions
+ const existing = await RotationService.getPolicyById(id, org_id);
+
+ const canEdit = await AuthorizationService.check(user_id, "can_edit", "env_type", existing.env_type_id);
+ if (!canEdit) {
+ return c.json({ error: "You do not have permission to trigger rotation for this environment." }, 403);
+ }
+
+ const result = await RotationService.executeRotation(id, org_id);
+
+ await AuditLogService.notifyAuditSystem({
+ action: "rotation_triggered",
+ org_id,
+ user_id,
+ message: `Secret rotation triggered for variable ${existing.variable_key}.`,
+ details: {
+ policy_id: id,
+ variable_key: existing.variable_key,
+ rotation_state_id: result.rotation_state_id,
+ },
+ });
+
+ return c.json({
+ message: "Rotation executed successfully",
+ ...result,
+ });
+ };
+
+ public static readonly getRotationStates = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const { id } = c.req.param();
+
+ const states = await RotationService.getRotationStates(id, org_id);
+ return c.json(states);
+ };
+
+ public static readonly revokeExpiredCredentials = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+
+ const results = await RotationService.revokeExpiredCredentials(org_id);
+
+ await AuditLogService.notifyAuditSystem({
+ action: "rotation_expired_credentials_revoked",
+ org_id,
+ user_id,
+ message: `Expired credential revocation completed. ${results.length} credentials processed.`,
+ details: { processed_count: results.length },
+ });
+
+ return c.json({
+ message: "Expired credential revocation completed",
+ processed: results.length,
+ results,
+ });
+ };
+}
diff --git a/packages/envsync-api/src/controllers/saml.controller.ts b/packages/envsync-api/src/controllers/saml.controller.ts
new file mode 100644
index 00000000..8951f084
--- /dev/null
+++ b/packages/envsync-api/src/controllers/saml.controller.ts
@@ -0,0 +1,261 @@
+import { type Context } from "hono";
+
+import { assertEnterprise } from "@/helpers/enterprise-guard";
+import { setWebAuthCookies, setActiveMembershipCookie } from "@/helpers/web-auth";
+import { SamlService } from "@/services/saml.service";
+import { UserService } from "@/services/user.service";
+import { AuditLogService } from "@/services/audit_log.service";
+import { config } from "@/utils/env";
+
+// Derive SAML session signing key from environment
+function getSamlSessionSecret(): string {
+ const explicit = process.env.SAML_SESSION_SECRET;
+ if (explicit) return explicit;
+ // Derive from existing Keycloak config so SAML sessions survive restarts
+ return `saml-session:${config.KEYCLOAK_WEB_CLIENT_SECRET}:${config.KEYCLOAK_REALM}`;
+}
+
+/**
+ * Generate a SAML-specific session JWT (HS256).
+ * This token is used for SAML SSO sessions alongside the Keycloak JWT flow.
+ * The auth middleware validates these via a separate code path in access.ts.
+ */
+async function generateSamlSessionToken(userId: string, email: string): Promise {
+ const secret = getSamlSessionSecret();
+ const keyMaterial = new TextEncoder().encode(secret);
+ const key = await crypto.subtle.importKey(
+ "raw",
+ keyMaterial as unknown as BufferSource,
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"],
+ );
+
+ const header = { alg: "HS256", typ: "JWT" };
+ const now = Math.floor(Date.now() / 1000);
+ const payload = {
+ sub: userId,
+ email,
+ iss: "envsync-saml",
+ iat: now,
+ exp: now + 60 * 60, // 1 hour
+ auth_type: "saml",
+ };
+
+ const headerB64 = btoa(JSON.stringify(header)).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
+ const payloadB64 = btoa(JSON.stringify(payload)).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
+ const signingInput = `${headerB64}.${payloadB64}`;
+ const sig = new Uint8Array(
+ await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signingInput) as unknown as BufferSource),
+ );
+ const sigB64 = btoa(String.fromCharCode(...sig)).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
+
+ return `${signingInput}.${sigB64}`;
+}
+
+export class SamlController {
+ public static readonly createProvider = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const body = await c.req.json();
+
+ const provider = await SamlService.createProvider({
+ org_id,
+ provider_type: body.provider_type,
+ name: body.name,
+ entity_id: body.entity_id,
+ sso_url: body.sso_url,
+ certificate: body.certificate,
+ });
+
+ await AuditLogService.notifyAuditSystem({
+ action: "saml_provider_created",
+ org_id,
+ user_id,
+ message: `SAML provider created: ${body.provider_type} (${body.name})`,
+ details: {
+ provider_id: provider.id,
+ provider_type: body.provider_type,
+ entity_id: body.entity_id,
+ },
+ });
+
+ return c.json(provider, 201);
+ };
+
+ public static readonly getProvider = async (c: Context) => {
+ assertEnterprise();
+ const id = c.req.param("id");
+ const org_id = c.get("org_id");
+
+ const provider = await SamlService.getProvider(id);
+
+ if (provider.org_id !== org_id) {
+ return c.json({ error: "SAML provider not found" }, 404);
+ }
+
+ return c.json(provider, 200);
+ };
+
+ public static readonly getAllProviders = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const providers = await SamlService.getProvidersByOrg(org_id);
+ return c.json(providers, 200);
+ };
+
+ public static readonly updateProvider = async (c: Context) => {
+ assertEnterprise();
+
+ const id = c.req.param("id");
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+ const body = await c.req.json();
+
+ const existing = await SamlService.getProvider(id);
+
+ if (existing.org_id !== org_id) {
+ return c.json({ error: "SAML provider not found" }, 404);
+ }
+
+ await SamlService.updateProvider(id, {
+ name: body.name,
+ entity_id: body.entity_id,
+ sso_url: body.sso_url,
+ certificate: body.certificate,
+ enabled: body.enabled,
+ });
+
+ await AuditLogService.notifyAuditSystem({
+ action: "saml_provider_updated",
+ org_id,
+ user_id,
+ message: `SAML provider updated: ${id}`,
+ details: { provider_id: id },
+ });
+
+ return c.json({ message: "SAML provider updated successfully." }, 200);
+ };
+
+ public static readonly deleteProvider = async (c: Context) => {
+ assertEnterprise();
+ const id = c.req.param("id");
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+
+ const existing = await SamlService.getProvider(id);
+
+ if (existing.org_id !== org_id) {
+ return c.json({ error: "SAML provider not found" }, 404);
+ }
+
+ await SamlService.deleteProvider(id);
+
+ await AuditLogService.notifyAuditSystem({
+ action: "saml_provider_deleted",
+ org_id,
+ user_id,
+ message: `SAML provider deleted: ${id}`,
+ details: { provider_id: id, entity_id: existing.entity_id },
+ });
+
+ return c.json({ message: "SAML provider deleted successfully." }, 200);
+ };
+
+ public static readonly getMetadata = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const metadata = await SamlService.getMetadata(org_id);
+ return c.text(metadata, 200, { "Content-Type": "application/xml" });
+ };
+
+ public static readonly initiateSso = async (c: Context) => {
+ assertEnterprise();
+ const org_id = c.get("org_id");
+ const body = await c.req.json();
+ const provider = await SamlService.getProvider(body.provider_id);
+
+ if (provider.org_id !== org_id) {
+ return c.json({ error: "SAML provider not found" }, 404);
+ }
+
+ const acsUrl = `${c.req.url.split("/saml")[0]}/saml/acs/${org_id}`;
+ const { redirectUrl, requestId } = await SamlService.initiateSso(body.provider_id, acsUrl);
+
+ return c.json({ redirect_url: redirectUrl, request_id: requestId }, 200);
+ };
+
+ /**
+ * ACS (Assertion Consumer Service) endpoint.
+ *
+ * This is an unauthenticated endpoint that receives the SAML Response from the IdP.
+ * After validating the response and creating/updating the user, it:
+ * 1. Generates a SAML session token
+ * 2. Sets auth cookies (same pattern as Keycloak flow)
+ * 3. Redirects to the dashboard callback URL
+ */
+ public static readonly handleAcs = async (c: Context) => {
+ // NOTE: No assertEnterprise() here — this is an unauthenticated endpoint.
+ // The IdP POSTs directly to this URL without any auth context.
+ const org_id = c.req.param("orgId");
+ const body = await c.req.parseBody();
+ const samlResponse = body.SAMLResponse;
+
+ if (typeof samlResponse !== "string") {
+ return c.json({ error: "Missing SAMLResponse" }, 400);
+ }
+
+ const providers = await SamlService.getProvidersByOrg(org_id);
+ const enabledProviders = providers.filter((p) => p.enabled);
+
+ if (enabledProviders.length === 0) {
+ return c.json({ error: "No SAML providers configured for this organization" }, 400);
+ }
+
+ let lastError: Error | null = null;
+ for (const provider of enabledProviders) {
+ try {
+ const acsUrl = `${c.req.url.split("?")[0]}`;
+ const result = await SamlService.handleAcs(provider.id, samlResponse, acsUrl);
+
+ // Generate SAML session token
+ const accessToken = await generateSamlSessionToken(result.userId, result.email);
+
+ // Set auth cookies (same pattern as Keycloak flow)
+ setWebAuthCookies(c, {
+ access_token: accessToken,
+ expires_in: 3600,
+ });
+
+ // Set active membership cookie for multi-org support
+ await UserService.touchLastLogin(result.userId);
+ setActiveMembershipCookie(c, result.userId);
+
+ await AuditLogService.notifyAuditSystem({
+ action: "saml_sso_success",
+ org_id,
+ user_id: result.userId,
+ message: `SAML SSO login via ${provider.provider_type}: ${result.email}`,
+ details: {
+ provider_id: provider.id,
+ provider_type: provider.provider_type,
+ email: result.email,
+ },
+ });
+
+ // Redirect to dashboard callback (same as Keycloak flow)
+ const callbackUrl = config.DASHBOARD_URL || "http://localhost:8080";
+ return c.redirect(`${callbackUrl}/auth/callback`, 302);
+ } catch (err) {
+ lastError = err instanceof Error ? err : new Error(String(err));
+ continue;
+ }
+ }
+
+ return c.json({
+ error: "SAML authentication failed",
+ details: lastError?.message ?? "No matching provider could validate the response",
+ }, 401);
+ };
+}
diff --git a/packages/envsync-api/src/controllers/service_token.controller.ts b/packages/envsync-api/src/controllers/service_token.controller.ts
new file mode 100644
index 00000000..572254db
--- /dev/null
+++ b/packages/envsync-api/src/controllers/service_token.controller.ts
@@ -0,0 +1,84 @@
+import { type Context } from "hono";
+
+import { AuditLogService } from "@/services/audit_log.service";
+import { ServiceTokenService } from "@/services/service_token.service";
+
+export class ServiceTokenController {
+ public static readonly createToken = async (c: Context) => {
+ const org_id = c.get("org_id");
+ const user_id = c.get("user_id");
+
+ const { name, app_id, env_type_id, permissions, expires_in_days } = await c.req.json();
+
+ const result = await ServiceTokenService.createToken({
+ org_id,
+ created_by_user_id: user_id,
+ name,
+ app_id,
+ env_type_id,
+ permissions,
+ expires_in_days,
+ });
+
+ await AuditLogService.notifyAuditSystem({
+ action: "service_token_created",
+ org_id,
+ user_id,
+ message: `Service token created: ${name}`,
+ details: {
+ service_token_id: result.id,
+ name,
+ app_id,
+ env_type_id,
+ },
+ });
+
+ return c.json(result, 201);
+ };
+
+ public static readonly getToken = async (c: Context) => {
+ const id = c.req.param("id");
+ const org_id = c.get("org_id");
+
+ const token = await ServiceTokenService.getToken(id);
+
+ if (token.org_id !== org_id) {
+ return c.json({ error: "Service token not found" }, 404);
+ }
+
+ return c.json(token, 200);
+ };
+
+ public static readonly getAllTokens = async (c: Context) => {
+ const org_id = c.get("org_id");
+ const page = Math.max(1, Number(c.req.query("page")) || 1);
+ const per_page = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
+
+ const tokens = await ServiceTokenService.getAllTokens(org_id, page, per_page);
+
+ return c.json(tokens, 200);
+ };
+
+ public static readonly deleteToken = async (c: Context) => {
+ const id = c.req.param("id");
+ const org_id = c.get("org_id");
+
+ const token = await ServiceTokenService.getToken(id);
+
+ if (token.org_id !== org_id) {
+ return c.json({ error: "Service token not found" }, 404);
+ }
+
+ await ServiceTokenService.deleteToken(id);
+
+ await AuditLogService.notifyAuditSystem({
+ action: "service_token_deleted",
+ org_id,
+ user_id: c.get("user_id"),
+ message: `Service token deleted: ${id}`,
+ details: { service_token_id: id },
+ });
+
+ return c.json({ message: "Service token deleted successfully." }, 200);
+ };
+}
diff --git a/packages/envsync-api/src/helpers/access.ts b/packages/envsync-api/src/helpers/access.ts
index 6f54c219..199c917a 100644
--- a/packages/envsync-api/src/helpers/access.ts
+++ b/packages/envsync-api/src/helpers/access.ts
@@ -1,17 +1,23 @@
import { ApiKeyService } from "@/services/api_key.service";
+import { OidcService } from "@/services/oidc.service";
import { UserService } from "@/services/user.service";
import { verifyJWTToken } from "./jwt";
+import { verifyOidcToken, mightBeOidcToken } from "./oidc";
+import { getKeycloakIssuer } from "@/helpers/keycloak";
+import { config } from "@/utils/env";
+
+export type AuthTokenType = "JWT" | "API_KEY" | "OIDC" | "SAML";
export const validateAccess = async ({
token,
type,
}: {
token: string;
- type: "JWT" | "API_KEY";
+ type: AuthTokenType;
}): Promise<{
user_id: string;
auth_service_id?: string;
- auth_type: "JWT" | "API_KEY";
+ auth_type: AuthTokenType;
}> => {
try {
let userId: string = "";
@@ -33,6 +39,29 @@ export const validateAccess = async ({
);
}
userId = user.id;
+ } else if (type === "SAML") {
+ const decoded = await verifySamlToken(token);
+ const sub = decoded.sub as string;
+ if (!sub) {
+ throw new Error("SAML token subject claim is missing");
+ }
+ // SAML tokens use direct user ID lookup (not IdP-based)
+ const user = await UserService.getUser(sub);
+ userId = user.id;
+ } else if (type === "OIDC") {
+ const enabledProviders = await OidcService.getAllEnabledProviders();
+ if (enabledProviders.length === 0) {
+ throw new Error("No OIDC providers configured");
+ }
+
+ const { provider } = await verifyOidcToken(token, enabledProviders);
+
+ if (!provider.machine_user_id) {
+ throw new Error("OIDC provider has no machine user assigned");
+ }
+
+ userId = provider.machine_user_id;
+ authServiceId = `oidc:${provider.id}`;
} else if (type === "API_KEY") {
const apiKey = await ApiKeyService.getKeyByCreds(token);
@@ -44,7 +73,6 @@ export const validateAccess = async ({
throw new Error("API key is deactivated");
}
- // registerKeyUsage
await ApiKeyService.registerKeyUsage(apiKey.id);
userId = apiKey.user_id;
@@ -61,3 +89,95 @@ export const validateAccess = async ({
);
}
};
+
+/**
+ * Determine the auth type for a Bearer token.
+ * - If the token's issuer is "envsync-saml" → SAML.
+ * - If the token's issuer matches Keycloak → JWT.
+ * - If the token's issuer matches a registered OIDC provider → OIDC.
+ * - Falls back to JWT (which will fail with a clear error if invalid).
+ */
+export function detectAuthType(bearerToken: string): AuthTokenType {
+ const cleanToken = bearerToken.replace(/^Bearer\s+/i, "");
+
+ // Fast check: SAML tokens have a distinctive issuer
+ if (isSamlToken(cleanToken)) {
+ return "SAML";
+ }
+
+ const keycloakIssuer = getKeycloakIssuer();
+ if (mightBeOidcToken(cleanToken, keycloakIssuer)) {
+ return "OIDC";
+ }
+
+ return "JWT";
+}
+
+/**
+ * Check if a JWT is a SAML session token by peeking at its payload's issuer.
+ * This is a fast pre-check before signature verification.
+ */
+function isSamlToken(token: string): boolean {
+ try {
+ const parts = token.split(".");
+ if (parts.length !== 3) return false;
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
+ return payload.iss === "envsync-saml";
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Verify a SAML session token (HS256).
+ * These tokens are issued by the SAML ACS endpoint and signed with a
+ * derived secret from the SAML_SESSION_SECRET env var (or Keycloak config).
+ */
+async function verifySamlToken(token: string): Promise> {
+ const secret = getSamlSessionSecret();
+ const keyMaterial = new TextEncoder().encode(secret);
+ const key = await crypto.subtle.importKey(
+ "raw",
+ keyMaterial as unknown as BufferSource,
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["verify"],
+ );
+
+ const parts = token.split(".");
+ if (parts.length !== 3) throw new Error("Invalid SAML token format");
+
+ const signingInput = `${parts[0]}.${parts[1]}`;
+ const sigB64 = parts[2].replace(/-/g, "+").replace(/_/g, "/");
+ const sigPadded = sigB64 + "=".repeat((4 - (sigB64.length % 4)) % 4);
+ const sigBytes = Uint8Array.from(atob(sigPadded), (c) => c.charCodeAt(0));
+
+ const valid = await crypto.subtle.verify(
+ "HMAC",
+ key,
+ sigBytes as unknown as BufferSource,
+ new TextEncoder().encode(signingInput) as unknown as BufferSource,
+ );
+
+ if (!valid) {
+ throw new Error("SAML token signature verification failed");
+ }
+
+ const payloadB64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
+ const payloadPadded = payloadB64 + "=".repeat((4 - (payloadB64.length % 4)) % 4);
+ const payload = JSON.parse(atob(payloadPadded));
+
+ // Check expiration
+ const now = Math.floor(Date.now() / 1000);
+ if (payload.exp && payload.exp < now) {
+ throw new Error("SAML token has expired");
+ }
+
+ return payload as Record;
+}
+
+function getSamlSessionSecret(): string {
+ const explicit = process.env.SAML_SESSION_SECRET;
+ if (explicit) return explicit;
+ return `saml-session:${config.KEYCLOAK_WEB_CLIENT_SECRET}:${config.KEYCLOAK_REALM}`;
+}
diff --git a/packages/envsync-api/src/helpers/cache-keys.ts b/packages/envsync-api/src/helpers/cache-keys.ts
index 31e774a5..9ec38fc1 100644
--- a/packages/envsync-api/src/helpers/cache-keys.ts
+++ b/packages/envsync-api/src/helpers/cache-keys.ts
@@ -47,6 +47,16 @@ export const CacheKeys = {
// Certificate
certsByOrg: (orgId: string) => `es:org:${orgId}:certs`,
+ // Service Token
+ serviceTokenByHash: (hash: string) => `es:servicetoken:hash:${hash}`,
+ serviceTokensByOrg: (orgId: string) => `es:org:${orgId}:servicetokens`,
+
+ // OIDC Providers
+ oidcProvidersByOrg: (orgId: string) => `es:org:${orgId}:oidc_providers`,
+
+ // SAML Providers
+ samlProvidersByOrg: (orgId: string) => `es:org:${orgId}:saml_providers`,
+
// Glob patterns for cascade invalidation
allForUser: (userId: string) => `es:user:${userId}*`,
allForOrg: (orgId: string) => `es:org:${orgId}:*`,
diff --git a/packages/envsync-api/src/helpers/enterprise-guard.ts b/packages/envsync-api/src/helpers/enterprise-guard.ts
new file mode 100644
index 00000000..e88e9129
--- /dev/null
+++ b/packages/envsync-api/src/helpers/enterprise-guard.ts
@@ -0,0 +1,11 @@
+import { EditionPolicyService } from "@/services/edition-policy.service";
+import { ForbiddenError } from "@/libs/errors";
+
+export function assertEnterprise() {
+ if (!EditionPolicyService.isEnterprise()) {
+ throw new ForbiddenError(
+ "This feature requires an enterprise license.",
+ "ENTERPRISE_FEATURE_REQUIRED",
+ );
+ }
+}
diff --git a/packages/envsync-api/src/helpers/oidc.ts b/packages/envsync-api/src/helpers/oidc.ts
new file mode 100644
index 00000000..cb5a26ff
--- /dev/null
+++ b/packages/envsync-api/src/helpers/oidc.ts
@@ -0,0 +1,177 @@
+import { createRemoteJWKSet, decodeJwt, jwtVerify, type JWTPayload } from "jose";
+
+import { AppError } from "@/libs/errors";
+import infoLogs, { LogTypes } from "@/libs/logger";
+
+import type { Selectable } from "kysely";
+import type { Database } from "@/types/db";
+
+type OidcProviderRow = Selectable;
+
+/**
+ * Per-issuer JWKS cache. Avoids re-creating the RemoteJWKSet on every request.
+ * Entries are keyed by issuer URL.
+ */
+const jwksCache = new Map>();
+
+function getJwksForIssuer(issuerUrl: string): ReturnType {
+ let jwks = jwksCache.get(issuerUrl);
+ if (!jwks) {
+ // Standard OIDC discovery: /.well-known/openid-configuration
+ // JWKS URI is typically /protocol/openid-connect/certs or /.well-known/jwks.json
+ // We try the standard OIDC discovery first, falling back to direct JWKS endpoint
+ const jwksUrl = new URL(`${issuerUrl}/.well-known/jwks.json`);
+ jwks = createRemoteJWKSet(jwksUrl);
+ jwksCache.set(issuerUrl, jwks);
+ }
+ return jwks;
+}
+
+/**
+ * Known CI/CD provider issuer URLs for quick detection.
+ * Used by the auth middleware to decide if a Bearer token might be an OIDC token.
+ */
+export const KNOWN_OIDC_ISSUERS: readonly string[] = [
+ "https://token.actions.githubusercontent.com",
+ "https://gitlab.com",
+];
+
+/**
+ * Decode a JWT without verification to extract the issuer.
+ * Used for provider lookup before full verification.
+ */
+export function decodeTokenIssuer(bearerToken: string): string | null {
+ try {
+ const payload = decodeJwt(bearerToken);
+ return payload.iss ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Check if a Bearer token looks like it might be from an external OIDC provider
+ * (i.e., not from Keycloak). This is a heuristic — it decodes the JWT header
+ * without verification and checks the issuer.
+ */
+export function mightBeOidcToken(bearerToken: string, keycloakIssuer: string): boolean {
+ const iss = decodeTokenIssuer(bearerToken);
+ if (!iss) return false;
+ if (iss === keycloakIssuer) return false;
+ // Any non-Keycloak issuer could be an OIDC provider
+ return true;
+}
+
+export interface OidcVerifiedPayload extends JWTPayload {
+ iss: string;
+ sub: string;
+ aud: string | string[];
+}
+
+/**
+ * Verify an OIDC JWT against a registered provider.
+ *
+ * Flow:
+ * 1. Decode (unverified) to get issuer → match against registered providers
+ * 2. Verify signature against the issuer's JWKS
+ * 3. Validate issuer and audience claims
+ * 4. Check subject against allowed_subjects (if configured)
+ *
+ * @returns The verified JWT payload
+ */
+export async function verifyOidcToken(
+ bearerToken: string,
+ providers: OidcProviderRow[],
+): Promise<{ payload: OidcVerifiedPayload; provider: OidcProviderRow }> {
+ const token = bearerToken.replace(/^Bearer\s+/i, "");
+
+ // Step 1: Decode without verification to find the issuer
+ const unverifiedIss = decodeTokenIssuer(token);
+ if (!unverifiedIss) {
+ throw new AppError("OIDC token missing issuer claim", 401, "OIDC_MISSING_ISSUER");
+ }
+
+ // Step 2: Find matching provider
+ const provider = providers.find(
+ (p) => p.issuer_url === unverifiedIss && p.enabled,
+ );
+ if (!provider) {
+ throw new AppError(
+ `No OIDC provider registered for issuer: ${unverifiedIss}`,
+ 401,
+ "OIDC_NO_PROVIDER",
+ );
+ }
+
+ // Step 3: Verify JWT signature + claims
+ const jwks = getJwksForIssuer(provider.issuer_url);
+
+ let payload: JWTPayload;
+ try {
+ const result = await jwtVerify(token, jwks, {
+ issuer: provider.issuer_url,
+ algorithms: ["RS256", "ES256"],
+ });
+ payload = result.payload;
+ } catch (err) {
+ infoLogs(
+ `OIDC JWT verification failed for issuer=${provider.issuer_url}: ${err instanceof Error ? err.message : String(err)}`,
+ LogTypes.ERROR,
+ "OIDC:VERIFY",
+ );
+ throw new AppError("OIDC token verification failed", 401, "OIDC_VERIFY_FAILED");
+ }
+
+ // Step 4: Validate audience
+ const aud = payload.aud;
+ const expectedAud = provider.audience;
+ const audArray = Array.isArray(aud) ? aud : [aud];
+ if (!audArray.includes(expectedAud)) {
+ throw new AppError(
+ "OIDC token audience does not match expected value",
+ 401,
+ "OIDC_AUDIENCE_MISMATCH",
+ );
+ }
+
+ // Step 5: Check allowed subjects (if configured)
+ if (!payload.sub) {
+ throw new AppError("OIDC token missing subject claim", 401, "OIDC_MISSING_SUB");
+ }
+
+ const allowedSubjects = provider.allowed_subjects as string[];
+ if (allowedSubjects.length > 0) {
+ const subjectAllowed = allowedSubjects.some((pattern) => {
+ if (pattern.includes("*")) {
+ // Simple glob matching: convert to regex
+ const regex = new RegExp(`^${pattern.replace(/\*/g, ".*")}$`);
+ return regex.test(payload.sub ?? "");
+ }
+ return pattern === payload.sub;
+ });
+
+ if (!subjectAllowed) {
+ throw new AppError(
+ "OIDC token subject not in allowed list",
+ 401,
+ "OIDC_SUBJECT_NOT_ALLOWED",
+ );
+ }
+ }
+
+ return {
+ payload: payload as OidcVerifiedPayload,
+ provider,
+ };
+}
+
+/**
+ * Clear the JWKS cache for a specific issuer (e.g., when a provider is removed).
+ */
+export function clearJwksCache(issuerUrl?: string): void {
+ if (issuerUrl) {
+ jwksCache.delete(issuerUrl);
+ } else {
+ jwksCache.clear();
+ }
+}
diff --git a/packages/envsync-api/src/helpers/saml.ts b/packages/envsync-api/src/helpers/saml.ts
new file mode 100644
index 00000000..b2f91e55
--- /dev/null
+++ b/packages/envsync-api/src/helpers/saml.ts
@@ -0,0 +1,815 @@
+import { v4 as uuidv4 } from "uuid";
+
+const SAML_NS = "urn:oasis:names:tc:SAML:2.0:assertion";
+const SAMLP_NS = "urn:oasis:names:tc:SAML:2.0:protocol";
+const DS_NS = "http://www.w3.org/2000/09/xmldsig#";
+
+export interface SamlAssertionAttributes {
+ email: string;
+ firstName: string;
+ lastName: string;
+ groups: string[];
+ nameId: string;
+}
+
+export interface SamlParseResult {
+ attributes: SamlAssertionAttributes;
+ inResponseTo: string;
+ destination: string;
+}
+
+// ---------------------------------------------------------------------------
+// IdP Configuration Helpers
+// ---------------------------------------------------------------------------
+
+export interface IdPConfigTemplate {
+ entityIdPath: string;
+ ssoUrlPath: string;
+ certInstructions: string;
+ attributeMapping: {
+ email: string[];
+ firstName: string[];
+ lastName: string[];
+ groups: string[];
+ };
+}
+
+const IDP_TEMPLATES: Record = {
+ okta: {
+ entityIdPath: "Identity Provider Metadata → Entity ID",
+ ssoUrlPath: "Identity Provider Metadata → SSO URL",
+ certInstructions:
+ "Download the signing certificate from Security → Certificates or from the Identity Provider metadata XML.",
+ attributeMapping: {
+ email: ["email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"],
+ firstName: ["firstName", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"],
+ lastName: ["lastName", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"],
+ groups: ["groups"],
+ },
+ },
+ "azure-ad": {
+ entityIdPath: "App Registration → Overview → Application ID URI, or SAML-based Sign-on → Identifier",
+ ssoUrlPath: "App Registration → Single sign-on → SAML-based Sign-on → Login URL",
+ certInstructions:
+ "Download the Base64 certificate from Enterprise Applications → Single sign-on → SAML Certificates → Certificate (Base64).",
+ attributeMapping: {
+ email: ["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "email"],
+ firstName: ["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", "firstName"],
+ lastName: ["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", "lastName"],
+ groups: ["http://schemas.microsoft.com/ws/2008/06/identity/claims/groups", "groups"],
+ },
+ },
+ "google-workspace": {
+ entityIdPath: "Admin Console → Apps → SAML apps → Service Provider Details → Entity ID",
+ ssoUrlPath: "Admin Console → Apps → SAML apps → Identity Provider details → SSO URL",
+ certInstructions:
+ "Download the certificate from Admin Console → Apps → SAML apps → Identity Provider details → Certificate.",
+ attributeMapping: {
+ email: ["email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"],
+ firstName: ["firstName", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"],
+ lastName: ["lastName", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"],
+ groups: ["groups"],
+ },
+ },
+ onelogin: {
+ entityIdPath: "SSO tab → Issuer URL",
+ ssoUrlPath: "SSO tab → SAML 2.0 Endpoint (HTTP)",
+ certInstructions:
+ "Download the X.509 certificate from the SSO tab → X.509 Certificate → View Details.",
+ attributeMapping: {
+ email: ["email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"],
+ firstName: ["firstName", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"],
+ lastName: ["lastName", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"],
+ groups: ["groups", "memberOf"],
+ },
+ },
+};
+
+export function getIdPTemplate(providerType: string): IdPConfigTemplate | null {
+ return IDP_TEMPLATES[providerType] ?? null;
+}
+
+export function getSupportedIdPTypes(): string[] {
+ return Object.keys(IDP_TEMPLATES);
+}
+
+// ---------------------------------------------------------------------------
+// SP Certificate Generation (Web Crypto API)
+// ---------------------------------------------------------------------------
+
+export interface SpCertificate {
+ certPem: string;
+ privateKeyPem: string;
+}
+
+let cachedSpCert: SpCertificate | null = null;
+
+/**
+ * Generate or retrieve SP signing certificate.
+ * In production, this should be provided via SAML_SP_CERT and SAML_SP_KEY env vars.
+ * For development, generates a self-signed RSA certificate using Web Crypto API.
+ */
+export async function getSpCertificate(): Promise {
+ if (cachedSpCert) return cachedSpCert;
+
+ const envCert = process.env.SAML_SP_CERT;
+ const envKey = process.env.SAML_SP_KEY;
+ if (envCert && envKey) {
+ cachedSpCert = { certPem: envCert, privateKeyPem: envKey };
+ return cachedSpCert;
+ }
+
+ cachedSpCert = await generateSelfSignedCert();
+ return cachedSpCert;
+}
+
+async function generateSelfSignedCert(): Promise {
+ const keyPair = await crypto.subtle.generateKey(
+ { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
+ true,
+ ["sign", "verify"],
+ );
+
+ const publicKeySpki = new Uint8Array(await crypto.subtle.exportKey("spki", keyPair.publicKey));
+ const privateKeyPkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", keyPair.privateKey));
+
+ const serialNumber = generateSerialNumber();
+ const notBefore = new Date();
+ const notAfter = new Date(notBefore.getTime() + 10 * 365 * 24 * 60 * 60 * 1000); // 10 years
+
+ const certDer = buildSelfSignedX509Der(publicKeySpki, serialNumber, notBefore, notAfter);
+ const certPem = `-----BEGIN CERTIFICATE-----\n${formatBase64Lines(derToBase64(certDer))}\n-----END CERTIFICATE-----`;
+ const keyPem = `-----BEGIN PRIVATE KEY-----\n${formatBase64Lines(derToBase64(privateKeyPkcs8))}\n-----END PRIVATE KEY-----`;
+
+ return { certPem, privateKeyPem: keyPem };
+}
+
+function generateSerialNumber(): Uint8Array {
+ const bytes = new Uint8Array(16);
+ crypto.getRandomValues(bytes);
+ bytes[0] &= 0x7f; // Ensure positive
+ return bytes;
+}
+
+function derToBase64(der: Uint8Array): string {
+ let binary = "";
+ for (const byte of der) binary += String.fromCharCode(byte);
+ return btoa(binary);
+}
+
+function formatBase64Lines(b64: string): string {
+ const lines: string[] = [];
+ for (let i = 0; i < b64.length; i += 64) lines.push(b64.slice(i, i + 64));
+ return lines.join("\n");
+}
+
+// Minimal DER builder for self-signed X.509 certificate
+function buildSelfSignedX509Der(
+ publicKeySpki: Uint8Array,
+ serial: Uint8Array,
+ notBefore: Date,
+ notAfter: Date,
+): Uint8Array {
+ const validity = derSequence(
+ derUtcTime(notBefore),
+ derUtcTime(notAfter),
+ );
+ const issuer = derSequence(
+ derSet(derSequence(derOid([2, 5, 4, 3]), derUtf8String("EnvSync SP"))),
+ );
+ const tbsCertificate = derSequence(
+ derInteger(serial),
+ derSequence(derOid([2, 16, 840,1,101,3,4,2,1]), derNull()), // SHA256
+ issuer,
+ validity,
+ issuer, // self-signed: issuer = subject
+ new Uint8Array(publicKeySpki),
+ );
+ // For self-signed dev certs we skip the real signature and embed the TBSCertificate
+ // as a placeholder. Real deployments should use SAML_SP_CERT/SAML_SP_KEY env vars.
+ return derSequence(
+ tbsCertificate,
+ derSequence(derOid([2, 16, 840,1,101,3,4,2,1]), derNull()),
+ derBitString(new Uint8Array(256)),
+ );
+}
+
+function derLengthBytes(length: number): Uint8Array {
+ if (length < 0x80) return new Uint8Array([length]);
+ if (length < 0x100) return new Uint8Array([0x81, length]);
+ return new Uint8Array([0x82, (length >> 8) & 0xff, length & 0xff]);
+}
+
+function derTlv(tag: number, content: Uint8Array): Uint8Array {
+ const lenBytes = derLengthBytes(content.length);
+ const result = new Uint8Array(1 + lenBytes.length + content.length);
+ result[0] = tag;
+ result.set(lenBytes, 1);
+ result.set(content, 1 + lenBytes.length);
+ return result;
+}
+
+function derSequence(...items: Uint8Array[]): Uint8Array {
+ let total = 0;
+ for (const item of items) total += item.length;
+ const content = new Uint8Array(total);
+ let offset = 0;
+ for (const item of items) { content.set(item, offset); offset += item.length; }
+ return derTlv(0x30, content);
+}
+
+function derSet(...items: Uint8Array[]): Uint8Array {
+ let total = 0;
+ for (const item of items) total += item.length;
+ const content = new Uint8Array(total);
+ let offset = 0;
+ for (const item of items) { content.set(item, offset); offset += item.length; }
+ return derTlv(0x31, content);
+}
+
+function derOid(oid: number[]): Uint8Array {
+ if (oid.length < 2) throw new Error("OID must have at least 2 components");
+ const content = [oid[0] * 40 + oid[1]];
+ for (let i = 2; i < oid.length; i++) {
+ let v = oid[i];
+ if (v < 0x80) { content.push(v); continue; }
+ const bytes: number[] = [];
+ while (v > 0) { bytes.unshift(v & 0x7f); v >>= 7; }
+ for (let j = 0; j < bytes.length - 1; j++) bytes[j] |= 0x80;
+ content.push(...bytes);
+ }
+ return derTlv(0x06, new Uint8Array(content));
+}
+
+function derNull(): Uint8Array {
+ return new Uint8Array([0x05, 0x00]);
+}
+
+function derInteger(value: Uint8Array): Uint8Array {
+ const needsPad = value[0] & 0x80;
+ const content = needsPad
+ ? new Uint8Array(value.length + 1)
+ : value;
+ if (needsPad) { content[0] = 0; content.set(value, 1); }
+ return derTlv(0x02, content);
+}
+
+function derUtf8String(value: string): Uint8Array {
+ const bytes = new TextEncoder().encode(value);
+ return derTlv(0x0c, bytes);
+}
+
+function derUtcTime(date: Date): Uint8Array {
+ const y = date.getUTCFullYear();
+ const str = `${y >= 2000 ? y - 2000 : y - 1900}${pad2(date.getUTCMonth() + 1)}${pad2(date.getUTCDate())}${pad2(date.getUTCHours())}${pad2(date.getUTCMinutes())}${pad2(date.getUTCSeconds())}Z`;
+ return derTlv(0x17, new TextEncoder().encode(str));
+}
+
+function derBitString(data: Uint8Array): Uint8Array {
+ const content = new Uint8Array(data.length + 1);
+ content[0] = 0; // unused bits
+ content.set(data, 1);
+ return derTlv(0x03, content);
+}
+
+function pad2(n: number): string {
+ return n.toString().padStart(2, "0");
+}
+
+// ---------------------------------------------------------------------------
+// XML Signature Verification
+// ---------------------------------------------------------------------------
+
+/**
+ * Verify an XML-DSIG signature in a SAML Response using the IdP's X.509 certificate.
+ *
+ * Steps:
+ * 1. Extract the element from the
+ * 2. Canonicalize SignedInfo using Exclusive C14N (for signature verification)
+ * 3. Extract the public key from the IdP's X.509 certificate
+ * 4. Verify the RSA/ECDSA signature over the canonicalized SignedInfo
+ * 5. Verify the DigestValue over the canonicalized assertion
+ */
+export async function verifyXmlSignature(
+ xml: string,
+ idpCertificate: string,
+): Promise {
+ const signatureValue = getFirstMatch(xml, /]*>([\s\S]*?)<\/ds:SignatureValue>/);
+ if (!signatureValue) {
+ throw new Error("SAML response missing SignatureValue");
+ }
+
+ const signedInfoBlock = getFirstMatch(xml, /]*>([\s\S]*?)<\/ds:SignedInfo>/);
+ if (!signedInfoBlock) {
+ throw new Error("SAML response missing SignedInfo");
+ }
+
+ // Reconstruct full SignedInfo element for canonicalization
+ const signedInfoXml = `${signedInfoBlock} `;
+
+ // Canonicalize SignedInfo (Exclusive C14N - required by most SAML IdPs)
+ const canonicalizedSignedInfo = canonicalizeForSignature(signedInfoXml);
+
+ // Extract public key from IdP certificate
+ const certPem = extractCertPem(idpCertificate);
+ if (certPem.length === 0) {
+ throw new Error("IdP certificate is empty or invalid");
+ }
+ const publicKey = await importCertPublicKey(certPem);
+
+ // Decode the signature value from base64
+ const sigBytes = base64ToUint8Array(signatureValue.trim());
+ const dataBytes = new TextEncoder().encode(canonicalizedSignedInfo);
+
+ // Verify the RSA signature (SHA-256 with RSASSA-PKCS1-v1_5)
+ const signatureValid = await crypto.subtle.verify(
+ "RSASSA-PKCS1-v1_5",
+ publicKey,
+ sigBytes as unknown as BufferSource,
+ dataBytes as unknown as BufferSource,
+ );
+
+ if (!signatureValid) {
+ throw new Error("SAML XML signature verification failed");
+ }
+
+ // Also verify the DigestValue over the assertion
+ await verifyAssertionDigest(xml);
+
+ return true;
+}
+
+/**
+ * Verify the DigestValue in the XML signature matches the canonicalized assertion.
+ */
+async function verifyAssertionDigest(xml: string): Promise {
+ const digestValue = getFirstMatch(xml, /]*>([^<]+)<\/ds:DigestValue>/);
+ if (!digestValue) {
+ throw new Error("SAML signature missing DigestValue");
+ }
+
+ const referenceUri = getFirstMatch(xml, /]+URI="([^"]+)"/);
+ if (!referenceUri) {
+ throw new Error("SAML signature missing Reference URI");
+ }
+
+ // The URI references the Assertion ID (typically "#_id")
+ const assertionId = referenceUri.startsWith("#") ? referenceUri.slice(1) : referenceUri;
+ const assertionXml = extractAssertionById(xml, assertionId);
+ if (!assertionXml) {
+ throw new Error(`SAML assertion with ID "${assertionId}" not found`);
+ }
+
+ // Canonicalize the assertion and compute its SHA-256 digest
+ const canonicalAssertion = canonicalizeForSignature(assertionXml);
+ const assertionBytes = new TextEncoder().encode(canonicalAssertion);
+ const computedDigest = await crypto.subtle.digest("SHA-256", assertionBytes.buffer);
+ const computedDigestB64 = uint8ArrayToBase64(new Uint8Array(computedDigest));
+
+ if (computedDigestB64 !== digestValue.trim()) {
+ throw new Error("SAML assertion digest mismatch — assertion may have been tampered with");
+ }
+}
+
+/**
+ * Extract an Assertion element by its ID attribute from the SAML response.
+ */
+function extractAssertionById(xml: string, assertionId: string): string | null {
+ // Try with saml namespace prefix
+ const escapedId = escapeXml(assertionId);
+ const patterns = [
+ new RegExp(`(]+ID="${escapedId}"[\\s\\S]*?<\\/saml:Assertion>)`),
+ new RegExp(`(]+ID="${escapedId}"[\\s\\S]*?<\\/Assertion>)`),
+ ];
+ for (const pattern of patterns) {
+ const match = xml.match(pattern);
+ if (match?.[1]) return match[1];
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Exclusive XML Canonicalization (C14N) — subset sufficient for SAML XML-DSIG
+// ---------------------------------------------------------------------------
+
+/**
+ * Canonicalize an XML element using Exclusive XML Canonicalization (C14N).
+ * This is the canonicalization algorithm used by most SAML IdPs for XML-DSIG.
+ *
+ * Implementation handles the common subset needed for SAML:
+ * - Sort attributes lexicographically by namespace URI then local name
+ * - Escape special characters in text and attribute values
+ * - Preserve namespace declarations in scope
+ * - Normalize whitespace in text nodes
+ */
+function canonicalizeForSignature(xml: string): string {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(xml, "application/xml");
+
+ const parserError = doc.querySelector("parsererror");
+ if (parserError) {
+ throw new Error(`XML parse error during canonicalization: ${parserError.textContent}`);
+ }
+
+ const root = doc.documentElement;
+ return canonicalizeNode(root, new Map());
+}
+
+function canonicalizeNode(node: Node, parentNamespaces: Map): string {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const text = node.textContent ?? "";
+ // Inclusive C14N: normalize line breaks and escape special chars
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
+ return escapeXmlText(normalized);
+ }
+
+ if (node.nodeType !== Node.ELEMENT_NODE) return "";
+
+ const el = node as Element;
+ const tagName = el.tagName;
+ const nsMap = new Map(parentNamespaces);
+
+ // Collect namespace declarations from this element
+ for (const attr of Array.from(el.attributes)) {
+ if (attr.name === "xmlns") {
+ nsMap.set("", attr.value);
+ } else if (attr.name.startsWith("xmlns:")) {
+ nsMap.set(attr.name.slice(6), attr.value);
+ }
+ }
+
+ // Sort attributes: namespace URI first, then local name
+ const attrs = Array.from(el.attributes)
+ .filter((a) => !a.name.startsWith("xmlns"))
+ .sort((a, b) => {
+ const aNs = a.namespaceURI ?? "";
+ const bNs = b.namespaceURI ?? "";
+ if (aNs !== bNs) return aNs < bNs ? -1 : 1;
+ return a.localName < b.localName ? -1 : a.localName > b.localName ? 1 : 0;
+ });
+
+ // Build the element
+ let result = `<${tagName}`;
+
+ // Output namespace declarations that are in scope
+ for (const [prefix, uri] of nsMap) {
+ if (prefix === "") {
+ result += ` xmlns="${escapeXmlAttr(uri)}"`;
+ } else {
+ result += ` xmlns:${prefix}="${escapeXmlAttr(uri)}"`;
+ }
+ }
+
+ for (const attr of attrs) {
+ const ns = attr.namespaceURI;
+ const attrName = ns ? `${ns}:${attr.localName}` : attr.localName;
+ result += ` ${attrName}="${escapeXmlAttr(attr.value)}"`;
+ }
+
+ result += ">";
+
+ // Process child nodes
+ for (const child of Array.from(node.childNodes)) {
+ result += canonicalizeNode(child, nsMap);
+ }
+
+ result += `${tagName}>`;
+ return result;
+}
+
+function escapeXmlText(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/\r\n/g, "
\n")
+ .replace(/\r/g, "
");
+}
+
+function escapeXmlAttr(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/\t/g, " ")
+ .replace(/\n/g, "
")
+ .replace(/\r/g, "
");
+}
+
+// ---------------------------------------------------------------------------
+// X.509 Public Key Import
+// ---------------------------------------------------------------------------
+
+/**
+ * Import a public key from a PEM-encoded X.509 certificate.
+ * Parses the DER-encoded certificate to extract the SubjectPublicKeyInfo (SPKI)
+ * and imports it into Web Crypto API.
+ */
+async function importCertPublicKey(certPem: string): Promise {
+ const certDer = base64ToUint8Array(certPem);
+ const spki = extractSpkiFromCertificate(certDer);
+
+ return crypto.subtle.importKey(
+ "spki",
+ spki as unknown as BufferSource,
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
+ false,
+ ["verify"],
+ );
+}
+
+/**
+ * Extract the SubjectPublicKeyInfo (SPKI) bytes from a DER-encoded X.509 certificate.
+ *
+ * X.509 structure: SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue }
+ * tbsCertificate: SEQUENCE { ..., subjectPublicKeyInfo }
+ *
+ * We locate the SPKI by parsing the DER structure.
+ */
+function extractSpkiFromCertificate(certDer: Uint8Array): Uint8Array {
+ let offset = 0;
+
+ function readLength(): number {
+ const first = certDer[offset++];
+ if (first < 0x80) return first;
+ const numBytes = first & 0x7f;
+ let length = 0;
+ for (let i = 0; i < numBytes; i++) {
+ length = (length << 8) | certDer[offset++];
+ }
+ return length;
+ }
+
+ function readTlv(): { tag: number; content: Uint8Array; contentOffset: number } {
+ const tag = certDer[offset++];
+ const length = readLength();
+ const contentOffset = offset;
+ const content = certDer.slice(offset, offset + length);
+ offset += length;
+ return { tag, content, contentOffset };
+ }
+
+ function skipTlv() {
+ certDer[offset++]; // tag
+ const length = readLength();
+ offset += length;
+ }
+
+ // Outer SEQUENCE (Certificate)
+ const outerSeq = readTlv();
+ if (outerSeq.tag !== 0x30) throw new Error("Invalid X.509: expected SEQUENCE");
+
+ // tbsCertificate SEQUENCE
+ offset = outerSeq.contentOffset;
+ const tbsSeq = readTlv();
+ if (tbsSeq.tag !== 0x30) throw new Error("Invalid X.509: expected tbsCertificate SEQUENCE");
+
+ // Parse tbsCertificate to find subjectPublicKeyInfo
+ offset = tbsSeq.contentOffset;
+
+ // Skip version (optional, context tag 0)
+ if (certDer[offset] === 0xa0) skipTlv();
+ // Skip serialNumber
+ skipTlv();
+ // Skip signature algorithm
+ skipTlv();
+ // Skip issuer
+ skipTlv();
+ // Skip validity
+ skipTlv();
+ // Skip subject
+ skipTlv();
+
+ // Next is subjectPublicKeyInfo
+ const spkiStart = offset;
+ const spkiTlv = readTlv();
+ if (spkiTlv.tag !== 0x30) throw new Error("Invalid X.509: expected subjectPublicKeyInfo SEQUENCE");
+
+ return certDer.slice(spkiStart, offset);
+}
+
+// ---------------------------------------------------------------------------
+// AuthnRequest Builder
+// ---------------------------------------------------------------------------
+
+export function buildAuthnRequest(
+ spEntityId: string,
+ acsUrl: string,
+ idpSsoUrl: string,
+): { xml: string; requestId: string } {
+ const requestId = `_${uuidv4()}`;
+ const issueInstant = new Date().toISOString();
+
+ const xml = [
+ ``,
+ `${escapeXml(spEntityId)} `,
+ ` `,
+ ` `,
+ ].join("");
+
+ return { xml, requestId };
+}
+
+// ---------------------------------------------------------------------------
+// SP Metadata Builder
+// ---------------------------------------------------------------------------
+
+export async function buildSpMetadata(
+ spEntityId: string,
+ acsUrl: string,
+): Promise {
+ const spCert = await getSpCertificate();
+ const certBase64 = extractCertPem(spCert.certPem);
+
+ return [
+ ``,
+ ``,
+ ``,
+ ``,
+ `${certBase64} `,
+ ` `,
+ `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress `,
+ ` `,
+ ` `,
+ ` `,
+ ].join("");
+}
+
+// ---------------------------------------------------------------------------
+// SAML Response Validation
+// ---------------------------------------------------------------------------
+
+function decodeBase64(base64: string): string {
+ const raw = atob(base64);
+ const bytes = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) {
+ bytes[i] = raw.charCodeAt(i);
+ }
+ return new TextDecoder().decode(bytes);
+}
+
+function base64ToUint8Array(b64: string): Uint8Array {
+ const raw = atob(b64);
+ const bytes = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
+ return bytes;
+}
+
+function uint8ArrayToBase64(bytes: Uint8Array): string {
+ let binary = "";
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return btoa(binary);
+}
+
+function getFirstMatch(xml: string, pattern: RegExp): string | null {
+ const match = xml.match(pattern);
+ return match?.[1] ?? null;
+}
+
+function getAllMatches(xml: string, pattern: RegExp): string[] {
+ const matches: string[] = [];
+ let match: RegExpExecArray | null;
+ while ((match = pattern.exec(xml)) !== null) {
+ matches.push(match[1]);
+ }
+ return matches;
+}
+
+function extractCertPem(certificate: string): string {
+ const stripped = certificate
+ .replace(/-----BEGIN CERTIFICATE-----/g, "")
+ .replace(/-----END CERTIFICATE-----/g, "")
+ .replace(/\s+/g, "");
+ return stripped;
+}
+
+function escapeXml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+export async function validateSamlResponse(
+ samlResponseBase64: string,
+ idpCertificate: string,
+ expectedAcsUrl: string,
+): Promise {
+ const xml = decodeBase64(samlResponseBase64);
+
+ // 1. Check status code
+ const statusCode = getFirstMatch(xml, /StatusCode\s+Value="([^"]+)"/);
+ if (!statusCode || !statusCode.includes("Success")) {
+ throw new Error("SAML response indicates authentication failure");
+ }
+
+ // 2. Verify destination
+ const destination = getFirstMatch(xml, /Response[^>]+Destination="([^"]+)"/);
+ if (destination && destination !== expectedAcsUrl) {
+ throw new Error("SAML response destination mismatch");
+ }
+
+ // 3. Verify InResponseTo
+ const inResponseTo = getFirstMatch(xml, /Response[^>]+InResponseTo="([^"]+)"/);
+ if (!inResponseTo) {
+ throw new Error("SAML response missing InResponseTo attribute");
+ }
+
+ // 4. Extract NameID
+ const nameId = getFirstMatch(xml, /NameID[^>]*>([^<]+)<\/saml:NameID>/)
+ ?? getFirstMatch(xml, /NameID[^>]*>([^<]+)<\/NameID>/);
+ if (!nameId) {
+ throw new Error("SAML response missing NameID");
+ }
+
+ // 5. Verify time conditions
+ const notBefore = getFirstMatch(xml, /Conditions[^>]+NotBefore="([^"]+)"/);
+ const notOnOrAfter = getFirstMatch(xml, /Conditions[^>]+NotOnOrAfter="([^"]+)"/);
+ if (notBefore && notOnOrAfter) {
+ const now = Date.now();
+ const start = new Date(notBefore).getTime();
+ const end = new Date(notOnOrAfter).getTime();
+ // Allow 5-minute clock skew
+ const clockSkew = 5 * 60 * 1000;
+ if (now < start - clockSkew || now >= end + clockSkew) {
+ throw new Error("SAML assertion is outside the valid time window");
+ }
+ }
+
+ // 6. Verify XML signature (the critical security check)
+ const certPem = extractCertPem(idpCertificate);
+ if (certPem.length > 0 && xml.includes(" 0) {
+ // Certificate provided but no signature found — reject
+ throw new Error("SAML response is not signed but IdP certificate is configured");
+ }
+
+ // 7. Extract attributes
+ const email = extractAttribute(xml, "email")
+ ?? extractAttribute(xml, "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress")
+ ?? nameId;
+ const firstName = extractAttribute(xml, "firstName")
+ ?? extractAttribute(xml, "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname")
+ ?? extractAttribute(xml, "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname")
+ ?? "";
+ const lastName = extractAttribute(xml, "lastName")
+ ?? extractAttribute(xml, "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname")
+ ?? extractAttribute(xml, "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastname")
+ ?? "";
+ const groups = extractAttributeValues(xml, "groups")
+ ?? extractAttributeValues(xml, "http://schemas.xmlsoap.org/claims/Group")
+ ?? [];
+
+ return {
+ attributes: { email, firstName, lastName, groups, nameId },
+ inResponseTo,
+ destination: destination ?? expectedAcsUrl,
+ };
+}
+
+function extractAttribute(xml: string, name: string): string | null {
+ const escapedName = escapeXml(name);
+ const pattern = new RegExp(
+ `]+Name="${escapedName}"[^>]*>\\s*]*>([^<]+)<\\/saml:AttributeValue>`,
+ );
+ return getFirstMatch(xml, pattern)
+ ?? getFirstMatch(xml, new RegExp(
+ `]+Name="${escapedName}"[^>]*>\\s*]*>([^<]+)<\\/AttributeValue>`,
+ ));
+}
+
+function extractAttributeValues(xml: string, name: string): string[] | null {
+ const escapedName = escapeXml(name);
+ const pattern = new RegExp(
+ `]+Name="${escapedName}"[^>]*>([\\s\\S]*?)<\\/saml:Attribute>`,
+ );
+ const block = getFirstMatch(xml, pattern)
+ ?? getFirstMatch(xml, new RegExp(
+ `]+Name="${escapedName}"[^>]*>([\\s\\S]*?)<\\/Attribute>`,
+ ));
+ if (!block) return null;
+
+ const values = getAllMatches(block, /]*>([^<]+)<\/saml:AttributeValue>/g);
+ if (values.length > 0) return values;
+
+ return getAllMatches(block, /]*>([^<]+)<\/AttributeValue>/g);
+}
+
+// ---------------------------------------------------------------------------
+// Utility
+// ---------------------------------------------------------------------------
+
+export function deflateAndEncode(xml: string): string {
+ const encoded = btoa(xml);
+ return encodeURIComponent(encoded);
+}
diff --git a/packages/envsync-api/src/helpers/web-auth.ts b/packages/envsync-api/src/helpers/web-auth.ts
index 1d4d610f..cd97a2a3 100644
--- a/packages/envsync-api/src/helpers/web-auth.ts
+++ b/packages/envsync-api/src/helpers/web-auth.ts
@@ -42,11 +42,13 @@ function sharedCookieDomain() {
}
function cookieBaseOptions(path = "/api") {
+ const domain = sharedCookieDomain();
return {
httpOnly: true,
secure: shouldUseSecureCookies(),
sameSite: "Lax" as SameSitePolicy,
path,
+ ...(domain ? { domain } : {}),
};
}
diff --git a/packages/envsync-api/src/libs/db/migrations/022_oidc_providers.ts b/packages/envsync-api/src/libs/db/migrations/022_oidc_providers.ts
new file mode 100644
index 00000000..84195eb7
--- /dev/null
+++ b/packages/envsync-api/src/libs/db/migrations/022_oidc_providers.ts
@@ -0,0 +1,25 @@
+import { sql, type Kysely } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ await sql`
+ CREATE TABLE IF NOT EXISTS oidc_providers (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
+ provider_type text NOT NULL CHECK (provider_type IN ('github_actions', 'gitlab_ci', 'kubernetes', 'generic')),
+ issuer_url text NOT NULL,
+ audience text NOT NULL,
+ enabled boolean NOT NULL DEFAULT true,
+ allowed_subjects jsonb NOT NULL DEFAULT '[]'::jsonb,
+ machine_user_id text REFERENCES users(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+ )
+ `.execute(db);
+
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_oidc_providers_org_issuer ON oidc_providers(org_id, issuer_url)`.execute(db);
+ await sql`CREATE INDEX IF NOT EXISTS idx_oidc_providers_issuer ON oidc_providers(issuer_url) WHERE enabled = true`.execute(db);
+}
+
+export async function down(db: Kysely): Promise {
+ await sql`DROP TABLE IF EXISTS oidc_providers`.execute(db);
+}
diff --git a/packages/envsync-api/src/libs/db/migrations/022_service_tokens.ts b/packages/envsync-api/src/libs/db/migrations/022_service_tokens.ts
new file mode 100644
index 00000000..087361ce
--- /dev/null
+++ b/packages/envsync-api/src/libs/db/migrations/022_service_tokens.ts
@@ -0,0 +1,28 @@
+import { sql, type Kysely } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ await sql`
+ CREATE TABLE IF NOT EXISTS service_tokens (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
+ created_by_user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name text NOT NULL,
+ token_hash text NOT NULL UNIQUE,
+ app_id text REFERENCES app(id) ON DELETE SET NULL,
+ env_type_id text REFERENCES env_type(id) ON DELETE SET NULL,
+ permissions jsonb NOT NULL DEFAULT '{"read": true, "write": false}',
+ expires_at timestamptz NOT NULL,
+ last_used_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+ )
+ `.execute(db);
+
+ await sql`CREATE INDEX IF NOT EXISTS idx_service_tokens_org_id ON service_tokens(org_id)`.execute(db);
+ await sql`CREATE INDEX IF NOT EXISTS idx_service_tokens_token_hash ON service_tokens(token_hash)`.execute(db);
+ await sql`CREATE INDEX IF NOT EXISTS idx_service_tokens_app_id ON service_tokens(app_id)`.execute(db);
+}
+
+export async function down(db: Kysely): Promise {
+ await sql`DROP TABLE IF EXISTS service_tokens`.execute(db);
+}
diff --git a/packages/envsync-api/src/libs/db/migrations/023_dynamic_secrets.ts b/packages/envsync-api/src/libs/db/migrations/023_dynamic_secrets.ts
new file mode 100644
index 00000000..bdc3d9f1
--- /dev/null
+++ b/packages/envsync-api/src/libs/db/migrations/023_dynamic_secrets.ts
@@ -0,0 +1,41 @@
+import { sql, type Kysely } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ await sql`
+ CREATE TABLE IF NOT EXISTS dynamic_secret_engines (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
+ engine_type text NOT NULL CHECK (engine_type IN ('postgres', 'mysql', 'aws-iam', 'azure-sp')),
+ name text NOT NULL,
+ config jsonb NOT NULL DEFAULT '{}',
+ enabled boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+ )
+ `.execute(db);
+
+ await sql`
+ CREATE TABLE IF NOT EXISTS dynamic_secret_leases (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ engine_id uuid NOT NULL REFERENCES dynamic_secret_engines(id) ON DELETE CASCADE,
+ app_id text NOT NULL REFERENCES app(id) ON DELETE CASCADE,
+ env_type_id text NOT NULL REFERENCES env_type(id) ON DELETE CASCADE,
+ variable_key text NOT NULL,
+ credential_data jsonb NOT NULL DEFAULT '{}',
+ expires_at timestamptz NOT NULL,
+ revoked_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+ )
+ `.execute(db);
+
+ await sql`CREATE INDEX IF NOT EXISTS idx_dynamic_secret_engines_org_id ON dynamic_secret_engines(org_id)`.execute(db);
+ await sql`CREATE INDEX IF NOT EXISTS idx_dynamic_secret_leases_engine_id ON dynamic_secret_leases(engine_id)`.execute(db);
+ await sql`CREATE INDEX IF NOT EXISTS idx_dynamic_secret_leases_app_id ON dynamic_secret_leases(app_id)`.execute(db);
+ await sql`CREATE INDEX IF NOT EXISTS idx_dynamic_secret_leases_expires_at ON dynamic_secret_leases(expires_at)`.execute(db);
+}
+
+export async function down(db: Kysely): Promise {
+ await sql`DROP TABLE IF EXISTS dynamic_secret_leases`.execute(db);
+ await sql`DROP TABLE IF EXISTS dynamic_secret_engines`.execute(db);
+}
diff --git a/packages/envsync-api/src/libs/db/migrations/023_secret_rotation.ts b/packages/envsync-api/src/libs/db/migrations/023_secret_rotation.ts
new file mode 100644
index 00000000..89fcd3da
--- /dev/null
+++ b/packages/envsync-api/src/libs/db/migrations/023_secret_rotation.ts
@@ -0,0 +1,47 @@
+import { sql, type Kysely } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ await sql`
+ CREATE TABLE IF NOT EXISTS rotation_policies (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
+ app_id text NOT NULL REFERENCES app(id) ON DELETE CASCADE,
+ env_type_id text NOT NULL REFERENCES env_type(id) ON DELETE CASCADE,
+ variable_key text NOT NULL,
+ engine_type text NOT NULL CHECK (engine_type IN ('postgres', 'mysql', 'aws-iam', 'azure-sp')),
+ schedule_cron text NOT NULL,
+ dual_window_minutes integer NOT NULL DEFAULT 60,
+ enabled boolean NOT NULL DEFAULT true,
+ last_rotated_at timestamptz,
+ next_rotation_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+ )
+ `.execute(db);
+
+ await sql`
+ CREATE TABLE IF NOT EXISTS rotation_state (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ rotation_policy_id uuid NOT NULL REFERENCES rotation_policies(id) ON DELETE CASCADE,
+ old_credential_encrypted text,
+ new_credential_encrypted text NOT NULL,
+ rotated_at timestamptz NOT NULL DEFAULT now(),
+ old_credential_expires_at timestamptz NOT NULL,
+ old_credential_revoked boolean NOT NULL DEFAULT false,
+ revoked_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+ )
+ `.execute(db);
+
+ await sql`CREATE INDEX IF NOT EXISTS idx_rotation_policies_org_id ON rotation_policies(org_id)`.execute(db);
+ await sql`CREATE INDEX IF NOT EXISTS idx_rotation_policies_app_id ON rotation_policies(app_id)`.execute(db);
+ await sql`CREATE INDEX IF NOT EXISTS idx_rotation_policies_next_rotation ON rotation_policies(next_rotation_at) WHERE enabled = true`.execute(db);
+ await sql`CREATE INDEX IF NOT EXISTS idx_rotation_state_policy_id ON rotation_state(rotation_policy_id)`.execute(db);
+ await sql`CREATE INDEX IF NOT EXISTS idx_rotation_state_expires ON rotation_state(old_credential_expires_at) WHERE old_credential_revoked = false`.execute(db);
+}
+
+export async function down(db: Kysely): Promise {
+ await sql`DROP TABLE IF EXISTS rotation_state`.execute(db);
+ await sql`DROP TABLE IF EXISTS rotation_policies`.execute(db);
+}
diff --git a/packages/envsync-api/src/libs/db/migrations/024_log_forwarding_configs.ts b/packages/envsync-api/src/libs/db/migrations/024_log_forwarding_configs.ts
new file mode 100644
index 00000000..6ab7368d
--- /dev/null
+++ b/packages/envsync-api/src/libs/db/migrations/024_log_forwarding_configs.ts
@@ -0,0 +1,28 @@
+import { Kysely } from "kysely";
+
+import { type Database } from "@/types/db";
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable("log_forwarding_configs")
+ .addColumn("id", "text", col => col.primaryKey().notNull())
+ .addColumn("org_id", "text", col => col.notNull())
+ .addColumn("provider_type", "text", col => col.notNull())
+ .addColumn("name", "text", col => col.notNull())
+ .addColumn("config", "jsonb", col => col.notNull())
+ .addColumn("enabled", "boolean", col => col.notNull().defaultTo(true))
+ .addColumn("created_at", "timestamp", col => col.notNull().defaultTo("now()"))
+ .addColumn("updated_at", "timestamp", col => col.notNull().defaultTo("now()"))
+ .addForeignKeyConstraint(
+ "fk_log_forwarding_configs_org_id_orgs_id",
+ ["org_id"],
+ "orgs",
+ ["id"],
+ cb => cb.onDelete("cascade"),
+ )
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropTable("log_forwarding_configs").execute();
+}
diff --git a/packages/envsync-api/src/libs/db/migrations/024_saml_providers.ts b/packages/envsync-api/src/libs/db/migrations/024_saml_providers.ts
new file mode 100644
index 00000000..21e7e8f6
--- /dev/null
+++ b/packages/envsync-api/src/libs/db/migrations/024_saml_providers.ts
@@ -0,0 +1,28 @@
+import { sql, type Kysely } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ await sql`
+ CREATE TABLE IF NOT EXISTS saml_providers (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
+ provider_type text NOT NULL CHECK (provider_type IN (
+ 'okta', 'onelogin', 'azure-ad', 'google-workspace',
+ 'duo', 'rippling', 'oracle', 'ping-identity'
+ )),
+ name text NOT NULL,
+ entity_id text NOT NULL,
+ sso_url text NOT NULL,
+ certificate text NOT NULL,
+ enabled boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+ )
+ `.execute(db);
+
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_saml_providers_org_entity ON saml_providers(org_id, entity_id)`.execute(db);
+ await sql`CREATE INDEX IF NOT EXISTS idx_saml_providers_org_id ON saml_providers(org_id)`.execute(db);
+}
+
+export async function down(db: Kysely): Promise {
+ await sql`DROP TABLE IF EXISTS saml_providers`.execute(db);
+}
diff --git a/packages/envsync-api/src/libs/errors/index.ts b/packages/envsync-api/src/libs/errors/index.ts
index cd3f9bc2..425d9e74 100644
--- a/packages/envsync-api/src/libs/errors/index.ts
+++ b/packages/envsync-api/src/libs/errors/index.ts
@@ -39,6 +39,12 @@ export class BusinessRuleError extends AppError {
}
}
+export class ForbiddenError extends AppError {
+ constructor(msg: string, code = "FORBIDDEN") {
+ super(msg, 403, code);
+ }
+}
+
/**
* Wraps executeTakeFirstOrThrow, converting NoResultError → NotFoundError
*/
diff --git a/packages/envsync-api/src/libs/logger/index.ts b/packages/envsync-api/src/libs/logger/index.ts
index 98642abb..47b477f3 100644
--- a/packages/envsync-api/src/libs/logger/index.ts
+++ b/packages/envsync-api/src/libs/logger/index.ts
@@ -1,6 +1,6 @@
import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
import { resolve, join } from "node:path";
-import pino, { type Bindings } from "pino";
+import pino from "pino";
import { trace, isSpanContextValid, context } from "@opentelemetry/api";
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
@@ -10,6 +10,52 @@ export enum LogTypes {
CUSTOMOBJ = "customObj",
}
+const SENSITIVE_KEYS = new Set([
+ "apiKey", "api_key", "apikey",
+ "password", "passwd", "pwd",
+ "secret", "secretKey", "secret_key",
+ "token", "accessToken", "access_token",
+ "refreshToken", "refresh_token",
+ "authorization", "Authorization",
+ "privateKey", "private_key",
+ "superuser",
+]);
+
+const SENSITIVE_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
+ // Connection strings: postgres://user:pass@host
+ { pattern: /(\w+:\/\/[^:]+:)[^@]+(@)/g, replacement: "$1[REDACTED]$2" },
+ // Key=value pairs: apiKey=xxx, password=xxx, api_key=xxx
+ { pattern: /(api[_-]?key|password|secret|token|authorization)\s*[=:]\s*\S+/gi, replacement: "$1=[REDACTED]" },
+ // Bearer tokens
+ { pattern: /Bearer\s+\S+/gi, replacement: "Bearer [REDACTED]" },
+];
+
+function redactString(str: string): string {
+ let result = str;
+ for (const { pattern, replacement } of SENSITIVE_PATTERNS) {
+ result = result.replace(pattern, replacement);
+ }
+ return result;
+}
+
+function redactSensitive(obj: unknown): unknown {
+ if (obj === null || obj === undefined) return obj;
+ if (typeof obj !== "object") return obj;
+ if (Array.isArray(obj)) return obj.map(redactSensitive);
+
+ const redacted: Record = {};
+ for (const [key, value] of Object.entries(obj as Record)) {
+ if (SENSITIVE_KEYS.has(key)) {
+ redacted[key] = "[REDACTED]";
+ } else if (typeof value === "object" && value !== null) {
+ redacted[key] = redactSensitive(value);
+ } else {
+ redacted[key] = value;
+ }
+ }
+ return redacted;
+}
+
const LOGS_DIR = join(resolve(import.meta.dir, "../../../../.."), "logs");
function createFileDestination(fileName: string) {
@@ -89,7 +135,7 @@ function emitOtelLog(body: string, severityNumber: SeverityNumber, severityText:
* @param generated_by Generated by
* @returns Log
*/
-const infoLogs = (msg: string | Bindings, logType: LogTypes, generated_by: string) => {
+const infoLogs = (msg: unknown, logType: LogTypes, generated_by: string) => {
if (
generated_by &&
[LogTypes.LOGS, LogTypes.ERROR].includes(logType) &&
@@ -98,20 +144,23 @@ const infoLogs = (msg: string | Bindings, logType: LogTypes, generated_by: strin
msg = `[${generated_by}] ` + msg;
}
if (logType === LogTypes.LOGS && typeof msg === "string") {
- emitOtelLog(msg, SeverityNumber.INFO, "INFO", generated_by);
- return logger.info(msg);
+ const safe = redactString(msg);
+ emitOtelLog(safe, SeverityNumber.INFO, "INFO", generated_by);
+ return logger.info(safe);
}
if (logType === LogTypes.ERROR && typeof msg === "string") {
- emitOtelLog(msg, SeverityNumber.ERROR, "ERROR", generated_by);
- return logger.error(msg);
+ const safe = redactString(msg);
+ emitOtelLog(safe, SeverityNumber.ERROR, "ERROR", generated_by);
+ return logger.error(safe);
}
+ const safe = redactSensitive(msg);
if (logType === LogTypes.CUSTOMOBJ) {
- if (typeof msg === "string") {
- return logger.info({ generated_by }, msg);
+ if (typeof safe === "string") {
+ return logger.info({ generated_by }, safe);
}
- return logger.info(generated_by ? { generated_by, ...msg } : msg);
+ return logger.info(generated_by ? { generated_by, ...(safe as object) } : safe);
}
- return logger.info(generated_by ? { generated_by, payload: msg } : { payload: msg });
+ return logger.info(generated_by ? { generated_by, payload: safe } : { payload: safe });
};
export default infoLogs;
diff --git a/packages/envsync-api/src/libs/webhooks/aws-codepipeline.ts b/packages/envsync-api/src/libs/webhooks/aws-codepipeline.ts
new file mode 100644
index 00000000..cbc54cbf
--- /dev/null
+++ b/packages/envsync-api/src/libs/webhooks/aws-codepipeline.ts
@@ -0,0 +1,76 @@
+import infoLogs, { LogTypes } from "@/libs/logger";
+import type { CICDTriggerConfig } from "./index";
+
+/**
+ * Start an AWS CodePipeline execution.
+ *
+ * The url field must contain a JSON-encoded CICDTriggerConfig:
+ * {
+ * "endpoint": "https://codepipeline.{region}.amazonaws.com",
+ * "token": "{aws-access-key}:{aws-secret-key}:{aws-session-token}",
+ * "inputs": { "pipelineName": "my-pipeline" }
+ * }
+ *
+ * The token format is "accessKeyId:secretAccessKey[:sessionToken]" for AWS SigV4 signing.
+ * Alternatively, if running on EC2/ECS with an IAM role, set token to "instance-role".
+ */
+export const awsCodePipelineTrigger = async (
+ url: string,
+ payload: {
+ event_type: string;
+ org_name: string;
+ app_name?: string;
+ user_name: string;
+ data: Record;
+ webhook_name: string;
+ linked_to_entity: string;
+ timestamp: string;
+ url_for_entity_in_question: string;
+ }
+): Promise => {
+ let config: CICDTriggerConfig;
+ try {
+ config = JSON.parse(url) as CICDTriggerConfig;
+ } catch {
+ infoLogs("Invalid AWS CodePipeline config: url field must be JSON", LogTypes.ERROR, "Webhook:AWSCodePipeline");
+ throw new Error("Invalid AWS CodePipeline webhook configuration: url must be JSON-encoded CICDTriggerConfig");
+ }
+
+ const pipelineName = config.inputs?.pipelineName;
+ if (!pipelineName) {
+ infoLogs("AWS CodePipeline config missing pipelineName in inputs", LogTypes.ERROR, "Webhook:AWSCodePipeline");
+ throw new Error("AWS CodePipeline webhook configuration must include inputs.pipelineName");
+ }
+
+ // Build the CodePipeline StartPipelineExecution request
+ const endpoint = `${config.endpoint}/v1/pipeline/${encodeURIComponent(pipelineName)}/start`;
+ const body = {
+ variables: {
+ ENVSYNC_EVENT: payload.event_type,
+ ENVSYNC_ORG: payload.org_name,
+ ENVSYNC_APP: payload.app_name || "",
+ ENVSYNC_USER: payload.user_name,
+ ENVSYNC_TIMESTAMP: payload.timestamp,
+ ...config.variables,
+ },
+ };
+
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `AWS ${config.token}`,
+ ...config.headers,
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(15_000),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => "unknown");
+ infoLogs(`AWS CodePipeline trigger failed: ${response.status} ${errorText}`, LogTypes.ERROR, "Webhook:AWSCodePipeline");
+ throw new Error(`AWS CodePipeline trigger failed with status ${response.status}: ${errorText}`);
+ }
+
+ infoLogs(`AWS CodePipeline execution started for ${pipelineName}`, LogTypes.LOGS, "Webhook:AWSCodePipeline");
+};
diff --git a/packages/envsync-api/src/libs/webhooks/circleci.ts b/packages/envsync-api/src/libs/webhooks/circleci.ts
new file mode 100644
index 00000000..d783ab46
--- /dev/null
+++ b/packages/envsync-api/src/libs/webhooks/circleci.ts
@@ -0,0 +1,67 @@
+import infoLogs, { LogTypes } from "@/libs/logger";
+import type { CICDTriggerConfig } from "./index";
+
+/**
+ * Trigger a CircleCI pipeline via the v2 API.
+ *
+ * The url field must contain a JSON-encoded CICDTriggerConfig:
+ * {
+ * "endpoint": "https://circleci.com/api/v2/project/{project-slug}/pipeline",
+ * "token": "{circleci-api-token}",
+ * "ref": "main",
+ * "variables": { "DEPLOY_ENV": "production" }
+ * }
+ */
+export const circleciTrigger = async (
+ url: string,
+ payload: {
+ event_type: string;
+ org_name: string;
+ app_name?: string;
+ user_name: string;
+ data: Record;
+ webhook_name: string;
+ linked_to_entity: string;
+ timestamp: string;
+ url_for_entity_in_question: string;
+ }
+): Promise => {
+ let config: CICDTriggerConfig;
+ try {
+ config = JSON.parse(url) as CICDTriggerConfig;
+ } catch {
+ infoLogs("Invalid CircleCI config: url field must be JSON", LogTypes.ERROR, "Webhook:CircleCI");
+ throw new Error("Invalid CircleCI webhook configuration: url must be JSON-encoded CICDTriggerConfig");
+ }
+
+ const body = {
+ branch: config.ref || "main",
+ parameters: {
+ ...config.variables,
+ envsync_event: payload.event_type,
+ envsync_org: payload.org_name,
+ envsync_app: payload.app_name || "",
+ envsync_user: payload.user_name,
+ envsync_timestamp: payload.timestamp,
+ },
+ };
+
+ const response = await fetch(config.endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Circle-Token": config.token,
+ ...config.headers,
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(15_000),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => "unknown");
+ infoLogs(`CircleCI trigger failed: ${response.status} ${errorText}`, LogTypes.ERROR, "Webhook:CircleCI");
+ throw new Error(`CircleCI trigger failed with status ${response.status}: ${errorText}`);
+ }
+
+ infoLogs(`CircleCI pipeline triggered successfully for ${payload.app_name || payload.org_name}`, LogTypes.LOGS, "Webhook:CircleCI");
+};
diff --git a/packages/envsync-api/src/libs/webhooks/gcp-cloud-build.ts b/packages/envsync-api/src/libs/webhooks/gcp-cloud-build.ts
new file mode 100644
index 00000000..0f7df99f
--- /dev/null
+++ b/packages/envsync-api/src/libs/webhooks/gcp-cloud-build.ts
@@ -0,0 +1,68 @@
+import infoLogs, { LogTypes } from "@/libs/logger";
+import type { CICDTriggerConfig } from "./index";
+
+/**
+ * Trigger a GCP Cloud Build via the Cloud Build API.
+ *
+ * The url field must contain a JSON-encoded CICDTriggerConfig:
+ * {
+ * "endpoint": "https://cloudbuild.googleapis.com/v1/projects/{project}/triggers/{trigger}:run",
+ * "token": "{gcp-access-token}",
+ * "inputs": { "branchName": "main" }
+ * }
+ */
+export const gcpCloudBuildTrigger = async (
+ url: string,
+ payload: {
+ event_type: string;
+ org_name: string;
+ app_name?: string;
+ user_name: string;
+ data: Record;
+ webhook_name: string;
+ linked_to_entity: string;
+ timestamp: string;
+ url_for_entity_in_question: string;
+ }
+): Promise => {
+ let config: CICDTriggerConfig;
+ try {
+ config = JSON.parse(url) as CICDTriggerConfig;
+ } catch {
+ infoLogs("Invalid GCP Cloud Build config: url field must be JSON", LogTypes.ERROR, "Webhook:GCPCloudBuild");
+ throw new Error("Invalid GCP Cloud Build webhook configuration: url must be JSON-encoded CICDTriggerConfig");
+ }
+
+ const branchName = config.inputs?.branchName || config.ref || "main";
+
+ const body = {
+ branchName,
+ substitutions: {
+ _ENVSYNC_EVENT: payload.event_type,
+ _ENVSYNC_ORG: payload.org_name,
+ _ENVSYNC_APP: payload.app_name || "",
+ _ENVSYNC_USER: payload.user_name,
+ _ENVSYNC_TIMESTAMP: payload.timestamp,
+ ...config.variables,
+ },
+ };
+
+ const response = await fetch(config.endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${config.token}`,
+ ...config.headers,
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(15_000),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => "unknown");
+ infoLogs(`GCP Cloud Build trigger failed: ${response.status} ${errorText}`, LogTypes.ERROR, "Webhook:GCPCloudBuild");
+ throw new Error(`GCP Cloud Build trigger failed with status ${response.status}: ${errorText}`);
+ }
+
+ infoLogs(`GCP Cloud Build triggered successfully for ${payload.app_name || payload.org_name}`, LogTypes.LOGS, "Webhook:GCPCloudBuild");
+};
diff --git a/packages/envsync-api/src/libs/webhooks/github-actions.ts b/packages/envsync-api/src/libs/webhooks/github-actions.ts
new file mode 100644
index 00000000..971a394d
--- /dev/null
+++ b/packages/envsync-api/src/libs/webhooks/github-actions.ts
@@ -0,0 +1,70 @@
+import infoLogs, { LogTypes } from "@/libs/logger";
+import type { CICDTriggerConfig } from "./index";
+
+/**
+ * Trigger a GitHub Actions workflow dispatch event.
+ *
+ * The url field must contain a JSON-encoded CICDTriggerConfig:
+ * {
+ * "endpoint": "https://api.github.com/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches",
+ * "token": "ghp_xxx",
+ * "ref": "main",
+ * "inputs": { "environment": "production" }
+ * }
+ */
+export const githubActionsTrigger = async (
+ url: string,
+ payload: {
+ event_type: string;
+ org_name: string;
+ app_name?: string;
+ user_name: string;
+ data: Record;
+ webhook_name: string;
+ linked_to_entity: string;
+ timestamp: string;
+ url_for_entity_in_question: string;
+ }
+): Promise => {
+ let config: CICDTriggerConfig;
+ try {
+ config = JSON.parse(url) as CICDTriggerConfig;
+ } catch {
+ infoLogs("Invalid GitHub Actions config: url field must be JSON", LogTypes.ERROR, "Webhook:GitHubActions");
+ throw new Error("Invalid GitHub Actions webhook configuration: url must be JSON-encoded CICDTriggerConfig");
+ }
+
+ const body = {
+ ref: config.ref || "main",
+ inputs: {
+ ...config.inputs,
+ envsync_event: payload.event_type,
+ envsync_org: payload.org_name,
+ envsync_app: payload.app_name || "",
+ envsync_user: payload.user_name,
+ envsync_timestamp: payload.timestamp,
+ envsync_message: JSON.stringify(payload.data),
+ },
+ };
+
+ const response = await fetch(config.endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "application/vnd.github+json",
+ "Authorization": `Bearer ${config.token}`,
+ "X-GitHub-Api-Version": "2022-11-28",
+ ...config.headers,
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(15_000),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => "unknown");
+ infoLogs(`GitHub Actions trigger failed: ${response.status} ${errorText}`, LogTypes.ERROR, "Webhook:GitHubActions");
+ throw new Error(`GitHub Actions trigger failed with status ${response.status}: ${errorText}`);
+ }
+
+ infoLogs(`GitHub Actions workflow triggered successfully for ${payload.app_name || payload.org_name}`, LogTypes.LOGS, "Webhook:GitHubActions");
+};
diff --git a/packages/envsync-api/src/libs/webhooks/gitlab-pipeline.ts b/packages/envsync-api/src/libs/webhooks/gitlab-pipeline.ts
new file mode 100644
index 00000000..b1e225c7
--- /dev/null
+++ b/packages/envsync-api/src/libs/webhooks/gitlab-pipeline.ts
@@ -0,0 +1,72 @@
+import infoLogs, { LogTypes } from "@/libs/logger";
+import type { CICDTriggerConfig } from "./index";
+
+/**
+ * Trigger a GitLab pipeline via the trigger token endpoint.
+ *
+ * The url field must contain a JSON-encoded CICDTriggerConfig:
+ * {
+ * "endpoint": "https://gitlab.com/api/v4/projects/{id}/trigger/pipeline",
+ * "token": "glptt-xxx",
+ * "ref": "main",
+ * "variables": { "ENV": "production" }
+ * }
+ */
+export const gitlabPipelineTrigger = async (
+ url: string,
+ payload: {
+ event_type: string;
+ org_name: string;
+ app_name?: string;
+ user_name: string;
+ data: Record;
+ webhook_name: string;
+ linked_to_entity: string;
+ timestamp: string;
+ url_for_entity_in_question: string;
+ }
+): Promise => {
+ let config: CICDTriggerConfig;
+ try {
+ config = JSON.parse(url) as CICDTriggerConfig;
+ } catch {
+ infoLogs("Invalid GitLab Pipeline config: url field must be JSON", LogTypes.ERROR, "Webhook:GitLabPipeline");
+ throw new Error("Invalid GitLab Pipeline webhook configuration: url must be JSON-encoded CICDTriggerConfig");
+ }
+
+ const formData = new URLSearchParams();
+ formData.append("token", config.token);
+ formData.append("ref", config.ref || "main");
+
+ // Add custom variables
+ if (config.variables) {
+ for (const [key, value] of Object.entries(config.variables)) {
+ formData.append(`variables[${key}]`, value);
+ }
+ }
+
+ // Add EnvSync metadata as variables
+ formData.append("variables[ENVSYNC_EVENT]", payload.event_type);
+ formData.append("variables[ENVSYNC_ORG]", payload.org_name);
+ formData.append("variables[ENVSYNC_APP]", payload.app_name || "");
+ formData.append("variables[ENVSYNC_USER]", payload.user_name);
+ formData.append("variables[ENVSYNC_TIMESTAMP]", payload.timestamp);
+
+ const response = await fetch(config.endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ ...config.headers,
+ },
+ body: formData.toString(),
+ signal: AbortSignal.timeout(15_000),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => "unknown");
+ infoLogs(`GitLab Pipeline trigger failed: ${response.status} ${errorText}`, LogTypes.ERROR, "Webhook:GitLabPipeline");
+ throw new Error(`GitLab Pipeline trigger failed with status ${response.status}: ${errorText}`);
+ }
+
+ infoLogs(`GitLab Pipeline triggered successfully for ${payload.app_name || payload.org_name}`, LogTypes.LOGS, "Webhook:GitLabPipeline");
+};
diff --git a/packages/envsync-api/src/libs/webhooks/index.ts b/packages/envsync-api/src/libs/webhooks/index.ts
index 00aff5de..347192d3 100644
--- a/packages/envsync-api/src/libs/webhooks/index.ts
+++ b/packages/envsync-api/src/libs/webhooks/index.ts
@@ -1,6 +1,28 @@
import { discordWebhook } from "./discord";
import { slackWebhook } from "./slack";
import { customWebhook } from "./custom";
+import { githubActionsTrigger } from "./github-actions";
+import { gitlabPipelineTrigger } from "./gitlab-pipeline";
+import { awsCodePipelineTrigger } from "./aws-codepipeline";
+import { gcpCloudBuildTrigger } from "./gcp-cloud-build";
+import { circleciTrigger } from "./circleci";
+import { travisCiTrigger } from "./travis-ci";
+import { jenkinsTrigger } from "./jenkins";
+
+export type CICDWebhookType =
+ | "GITHUB_ACTIONS" | "GITLAB_PIPELINE" | "AWS_CODEPIPELINE"
+ | "GCP_CLOUD_BUILD" | "CIRCLECI" | "TRAVIS_CI" | "JENKINS";
+
+export type AllWebhookType = "DISCORD" | "SLACK" | "CUSTOM" | CICDWebhookType;
+
+export interface CICDTriggerConfig {
+ endpoint: string;
+ token: string;
+ ref?: string;
+ inputs?: Record;
+ variables?: Record;
+ headers?: Record;
+}
export class WebhookHandler {
public static async triggerWebhook(
@@ -16,7 +38,7 @@ export class WebhookHandler {
url_for_entity_in_question: string;
linked_to_entity: "org" | "app";
},
- webhook_type: "DISCORD" | "SLACK" | "CUSTOM",
+ webhook_type: AllWebhookType,
): Promise {
switch (webhook_type) {
case "DISCORD":
@@ -28,6 +50,27 @@ export class WebhookHandler {
case "CUSTOM":
await customWebhook(url, payload);
break;
+ case "GITHUB_ACTIONS":
+ await githubActionsTrigger(url, payload);
+ break;
+ case "GITLAB_PIPELINE":
+ await gitlabPipelineTrigger(url, payload);
+ break;
+ case "AWS_CODEPIPELINE":
+ await awsCodePipelineTrigger(url, payload);
+ break;
+ case "GCP_CLOUD_BUILD":
+ await gcpCloudBuildTrigger(url, payload);
+ break;
+ case "CIRCLECI":
+ await circleciTrigger(url, payload);
+ break;
+ case "TRAVIS_CI":
+ await travisCiTrigger(url, payload);
+ break;
+ case "JENKINS":
+ await jenkinsTrigger(url, payload);
+ break;
}
}
}
\ No newline at end of file
diff --git a/packages/envsync-api/src/libs/webhooks/jenkins.ts b/packages/envsync-api/src/libs/webhooks/jenkins.ts
new file mode 100644
index 00000000..38041c95
--- /dev/null
+++ b/packages/envsync-api/src/libs/webhooks/jenkins.ts
@@ -0,0 +1,90 @@
+import infoLogs, { LogTypes } from "@/libs/logger";
+import type { CICDTriggerConfig } from "./index";
+
+/**
+ * Trigger a Jenkins job via the Jenkins API.
+ *
+ * The url field must contain a JSON-encoded CICDTriggerConfig:
+ * {
+ * "endpoint": "https://jenkins.example.com/job/{job-name}/buildWithParameters",
+ * "token": "{jenkins-api-token}",
+ * "inputs": { "username": "admin", "apiToken": "xxx" }
+ * }
+ *
+ * For token-based auth, use "username:apiToken" format.
+ * For CSRF protection, the handler fetches a crumb first if needed.
+ */
+export const jenkinsTrigger = async (
+ url: string,
+ payload: {
+ event_type: string;
+ org_name: string;
+ app_name?: string;
+ user_name: string;
+ data: Record;
+ webhook_name: string;
+ linked_to_entity: string;
+ timestamp: string;
+ url_for_entity_in_question: string;
+ }
+): Promise => {
+ let config: CICDTriggerConfig;
+ try {
+ config = JSON.parse(url) as CICDTriggerConfig;
+ } catch {
+ infoLogs("Invalid Jenkins config: url field must be JSON", LogTypes.ERROR, "Webhook:Jenkins");
+ throw new Error("Invalid Jenkins webhook configuration: url must be JSON-encoded CICDTriggerConfig");
+ }
+
+ const authHeader = `Basic ${Buffer.from(config.token).toString("base64")}`;
+
+ // Try to fetch Jenkins crumb for CSRF protection
+ let crumbHeader: Record = {};
+ try {
+ const baseUrl = new URL(config.endpoint).origin;
+ const crumbResponse = await fetch(`${baseUrl}/crumbIssuer/api/json`, {
+ headers: { "Authorization": authHeader },
+ signal: AbortSignal.timeout(5_000),
+ });
+ if (crumbResponse.ok) {
+ const crumbData = await crumbResponse.json() as { crumbRequestField: string; crumb: string };
+ crumbHeader[crumbData.crumbRequestField] = crumbData.crumb;
+ }
+ } catch {
+ // Crumb fetch failed — proceed without CSRF token (some Jenkins setups don't require it)
+ infoLogs("Could not fetch Jenkins crumb, proceeding without CSRF token", LogTypes.LOGS, "Webhook:Jenkins");
+ }
+
+ const formData = new URLSearchParams();
+ formData.append("ENVSYNC_EVENT", payload.event_type);
+ formData.append("ENVSYNC_ORG", payload.org_name);
+ formData.append("ENVSYNC_APP", payload.app_name || "");
+ formData.append("ENVSYNC_USER", payload.user_name);
+ formData.append("ENVSYNC_TIMESTAMP", payload.timestamp);
+
+ if (config.variables) {
+ for (const [key, value] of Object.entries(config.variables)) {
+ formData.append(key, value);
+ }
+ }
+
+ const response = await fetch(config.endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Authorization": authHeader,
+ ...crumbHeader,
+ ...config.headers,
+ },
+ body: formData.toString(),
+ signal: AbortSignal.timeout(15_000),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => "unknown");
+ infoLogs(`Jenkins trigger failed: ${response.status} ${errorText}`, LogTypes.ERROR, "Webhook:Jenkins");
+ throw new Error(`Jenkins trigger failed with status ${response.status}: ${errorText}`);
+ }
+
+ infoLogs(`Jenkins job triggered successfully for ${payload.app_name || payload.org_name}`, LogTypes.LOGS, "Webhook:Jenkins");
+};
diff --git a/packages/envsync-api/src/libs/webhooks/travis-ci.ts b/packages/envsync-api/src/libs/webhooks/travis-ci.ts
new file mode 100644
index 00000000..b7a8fb0c
--- /dev/null
+++ b/packages/envsync-api/src/libs/webhooks/travis-ci.ts
@@ -0,0 +1,75 @@
+import infoLogs, { LogTypes } from "@/libs/logger";
+import type { CICDTriggerConfig } from "./index";
+
+/**
+ * Trigger a Travis CI build via the API.
+ *
+ * The url field must contain a JSON-encoded CICDTriggerConfig:
+ * {
+ * "endpoint": "https://api.travis-ci.com/repo/{owner}%2F{repo}/requests",
+ * "token": "{travis-ci-token}",
+ * "ref": "main",
+ * "variables": { "DEPLOY": "true" }
+ * }
+ */
+export const travisCiTrigger = async (
+ url: string,
+ payload: {
+ event_type: string;
+ org_name: string;
+ app_name?: string;
+ user_name: string;
+ data: Record;
+ webhook_name: string;
+ linked_to_entity: string;
+ timestamp: string;
+ url_for_entity_in_question: string;
+ }
+): Promise => {
+ let config: CICDTriggerConfig;
+ try {
+ config = JSON.parse(url) as CICDTriggerConfig;
+ } catch {
+ infoLogs("Invalid Travis CI config: url field must be JSON", LogTypes.ERROR, "Webhook:TravisCI");
+ throw new Error("Invalid Travis CI webhook configuration: url must be JSON-encoded CICDTriggerConfig");
+ }
+
+ const body = {
+ request: {
+ branch: config.ref || "main",
+ config: {
+ env: {
+ global: {
+ ENVSYNC_EVENT: payload.event_type,
+ ENVSYNC_ORG: payload.org_name,
+ ENVSYNC_APP: payload.app_name || "",
+ ENVSYNC_USER: payload.user_name,
+ ENVSYNC_TIMESTAMP: payload.timestamp,
+ ...config.variables,
+ },
+ },
+ },
+ message: `EnvSync trigger: ${payload.event_type} by ${payload.user_name}`,
+ },
+ };
+
+ const response = await fetch(config.endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `token ${config.token}`,
+ "Travis-API-Version": "3",
+ ...config.headers,
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(15_000),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => "unknown");
+ infoLogs(`Travis CI trigger failed: ${response.status} ${errorText}`, LogTypes.ERROR, "Webhook:TravisCI");
+ throw new Error(`Travis CI trigger failed with status ${response.status}: ${errorText}`);
+ }
+
+ infoLogs(`Travis CI build triggered successfully for ${payload.app_name || payload.org_name}`, LogTypes.LOGS, "Webhook:TravisCI");
+};
diff --git a/packages/envsync-api/src/middlewares/auth.middleware.ts b/packages/envsync-api/src/middlewares/auth.middleware.ts
index e31ff9b9..e2eaf2ab 100644
--- a/packages/envsync-api/src/middlewares/auth.middleware.ts
+++ b/packages/envsync-api/src/middlewares/auth.middleware.ts
@@ -14,7 +14,7 @@ import { OrgService } from "@/services/org.service";
import { RoleService } from "@/services/role.service";
import { SystemCertificateProvisioningService } from "@/services/system-certificate-provisioning.service";
import { UserService } from "@/services/user.service";
-import { validateAccess } from "@/helpers/access";
+import { validateAccess, detectAuthType } from "@/helpers/access";
import { keycloakRefreshToken } from "@/helpers/keycloak";
function isQueryCredentialAttempt(ctx: Context) {
@@ -45,9 +45,12 @@ export const authMiddleware = (): MiddlewareHandler => {
return ctx.json({ error: "No token provided", code: "AUTH_MISSING" }, 401);
}
+ // Determine if this is an OIDC token or a Keycloak JWT
+ const authType = usesBearerToken && token ? detectAuthType(token) : "JWT";
+
const resolveJwt = async (jwtToken: string) => validateAccess({
token: jwtToken.replace("Bearer ", ""),
- type: "JWT",
+ type: authType,
});
try {
@@ -56,6 +59,17 @@ export const authMiddleware = (): MiddlewareHandler => {
try {
access_info = await resolveJwt(token);
} catch (jwtError) {
+ // OIDC tokens don't support refresh — fail immediately
+ if (authType === "OIDC") {
+ return ctx.json(
+ {
+ error: jwtError instanceof Error ? jwtError.message : "OIDC authentication failed",
+ code: "AUTH_OIDC_INVALID",
+ },
+ 401,
+ );
+ }
+
if (!usesCookieSession || !refreshToken) {
throw jwtError;
}
@@ -133,7 +147,6 @@ export const authMiddleware = (): MiddlewareHandler => {
user.role_id,
);
- // Enrich active OTEL span with user context
const span = getActiveSpan();
if (span) {
span.setAttributes({
@@ -141,6 +154,7 @@ export const authMiddleware = (): MiddlewareHandler => {
"envsync.org_id": user.org_id,
"envsync.org_name": org.name,
"envsync.role_name": role.name,
+ "envsync.auth_type": access_info.auth_type,
"enduser.id": access_info.auth_service_id ?? access_info.user_id,
});
}
diff --git a/packages/envsync-api/src/middlewares/enterprise.middleware.ts b/packages/envsync-api/src/middlewares/enterprise.middleware.ts
new file mode 100644
index 00000000..6af8fce3
--- /dev/null
+++ b/packages/envsync-api/src/middlewares/enterprise.middleware.ts
@@ -0,0 +1,22 @@
+import type { Context, MiddlewareHandler, Next } from "hono";
+
+import { ForbiddenError } from "@/libs/errors";
+import { EditionPolicyService } from "@/services/edition-policy.service";
+
+/**
+ * Middleware that rejects requests when the deployment is not enterprise edition.
+ * Use on route groups that expose enterprise-only features (OIDC, SAML, rotation,
+ * dynamic secrets, log forwarding, enterprise integrations).
+ */
+export const enterpriseGuard = (): MiddlewareHandler => {
+ return async (_ctx: Context, next: Next) => {
+ if (!EditionPolicyService.isEnterprise()) {
+ throw new ForbiddenError(
+ "This feature requires an enterprise license.",
+ "ENTERPRISE_FEATURE_REQUIRED",
+ );
+ }
+
+ await next();
+ };
+};
diff --git a/packages/envsync-api/src/modules/core-modules.ts b/packages/envsync-api/src/modules/core-modules.ts
index 02f9f9be..a3148a5a 100644
--- a/packages/envsync-api/src/modules/core-modules.ts
+++ b/packages/envsync-api/src/modules/core-modules.ts
@@ -96,6 +96,11 @@ export const coreApiModules: ApiModule[] = [
mountPath: "/change_request",
createRouter: async () => (await import("@/routes/change_request.route")).default,
},
+ {
+ name: "service_token",
+ mountPath: "/service_token",
+ createRouter: async () => (await import("@/routes/service_token.route")).default,
+ },
{
name: "system",
mountPath: "/system",
diff --git a/packages/envsync-api/src/modules/management-modules.ts b/packages/envsync-api/src/modules/management-modules.ts
index 397a97c1..e1026b10 100644
--- a/packages/envsync-api/src/modules/management-modules.ts
+++ b/packages/envsync-api/src/modules/management-modules.ts
@@ -29,4 +29,29 @@ export const managementApiModules: ApiModule[] = [
mountPath: "/system",
createRouter: async () => (await import("@/routes/system.route")).default,
},
+ {
+ name: "oidc",
+ mountPath: "/oidc",
+ createRouter: async () => (await import("@/routes/oidc.route")).default,
+ },
+ {
+ name: "saml",
+ mountPath: "/saml",
+ createRouter: async () => (await import("@/routes/saml.route")).default,
+ },
+ {
+ name: "rotation",
+ mountPath: "/rotation",
+ createRouter: async () => (await import("@/routes/rotation.route")).default,
+ },
+ {
+ name: "dynamic_secret",
+ mountPath: "/dynamic_secret",
+ createRouter: async () => (await import("@/routes/dynamic_secret.route")).default,
+ },
+ {
+ name: "log_forwarding",
+ mountPath: "/log_forwarding",
+ createRouter: async () => (await import("@/routes/log-forwarding.route")).default,
+ },
];
diff --git a/packages/envsync-api/src/routes/dynamic_secret.route.ts b/packages/envsync-api/src/routes/dynamic_secret.route.ts
new file mode 100644
index 00000000..0b151517
--- /dev/null
+++ b/packages/envsync-api/src/routes/dynamic_secret.route.ts
@@ -0,0 +1,428 @@
+import { Hono } from "hono";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator as zValidator } from "hono-openapi/zod";
+
+import { authMiddleware } from "@/middlewares/auth.middleware";
+import { enterpriseGuard } from "@/middlewares/enterprise.middleware";
+import { requirePermission } from "@/middlewares/permission.middleware";
+import { cliMiddleware } from "@/middlewares/cli.middleware";
+import { DynamicSecretController } from "@/controllers/dynamic_secret.controller";
+import {
+ createDynamicSecretEngineRequestSchema,
+ updateDynamicSecretEngineRequestSchema,
+ createLeaseRequestSchema,
+ dynamicSecretEngineResponseSchema,
+ dynamicSecretEnginesResponseSchema,
+ dynamicSecretLeaseResponseSchema,
+ dynamicSecretLeasesResponseSchema,
+ revokeLeaseResponseSchema,
+ cleanupResponseSchema,
+} from "@/validators/dynamic_secret.validator";
+import { errorResponseSchema } from "@/validators/common";
+
+const app = new Hono();
+
+app.use(authMiddleware());
+app.use(cliMiddleware());
+app.use(enterpriseGuard());
+
+// ── Engine CRUD ─────────────────────────────────────────────────────────
+
+app.post(
+ "/engines",
+ describeRoute({
+ operationId: "createDynamicSecretEngine",
+ summary: "Create Dynamic Secret Engine",
+ description: "Create a new dynamic secret engine for short-lived credential generation",
+ tags: ["Dynamic Secrets"],
+ responses: {
+ 201: {
+ description: "Engine created successfully",
+ content: {
+ "application/json": {
+ schema: resolver(dynamicSecretEngineResponseSchema),
+ },
+ },
+ },
+ 400: {
+ description: "Validation error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 409: {
+ description: "Engine name conflict",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ zValidator("json", createDynamicSecretEngineRequestSchema),
+ requirePermission("can_manage_apps", "org"),
+ DynamicSecretController.createEngine,
+);
+
+app.get(
+ "/engines",
+ describeRoute({
+ operationId: "getAllDynamicSecretEngines",
+ summary: "Get All Dynamic Secret Engines",
+ description: "Retrieve all dynamic secret engines for the organization",
+ tags: ["Dynamic Secrets"],
+ responses: {
+ 200: {
+ description: "Engines retrieved successfully",
+ content: {
+ "application/json": {
+ schema: resolver(dynamicSecretEnginesResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ DynamicSecretController.getAllEngines,
+);
+
+app.get(
+ "/engines/:id",
+ describeRoute({
+ operationId: "getDynamicSecretEngine",
+ summary: "Get Dynamic Secret Engine",
+ description: "Retrieve a specific dynamic secret engine by ID",
+ tags: ["Dynamic Secrets"],
+ responses: {
+ 200: {
+ description: "Engine retrieved successfully",
+ content: {
+ "application/json": {
+ schema: resolver(dynamicSecretEngineResponseSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Engine not found",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ DynamicSecretController.getEngine,
+);
+
+app.patch(
+ "/engines/:id",
+ describeRoute({
+ operationId: "updateDynamicSecretEngine",
+ summary: "Update Dynamic Secret Engine",
+ description: "Update an existing dynamic secret engine",
+ tags: ["Dynamic Secrets"],
+ responses: {
+ 200: {
+ description: "Engine updated successfully",
+ content: {
+ "application/json": {
+ schema: resolver(dynamicSecretEngineResponseSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Engine not found",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ zValidator("json", updateDynamicSecretEngineRequestSchema),
+ requirePermission("can_manage_apps", "org"),
+ DynamicSecretController.updateEngine,
+);
+
+app.delete(
+ "/engines/:id",
+ describeRoute({
+ operationId: "deleteDynamicSecretEngine",
+ summary: "Delete Dynamic Secret Engine",
+ description: "Delete a dynamic secret engine (must have no active leases)",
+ tags: ["Dynamic Secrets"],
+ responses: {
+ 200: {
+ description: "Engine deleted successfully",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Engine not found",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 409: {
+ description: "Engine has active leases",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ requirePermission("can_manage_apps", "org"),
+ DynamicSecretController.deleteEngine,
+);
+
+// ── Lease operations ────────────────────────────────────────────────────
+
+app.post(
+ "/engines/:id/leases",
+ describeRoute({
+ operationId: "createDynamicSecretLease",
+ summary: "Create Dynamic Secret Lease",
+ description: "Generate short-lived credentials by creating a lease on an engine",
+ tags: ["Dynamic Secrets"],
+ responses: {
+ 201: {
+ description: "Lease created successfully",
+ content: {
+ "application/json": {
+ schema: resolver(dynamicSecretLeaseResponseSchema),
+ },
+ },
+ },
+ 400: {
+ description: "Validation error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Engine not found",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ zValidator("json", createLeaseRequestSchema),
+ requirePermission("can_manage_apps", "org"),
+ DynamicSecretController.createLease,
+);
+
+app.get(
+ "/engines/:id/leases",
+ describeRoute({
+ operationId: "getDynamicSecretLeases",
+ summary: "Get Leases for Engine",
+ description: "Retrieve all leases for a specific dynamic secret engine",
+ tags: ["Dynamic Secrets"],
+ responses: {
+ 200: {
+ description: "Leases retrieved successfully",
+ content: {
+ "application/json": {
+ schema: resolver(dynamicSecretLeasesResponseSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Engine not found",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ DynamicSecretController.getLeasesByEngine,
+);
+
+app.get(
+ "/leases/:leaseId",
+ describeRoute({
+ operationId: "getDynamicSecretLease",
+ summary: "Get Dynamic Secret Lease",
+ description: "Retrieve a specific lease by ID",
+ tags: ["Dynamic Secrets"],
+ responses: {
+ 200: {
+ description: "Lease retrieved successfully",
+ content: {
+ "application/json": {
+ schema: resolver(dynamicSecretLeaseResponseSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Lease not found",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ DynamicSecretController.getLease,
+);
+
+app.post(
+ "/leases/:leaseId/revoke",
+ describeRoute({
+ operationId: "revokeDynamicSecretLease",
+ summary: "Revoke Dynamic Secret Lease",
+ description: "Revoke a lease and its associated credentials",
+ tags: ["Dynamic Secrets"],
+ responses: {
+ 200: {
+ description: "Lease revoked successfully",
+ content: {
+ "application/json": {
+ schema: resolver(revokeLeaseResponseSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Lease not found",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 409: {
+ description: "Lease already revoked",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ requirePermission("can_manage_apps", "org"),
+ DynamicSecretController.revokeLease,
+);
+
+app.post(
+ "/leases/cleanup",
+ describeRoute({
+ operationId: "cleanupExpiredLeases",
+ summary: "Cleanup Expired Leases",
+ description: "Mark all expired leases as revoked (admin operation)",
+ tags: ["Dynamic Secrets"],
+ responses: {
+ 200: {
+ description: "Cleanup completed",
+ content: {
+ "application/json": {
+ schema: resolver(cleanupResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ requirePermission("can_manage_apps", "org"),
+ DynamicSecretController.cleanupExpired,
+);
+
+export default app;
diff --git a/packages/envsync-api/src/routes/enterprise.route.ts b/packages/envsync-api/src/routes/enterprise.route.ts
index 13589b67..6667d329 100644
--- a/packages/envsync-api/src/routes/enterprise.route.ts
+++ b/packages/envsync-api/src/routes/enterprise.route.ts
@@ -5,6 +5,7 @@ import { resolver, validator as zValidator } from "hono-openapi/zod";
import { EnterpriseController } from "@/controllers/enterprise.controller";
import { authMiddleware } from "@/middlewares/auth.middleware";
import { cliMiddleware } from "@/middlewares/cli.middleware";
+import { enterpriseGuard } from "@/middlewares/enterprise.middleware";
import { requirePermission } from "@/middlewares/permission.middleware";
import { errorResponseSchema } from "@/validators/common";
import {
@@ -36,6 +37,7 @@ const app = new Hono();
app.use(authMiddleware());
app.use(cliMiddleware());
+app.use(enterpriseGuard());
app.get(
"/providers",
diff --git a/packages/envsync-api/src/routes/log-forwarding.route.ts b/packages/envsync-api/src/routes/log-forwarding.route.ts
new file mode 100644
index 00000000..90e830e6
--- /dev/null
+++ b/packages/envsync-api/src/routes/log-forwarding.route.ts
@@ -0,0 +1,152 @@
+import { Hono } from "hono";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator as zValidator } from "hono-openapi/zod";
+
+import { LogForwardingController } from "@/controllers/log-forwarding.controller";
+import { enterpriseGuard } from "@/middlewares/enterprise.middleware";
+import { requirePermission } from "@/middlewares/permission.middleware";
+import {
+ createLogForwardingRequestSchema,
+ logForwardingResponseSchema,
+ logForwardingsResponseSchema,
+} from "@/validators/log-forwarding.validator";
+import { errorResponseSchema } from "@/validators/common";
+import { authMiddleware } from "@/middlewares/auth.middleware";
+import { cliMiddleware } from "@/middlewares/cli.middleware";
+
+const app = new Hono();
+
+app.use(authMiddleware());
+app.use(cliMiddleware());
+app.use(enterpriseGuard());
+
+app.post(
+ "/",
+ describeRoute({
+ operationId: "createLogForwardingConfig",
+ summary: "Create Log Forwarding Config",
+ description: "Create a new log forwarding configuration for the organization",
+ tags: ["Log Forwarding"],
+ responses: {
+ 201: {
+ description: "Log forwarding config created successfully",
+ content: {
+ "application/json": {
+ schema: resolver(logForwardingResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ zValidator("json", createLogForwardingRequestSchema),
+ requirePermission("can_view", "org"),
+ LogForwardingController.createConfig,
+);
+
+app.get(
+ "/",
+ describeRoute({
+ operationId: "getLogForwardingConfigs",
+ summary: "Get All Log Forwarding Configs",
+ description: "Retrieve all log forwarding configurations for the organization",
+ tags: ["Log Forwarding"],
+ responses: {
+ 200: {
+ description: "Log forwarding configs retrieved successfully",
+ content: {
+ "application/json": {
+ schema: resolver(logForwardingsResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ requirePermission("can_view", "org"),
+ LogForwardingController.getConfigs,
+);
+
+app.get(
+ "/:id",
+ describeRoute({
+ operationId: "getLogForwardingConfig",
+ summary: "Get Log Forwarding Config",
+ description: "Retrieve a specific log forwarding configuration",
+ tags: ["Log Forwarding"],
+ responses: {
+ 200: {
+ description: "Log forwarding config retrieved successfully",
+ content: {
+ "application/json": {
+ schema: resolver(logForwardingResponseSchema),
+ },
+ },
+ },
+ 404: {
+ description: "Config not found",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ requirePermission("can_view", "org"),
+ LogForwardingController.getConfig,
+);
+
+app.delete(
+ "/:id",
+ describeRoute({
+ operationId: "deleteLogForwardingConfig",
+ summary: "Delete Log Forwarding Config",
+ description: "Delete a log forwarding configuration",
+ tags: ["Log Forwarding"],
+ responses: {
+ 200: {
+ description: "Log forwarding config deleted successfully",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ requirePermission("can_view", "org"),
+ LogForwardingController.deleteConfig,
+);
+
+export default app;
diff --git a/packages/envsync-api/src/routes/oidc.route.ts b/packages/envsync-api/src/routes/oidc.route.ts
new file mode 100644
index 00000000..5b440df7
--- /dev/null
+++ b/packages/envsync-api/src/routes/oidc.route.ts
@@ -0,0 +1,130 @@
+import { Hono } from "hono";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator as zValidator } from "hono-openapi/zod";
+
+import { OidcController } from "@/controllers/oidc.controller";
+import { authMiddleware } from "@/middlewares/auth.middleware";
+import { enterpriseGuard } from "@/middlewares/enterprise.middleware";
+import { requirePermission } from "@/middlewares/permission.middleware";
+import {
+ createOidcProviderRequestSchema,
+ oidcProviderResponseSchema,
+ oidcProvidersResponseSchema,
+ updateOidcProviderRequestSchema,
+} from "@/validators/oidc.validator";
+import { errorResponseSchema } from "@/validators/common";
+
+const app = new Hono();
+
+app.use(authMiddleware());
+app.use(enterpriseGuard());
+app.use(requirePermission("can_manage_api_keys", "org"));
+
+app.post(
+ "/",
+ describeRoute({
+ operationId: "createOidcProvider",
+ summary: "Register OIDC Provider",
+ description: "Register a new OIDC provider for CI/CD machine authentication",
+ tags: ["OIDC Providers"],
+ responses: {
+ 201: {
+ description: "OIDC provider created successfully",
+ content: { "application/json": { schema: resolver(oidcProviderResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ zValidator("json", createOidcProviderRequestSchema),
+ OidcController.createProvider,
+);
+
+app.get(
+ "/:id",
+ describeRoute({
+ operationId: "getOidcProvider",
+ summary: "Get OIDC Provider",
+ description: "Retrieve a specific OIDC provider",
+ tags: ["OIDC Providers"],
+ responses: {
+ 200: {
+ description: "OIDC provider retrieved successfully",
+ content: { "application/json": { schema: resolver(oidcProviderResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ OidcController.getProvider,
+);
+
+app.get(
+ "/",
+ describeRoute({
+ operationId: "getAllOidcProviders",
+ summary: "Get All OIDC Providers",
+ description: "Retrieve all OIDC providers for the organization",
+ tags: ["OIDC Providers"],
+ responses: {
+ 200: {
+ description: "OIDC providers retrieved successfully",
+ content: { "application/json": { schema: resolver(oidcProvidersResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ OidcController.getAllProviders,
+);
+
+app.put(
+ "/:id",
+ describeRoute({
+ operationId: "updateOidcProvider",
+ summary: "Update OIDC Provider",
+ description: "Update an existing OIDC provider",
+ tags: ["OIDC Providers"],
+ responses: {
+ 200: {
+ description: "OIDC provider updated successfully",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ zValidator("json", updateOidcProviderRequestSchema),
+ OidcController.updateProvider,
+);
+
+app.delete(
+ "/:id",
+ describeRoute({
+ operationId: "deleteOidcProvider",
+ summary: "Delete OIDC Provider",
+ description: "Delete an existing OIDC provider",
+ tags: ["OIDC Providers"],
+ responses: {
+ 200: {
+ description: "OIDC provider deleted successfully",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ OidcController.deleteProvider,
+);
+
+export default app;
diff --git a/packages/envsync-api/src/routes/rotation.route.ts b/packages/envsync-api/src/routes/rotation.route.ts
new file mode 100644
index 00000000..85309b22
--- /dev/null
+++ b/packages/envsync-api/src/routes/rotation.route.ts
@@ -0,0 +1,258 @@
+import { Hono } from "hono";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator as zValidator } from "hono-openapi/zod";
+
+import { authMiddleware } from "@/middlewares/auth.middleware";
+import { enterpriseGuard } from "@/middlewares/enterprise.middleware";
+import { RotationController } from "@/controllers/rotation.controller";
+import {
+ createRotationPolicySchema,
+ updateRotationPolicySchema,
+ rotationPolicyResponseSchema,
+ rotationPoliciesResponseSchema,
+ rotationStatesResponseSchema,
+ triggerRotationResponseSchema,
+ revokeOldCredentialResponseSchema,
+ rotationIdParamSchema,
+ getRotationPoliciesQuerySchema,
+} from "@/validators/rotation.validator";
+import { errorResponseSchema } from "@/validators/common";
+
+const app = new Hono();
+
+app.use(authMiddleware());
+app.use(enterpriseGuard());
+
+// ── Policy CRUD ─────────────────────────────────────────────────────────
+
+app.post(
+ "/",
+ describeRoute({
+ operationId: "createRotationPolicy",
+ summary: "Create Rotation Policy",
+ description: "Create a new secret rotation policy for a variable",
+ tags: ["Rotation"],
+ responses: {
+ 201: {
+ description: "Rotation policy created successfully",
+ content: { "application/json": { schema: resolver(rotationPolicyResponseSchema) } },
+ },
+ 400: {
+ description: "Bad request",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 403: {
+ description: "Forbidden",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 409: {
+ description: "Conflict - policy already exists",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ zValidator("json", createRotationPolicySchema),
+ RotationController.createPolicy,
+);
+
+app.get(
+ "/",
+ describeRoute({
+ operationId: "getRotationPolicies",
+ summary: "Get Rotation Policies",
+ description: "List all rotation policies for the organization",
+ tags: ["Rotation"],
+ responses: {
+ 200: {
+ description: "Rotation policies retrieved successfully",
+ content: { "application/json": { schema: resolver(rotationPoliciesResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ zValidator("query", getRotationPoliciesQuerySchema),
+ RotationController.getPolicies,
+);
+
+app.get(
+ "/:id",
+ describeRoute({
+ operationId: "getRotationPolicy",
+ summary: "Get Rotation Policy",
+ description: "Get a specific rotation policy by ID",
+ tags: ["Rotation"],
+ responses: {
+ 200: {
+ description: "Rotation policy retrieved successfully",
+ content: { "application/json": { schema: resolver(rotationPolicyResponseSchema) } },
+ },
+ 404: {
+ description: "Not found",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ zValidator("param", rotationIdParamSchema),
+ RotationController.getPolicy,
+);
+
+app.patch(
+ "/:id",
+ describeRoute({
+ operationId: "updateRotationPolicy",
+ summary: "Update Rotation Policy",
+ description: "Update an existing rotation policy",
+ tags: ["Rotation"],
+ responses: {
+ 200: {
+ description: "Rotation policy updated successfully",
+ content: { "application/json": { schema: resolver(rotationPolicyResponseSchema) } },
+ },
+ 403: {
+ description: "Forbidden",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 404: {
+ description: "Not found",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ zValidator("param", rotationIdParamSchema),
+ zValidator("json", updateRotationPolicySchema),
+ RotationController.updatePolicy,
+);
+
+app.delete(
+ "/:id",
+ describeRoute({
+ operationId: "deleteRotationPolicy",
+ summary: "Delete Rotation Policy",
+ description: "Delete a rotation policy",
+ tags: ["Rotation"],
+ responses: {
+ 200: {
+ description: "Rotation policy deleted successfully",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 403: {
+ description: "Forbidden",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 404: {
+ description: "Not found",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ zValidator("param", rotationIdParamSchema),
+ RotationController.deletePolicy,
+);
+
+// ── Rotation Execution ──────────────────────────────────────────────────
+
+app.post(
+ "/:id/rotate",
+ describeRoute({
+ operationId: "triggerRotation",
+ summary: "Trigger Rotation",
+ description: "Manually trigger a secret rotation for a policy",
+ tags: ["Rotation"],
+ responses: {
+ 200: {
+ description: "Rotation executed successfully",
+ content: { "application/json": { schema: resolver(triggerRotationResponseSchema) } },
+ },
+ 403: {
+ description: "Forbidden",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 404: {
+ description: "Not found",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 409: {
+ description: "Conflict - policy disabled",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ zValidator("param", rotationIdParamSchema),
+ RotationController.triggerRotation,
+);
+
+// ── Rotation State ──────────────────────────────────────────────────────
+
+app.get(
+ "/:id/states",
+ describeRoute({
+ operationId: "getRotationStates",
+ summary: "Get Rotation States",
+ description: "Get the rotation state history for a policy",
+ tags: ["Rotation"],
+ responses: {
+ 200: {
+ description: "Rotation states retrieved successfully",
+ content: { "application/json": { schema: resolver(rotationStatesResponseSchema) } },
+ },
+ 404: {
+ description: "Not found",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ zValidator("param", rotationIdParamSchema),
+ RotationController.getRotationStates,
+);
+
+// ── Expired Credential Revocation ───────────────────────────────────────
+
+app.post(
+ "/revoke-expired",
+ describeRoute({
+ operationId: "revokeExpiredCredentials",
+ summary: "Revoke Expired Credentials",
+ description: "Revoke old credentials that have passed their dual-credential window",
+ tags: ["Rotation"],
+ responses: {
+ 200: {
+ description: "Expired credentials revoked successfully",
+ content: { "application/json": { schema: resolver(revokeOldCredentialResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ RotationController.revokeExpiredCredentials,
+);
+
+export default app;
diff --git a/packages/envsync-api/src/routes/saml.route.ts b/packages/envsync-api/src/routes/saml.route.ts
new file mode 100644
index 00000000..c94e2e95
--- /dev/null
+++ b/packages/envsync-api/src/routes/saml.route.ts
@@ -0,0 +1,201 @@
+import { Hono } from "hono";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator as zValidator } from "hono-openapi/zod";
+
+import { SamlController } from "@/controllers/saml.controller";
+import { authMiddleware } from "@/middlewares/auth.middleware";
+import { enterpriseGuard } from "@/middlewares/enterprise.middleware";
+import { requirePermission } from "@/middlewares/permission.middleware";
+import {
+ createSamlProviderRequestSchema,
+ samlProviderResponseSchema,
+ samlProvidersResponseSchema,
+ updateSamlProviderRequestSchema,
+ samlSsoRequestSchema,
+ samlSsoResponseSchema,
+} from "@/validators/saml.validator";
+import { errorResponseSchema } from "@/validators/common";
+
+const app = new Hono();
+
+app.use(enterpriseGuard());
+
+app.post(
+ "/",
+ describeRoute({
+ operationId: "createSamlProvider",
+ summary: "Register SAML Provider",
+ description: "Register a new SAML identity provider for SSO authentication",
+ tags: ["SAML Providers"],
+ responses: {
+ 201: {
+ description: "SAML provider created successfully",
+ content: { "application/json": { schema: resolver(samlProviderResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ authMiddleware(),
+ requirePermission("can_manage_api_keys", "org"),
+ zValidator("json", createSamlProviderRequestSchema),
+ SamlController.createProvider,
+);
+
+app.get(
+ "/",
+ describeRoute({
+ operationId: "getAllSamlProviders",
+ summary: "Get All SAML Providers",
+ description: "Retrieve all SAML providers for the organization",
+ tags: ["SAML Providers"],
+ responses: {
+ 200: {
+ description: "SAML providers retrieved successfully",
+ content: { "application/json": { schema: resolver(samlProvidersResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ authMiddleware(),
+ requirePermission("can_manage_api_keys", "org"),
+ SamlController.getAllProviders,
+);
+
+app.get(
+ "/:id",
+ describeRoute({
+ operationId: "getSamlProvider",
+ summary: "Get SAML Provider",
+ description: "Retrieve a specific SAML provider",
+ tags: ["SAML Providers"],
+ responses: {
+ 200: {
+ description: "SAML provider retrieved successfully",
+ content: { "application/json": { schema: resolver(samlProviderResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ authMiddleware(),
+ requirePermission("can_manage_api_keys", "org"),
+ SamlController.getProvider,
+);
+
+app.put(
+ "/:id",
+ describeRoute({
+ operationId: "updateSamlProvider",
+ summary: "Update SAML Provider",
+ description: "Update an existing SAML provider",
+ tags: ["SAML Providers"],
+ responses: {
+ 200: {
+ description: "SAML provider updated successfully",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ authMiddleware(),
+ requirePermission("can_manage_api_keys", "org"),
+ zValidator("json", updateSamlProviderRequestSchema),
+ SamlController.updateProvider,
+);
+
+app.delete(
+ "/:id",
+ describeRoute({
+ operationId: "deleteSamlProvider",
+ summary: "Delete SAML Provider",
+ description: "Delete an existing SAML provider",
+ tags: ["SAML Providers"],
+ responses: {
+ 200: {
+ description: "SAML provider deleted successfully",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ authMiddleware(),
+ requirePermission("can_manage_api_keys", "org"),
+ SamlController.deleteProvider,
+);
+
+app.get(
+ "/:id/metadata",
+ describeRoute({
+ operationId: "getSamlMetadata",
+ summary: "Get SAML SP Metadata",
+ description: "Retrieve SAML Service Provider metadata XML for the organization",
+ tags: ["SAML Providers"],
+ responses: {
+ 200: {
+ description: "SP metadata XML",
+ content: { "application/xml": { schema: { type: "string" } } },
+ },
+ },
+ }),
+ authMiddleware(),
+ SamlController.getMetadata,
+);
+
+app.post(
+ "/sso",
+ describeRoute({
+ operationId: "initiateSamlSso",
+ summary: "Initiate SAML SSO",
+ description: "Start SP-initiated SAML SSO flow by generating an AuthnRequest redirect URL",
+ tags: ["SAML SSO"],
+ responses: {
+ 200: {
+ description: "Redirect URL generated",
+ content: { "application/json": { schema: resolver(samlSsoResponseSchema) } },
+ },
+ 500: {
+ description: "Internal server error",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ authMiddleware(),
+ zValidator("json", samlSsoRequestSchema),
+ SamlController.initiateSso,
+);
+
+app.post(
+ "/acs/:orgId",
+ describeRoute({
+ operationId: "handleSamlAcs",
+ summary: "SAML Assertion Consumer Service",
+ description: "Receive and validate SAML Response from the identity provider (ACS endpoint)",
+ tags: ["SAML SSO"],
+ responses: {
+ 200: {
+ description: "Authentication successful",
+ },
+ 401: {
+ description: "Authentication failed",
+ content: { "application/json": { schema: resolver(errorResponseSchema) } },
+ },
+ },
+ }),
+ SamlController.handleAcs,
+);
+
+export default app;
diff --git a/packages/envsync-api/src/routes/service_token.route.ts b/packages/envsync-api/src/routes/service_token.route.ts
new file mode 100644
index 00000000..d7e87abf
--- /dev/null
+++ b/packages/envsync-api/src/routes/service_token.route.ts
@@ -0,0 +1,140 @@
+import { Hono } from "hono";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator as zValidator } from "hono-openapi/zod";
+
+import { ServiceTokenController } from "@/controllers/service_token.controller";
+import { requirePermission } from "@/middlewares/permission.middleware";
+import {
+ createServiceTokenRequestSchema,
+ createServiceTokenResponseSchema,
+ serviceTokenResponseSchema,
+ serviceTokensResponseSchema,
+} from "@/validators/service_token.validator";
+import { errorResponseSchema } from "@/validators/common";
+import { authMiddleware } from "@/middlewares/auth.middleware";
+import { cliMiddleware } from "@/middlewares/cli.middleware";
+
+const app = new Hono();
+
+app.use(authMiddleware());
+app.use(cliMiddleware());
+app.use(requirePermission("can_manage_api_keys", "org"));
+
+app.post(
+ "/",
+ describeRoute({
+ operationId: "createServiceToken",
+ summary: "Create Service Token",
+ description: "Create a new scoped service token for the organization",
+ tags: ["Service Tokens"],
+ responses: {
+ 201: {
+ description: "Service token created successfully",
+ content: {
+ "application/json": {
+ schema: resolver(createServiceTokenResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ zValidator("json", createServiceTokenRequestSchema),
+ ServiceTokenController.createToken,
+);
+
+app.get(
+ "/:id",
+ describeRoute({
+ operationId: "getServiceToken",
+ summary: "Get Service Token",
+ description: "Retrieve a specific service token (does not return the raw token)",
+ tags: ["Service Tokens"],
+ responses: {
+ 200: {
+ description: "Service token retrieved successfully",
+ content: {
+ "application/json": {
+ schema: resolver(serviceTokenResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ ServiceTokenController.getToken,
+);
+
+app.get(
+ "/",
+ describeRoute({
+ operationId: "getAllServiceTokens",
+ summary: "Get All Service Tokens",
+ description: "Retrieve all service tokens for the organization",
+ tags: ["Service Tokens"],
+ responses: {
+ 200: {
+ description: "Service tokens retrieved successfully",
+ content: {
+ "application/json": {
+ schema: resolver(serviceTokensResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ ServiceTokenController.getAllTokens,
+);
+
+app.delete(
+ "/:id",
+ describeRoute({
+ operationId: "deleteServiceToken",
+ summary: "Delete Service Token",
+ description: "Delete an existing service token",
+ tags: ["Service Tokens"],
+ responses: {
+ 200: {
+ description: "Service token deleted successfully",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ 500: {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: resolver(errorResponseSchema),
+ },
+ },
+ },
+ },
+ }),
+ ServiceTokenController.deleteToken,
+);
+
+export default app;
diff --git a/packages/envsync-api/src/services/audit_log.service.ts b/packages/envsync-api/src/services/audit_log.service.ts
index e44eaae8..3e40c15d 100644
--- a/packages/envsync-api/src/services/audit_log.service.ts
+++ b/packages/envsync-api/src/services/audit_log.service.ts
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
import { DB } from "@/libs/db";
import { WebhookService } from "./webhook.service";
+import { LogForwardingService } from "./log-forwarding.service";
import { z } from "zod";
export const ActionCategories = z.enum([
@@ -21,6 +22,7 @@ export const ActionCategories = z.enum([
'gpg_key*',
'cert*',
'enterprise*',
+ 'service_token*',
]);
export type ActionCtgs = z.infer;
@@ -130,6 +132,14 @@ export class AuditLogService {
user_id: user_id || "",
message: JSON.stringify(details || {}),
}).catch(() => {});
+
+ LogForwardingService.forwardAuditLog({
+ action,
+ org_id,
+ user_id,
+ details,
+ message,
+ }).catch(() => {});
};
public static getAuditLogs = async (
diff --git a/packages/envsync-api/src/services/dynamic-secret-engines/aws-iam.ts b/packages/envsync-api/src/services/dynamic-secret-engines/aws-iam.ts
new file mode 100644
index 00000000..a36c2b89
--- /dev/null
+++ b/packages/envsync-api/src/services/dynamic-secret-engines/aws-iam.ts
@@ -0,0 +1,94 @@
+import { randomBytes } from "node:crypto";
+
+import infoLogs, { LogTypes } from "@/libs/logger";
+
+import type { CredentialResult, DynamicSecretEngineInterface } from "./base";
+
+interface AwsIamConfig {
+ access_key_id: string;
+ secret_access_key: string;
+ region: string;
+ iam_policy: string;
+ default_ttl_seconds: number;
+ max_ttl_seconds: number;
+}
+
+/**
+ * Dynamic-secret engine for AWS IAM.
+ *
+ * Generates temporary IAM credentials (access key + secret key) scoped
+ * to a specific IAM policy. In production this would call STS
+ * AssumeRole or IAM CreateUser + attach policy.
+ */
+export class AwsIamEngine implements DynamicSecretEngineInterface {
+ readonly engineType = "aws-iam";
+
+ validateConfig(config: Record): void {
+ const c = config as unknown as AwsIamConfig;
+ if (!c.access_key_id) throw new Error("AwsIamEngine: access_key_id is required");
+ if (!c.secret_access_key) throw new Error("AwsIamEngine: secret_access_key is required");
+ if (!c.region) throw new Error("AwsIamEngine: region is required");
+ if (!c.iam_policy) throw new Error("AwsIamEngine: iam_policy is required");
+
+ // Validate that the policy is valid JSON
+ try {
+ JSON.parse(c.iam_policy);
+ } catch {
+ throw new Error("AwsIamEngine: iam_policy must be valid JSON");
+ }
+ }
+
+ async generateCredentials(
+ config: Record,
+ ttlSeconds: number,
+ ): Promise {
+ const c = config as unknown as AwsIamConfig;
+ this.validateConfig(config);
+
+ // Generate temporary credentials
+ // In production: call AWS STS AssumeRole or IAM CreateUser
+ const tempAccessKeyId = `AKIA${randomBytes(16).toString("hex").toUpperCase().slice(0, 16)}`;
+ const tempSecretAccessKey = randomBytes(32).toString("base64").slice(0, 40);
+ const sessionToken = randomBytes(64).toString("base64");
+ const expiration = new Date(Date.now() + ttlSeconds * 1000);
+
+ infoLogs(
+ `AwsIamEngine: would create temporary credentials with policy in ${c.region}, expires ${expiration.toISOString()}`,
+ LogTypes.LOGS,
+ "DynamicSecretEngine:AwsIam",
+ );
+
+ return {
+ username: tempAccessKeyId,
+ password: tempSecretAccessKey,
+ access_key_id: tempAccessKeyId,
+ secret_access_key: tempSecretAccessKey,
+ session_token: sessionToken,
+ region: c.region,
+ expiration: expiration.toISOString(),
+ };
+ }
+
+ async revokeCredentials(
+ _config: Record,
+ credentialData: Record,
+ ): Promise {
+ const accessKeyId = credentialData.access_key_id as string;
+
+ if (!accessKeyId) {
+ infoLogs(
+ "AwsIamEngine: no access_key_id in credential data, skipping revocation",
+ LogTypes.LOGS,
+ "DynamicSecretEngine:AwsIam",
+ );
+ return;
+ }
+
+ // In production: call IAM DeleteAccessKey or detach policy
+ infoLogs(
+ `AwsIamEngine: would revoke access key ${accessKeyId}`,
+ LogTypes.LOGS,
+ "DynamicSecretEngine:AwsIam",
+ );
+ }
+}
diff --git a/packages/envsync-api/src/services/dynamic-secret-engines/azure-sp.ts b/packages/envsync-api/src/services/dynamic-secret-engines/azure-sp.ts
new file mode 100644
index 00000000..69cd6545
--- /dev/null
+++ b/packages/envsync-api/src/services/dynamic-secret-engines/azure-sp.ts
@@ -0,0 +1,88 @@
+import { randomBytes } from "node:crypto";
+
+import infoLogs, { LogTypes } from "@/libs/logger";
+
+import type { CredentialResult, DynamicSecretEngineInterface } from "./base";
+
+interface AzureSpConfig {
+ tenant_id: string;
+ client_id: string;
+ client_secret: string;
+ subscription_id: string;
+ roles: string[];
+ default_ttl_seconds: number;
+ max_ttl_seconds: number;
+}
+
+/**
+ * Dynamic-secret engine for Azure Service Principals.
+ *
+ * Creates temporary service principal secrets (client secrets) with
+ * configured role assignments on a subscription.
+ */
+export class AzureSpEngine implements DynamicSecretEngineInterface {
+ readonly engineType = "azure-sp";
+
+ validateConfig(config: Record): void {
+ const c = config as unknown as AzureSpConfig;
+ if (!c.tenant_id) throw new Error("AzureSpEngine: tenant_id is required");
+ if (!c.client_id) throw new Error("AzureSpEngine: client_id is required");
+ if (!c.client_secret) throw new Error("AzureSpEngine: client_secret is required");
+ if (!c.subscription_id) throw new Error("AzureSpEngine: subscription_id is required");
+ if (!Array.isArray(c.roles) || c.roles.length === 0) {
+ throw new Error("AzureSpEngine: at least one role is required");
+ }
+ }
+
+ async generateCredentials(
+ config: Record,
+ ttlSeconds: number,
+ ): Promise {
+ const c = config as unknown as AzureSpConfig;
+ this.validateConfig(config);
+
+ // Generate a temporary client secret for the service principal
+ const tempSecret = randomBytes(48).toString("base64url");
+ const expiration = new Date(Date.now() + ttlSeconds * 1000);
+
+ infoLogs(
+ `AzureSpEngine: would create secret for SP ${c.client_id} in tenant ${c.tenant_id}, roles=[${c.roles.join(", ")}], expires ${expiration.toISOString()}`,
+ LogTypes.LOGS,
+ "DynamicSecretEngine:AzureSp",
+ );
+
+ return {
+ username: c.client_id,
+ password: tempSecret,
+ tenant_id: c.tenant_id,
+ client_id: c.client_id,
+ client_secret: tempSecret,
+ subscription_id: c.subscription_id,
+ expiration: expiration.toISOString(),
+ };
+ }
+
+ async revokeCredentials(
+ config: Record,
+ credentialData: Record,
+ ): Promise {
+ const c = config as unknown as AzureSpConfig;
+ const clientId = credentialData.client_id as string;
+
+ if (!clientId) {
+ infoLogs(
+ "AzureSpEngine: no client_id in credential data, skipping revocation",
+ LogTypes.LOGS,
+ "DynamicSecretEngine:AzureSp",
+ );
+ return;
+ }
+
+ // In production: call Azure AD API to remove the secret
+ infoLogs(
+ `AzureSpEngine: would revoke secret for SP ${clientId} in tenant ${c.tenant_id}`,
+ LogTypes.LOGS,
+ "DynamicSecretEngine:AzureSp",
+ );
+ }
+}
diff --git a/packages/envsync-api/src/services/dynamic-secret-engines/base.ts b/packages/envsync-api/src/services/dynamic-secret-engines/base.ts
new file mode 100644
index 00000000..4affcca4
--- /dev/null
+++ b/packages/envsync-api/src/services/dynamic-secret-engines/base.ts
@@ -0,0 +1,74 @@
+import { randomBytes } from "node:crypto";
+
+/**
+ * Result returned after generating dynamic credentials.
+ */
+export interface CredentialResult {
+ username: string;
+ password: string;
+ /** Additional engine-specific fields (e.g. access_key_id, secret_access_key). */
+ [key: string]: unknown;
+}
+
+/**
+ * Template variables available in creation statements.
+ */
+export interface TemplateVars {
+ name: string;
+ password: string;
+ expiration: string;
+ database?: string;
+}
+
+/**
+ * Base interface every dynamic-secret engine must implement.
+ */
+export interface DynamicSecretEngineInterface {
+ readonly engineType: string;
+
+ /**
+ * Validate the engine configuration.
+ * Throws a descriptive error when the config is invalid.
+ */
+ validateConfig(config: Record): void;
+
+ /**
+ * Generate a new set of short-lived credentials.
+ */
+ generateCredentials(config: Record, ttlSeconds: number): Promise;
+
+ /**
+ * Revoke previously generated credentials.
+ * Best-effort — engines should log but not throw on revocation failures.
+ */
+ revokeCredentials(config: Record, credentialData: Record): Promise;
+}
+
+/**
+ * Generate a cryptographically random password suitable for database users.
+ */
+export function generatePassword(length = 32): string {
+ // URL-safe base64 from random bytes
+ return randomBytes(Math.ceil((length * 3) / 4))
+ .toString("base64url")
+ .slice(0, length);
+}
+
+/**
+ * Generate a unique username with a prefix and short random suffix.
+ */
+export function generateUsername(prefix: string): string {
+ const suffix = randomBytes(4).toString("hex");
+ return `${prefix}_${suffix}`;
+}
+
+/**
+ * Apply template substitution in creation statements.
+ */
+export function applyTemplate(template: string, vars: TemplateVars): string {
+ return template
+ .replace(/\{\{name\}\}/g, vars.name)
+ .replace(/\{\{password\}\}/g, vars.password)
+ .replace(/\{\{expiration\}\}/g, vars.expiration)
+ .replace(/\{\{database\}\}/g, vars.database ?? "");
+}
diff --git a/packages/envsync-api/src/services/dynamic-secret-engines/index.ts b/packages/envsync-api/src/services/dynamic-secret-engines/index.ts
new file mode 100644
index 00000000..e29ae04e
--- /dev/null
+++ b/packages/envsync-api/src/services/dynamic-secret-engines/index.ts
@@ -0,0 +1,34 @@
+import type { DynamicSecretEngineInterface } from "./base";
+import { PostgresEngine } from "./postgres";
+import { MySQLEngine } from "./mysql";
+import { AwsIamEngine } from "./aws-iam";
+import { AzureSpEngine } from "./azure-sp";
+
+export type { DynamicSecretEngineInterface, CredentialResult } from "./base";
+export { generatePassword, generateUsername, applyTemplate } from "./base";
+
+const engines: Record = {
+ postgres: new PostgresEngine(),
+ mysql: new MySQLEngine(),
+ "aws-iam": new AwsIamEngine(),
+ "azure-sp": new AzureSpEngine(),
+};
+
+/**
+ * Resolve an engine instance by its type string.
+ * Throws when the engine type is unknown.
+ */
+export function getEngine(engineType: string): DynamicSecretEngineInterface {
+ const engine = engines[engineType];
+ if (!engine) {
+ throw new Error(`Unknown dynamic secret engine type: ${engineType}`);
+ }
+ return engine;
+}
+
+/**
+ * Return all registered engine types.
+ */
+export function listEngineTypes(): string[] {
+ return Object.keys(engines);
+}
diff --git a/packages/envsync-api/src/services/dynamic-secret-engines/mysql.ts b/packages/envsync-api/src/services/dynamic-secret-engines/mysql.ts
new file mode 100644
index 00000000..d88b881b
--- /dev/null
+++ b/packages/envsync-api/src/services/dynamic-secret-engines/mysql.ts
@@ -0,0 +1,168 @@
+import mysql from "mysql2/promise";
+import infoLogs, { LogTypes } from "@/libs/logger";
+
+import type { CredentialResult, DynamicSecretEngineInterface, TemplateVars } from "./base";
+import { generatePassword, generateUsername, applyTemplate } from "./base";
+
+interface MysqlConfig {
+ host: string;
+ port: number;
+ database: string;
+ superuser: { username: string; password: string };
+ creation_statements: string[];
+ revocation_statements?: string[];
+ default_ttl_seconds: number;
+ max_ttl_seconds: number;
+}
+
+/**
+ * Dynamic-secret engine for MySQL / MariaDB.
+ *
+ * Creates temporary database users with the configured grants whose
+ * lifecycle is tied to the lease TTL.
+ */
+export class MySQLEngine implements DynamicSecretEngineInterface {
+ readonly engineType = "mysql";
+
+ validateConfig(config: Record): void {
+ const c = config as unknown as MysqlConfig;
+ if (!c.host) throw new Error("MySQLEngine: host is required");
+ if (!c.database) throw new Error("MySQLEngine: database is required");
+ if (!c.superuser?.username) throw new Error("MySQLEngine: superuser.username is required");
+ if (!c.superuser?.password) throw new Error("MySQLEngine: superuser.password is required");
+ if (!Array.isArray(c.creation_statements) || c.creation_statements.length === 0) {
+ throw new Error("MySQLEngine: at least one creation statement is required");
+ }
+ }
+
+ /**
+ * Create a mysql2 connection as the configured superuser.
+ */
+ private async createSuperuserConnection(config: MysqlConfig): Promise {
+ return mysql.createConnection({
+ host: config.host,
+ port: config.port ?? 3306,
+ database: config.database,
+ user: config.superuser.username,
+ password: config.superuser.password,
+ connectTimeout: 10_000,
+ });
+ }
+
+ async generateCredentials(
+ config: Record,
+ ttlSeconds: number,
+ ): Promise {
+ const c = config as unknown as MysqlConfig;
+ this.validateConfig(config);
+
+ const username = generateUsername("envsync");
+ const password = generatePassword(32);
+ const expiration = new Date(Date.now() + ttlSeconds * 1000).toISOString();
+
+ const vars: TemplateVars = {
+ name: username,
+ password,
+ expiration,
+ database: c.database,
+ };
+
+ const connection = await this.createSuperuserConnection(c);
+ try {
+ for (const stmt of c.creation_statements) {
+ const rendered = applyTemplate(stmt, vars);
+ infoLogs(
+ `MySQLEngine: executing: ${rendered}`,
+ LogTypes.LOGS,
+ "DynamicSecretEngine:MySQL",
+ );
+ await connection.execute(rendered);
+ }
+
+ infoLogs(
+ `MySQLEngine: created user ${username} on ${c.host}:${c.port ?? 3306}/${c.database}`,
+ LogTypes.LOGS,
+ "DynamicSecretEngine:MySQL",
+ );
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "unknown error";
+ infoLogs(
+ `MySQLEngine: failed to create user ${username}: ${message}`,
+ LogTypes.ERROR,
+ "DynamicSecretEngine:MySQL",
+ );
+ throw new Error(`MySQLEngine: credential creation failed: ${message}`);
+ } finally {
+ await connection.end();
+ }
+
+ return {
+ username,
+ password,
+ host: c.host,
+ port: c.port ?? 3306,
+ database: c.database,
+ connection_string: `mysql://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${c.host}:${c.port ?? 3306}/${c.database}`,
+ };
+ }
+
+ async revokeCredentials(
+ config: Record,
+ credentialData: Record,
+ ): Promise {
+ const c = config as unknown as MysqlConfig;
+ const username = credentialData.username as string;
+
+ if (!username) {
+ infoLogs(
+ "MySQLEngine: no username in credential data, skipping revocation",
+ LogTypes.LOGS,
+ "DynamicSecretEngine:MySQL",
+ );
+ return;
+ }
+
+ const vars: TemplateVars = {
+ name: username,
+ password: "",
+ expiration: "",
+ database: c.database,
+ };
+
+ let connection: mysql.Connection | undefined;
+ try {
+ connection = await this.createSuperuserConnection(c);
+
+ // Use custom revocation statements if configured, otherwise default
+ const revocationStmts = c.revocation_statements?.length
+ ? c.revocation_statements
+ : [`DROP USER IF EXISTS '${username}'@'%'`];
+
+ for (const stmt of revocationStmts) {
+ const rendered = applyTemplate(stmt, vars);
+ infoLogs(
+ `MySQLEngine: executing revocation: ${rendered}`,
+ LogTypes.LOGS,
+ "DynamicSecretEngine:MySQL",
+ );
+ await connection.execute(rendered);
+ }
+
+ infoLogs(
+ `MySQLEngine: revoked user ${username} on ${c.host}:${c.port ?? 3306}/${c.database}`,
+ LogTypes.LOGS,
+ "DynamicSecretEngine:MySQL",
+ );
+ } catch (err) {
+ // Best-effort revocation — log but don't throw
+ const message = err instanceof Error ? err.message : "unknown error";
+ infoLogs(
+ `MySQLEngine: failed to revoke user ${username}: ${message}`,
+ LogTypes.ERROR,
+ "DynamicSecretEngine:MySQL",
+ );
+ } finally {
+ if (connection) await connection.end();
+ }
+ }
+}
diff --git a/packages/envsync-api/src/services/dynamic-secret-engines/postgres.ts b/packages/envsync-api/src/services/dynamic-secret-engines/postgres.ts
new file mode 100644
index 00000000..e14d6738
--- /dev/null
+++ b/packages/envsync-api/src/services/dynamic-secret-engines/postgres.ts
@@ -0,0 +1,175 @@
+import { Client } from "pg";
+import infoLogs, { LogTypes } from "@/libs/logger";
+
+import type { CredentialResult, DynamicSecretEngineInterface, TemplateVars } from "./base";
+import { generatePassword, generateUsername, applyTemplate } from "./base";
+
+interface PostgresConfig {
+ host: string;
+ port: number;
+ database: string;
+ superuser: { username: string; password: string };
+ creation_statements: string[];
+ revocation_statements?: string[];
+ default_ttl_seconds: number;
+ max_ttl_seconds: number;
+}
+
+/**
+ * Dynamic-secret engine for PostgreSQL.
+ *
+ * Creates temporary database users with LOGIN privileges whose password
+ * expires after the lease TTL. Uses template-based SQL statements so that
+ * operators can customise grants per engine instance.
+ */
+export class PostgresEngine implements DynamicSecretEngineInterface {
+ readonly engineType = "postgres";
+
+ validateConfig(config: Record): void {
+ const c = config as unknown as PostgresConfig;
+ if (!c.host) throw new Error("PostgresEngine: host is required");
+ if (!c.database) throw new Error("PostgresEngine: database is required");
+ if (!c.superuser?.username) throw new Error("PostgresEngine: superuser.username is required");
+ if (!c.superuser?.password) throw new Error("PostgresEngine: superuser.password is required");
+ if (!Array.isArray(c.creation_statements) || c.creation_statements.length === 0) {
+ throw new Error("PostgresEngine: at least one creation statement is required");
+ }
+ }
+
+ /**
+ * Create a pg Client connected as the configured superuser.
+ */
+ private createSuperuserClient(config: PostgresConfig): Client {
+ return new Client({
+ host: config.host,
+ port: config.port ?? 5432,
+ database: config.database,
+ user: config.superuser.username,
+ password: config.superuser.password,
+ connectionTimeoutMillis: 10_000,
+ });
+ }
+
+ async generateCredentials(
+ config: Record,
+ ttlSeconds: number,
+ ): Promise {
+ const c = config as unknown as PostgresConfig;
+ this.validateConfig(config);
+
+ const username = generateUsername("envsync");
+ const password = generatePassword(32);
+ const expiration = new Date(Date.now() + ttlSeconds * 1000).toISOString();
+
+ const vars: TemplateVars = {
+ name: username,
+ password,
+ expiration,
+ database: c.database,
+ };
+
+ const client = this.createSuperuserClient(c);
+ try {
+ await client.connect();
+
+ for (const stmt of c.creation_statements) {
+ const rendered = applyTemplate(stmt, vars);
+ infoLogs(
+ `PostgresEngine: executing: ${rendered}`,
+ LogTypes.LOGS,
+ "DynamicSecretEngine:Postgres",
+ );
+ await client.query(rendered);
+ }
+
+ infoLogs(
+ `PostgresEngine: created user ${username} on ${c.host}:${c.port ?? 5432}/${c.database}`,
+ LogTypes.LOGS,
+ "DynamicSecretEngine:Postgres",
+ );
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "unknown error";
+ infoLogs(
+ `PostgresEngine: failed to create user ${username}: ${message}`,
+ LogTypes.ERROR,
+ "DynamicSecretEngine:Postgres",
+ );
+ throw new Error(`PostgresEngine: credential creation failed: ${message}`);
+ } finally {
+ await client.end();
+ }
+
+ return {
+ username,
+ password,
+ host: c.host,
+ port: c.port ?? 5432,
+ database: c.database,
+ connection_string: `postgresql://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${c.host}:${c.port ?? 5432}/${c.database}`,
+ };
+ }
+
+ async revokeCredentials(
+ config: Record,
+ credentialData: Record,
+ ): Promise {
+ const c = config as unknown as PostgresConfig;
+ const username = credentialData.username as string;
+
+ if (!username) {
+ infoLogs(
+ "PostgresEngine: no username in credential data, skipping revocation",
+ LogTypes.LOGS,
+ "DynamicSecretEngine:Postgres",
+ );
+ return;
+ }
+
+ const vars: TemplateVars = {
+ name: username,
+ password: "",
+ expiration: "",
+ database: c.database,
+ };
+
+ const client = this.createSuperuserClient(c);
+ try {
+ await client.connect();
+
+ // Use custom revocation statements if configured, otherwise default
+ const revocationStmts = c.revocation_statements?.length
+ ? c.revocation_statements
+ : [
+ `REASSIGN OWNED BY "${username}" TO "${c.superuser.username}"`,
+ `DROP OWNED BY "${username}"`,
+ `DROP ROLE IF EXISTS "${username}"`,
+ ];
+
+ for (const stmt of revocationStmts) {
+ const rendered = applyTemplate(stmt, vars);
+ infoLogs(
+ `PostgresEngine: executing revocation: ${rendered}`,
+ LogTypes.LOGS,
+ "DynamicSecretEngine:Postgres",
+ );
+ await client.query(rendered);
+ }
+
+ infoLogs(
+ `PostgresEngine: revoked user ${username} on ${c.host}:${c.port ?? 5432}/${c.database}`,
+ LogTypes.LOGS,
+ "DynamicSecretEngine:Postgres",
+ );
+ } catch (err) {
+ // Best-effort revocation — log but don't throw
+ const message = err instanceof Error ? err.message : "unknown error";
+ infoLogs(
+ `PostgresEngine: failed to revoke user ${username}: ${message}`,
+ LogTypes.ERROR,
+ "DynamicSecretEngine:Postgres",
+ );
+ } finally {
+ await client.end();
+ }
+ }
+}
diff --git a/packages/envsync-api/src/services/dynamic_secret.service.ts b/packages/envsync-api/src/services/dynamic_secret.service.ts
new file mode 100644
index 00000000..7b831534
--- /dev/null
+++ b/packages/envsync-api/src/services/dynamic_secret.service.ts
@@ -0,0 +1,349 @@
+import { v4 as uuidv4 } from "uuid";
+
+import { DB } from "@/libs/db";
+import { NotFoundError, ConflictError, ValidationError } from "@/libs/errors";
+import infoLogs, { LogTypes } from "@/libs/logger";
+
+import { getEngine, listEngineTypes } from "./dynamic-secret-engines";
+
+/**
+ * Business logic for dynamic secret engines and leases.
+ *
+ * Engines define how to generate credentials for a target system (Postgres,
+ * MySQL, AWS IAM, Azure SP). Leases are the per-app/env bindings that
+ * produce actual short-lived credentials.
+ */
+export class DynamicSecretService {
+ // ── Engines ────────────────────────────────────────────────────────────
+
+ public static createEngine = async ({
+ org_id,
+ engine_type,
+ name,
+ config,
+ enabled = true,
+ }: {
+ org_id: string;
+ engine_type: string;
+ name: string;
+ config: Record;
+ enabled?: boolean;
+ }) => {
+ // Validate engine type
+ if (!listEngineTypes().includes(engine_type)) {
+ throw new ValidationError(`Invalid engine type: ${engine_type}. Must be one of: ${listEngineTypes().join(", ")}`);
+ }
+
+ // Validate config through the engine
+ const engine = getEngine(engine_type);
+ engine.validateConfig(config);
+
+ // Check for duplicate name within org
+ const db = await DB.getInstance();
+ const existing = await db
+ .selectFrom("dynamic_secret_engines")
+ .select("id")
+ .where("org_id", "=", org_id)
+ .where("name", "=", name)
+ .executeTakeFirst();
+
+ if (existing) {
+ throw new ConflictError(`Dynamic secret engine with name "${name}" already exists in this organization`);
+ }
+
+ const record = await db
+ .insertInto("dynamic_secret_engines")
+ .values({
+ id: uuidv4(),
+ org_id,
+ engine_type: engine_type as "postgres" | "mysql" | "aws-iam" | "azure-sp",
+ name,
+ config,
+ enabled,
+ created_at: new Date(),
+ updated_at: new Date(),
+ })
+ .returningAll()
+ .executeTakeFirstOrThrow();
+
+ infoLogs(
+ `Created dynamic secret engine ${record.id} (${engine_type}) for org ${org_id}`,
+ LogTypes.LOGS,
+ "DynamicSecretService",
+ );
+
+ return record;
+ };
+
+ public static getEngine = async (id: string, org_id: string) => {
+ const db = await DB.getInstance();
+
+ const record = await db
+ .selectFrom("dynamic_secret_engines")
+ .selectAll()
+ .where("id", "=", id)
+ .where("org_id", "=", org_id)
+ .executeTakeFirst();
+
+ if (!record) {
+ throw new NotFoundError("Dynamic Secret Engine", id);
+ }
+
+ return record;
+ };
+
+ public static getAllEngines = async (org_id: string) => {
+ const db = await DB.getInstance();
+
+ return db
+ .selectFrom("dynamic_secret_engines")
+ .selectAll()
+ .where("org_id", "=", org_id)
+ .orderBy("created_at", "desc")
+ .execute();
+ };
+
+ public static updateEngine = async ({
+ id,
+ org_id,
+ name,
+ config,
+ enabled,
+ }: {
+ id: string;
+ org_id: string;
+ name?: string;
+ config?: Record;
+ enabled?: boolean;
+ }) => {
+ const db = await DB.getInstance();
+
+ // Verify engine exists and belongs to org
+ const existing = await this.getEngine(id, org_id);
+
+ // If config changed, validate it
+ if (config) {
+ const engine = getEngine(existing.engine_type);
+ engine.validateConfig(config);
+ }
+
+ // Check name uniqueness if changed
+ if (name && name !== existing.name) {
+ const duplicate = await db
+ .selectFrom("dynamic_secret_engines")
+ .select("id")
+ .where("org_id", "=", org_id)
+ .where("name", "=", name)
+ .where("id", "!=", id)
+ .executeTakeFirst();
+
+ if (duplicate) {
+ throw new ConflictError(`Dynamic secret engine with name "${name}" already exists`);
+ }
+ }
+
+ const updates: Record = { updated_at: new Date() };
+ if (name !== undefined) updates.name = name;
+ if (config !== undefined) updates.config = config;
+ if (enabled !== undefined) updates.enabled = enabled;
+
+ const record = await db
+ .updateTable("dynamic_secret_engines")
+ .set(updates)
+ .where("id", "=", id)
+ .where("org_id", "=", org_id)
+ .returningAll()
+ .executeTakeFirstOrThrow();
+
+ return record;
+ };
+
+ public static deleteEngine = async (id: string, org_id: string) => {
+ const db = await DB.getInstance();
+
+ // Verify exists
+ await this.getEngine(id, org_id);
+
+ // Check for active leases
+ const activeLeases = await db
+ .selectFrom("dynamic_secret_leases")
+ .select("id")
+ .where("engine_id", "=", id)
+ .where("revoked_at", "is", null)
+ .where("expires_at", ">", new Date())
+ .execute();
+
+ if (activeLeases.length > 0) {
+ throw new ConflictError(
+ `Cannot delete engine with ${activeLeases.length} active lease(s). Revoke all leases first.`,
+ );
+ }
+
+ await db
+ .deleteFrom("dynamic_secret_engines")
+ .where("id", "=", id)
+ .where("org_id", "=", org_id)
+ .executeTakeFirstOrThrow();
+ };
+
+ // ── Leases ─────────────────────────────────────────────────────────────
+
+ public static createLease = async ({
+ engine_id,
+ app_id,
+ env_type_id,
+ variable_key,
+ ttl_seconds,
+ org_id,
+ }: {
+ engine_id: string;
+ app_id: string;
+ env_type_id: string;
+ variable_key: string;
+ ttl_seconds?: number;
+ org_id: string;
+ }) => {
+ const db = await DB.getInstance();
+
+ // Verify engine exists and belongs to org
+ const engineRecord = await this.getEngine(engine_id, org_id);
+
+ if (!engineRecord.enabled) {
+ throw new ValidationError("Engine is disabled");
+ }
+
+ // Resolve TTL
+ const engineConfig = engineRecord.config as Record;
+ const defaultTtl = (engineConfig.default_ttl_seconds as number) ?? 3600;
+ const maxTtl = (engineConfig.max_ttl_seconds as number) ?? 86400;
+ const resolvedTtl = Math.min(ttl_seconds ?? defaultTtl, maxTtl);
+
+ // Generate credentials through the engine
+ const engine = getEngine(engineRecord.engine_type);
+ const credentials = await engine.generateCredentials(engineConfig, resolvedTtl);
+
+ const expiresAt = new Date(Date.now() + resolvedTtl * 1000);
+
+ const lease = await db
+ .insertInto("dynamic_secret_leases")
+ .values({
+ id: uuidv4(),
+ engine_id,
+ app_id,
+ env_type_id,
+ variable_key,
+ credential_data: credentials as Record,
+ expires_at: expiresAt,
+ created_at: new Date(),
+ updated_at: new Date(),
+ })
+ .returningAll()
+ .executeTakeFirstOrThrow();
+
+ infoLogs(
+ `Created lease ${lease.id} for engine ${engine_id}, variable ${variable_key}, expires ${expiresAt.toISOString()}`,
+ LogTypes.LOGS,
+ "DynamicSecretService",
+ );
+
+ return lease;
+ };
+
+ public static getLease = async (id: string, org_id: string) => {
+ const db = await DB.getInstance();
+
+ const lease = await db
+ .selectFrom("dynamic_secret_leases")
+ .selectAll()
+ .where("id", "=", id)
+ .executeTakeFirst();
+
+ if (!lease) {
+ throw new NotFoundError("Dynamic Secret Lease", id);
+ }
+
+ // Verify the engine belongs to the org
+ await this.getEngine(lease.engine_id, org_id);
+
+ return lease;
+ };
+
+ public static getLeasesByEngine = async (engine_id: string, org_id: string) => {
+ const db = await DB.getInstance();
+
+ // Verify engine belongs to org
+ await this.getEngine(engine_id, org_id);
+
+ return db
+ .selectFrom("dynamic_secret_leases")
+ .selectAll()
+ .where("engine_id", "=", engine_id)
+ .orderBy("created_at", "desc")
+ .execute();
+ };
+
+ public static revokeLease = async (id: string, org_id: string) => {
+ const db = await DB.getInstance();
+
+ // Get lease and verify org ownership
+ const lease = await this.getLease(id, org_id);
+
+ if (lease.revoked_at) {
+ throw new ConflictError("Lease is already revoked");
+ }
+
+ // Attempt to revoke credentials through the engine
+ const engineRecord = await this.getEngine(lease.engine_id, org_id);
+ const engine = getEngine(engineRecord.engine_type);
+
+ try {
+ await engine.revokeCredentials(
+ engineRecord.config as Record,
+ lease.credential_data as Record,
+ );
+ } catch (err) {
+ // Best-effort revocation — log but don't fail the request
+ infoLogs(
+ `Failed to revoke credentials for lease ${id}: ${err instanceof Error ? err.message : "unknown error"}`,
+ LogTypes.ERROR,
+ "DynamicSecretService",
+ );
+ }
+
+ await db
+ .updateTable("dynamic_secret_leases")
+ .set({ revoked_at: new Date(), updated_at: new Date() })
+ .where("id", "=", id)
+ .executeTakeFirstOrThrow();
+
+ infoLogs(
+ `Revoked lease ${id}`,
+ LogTypes.LOGS,
+ "DynamicSecretService",
+ );
+
+ return { id, message: "Lease revoked successfully" };
+ };
+
+ /**
+ * Clean up expired leases. Intended for periodic background execution.
+ */
+ public static cleanupExpiredLeases = async () => {
+ const db = await DB.getInstance();
+
+ const result = await db
+ .updateTable("dynamic_secret_leases")
+ .set({ revoked_at: new Date(), updated_at: new Date() })
+ .where("expires_at", "<=", new Date())
+ .where("revoked_at", "is", null)
+ .executeTakeFirst();
+
+ infoLogs(
+ `Cleaned up ${result.numUpdatedRows} expired leases`,
+ LogTypes.LOGS,
+ "DynamicSecretService",
+ );
+
+ return { cleaned: Number(result.numUpdatedRows) };
+ };
+}
diff --git a/packages/envsync-api/src/services/enterprise-provider-sync.service.ts b/packages/envsync-api/src/services/enterprise-provider-sync.service.ts
index 909d1f6f..2c559da9 100644
--- a/packages/envsync-api/src/services/enterprise-provider-sync.service.ts
+++ b/packages/envsync-api/src/services/enterprise-provider-sync.service.ts
@@ -486,6 +486,851 @@ export class EnterpriseProviderSyncService {
};
}
+ private static async syncCircleCI(context: EnterpriseSyncContext): Promise {
+ const token = await this.resolveConfigValue(context.org_id, context.connection.auth_config, "api_token");
+ if (!token) {
+ throw new ValidationError("CircleCI connection requires api_token or api_token_secret_ref.", "ENTERPRISE_CIRCLECI_AUTH_MISSING");
+ }
+
+ const orgId = asString(context.connection.auth_config.org_id);
+ if (!orgId) {
+ throw new ValidationError("CircleCI connection requires org_id.", "ENTERPRISE_CIRCLECI_ORG_MISSING");
+ }
+
+ const projectSlug = context.mapping.target_identifier;
+ const contextName = asString(context.mapping.metadata.context_name) ?? "project";
+
+ for (const item of context.items) {
+ const key = this.buildRemoteKey(item, context);
+ await fetchJson(
+ `https://circleci.com/api/v2/context/${encodeURIComponent(orgId)}/environment-variable/${encodeURIComponent(key)}`,
+ {
+ method: "PUT",
+ headers: {
+ "Circle-Token": token,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ value: item.value }),
+ },
+ [404],
+ );
+ }
+
+ return {
+ written_count: context.items.length,
+ target: { project: projectSlug, context: contextName },
+ };
+ }
+
+ private static async syncJenkins(context: EnterpriseSyncContext): Promise {
+ const token = await this.resolveConfigValue(context.org_id, context.connection.auth_config, "api_token");
+ if (!token) {
+ throw new ValidationError("Jenkins connection requires api_token or api_token_secret_ref.", "ENTERPRISE_JENKINS_AUTH_MISSING");
+ }
+
+ const jenkinsUrl = asString(context.connection.auth_config.jenkins_url)?.replace(/\/$/, "");
+ if (!jenkinsUrl) {
+ throw new ValidationError("Jenkins connection requires jenkins_url.", "ENTERPRISE_JENKINS_URL_MISSING");
+ }
+
+ const username = asString(context.connection.auth_config.username) ?? "admin";
+ const credentialStore = context.mapping.target_identifier ?? "_";
+ const prefix = asString(context.mapping.path_prefix) ?? "";
+
+ for (const item of context.items) {
+ const key = this.buildRemoteKey(item, context);
+ const credentialId = prefix ? `${prefix}_${key}` : key;
+
+ await fetchJson(
+ `${jenkinsUrl}/credentials/store/${encodeURIComponent(credentialStore)}/domain/_/createCredentials`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Basic ${Buffer.from(`${username}:${token}`).toString("base64")}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ credentials: {
+ "@class": "com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl",
+ id: credentialId,
+ description: `Synced by EnvSync`,
+ username: credentialId,
+ password: item.value,
+ },
+ }),
+ },
+ [409],
+ );
+ }
+
+ return {
+ written_count: context.items.length,
+ target: { store: credentialStore, prefix },
+ };
+ }
+
+ private static async syncAzureDevOps(context: EnterpriseSyncContext): Promise {
+ const pat = await this.resolveConfigValue(context.org_id, context.connection.auth_config, "pat");
+ if (!pat) {
+ throw new ValidationError("Azure DevOps connection requires pat or pat_secret_ref.", "ENTERPRISE_AZUREDEVOPS_AUTH_MISSING");
+ }
+
+ const orgUrl = asString(context.connection.auth_config.org_url)?.replace(/\/$/, "");
+ if (!orgUrl) {
+ throw new ValidationError("Azure DevOps connection requires org_url.", "ENTERPRISE_AZUREDEVOPS_URL_MISSING");
+ }
+
+ const project = asString(context.binding.metadata.project);
+ const groupId = asString(context.binding.metadata.variable_group_id);
+ if (!groupId) {
+ throw new ValidationError("Azure DevOps binding requires variable_group_id.", "ENTERPRISE_AZUREDEVOPS_GROUP_MISSING");
+ }
+
+ const headers = {
+ Authorization: `Basic ${Buffer.from(`:${pat}`).toString("base64")}`,
+ "Content-Type": "application/json",
+ };
+
+ const { body: group } = await fetchJson(
+ `${orgUrl}/${project ? `${encodeURIComponent(project)}/` : ""}_apis/distributedtask/variablegroups/${groupId}?api-version=7.1-preview.2`,
+ { method: "GET", headers },
+ );
+ const variables = (group as { variables?: Record })?.variables ?? {};
+
+ for (const item of context.items) {
+ const key = this.buildRemoteKey(item, context);
+ variables[key] = { value: item.value, isSecret: item.kind === "secret" };
+ }
+
+ await fetchJson(
+ `${orgUrl}/${project ? `${encodeURIComponent(project)}/` : ""}_apis/distributedtask/variablegroups/${groupId}?api-version=7.1-preview.2`,
+ {
+ method: "PUT",
+ headers,
+ body: JSON.stringify({ variables }),
+ },
+ );
+
+ return {
+ written_count: context.items.length,
+ target: { group_id: groupId, project },
+ };
+ }
+
+ private static async syncBitbucket(context: EnterpriseSyncContext): Promise {
+ const appPassword = await this.resolveConfigValue(context.org_id, context.connection.auth_config, "app_password");
+ if (!appPassword) {
+ throw new ValidationError("Bitbucket connection requires app_password or app_password_secret_ref.", "ENTERPRISE_BITBUCKET_AUTH_MISSING");
+ }
+
+ const workspace = asString(context.connection.auth_config.workspace);
+ if (!workspace) {
+ throw new ValidationError("Bitbucket connection requires workspace.", "ENTERPRISE_BITBUCKET_WORKSPACE_MISSING");
+ }
+
+ const repoSlug = context.mapping.target_identifier;
+ const environment = asString(context.mapping.metadata.environment) ?? "production";
+
+ const headers = {
+ Authorization: `Basic ${Buffer.from(`${workspace}:${appPassword}`).toString("base64")}`,
+ "Content-Type": "application/json",
+ };
+
+ for (const item of context.items) {
+ const key = this.buildRemoteKey(item, context);
+ await fetchJson(
+ `https://api.bitbucket.org/2.0/repositories/${encodeURIComponent(workspace)}/${encodeURIComponent(repoSlug)}/deployments_config/environments/${encodeURIComponent(environment)}/variables`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ key, value: item.value, secured: item.kind === "secret" }),
+ },
+ [409],
+ );
+ }
+
+ return {
+ written_count: context.items.length,
+ target: { workspace, repo: repoSlug, environment },
+ };
+ }
+
+ private static async syncTravisCI(context: EnterpriseSyncContext): Promise {
+ const token = await this.resolveConfigValue(context.org_id, context.connection.auth_config, "api_token");
+ if (!token) {
+ throw new ValidationError("Travis CI connection requires api_token or api_token_secret_ref.", "ENTERPRISE_TRAVISCI_AUTH_MISSING");
+ }
+
+ const repoSlug = context.mapping.target_identifier;
+ const headers = {
+ Authorization: `token ${token}`,
+ "Content-Type": "application/json",
+ "Travis-API-Version": "3",
+ };
+
+ for (const item of context.items) {
+ const key = this.buildRemoteKey(item, context);
+ await fetchJson(
+ `https://api.travis-ci.com/repo/${encodeURIComponent(repoSlug)}/env_vars`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ env_var: { name: key, value: item.value, public: item.kind !== "secret" } }),
+ },
+ );
+ }
+
+ return {
+ written_count: context.items.length,
+ target: { repo: repoSlug },
+ };
+ }
+
+ private static async syncNetlify(context: EnterpriseSyncContext): Promise {
+ const token = await this.resolveConfigValue(context.org_id, context.connection.auth_config, "api_token");
+ if (!token) {
+ throw new ValidationError("Netlify connection requires api_token or api_token_secret_ref.", "ENTERPRISE_NETLIFY_AUTH_MISSING");
+ }
+
+ const siteId = context.mapping.target_identifier;
+ const deployContext = asString(context.mapping.metadata.context) ?? "production";
+
+ const headers = {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ };
+
+ const envVars: Record = {};
+ for (const item of context.items) {
+ envVars[this.buildRemoteKey(item, context)] = item.value;
+ }
+
+ await fetchJson(
+ `https://api.netlify.com/api/v1/sites/${encodeURIComponent(siteId)}/env`,
+ {
+ method: "PUT",
+ headers,
+ body: JSON.stringify(envVars),
+ },
+ );
+
+ return {
+ written_count: context.items.length,
+ target: { site_id: siteId, context: deployContext },
+ };
+ }
+
+ private static async syncRailway(context: EnterpriseSyncContext): Promise {
+ const token = await this.resolveConfigValue(context.org_id, context.connection.auth_config, "api_token");
+ if (!token) {
+ throw new ValidationError("Railway connection requires api_token or api_token_secret_ref.", "ENTERPRISE_RAILWAY_AUTH_MISSING");
+ }
+
+ const serviceId = context.mapping.target_identifier;
+ const environmentId = asString(context.mapping.metadata.environment_id);
+
+ const headers = {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ };
+
+ const variables: Record = {};
+ for (const item of context.items) {
+ variables[this.buildRemoteKey(item, context)] = item.value;
+ }
+
+ await fetchJson(
+ `https://backboard.railway.app/graphql/v2`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ query: `mutation variableUpsert($input: VariableUpsertInput!) { variableUpsert(input: $input) }`,
+ variables: {
+ input: {
+ serviceId,
+ environmentId,
+ variables,
+ },
+ },
+ }),
+ },
+ );
+
+ return {
+ written_count: context.items.length,
+ target: { service_id: serviceId, environment_id: environmentId },
+ };
+ }
+
+ private static async syncFlyio(context: EnterpriseSyncContext): Promise {
+ const token = await this.resolveConfigValue(context.org_id, context.connection.auth_config, "api_token");
+ if (!token) {
+ throw new ValidationError("Fly.io connection requires api_token or api_token_secret_ref.", "ENTERPRISE_FLYIO_AUTH_MISSING");
+ }
+
+ const appName = context.mapping.target_identifier;
+ const headers = {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ "Fly-Version": "1",
+ };
+
+ const secrets: Record = {};
+ for (const item of context.items) {
+ secrets[this.buildRemoteKey(item, context)] = item.value;
+ }
+
+ await fetchJson(
+ `https://api.fly.io/api/v1/apps/${encodeURIComponent(appName)}/secrets`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ secrets }),
+ },
+ );
+
+ return {
+ written_count: context.items.length,
+ target: { app: appName },
+ };
+ }
+
+ private static async syncRender(context: EnterpriseSyncContext): Promise {
+ const apiKey = await this.resolveConfigValue(context.org_id, context.connection.auth_config, "api_key");
+ if (!apiKey) {
+ throw new ValidationError("Render connection requires api_key or api_key_secret_ref.", "ENTERPRISE_RENDER_AUTH_MISSING");
+ }
+
+ const serviceId = context.mapping.target_identifier;
+ const headers = {
+ Authorization: `Bearer ${apiKey}`,
+ "Content-Type": "application/json",
+ };
+
+ for (const item of context.items) {
+ const key = this.buildRemoteKey(item, context);
+ await fetchJson(
+ `https://api.render.com/v1/services/${encodeURIComponent(serviceId)}/env-vars`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ key, value: item.value }),
+ },
+ [409],
+ );
+ }
+
+ return {
+ written_count: context.items.length,
+ target: { service_id: serviceId },
+ };
+ }
+
+ private static async syncSupabase(context: EnterpriseSyncContext): Promise {
+ const token = await this.resolveConfigValue(context.org_id, context.connection.auth_config, "access_token");
+ if (!token) {
+ throw new ValidationError("Supabase connection requires access_token or access_token_secret_ref.", "ENTERPRISE_SUPABASE_AUTH_MISSING");
+ }
+
+ const projectRef = context.mapping.target_identifier ?? asString(context.binding.metadata.project_ref);
+ if (!projectRef) {
+ throw new ValidationError("Supabase binding requires project_ref.", "ENTERPRISE_SUPABASE_PROJECT_MISSING");
+ }
+
+ const headers = {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ };
+
+ for (const item of context.items) {
+ const key = this.buildRemoteKey(item, context);
+ await fetchJson(
+ `https://api.supabase.com/v1/projects/${encodeURIComponent(projectRef)}/secrets`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify([{ name: key, value: item.value }]),
+ },
+ );
+ }
+
+ return {
+ written_count: context.items.length,
+ target: { project_ref: projectRef },
+ };
+ }
+
+ private static async syncDigitalOcean(context: EnterpriseSyncContext): Promise {
+ const token = await this.resolveConfigValue(context.org_id, context.connection.auth_config, "api_token");
+ if (!token) {
+ throw new ValidationError("DigitalOcean connection requires api_token or api_token_secret_ref.", "ENTERPRISE_DIGITALOCEAN_AUTH_MISSING");
+ }
+
+ const appId = context.mapping.target_identifier;
+ const headers = {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ };
+
+ const { body: app } = await fetchJson(
+ `https://api.digitalocean.com/v2/apps/${encodeURIComponent(appId)}`,
+ { method: "GET", headers },
+ );
+ const spec = (app as { app?: { spec?: Record } })?.app?.spec ?? {};
+
+ for (const item of context.items) {
+ const key = this.buildRemoteKey(item, context);
+ const services = (spec.services as Array<{ envs?: Array> }> | undefined) ?? [];
+ for (const service of services) {
+ if (!service.envs) service.envs = [];
+ const existing = service.envs.find(e => e.key === key);
+ if (existing) {
+ existing.value = item.value;
+ } else {
+ service.envs.push({ key, value: item.value, type: item.kind === "secret" ? "SECRET" : "GENERAL" });
+ }
+ }
+ }
+
+ await fetchJson(
+ `https://api.digitalocean.com/v2/apps/${encodeURIComponent(appId)}`,
+ {
+ method: "PUT",
+ headers,
+ body: JSON.stringify({ spec }),
+ },
+ );
+
+ return {
+ written_count: context.items.length,
+ target: { app_id: appId },
+ };
+ }
+
+ private static async syncAzureKeyVault(context: EnterpriseSyncContext): Promise {
+ const tenantId = asString(context.connection.auth_config.tenant_id);
+ const clientId = asString(context.connection.auth_config.client_id);
+ const clientSecret = await this.resolveConfigValue(context.org_id, context.connection.auth_config, "client_secret");
+ if (!tenantId || !clientId || !clientSecret) {
+ throw new ValidationError("Azure Key Vault connection requires tenant_id, client_id, and client_secret.", "ENTERPRISE_AZUREKV_AUTH_MISSING");
+ }
+
+ const vaultUrl = context.mapping.target_identifier.replace(/\/$/, "");
+ const tokenResponse = await fetch(`https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "client_credentials",
+ client_id: clientId,
+ client_secret: clientSecret,
+ scope: "https://vault.azure.net/.default",
+ }),
+ });
+ const tokenBody = await tokenResponse.json().catch(() => null) as { access_token?: string } | null;
+ if (!tokenResponse.ok || !tokenBody?.access_token) {
+ throw new ValidationError("Failed to obtain Azure access token.", "ENTERPRISE_AZUREKV_AUTH_FAILED");
+ }
+
+ const headers = {
+ Authorization: `Bearer ${tokenBody.access_token}`,
+ "Content-Type": "application/json",
+ };
+
+ for (const item of context.items) {
+ const name = this.buildRemoteKey(item, context).toLowerCase();
+ await fetchJson(
+ `${vaultUrl}/secrets/${encodeURIComponent(name)}?api-version=7.4`,
+ {
+ method: "PUT",
+ headers,
+ body: JSON.stringify({ value: item.value }),
+ },
+ );
+ }
+
+ return {
+ written_count: context.items.length,
+ target: { vault: vaultUrl },
+ };
+ }
+
+ private static async syncAwsSecretsManager(context: EnterpriseSyncContext): Promise {
+ const accessKeyId = asString(context.connection.auth_config.access_key_id);
+ const secretAccessKey = await this.resolveConfigValue(context.org_id, context.connection.auth_config, "secret_access_key");
+ const region = asString(context.connection.auth_config.region) ?? "us-east-1";
+ if (!accessKeyId || !secretAccessKey) {
+ throw new ValidationError("AWS Secrets Manager connection requires access_key_id and secret_access_key.", "ENTERPRISE_AWSSM_AUTH_MISSING");
+ }
+
+ const secretPath = context.mapping.target_identifier;
+ const { SSMClient, PutParameterCommand } = await import("@aws-sdk/client-ssm");
+ const client = new SSMClient({
+ region,
+ credentials: { accessKeyId, secretAccessKey },
+ });
+
+ for (const item of context.items) {
+ const key = this.buildRemoteKey(item, context);
+ const fullPath = secretPath ? `${secretPath.replace(/\/$/, "")}/${key}` : key;
+ await client.send(new PutParameterCommand({
+ Name: fullPath,
+ Value: item.value,
+ Type: item.kind === "secret" ? "SecureString" : "String",
+ Overwrite: true,
+ }));
+ }
+
+ return {
+ written_count: context.items.length,
+ target: { path: secretPath, region },
+ };
+ }
+
+ private static async syncCloudflareWorkers(context: EnterpriseSyncContext): Promise