Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions apps/cloud/src/services/secrets-api.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,120 @@ describe("secrets api (HTTP)", () => {
expect(second.find((s) => s.id === id)?.name).toBe("updated");
}),
);

it.effect("update changes the secret display name", () =>
Effect.gen(function* () {
const org = `org_${crypto.randomUUID()}`;
const id = `sec_${crypto.randomUUID().slice(0, 8)}`;

// Create the secret
yield* asOrg(org, (client) =>
client.secrets.set({
params: { scopeId: ScopeId.make(org) },
payload: { id: SecretId.make(id), name: "Original Name", value: "secret-value" },
}),
);

// Update the name via PATCH
const updated = yield* asOrg(org, (client) =>
client.secrets.update({
params: { scopeId: ScopeId.make(org), secretId: SecretId.make(id) },
payload: { name: "Updated Name" },
}),
);
expect(updated.id).toBe(id);
expect(updated.name).toBe("Updated Name");

// Verify the list reflects the updated name
const list = yield* asOrg(org, (client) =>
client.secrets.list({ params: { scopeId: ScopeId.make(org) } }),
);
expect(list.find((s) => s.id === id)?.name).toBe("Updated Name");
}),
);

it.effect("update on an unknown id returns 404", () =>
Effect.gen(function* () {
const org = `org_${crypto.randomUUID()}`;
const missing = `missing_${crypto.randomUUID().slice(0, 8)}`;

const result = yield* asOrg(org, (client) =>
client.secrets
.update({
params: { scopeId: ScopeId.make(org), secretId: SecretId.make(missing) },
payload: { name: "New Name" },
})
.pipe(Effect.result),
);
expect(Result.isFailure(result)).toBe(true);
}),
);

it.effect("update changes the secret value", () =>
Effect.gen(function* () {
const org = `org_${crypto.randomUUID()}`;
const id = `sec_${crypto.randomUUID().slice(0, 8)}`;

// Create the secret with initial value
yield* asOrg(org, (client) =>
client.secrets.set({
params: { scopeId: ScopeId.make(org) },
payload: { id: SecretId.make(id), name: "API Key", value: "initial-secret-value" },
}),
);

// Update the value via PATCH
const updated = yield* asOrg(org, (client) =>
client.secrets.update({
params: { scopeId: ScopeId.make(org), secretId: SecretId.make(id) },
payload: { value: "updated-secret-value" },
}),
);
expect(updated.id).toBe(id);
// Name should remain unchanged
expect(updated.name).toBe("API Key");

// Verify the secret status is still resolved
const status = yield* asOrg(org, (client) =>
client.secrets.status({
params: { scopeId: ScopeId.make(org), secretId: SecretId.make(id) },
}),
);
expect(status.status).toBe("resolved");

// Verify the response doesn't contain the secret value
expect(JSON.stringify(updated)).not.toContain("updated-secret-value");
}),
);

it.effect("update changes both name and value", () =>
Effect.gen(function* () {
const org = `org_${crypto.randomUUID()}`;
const id = `sec_${crypto.randomUUID().slice(0, 8)}`;

// Create the secret
yield* asOrg(org, (client) =>
client.secrets.set({
params: { scopeId: ScopeId.make(org) },
payload: { id: SecretId.make(id), name: "Original Name", value: "original-value" },
}),
);

// Update both name and value via PATCH
const updated = yield* asOrg(org, (client) =>
client.secrets.update({
params: { scopeId: ScopeId.make(org), secretId: SecretId.make(id) },
payload: { name: "Updated Name", value: "new-value" },
}),
);
expect(updated.id).toBe(id);
expect(updated.name).toBe("Updated Name");

// Verify the list reflects the updated name
const list = yield* asOrg(org, (client) =>
client.secrets.list({ params: { scopeId: ScopeId.make(org) } }),
);
expect(list.find((s) => s.id === id)?.name).toBe("Updated Name");
}),
);
});
67 changes: 40 additions & 27 deletions apps/cloud/src/web/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ import {
} from "@executor-js/react/components/dropdown-menu";
import { SourceFavicon } from "@executor-js/react/components/source-favicon";
import { CommandPalette } from "@executor-js/react/components/command-palette";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@executor-js/react/components/tooltip";
import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
import { AUTH_PATHS } from "../auth/api";
import { organizationsAtom, switchOrganization, useAuth } from "./auth";
Expand Down Expand Up @@ -96,33 +102,40 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) {
No sources yet
</div>
) : (
<div className="flex flex-col gap-px">
{value.map((s) => {
const detailPath = `/sources/${s.id}`;
const active =
props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`);
return (
<Link
key={s.id}
to="/sources/$namespace"
params={{ namespace: s.id }}
onClick={props.onNavigate}
className={[
"group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors",
active
? "bg-sidebar-active text-foreground font-medium"
: "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
].join(" ")}
>
<SourceFavicon url={s.url} />
<span className="flex-1 truncate">{s.name}</span>
<span className="rounded bg-secondary/50 px-1 py-px text-xs font-medium text-muted-foreground">
{s.kind}
</span>
</Link>
);
})}
</div>
<TooltipProvider delayDuration={0}>
<div className="flex flex-col gap-px">
{value.map((s) => {
const detailPath = `/sources/${s.id}`;
const active =
props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`);
return (
<Link
key={s.id}
to="/sources/$namespace"
params={{ namespace: s.id }}
onClick={props.onNavigate}
className={[
"group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors",
active
? "bg-sidebar-active text-foreground font-medium"
: "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
].join(" ")}
>
<SourceFavicon url={s.url} />
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-1 truncate">{s.name}</span>
</TooltipTrigger>
<TooltipContent side="right">{s.name}</TooltipContent>
</Tooltip>
<span className="rounded bg-secondary/50 px-1 py-px text-xs font-medium text-muted-foreground">
{s.kind}
</span>
</Link>
);
})}
</div>
</TooltipProvider>
),
});
}
Expand Down
67 changes: 40 additions & 27 deletions apps/local/src/web/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context";
import { Button } from "@executor-js/react/components/button";
import { SourceFavicon } from "@executor-js/react/components/source-favicon";
import { CommandPalette } from "@executor-js/react/components/command-palette";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@executor-js/react/components/tooltip";
import { useClientPlugins } from "@executor-js/sdk/client";

