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
149 changes: 149 additions & 0 deletions packages/core/sdk/src/http-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, expect, it } from "@effect/vitest";
import { Data, Effect } from "effect";

import { CredentialBindingRef } from "./credential-bindings";
import { prepareHttpCredentialMap, resolveConfiguredHttpCredentialMap } from "./http-credentials";
import { ConnectionId, CredentialBindingId, ScopeId, SecretId } from "./ids";

class TestCredentialError extends Data.TaggedError("TestCredentialError")<{
readonly message: string;
}> {}

describe("http credential helpers", () => {
it.effect("prepares direct secret inputs into configured credential bindings", () =>
Effect.sync(() => {
const prepared = prepareHttpCredentialMap({
values: {
Authorization: {
secretId: "api-token",
prefix: "Bearer ",
targetScope: ScopeId.make("user"),
secretScopeId: ScopeId.make("org"),
},
"X-Static": "static",
"X-Slot": { kind: "binding", slot: "header:x-slot" },
},
slotForName: (name) => `header:${name.toLowerCase()}`,
});

expect(prepared.values).toEqual({
Authorization: {
kind: "binding",
slot: "header:authorization",
prefix: "Bearer ",
},
"X-Static": "static",
"X-Slot": { kind: "binding", slot: "header:x-slot" },
});
expect(prepared.bindings).toEqual([
{
slotKey: "header:authorization",
targetScope: ScopeId.make("user"),
value: {
kind: "secret",
secretId: SecretId.make("api-token"),
secretScopeId: ScopeId.make("org"),
},
},
]);
}),
);

it.effect("resolves configured text and secret bindings", () =>
Effect.gen(function* () {
const now = new Date("2026-01-01T00:00:00.000Z");
const bindings = [
CredentialBindingRef.make({
id: CredentialBindingId.make("secret-binding"),
pluginId: "test",
sourceId: "source",
sourceScopeId: ScopeId.make("org"),
scopeId: ScopeId.make("user"),
slotKey: "header:authorization",
value: { kind: "secret", secretId: SecretId.make("api-token") },
createdAt: now,
updatedAt: now,
}),
CredentialBindingRef.make({
id: CredentialBindingId.make("text-binding"),
pluginId: "test",
sourceId: "source",
sourceScopeId: ScopeId.make("org"),
scopeId: ScopeId.make("user"),
slotKey: "query_param:mode",
value: { kind: "text", text: "fast" },
createdAt: now,
updatedAt: now,
}),
];

const resolved = yield* resolveConfiguredHttpCredentialMap({
credentialBindings: {
listForSource: () => Effect.succeed(bindings),
},
pluginId: "test",
sourceId: "source",
sourceScope: ScopeId.make("org"),
values: {
Authorization: {
kind: "binding",
slot: "header:authorization",
prefix: "Bearer ",
},
mode: { kind: "binding", slot: "query_param:mode" },
},
getSecretAtScope: (secretId, scopeId) =>
Effect.succeed(
secretId === SecretId.make("api-token") && scopeId === ScopeId.make("user")
? "token"
: null,
),
onMissingBinding: (name) => new TestCredentialError({ message: `missing binding ${name}` }),
onMissingSecret: (name) => new TestCredentialError({ message: `missing secret ${name}` }),
});

expect(resolved).toEqual({
Authorization: "Bearer token",
mode: "fast",
});
}),
);

it.effect("treats connection bindings as missing for HTTP credential values", () =>
Effect.gen(function* () {
const now = new Date("2026-01-01T00:00:00.000Z");
const failure = yield* resolveConfiguredHttpCredentialMap({
credentialBindings: {
listForSource: () =>
Effect.succeed([
CredentialBindingRef.make({
id: CredentialBindingId.make("connection-binding"),
pluginId: "test",
sourceId: "source",
sourceScopeId: ScopeId.make("org"),
scopeId: ScopeId.make("user"),
slotKey: "header:authorization",
value: {
kind: "connection",
connectionId: ConnectionId.make("conn"),
},
createdAt: now,
updatedAt: now,
}),
]),
},
pluginId: "test",
sourceId: "source",
sourceScope: ScopeId.make("org"),
values: {
Authorization: { kind: "binding", slot: "header:authorization" },
},
getSecretAtScope: () => Effect.succeed(null),
onMissingBinding: (name) => new TestCredentialError({ message: `missing binding ${name}` }),
onMissingSecret: (name) => new TestCredentialError({ message: `missing secret ${name}` }),
}).pipe(Effect.flip);

expect(failure.message).toBe("missing binding Authorization");
}),
);
});
228 changes: 228 additions & 0 deletions packages/core/sdk/src/http-credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { Effect } from "effect";

