Skip to content

Commit 301fb4c

Browse files
committed
Share HTTP credential handling
1 parent 9b48363 commit 301fb4c

5 files changed

Lines changed: 536 additions & 354 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Data, Effect } from "effect";
3+
4+
import { CredentialBindingRef } from "./credential-bindings";
5+
import { prepareHttpCredentialMap, resolveConfiguredHttpCredentialMap } from "./http-credentials";
6+
import { ConnectionId, CredentialBindingId, ScopeId, SecretId } from "./ids";
7+
8+
class TestCredentialError extends Data.TaggedError("TestCredentialError")<{
9+
readonly message: string;
10+
}> {}
11+
12+
describe("http credential helpers", () => {
13+
it.effect("prepares direct secret inputs into configured credential bindings", () =>
14+
Effect.sync(() => {
15+
const prepared = prepareHttpCredentialMap({
16+
values: {
17+
Authorization: {
18+
secretId: "api-token",
19+
prefix: "Bearer ",
20+
targetScope: ScopeId.make("user"),
21+
secretScopeId: ScopeId.make("org"),
22+
},
23+
"X-Static": "static",
24+
"X-Slot": { kind: "binding", slot: "header:x-slot" },
25+
},
26+
slotForName: (name) => `header:${name.toLowerCase()}`,
27+
});
28+
29+
expect(prepared.values).toEqual({
30+
Authorization: {
31+
kind: "binding",
32+
slot: "header:authorization",
33+
prefix: "Bearer ",
34+
},
35+
"X-Static": "static",
36+
"X-Slot": { kind: "binding", slot: "header:x-slot" },
37+
});
38+
expect(prepared.bindings).toEqual([
39+
{
40+
slotKey: "header:authorization",
41+
targetScope: ScopeId.make("user"),
42+
value: {
43+
kind: "secret",
44+
secretId: SecretId.make("api-token"),
45+
secretScopeId: ScopeId.make("org"),
46+
},
47+
},
48+
]);
49+
}),
50+
);
51+
52+
it.effect("resolves configured text and secret bindings", () =>
53+
Effect.gen(function* () {
54+
const now = new Date("2026-01-01T00:00:00.000Z");
55+
const bindings = [
56+
CredentialBindingRef.make({
57+
id: CredentialBindingId.make("secret-binding"),
58+
pluginId: "test",
59+
sourceId: "source",
60+
sourceScopeId: ScopeId.make("org"),
61+
scopeId: ScopeId.make("user"),
62+
slotKey: "header:authorization",
63+
value: { kind: "secret", secretId: SecretId.make("api-token") },
64+
createdAt: now,
65+
updatedAt: now,
66+
}),
67+
CredentialBindingRef.make({
68+
id: CredentialBindingId.make("text-binding"),
69+
pluginId: "test",
70+
sourceId: "source",
71+
sourceScopeId: ScopeId.make("org"),
72+
scopeId: ScopeId.make("user"),
73+
slotKey: "query_param:mode",
74+
value: { kind: "text", text: "fast" },
75+
createdAt: now,
76+
updatedAt: now,
77+
}),
78+
];
79+
80+
const resolved = yield* resolveConfiguredHttpCredentialMap({
81+
credentialBindings: {
82+
listForSource: () => Effect.succeed(bindings),
83+
},
84+
pluginId: "test",
85+
sourceId: "source",
86+
sourceScope: ScopeId.make("org"),
87+
values: {
88+
Authorization: {
89+
kind: "binding",
90+
slot: "header:authorization",
91+
prefix: "Bearer ",
92+
},
93+
mode: { kind: "binding", slot: "query_param:mode" },
94+
},
95+
getSecretAtScope: (secretId, scopeId) =>
96+
Effect.succeed(
97+
secretId === SecretId.make("api-token") && scopeId === ScopeId.make("user")
98+
? "token"
99+
: null,
100+
),
101+
onMissingBinding: (name) => new TestCredentialError({ message: `missing binding ${name}` }),
102+
onMissingSecret: (name) => new TestCredentialError({ message: `missing secret ${name}` }),
103+
});
104+
105+
expect(resolved).toEqual({
106+
Authorization: "Bearer token",
107+
mode: "fast",
108+
});
109+
}),
110+
);
111+
112+
it.effect("treats connection bindings as missing for HTTP credential values", () =>
113+
Effect.gen(function* () {
114+
const now = new Date("2026-01-01T00:00:00.000Z");
115+
const failure = yield* resolveConfiguredHttpCredentialMap({
116+
credentialBindings: {
117+
listForSource: () =>
118+
Effect.succeed([
119+
CredentialBindingRef.make({
120+
id: CredentialBindingId.make("connection-binding"),
121+
pluginId: "test",
122+
sourceId: "source",
123+
sourceScopeId: ScopeId.make("org"),
124+
scopeId: ScopeId.make("user"),
125+
slotKey: "header:authorization",
126+
value: {
127+
kind: "connection",
128+
connectionId: ConnectionId.make("conn"),
129+
},
130+
createdAt: now,
131+
updatedAt: now,
132+
}),
133+
]),
134+
},
135+
pluginId: "test",
136+
sourceId: "source",
137+
sourceScope: ScopeId.make("org"),
138+
values: {
139+
Authorization: { kind: "binding", slot: "header:authorization" },
140+
},
141+
getSecretAtScope: () => Effect.succeed(null),
142+
onMissingBinding: (name) => new TestCredentialError({ message: `missing binding ${name}` }),
143+
onMissingSecret: (name) => new TestCredentialError({ message: `missing secret ${name}` }),
144+
}).pipe(Effect.flip);
145+
146+
expect(failure.message).toBe("missing binding Authorization");
147+
}),
148+
);
149+
});
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { Effect } from "effect";
2+
3+
import type { StorageFailure } from "@executor-js/storage-core";
4+
5+
import {
6+
ConfiguredCredentialBinding,
7+
type ConfiguredCredentialValue,
8+
type CredentialBindingRef,
9+
type CredentialBindingsFacade,
10+
type CredentialBindingValue,
11+
} from "./credential-bindings";
12+
import { ScopeId, SecretId } from "./ids";
13+
14+
export type HttpCredentialInput = ConfiguredCredentialValue | DirectHttpSecretCredentialInput;
15+
16+
export interface DirectHttpSecretCredentialInput {
17+
readonly secretId: string;
18+
readonly prefix?: string;
19+
readonly targetScope?: string;
20+
readonly secretScopeId?: string;
21+
}
22+
23+
export interface PreparedHttpCredentialBinding {
24+
readonly slotKey: string;
25+
readonly value: CredentialBindingValue;
26+
readonly targetScope?: ScopeId;
27+
}
28+
29+
export interface PreparedHttpCredentialMap {
30+
readonly values: Record<string, ConfiguredCredentialValue>;
31+
readonly bindings: readonly PreparedHttpCredentialBinding[];
32+
}
33+
34+
const scopeId = (scope: ScopeId | string): ScopeId => ScopeId.make(String(scope));
35+
36+
const isConfiguredBinding = (value: HttpCredentialInput): value is ConfiguredCredentialBinding =>
37+
typeof value === "object" && value !== null && "kind" in value && value.kind === "binding";
38+
39+
const isDirectHttpSecretCredentialInput = (
40+
value: HttpCredentialInput,
41+
): value is DirectHttpSecretCredentialInput =>
42+
typeof value === "object" && value !== null && "secretId" in value;
43+
44+
export const prepareHttpCredentialMap = <TInput extends HttpCredentialInput>(options: {
45+
readonly values: Record<string, TInput> | undefined;
46+
readonly slotForName: (name: string) => string;
47+
}): PreparedHttpCredentialMap => {
48+
const values: Record<string, ConfiguredCredentialValue> = {};
49+
const bindings: PreparedHttpCredentialBinding[] = [];
50+
51+
for (const [name, value] of Object.entries(options.values ?? {})) {
52+
if (typeof value === "string") {
53+
values[name] = value;
54+
continue;
55+
}
56+
57+
if (isConfiguredBinding(value)) {
58+
values[name] = value;
59+
continue;
60+
}
61+
62+
if (!isDirectHttpSecretCredentialInput(value)) continue;
63+
64+
const slotKey = options.slotForName(name);
65+
values[name] = ConfiguredCredentialBinding.make({
66+
kind: "binding",
67+
slot: slotKey,
68+
prefix: value.prefix,
69+
});
70+
bindings.push({
71+
slotKey,
72+
targetScope:
73+
"targetScope" in value && value.targetScope ? scopeId(value.targetScope) : undefined,
74+
value: {
75+
kind: "secret",
76+
secretId: SecretId.make(value.secretId),
77+
...("secretScopeId" in value && value.secretScopeId
78+
? { secretScopeId: scopeId(value.secretScopeId) }
79+
: {}),
80+
},
81+
});
82+
}
83+
84+
return { values, bindings };
85+
};
86+
87+
export const resolveSourceCredentialBinding = (options: {
88+
readonly credentialBindings: Pick<CredentialBindingsFacade, "listForSource">;
89+
readonly pluginId: string;
90+
readonly sourceId: string;
91+
readonly sourceScope: ScopeId | string;
92+
readonly slotKey: string;
93+
}): Effect.Effect<CredentialBindingRef | null, StorageFailure> =>
94+
Effect.gen(function* () {
95+
const bindings = yield* options.credentialBindings.listForSource({
96+
pluginId: options.pluginId,
97+
sourceId: options.sourceId,
98+
sourceScope: scopeId(options.sourceScope),
99+
});
100+
return bindings.find((binding) => binding.slotKey === options.slotKey) ?? null;
101+
});
102+
103+
export type SecretCredentialBindingRef = Omit<CredentialBindingRef, "value"> & {
104+
readonly value: Extract<CredentialBindingValue, { readonly kind: "secret" }>;
105+
};
106+
107+
export const resolveConfiguredHttpCredentialMap = <SecretError, PluginError>(options: {
108+
readonly credentialBindings: Pick<CredentialBindingsFacade, "listForSource">;
109+
readonly pluginId: string;
110+
readonly sourceId: string;
111+
readonly sourceScope: ScopeId | string;
112+
readonly values: Record<string, ConfiguredCredentialValue> | undefined;
113+
readonly empty?: "undefined" | "record";
114+
readonly getSecretAtScope: (
115+
secretId: SecretId,
116+
scopeId: ScopeId,
117+
context: {
118+
readonly name: string;
119+
readonly binding: SecretCredentialBindingRef;
120+
},
121+
) => Effect.Effect<string | null, SecretError>;
122+
readonly onMissingBinding: (name: string, value: ConfiguredCredentialBinding) => PluginError;
123+
readonly onMissingSecret: (name: string, binding: SecretCredentialBindingRef) => PluginError;
124+
}): Effect.Effect<Record<string, string> | undefined, SecretError | PluginError | StorageFailure> =>
125+
Effect.gen(function* () {
126+
const entries = Object.entries(options.values ?? {});
127+
if (entries.length === 0) {
128+
return options.empty === "record" ? {} : undefined;
129+
}
130+
131+
const resolved: Record<string, string> = {};
132+
for (const [name, value] of entries) {
133+
if (typeof value === "string") {
134+
resolved[name] = value;
135+
continue;
136+
}
137+
138+
const binding = yield* resolveSourceCredentialBinding({
139+
credentialBindings: options.credentialBindings,
140+
pluginId: options.pluginId,
141+
sourceId: options.sourceId,
142+
sourceScope: options.sourceScope,
143+
slotKey: value.slot,
144+
});
145+
if (binding?.value.kind === "secret") {
146+
const secretBinding = binding as SecretCredentialBindingRef;
147+
const secret = yield* options.getSecretAtScope(
148+
secretBinding.value.secretId,
149+
secretBinding.value.secretScopeId ?? secretBinding.scopeId,
150+
{ name, binding: secretBinding },
151+
);
152+
if (secret === null) {
153+
return yield* Effect.fail(options.onMissingSecret(name, secretBinding));
154+
}
155+
resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;
156+
continue;
157+
}
158+
159+
if (binding?.value.kind === "text") {
160+
resolved[name] = value.prefix ? `${value.prefix}${binding.value.text}` : binding.value.text;
161+
continue;
162+
}
163+
164+
return yield* Effect.fail(options.onMissingBinding(name, value));
165+
}
166+
167+
return Object.keys(resolved).length > 0 || options.empty === "record" ? resolved : undefined;
168+
});
169+
170+
export const targetScopeForPreparedHttpCredentialBinding = <E>(
171+
fallbackTargetScope: ScopeId | string | undefined,
172+
binding: PreparedHttpCredentialBinding,
173+
onMissing: (binding: PreparedHttpCredentialBinding) => E,
174+
): Effect.Effect<ScopeId, E> => {
175+
const targetScope = binding.targetScope ?? fallbackTargetScope;
176+
return targetScope ? Effect.succeed(scopeId(targetScope)) : Effect.fail(onMissing(binding));
177+
};
178+
179+
export const setPreparedHttpCredentialBindings = <E>(options: {
180+
readonly credentialBindings: Pick<CredentialBindingsFacade, "set">;
181+
readonly pluginId: string;
182+
readonly sourceId: string;
183+
readonly sourceScope: ScopeId | string;
184+
readonly fallbackTargetScope?: ScopeId | string;
185+
readonly bindings: readonly PreparedHttpCredentialBinding[];
186+
readonly onMissingTargetScope: (binding: PreparedHttpCredentialBinding) => E;
187+
}): Effect.Effect<void, E | StorageFailure> =>
188+
Effect.forEach(
189+
options.bindings,
190+
(binding) =>
191+
Effect.gen(function* () {
192+
const targetScope = yield* targetScopeForPreparedHttpCredentialBinding(
193+
options.fallbackTargetScope,
194+
binding,
195+
options.onMissingTargetScope,
196+
);
197+
yield* options.credentialBindings.set({
198+
targetScope,
199+
pluginId: options.pluginId,
200+
sourceId: options.sourceId,
201+
sourceScope: scopeId(options.sourceScope),
202+
slotKey: binding.slotKey,
203+
value: binding.value,
204+
});
205+
}),
206+
{ discard: true },
207+
);
208+
209+
export const replacePreparedHttpCredentialBindingsForSource = (options: {
210+
readonly credentialBindings: Pick<CredentialBindingsFacade, "replaceForSource">;
211+
readonly pluginId: string;
212+
readonly sourceId: string;
213+
readonly sourceScope: ScopeId | string;
214+
readonly targetScope: ScopeId | string;
215+
readonly slotPrefixes: readonly string[];
216+
readonly bindings: readonly PreparedHttpCredentialBinding[];
217+
}): Effect.Effect<readonly CredentialBindingRef[], StorageFailure> =>
218+
options.credentialBindings.replaceForSource({
219+
targetScope: scopeId(options.targetScope),
220+
pluginId: options.pluginId,
221+
sourceId: options.sourceId,
222+
sourceScope: scopeId(options.sourceScope),
223+
slotPrefixes: [...options.slotPrefixes],
224+
bindings: options.bindings.map((binding) => ({
225+
slotKey: binding.slotKey,
226+
value: binding.value,
227+
})),
228+
});

packages/core/sdk/src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,20 @@ export {
147147
type CredentialBindingsFacade,
148148
} from "./credential-bindings";
149149

150+
export {
151+
prepareHttpCredentialMap,
152+
replacePreparedHttpCredentialBindingsForSource,
153+
resolveConfiguredHttpCredentialMap,
154+
resolveSourceCredentialBinding,
155+
setPreparedHttpCredentialBindings,
156+
targetScopeForPreparedHttpCredentialBinding,
157+
type DirectHttpSecretCredentialInput,
158+
type HttpCredentialInput,
159+
type PreparedHttpCredentialBinding,
160+
type PreparedHttpCredentialMap,
161+
type SecretCredentialBindingRef,
162+
} from "./http-credentials";
163+
150164
// Usage tracking — secret/connection refs across plugins
151165
export { Usage, type UsagesForSecretInput, type UsagesForConnectionInput } from "./usages";
152166

0 commit comments

Comments
 (0)