From 5b7d768762caab8ff3f5013e070fdc2aedfb98f0 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 4 May 2026 02:00:45 -0700 Subject: [PATCH] Visible credential target selector for secret writes; SDK Result import cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `CredentialTargetSelector` to the secret-create dialog, exposing the full URL-context stack with the labels the plan in `notes/cloud-workspaces-and-global-sources-plan.md` calls for: - Only me in this workspace → user-workspace - Everyone in → workspace - Only me across this org → user-org - Everyone in → org Default selection is the URL context's active write scope (workspace in workspace context, org in global). Local CLI hosts run a single-scope stack and the selector renders one disabled option labeled with that scope. Server side, `assertScopedWrite` already rejects writes whose scope_id is outside the URL-resolved stack as a typed `StorageError`; the new `credential-target.node.test.ts` exercises both halves of the contract: - All four legal targets in workspace context succeed and list back tagged with the correct scope id. - A cross-org write from workspace context is rejected. Connection writes in v1 still default to the source-add form's configured `tokenScope` and `bindingScope` (per-user); a future change can layer this selector into the OAuth setup flow without changing the server contract. --- .../services/credential-target.node.test.ts | 94 +++++++++++ packages/core/sdk/src/executor.test.ts | 3 +- packages/react/src/pages/secrets.tsx | 19 ++- .../plugins/credential-target-selector.tsx | 158 ++++++++++++++++++ 4 files changed, 270 insertions(+), 4 deletions(-) create mode 100644 apps/cloud/src/services/credential-target.node.test.ts create mode 100644 packages/react/src/plugins/credential-target-selector.tsx diff --git a/apps/cloud/src/services/credential-target.node.test.ts b/apps/cloud/src/services/credential-target.node.test.ts new file mode 100644 index 000000000..74fce732e --- /dev/null +++ b/apps/cloud/src/services/credential-target.node.test.ts @@ -0,0 +1,94 @@ +// Credential write-target invariants — secrets and connections accept any +// scope in the URL context's stack (4 levels in workspace context, 2 in +// global), and reject scopes outside the stack with a typed storage +// failure. Mirrors `secrets-isolation.e2e.node.test.ts` for cross-org +// rejections, but exercises the full personal/shared cross product within +// a single workspace context. + +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { SecretId } from "@executor-js/sdk"; + +import { + asWorkspace, + asWorkspaceUser, + orgScopeId, + testUserOrgScopeId, + testUserWorkspaceScopeId, + testWorkspaceScopeId, +} from "./__test-harness__/api-harness"; + +const setSecret = ( + client: Parameters[2]>[0], + scopeId: string, + id: string, + value: string, +) => + client.secrets.set({ + params: { scopeId: scopeId as never }, + payload: { + id: SecretId.make(id), + name: id, + value, + }, + }); + +describe("credential write targets in workspace context", () => { + it.effect( + "secrets land at every scope in the URL-resolved stack and list back tagged with that scope", + () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const slug = `ws_${crypto.randomUUID().slice(0, 8)}`; + const userId = `u_${crypto.randomUUID().slice(0, 8)}`; + const wsScope = testWorkspaceScopeId(org, slug); + const orgScope = orgScopeId(org); + const userOrg = testUserOrgScopeId(userId, org); + const userWs = testUserWorkspaceScopeId(userId, org, slug); + + // One secret per scope, distinct ids so they don't dedup. + yield* asWorkspaceUser(userId, org, slug, (client) => + Effect.gen(function* () { + yield* setSecret(client, userWs, "uws", "uws-val"); + yield* setSecret(client, wsScope, "ws", "ws-val"); + yield* setSecret(client, userOrg, "uorg", "uorg-val"); + yield* setSecret(client, orgScope, "org", "org-val"); + }), + ); + + // Listing from the workspace scope walks the full stack — all 4 + // secrets show up, each tagged with its owning scope. + const list = yield* asWorkspaceUser(userId, org, slug, (client) => + client.secrets.list({ params: { scopeId: wsScope } }), + ); + const byId = new Map(list.map((r) => [r.id, r.scopeId])); + expect(byId.get(SecretId.make("uws"))).toBe(userWs); + expect(byId.get(SecretId.make("ws"))).toBe(wsScope); + expect(byId.get(SecretId.make("uorg"))).toBe(userOrg); + expect(byId.get(SecretId.make("org"))).toBe(orgScope); + }), + ); + + it.effect( + "secret writes targeting an out-of-stack scope are rejected", + () => + Effect.gen(function* () { + const orgA = `org_${crypto.randomUUID()}`; + const orgB = `org_${crypto.randomUUID()}`; + const slugA = `ws_${crypto.randomUUID().slice(0, 8)}`; + + // From workspace context for orgA, try to write a secret + // targeting orgB's scope. The scoped adapter rejects writes whose + // `scope_id` isn't in the executor's stack — the cloud's + // `secrets-isolation.e2e.node.test.ts` covers the org boundary; + // this case adds the workspace-context wrapper for parity. + const exit = yield* Effect.exit( + asWorkspace(orgA, slugA, (client) => + setSecret(client, orgScopeId(orgB), "leak", "v"), + ), + ); + expect(exit._tag).toBe("Failure"); + }), + ); +}); diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index 3b530e261..f88738409 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -501,8 +501,7 @@ describe("createExecutor", () => { "personal-test" ].registerAt(personalScope).pipe(Effect.exit); expect(exit._tag).toBe("Failure"); - const err = Result.isFailure(exit) ? exit.cause : null; - const errStr = JSON.stringify(err); + const errStr = JSON.stringify(exit); expect(errStr).toContain("InvalidSourceWriteTargetError"); // Same call to a non-personal scope (the org) succeeds. diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index bda382c98..0ef231518 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -6,6 +6,10 @@ import { secretWriteKeys } from "../api/reactivity-keys"; import { useSecretProviderPlugins } from "@executor-js/sdk/client"; import { SecretId } from "@executor-js/sdk"; import { useActiveWriteScopeId } from "../hooks/use-scope"; +import { + CredentialTargetSelector, + useCredentialTargetState, +} from "../plugins/credential-target-selector"; import { Dialog, DialogContent, @@ -72,7 +76,11 @@ function AddSecretDialog(props: { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const scopeId = useActiveWriteScopeId(); + // The credential-target selector defaults to the URL context's active + // write scope (workspace in workspace context, org in global). Users + // can flip to "Only me here" / "Only me org-wide" without leaving the + // dialog. + const target = useCredentialTargetState(); const doSet = useAtomSet(setSecret, { mode: "promise" }); const reset = () => { @@ -90,7 +98,7 @@ function AddSecretDialog(props: { setError(null); try { await doSet({ - params: { scopeId }, + params: { scopeId: target.value }, payload: { id: SecretId.make(id.trim()), name: name.trim(), @@ -174,6 +182,13 @@ function AddSecretDialog(props: { /> + +
{props.storageOptions.length > 1 && (
diff --git a/packages/react/src/plugins/credential-target-selector.tsx b/packages/react/src/plugins/credential-target-selector.tsx new file mode 100644 index 000000000..9ae372859 --- /dev/null +++ b/packages/react/src/plugins/credential-target-selector.tsx @@ -0,0 +1,158 @@ +import * as React from "react"; +import { Label } from "../components/label"; +import { NativeSelect, NativeSelectOption } from "../components/native-select"; +import type { ScopeId } from "@executor-js/sdk"; + +import { useActiveWriteScopeId, useScopeStack } from "../api/scope-context"; + +// --------------------------------------------------------------------------- +// CredentialTargetSelector — visible chooser for the scope a credential +// (secret / connection / policy) write should land at. Unlike source +// definitions, credentials are valid at every scope in the URL context's +// stack, including the personal scopes. The plan in +// `notes/cloud-workspaces-and-global-sources-plan.md` calls out four +// labels: +// +// - Only me in this workspace → user-workspace +// - Everyone in this workspace → workspace +// - Only me across this org → user-org +// - Everyone in this org → org +// +// In global context only the latter two are visible. The default +// selection is the URL context's active write scope (`org` global, +// `workspace` workspace) — pre-fills a "team-wide" target while still +// letting the user opt into a personal override. +// +// Local CLI hosts have a single-scope stack; the selector renders a single +// option labeled with the scope's display name and disables the dropdown. +// --------------------------------------------------------------------------- + +export interface CredentialTargetOption { + readonly scopeId: ScopeId; + readonly label: string; + /** Hint for ordering / grouping. Not used for matching. */ + readonly kind: "user-workspace" | "workspace" | "user-org" | "org" | "other"; +} + +const kindFor = (id: string): CredentialTargetOption["kind"] => { + if (id.startsWith("user_workspace_")) return "user-workspace"; + if (id.startsWith("workspace_")) return "workspace"; + if (id.startsWith("user_org_")) return "user-org"; + if (id.startsWith("org_")) return "org"; + return "other"; +}; + +const labelFor = (id: string, name: string, kind: CredentialTargetOption["kind"]): string => { + switch (kind) { + case "user-workspace": + return "Only me in this workspace"; + case "workspace": + return `Everyone in ${name}`; + case "user-org": + return "Only me across this org"; + case "org": + return `Everyone in ${name}`; + default: + return name; + } +}; + +/** + * Returns the legal credential targets for the current URL context, in + * display order: most personal → most shared. In a workspace context that + * is `[user-workspace, workspace, user-org, org]`; in global it is + * `[user-org, org]`. The cloud's executor stack already lists scopes + * innermost-first, so this is just a relabel + label-merge. + */ +export function useCredentialTargetOptions(): readonly CredentialTargetOption[] { + const stack = useScopeStack(); + return React.useMemo(() => { + const options: CredentialTargetOption[] = []; + for (const entry of stack) { + const kind = kindFor(entry.id); + options.push({ + scopeId: entry.id, + kind, + label: labelFor(entry.id, entry.name, kind), + }); + } + return options; + }, [stack]); +} + +export interface CredentialTargetSelectorProps { + readonly value: ScopeId; + readonly onChange: (next: ScopeId) => void; + readonly disabled?: boolean; + /** Override the default label "Save to". */ + readonly label?: string; + readonly id?: string; +} + +/** + * Visible target selector for credential write forms (secrets, connection + * tokens, policies). Always renders even when there's only one option — + * the selector documents the explicit target and matches the plan's + * "no hidden defaults" invariant. + */ +export function CredentialTargetSelector(props: CredentialTargetSelectorProps) { + const options = useCredentialTargetOptions(); + const fallbackId = useId(); + const id = props.id ?? fallbackId; + + if (options.length === 0) { + return null; + } + + return ( +
+ + props.onChange(e.target.value as ScopeId)} + > + {options.map((opt) => ( + + {opt.label} + + ))} + +
+ ); +} + +/** + * Hook for managed credential-target state. Returns the selected target + * plus a setter, defaulting to the URL context's active write scope. The + * default lines up with "team-wide" (workspace in workspace context, org + * global). Callers pass `value` into the API call's `params.scopeId` and + * render `` over `value` + `setValue`. + */ +export function useCredentialTargetState(): { + readonly value: ScopeId; + readonly setValue: (next: ScopeId) => void; + readonly options: readonly CredentialTargetOption[]; +} { + const defaultId = useActiveWriteScopeId(); + const options = useCredentialTargetOptions(); + const [value, setValue] = React.useState(defaultId); + React.useEffect(() => { + if (!options.some((o) => o.scopeId === value)) { + setValue(defaultId); + } + }, [defaultId, options, value]); + return { value, setValue, options }; +} + +function useId(): string { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + const useIdImpl = (React as any).useId as (() => string) | undefined; + const ref = React.useRef(null); + if (useIdImpl) return useIdImpl(); + if (ref.current === null) { + ref.current = `credential-target-${Math.random().toString(36).slice(2)}`; + } + return ref.current; +}