import type { StorageFailure } from "@executor-js/storage-core";

import {
ConfiguredCredentialBinding,
type ConfiguredCredentialValue,
type CredentialBindingRef,
type CredentialBindingsFacade,
type CredentialBindingValue,
} from "./credential-bindings";
import { ScopeId, SecretId } from "./ids";

export type HttpCredentialInput = ConfiguredCredentialValue | DirectHttpSecretCredentialInput;

export interface DirectHttpSecretCredentialInput {
readonly secretId: string;
readonly prefix?: string;
readonly targetScope?: string;
readonly secretScopeId?: string;
}

export interface PreparedHttpCredentialBinding {
readonly slotKey: string;
readonly value: CredentialBindingValue;
readonly targetScope?: ScopeId;
}

export interface PreparedHttpCredentialMap {
readonly values: Record<string, ConfiguredCredentialValue>;
readonly bindings: readonly PreparedHttpCredentialBinding[];
}

const scopeId = (scope: ScopeId | string): ScopeId => ScopeId.make(String(scope));

const isConfiguredBinding = (value: HttpCredentialInput): value is ConfiguredCredentialBinding =>
typeof value === "object" && value !== null && "kind" in value && value.kind === "binding";

const isDirectHttpSecretCredentialInput = (
value: HttpCredentialInput,
): value is DirectHttpSecretCredentialInput =>
typeof value === "object" && value !== null && "secretId" in value;

export const prepareHttpCredentialMap = <TInput extends HttpCredentialInput>(options: {
readonly values: Record<string, TInput> | undefined;
readonly slotForName: (name: string) => string;
}): PreparedHttpCredentialMap => {
const values: Record<string, ConfiguredCredentialValue> = {};
const bindings: PreparedHttpCredentialBinding[] = [];

for (const [name, value] of Object.entries(options.values ?? {})) {
if (typeof value === "string") {
values[name] = value;
continue;
}

if (isConfiguredBinding(value)) {
values[name] = value;
continue;
}

if (!isDirectHttpSecretCredentialInput(value)) continue;

const slotKey = options.slotForName(name);
values[name] = ConfiguredCredentialBinding.make({
kind: "binding",
slot: slotKey,
prefix: value.prefix,
});
bindings.push({
slotKey,
targetScope:
"targetScope" in value && value.targetScope ? scopeId(value.targetScope) : undefined,
value: {
kind: "secret",
secretId: SecretId.make(value.secretId),
...("secretScopeId" in value && value.secretScopeId
? { secretScopeId: scopeId(value.secretScopeId) }
: {}),
},
});
}

return { values, bindings };
};

export const resolveSourceCredentialBinding = (options: {
readonly credentialBindings: Pick<CredentialBindingsFacade, "listForSource">;
readonly pluginId: string;
readonly sourceId: string;
readonly sourceScope: ScopeId | string;
readonly slotKey: string;
}): Effect.Effect<CredentialBindingRef | null, StorageFailure> =>
Effect.gen(function* () {
const bindings = yield* options.credentialBindings.listForSource({
pluginId: options.pluginId,
sourceId: options.sourceId,
sourceScope: scopeId(options.sourceScope),
});
return bindings.find((binding) => binding.slotKey === options.slotKey) ?? null;
});

export type SecretCredentialBindingRef = Omit<CredentialBindingRef, "value"> & {
readonly value: Extract<CredentialBindingValue, { readonly kind: "secret" }>;
};