// ── Env ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -280,33 +286,40 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) {
No sources yet
</div>
) : (
<div className="flex flex-col gap-px">
{value.map((s) => {
const detailPath = `/sources/${s.id}`;
const active =
props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`);
return (
<Link
key={s.id}
to="/sources/$namespace"
params={{ namespace: s.id }}
onClick={props.onNavigate}
className={[
"group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors",
active
? "bg-sidebar-active text-foreground font-medium"
: "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
].join(" ")}
>
<SourceFavicon url={s.url} />
<span className="flex-1 truncate">{s.name}</span>
<span className="rounded bg-secondary/50 px-1 py-px text-xs font-medium text-muted-foreground">
{s.kind}
</span>
</Link>
);
})}
</div>
<TooltipProvider delayDuration={0}>
<div className="flex flex-col gap-px">
{value.map((s) => {
const detailPath = `/sources/${s.id}`;
const active =
props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`);
return (
<Link
key={s.id}
to="/sources/$namespace"
params={{ namespace: s.id }}
onClick={props.onNavigate}
className={[
"group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors",
active
? "bg-sidebar-active text-foreground font-medium"
: "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
].join(" ")}
>
<SourceFavicon url={s.url} />
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-1 truncate">{s.name}</span>
</TooltipTrigger>
<TooltipContent side="right">{s.name}</TooltipContent>
</Tooltip>
<span className="rounded bg-secondary/50 px-1 py-px text-xs font-medium text-muted-foreground">
{s.kind}
</span>
</Link>
);
})}
</div>
</TooltipProvider>
),
});
}
Expand Down
18 changes: 17 additions & 1 deletion packages/core/api/src/handlers/secrets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { Effect } from "effect";
import { RemoveSecretInput, SetSecretInput, type SecretRef } from "@executor-js/sdk";
import { RemoveSecretInput, SetSecretInput, UpdateSecretInput, type SecretRef } from "@executor-js/sdk";

import { ExecutorApi } from "../api";
import { ExecutorService } from "../services";
Expand Down Expand Up @@ -60,6 +60,22 @@ export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (han
}),
),
)
.handle("update", ({ params: path, payload }) =>
capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
const ref = yield* executor.secrets.update(
new UpdateSecretInput({
id: path.secretId,
scope: path.scopeId,
name: payload.name,
value: payload.value,
}),
);
return refToResponse(ref);
}),
),
)
.handle("remove", ({ params: path }) =>
capture(
Effect.gen(function* () {
Expand Down
13 changes: 13 additions & 0 deletions packages/core/api/src/secrets/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ const SetSecretPayload = Schema.Struct({
provider: Schema.optional(Schema.String),
});

const UpdateSecretPayload = Schema.Struct({
name: Schema.optional(Schema.String),
value: Schema.optional(Schema.String),
});

// ---------------------------------------------------------------------------
// Error schemas with HTTP status annotations
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -86,6 +91,14 @@ export const SecretsApi = HttpApiGroup.make("secrets")
error: [InternalError, SecretResolution],
}),
)
.add(
HttpApiEndpoint.patch("update", "/scopes/:scopeId/secrets/:secretId", {
params: SecretParams,
payload: UpdateSecretPayload,
success: SecretRefResponse,
error: [InternalError, SecretNotFound],
}),
)
.add(
HttpApiEndpoint.delete("remove", "/scopes/:scopeId/secrets/:secretId", {
params: SecretParams,
Expand Down
1 change: 1 addition & 0 deletions packages/core/execution/src/promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({
getAtScope: (id, scope) => fromPromise(() => pe.secrets.getAtScope(id, scope)),
status: (id) => fromPromise(() => pe.secrets.status(id)),
set: (input) => fromPromise(() => pe.secrets.set(input)),
update: (input) => fromPromise(() => pe.secrets.update(input)),
remove: (input) => fromPromise(() => pe.secrets.remove(input)),
list: () => fromPromise(() => pe.secrets.list()),
listAll: () => fromPromise(() => pe.secrets.listAll()),
Expand Down
Loading