Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
138 changes: 138 additions & 0 deletions apps/server/src/cloud/CloudPreferences.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { EnvironmentId } from "@t3tools/contracts";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as FetchHttpClient from "effect/http/FetchHttpClient";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
import * as CloudPreferences from "./CloudPreferences.ts";
import {
HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET,
PUBLISH_AGENT_ACTIVITY_SECRET,
RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
RELAY_URL_SECRET,
} from "./config.ts";

const encode = (value: string) => new TextEncoder().encode(value);

/** A linked environment whose secret store can refuse writes, and the relay calls it made. */
const withService = <A, E>(
options: {
readonly failHoldWrite?: boolean;
readonly failActivityWrite?: boolean;
readonly relayFails?: boolean;
},
body: (input: {
readonly preferences: CloudPreferences.CloudPreferences["Service"];
readonly stored: Map<string, Uint8Array>;
readonly relayCalls: Array<boolean>;
}) => Effect.Effect<A, E, never>,
) =>
Effect.gen(function* () {
const stored = new Map<string, Uint8Array>([
[RELAY_URL_SECRET, encode("https://relay.test")],
[RELAY_ENVIRONMENT_CREDENTIAL_SECRET, encode("credential")],
[HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, encode("false")],
[PUBLISH_AGENT_ACTIVITY_SECRET, encode("false")],
]);
const relayCalls: Array<boolean> = [];
const fetch: typeof globalThis.fetch = Object.assign(
(_input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
const payload = JSON.parse(String(init?.body)) as { holdWebhooksWhileOffline: boolean };
relayCalls.push(payload.holdWebhooksWhileOffline);
return options.relayFails
? Promise.resolve(new Response("unavailable", { status: 503 }))
: Promise.resolve(Response.json(payload));
},
{ preconnect: () => {} },
);
const dependencies = Layer.mergeAll(
Layer.mock(ServerSecretStore.ServerSecretStore)({
get: (name) => Effect.succeed(Option.fromNullishOr(stored.get(name))),
set: (name, value) =>
(options.failHoldWrite && name === HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET) ||
(options.failActivityWrite && name === PUBLISH_AGENT_ACTIVITY_SECRET)
? Effect.fail(
new ServerSecretStore.SecretStorePersistError({
resource: name,
cause: new Error("read-only"),
}),
)
: Effect.sync(() => void stored.set(name, value)),
}),
Layer.mock(ServerEnvironment.ServerEnvironment)({
getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-1")),
}),
Layer.mock(AgentAwarenessRelay.AgentAwarenessRelay)({ requestCatchUp: () => Effect.void }),
);
return yield* Effect.gen(function* () {
const preferences = yield* CloudPreferences.CloudPreferences;
return yield* body({ preferences, stored, relayCalls });
}).pipe(
Effect.provide(CloudPreferences.layer.pipe(Layer.provide(dependencies))),
Effect.provideService(FetchHttpClient.Fetch, fetch),
);
});

it.effect("tells the relay before saving the hold setting locally", () =>
withService({}, ({ preferences, stored, relayCalls }) =>
Effect.gen(function* () {
yield* preferences.update({ publishAgentActivity: true, holdWebhooksWhileOffline: true });
assert.deepEqual(relayCalls, [true]);
assert.equal(
new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),
"true",
);
}),
),
);

it.effect("puts the relay back when the local save fails", () =>
withService({ failHoldWrite: true }, ({ preferences, stored, relayCalls }) =>
Effect.gen(function* () {
const error = yield* preferences
.update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
.pipe(Effect.flip);
assert.equal(error._tag, "EnvironmentHttpInternalServerError");
assert.deepEqual(relayCalls, [true, false]);
assert.equal(
new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),
"false",
);
}),
),
);

it.effect("leaves the relay untouched when the activity setting can't be saved", () =>
withService({ failActivityWrite: true }, ({ preferences, stored, relayCalls }) =>
Effect.gen(function* () {
const error = yield* preferences
.update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
.pipe(Effect.flip);
assert.equal(error._tag, "EnvironmentHttpInternalServerError");
assert.deepEqual(relayCalls, []);
assert.equal(
new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),
"false",
);
}),
),
);

it.effect("keeps both settings unchanged when the relay refuses the hold change", () =>
withService({ relayFails: true }, ({ preferences, stored }) =>
Effect.gen(function* () {
yield* preferences
.update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
.pipe(Effect.flip);
assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false");
assert.equal(
new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),
"false",
);
}),
),
);
111 changes: 111 additions & 0 deletions apps/server/src/cloud/CloudPreferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
type EnvironmentCloudPreferencesRequest,
EnvironmentHttpBadRequestError,
EnvironmentHttpInternalServerError,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
import { makeRelayEnvironmentClient } from "../relay/relayEnvironmentClient.ts";
import {
HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET,
PUBLISH_AGENT_ACTIVITY_SECRET,
readHoldWebhooksWhileOffline,
readRelayConnection,
} from "./config.ts";