export const resolveConfiguredHttpCredentialMap = <SecretError, PluginError>(options: {
readonly credentialBindings: Pick<CredentialBindingsFacade, "listForSource">;
readonly pluginId: string;
readonly sourceId: string;
readonly sourceScope: ScopeId | string;
readonly values: Record<string, ConfiguredCredentialValue> | undefined;
readonly empty?: "undefined" | "record";
readonly getSecretAtScope: (
secretId: SecretId,
scopeId: ScopeId,
context: {
readonly name: string;
readonly binding: SecretCredentialBindingRef;
},
) => Effect.Effect<string | null, SecretError>;
readonly onMissingBinding: (name: string, value: ConfiguredCredentialBinding) => PluginError;
readonly onMissingSecret: (name: string, binding: SecretCredentialBindingRef) => PluginError;
}): Effect.Effect<Record<string, string> | undefined, SecretError | PluginError | StorageFailure> =>
Effect.gen(function* () {
const entries = Object.entries(options.values ?? {});
if (entries.length === 0) {
return options.empty === "record" ? {} : undefined;
}

const resolved: Record<string, string> = {};
for (const [name, value] of entries) {
if (typeof value === "string") {
resolved[name] = value;
continue;
}

const binding = yield* resolveSourceCredentialBinding({
credentialBindings: options.credentialBindings,
pluginId: options.pluginId,
sourceId: options.sourceId,
sourceScope: options.sourceScope,
slotKey: value.slot,
});
if (binding?.value.kind === "secret") {
const secretBinding = binding as SecretCredentialBindingRef;
const secret = yield* options.getSecretAtScope(
secretBinding.value.secretId,
secretBinding.value.secretScopeId ?? secretBinding.scopeId,
{ name, binding: secretBinding },
);
if (secret === null) {
return yield* Effect.fail(options.onMissingSecret(name, secretBinding));
}
resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;
continue;
}

if (binding?.value.kind === "text") {
resolved[name] = value.prefix ? `${value.prefix}${binding.value.text}` : binding.value.text;
continue;
}

return yield* Effect.fail(options.onMissingBinding(name, value));
}

return Object.keys(resolved).length > 0 || options.empty === "record" ? resolved : undefined;
});

export const targetScopeForPreparedHttpCredentialBinding = <E>(
fallbackTargetScope: ScopeId | string | undefined,
binding: PreparedHttpCredentialBinding,
onMissing: (binding: PreparedHttpCredentialBinding) => E,
): Effect.Effect<ScopeId, E> => {
const targetScope = binding.targetScope ?? fallbackTargetScope;
return targetScope ? Effect.succeed(scopeId(targetScope)) : Effect.fail(onMissing(binding));
};

export const setPreparedHttpCredentialBindings = <E>(options: {
readonly credentialBindings: Pick<CredentialBindingsFacade, "set">;
readonly pluginId: string;
readonly sourceId: string;
readonly sourceScope: ScopeId | string;
readonly fallbackTargetScope?: ScopeId | string;
readonly bindings: readonly PreparedHttpCredentialBinding[];
readonly onMissingTargetScope: (binding: PreparedHttpCredentialBinding) => E;
}): Effect.Effect<void, E | StorageFailure> =>
Effect.forEach(
options.bindings,
(binding) =>
Effect.gen(function* () {
const targetScope = yield* targetScopeForPreparedHttpCredentialBinding(
options.fallbackTargetScope,
binding,
options.onMissingTargetScope,
);
yield* options.credentialBindings.set({
targetScope,
pluginId: options.pluginId,
sourceId: options.sourceId,
sourceScope: scopeId(options.sourceScope),
slotKey: binding.slotKey,
value: binding.value,
});
}),
{ discard: true },
);

export const replacePreparedHttpCredentialBindingsForSource = (options: {
readonly credentialBindings: Pick<CredentialBindingsFacade, "replaceForSource">;
readonly pluginId: string;
readonly sourceId: string;
readonly sourceScope: ScopeId | string;
readonly targetScope: ScopeId | string;
readonly slotPrefixes: readonly string[];
readonly bindings: readonly PreparedHttpCredentialBinding[];
}): Effect.Effect<readonly CredentialBindingRef[], StorageFailure> =>
options.credentialBindings.replaceForSource({
targetScope: scopeId(options.targetScope),
pluginId: options.pluginId,
sourceId: options.sourceId,
sourceScope: scopeId(options.sourceScope),
slotPrefixes: [...options.slotPrefixes],
bindings: options.bindings.map((binding) => ({
slotKey: binding.slotKey,
value: binding.value,
})),
});
14 changes: 14 additions & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,20 @@ export {
type CredentialBindingsFacade,
} from "./credential-bindings";

export {
prepareHttpCredentialMap,
replacePreparedHttpCredentialBindingsForSource,
resolveConfiguredHttpCredentialMap,
resolveSourceCredentialBinding,
setPreparedHttpCredentialBindings,
targetScopeForPreparedHttpCredentialBinding,
type DirectHttpSecretCredentialInput,
type HttpCredentialInput,
type PreparedHttpCredentialBinding,
type PreparedHttpCredentialMap,
type SecretCredentialBindingRef,
} from "./http-credentials";

// Usage tracking — secret/connection refs across plugins
export { Usage, type UsagesForSecretInput, type UsagesForConnectionInput } from "./usages";

Expand Down
Loading
Loading