diff --git a/.env.example b/.env.example
index 6dc5001..f9a0545 100644
--- a/.env.example
+++ b/.env.example
@@ -11,6 +11,10 @@ GITDB_AUTHOR_NAME=gitops
GITDB_AUTHOR_EMAIL=gitops@example.com
GITDB_SYNC_POLL_SECONDS=60
+# Optional dedicated key for the vault secret values (AES-256-GCM).
+# Falls back to GITDB_ENCRYPTION_KEY. Rotating it makes existing secrets unreadable.
+# VAULT_ENCRYPTION_KEY=
+
# Optional HTTP(S) authentication for the GitDB repository
# GITDB_TOKEN=
# GITDB_USERNAME=
diff --git a/e2e/specs/vault-stateiac.spec.ts b/e2e/specs/vault-stateiac.spec.ts
index 295637f..8dacf40 100644
--- a/e2e/specs/vault-stateiac.spec.ts
+++ b/e2e/specs/vault-stateiac.spec.ts
@@ -11,7 +11,7 @@ test.describe('vault — secrets:read alone is enough', () => {
loginAs,
}) => {
await loginAs('vaultSecretsOnly');
- await expectAllowed(page, `${base}/vault`);
+ await expectAllowed(page, `${base}/vault/dev`);
});
});
@@ -21,7 +21,7 @@ test.describe('vault — environments:read alone is enough', () => {
loginAs,
}) => {
await loginAs('vaultEnvironmentsOnly');
- await expectAllowed(page, `${base}/vault`);
+ await expectAllowed(page, `${base}/vault/dev`);
});
});
diff --git a/package.json b/package.json
index 41e57f9..ce3bfb4 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "gitops",
"private": true,
- "version": "1.2.3",
+ "version": "1.3.0",
"type": "module",
"scripts": {
"dev": "vite dev",
diff --git a/src/app.html b/src/app.html
index adf8bd8..e55e40b 100644
--- a/src/app.html
+++ b/src/app.html
@@ -3,6 +3,9 @@
- {selected?.name || 'Select'}
+ {selected?.name || 'Select'}
@@ -53,17 +57,29 @@
selectOption(option.id)}
+ aria-disabled={option.disabled}
+ on:click={() => selectOption(option)}
>
{option.name}
- {#if option.id === value}
-
- {/if}
+
+ {#if option.badge}
+
+ {option.badge}
+
+ {/if}
+ {#if option.id === value}
+
+ {/if}
+
{/each}
diff --git a/src/lib/components/Footer.svelte b/src/lib/components/Footer.svelte
index 0bd9b07..64018a9 100644
--- a/src/lib/components/Footer.svelte
+++ b/src/lib/components/Footer.svelte
@@ -1,8 +1,9 @@
diff --git a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte
index 228b6e3..bd9098f 100644
--- a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte
+++ b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte
@@ -67,7 +67,6 @@
label: 'Vault',
icon: Shield,
href: `/org/${orgSlug}/projects/${project.slug}/vault`,
- soon: true,
},
{
key: 'codereport',
@@ -117,11 +116,6 @@
editModules = { ...editModules, [key]: !editModules[key] };
}
- function moduleValueForSubmit(key: keyof ProjectModules) {
- const option = moduleOptions.find((moduleOption) => moduleOption.key === key);
- return editModules[key] && !option?.soon ? 'on' : '';
- }
-
async function copySlug() {
try {
await navigator.clipboard.writeText(project.slug);
@@ -244,9 +238,9 @@
class="overflow-hidden rounded-md border border-slate-200 bg-white"
>
-
-
-
+
+
+
{#if isArchived}
- import { Shield, KeyRound, Lock, Sparkles } from '@lucide/svelte';
- import { _ } from '$lib/i18n';
+ import { deserialize, enhance } from '$app/forms';
+ import { goto, invalidateAll } from '$app/navigation';
+ import { page } from '$app/stores';
+ import type { SubmitFunction } from '@sveltejs/kit';
+ import {
+ Check,
+ CheckCircle,
+ ChevronDown,
+ ChevronRight,
+ Download,
+ Edit3,
+ Eye,
+ EyeOff,
+ Folder,
+ FolderInput,
+ Info,
+ Link2,
+ Plus,
+ Search,
+ Shield,
+ Upload,
+ X,
+ } from '@lucide/svelte';
+
+ type VaultEnvironment = {
+ id: string;
+ slug: string;
+ name: string;
+ description?: string | null;
+ };
+
+ type VaultFolder = {
+ id: string;
+ parentFolderId: string | null;
+ linkedFolderId: string | null;
+ name: string;
+ path: string;
+ description?: string | null;
+ };
+
+ type VaultSecret = {
+ id: string;
+ folderId: string | null;
+ key: string;
+ description?: string | null;
+ values: Record;
+ };
+
+ export let data: {
+ environments: VaultEnvironment[];
+ folders: VaultFolder[];
+ secrets: VaultSecret[];
+ currentEnvironment?: VaultEnvironment | null;
+ currentFolder?: VaultFolder | null;
+ currentPath?: string;
+ canManageSecrets: boolean;
+ canDeleteSecrets: boolean;
+ capitalizeSecrets: boolean;
+ project?: { name?: string | null; slug?: string | null } | null;
+ };
+
+ let selectedEnvironmentSlug = '';
+ let environmentMenuOpen = false;
+ let environmentMenuEl: HTMLDivElement | null = null;
+ let actionsMenuOpen = false;
+ let actionsMenuEl: HTMLDivElement | null = null;
+ let searchQuery = '';
+ let importOpen = false;
+ let importContent = '';
+ let copyEnvOpen = false;
+ let copySourceSlug = '';
+ let dropActive = false;
+ let dropError = '';
+ let createSecretOpen = false;
+ let createFolderOpen = false;
+ let linkFolderOpen = false;
+ let editingSecret: VaultSecret | null = null;
+ let deletingSecret: VaultSecret | null = null;
+ let deletingFolder: VaultFolder | null = null;
+ let revealed: Record = {};
+ let formError = '';
+ let success = '';
+ let submitting = false;
+
+ let newSecretKey = '';
+ let newSecretValue = '';
+
+ let editSecretKey = '';
+ let editSecretDescription = '';
+ let editSecretFolderId = '';
+ let editSecretValues: Record = {};
+
+ let newFolderName = '';
+
+ let linkedFolderPath = '';
+
+ $: orgSlug = $page.params.org;
+ $: projectSlug = $page.params.slug;
+ $: currentFolderId = data.currentFolder?.id ?? null;
+ $: currentPath = data.currentPath ?? '/';
+ $: if (!selectedEnvironmentSlug && data.environments[0]) {
+ selectedEnvironmentSlug = data.currentEnvironment?.slug ?? data.environments[0].slug;
+ }
+
+ $: if (
+ data.currentEnvironment?.slug &&
+ selectedEnvironmentSlug !== data.currentEnvironment.slug
+ ) {
+ selectedEnvironmentSlug = data.currentEnvironment.slug;
+ }
+
+ $: selectedEnvironment =
+ data.environments.find((environment) => environment.slug === selectedEnvironmentSlug) ??
+ data.environments[0] ??
+ null;
+ $: selectedFolder = data.folders.find((folder) => folder.id === currentFolderId) ?? null;
+ $: childFolders = data.folders.filter((folder) => folder.parentFolderId === currentFolderId);
+ $: visibleSecretFolderIds = selectedFolder ? folderScopeIds(selectedFolder) : [null];
+ $: visibleSecrets = data.secrets.filter((secret) =>
+ visibleSecretFolderIds.includes(secret.folderId),
+ );
+ $: filteredFolders = childFolders.filter((folder) => {
+ const query = searchQuery.trim().toLowerCase();
+ return !query || folder.name.toLowerCase().includes(query);
+ });
+ $: filteredSecrets = visibleSecrets.filter((secret) => {
+ const query = searchQuery.trim().toLowerCase();
+ return !query || secret.key.toLowerCase().includes(query);
+ });
+ $: allRevealed =
+ visibleSecrets.length > 0 &&
+ visibleSecrets.every((secret) => revealed[`${secret.id}:${selectedEnvironmentSlug}`]);
+ $: parentFolder = selectedFolder?.parentFolderId
+ ? (data.folders.find((folder) => folder.id === selectedFolder?.parentFolderId) ?? null)
+ : null;
+
+ function flashSuccess(message: string) {
+ success = message;
+ setTimeout(() => {
+ success = '';
+ }, 2500);
+ }
+
+ function folderScopeIds(folder: VaultFolder): Array {
+ const ids: Array = [folder.id];
+
+ if (folder.linkedFolderId) {
+ const linkedFolder = data.folders.find((candidate) => candidate.id === folder.linkedFolderId);
+ if (linkedFolder) ids.push(...folderScopeIds(linkedFolder));
+ }
+
+ return [...new Set(ids)];
+ }
+
+ function pathToUrl(path: string) {
+ if (path === '/')
+ return `/org/${orgSlug}/projects/${projectSlug}/vault/${selectedEnvironmentSlug}`;
+ const encodedPath = path
+ .split('/')
+ .filter(Boolean)
+ .map((segment) => encodeURIComponent(segment))
+ .join('/');
+ return `/org/${orgSlug}/projects/${projectSlug}/vault/${selectedEnvironmentSlug}/${encodedPath}`;
+ }
+
+ function goToFolder(folder: VaultFolder | null) {
+ void goto(pathToUrl(folder?.path ?? '/'));
+ }
+
+ function changeEnvironment() {
+ void goto(pathToUrl(currentPath));
+ }
+
+ function selectEnvironment(slug: string) {
+ environmentMenuOpen = false;
+ if (slug === selectedEnvironmentSlug) return;
+ selectedEnvironmentSlug = slug;
+ changeEnvironment();
+ }
+
+ function handleWindowClick(event: MouseEvent) {
+ const target = event.target as Node;
+ if (environmentMenuOpen && environmentMenuEl && !environmentMenuEl.contains(target)) {
+ environmentMenuOpen = false;
+ }
+ if (actionsMenuOpen && actionsMenuEl && !actionsMenuEl.contains(target)) {
+ actionsMenuOpen = false;
+ }
+ }
+
+ function toggleReveal(secret: VaultSecret) {
+ const key = `${secret.id}:${selectedEnvironmentSlug}`;
+ revealed = { ...revealed, [key]: !revealed[key] };
+ }
+
+ function toggleRevealAll() {
+ revealed = allRevealed
+ ? {}
+ : Object.fromEntries(
+ visibleSecrets.map((secret) => [`${secret.id}:${selectedEnvironmentSlug}`, true]),
+ );
+ }
+
+ async function uploadEnvContent(content: string) {
+ const body = new FormData();
+ body.set('folderId', currentFolderId ?? '');
+ body.set('environmentSlug', selectedEnvironmentSlug);
+ body.set('content', content);
+
+ const response = await fetch('?/importSecrets', {
+ method: 'POST',
+ headers: { 'x-sveltekit-action': 'true' },
+ body,
+ });
+ const result = deserialize(await response.text());
+
+ if (result.type !== 'success') {
+ dropError =
+ result.type === 'failure' && result.data?.error
+ ? String(result.data.error)
+ : 'No se pudo importar el archivo';
+ return;
+ }
+
+ dropError = '';
+ await invalidateAll();
+ flashSuccess('Secretos importados');
+ }
+
+ async function handleDrop(event: DragEvent) {
+ dropActive = false;
+ const file = event.dataTransfer?.files?.[0];
+ if (!file) return;
+
+ dropError = '';
+ await uploadEnvContent(await file.text());
+ }
+
+ function handleDragLeave(event: DragEvent) {
+ const related = event.relatedTarget as Node | null;
+ if (related && (event.currentTarget as HTMLElement).contains(related)) return;
+ dropActive = false;
+ }
+
+ async function downloadEnvFile() {
+ const response = await fetch('?/exportSecrets', {
+ method: 'POST',
+ headers: { 'x-sveltekit-action': 'true' },
+ body: new FormData(),
+ });
+ const result = deserialize(await response.text());
+
+ if (result.type !== 'success' || !result.data) {
+ formError = 'No se pudieron exportar los secretos';
+ return;
+ }
+
+ const { content, filename } = result.data as { content: string; filename: string };
+ const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }));
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ link.click();
+ URL.revokeObjectURL(url);
+ }
+
+ function normalizeKeyInput(event: Event) {
+ const value = (event.target as HTMLInputElement).value;
+ return data.capitalizeSecrets ? value.toUpperCase() : value;
+ }
+
+ function resetSecretForm() {
+ newSecretKey = '';
+ newSecretValue = '';
+ }
+
+ function openCreateSecret() {
+ formError = '';
+ resetSecretForm();
+ createSecretOpen = true;
+ }
+
+ function openEditSecret(secret: VaultSecret) {
+ formError = '';
+ editingSecret = secret;
+ editSecretKey = secret.key;
+ editSecretDescription = secret.description ?? '';
+ editSecretFolderId = secret.folderId ?? '';
+ editSecretValues = Object.fromEntries(
+ data.environments.map((environment) => [
+ environment.slug,
+ secret.values?.[environment.slug] ?? '',
+ ]),
+ );
+ }
+
+ function openCreateFolder() {
+ formError = '';
+ newFolderName = '';
+ createFolderOpen = true;
+ }
+
+ function openLinkFolder() {
+ formError = '';
+ linkedFolderPath = '';
+ linkFolderOpen = true;
+ }
+
+ function openImportSecrets() {
+ formError = '';
+ importContent = '';
+ importOpen = true;
+ }
+
+ function openCopyEnvironment() {
+ formError = '';
+ copySourceSlug =
+ data.environments.find((environment) => environment.slug !== selectedEnvironmentSlug)?.slug ??
+ '';
+ copyEnvOpen = true;
+ }
+
+ const handleSubmit: SubmitFunction = () => {
+ formError = '';
+ submitting = true;
+ return async ({ result, update }) => {
+ await update();
+ submitting = false;
+
+ if (result.type === 'success') {
+ createSecretOpen = false;
+ createFolderOpen = false;
+ linkFolderOpen = false;
+ importOpen = false;
+ copyEnvOpen = false;
+ editingSecret = null;
+ deletingSecret = null;
+ deletingFolder = null;
+ flashSuccess('Cambios guardados');
+ return;
+ }
+
+ formError =
+ result.type === 'failure' && result.data?.error
+ ? String(result.data.error)
+ : 'No se pudieron guardar los cambios';
+ };
+ };
+
+ function maskValue(value: string | undefined) {
+ if (!value) return 'Sin valor';
+ return '************';
+ }
Vault - GitOps
+
+
-
-
-
-
-
{$_('project.vault.sectionLabel')}
-
Vault
-
{$_('project.vault.description')}
-
-
-
- {$_('common.comingSoon')}
-
-
+ {#if success}
+
+ {success}
+ {/if}
+
+
+
+
+
+
+
+
+ {data.project?.name ?? projectSlug}
+
+
+
/
-
-
-
-
{$_('project.vault.policies')}
-
{$_('project.vault.policiesDescription')}
+
+
(environmentMenuOpen = !environmentMenuOpen)}
+ aria-haspopup="listbox"
+ aria-expanded={environmentMenuOpen}
+ class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-slate-700 transition hover:bg-slate-100"
+ >
+ {selectedEnvironment?.name ?? selectedEnvironmentSlug}
+
+
+
+ {#if environmentMenuOpen}
+
+ {#each data.environments as environment (environment.id)}
+
+ selectEnvironment(environment.slug)}
+ class="flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-sm text-slate-700 hover:bg-slate-50"
+ >
+ {environment.name}
+ {#if environment.slug === selectedEnvironmentSlug}
+
+ {/if}
+
+
+ {/each}
+
+ {/if}
-
-
-
{$_('project.vault.secrets')}
-
{$_('project.vault.secretsDescription')}
+
+ {#if currentPath !== '/'}
+
/
+
{currentPath.replace(/^\//, '')}
+ {/if}
+
+
+
+
+
+
-
-
-
{$_('project.vault.access')}
-
{$_('project.vault.accessDescription')}
+
+
+
+
+
+
+ {#if allRevealed}
+
+ {:else}
+
+ {/if}
+
+
+ {#if data.canManageSecrets}
+
+
+ Nuevo secreto
+
+
(actionsMenuOpen = !actionsMenuOpen)}
+ aria-haspopup="menu"
+ aria-expanded={actionsMenuOpen}
+ aria-label="Más acciones"
+ class="btn-primary inline-flex items-center rounded-r-md border-l border-white/20 px-2 py-2"
+ >
+
+
+
+ {#if actionsMenuOpen}
+
+ {
+ actionsMenuOpen = false;
+ openCreateFolder();
+ }}
+ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-700 hover:bg-slate-50"
+ >
+ Add Folder
+
+ {
+ actionsMenuOpen = false;
+ openLinkFolder();
+ }}
+ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-700 hover:bg-slate-50"
+ >
+ Link Folder
+
+ {
+ actionsMenuOpen = false;
+ openImportSecrets();
+ }}
+ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-700 hover:bg-slate-50"
+ >
+ Import Secrets
+
+
+ {/if}
+
+ {/if}
-
-
-
-
+
+
+
+
+
+ Key
+ Value
+
+
+
+
+ {#if filteredFolders.length === 0 && filteredSecrets.length === 0}
+
+
+ No hay carpetas ni secretos en esta vista.
+
+
+ {:else}
+ {#if currentFolderId}
+ goToFolder(parentFolder)}
+ >
+
+
+
+ ..
+
+
+
+
+
+
+
+ {/if}
+ {#each filteredFolders as folder (folder.id)}
+ goToFolder(folder)}
+ >
+
+
+ {#if folder.linkedFolderId}
+
+ {:else}
+
+ {/if}
+ {folder.name}
+
+
+
+
+
+ {#if data.canDeleteSecrets}
+ (deletingFolder = folder)}
+ class="inline-flex items-center justify-center rounded-md border border-slate-200 p-2 text-slate-600 hover:bg-red-50 hover:text-red-600"
+ aria-label="Borrar carpeta"
+ >
+
+
+ {/if}
+
+
+
+
+ {/each}
+ {#each filteredSecrets as secret (secret.id)}
+
+
+
+ {secret.key}
+ {#if secret.description}
+
+
+
+ {secret.description}
+
+
+
+ {/if}
+
+
+
+ toggleReveal(secret)}
+ title={revealed[`${secret.id}:${selectedEnvironmentSlug}`]
+ ? 'Ocultar valor'
+ : 'Mostrar valor'}
+ class="block w-60 truncate text-left font-mono text-sm text-slate-700 hover:text-slate-900"
+ >
+ {#if revealed[`${secret.id}:${selectedEnvironmentSlug}`]}
+ {secret.values?.[selectedEnvironmentSlug] || 'Sin valor'}
+ {:else}
+ {maskValue(secret.values?.[selectedEnvironmentSlug])}
+ {/if}
+
+
+
+ {#if data.canManageSecrets}
+ openEditSecret(secret)}
+ class="inline-flex items-center justify-center rounded-md border border-slate-200 p-2 text-slate-600 hover:bg-slate-50"
+ aria-label="Editar secreto"
+ >
+
+
+ {/if}
+ {#if data.canDeleteSecrets}
+ (deletingSecret = secret)}
+ class="ml-2 inline-flex items-center justify-center rounded-md border border-slate-200 p-2 text-slate-600 hover:bg-red-50 hover:text-red-600"
+ aria-label="Borrar secreto"
+ >
+
+
+ {/if}
+
+
+ {/each}
+ {/if}
+
+
+
-
{$_('project.vault.workingTitle')}
-
{$_('project.vault.workingDescription')}
+
+ {#if data.canManageSecrets}
+
(dropActive = true)}
+ on:dragover|preventDefault|stopPropagation={() => (dropActive = true)}
+ on:dragleave|preventDefault|stopPropagation={handleDragLeave}
+ on:drop|preventDefault|stopPropagation={handleDrop}
+ class="flex min-h-40 flex-col items-center justify-center rounded-md border-2 border-dashed px-4 py-16 text-center transition {dropActive
+ ? 'border-slate-400 bg-slate-50'
+ : 'border-slate-200 bg-white'}"
+ >
+
+ Drag and drop .env, or
+ import .env
+
+
+ OR
+ Copy from environment
+
+ {#if dropError}
+
{dropError}
+ {/if}
+
+ {/if}
+
+{#if createSecretOpen}
+
+{/if}
+
+{#if editingSecret}
+
+{/if}
+
+{#if deletingSecret}
+
+
+
+ Borrar secreto
+
+ Se borrara {deletingSecret.key} de
+ forma permanente.
+
+ {#if formError}
+ {formError}
+
{/if}
+
+ (deletingSecret = null)}
+ class="rounded-md border border-slate-200 px-3 py-2 text-sm">Cancelar Borrar
+
+
+
+{/if}
+
+{#if deletingFolder}
+
+
+
+ Borrar carpeta
+
+ Se borrara {deletingFolder.name}
+ de forma permanente. Debe estar vacia.
+
+ {#if formError}
+ {formError}
+
{/if}
+
+ (deletingFolder = null)}
+ class="rounded-md border border-slate-200 px-3 py-2 text-sm">Cancelar Borrar
+
+
+
+{/if}
+
+{#if createFolderOpen}
+
+
+ Crear carpeta
+ {#if formError}
+ {formError}
+
{/if}
+
+
+
Nombre
+
+ Se creara en {currentPath}
+
+
+
+ (createFolderOpen = false)}
+ class="rounded-md border border-slate-200 px-3 py-2 text-sm">Cancelar Crear
+
+
+
+{/if}
+
+{#if linkFolderOpen}
+
+
+ Enlazar carpeta
+ {#if formError}
+ {formError}
+
{/if}
+
+
+ Path a enlazar
+
+
+ (linkFolderOpen = false)}
+ class="rounded-md border border-slate-200 px-3 py-2 text-sm">Cancelar Enlazar
+
+
+
+{/if}
+
+{#if importOpen}
+
+
+ Importar secretos
+
+ Pega el contenido de un archivo .env para el entorno {selectedEnvironment?.name ??
+ selectedEnvironmentSlug}.
+
+ {#if formError}
+ {formError}
+
{/if}
+
+
+
+
+ (importOpen = false)}
+ class="rounded-md border border-slate-200 px-3 py-2 text-sm">Cancelar Importar
+
+
+
+{/if}
+
+{#if copyEnvOpen}
+
+
+ Copiar desde otro entorno
+
+ Copia los valores de los secretos de {currentPath} hacia
+ {selectedEnvironment?.name ?? selectedEnvironmentSlug}.
+
+ {#if formError}
+ {formError}
+
{/if}
+
+ Entorno origen{#each data.environments.filter((environment) => environment.slug !== selectedEnvironmentSlug) as environment (environment.id)}{environment.name} {/each}
+
+ (copyEnvOpen = false)}
+ class="rounded-md border border-slate-200 px-3 py-2 text-sm">Cancelar Copiar
+
+
+
+{/if}
diff --git a/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts b/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts
new file mode 100644
index 0000000..5fb95e1
--- /dev/null
+++ b/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.server.ts
@@ -0,0 +1,265 @@
+import { error, fail, redirect } from '@sveltejs/kit';
+import type { RequestEvent } from '@sveltejs/kit';
+import { cancanService } from '$modules/auth';
+import { projectService } from '$modules/projects';
+import { vaultService } from '$modules/vault';
+
+export async function load({ params, locals }) {
+ let project;
+ try {
+ project = await projectService.getProjectBySlug(params.slug);
+ } catch {
+ throw error(404, 'Project not found');
+ }
+
+ const organizationId = project.organization?.id;
+ const [canReadSecrets, canReadEnvironments] = await Promise.all([
+ cancanService.canSessionUser(locals.user, 'project:vault:secrets:read', {
+ scope: 'project',
+ projectId: project.id,
+ organizationId,
+ }),
+ cancanService.canSessionUser(locals.user, 'project:vault:environments:read', {
+ scope: 'project',
+ projectId: project.id,
+ organizationId,
+ }),
+ ]);
+
+ if (!canReadSecrets && !canReadEnvironments) {
+ throw error(403, 'Forbidden');
+ }
+
+ const vault = await vaultService.getProjectVault(project.id);
+ const settings = await vaultService.getSettings(project.id);
+ const currentEnvironment = vault.environments.find(
+ (environment) => environment.slug === params.env,
+ );
+ if (!currentEnvironment) {
+ throw redirect(
+ 302,
+ `/org/${params.org}/projects/${params.slug}/vault/${vault.environments[0]?.slug ?? 'dev'}`,
+ );
+ }
+
+ const currentPath = routePath(params.path);
+ let currentFolder;
+ try {
+ currentFolder = await vaultService.getFolderByPath(project.id, currentPath);
+ } catch {
+ throw error(404, 'Folder not found');
+ }
+
+ return {
+ ...vault,
+ currentEnvironment,
+ currentFolder,
+ currentPath,
+ canManageSecrets: await cancanService.canSessionUser(
+ locals.user,
+ 'project:vault:secrets:update',
+ {
+ scope: 'project',
+ projectId: project.id,
+ organizationId,
+ },
+ ),
+ canDeleteSecrets: await cancanService.canSessionUser(
+ locals.user,
+ 'project:vault:secrets:delete',
+ {
+ scope: 'project',
+ projectId: project.id,
+ organizationId,
+ },
+ ),
+ capitalizeSecrets: settings.capitalizeSecrets,
+ };
+}
+
+async function getAuthorizedProject(
+ params: { slug: string },
+ locals: RequestEvent['locals'],
+ permission: string,
+) {
+ const project = await projectService.getProjectBySlug(params.slug);
+ const allowed = await cancanService.canSessionUser(locals.user, permission, {
+ scope: 'project',
+ projectId: project.id,
+ organizationId: project.organization?.id,
+ });
+
+ if (!allowed) throw new Error('Forbidden');
+ return project;
+}
+
+function routePath(path: string | undefined) {
+ if (!path) return '/';
+ return `/${path
+ .split('/')
+ .filter(Boolean)
+ .map((segment) => decodeURIComponent(segment))
+ .join('/')}`;
+}
+
+function normalizeFolderPath(path: string) {
+ const segments = path.trim().split('/').filter(Boolean);
+ return segments.length === 0 ? '/' : `/${segments.join('/')}`;
+}
+
+function secretValuesFromForm(formData: FormData) {
+ const values: Record = {};
+ for (const [key, value] of formData.entries()) {
+ if (key.startsWith('value:')) values[key.slice(6)] = String(value);
+ }
+ return values;
+}
+
+export const actions = {
+ createSecret: async ({ request, params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:create');
+ const formData = await request.formData();
+ await vaultService.createSecret(project.id, {
+ folderId: String(formData.get('folderId') || '') || null,
+ key: String(formData.get('key') || ''),
+ description: String(formData.get('description') || ''),
+ values: secretValuesFromForm(formData),
+ });
+ return { success: true };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudo crear el secreto',
+ });
+ }
+ },
+ updateSecret: async ({ request, params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:update');
+ const formData = await request.formData();
+ await vaultService.updateSecret(project.id, String(formData.get('id') || ''), {
+ folderId: String(formData.get('folderId') || '') || null,
+ key: String(formData.get('key') || ''),
+ description: String(formData.get('description') || ''),
+ values: secretValuesFromForm(formData),
+ });
+ return { success: true };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudo actualizar el secreto',
+ });
+ }
+ },
+ deleteSecret: async ({ request, params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:delete');
+ const formData = await request.formData();
+ await vaultService.deleteSecret(project.id, String(formData.get('id') || ''));
+ return { success: true };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudo borrar el secreto',
+ });
+ }
+ },
+ deleteFolder: async ({ request, params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:delete');
+ const formData = await request.formData();
+ await vaultService.deleteFolder(project.id, String(formData.get('id') || ''));
+ return { success: true };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudo borrar la carpeta',
+ });
+ }
+ },
+ createFolder: async ({ request, params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:create');
+ const formData = await request.formData();
+ await vaultService.createFolder(project.id, {
+ parentFolderId: String(formData.get('parentFolderId') || '') || null,
+ name: String(formData.get('name') || ''),
+ description: String(formData.get('description') || ''),
+ });
+ return { success: true };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudo crear la carpeta',
+ });
+ }
+ },
+ linkFolder: async ({ request, params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:create');
+ const formData = await request.formData();
+ const linkedPath = normalizeFolderPath(String(formData.get('linkedPath') || ''));
+ const linkedFolder = await vaultService.getFolderByPath(project.id, linkedPath);
+
+ if (!linkedFolder) throw new Error('No se puede enlazar la raiz');
+
+ await vaultService.linkFolder(project.id, {
+ parentFolderId: String(formData.get('parentFolderId') || '') || null,
+ linkedFolderId: linkedFolder.id,
+ });
+ return { success: true };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudo enlazar la carpeta',
+ });
+ }
+ },
+ importSecrets: async ({ request, params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:create');
+ const formData = await request.formData();
+ const folderId = String(formData.get('folderId') || '') || null;
+ const environmentSlug = String(formData.get('environmentSlug') || params.env);
+ await vaultService.importEnvFile(
+ project.id,
+ environmentSlug,
+ folderId,
+ String(formData.get('content') || ''),
+ );
+
+ return { success: true };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudieron importar los secretos',
+ });
+ }
+ },
+ exportSecrets: async ({ params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:export');
+ const content = await vaultService.exportEnvFile(
+ project.id,
+ params.env,
+ routePath(params.path),
+ );
+ return { content, filename: `${params.slug}-${params.env}.env` };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudieron exportar los secretos',
+ });
+ }
+ },
+ copyEnvironment: async ({ request, params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(params, locals, 'project:vault:secrets:update');
+ const formData = await request.formData();
+ await vaultService.copyEnvironmentValues(
+ project.id,
+ String(formData.get('sourceEnvironment') || ''),
+ params.env,
+ String(formData.get('folderId') || '') || null,
+ );
+ return { success: true };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudieron copiar los secretos',
+ });
+ }
+ },
+};
diff --git a/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.svelte b/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.svelte
new file mode 100644
index 0000000..4b305f6
--- /dev/null
+++ b/src/routes/org/[org]/projects/[slug]/vault/[env]/[...path]/+page.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/routes/org/[org]/projects/[slug]/vault/environments/+page.server.ts b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.server.ts
new file mode 100644
index 0000000..1e40dcc
--- /dev/null
+++ b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.server.ts
@@ -0,0 +1,6 @@
+import { redirect } from '@sveltejs/kit';
+
+// Environments management moved into Vault > Settings.
+export async function load({ params }) {
+ throw redirect(302, `/org/${params.org}/projects/${params.slug}/vault/settings`);
+}
diff --git a/src/routes/org/[org]/projects/[slug]/vault/environments/+page.svelte b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.svelte
new file mode 100644
index 0000000..72e26aa
--- /dev/null
+++ b/src/routes/org/[org]/projects/[slug]/vault/environments/+page.svelte
@@ -0,0 +1,3 @@
+
diff --git a/src/routes/org/[org]/projects/[slug]/vault/integrations/+page.server.ts b/src/routes/org/[org]/projects/[slug]/vault/integrations/+page.server.ts
new file mode 100644
index 0000000..de23433
--- /dev/null
+++ b/src/routes/org/[org]/projects/[slug]/vault/integrations/+page.server.ts
@@ -0,0 +1,16 @@
+import { error } from '@sveltejs/kit';
+import { cancanService } from '$modules/auth';
+
+export async function load({ parent, locals }) {
+ const { project } = await parent();
+
+ const canRead = await cancanService.canSessionUser(locals.user, 'project:vault:secrets:read', {
+ scope: 'project',
+ projectId: project.id,
+ organizationId: project.organization?.id,
+ });
+
+ if (!canRead) throw error(403, 'Forbidden');
+
+ return {};
+}
diff --git a/src/routes/org/[org]/projects/[slug]/vault/integrations/+page.svelte b/src/routes/org/[org]/projects/[slug]/vault/integrations/+page.svelte
new file mode 100644
index 0000000..aec9caa
--- /dev/null
+++ b/src/routes/org/[org]/projects/[slug]/vault/integrations/+page.svelte
@@ -0,0 +1,192 @@
+
+
+
+ Vault integrations - GitOps
+
+
+
+
+ Vault
+ Integrations
+
+ Conecta el vault de este proyecto con tu stack. Cada guia explica como cargar los secretos por
+ entorno sin escribirlos en el repositorio.
+
+
+
+ {#each sections as section (section.id)}
+
+
+
{section.title}
+
{section.description}
+
+
+
+
+ {/each}
+
diff --git a/src/routes/org/[org]/projects/[slug]/vault/settings/+page.server.ts b/src/routes/org/[org]/projects/[slug]/vault/settings/+page.server.ts
new file mode 100644
index 0000000..01cd3db
--- /dev/null
+++ b/src/routes/org/[org]/projects/[slug]/vault/settings/+page.server.ts
@@ -0,0 +1,155 @@
+import { error, fail } from '@sveltejs/kit';
+import type { RequestEvent } from '@sveltejs/kit';
+import { cancanService } from '$modules/auth';
+import { projectService } from '$modules/projects';
+import { vaultService, VAULT_ENCRYPTION_PROVIDERS } from '$modules/vault';
+
+export async function load({ parent, locals }) {
+ const { project } = await parent();
+ const organizationId = project.organization?.id;
+
+ const [canReadSettings, canReadEnvironments] = await Promise.all([
+ cancanService.canSessionUser(locals.user, 'project:vault:settings:read', {
+ scope: 'project',
+ projectId: project.id,
+ organizationId,
+ }),
+ cancanService.canSessionUser(locals.user, 'project:vault:environments:read', {
+ scope: 'project',
+ projectId: project.id,
+ organizationId,
+ }),
+ ]);
+
+ if (!canReadSettings && !canReadEnvironments) throw error(403, 'Forbidden');
+
+ return {
+ settings: await vaultService.getSettings(project.id),
+ encryptionProviders: VAULT_ENCRYPTION_PROVIDERS,
+ canManageSettings: await cancanService.canSessionUser(
+ locals.user,
+ 'project:vault:settings:update',
+ { scope: 'project', projectId: project.id, organizationId },
+ ),
+ environments: await vaultService.listEnvironments(project.id),
+ canManageEnvironments: await cancanService.canSessionUser(
+ locals.user,
+ 'project:vault:environments:update',
+ { scope: 'project', projectId: project.id, organizationId },
+ ),
+ canDeleteEnvironments: await cancanService.canSessionUser(
+ locals.user,
+ 'project:vault:environments:delete',
+ { scope: 'project', projectId: project.id, organizationId },
+ ),
+ };
+}
+
+async function getAuthorizedProject(
+ params: { slug: string },
+ locals: RequestEvent['locals'],
+ permission: string,
+) {
+ const project = await projectService.getProjectBySlug(params.slug);
+ const allowed = await cancanService.canSessionUser(locals.user, permission, {
+ scope: 'project',
+ projectId: project.id,
+ organizationId: project.organization?.id,
+ });
+ if (!allowed) throw new Error('Forbidden');
+ return project;
+}
+
+export const actions = {
+ updateSettings: async ({ request, params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(params, locals, 'project:vault:settings:update');
+ const formData = await request.formData();
+ await vaultService.updateSettings(project.id, {
+ capitalizeSecrets: formData.get('capitalizeSecrets') === 'on',
+ encryptionProvider: String(
+ formData.get('encryptionProvider') || 'gitops_kms',
+ ) as 'gitops_kms',
+ });
+ return { success: true };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudo actualizar la configuracion',
+ });
+ }
+ },
+ createEnvironment: async ({ request, params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(
+ params,
+ locals,
+ 'project:vault:environments:create',
+ );
+ const formData = await request.formData();
+ await vaultService.createEnvironment(project.id, {
+ name: String(formData.get('name') || ''),
+ slug: String(formData.get('slug') || ''),
+ description: String(formData.get('description') || ''),
+ });
+ return { success: true };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudo crear el entorno',
+ });
+ }
+ },
+ updateEnvironment: async ({ request, params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(
+ params,
+ locals,
+ 'project:vault:environments:update',
+ );
+ const formData = await request.formData();
+ await vaultService.updateEnvironment(project.id, String(formData.get('id') || ''), {
+ name: String(formData.get('name') || ''),
+ slug: String(formData.get('slug') || ''),
+ description: String(formData.get('description') || ''),
+ });
+ return { success: true };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudo actualizar el entorno',
+ });
+ }
+ },
+ deleteEnvironment: async ({ request, params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(
+ params,
+ locals,
+ 'project:vault:environments:delete',
+ );
+ const formData = await request.formData();
+ await vaultService.deleteEnvironment(project.id, String(formData.get('id') || ''));
+ return { success: true };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudo borrar el entorno',
+ });
+ }
+ },
+ moveEnvironment: async ({ request, params, locals }) => {
+ try {
+ const project = await getAuthorizedProject(
+ params,
+ locals,
+ 'project:vault:environments:update',
+ );
+ const formData = await request.formData();
+ const direction = String(formData.get('direction') || '') === 'up' ? 'up' : 'down';
+ await vaultService.moveEnvironment(project.id, String(formData.get('id') || ''), direction);
+ return { success: true };
+ } catch (err) {
+ return fail(err instanceof Error && err.message === 'Forbidden' ? 403 : 400, {
+ error: err instanceof Error ? err.message : 'No se pudo reordenar el entorno',
+ });
+ }
+ },
+};
+
diff --git a/src/routes/org/[org]/projects/[slug]/vault/settings/+page.svelte b/src/routes/org/[org]/projects/[slug]/vault/settings/+page.svelte
new file mode 100644
index 0000000..e45d7e0
--- /dev/null
+++ b/src/routes/org/[org]/projects/[slug]/vault/settings/+page.svelte
@@ -0,0 +1,361 @@
+
+
+
+ Vault settings - GitOps
+
+
+
+
+ Vault
+ Settings
+
+ Configura el comportamiento general del vault de este proyecto.
+
+
+
+ {#if success}
+
+ {success}
+
+ {/if}
+
+ {#if formError}
+
{formError}
+ {/if}
+
+
+
+
+
+
+
+
Capitalize all secrets
+
+ Fuerza que las keys de los secretos se guarden siempre en mayusculas.
+
+
+
+ event.currentTarget.form?.requestSubmit()}
+ class="peer sr-only"
+ />
+
+
+
+
+
+
+
+
+
+
+
Environments
+
+ Gestiona los entornos que aparecen como columnas de valores en la tabla de secretos.
+
+
+ {#if data.canManageEnvironments}
+
+ Nuevo
+
+ {/if}
+
+
+
+
+
+
+ {#each data.environments as environment, index (environment.id)}
+
+
+ {#if data.canManageEnvironments}
+
+ moveEnvironment(environment, 'up')}
+ class="rounded border border-slate-200 p-0.5 text-slate-500 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
+ aria-label="Subir entorno">
+ moveEnvironment(environment, 'down')}
+ class="rounded border border-slate-200 p-0.5 text-slate-500 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
+ aria-label="Bajar entorno">
+
+ {/if}
+
+
+ {environment.name}
+ {environment.slug}
+
+
+ {#if data.canManageEnvironments}
+ openEditEnvironment(environment)}
+ class="rounded-md border border-slate-200 p-2 text-slate-600 hover:bg-slate-50"
+ aria-label="Editar entorno">
+ {/if}
+ {#if data.canDeleteEnvironments}
+ (deletingEnvironment = environment)}
+ class="ml-2 rounded-md border border-red-200 p-2 text-red-600 hover:bg-red-50"
+ aria-label="Borrar entorno">
+ {/if}
+
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
Encryption
+
+ Soon
+
+
+ Proveedor usado para cifrar los secretos en reposo.
+
+
+
+ ({
+ id: provider.value,
+ name: provider.label,
+ }))}
+ value={encryptionProvider}
+ disabled
+ ariaLabel="Encryption provider"
+ on:change={(event) => (encryptionProvider = event.detail.id)}
+ />
+ Guardar
+
+
+
+
+
+{#if createEnvironmentOpen || editingEnvironment}
+
+{/if}
+
+{#if deletingEnvironment}
+
+
+
+ Borrar entorno
+
+ Se borrara {deletingEnvironment.name} de la configuracion del vault.
+
+ {#if formError}
+ {formError}
+
{/if}
+
+ (deletingEnvironment = null)}
+ class="rounded-md border border-slate-200 px-3 py-2 text-sm">Cancelar Borrar
+
+
+
+{/if}
diff --git a/static/favicon.png b/static/favicon.png
new file mode 100644
index 0000000..7ccff9a
Binary files /dev/null and b/static/favicon.png differ
diff --git a/static/icono_128.png b/static/icono_128.png
new file mode 100644
index 0000000..6ecb1ae
Binary files /dev/null and b/static/icono_128.png differ
diff --git a/static/icono_258.png b/static/icono_258.png
new file mode 100644
index 0000000..ffb74f7
Binary files /dev/null and b/static/icono_258.png differ
diff --git a/static/icono_512.png b/static/icono_512.png
new file mode 100644
index 0000000..412f6bf
Binary files /dev/null and b/static/icono_512.png differ
diff --git a/static/integrations/bitbucket.svg b/static/integrations/bitbucket.svg
new file mode 100644
index 0000000..6475973
--- /dev/null
+++ b/static/integrations/bitbucket.svg
@@ -0,0 +1 @@
+Bitbucket
\ No newline at end of file
diff --git a/static/integrations/django.svg b/static/integrations/django.svg
new file mode 100644
index 0000000..0365832
--- /dev/null
+++ b/static/integrations/django.svg
@@ -0,0 +1 @@
+Django
\ No newline at end of file
diff --git a/static/integrations/dotnet.svg b/static/integrations/dotnet.svg
new file mode 100644
index 0000000..e747771
--- /dev/null
+++ b/static/integrations/dotnet.svg
@@ -0,0 +1 @@
+.NET
\ No newline at end of file
diff --git a/static/integrations/express.svg b/static/integrations/express.svg
new file mode 100644
index 0000000..98b8e09
--- /dev/null
+++ b/static/integrations/express.svg
@@ -0,0 +1 @@
+Express
\ No newline at end of file
diff --git a/static/integrations/flask.svg b/static/integrations/flask.svg
new file mode 100644
index 0000000..4c32ea9
--- /dev/null
+++ b/static/integrations/flask.svg
@@ -0,0 +1 @@
+Flask
\ No newline at end of file
diff --git a/static/integrations/githubactions.svg b/static/integrations/githubactions.svg
new file mode 100644
index 0000000..2a75ee5
--- /dev/null
+++ b/static/integrations/githubactions.svg
@@ -0,0 +1 @@
+GitHub Actions
\ No newline at end of file
diff --git a/static/integrations/gitlab.svg b/static/integrations/gitlab.svg
new file mode 100644
index 0000000..509fa7f
--- /dev/null
+++ b/static/integrations/gitlab.svg
@@ -0,0 +1 @@
+GitLab
\ No newline at end of file
diff --git a/static/integrations/jenkins.svg b/static/integrations/jenkins.svg
new file mode 100644
index 0000000..9b8910f
--- /dev/null
+++ b/static/integrations/jenkins.svg
@@ -0,0 +1 @@
+Jenkins
\ No newline at end of file
diff --git a/static/integrations/nestjs.svg b/static/integrations/nestjs.svg
new file mode 100644
index 0000000..61770a7
--- /dev/null
+++ b/static/integrations/nestjs.svg
@@ -0,0 +1 @@
+NestJS
\ No newline at end of file
diff --git a/static/integrations/nextjs.svg b/static/integrations/nextjs.svg
new file mode 100644
index 0000000..558820d
--- /dev/null
+++ b/static/integrations/nextjs.svg
@@ -0,0 +1 @@
+Next.js
\ No newline at end of file
diff --git a/static/integrations/nuxt.svg b/static/integrations/nuxt.svg
new file mode 100644
index 0000000..024d246
--- /dev/null
+++ b/static/integrations/nuxt.svg
@@ -0,0 +1 @@
+Nuxt
\ No newline at end of file
diff --git a/static/integrations/react.svg b/static/integrations/react.svg
new file mode 100644
index 0000000..88b9c77
--- /dev/null
+++ b/static/integrations/react.svg
@@ -0,0 +1 @@
+React
\ No newline at end of file
diff --git a/static/integrations/remix.svg b/static/integrations/remix.svg
new file mode 100644
index 0000000..c001fa8
--- /dev/null
+++ b/static/integrations/remix.svg
@@ -0,0 +1 @@
+Remix
\ No newline at end of file
diff --git a/static/integrations/sveltekit.svg b/static/integrations/sveltekit.svg
new file mode 100644
index 0000000..a3f86d5
--- /dev/null
+++ b/static/integrations/sveltekit.svg
@@ -0,0 +1 @@
+Svelte
\ No newline at end of file
diff --git a/static/integrations/vite.svg b/static/integrations/vite.svg
new file mode 100644
index 0000000..c912945
--- /dev/null
+++ b/static/integrations/vite.svg
@@ -0,0 +1 @@
+Vite
\ No newline at end of file
diff --git a/static/logo.svg b/static/logo.svg
new file mode 100644
index 0000000..478a3b2
--- /dev/null
+++ b/static/logo.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/static/robots.txt b/static/robots.txt
new file mode 100644
index 0000000..b6dd667
--- /dev/null
+++ b/static/robots.txt
@@ -0,0 +1,3 @@
+# allow crawling everything by default
+User-agent: *
+Disallow: