Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
23 changes: 21 additions & 2 deletions apps/server/src/sourceControl/sourceControlHostSecrets.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { assert, it } from "@effect/vitest";
import { SecretProviderId } from "@t3tools/contracts";
import { SecretProviderId, UsageLimitSourceId } from "@t3tools/contracts";
import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
import * as Effect from "effect/Effect";

Expand Down Expand Up @@ -125,12 +125,31 @@ it.effect("reads a collected reference again once it is invalidated", () =>
}),
});
const read = () => resolveSourceControlHostSecrets(resolver, hosts, "bitbucket");
const usageLimitSources = {
[UsageLimitSourceId.make("referenced")]: {
kind: "cliproxy",
url: "https://hub.test",
managementKey: source("cliproxy#key"),
enabled: true,
},
[UsageLimitSourceId.make("literal")]: {
kind: "cliproxy",
url: "https://hub.test",
managementKey: "plain-key",
enabled: true,
},
} as const;
cache.set("cliproxy#key", "stale");
assert.deepEqual((yield* read()).bitbucket, {
email: "me@example.test",
accessToken: "stale",
});
current = "rotated";
yield* invalidateSettingsSecrets(resolver, Effect.succeed({ sourceControlHosts: hosts }));
yield* invalidateSettingsSecrets(
resolver,
Effect.succeed({ sourceControlHosts: hosts, usageLimitSources }),
);
assert.isFalse(cache.has("cliproxy#key"));
assert.deepEqual((yield* read()).bitbucket, {
email: "me@example.test",
accessToken: "rotated",
Expand Down
14 changes: 12 additions & 2 deletions apps/server/src/sourceControl/sourceControlHostSecrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,23 @@ export const resolveSourceControlHostSecrets = Effect.fn("SourceControlHost.reso
},
);

const collectUsageLimitSecretReferences = (
sources: ServerSettings["usageLimitSources"],
): ReadonlyArray<SecretReference> =>
Object.values(sources).flatMap(({ managementKey }) =>
isSecretReference(managementKey) ? [managementKey] : [],
);

/** Settings that cannot be read clear every cached secret, so a refresh never keeps a stale one. */
export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")(function* <E>(
resolver: SecretResolver.SecretResolver["Service"],
settings: Effect.Effect<Pick<ServerSettings, "sourceControlHosts">, E>,
settings: Effect.Effect<Pick<ServerSettings, "sourceControlHosts" | "usageLimitSources">, E>,
) {
const references = yield* settings.pipe(
Effect.map((current) => collectSourceControlHostSecretReferences(current.sourceControlHosts)),
Effect.map((current) => [
...collectSourceControlHostSecretReferences(current.sourceControlHosts),
...collectUsageLimitSecretReferences(current.usageLimitSources),
]),
Effect.orElseSucceed(() => undefined),
);
yield* resolver.invalidate(references);
Expand Down
72 changes: 70 additions & 2 deletions apps/server/src/usage/UsageLimitSources.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import { expect, it } from "@effect/vitest";
import { ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts";
import { SecretProviderId, ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts";
import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import { HttpClient } from "effect/http";
import { HttpClient, HttpClientResponse } from "effect/http";

import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import * as ServerSettings from "../serverSettings.ts";
Expand Down Expand Up @@ -36,6 +37,10 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () =>
);
}).pipe(
Effect.provide([
Layer.succeed(SecretResolver.SecretResolver, {
read: (references) => Effect.succeed(references.map(() => undefined)),
invalidate: () => Effect.void,
}),
ServerSettings.layerTest({
usageLimitSources: {
[UsageLimitSourceId.make("local-hub")]: {
Expand Down Expand Up @@ -83,3 +88,66 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () =>
Effect.scoped,
),
);

it.effect("reads a management key from its secret provider", () => {
const authorizations: Array<string | undefined> = [];
const keySource = (reference: string) => ({
provider: SecretProviderId.make("homelab"),
reference,
});
return Effect.gen(function* () {
const sources = yield* UsageLimitSources.make;
yield* sources.refresh;
const snapshots = yield* sources.current;
expect(snapshots.map(({ id, error }) => ({ id, error }))).toEqual([
{ id: "resolved-hub", error: undefined },
{
id: "unresolved-hub",
error: "Could not read the management key from its secret provider.",
},
]);
expect(authorizations).toEqual(["Bearer resolved-key"]);
}).pipe(
Effect.provide([
ServerSettings.layerTest({
usageLimitSources: {
[UsageLimitSourceId.make("resolved-hub")]: {
kind: "cliproxy",
url: "https://resolved.test",
managementKey: keySource("cliproxy#key"),
enabled: true,
},
[UsageLimitSourceId.make("unresolved-hub")]: {
kind: "cliproxy",
url: "https://unresolved.test",
managementKey: keySource("missing#key"),
enabled: true,
},
},
}),
Layer.mock(BackgroundPolicy.BackgroundPolicy)({
shouldRunScopeWork: () => Effect.succeed(false),
}),
Layer.succeed(SecretResolver.SecretResolver, {
read: (references) =>
Effect.succeed(
references.map((reference) =>
reference.reference === "cliproxy#key" ? "resolved-key" : undefined,
),
),
invalidate: () => Effect.void,
}),
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
authorizations.push(request.headers.authorization);
return HttpClientResponse.fromWeb(request, Response.json({ files: [] }));
}),
),
),
NodeCrypto.layer,
]),
Effect.scoped,
);
});
44 changes: 37 additions & 7 deletions apps/server/src/usage/UsageLimitSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
type UsageLimitSourceConsumeResetCreditInput,
type ProviderConsumeResetCreditResult,
type ServerSettings,
type UsageLimitSourceConfig,
UsageLimitSourceConfig,
settingsSecretLiteral,
type UsageLimitSourceId,
type UsageLimitSourceSnapshot,
} from "@t3tools/contracts";
Expand All @@ -21,6 +22,9 @@ import * as Semaphore from "effect/Semaphore";
import * as Stream from "effect/Stream";

import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
import * as SecretResolverLayer from "../secrets/SecretResolver.ts";
import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts";
import * as Settings from "../serverSettings.ts";
import { makeCliproxyApi } from "./cliproxyApi.ts";

Expand All @@ -46,11 +50,24 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string {
}
}

function missingManagementKey(config: UsageLimitSourceConfig): string {
return typeof config.managementKey === "string"
? "No management key configured."
: "Could not read the management key from its secret provider.";
}

/** @public */
export const make = Effect.gen(function* () {
const api = yield* makeCliproxyApi;
const settingsService = yield* Settings.ServerSettingsService;
const secrets = yield* SecretResolver.SecretResolver;
const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
const readManagementKey = Effect.fn("UsageLimitSources.readManagementKey")(function* (
config: UsageLimitSourceConfig,
) {
const resolved = yield* resolveSettingsSecrets(secrets, UsageLimitSourceConfig, config);
return settingsSecretLiteral(resolved.managementKey);
});
const stateRef = yield* Ref.make<ReadonlyArray<UsageLimitSourceSnapshot>>([]);
const changes = yield* Effect.acquireRelease(
PubSub.unbounded<ReadonlyArray<UsageLimitSourceSnapshot>>(),
Expand All @@ -63,10 +80,13 @@ export const make = Effect.gen(function* () {
) {
const checkedAt = DateTime.formatIso(yield* DateTime.now);
const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const;
if (config.managementKey.length === 0) {
return { ...base, accounts: [], error: "No management key configured." };
const managementKey = yield* readManagementKey(config);
if (managementKey.length === 0) {
return { ...base, accounts: [], error: missingManagementKey(config) };
}
const accounts = yield* api.readAccounts(config).pipe(Effect.result);
const accounts = yield* api
.readAccounts({ url: config.url, managementKey })
.pipe(Effect.result);
if (accounts._tag === "Failure") {
yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure });
return { ...base, accounts: [], error: accounts.failure.detail };
Expand Down Expand Up @@ -108,12 +128,20 @@ export const make = Effect.gen(function* () {
Effect.mapError(() => new UsageLimitSourceError({ detail: "Could not read hub settings." })),
);
const config = settings.usageLimitSources[input.sourceId];
if (!config?.enabled || !config.managementKey) {
if (!config?.enabled) {
return yield* new UsageLimitSourceError({
detail: "The usage limit source is missing or disabled.",
});
}
const result = yield* api.consume(config, input.accountId, input.creditId);
const managementKey = yield* readManagementKey(config);
if (managementKey.length === 0) {
return yield* new UsageLimitSourceError({ detail: missingManagementKey(config) });
}
const result = yield* api.consume(
{ url: config.url, managementKey },
input.accountId,
input.creditId,
);
const snapshot = yield* readSource(input.sourceId, config);
const previous = yield* Ref.get(stateRef);
yield* publish(previous.map((source) => (source.id === input.sourceId ? snapshot : source)));
Expand Down Expand Up @@ -167,4 +195,6 @@ export const make = Effect.gen(function* () {
} satisfies UsageLimitSources["Service"];
});

export const layer = Layer.effect(UsageLimitSources, make);
export const layer = Layer.effect(UsageLimitSources, make).pipe(
Layer.provide(SecretResolverLayer.layer),
);
Loading
Loading