const encode = (value: boolean) => new TextEncoder().encode(String(value));

const internalError = (message: string) => (cause: unknown) =>
Effect.logError(message, { cause }).pipe(
Effect.andThen(Effect.fail(new EnvironmentHttpInternalServerError({ message }))),
);

export class CloudPreferences extends Context.Service<
CloudPreferences,
{
/**
* Saves this environment's T3 Connect preferences. Holding webhooks while
* offline is decided by the relay, so it is told first and put back if the
* local save fails.
*/
readonly update: (
input: EnvironmentCloudPreferencesRequest,
) => Effect.Effect<void, EnvironmentHttpBadRequestError | EnvironmentHttpInternalServerError>;
}
>()("t3/cloud/CloudPreferences") {}

const make = Effect.gen(function* () {
const secrets = yield* ServerSecretStore.ServerSecretStore;
const environment = yield* ServerEnvironment.ServerEnvironment;
const awarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;

const pushHoldWebhooksWhileOffline = Effect.fn("CloudPreferences.pushHoldWebhooksWhileOffline")(
function* (holdWebhooksWhileOffline: boolean) {
const connection = yield* readRelayConnection(secrets);
if (connection === null) {
return yield* new EnvironmentHttpBadRequestError({
message: "Link this environment to T3 Connect first.",
});
}
const environmentId = yield* environment.getEnvironmentId;
const client = yield* makeRelayEnvironmentClient(connection);
yield* client.server
.updateLinkPreferences({
params: { environmentId },
payload: { holdWebhooksWhileOffline },
})
.pipe(
Effect.timeout("10 seconds"),
Effect.catch(internalError("Could not update T3 Connect webhook settings.")),
);
},
);

const save = (name: string, value: boolean) =>
secrets
.set(name, encode(value))
.pipe(Effect.catch(internalError("Could not persist environment cloud preferences.")));

const update: CloudPreferences["Service"]["update"] = Effect.fn("CloudPreferences.update")(
function* (input) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
// All or nothing: the activity setting is saved first, before the relay
// is told anything, and put back if the hold change then fails.
const previousActivity = yield* secrets
.get(PUBLISH_AGENT_ACTIVITY_SECRET)
.pipe(Effect.orElseSucceed(() => Option.none<Uint8Array>()));
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
yield* save(PUBLISH_AGENT_ACTIVITY_SECRET, input.publishAgentActivity);
if (input.holdWebhooksWhileOffline !== undefined) {
const next = input.holdWebhooksWhileOffline;
const previous = yield* readHoldWebhooksWhileOffline(secrets);
yield* pushHoldWebhooksWhileOffline(next).pipe(
Effect.andThen(
save(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, next).pipe(
Effect.tapError(() =>
previous === next
? Effect.void
: pushHoldWebhooksWhileOffline(previous).pipe(Effect.ignore),
),
),
),
Effect.tapError(() =>
Option.match(previousActivity, {
onNone: () => secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET),
onSome: (bytes) => secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, bytes),
}).pipe(Effect.ignore),
),
);
}
yield* awarenessRelay.requestCatchUp();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
},
);

return CloudPreferences.of({ update });
});

export const layer = Layer.effect(CloudPreferences, make);
62 changes: 11 additions & 51 deletions apps/server/src/cloud/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { requireEnvironmentScope } from "../auth/http.ts";
import * as ServerConfig from "../config.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import { makeRelayEnvironmentClient } from "../relay/relayEnvironmentClient.ts";
import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
import {
Expand Down Expand Up @@ -102,6 +101,7 @@ import {
setCliDesiredCloudLink,
} from "./CliState.ts";
import * as CliTokenManager from "./CliTokenManager.ts";
import * as CloudPreferences from "./CloudPreferences.ts";
import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
import { traceRelayRequest } from "./traceRelayRequest.ts";
import { filterRelayResponse, relayRequestError, shouldRetryCloudLink } from "./relayResponse.ts";
Expand Down Expand Up @@ -1358,57 +1358,14 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")(
),
);

const pushHoldWebhooksWhileOffline = Effect.fn("environment.cloud.pushHoldWebhooksWhileOffline")(
function* (dependencies: CloudHttpDependencies, holdWebhooksWhileOffline: boolean) {
const connection = yield* readRelayConnection(dependencies.secrets);
if (connection === null) {
return yield* new EnvironmentHttpBadRequestError({
message: "Link this environment to T3 Connect first.",
});
}
const environmentId = yield* dependencies.environment.getEnvironmentId;
const client = yield* makeRelayEnvironmentClient(connection);
yield* client.server
.updateLinkPreferences({
params: { environmentId },
payload: { holdWebhooksWhileOffline },
})
.pipe(
Effect.timeout("10 seconds"),
Effect.catch(
failEnvironmentCloudInternalError("Could not update T3 Connect webhook settings."),
),
);
},
);

