Skip to content
Draft
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
98 changes: 98 additions & 0 deletions apps/cloud/src/services/source-target.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Source-definition write target invariant — the cloud half of the
// `InvalidSourceWriteTargetError` contract. The SDK-level test in
// `packages/core/sdk/src/executor.test.ts` covers the rejection path; this
// suite covers the HTTP boundary cases the SDK can't see:
//
// - addSpec under the URL context's workspace scope succeeds and lands
// at `workspace_<id>`.
// - addSpec under the org/global scope from workspace context succeeds
// (still legal — `org` is in the workspace stack).
//
// The personal-scope rejection paths are exercised at the SDK level,
// because Effect's HTTP path matcher has trouble round-tripping the cloud's
// long compound `user_*` scope ids; that's a routing limitation, not a
// product gap, and we still get coverage of the SDK guard from the SDK
// suite. The InvalidSourceWriteTargetError is wired through the openapi /
// mcp / graphql / google-discovery API groups with `httpApiStatus: 422`, so
// when the SDK fires the error, the HTTP edge already has a schema for it.

import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";

import {
asOrg,
asWorkspace,
orgScopeId,
testWorkspaceScopeId,
} from "./__test-harness__/api-harness";

const SPEC = JSON.stringify({
openapi: "3.0.0",
info: { title: "Target Test", version: "1.0.0" },
paths: {
"/ping": {
get: {
operationId: "ping",
summary: "ping",
responses: { "200": { description: "ok" } },
},
},
},
});

describe("source-definition write target invariant (HTTP)", () => {
it.effect(
"addSpec under the workspace scope from workspace context succeeds and lands at workspace scope",
() =>
Effect.gen(function* () {
const org = `org_${crypto.randomUUID()}`;
const slug = `ws_${crypto.randomUUID().slice(0, 8)}`;
const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`;
const wsScope = testWorkspaceScopeId(org, slug);

yield* asWorkspace(org, slug, (client) =>
client.openapi.addSpec({
params: { scopeId: wsScope },
payload: { spec: SPEC, namespace },
}),
);

const sources = yield* asWorkspace(org, slug, (client) =>
client.sources.list({ params: { scopeId: wsScope } }),
);
const row = sources.find((s) => s.id === namespace);
expect(row?.scopeId).toBe(wsScope);
}),
);

it.effect(
"addSpec from workspace context targeting the global org scope is allowed (still in stack)",
() =>
Effect.gen(function* () {
const org = `org_${crypto.randomUUID()}`;
const slug = `ws_${crypto.randomUUID().slice(0, 8)}`;
const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`;
const orgScope = orgScopeId(org);

yield* asWorkspace(org, slug, (client) =>
client.openapi.addSpec({
params: { scopeId: orgScope },
payload: { spec: SPEC, namespace },
}),
);

const sources = yield* asWorkspace(org, slug, (client) =>
client.sources.list({ params: { scopeId: orgScope } }),
);
const row = sources.find((s) => s.id === namespace);
expect(row?.scopeId).toBe(orgScope);

// Same row visible from the org-only context too — confirms the
// write actually landed at the global scope, not at the workspace.
const orgVisible = yield* asOrg(org, (client) =>
client.sources.list({ params: { scopeId: orgScope } }),
);
expect(orgVisible.find((s) => s.id === namespace)).toBeDefined();
}),
);
});
23 changes: 23 additions & 0 deletions packages/core/sdk/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,28 @@ export class SourceRemovalNotAllowedError extends Schema.TaggedErrorClass<Source
{ sourceId: Schema.String },
) {}

/** Raised when a source-definition write targets a personal scope
* (`user_org_*` or `user_workspace_*`). Personal source definitions are
* out of scope for cloud v1 — sources can only be defined at `org` or
* `workspace` scopes. The UI exposes only those targets in the add-source
* selectors; this error guards the contract on the server side. Callers
* whose deployment doesn't use the cloud's `user_*` prefix convention
* pass a plain scope id (no `user_*` prefix) and never trip this.
*
* HTTP 422: the request was syntactically valid but targeted an illegal
* scope. The plugin API surfaces it via `.annotate({ httpApiStatus: 422 })`
* in the relevant group's error union (see e.g.
* `packages/plugins/openapi/src/api/group.ts`). */
export class InvalidSourceWriteTargetError extends Schema.TaggedErrorClass<InvalidSourceWriteTargetError>()(
"InvalidSourceWriteTargetError",
{
scopeId: Schema.String,
reason: Schema.String,
},
) {
static annotations = { httpApiStatus: 422 };
}