const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")(
function* (dependencies: CloudHttpDependencies, payload: EnvironmentCloudPreferencesRequest) {
function* (
dependencies: CloudHttpDependencies,
preferences: CloudPreferences.CloudPreferences["Service"],
payload: EnvironmentCloudPreferencesRequest,
) {
yield* requireEnvironmentScope(AuthRelayWriteScope);
if (payload.holdWebhooksWhileOffline !== undefined) {
// The relay decides whether to hold a request, so it is told first; the
// local copy is only saved once the relay has the same value, and the
// relay is put back if that save fails.
const previous = yield* readHoldWebhooksWhileOffline(dependencies.secrets);
yield* pushHoldWebhooksWhileOffline(dependencies, payload.holdWebhooksWhileOffline);
yield* dependencies.secrets
.set(
HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET,
stringToBytes(String(payload.holdWebhooksWhileOffline)),
)
.pipe(
Effect.tapError(() =>
previous === payload.holdWebhooksWhileOffline
? Effect.void
: pushHoldWebhooksWhileOffline(dependencies, previous).pipe(Effect.ignore),
),
);
}
yield* dependencies.secrets.set(
PUBLISH_AGENT_ACTIVITY_SECRET,
stringToBytes(String(payload.publishAgentActivity)),
);
yield* dependencies.awarenessRelay.requestCatchUp();
yield* preferences.update(payload);
return yield* readCloudLinkState(dependencies);
},
Effect.catchIf(
Expand Down Expand Up @@ -1661,12 +1618,15 @@ export const connectHttpApiLayer = HttpApiBuilder.group(
"connect",
Effect.fnUntraced(function* (handlers) {
const dependencies = yield* cloudHttpDependencies;
const preferences = yield* CloudPreferences.CloudPreferences;
return handlers
.handle("linkProof", ({ payload }) => cloudLinkProofHandler(dependencies, payload))
.handle("relayConfig", ({ payload }) => cloudRelayConfigHandler(dependencies, payload))
.handle("linkState", () => cloudLinkStateHandler(dependencies))
.handle("unlink", () => cloudUnlinkHandler(dependencies))
.handle("preferences", ({ payload }) => cloudPreferencesHandler(dependencies, payload))
.handle("preferences", ({ payload }) =>
cloudPreferencesHandler(dependencies, preferences, payload),
)
.handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload))
.handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload))
.handle("t3MintCredential", ({ payload }) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
verifyRelayJwt,
} from "@t3tools/shared/relayJwt";
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

Expand All @@ -29,11 +31,23 @@ const decodePayload = Schema.decodeUnknownOption(RelayHookDeliveryProofPayload);
const text = (bytes: Option.Option<Uint8Array>) =>
Option.map(bytes, (value) => new TextDecoder().decode(value));

/** The relay's own delivery id and receive time, when the request proves it came from the relay. */
export const makeRelayDeliveryVerifier = Effect.gen(function* () {
export class RelayDeliveryProof extends Context.Service<
RelayDeliveryProof,
{
/** The relay's own delivery id and receive time, when the request proves it came from the relay. */
readonly verify: (input: {
readonly headers: Readonly<Record<string, string>>;
readonly hookId: string;
}) => Effect.Effect<
Option.Option<{ readonly deliveryId: string; readonly receivedAt: string }>
>;
}
>()("t3/scheduledTasks/RelayDeliveryProof") {}

const make = Effect.gen(function* () {
const secrets = yield* ServerSecretStore.ServerSecretStore;
const environment = yield* ServerEnvironment.ServerEnvironment;
return (input: { readonly headers: Readonly<Record<string, string>>; readonly hookId: string }) =>
const verify: RelayDeliveryProof["Service"]["verify"] = (input) =>
Effect.gen(function* () {
const proof = input.headers[RELAY_HOOK_DELIVERY_HEADER];
const deliveryId = input.headers["x-t3-relay-delivery-id"];
Expand Down Expand Up @@ -71,4 +85,7 @@ export const makeRelayDeliveryVerifier = Effect.gen(function* () {
}
return Option.some({ deliveryId, receivedAt });
}).pipe(Effect.orElseSucceed(Option.none), Effect.withSpan("webhook.verifyRelayDelivery"));
return RelayDeliveryProof.of({ verify });
});

export const layer = Layer.effect(RelayDeliveryProof, make);
Loading
Loading