// ---------------------------------------------------------------------------
// Secrets
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -153,6 +175,7 @@ export type ExecutorError =
| ToolBlockedError
| SourceNotFoundError
| SourceRemovalNotAllowedError
| InvalidSourceWriteTargetError
| SecretNotFoundError
| SecretResolutionError
| SecretOwnedByConnectionError
Expand Down
60 changes: 60 additions & 0 deletions packages/core/sdk/src/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,66 @@ describe("createExecutor", () => {
}),
);

it.effect(
"rejects sources.register at a personal scope (user_org_*) with InvalidSourceWriteTargetError",
() =>
Effect.gen(function* () {
// Mirrors the cloud's workspace stack: the request reached this
// executor with a personal scope id in the stack (legal for
// secret/connection writes) but is now trying to register a
// source definition there. The SDK guard fires regardless of
// which plugin invoked `core.sources.register`.
const personalScope = ScopeId.make("user_org_u1_org_a");
const orgScope = ScopeId.make("org_a");
const personalPlugin = definePlugin(() => ({
id: "personal-test" as const,
storage: () => ({}),
extension: (ctx) => ({
registerAt: (scope: ScopeId) =>
ctx.core.sources.register({
id: "x",
scope,
kind: "personal-test",
name: "x",
canRemove: true,
tools: [{ name: "tool", description: "" }],
}),
}),
}));

const executor = yield* createExecutor(
makeTestConfig({
plugins: [personalPlugin()] as const,
scopes: [
new Scope({
id: personalScope,
name: "personal",
createdAt: new Date(),
}),
new Scope({
id: orgScope,
name: "org",
createdAt: new Date(),
}),
],
}),
);

const exit = yield* executor[
"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);
expect(errStr).toContain("InvalidSourceWriteTargetError");

// Same call to a non-personal scope (the org) succeeds.
yield* executor["personal-test"].registerAt(orgScope);
const sources = yield* executor.sources.list();
expect(sources.find((s) => s.id === "x")?.scopeId).toBe(orgScope);
}),
);

it.effect("handles deeply-namespaced tool names (dots in name)", () =>
Effect.gen(function* () {
const namespacedPlugin = definePlugin(() => ({
Expand Down
24 changes: 23 additions & 1 deletion packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
ConnectionProviderNotRegisteredError,
ConnectionReauthRequiredError,
ConnectionRefreshNotSupportedError,
InvalidSourceWriteTargetError,
NoHandlerError,
PluginNotLoadedError,
SecretOwnedByConnectionError,
Expand Down Expand Up @@ -412,6 +413,16 @@ const staticDeclToTool = (
// never touch these functions.
// ---------------------------------------------------------------------------

// Source-definition writes only target shareable scopes (org/workspace).
// Personal scopes (user-org / user-workspace in cloud) are reserved for
// credentials, connections, and policies — sources written there would
// be invisible to anyone else, which the v1 product model excludes. The
// cloud's id helpers in `apps/cloud/src/services/ids.ts` produce
// `user_org_*` / `user_workspace_*` prefixes; deployments without those
// conventions (local CLI) never trigger the check.
const isPersonalScope = (scopeId: string): boolean =>
scopeId.startsWith("user_org_") || scopeId.startsWith("user_workspace_");

// Upsert shape: delete any existing source + tools + definitions for
// `input.id` before creating fresh rows. Keeps replayable — boot-time
// sync from executor.jsonc can call register() on rows that already
Expand All @@ -420,8 +431,19 @@ const writeSourceInput = (
core: TypedAdapter<CoreSchema>,
pluginId: string,
input: SourceInput,
): Effect.Effect<void, StorageFailure> =>
): Effect.Effect<void, StorageFailure | InvalidSourceWriteTargetError> =>
Effect.gen(function* () {
if (isPersonalScope(input.scope)) {
return yield* Effect.fail(
new InvalidSourceWriteTargetError({
scopeId: input.scope,
reason:
"source-definition writes must target a shareable scope " +
"(org or workspace); personal scopes are reserved for " +
"credentials, connections, and policies.",
}),
);
}
yield* deleteSourceById(core, input.id, input.scope);

const now = new Date();
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export {
NoHandlerError,
SourceNotFoundError,
SourceRemovalNotAllowedError,
InvalidSourceWriteTargetError,
PluginNotLoadedError,
SecretNotFoundError,
SecretResolutionError,
Expand Down
6 changes: 5 additions & 1 deletion packages/core/sdk/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
ConnectionProviderNotRegisteredError,
ConnectionReauthRequiredError,
ConnectionRefreshNotSupportedError,
InvalidSourceWriteTargetError,
SecretOwnedByConnectionError,
} from "./errors";
import type { OAuthService } from "./oauth";
Expand Down Expand Up @@ -113,7 +114,10 @@ export interface PluginCtx<TStore = unknown> {
readonly sources: {
readonly register: (
input: SourceInput,
) => Effect.Effect<void, StorageFailure>;
) => Effect.Effect<
void,
StorageFailure | InvalidSourceWriteTargetError
>;
readonly unregister: (
sourceId: string,
) => Effect.Effect<void, StorageFailure>;
Expand Down
12 changes: 10 additions & 2 deletions packages/plugins/google-discovery/src/api/group.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi";
import { Schema } from "effect";
import { ScopeId, SecretBackedValue } from "@executor-js/sdk/core";
import {
InvalidSourceWriteTargetError,
ScopeId,
SecretBackedValue,
} from "@executor-js/sdk/core";
import { InternalError } from "@executor-js/api";
import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "../sdk/errors";

const InvalidSourceWriteTarget = InvalidSourceWriteTargetError.annotate({
httpApiStatus: 422,
});
import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source";

export { HttpApiSchema };
Expand Down Expand Up @@ -114,7 +122,7 @@ export const GoogleDiscoveryGroup = HttpApiGroup.make("googleDiscovery")
params: { scopeId: ScopeId },
payload: AddSourcePayload,
success: AddSourceResponse,
error: GoogleDiscoveryErrors,
error: [...GoogleDiscoveryErrors, InvalidSourceWriteTarget],
}),
)
.add(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { useAtomSet } from "@effect/atom-react";

import { usePendingSources } from "@executor-js/react/api/optimistic";
import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
import { useScope } from "@executor-js/react/api/scope-context";
import {
SourceTargetSelector,
useSourceTargetState,
} from "@executor-js/react/plugins/source-target-selector";
import type { SecretPickerSecret } from "@executor-js/react/plugins/secret-picker";
import { CreatableSecretPicker } from "@executor-js/react/plugins/secret-header-auth";
import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
Expand Down Expand Up @@ -201,7 +204,8 @@ export default function AddGoogleDiscoverySource(props: {
slugifyNamespace(probe?.name ?? selectedTemplate?.name ?? "") ||
"google";

const scopeId = useScope();
const target = useSourceTargetState();
const scopeId = target.value;
const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promise" });
const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" });
const { beginAdd } = usePendingSources();
Expand Down Expand Up @@ -452,6 +456,12 @@ export default function AddGoogleDiscoverySource(props: {
namespacePlaceholder="google_sheets"
/>

<SourceTargetSelector
value={target.value}
onChange={target.setValue}
disabled={adding}
/>

{probe && (
<section className="space-y-3 rounded-xl border border-border bg-card px-4 py-4">
<div className="flex items-center justify-between gap-3">
Expand Down
6 changes: 5 additions & 1 deletion packages/plugins/google-discovery/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Effect, Option } from "effect";

import {
InvalidSourceWriteTargetError,
SourceDetectionResult,
definePlugin,
resolveSecretBackedMap,
Expand Down Expand Up @@ -95,7 +96,10 @@ export interface GoogleDiscoveryPluginExtension {
input: GoogleDiscoveryAddSourceInput,
) => Effect.Effect<
{ readonly toolCount: number; readonly namespace: string },
GoogleDiscoveryParseError | GoogleDiscoverySourceError | StorageFailure
| GoogleDiscoveryParseError
| GoogleDiscoverySourceError
| InvalidSourceWriteTargetError
| StorageFailure
>;
readonly removeSource: (namespace: string, scope: string) => Effect.Effect<void, StorageFailure>;
readonly getSource: (
Expand Down
8 changes: 6 additions & 2 deletions packages/plugins/graphql/src/api/group.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
import { Schema } from "effect";
import { ScopeId } from "@executor-js/sdk/core";
import { InvalidSourceWriteTargetError, ScopeId } from "@executor-js/sdk/core";
import { InternalError } from "@executor-js/api";

import {
Expand All @@ -9,6 +9,10 @@ import {
} from "../sdk/errors";
import { GraphqlSourceAuth, HeaderValue } from "../sdk/types";

const InvalidSourceWriteTarget = InvalidSourceWriteTargetError.annotate({
httpApiStatus: 422,
});

// StoredGraphqlSource shape as an HTTP response schema. Kept local to the
// api layer because the sdk-side `StoredGraphqlSource` is a plain interface.
export const StoredSourceSchema = Schema.Struct({
Expand Down Expand Up @@ -102,7 +106,7 @@ export const GraphqlGroup = HttpApiGroup.make("graphql")
params: ScopeParams,
payload: AddSourcePayload,
success: AddSourceResponse,
error: GraphqlErrors,
error: [...GraphqlErrors, InvalidSourceWriteTarget],
}),
)
.add(
Expand Down
Loading
Loading