authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 16:58:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 16:58:19-07:00
log7a047d0136de606accc8270dce64f2260529c120
tree4409010d81711e1f996fdcf8099fa7ccdaeff3fd
parentcfa4f765ec05950a032b6c1cf9cdfff0c2391545
signature Signed by PGP key B5690EEEBB952194

refactor(server,relay): webhook capabilities live in services, not handlers (#16232)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

18 files changed, 964 insertions(+), 438 deletions(-)

apps/server/src/cloud/CloudPreferences.test.ts created+219
...@@ -0,0 +1,219 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as FetchHttpClient from "effect/http/FetchHttpClient";
8
9import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
10import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
11import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
12import * as CloudPreferences from "./CloudPreferences.ts";
13import {
14 HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET,
15 PUBLISH_AGENT_ACTIVITY_SECRET,
16 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
17 RELAY_URL_SECRET,
18} from "./config.ts";
19
20const encode = (value: string) => new TextEncoder().encode(value);
21
22/** A linked environment whose secret store can refuse writes, and the relay calls it made. */
23const withService = <A, E>(
24 options: {
25 readonly failHoldWrite?: boolean;
26 readonly failActivityWrite?: boolean;
27 readonly relayFails?: boolean;
28 readonly failActivityRead?: boolean;
29 readonly failHoldRead?: boolean;
30 /** The first relay call reports itself, then waits for this before answering. */
31 readonly holdFirstRelayCall?: {
32 readonly started: () => void;
33 readonly released: Promise<void>;
34 };
35 },
36 body: (input: {
37 readonly preferences: CloudPreferences.CloudPreferences["Service"];
38 readonly stored: Map<string, Uint8Array>;
39 readonly relayCalls: Array<boolean>;
40 }) => Effect.Effect<A, E, never>,
41) =>
42 Effect.gen(function* () {
43 const stored = new Map<string, Uint8Array>([
44 [RELAY_URL_SECRET, encode("https://relay.test")],
45 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, encode("credential")],
46 [HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, encode("false")],
47 [PUBLISH_AGENT_ACTIVITY_SECRET, encode("false")],
48 ]);
49 const relayCalls: Array<boolean> = [];
50 const fetch: typeof globalThis.fetch = Object.assign(
51 (_input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
52 const payload = JSON.parse(String(init?.body)) as { holdWebhooksWhileOffline: boolean };
53 relayCalls.push(payload.holdWebhooksWhileOffline);
54 if (relayCalls.length === 1 && options.holdFirstRelayCall) {
55 options.holdFirstRelayCall.started();
56 return options.holdFirstRelayCall.released.then(() => Response.json(payload));
57 }
58 return options.relayFails
59 ? Promise.resolve(new Response("unavailable", { status: 503 }))
60 : Promise.resolve(Response.json(payload));
61 },
62 { preconnect: () => {} },
63 );
64 const dependencies = Layer.mergeAll(
65 Layer.mock(ServerSecretStore.ServerSecretStore)({
66 get: (name) =>
67 (options.failActivityRead && name === PUBLISH_AGENT_ACTIVITY_SECRET) ||
68 (options.failHoldRead && name === HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)
69 ? Effect.fail(
70 new ServerSecretStore.SecretStoreReadError({
71 resource: name,
72 cause: new Error("busy"),
73 }),
74 )
75 : Effect.succeed(Option.fromNullishOr(stored.get(name))),
76 set: (name, value) =>
77 (options.failHoldWrite && name === HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET) ||
78 (options.failActivityWrite && name === PUBLISH_AGENT_ACTIVITY_SECRET)
79 ? Effect.fail(
80 new ServerSecretStore.SecretStorePersistError({
81 resource: name,
82 cause: new Error("read-only"),
83 }),
84 )
85 : Effect.sync(() => void stored.set(name, value)),
86 }),
87 Layer.mock(ServerEnvironment.ServerEnvironment)({
88 getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-1")),
89 }),
90 Layer.mock(AgentAwarenessRelay.AgentAwarenessRelay)({ requestCatchUp: () => Effect.void }),
91 );
92 return yield* Effect.gen(function* () {
93 const preferences = yield* CloudPreferences.CloudPreferences;
94 return yield* body({ preferences, stored, relayCalls });
95 }).pipe(
96 Effect.provide(CloudPreferences.layer.pipe(Layer.provide(dependencies))),
97 Effect.provideService(FetchHttpClient.Fetch, fetch),
98 );
99 });
100
101it.effect("tells the relay before saving the hold setting locally", () =>
102 withService({}, ({ preferences, stored, relayCalls }) =>
103 Effect.gen(function* () {
104 yield* preferences.update({ publishAgentActivity: true, holdWebhooksWhileOffline: true });
105 assert.deepEqual(relayCalls, [true]);
106 assert.equal(
107 new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),
108 "true",
109 );
110 }),
111 ),
112);
113
114it.effect("puts the relay back when the local save fails", () =>
115 withService({ failHoldWrite: true }, ({ preferences, stored, relayCalls }) =>
116 Effect.gen(function* () {
117 const error = yield* preferences
118 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
119 .pipe(Effect.flip);
120 assert.equal(error._tag, "EnvironmentHttpInternalServerError");
121 assert.deepEqual(relayCalls, [true, false]);
122 assert.equal(
123 new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),
124 "false",
125 );
126 }),
127 ),
128);
129
130it.effect("leaves the relay untouched when the activity setting can't be saved", () =>
131 withService({ failActivityWrite: true }, ({ preferences, stored, relayCalls }) =>
132 Effect.gen(function* () {
133 const error = yield* preferences
134 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
135 .pipe(Effect.flip);
136 assert.equal(error._tag, "EnvironmentHttpInternalServerError");
137 assert.deepEqual(relayCalls, []);
138 assert.equal(
139 new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),
140 "false",
141 );
142 }),
143 ),
144);
145
146it.effect("keeps both settings unchanged when the relay refuses the hold change", () =>
147 withService({ relayFails: true }, ({ preferences, stored }) =>
148 Effect.gen(function* () {
149 yield* preferences
150 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
151 .pipe(Effect.flip);
152 assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false");
153 assert.equal(
154 new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),
155 "false",
156 );
157 }),
158 ),
159);
160
161it.effect("changes nothing when the current activity setting can't be read", () =>
162 withService({ failActivityRead: true, relayFails: true }, ({ preferences, stored, relayCalls }) =>
163 Effect.gen(function* () {
164 const error = yield* preferences
165 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
166 .pipe(Effect.flip);
167 assert.equal(error._tag, "EnvironmentHttpInternalServerError");
168 assert.deepEqual(relayCalls, []);
169 assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false");
170 }),
171 ),
172);
173
174it.effect("two overlapping updates are applied one after the other", () => {
175 let release!: () => void;
176 let started!: () => void;
177 const released = new Promise<void>((resolve) => {
178 release = resolve;
179 });
180 const firstAtRelay = new Promise<void>((resolve) => {
181 started = resolve;
182 });
183 return withService({ holdFirstRelayCall: { started, released } }, ({ preferences, stored }) =>
184 Effect.gen(function* () {
185 const first = yield* preferences
186 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true })
187 .pipe(Effect.forkChild);
188 yield* Effect.promise(() => firstAtRelay);
189 const second = yield* preferences
190 .update({ publishAgentActivity: false, holdWebhooksWhileOffline: false })
191 .pipe(Effect.forkChild);
192 // Give the second update every chance to run; it must wait for the first.
193 yield* Effect.yieldNow;
194 assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "true");
195 release();
196 yield* Fiber.join(first);
197 yield* Fiber.join(second);
198 assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false");
199 assert.equal(
200 new TextDecoder().decode(stored.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET)),
201 "false",
202 );
203 }),
204 );
205});
206
207it.effect("changes nothing when the current hold setting can't be read", () =>
208 withService({ failHoldRead: true, failHoldWrite: true }, ({ preferences, stored, relayCalls }) =>
209 Effect.gen(function* () {
210 stored.set(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, encode("true"));
211 const error = yield* preferences
212 .update({ publishAgentActivity: true, holdWebhooksWhileOffline: false })
213 .pipe(Effect.flip);
214 assert.equal(error._tag, "EnvironmentHttpInternalServerError");
215 assert.deepEqual(relayCalls, []);
216 assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false");
217 }),
218 ),
219);
apps/server/src/cloud/CloudPreferences.ts created+130
...@@ -0,0 +1,130 @@
1import {
2 type EnvironmentCloudPreferencesRequest,
3 EnvironmentHttpBadRequestError,
4 EnvironmentHttpInternalServerError,
5} from "@t3tools/contracts";
6import * as Context from "effect/Context";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Semaphore from "effect/Semaphore";
11
12import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
13import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
14import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
15import { makeRelayEnvironmentClient } from "../relay/relayEnvironmentClient.ts";
16import {
17 HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET,
18 PUBLISH_AGENT_ACTIVITY_SECRET,
19 readRelayConnection,
20} from "./config.ts";
21
22const encode = (value: boolean) => new TextEncoder().encode(String(value));
23
24/** A failed rollback leaves a setting changed; it is logged, not hidden. */
25const rollbackFailed = (cause: unknown) =>
26 Effect.logWarning("Could not roll back a T3 Connect preference", { cause });
27
28const internalError = (message: string) => (cause: unknown) =>
29 Effect.logError(message, { cause }).pipe(
30 Effect.andThen(Effect.fail(new EnvironmentHttpInternalServerError({ message }))),
31 );
32
33export class CloudPreferences extends Context.Service<
34 CloudPreferences,
35 {
36 /**
37 * Saves this environment's T3 Connect preferences, all or nothing. The
38 * activity setting is saved first. Holding webhooks while offline is decided
39 * by the relay, so the relay is told before the local copy is saved. If
40 * either step fails, the activity setting is put back, and so is the relay
41 * when only the local save failed.
42 */
43 readonly update: (
44 input: EnvironmentCloudPreferencesRequest,
45 ) => Effect.Effect<void, EnvironmentHttpBadRequestError | EnvironmentHttpInternalServerError>;
46 }
47>()("t3/cloud/CloudPreferences") {}
48
49const make = Effect.gen(function* () {
50 const secrets = yield* ServerSecretStore.ServerSecretStore;
51 const environment = yield* ServerEnvironment.ServerEnvironment;
52 const awarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
53 const withSecrets = Effect.provideService(ServerSecretStore.ServerSecretStore, secrets);
54
55 const pushHoldWebhooksWhileOffline = Effect.fn("CloudPreferences.pushHoldWebhooksWhileOffline")(
56 function* (holdWebhooksWhileOffline: boolean) {
57 const connection = yield* readRelayConnection.pipe(withSecrets);
58 if (connection === null) {
59 return yield* new EnvironmentHttpBadRequestError({
60 message: "Link this environment to T3 Connect first.",
61 });
62 }
63 const environmentId = yield* environment.getEnvironmentId;
64 const client = yield* makeRelayEnvironmentClient(connection);
65 yield* client.server
66 .updateLinkPreferences({
67 params: { environmentId },
68 payload: { holdWebhooksWhileOffline },
69 })
70 .pipe(
71 Effect.timeout("10 seconds"),
72 Effect.catch(internalError("Could not update T3 Connect webhook settings.")),
73 );
74 },
75 );
76
77 const save = (name: string, value: boolean) =>
78 secrets
79 .set(name, encode(value))
80 .pipe(Effect.catch(internalError("Could not persist environment cloud preferences.")));
81
82 // One update at a time, so two requests can't each leave one setting behind.
83 const updateLock = yield* Semaphore.make(1);
84
85 const update: CloudPreferences["Service"]["update"] = Effect.fn("CloudPreferences.update")(
86 function* (input) {
87 // All or nothing: the activity setting is saved first, before the relay
88 // is told anything, and put back if the hold change then fails. Both
89 // current values are read up front; a failed read stops here, before
90 // anything changes, because a guessed value would be the rollback target.
91 const readCurrent = (name: string) =>
92 secrets
93 .get(name)
94 .pipe(Effect.catch(internalError("Could not read environment cloud preferences.")));
95 const previousActivity = yield* readCurrent(PUBLISH_AGENT_ACTIVITY_SECRET);
96 const previousHold = yield* readCurrent(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET);
97 yield* save(PUBLISH_AGENT_ACTIVITY_SECRET, input.publishAgentActivity);
98 if (input.holdWebhooksWhileOffline !== undefined) {
99 const next = input.holdWebhooksWhileOffline;
100 const previous = Option.match(previousHold, {
101 onNone: () => false,
102 onSome: (bytes) => new TextDecoder().decode(bytes) === "true",
103 });
104 yield* pushHoldWebhooksWhileOffline(next).pipe(
105 Effect.andThen(
106 save(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, next).pipe(
107 Effect.tapError(() =>
108 previous === next
109 ? Effect.void
110 : pushHoldWebhooksWhileOffline(previous).pipe(Effect.catch(rollbackFailed)),
111 ),
112 ),
113 ),
114 Effect.tapError(() =>
115 Option.match(previousActivity, {
116 onNone: () => secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET),
117 onSome: (bytes) => secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, bytes),
118 }).pipe(Effect.catch(rollbackFailed)),
119 ),
120 );
121 }
122 yield* awarenessRelay.requestCatchUp();
123 },
124 updateLock.withPermits(1),
125 );
126
127 return CloudPreferences.of({ update });
128});
129
130export const layer = Layer.effect(CloudPreferences, make);
apps/server/src/cloud/config.ts+16-21
...@@ -6,7 +6,7 @@ import * as Effect from "effect/Effect";...@@ -6,7 +6,7 @@ import * as Effect from "effect/Effect";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
88
9import type * as ServerSecretStore from "../auth/ServerSecretStore.ts";9import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
1010
11export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key";11export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key";
12export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config";12export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config";
...@@ -75,11 +75,10 @@ export const readAgentActivityPublishingActive = (...@@ -75,11 +75,10 @@ export const readAgentActivityPublishingActive = (
75 );75 );
76 }).pipe(Effect.orElseSucceed(() => false));76 }).pipe(Effect.orElseSucceed(() => false));
7777
78const readSecretString = (78/** A non-empty secret as text, or null when it is missing, empty or unreadable. */
79 secrets: ServerSecretStore.ServerSecretStore["Service"],79const readSecretString = (name: string) =>
80 name: string,80 ServerSecretStore.ServerSecretStore.pipe(
81): Effect.Effect<string | null> =>81 Effect.flatMap((secrets) => secrets.get(name)),
82 secrets.get(name).pipe(
83 Effect.map((bytes) =>82 Effect.map((bytes) =>
84 Option.isSome(bytes) && bytes.value.length > 0 ? new TextDecoder().decode(bytes.value) : null,83 Option.isSome(bytes) && bytes.value.length > 0 ? new TextDecoder().decode(bytes.value) : null,
85 ),84 ),
...@@ -87,20 +86,16 @@ const readSecretString = (...@@ -87,20 +86,16 @@ const readSecretString = (
87 );86 );
8887
89/** The relay URL and environment credential, or null when not linked to T3 Connect. */88/** The relay URL and environment credential, or null when not linked to T3 Connect. */
90export const readRelayConnection = (secrets: ServerSecretStore.ServerSecretStore["Service"]) =>89export const readRelayConnection = Effect.all([
91 Effect.all([90 readSecretString(RELAY_URL_SECRET),
92 readSecretString(secrets, RELAY_URL_SECRET),91 readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET),
93 readSecretString(secrets, RELAY_ENVIRONMENT_CREDENTIAL_SECRET),92]).pipe(
94 ]).pipe(93 Effect.map(([url, environmentCredential]) =>
95 Effect.map(([url, environmentCredential]) =>94 url && environmentCredential ? { url, environmentCredential } : null,
96 url && environmentCredential ? { url, environmentCredential } : null,95 ),
97 ),96);
98 );
9997
100/** Whether this environment opted in to T3 Connect holding webhooks while it is offline. */98/** Whether this environment opted in to T3 Connect holding webhooks while it is offline. */
101export const readHoldWebhooksWhileOffline = (99export const readHoldWebhooksWhileOffline = readSecretString(
102 secrets: ServerSecretStore.ServerSecretStore["Service"],100 HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET,
103) =>101).pipe(Effect.map((value) => value === "true"));
104 readSecretString(secrets, HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET).pipe(
105 Effect.map((value) => value === "true"),
106 );
apps/server/src/cloud/http.ts+16-64
...@@ -8,7 +8,6 @@ import {...@@ -8,7 +8,6 @@ import {
8 EnvironmentCloudRelayConfigResult,8 EnvironmentCloudRelayConfigResult,
9 EnvironmentHttpApi,9 EnvironmentHttpApi,
10 EnvironmentHttpBadRequestError,10 EnvironmentHttpBadRequestError,
11 type EnvironmentCloudPreferencesRequest,
12 EnvironmentHttpConflictError,11 EnvironmentHttpConflictError,
13 EnvironmentHttpInternalServerError,12 EnvironmentHttpInternalServerError,
14 EnvironmentHttpUnauthorizedError,13 EnvironmentHttpUnauthorizedError,
...@@ -70,7 +69,6 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";...@@ -70,7 +69,6 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
70import { requireEnvironmentScope } from "../auth/http.ts";69import { requireEnvironmentScope } from "../auth/http.ts";
71import * as ServerConfig from "../config.ts";70import * as ServerConfig from "../config.ts";
72import * as ServerEnvironment from "../environment/ServerEnvironment.ts";71import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
73import { makeRelayEnvironmentClient } from "../relay/relayEnvironmentClient.ts";
74import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";72import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
75import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";73import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
76import {74import {
...@@ -89,8 +87,6 @@ import {...@@ -89,8 +87,6 @@ import {
89 encodeConfirmedOriginJson,87 encodeConfirmedOriginJson,
90 PUBLISH_AGENT_ACTIVITY_SECRET,88 PUBLISH_AGENT_ACTIVITY_SECRET,
91 HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET,89 HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET,
92 readHoldWebhooksWhileOffline,
93 readRelayConnection,
94 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,90 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
95 RELAY_ISSUER_SECRET,91 RELAY_ISSUER_SECRET,
96 RELAY_URL_SECRET,92 RELAY_URL_SECRET,
...@@ -102,6 +98,7 @@ import {...@@ -102,6 +98,7 @@ import {
102 setCliDesiredCloudLink,98 setCliDesiredCloudLink,
103} from "./CliState.ts";99} from "./CliState.ts";
104import * as CliTokenManager from "./CliTokenManager.ts";100import * as CliTokenManager from "./CliTokenManager.ts";
101import * as CloudPreferences from "./CloudPreferences.ts";
105import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";102import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
106import { traceRelayRequest } from "./traceRelayRequest.ts";103import { traceRelayRequest } from "./traceRelayRequest.ts";
107import { filterRelayResponse, relayRequestError, shouldRetryCloudLink } from "./relayResponse.ts";104import { filterRelayResponse, relayRequestError, shouldRetryCloudLink } from "./relayResponse.ts";
...@@ -1358,65 +1355,6 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")(...@@ -1358,65 +1355,6 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")(
1358 ),1355 ),
1359);1356);
13601357
1361const pushHoldWebhooksWhileOffline = Effect.fn("environment.cloud.pushHoldWebhooksWhileOffline")(
1362 function* (dependencies: CloudHttpDependencies, holdWebhooksWhileOffline: boolean) {
1363 const connection = yield* readRelayConnection(dependencies.secrets);
1364 if (connection === null) {
1365 return yield* new EnvironmentHttpBadRequestError({
1366 message: "Link this environment to T3 Connect first.",
1367 });
1368 }
1369 const environmentId = yield* dependencies.environment.getEnvironmentId;
1370 const client = yield* makeRelayEnvironmentClient(connection);
1371 yield* client.server
1372 .updateLinkPreferences({
1373 params: { environmentId },
1374 payload: { holdWebhooksWhileOffline },
1375 })
1376 .pipe(
1377 Effect.timeout("10 seconds"),
1378 Effect.catch(
1379 failEnvironmentCloudInternalError("Could not update T3 Connect webhook settings."),
1380 ),
1381 );
1382 },
1383);
1384
1385const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")(
1386 function* (dependencies: CloudHttpDependencies, payload: EnvironmentCloudPreferencesRequest) {
1387 yield* requireEnvironmentScope(AuthRelayWriteScope);
1388 if (payload.holdWebhooksWhileOffline !== undefined) {
1389 // The relay decides whether to hold a request, so it is told first; the
1390 // local copy is only saved once the relay has the same value, and the
1391 // relay is put back if that save fails.
1392 const previous = yield* readHoldWebhooksWhileOffline(dependencies.secrets);
1393 yield* pushHoldWebhooksWhileOffline(dependencies, payload.holdWebhooksWhileOffline);
1394 yield* dependencies.secrets
1395 .set(
1396 HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET,
1397 stringToBytes(String(payload.holdWebhooksWhileOffline)),
1398 )
1399 .pipe(
1400 Effect.tapError(() =>
1401 previous === payload.holdWebhooksWhileOffline
1402 ? Effect.void
1403 : pushHoldWebhooksWhileOffline(dependencies, previous).pipe(Effect.ignore),
1404 ),
1405 );
1406 }
1407 yield* dependencies.secrets.set(
1408 PUBLISH_AGENT_ACTIVITY_SECRET,
1409 stringToBytes(String(payload.publishAgentActivity)),
1410 );
1411 yield* dependencies.awarenessRelay.requestCatchUp();
1412 return yield* readCloudLinkState(dependencies);
1413 },
1414 Effect.catchIf(
1415 ServerSecretStore.isSecretStoreError,
1416 failEnvironmentCloudInternalError("Could not persist environment cloud preferences."),
1417 ),
1418);
1419
1420const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")(1358const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")(
1421 function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) {1359 function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) {
1422 const cloudMintPublicKey = yield* dependencies.secrets1360 const cloudMintPublicKey = yield* dependencies.secrets
...@@ -1661,12 +1599,26 @@ export const connectHttpApiLayer = HttpApiBuilder.group(...@@ -1661,12 +1599,26 @@ export const connectHttpApiLayer = HttpApiBuilder.group(
1661 "connect",1599 "connect",
1662 Effect.fnUntraced(function* (handlers) {1600 Effect.fnUntraced(function* (handlers) {
1663 const dependencies = yield* cloudHttpDependencies;1601 const dependencies = yield* cloudHttpDependencies;
1602 const preferences = yield* CloudPreferences.CloudPreferences;
1664 return handlers1603 return handlers
1665 .handle("linkProof", ({ payload }) => cloudLinkProofHandler(dependencies, payload))1604 .handle("linkProof", ({ payload }) => cloudLinkProofHandler(dependencies, payload))
1666 .handle("relayConfig", ({ payload }) => cloudRelayConfigHandler(dependencies, payload))1605 .handle("relayConfig", ({ payload }) => cloudRelayConfigHandler(dependencies, payload))
1667 .handle("linkState", () => cloudLinkStateHandler(dependencies))1606 .handle("linkState", () => cloudLinkStateHandler(dependencies))
1668 .handle("unlink", () => cloudUnlinkHandler(dependencies))1607 .handle("unlink", () => cloudUnlinkHandler(dependencies))
1669 .handle("preferences", ({ payload }) => cloudPreferencesHandler(dependencies, payload))1608 .handle(
1609 "preferences",
1610 Effect.fn("environment.cloud.preferences")(
1611 function* ({ payload }) {
1612 yield* requireEnvironmentScope(AuthRelayWriteScope);
1613 yield* preferences.update(payload);
1614 return yield* readCloudLinkState(dependencies);
1615 },
1616 Effect.catchIf(
1617 ServerSecretStore.isSecretStoreError,
1618 failEnvironmentCloudInternalError("Could not read environment cloud preferences."),
1619 ),
1620 ),
1621 )
1670 .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload))1622 .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload))
1671 .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload))1623 .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload))
1672 .handle("t3MintCredential", ({ payload }) =>1624 .handle("t3MintCredential", ({ payload }) =>
apps/server/src/relay/HeldHooksWaker.ts+2-4
...@@ -3,7 +3,6 @@ import * as Layer from "effect/Layer";...@@ -3,7 +3,6 @@ import * as Layer from "effect/Layer";
3import * as Schedule from "effect/Schedule";3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";4import * as Stream from "effect/Stream";
55
6import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
7import * as CloudManagedEndpointRuntime from "../cloud/ManagedEndpointRuntime.ts";6import * as CloudManagedEndpointRuntime from "../cloud/ManagedEndpointRuntime.ts";
8import { readHoldWebhooksWhileOffline, readRelayConnection } from "../cloud/config.ts";7import { readHoldWebhooksWhileOffline, readRelayConnection } from "../cloud/config.ts";
9import * as ServerEnvironment from "../environment/ServerEnvironment.ts";8import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
...@@ -15,9 +14,8 @@ import { makeRelayEnvironmentClient } from "./relayEnvironmentClient.ts";...@@ -15,9 +14,8 @@ import { makeRelayEnvironmentClient } from "./relayEnvironmentClient.ts";
15 * next backoff step. Nothing happens unless the environment opted in.14 * next backoff step. Nothing happens unless the environment opted in.
16 */15 */
17const wakeHeldHooks = Effect.fn("HeldHooksWaker.wake")(function* () {16const wakeHeldHooks = Effect.fn("HeldHooksWaker.wake")(function* () {
18 const secrets = yield* ServerSecretStore.ServerSecretStore;17 if (!(yield* readHoldWebhooksWhileOffline)) return false;
19 if (!(yield* readHoldWebhooksWhileOffline(secrets))) return false;18 const connection = yield* readRelayConnection;
20 const connection = yield* readRelayConnection(secrets);
21 if (connection === null) return false;19 if (connection === null) return false;
22 const environmentId = yield* (yield* ServerEnvironment.ServerEnvironment).getEnvironmentId;20 const environmentId = yield* (yield* ServerEnvironment.ServerEnvironment).getEnvironmentId;
23 const client = yield* makeRelayEnvironmentClient(connection);21 const client = yield* makeRelayEnvironmentClient(connection);
apps/server/src/scheduledTasks/RelayDeliveryProof.ts created+91
...@@ -0,0 +1,91 @@
1/**
2 * Whether a webhook request really came through this environment's relay.
3 *
4 * The relay's delivery id, receive time and trace context arrive as plain
5 * headers, and the webhook URL can also be called directly, so they are only
6 * trusted alongside a proof the relay signed with its mint key. A request
7 * without a valid proof is handled as a direct request.
8 */
9import { RelayHookDeliveryProofPayload } from "@t3tools/contracts/relay";
10import {
11 normalizeRelayIssuer,
12 RELAY_HOOK_DELIVERY_HEADER,
13 RELAY_HOOK_DELIVERY_TYP,
14 verifyRelayJwt,
15} from "@t3tools/shared/relayJwt";
16import * as Clock from "effect/Clock";
17import * as Context from "effect/Context";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Schema from "effect/Schema";
22
23import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
24import { CLOUD_MINT_PUBLIC_KEY, RELAY_ISSUER_SECRET, RELAY_URL_SECRET } from "../cloud/config.ts";
25import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
26
27/** Covers the relay's 24-hour hold plus a margin. */
28const MAX_PROOF_AGE_SECONDS = 25 * 60 * 60;
29
30const decodePayload = Schema.decodeUnknownOption(RelayHookDeliveryProofPayload);
31const text = (bytes: Option.Option<Uint8Array>) =>
32 Option.map(bytes, (value) => new TextDecoder().decode(value));
33
34export class RelayDeliveryProof extends Context.Service<
35 RelayDeliveryProof,
36 {
37 /** The relay's own delivery id and receive time, when the request proves it came from the relay. */
38 readonly verify: (input: {
39 readonly headers: Readonly<Record<string, string>>;
40 readonly hookId: string;
41 }) => Effect.Effect<
42 Option.Option<{ readonly deliveryId: string; readonly receivedAt: string }>
43 >;
44 }
45>()("t3/scheduledTasks/RelayDeliveryProof") {}
46
47const make = Effect.gen(function* () {
48 const secrets = yield* ServerSecretStore.ServerSecretStore;
49 const environment = yield* ServerEnvironment.ServerEnvironment;
50 const verify: RelayDeliveryProof["Service"]["verify"] = (input) =>
51 Effect.gen(function* () {
52 const proof = input.headers[RELAY_HOOK_DELIVERY_HEADER];
53 const deliveryId = input.headers["x-t3-relay-delivery-id"];
54 const receivedAt = input.headers["x-t3-relay-received-at"];
55 if (proof === undefined || deliveryId === undefined || receivedAt === undefined) {
56 return Option.none();
57 }
58 const publicKey = text(yield* secrets.get(CLOUD_MINT_PUBLIC_KEY));
59 const issuer = Option.orElse(text(yield* secrets.get(RELAY_ISSUER_SECRET)), () =>
60 Option.none<string>(),
61 );
62 const relayUrl = text(yield* secrets.get(RELAY_URL_SECRET));
63 const relayIssuer = Option.isSome(issuer) ? issuer : relayUrl;
64 // Not linked to T3 Connect: no relay can be delivering to us.
65 if (Option.isNone(publicKey) || Option.isNone(relayIssuer)) return Option.none();
66 const environmentId = yield* environment.getEnvironmentId;
67 const payload = yield* verifyRelayJwt({
68 publicKey: publicKey.value,
69 token: proof,
70 typ: RELAY_HOOK_DELIVERY_TYP,
71 issuer: normalizeRelayIssuer(relayIssuer.value),
72 audience: `t3-env:${environmentId}`,
73 nowEpochSeconds: Math.floor((yield* Clock.currentTimeMillis) / 1_000),
74 maxTokenAge: MAX_PROOF_AGE_SECONDS,
75 }).pipe(Effect.map(decodePayload), Effect.orElseSucceed(Option.none));
76 // The proof must name exactly this delivery, so it cannot be lifted onto another one.
77 if (
78 Option.isNone(payload) ||
79 payload.value.environmentId !== environmentId ||
80 payload.value.deliveryId !== deliveryId ||
81 payload.value.receivedAt !== receivedAt ||
82 payload.value.hookId !== input.hookId
83 ) {
84 return Option.none();
85 }
86 return Option.some({ deliveryId, receivedAt });
87 }).pipe(Effect.orElseSucceed(Option.none), Effect.withSpan("webhook.verifyRelayDelivery"));
88 return RelayDeliveryProof.of({ verify });
89});
90
91export const layer = Layer.effect(RelayDeliveryProof, make);
apps/server/src/scheduledTasks/relayDeliveryProof.ts deleted-74
...@@ -1,74 +0,0 @@
1/**
2 * Whether a webhook request really came through this environment's relay.
3 *
4 * The relay's delivery id, receive time and trace context arrive as plain
5 * headers, and the webhook URL can also be called directly, so they are only
6 * trusted alongside a proof the relay signed with its mint key. A request
7 * without a valid proof is handled as a direct request.
8 */
9import { RelayHookDeliveryProofPayload } from "@t3tools/contracts/relay";
10import {
11 normalizeRelayIssuer,
12 RELAY_HOOK_DELIVERY_HEADER,
13 RELAY_HOOK_DELIVERY_TYP,
14 verifyRelayJwt,
15} from "@t3tools/shared/relayJwt";
16import * as Clock from "effect/Clock";
17import * as Effect from "effect/Effect";
18import * as Option from "effect/Option";
19import * as Schema from "effect/Schema";
20
21import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
22import { CLOUD_MINT_PUBLIC_KEY, RELAY_ISSUER_SECRET, RELAY_URL_SECRET } from "../cloud/config.ts";
23import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
24
25/** Covers the relay's 24-hour hold plus a margin. */
26const MAX_PROOF_AGE_SECONDS = 25 * 60 * 60;
27
28const decodePayload = Schema.decodeUnknownOption(RelayHookDeliveryProofPayload);
29const text = (bytes: Option.Option<Uint8Array>) =>
30 Option.map(bytes, (value) => new TextDecoder().decode(value));
31
32/** The relay's own delivery id and receive time, when the request proves it came from the relay. */
33export const makeRelayDeliveryVerifier = Effect.gen(function* () {
34 const secrets = yield* ServerSecretStore.ServerSecretStore;
35 const environment = yield* ServerEnvironment.ServerEnvironment;
36 return (input: { readonly headers: Readonly<Record<string, string>>; readonly hookId: string }) =>
37 Effect.gen(function* () {
38 const proof = input.headers[RELAY_HOOK_DELIVERY_HEADER];
39 const deliveryId = input.headers["x-t3-relay-delivery-id"];
40 const receivedAt = input.headers["x-t3-relay-received-at"];
41 if (proof === undefined || deliveryId === undefined || receivedAt === undefined) {
42 return Option.none();
43 }
44 const publicKey = text(yield* secrets.get(CLOUD_MINT_PUBLIC_KEY));
45 const issuer = Option.orElse(text(yield* secrets.get(RELAY_ISSUER_SECRET)), () =>
46 Option.none<string>(),
47 );
48 const relayUrl = text(yield* secrets.get(RELAY_URL_SECRET));
49 const relayIssuer = Option.isSome(issuer) ? issuer : relayUrl;
50 // Not linked to T3 Connect: no relay can be delivering to us.
51 if (Option.isNone(publicKey) || Option.isNone(relayIssuer)) return Option.none();
52 const environmentId = yield* environment.getEnvironmentId;
53 const payload = yield* verifyRelayJwt({
54 publicKey: publicKey.value,
55 token: proof,
56 typ: RELAY_HOOK_DELIVERY_TYP,
57 issuer: normalizeRelayIssuer(relayIssuer.value),
58 audience: `t3-env:${environmentId}`,
59 nowEpochSeconds: Math.floor((yield* Clock.currentTimeMillis) / 1_000),
60 maxTokenAge: MAX_PROOF_AGE_SECONDS,
61 }).pipe(Effect.map(decodePayload), Effect.orElseSucceed(Option.none));
62 // The proof must name exactly this delivery, so it cannot be lifted onto another one.
63 if (
64 Option.isNone(payload) ||
65 payload.value.environmentId !== environmentId ||
66 payload.value.deliveryId !== deliveryId ||
67 payload.value.receivedAt !== receivedAt ||
68 payload.value.hookId !== input.hookId
69 ) {
70 return Option.none();
71 }
72 return Option.some({ deliveryId, receivedAt });
73 }).pipe(Effect.orElseSucceed(Option.none), Effect.withSpan("webhook.verifyRelayDelivery"));
74});
apps/server/src/scheduledTasks/webhookRoute.test.ts+2
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26import * as ServerSecretStore from "../auth/ServerSecretStore.ts";26import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
27import { CLOUD_MINT_PUBLIC_KEY, RELAY_ISSUER_SECRET } from "../cloud/config.ts";27import { CLOUD_MINT_PUBLIC_KEY, RELAY_ISSUER_SECRET } from "../cloud/config.ts";
28import * as ServerEnvironment from "../environment/ServerEnvironment.ts";28import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
29import * as RelayDeliveryProof from "./RelayDeliveryProof.ts";
29import { WEBHOOK_MAX_BODY_BYTES, webhookHttpApiLayer } from "./webhookRoute.ts";30import { WEBHOOK_MAX_BODY_BYTES, webhookHttpApiLayer } from "./webhookRoute.ts";
3031
31class WebhookTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.webhooks) {}32class WebhookTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.webhooks) {}
...@@ -77,6 +78,7 @@ const handlerFor = (...@@ -77,6 +78,7 @@ const handlerFor = (
77 HttpRouter.toWebHandler(78 HttpRouter.toWebHandler(
78 HttpApiBuilder.layer(WebhookTestApi).pipe(79 HttpApiBuilder.layer(WebhookTestApi).pipe(
79 Layer.provide(webhookHttpApiLayer),80 Layer.provide(webhookHttpApiLayer),
81 Layer.provide(RelayDeliveryProof.layer),
80 Layer.provide(Layer.mock(ScheduledTaskService)({ triggerWebhook: trigger })),82 Layer.provide(Layer.mock(ScheduledTaskService)({ triggerWebhook: trigger })),
81 Layer.provide(83 Layer.provide(
82 Layer.mock(ServerSecretStore.ServerSecretStore)({84 Layer.mock(ServerSecretStore.ServerSecretStore)({
apps/server/src/scheduledTasks/webhookRoute.ts+104-112
...@@ -11,7 +11,7 @@ import { withRelayClientTracing } from "@t3tools/shared/relayTracing";...@@ -11,7 +11,7 @@ import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
11import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";11import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
1212
13import * as Metrics from "../observability/Metrics.ts";13import * as Metrics from "../observability/Metrics.ts";
14import { makeRelayDeliveryVerifier } from "./relayDeliveryProof.ts";14import * as RelayDeliveryProof from "./RelayDeliveryProof.ts";
15import * as ScheduledTaskService from "./ScheduledTaskService.ts";15import * as ScheduledTaskService from "./ScheduledTaskService.ts";
1616
17/** Largest request body a webhook accepts. The relay enforces the same cap. */17/** Largest request body a webhook accepts. The relay enforces the same cap. */
...@@ -23,121 +23,113 @@ const WEBHOOK_OUTCOME_HEADER = "x-t3-hook-outcome";...@@ -23,121 +23,113 @@ const WEBHOOK_OUTCOME_HEADER = "x-t3-hook-outcome";
23const json = (status: number, body: Record<string, string>, outcome: string) =>23const json = (status: number, body: Record<string, string>, outcome: string) =>
24 HttpServerResponse.jsonUnsafe(body, { status, headers: { [WEBHOOK_OUTCOME_HEADER]: outcome } });24 HttpServerResponse.jsonUnsafe(body, { status, headers: { [WEBHOOK_OUTCOME_HEADER]: outcome } });
2525
26/**
27 * Handles `/api/hooks/:hookId/:token` for every accepted method. The endpoint
28 * is raw so the signature is checked over the exact body bytes; the service
29 * checks the token and signature. It is reachable directly, over the managed
30 * tunnel, or through the relay's stable `/v1/hooks/:endpointKey/:hookId/:token`
31 * URL, where the endpoint key is this environment's managed tunnel key.
32 */
33const handleWebhook =
34 (
35 scheduledTasks: ScheduledTaskService.ScheduledTaskService["Service"],
36 verifyRelayDelivery: Effect.Success<typeof makeRelayDeliveryVerifier>,
37 ) =>
38 ({
39 params,
40 request,
41 }: {
42 readonly params: { readonly hookId: string; readonly token: string };
43 readonly request: HttpServerRequest.HttpServerRequest;
44 }) =>
45 Effect.gen(function* () {
46 const contentLength = Number(request.headers["content-length"] ?? "0");
47 if (!Number.isFinite(contentLength) || contentLength > WEBHOOK_MAX_BODY_BYTES) {
48 return json(413, { error: "body_too_large" }, "body_too_large");
49 }
50 // Chunked requests carry no content-length, so the reader itself is capped.
51 const body = yield* request.arrayBuffer.pipe(
52 Effect.map((buffer) => new Uint8Array(buffer)),
53 Effect.provideService(
54 HttpIncomingMessage.MaxBodySize,
55 ByteSize.bytes(WEBHOOK_MAX_BODY_BYTES),
56 ),
57 Effect.option,
58 );
59 // Refused before a task is looked up, so the service never sees them.
60 const tooLarge = (error: string) =>
61 Metrics.increment(Metrics.webhookDeliveriesTotal, {
62 outcome: "body_too_large",
63 source: request.headers["x-t3-relay-delivery-id"] ? "relay" : "direct",
64 }).pipe(Effect.as(json(413, { error }, "body_too_large")));
65 if (Option.isNone(body)) return yield* tooLarge("body_too_large_or_unreadable");
66 if (body.value.byteLength > WEBHOOK_MAX_BODY_BYTES) {
67 return yield* tooLarge("body_too_large");
68 }
69
70 const headers: Record<string, string> = {};
71 for (const [name, value] of Object.entries(request.headers)) {
72 if (typeof value === "string") headers[name.toLowerCase()] = value;
73 }
74 const queryIndex = request.url.indexOf("?");
75 // The relay's delivery id and receive time count only with its signed
76 // proof; the URL can also be called directly. The receive time matters
77 // for requests the relay held while we were offline.
78 const relay = yield* verifyRelayDelivery({ headers, hookId: params.hookId });
79 const relayDeliveryId = Option.isSome(relay) ? relay.value.deliveryId : undefined;
80 const relayReceivedAt = Option.isSome(relay) ? relay.value.receivedAt : undefined;
81
82 // A relay delivery joins the relay's trace, and goes to the T3 Connect
83 // tracer with it. Anyone else's traceparent is never trusted.
84 const relayParent = Option.isSome(relay)
85 ? HttpTraceContext.fromHeaders(request.headers)
86 : Option.none<Tracer.ExternalSpan>();
87 const result = yield* scheduledTasks
88 .triggerWebhook({
89 hookId: params.hookId,
90 token: params.token,
91 method: request.method,
92 path: `${ScheduledTaskService.WEBHOOK_ROUTE_PREFIX}/${encodeURIComponent(params.hookId)}`,
93 query: queryIndex === -1 ? "" : request.url.slice(queryIndex + 1),
94 headers,
95 body: body.value,
96 bodyText: new TextDecoder().decode(body.value),
97 ...(relayDeliveryId ? { relayDeliveryId } : {}),
98 ...(relayReceivedAt ? { receivedAt: relayReceivedAt } : {}),
99 })
100 .pipe(
101 Option.isSome(relayParent)
102 ? (effect) =>
103 effect.pipe(Effect.withParentSpan(relayParent.value), withRelayClientTracing)
104 : (effect) => effect,
105 // Defects too, so the sender only ever sees the fixed error body.
106 Effect.catchCause((cause) =>
107 Effect.logWarning("Webhook delivery failed").pipe(
108 Effect.annotateLogs({ hookId: params.hookId }),
109 Effect.andThen(Effect.logDebug("Webhook delivery failure cause", { cause })),
110 Effect.as({ _tag: "error" as const }),
111 ),
112 ),
113 );
114
115 switch (result._tag) {
116 case "accepted":
117 return json(202, { deliveryId: result.deliveryId }, result.outcome);
118 case "not_found":
119 return json(404, { error: "hook_not_found" }, "not_found");
120 case "rejected_signature":
121 return json(401, { error: "invalid_signature" }, "rejected_signature");
122 case "disabled":
123 return json(409, { error: "hook_disabled" }, "disabled");
124 case "rate_limited":
125 return json(429, { error: "rate_limited" }, result.outcome);
126 case "expired":
127 return json(410, { error: "delivery_too_old" }, "expired");
128 case "error":
129 return json(500, { error: "internal_error" }, "error");
130 }
131 });
132
133export const webhookHttpApiLayer = HttpApiBuilder.group(26export const webhookHttpApiLayer = HttpApiBuilder.group(
134 EnvironmentHttpApi,27 EnvironmentHttpApi,
135 "webhooks",28 "webhooks",
136 Effect.fnUntraced(function* (handlers) {29 Effect.fnUntraced(function* (handlers) {
137 const handler = handleWebhook(30 const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService;
138 yield* ScheduledTaskService.ScheduledTaskService,31 const relayDeliveryProof = yield* RelayDeliveryProof.RelayDeliveryProof;
139 yield* makeRelayDeliveryVerifier,32 /**
140 );33 * Handles `/api/hooks/:hookId/:token` for every accepted method. The endpoint
34 * is raw so the signature is checked over the exact body bytes; the service
35 * checks the token and signature. It is reachable directly, over the managed
36 * tunnel, or through the relay's stable `/v1/hooks/:endpointKey/:hookId/:token`
37 * URL, where the endpoint key is this environment's managed tunnel key.
38 */
39 const handler = ({
40 params,
41 request,
42 }: {
43 readonly params: { readonly hookId: string; readonly token: string };
44 readonly request: HttpServerRequest.HttpServerRequest;
45 }) =>
46 Effect.gen(function* () {
47 const contentLength = Number(request.headers["content-length"] ?? "0");
48 if (!Number.isFinite(contentLength) || contentLength > WEBHOOK_MAX_BODY_BYTES) {
49 return json(413, { error: "body_too_large" }, "body_too_large");
50 }
51 // Chunked requests carry no content-length, so the reader itself is capped.
52 const body = yield* request.arrayBuffer.pipe(
53 Effect.map((buffer) => new Uint8Array(buffer)),
54 Effect.provideService(
55 HttpIncomingMessage.MaxBodySize,
56 ByteSize.bytes(WEBHOOK_MAX_BODY_BYTES),
57 ),
58 Effect.option,
59 );
60 // Refused before a task is looked up, so the service never sees them.
61 const tooLarge = (error: string) =>
62 Metrics.increment(Metrics.webhookDeliveriesTotal, {
63 outcome: "body_too_large",
64 source: request.headers["x-t3-relay-delivery-id"] ? "relay" : "direct",
65 }).pipe(Effect.as(json(413, { error }, "body_too_large")));
66 if (Option.isNone(body)) return yield* tooLarge("body_too_large_or_unreadable");
67 if (body.value.byteLength > WEBHOOK_MAX_BODY_BYTES) {
68 return yield* tooLarge("body_too_large");
69 }
70
71 const headers: Record<string, string> = {};
72 for (const [name, value] of Object.entries(request.headers)) {
73 if (typeof value === "string") headers[name.toLowerCase()] = value;
74 }
75 const queryIndex = request.url.indexOf("?");
76 // The relay's delivery id and receive time count only with its signed
77 // proof; the URL can also be called directly. The receive time matters
78 // for requests the relay held while we were offline.
79 const relay = yield* relayDeliveryProof.verify({ headers, hookId: params.hookId });
80 const relayDeliveryId = Option.isSome(relay) ? relay.value.deliveryId : undefined;
81 const relayReceivedAt = Option.isSome(relay) ? relay.value.receivedAt : undefined;
82
83 // A relay delivery joins the relay's trace, and goes to the T3 Connect
84 // tracer with it. Anyone else's traceparent is never trusted.
85 const relayParent = Option.isSome(relay)
86 ? HttpTraceContext.fromHeaders(request.headers)
87 : Option.none<Tracer.ExternalSpan>();
88 const result = yield* scheduledTasks
89 .triggerWebhook({
90 hookId: params.hookId,
91 token: params.token,
92 method: request.method,
93 path: `${ScheduledTaskService.WEBHOOK_ROUTE_PREFIX}/${encodeURIComponent(params.hookId)}`,
94 query: queryIndex === -1 ? "" : request.url.slice(queryIndex + 1),
95 headers,
96 body: body.value,
97 bodyText: new TextDecoder().decode(body.value),
98 ...(relayDeliveryId ? { relayDeliveryId } : {}),
99 ...(relayReceivedAt ? { receivedAt: relayReceivedAt } : {}),
100 })
101 .pipe(
102 Option.isSome(relayParent)
103 ? (effect) =>
104 effect.pipe(Effect.withParentSpan(relayParent.value), withRelayClientTracing)
105 : (effect) => effect,
106 // Defects too, so the sender only ever sees the fixed error body.
107 Effect.catchCause((cause) =>
108 Effect.logWarning("Webhook delivery failed").pipe(
109 Effect.annotateLogs({ hookId: params.hookId }),
110 Effect.andThen(Effect.logDebug("Webhook delivery failure cause", { cause })),
111 Effect.as({ _tag: "error" as const }),
112 ),
113 ),
114 );
115
116 switch (result._tag) {
117 case "accepted":
118 return json(202, { deliveryId: result.deliveryId }, result.outcome);
119 case "not_found":
120 return json(404, { error: "hook_not_found" }, "not_found");
121 case "rejected_signature":
122 return json(401, { error: "invalid_signature" }, "rejected_signature");
123 case "disabled":
124 return json(409, { error: "hook_disabled" }, "disabled");
125 case "rate_limited":
126 return json(429, { error: "rate_limited" }, result.outcome);
127 case "expired":
128 return json(410, { error: "delivery_too_old" }, "expired");
129 case "error":
130 return json(500, { error: "internal_error" }, "error");
131 }
132 });
141 return handlers133 return handlers
142 .handleRaw("webhookPost", handler)134 .handleRaw("webhookPost", handler)
143 .handleRaw("webhookPut", handler)135 .handleRaw("webhookPut", handler)
apps/server/src/secrets/SecretRequests.ts+30-17
...@@ -12,7 +12,6 @@ import {...@@ -12,7 +12,6 @@ import {
12 CommandId,12 CommandId,
13 SecretRef,13 SecretRef,
14 SecretRequestError,14 SecretRequestError,
15 type SecretRequestFailureReason,
16 type ProjectId,15 type ProjectId,
17 type SecretRequestAnswerInput,16 type SecretRequestAnswerInput,
18 type ThreadId,17 type ThreadId,
...@@ -62,9 +61,6 @@ const StoredSecret = Schema.fromJsonString(...@@ -62,9 +61,6 @@ const StoredSecret = Schema.fromJsonString(
62const encodeStored = Schema.encodeEffect(StoredSecret);61const encodeStored = Schema.encodeEffect(StoredSecret);
63const decodeStored = Schema.decodeUnknownOption(StoredSecret);62const decodeStored = Schema.decodeUnknownOption(StoredSecret);
6463
65const fail = (reason: SecretRequestFailureReason, cause?: unknown) =>
66 new SecretRequestError({ reason, ...(cause === undefined ? {} : { cause }) });
67
68export class SecretRequests extends Context.Service<64export class SecretRequests extends Context.Service<
69 SecretRequests,65 SecretRequests,
70 {66 {
...@@ -109,19 +105,23 @@ const make = Effect.gen(function* () {...@@ -109,19 +105,23 @@ const make = Effect.gen(function* () {
109 turnItemTypes: ["secret_request"],105 turnItemTypes: ["secret_request"],
110 messageRoles: [],106 messageRoles: [],
111 })107 })
112 .pipe(Effect.mapError((cause) => fail("load_failed", cause)));108 .pipe(
109 Effect.mapError(
110 (cause) => new SecretRequestError({ reason: "load_failed", cause: cause }),
111 ),
112 );
113 const item = records.turnItems.find((candidate) => candidate.id === input.turnItemId);113 const item = records.turnItems.find((candidate) => candidate.id === input.turnItemId);
114 if (item?.type !== "secret_request" || item.runId === null || item.nodeId === null) {114 if (item?.type !== "secret_request" || item.runId === null || item.nodeId === null) {
115 return yield* fail("not_found");115 return yield* new SecretRequestError({ reason: "not_found" });
116 }116 }
117 if (item.secretStatus !== "pending") {117 if (item.secretStatus !== "pending") {
118 return yield* fail("already_answered");118 return yield* new SecretRequestError({ reason: "already_answered" });
119 }119 }
120 // The agent is waiting inside the run that asked; once it has ended,120 // The agent is waiting inside the run that asked; once it has ended,
121 // nobody will ever receive the ref, so a value saved now would be lost.121 // nobody will ever receive the ref, so a value saved now would be lost.
122 const run = records.runs.find((candidate) => candidate.id === item.runId);122 const run = records.runs.find((candidate) => candidate.id === item.runId);
123 if (run === undefined || ThreadManagementService.isTerminalRunStatus(run.status)) {123 if (run === undefined || ThreadManagementService.isTerminalRunStatus(run.status)) {
124 return yield* fail("agent_stopped");124 return yield* new SecretRequestError({ reason: "agent_stopped" });
125 }125 }
126 // Store first: the card only says saved once the value is kept. Create,126 // Store first: the card only says saved once the value is kept. Create,
127 // not set: a second answer racing this one must not replace the value.127 // not set: a second answer racing this one must not replace the value.
...@@ -140,7 +140,9 @@ const make = Effect.gen(function* () {...@@ -140,7 +140,9 @@ const make = Effect.gen(function* () {
140 )140 )
141 .pipe(141 .pipe(
142 Effect.catchIf(ServerSecretStore.isSecretAlreadyExistsError, () => Effect.void),142 Effect.catchIf(ServerSecretStore.isSecretAlreadyExistsError, () => Effect.void),
143 Effect.mapError((error) => fail("store_failed", error)),143 Effect.mapError(
144 (error) => new SecretRequestError({ reason: "store_failed", cause: error }),
145 ),
144 );146 );
145 }147 }
146 const secretStatus = input.answer.type === "save" ? "saved" : "declined";148 const secretStatus = input.answer.type === "save" ? "saved" : "declined";
...@@ -158,7 +160,9 @@ const make = Effect.gen(function* () {...@@ -158,7 +160,9 @@ const make = Effect.gen(function* () {
158 secretStatus,160 secretStatus,
159 })161 })
160 .pipe(162 .pipe(
161 Effect.mapError((cause) => fail("record_failed", cause)),163 Effect.mapError(
164 (cause) => new SecretRequestError({ reason: "record_failed", cause: cause }),
165 ),
162 // The card still says pending, so the user can save again; the value166 // The card still says pending, so the user can save again; the value
163 // stored above would make that retry look already answered.167 // stored above would make that retry look already answered.
164 Effect.tapError(() =>168 Effect.tapError(() =>
...@@ -176,11 +180,15 @@ const make = Effect.gen(function* () {...@@ -176,11 +180,15 @@ const make = Effect.gen(function* () {
176 turnItemTypes: ["secret_request"],180 turnItemTypes: ["secret_request"],
177 messageRoles: [],181 messageRoles: [],
178 })182 })
179 .pipe(Effect.mapError((cause) => fail("record_failed", cause)));183 .pipe(
184 Effect.mapError(
185 (cause) => new SecretRequestError({ reason: "record_failed", cause: cause }),
186 ),
187 );
180 const card = recorded.turnItems.find((candidate) => candidate.id === item.id);188 const card = recorded.turnItems.find((candidate) => candidate.id === item.id);
181 if (card?.type === "secret_request" && card.secretStatus !== "saved") {189 if (card?.type === "secret_request" && card.secretStatus !== "saved") {
182 yield* removeLogged(storeName(refFor(salt, input.threadId, item.id)));190 yield* removeLogged(storeName(refFor(salt, input.threadId, item.id)));
183 return yield* fail("agent_stopped");191 return yield* new SecretRequestError({ reason: "agent_stopped" });
184 }192 }
185 }).pipe(Effect.withSpan("SecretRequests.answer"));193 }).pipe(Effect.withSpan("SecretRequests.answer"));
186194
...@@ -217,25 +225,30 @@ const make = Effect.gen(function* () {...@@ -217,25 +225,30 @@ const make = Effect.gen(function* () {
217225
218 const consumeRef = (input: { readonly ref: SecretRef; readonly projectId: ProjectId }) =>226 const consumeRef = (input: { readonly ref: SecretRef; readonly projectId: ProjectId }) =>
219 Effect.gen(function* () {227 Effect.gen(function* () {
220 if (!REF_PATTERN.test(input.ref)) return yield* fail("invalid_ref");228 if (!REF_PATTERN.test(input.ref))
229 return yield* new SecretRequestError({ reason: "invalid_ref" });
221 const stored = yield* store230 const stored = yield* store
222 .get(storeName(input.ref))231 .get(storeName(input.ref))
223 .pipe(Effect.mapError((cause) => fail("read_failed", cause)));232 .pipe(
233 Effect.mapError(
234 (cause) => new SecretRequestError({ reason: "read_failed", cause: cause }),
235 ),
236 );
224 const decoded = Option.flatMap(stored, (bytes) =>237 const decoded = Option.flatMap(stored, (bytes) =>
225 decodeStored(new TextDecoder().decode(bytes)),238 decodeStored(new TextDecoder().decode(bytes)),
226 );239 );
227 if (Option.isNone(decoded) || decoded.value.projectId !== input.projectId) {240 if (Option.isNone(decoded) || decoded.value.projectId !== input.projectId) {
228 return yield* fail("ref_unavailable");241 return yield* new SecretRequestError({ reason: "ref_unavailable" });
229 }242 }
230 if ((yield* Clock.currentTimeMillis) - decoded.value.savedAt > SECRET_REF_TTL_MS) {243 if ((yield* Clock.currentTimeMillis) - decoded.value.savedAt > SECRET_REF_TTL_MS) {
231 // The hourly sweep retries a removal that fails here.244 // The hourly sweep retries a removal that fails here.
232 yield* removeLogged(storeName(input.ref));245 yield* removeLogged(storeName(input.ref));
233 return yield* fail("ref_expired");246 return yield* new SecretRequestError({ reason: "ref_expired" });
234 }247 }
235 // One use: the value moves into whatever consumed it. If it cannot be248 // One use: the value moves into whatever consumed it. If it cannot be
236 // deleted, it is not handed out, so a ref is never used twice.249 // deleted, it is not handed out, so a ref is never used twice.
237 if (!(yield* removeLogged(storeName(input.ref)))) {250 if (!(yield* removeLogged(storeName(input.ref)))) {
238 return yield* fail("consume_failed");251 return yield* new SecretRequestError({ reason: "consume_failed" });
239 }252 }
240 return decoded.value.value;253 return decoded.value.value;
241 });254 });
apps/server/src/server.ts+4-2
...@@ -119,6 +119,8 @@ import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http...@@ -119,6 +119,8 @@ import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http
119import * as ReplayMarkers from "./auth/replayMarkers.ts";119import * as ReplayMarkers from "./auth/replayMarkers.ts";
120import * as ServerSecretStore from "./auth/ServerSecretStore.ts";120import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
121import { webhookHttpApiLayer } from "./scheduledTasks/webhookRoute.ts";121import { webhookHttpApiLayer } from "./scheduledTasks/webhookRoute.ts";
122import * as RelayDeliveryProof from "./scheduledTasks/RelayDeliveryProof.ts";
123import * as CloudPreferences from "./cloud/CloudPreferences.ts";
122import * as HeldHooksWaker from "./relay/HeldHooksWaker.ts";124import * as HeldHooksWaker from "./relay/HeldHooksWaker.ts";
123import {125import {
124 relayHookBaseUrl,126 relayHookBaseUrl,
...@@ -679,12 +681,12 @@ const makeRoutesLayer = Layer.mergeAll(...@@ -679,12 +681,12 @@ const makeRoutesLayer = Layer.mergeAll(
679 Layer.mergeAll(681 Layer.mergeAll(
680 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(682 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(
681 Layer.provide(authHttpApiLayer),683 Layer.provide(authHttpApiLayer),
682 Layer.provide(connectHttpApiLayer),684 Layer.provide(connectHttpApiLayer.pipe(Layer.provide(CloudPreferences.layer))),
683 Layer.provide(orchestrationHttpApiLayer),685 Layer.provide(orchestrationHttpApiLayer),
684 Layer.provide(pullRequestHttpApiLayer),686 Layer.provide(pullRequestHttpApiLayer),
685 Layer.provide(projectHttpApiLayer),687 Layer.provide(projectHttpApiLayer),
686 Layer.provide(serverEnvironmentHttpApiLayer),688 Layer.provide(serverEnvironmentHttpApiLayer),
687 Layer.provide(webhookHttpApiLayer),689 Layer.provide(webhookHttpApiLayer.pipe(Layer.provide(RelayDeliveryProof.layer))),
688 Layer.provide(environmentAuthenticatedAuthLayer),690 Layer.provide(environmentAuthenticatedAuthLayer),
689 ),691 ),
690 otlpTracesProxyRouteLayer,692 otlpTracesProxyRouteLayer,
infra/relay/src/hooks/HeldHooks.test.ts created+157
...@@ -0,0 +1,157 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Redacted from "effect/Redacted";
5
6import * as RelayConfiguration from "../Config.ts";
7import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
8import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts";
9import * as HeldHooks from "./HeldHooks.ts";
10import * as HookInbox from "./HookInbox.ts";
11
12const settings = {
13 managedEndpointBaseDomain: "example.test",
14 managedEndpointNamespace: "dev",
15 cloudMintPrivateKey: Redacted.make("unused"),
16} as RelayConfiguration.RelayConfiguration["Service"];
17
18const environmentId = "env-hook";
19const ownKey = "0123456789abcdef";
20const otherKey = "fedcba9876543210";
21
22const allocationFor = (
23 userId: string,
24 key: string,
25 ready: boolean,
26): ManagedEndpointAllocations.ManagedEndpointAllocation => ({
27 userId,
28 environmentId,
29 hostname: `${key}.example.test`,
30 tunnelId: `tunnel-${key}`,
31 tunnelName: `t3coderelay-managedendpoint-dev-${key}`,
32 dnsRecordId: "dns-record-id",
33 readyAt: ready ? "2026-05-25T00:00:00.000Z" : null,
34 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
35 updatedAt: "2026-05-25T00:00:00.000Z",
36 generation: 1,
37});
38
39const linkFor = (userId: string, key: string, environmentPublicKey: string) => ({
40 userId,
41 environmentId: environmentId as never,
42 label: "Hook env",
43 endpoint: {
44 httpBaseUrl: `https://${key}.example.test/`,
45 wsBaseUrl: `wss://${key}.example.test/ws`,
46 providerKind: "cloudflare_tunnel" as const,
47 },
48 environmentPublicKey,
49 linkedAt: "2026-05-25T00:00:00.000Z",
50 holdWebhooksWhileOffline: true,
51});
52
53/** This environment's own link (user_1) and another account's link of the same environment. */
54const withService = <A, E>(
55 options: { readonly ownReady?: boolean; readonly pendingKeys?: ReadonlyArray<string> },
56 body: (input: {
57 readonly heldHooks: HeldHooks.HeldHooks["Service"];
58 readonly cleared: Array<string>;
59 readonly woken: Array<string>;
60 }) => Effect.Effect<A, E>,
61) => {
62 const cleared: Array<string> = [];
63 const woken: Array<string> = [];
64 const links = [linkFor("user_1", ownKey, "own-key"), linkFor("user_2", otherKey, "other-key")];
65 const allocations = [
66 allocationFor("user_1", ownKey, options.ownReady ?? true),
67 allocationFor("user_2", otherKey, true),
68 ];
69 const dependencies = Layer.mergeAll(
70 Layer.succeed(RelayConfiguration.RelayConfiguration, settings),
71 Layer.mock(EnvironmentLinks.EnvironmentLinks, {
72 findActiveManagedForEnvironment: (input) =>
73 Effect.succeed(
74 links.filter(
75 (link) =>
76 link.environmentId === input.environmentId &&
77 (input.userId === undefined || link.userId === input.userId) &&
78 (input.environmentPublicKey === undefined ||
79 link.environmentPublicKey === input.environmentPublicKey),
80 ),
81 ),
82 setHoldWebhooksWhileOffline: () => Effect.void,
83 }),
84 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations, {
85 get: (input) =>
86 Effect.succeed(
87 allocations.find((allocation) => allocation.userId === input.userId) ?? null,
88 ),
89 getByTunnelName: (tunnelName) =>
90 Effect.succeed(
91 allocations.find((allocation) => allocation.tunnelName === tunnelName) ?? null,
92 ),
93 }),
94 Layer.mock(HookInbox.HookInbox, {
95 clear: ({ endpointKey }) => Effect.sync(() => void cleared.push(endpointKey)),
96 wake: ({ endpointKey }) =>
97 Effect.sync(() => {
98 woken.push(endpointKey);
99 return (options.pendingKeys ?? []).includes(endpointKey);
100 }),
101 }),
102 );
103 return Effect.gen(function* () {
104 const heldHooks = yield* HeldHooks.HeldHooks;
105 return yield* body({ heldHooks, cleared, woken });
106 }).pipe(Effect.provide(HeldHooks.layer.pipe(Layer.provide(dependencies))));
107};
108
109describe("HeldHooks", () => {
110 it.effect("opting out clears only the caller's own endpoints", () =>
111 withService({}, ({ heldHooks, cleared }) =>
112 Effect.gen(function* () {
113 yield* heldHooks.setHoldWhileOffline({
114 environmentId,
115 environmentPublicKey: "own-key",
116 holdWebhooksWhileOffline: false,
117 });
118 expect(cleared).toEqual([ownKey]);
119 }),
120 ),
121 );
122
123 it.effect("opting in keeps what is held", () =>
124 withService({}, ({ heldHooks, cleared }) =>
125 Effect.gen(function* () {
126 yield* heldHooks.setHoldWhileOffline({
127 environmentId,
128 environmentPublicKey: "own-key",
129 holdWebhooksWhileOffline: true,
130 });
131 expect(cleared).toEqual([]);
132 }),
133 ),
134 );
135
136 it.effect("wakes the caller's ready endpoints and reports whether anything was waiting", () =>
137 withService({ pendingKeys: [ownKey] }, ({ heldHooks, woken }) =>
138 Effect.gen(function* () {
139 expect(yield* heldHooks.wake({ environmentId, environmentPublicKey: "own-key" })).toBe(
140 true,
141 );
142 expect(woken).toEqual([ownKey]);
143 }),
144 ),
145 );
146
147 it.effect("does not wake an endpoint that is not ready", () =>
148 withService({ ownReady: false, pendingKeys: [ownKey] }, ({ heldHooks, woken }) =>
149 Effect.gen(function* () {
150 expect(yield* heldHooks.wake({ environmentId, environmentPublicKey: "own-key" })).toBe(
151 false,
152 );
153 expect(woken).toEqual([]);
154 }),
155 ),
156 );
157});
infra/relay/src/hooks/HeldHooks.ts created+143
...@@ -0,0 +1,143 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Result from "effect/Result";
5
6import * as RelayConfiguration from "../Config.ts";
7import {
8 MANAGED_ENDPOINT_KEY_PATTERN,
9 managedEndpointTunnelNameForKey,
10} from "../deploymentConfig.ts";
11import { validateManagedEndpoint } from "../environments/EnvironmentConnector.ts";
12import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
13import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts";
14import * as HookInbox from "./HookInbox.ts";
15
16/** The endpoint key of an environment's own managed endpoint, for authenticated callers. */
17export const endpointKeyForTunnelName = (namespace: string, tunnelName: string): string | null => {
18 const prefix = managedEndpointTunnelNameForKey(namespace, "");
19 const key = tunnelName.startsWith(prefix) ? tunnelName.slice(prefix.length) : "";
20 return MANAGED_ENDPOINT_KEY_PATTERN.test(key) ? key : null;
21};
22
23type PersistenceError =
24 | EnvironmentLinks.EnvironmentLinkEnvironmentLookupPersistenceError
25 | ManagedEndpointAllocations.ManagedEndpointAllocationPersistenceError;
26
27interface HookEndpoint {
28 readonly httpBaseUrl: string;
29 readonly environmentId: string;
30 readonly holdWhileOffline: boolean;
31}
32
33export class HeldHooks extends Context.Service<
34 HeldHooks,
35 {
36 /**
37 * The ready managed endpoint a webhook URL's endpoint key names, with whether
38 * its link opted in to holding webhooks while offline. The key is the tunnel
39 * name's hash of user and environment, so it names exactly one allocation and
40 * at most one active link; nobody else can link their way onto it.
41 */
42 readonly resolveEndpoint: (
43 endpointKey: string,
44 ) => Effect.Effect<HookEndpoint | null, PersistenceError>;
45 /**
46 * Saves an environment's opt-in. Opting out also drops what is already held,
47 * rather than delivering it later to an environment that said it does not
48 * want it. Only the links the caller's environment key proved are touched.
49 */
50 readonly setHoldWhileOffline: (input: {
51 readonly environmentId: string;
52 readonly environmentPublicKey: string;
53 readonly holdWebhooksWhileOffline: boolean;
54 }) => Effect.Effect<void, PersistenceError | HookInbox.HookInboxError>;
55 /** Delivers what the environment's endpoints hold now; true when anything was waiting. */
56 readonly wake: (input: {
57 readonly environmentId: string;
58 readonly environmentPublicKey: string;
59 }) => Effect.Effect<boolean, PersistenceError | HookInbox.HookInboxError>;
60 }
61>()("t3code-relay/hooks/HeldHooks") {}
62
63const make = Effect.gen(function* () {
64 const links = yield* EnvironmentLinks.EnvironmentLinks;
65 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
66 const settings = yield* RelayConfiguration.RelayConfiguration;
67 const inbox = yield* HookInbox.HookInbox;
68
69 const resolveEndpoint: HeldHooks["Service"]["resolveEndpoint"] = Effect.fn(
70 "relay.hooks.resolve_endpoint",
71 )(function* (endpointKey) {
72 if (!settings.managedEndpointNamespace) return null;
73 const allocation = yield* allocations.getByTunnelName(
74 managedEndpointTunnelNameForKey(settings.managedEndpointNamespace, endpointKey),
75 );
76 if (allocation === null) return null;
77 const [link] = yield* links.findActiveManagedForEnvironment({
78 environmentId: allocation.environmentId,
79 userId: allocation.userId,
80 });
81 if (!link) return null;
82 const result = validateManagedEndpoint({
83 link,
84 allocation,
85 baseDomain: settings.managedEndpointBaseDomain,
86 });
87 if (Result.isFailure(result)) return null;
88 return {
89 ...result.success,
90 environmentId: allocation.environmentId,
91 holdWhileOffline: link.holdWebhooksWhileOffline,
92 };
93 });
94
95 /**
96 * Endpoint keys of the managed links the calling environment key proved.
97 * The environment id alone would also match other accounts' links of it.
98 */
99 const ownEndpointKeys = Effect.fn("relay.hooks.own_endpoint_keys")(function* (input: {
100 readonly environmentId: string;
101 readonly environmentPublicKey: string;
102 }) {
103 const namespace = settings.managedEndpointNamespace;
104 if (!namespace) return [];
105 const ownLinks = yield* links.findActiveManagedForEnvironment(input);
106 const keys: Array<string> = [];
107 for (const link of ownLinks) {
108 const allocation = yield* allocations.get({
109 userId: link.userId,
110 environmentId: input.environmentId,
111 });
112 const key = allocation ? endpointKeyForTunnelName(namespace, allocation.tunnelName) : null;
113 if (key !== null) keys.push(key);
114 }
115 return keys;
116 });
117
118 const setHoldWhileOffline: HeldHooks["Service"]["setHoldWhileOffline"] = Effect.fn(
119 "relay.hooks.set_hold_while_offline",
120 )(function* (input) {
121 yield* links.setHoldWebhooksWhileOffline(input);
122 if (input.holdWebhooksWhileOffline) return;
123 for (const endpointKey of yield* ownEndpointKeys(input)) {
124 yield* inbox.clear({ endpointKey });
125 }
126 });
127
128 const wake: HeldHooks["Service"]["wake"] = Effect.fn("relay.hooks.wake")(function* (input) {
129 let pending = false;
130 for (const endpointKey of yield* ownEndpointKeys(input)) {
131 const endpoint = yield* resolveEndpoint(endpointKey);
132 if (endpoint === null) continue;
133 if (yield* inbox.wake({ endpointKey, baseUrl: endpoint.httpBaseUrl })) {
134 pending = true;
135 }
136 }
137 return pending;
138 });
139
140 return HeldHooks.of({ resolveEndpoint, setHoldWhileOffline, wake });
141});
142
143export const layer = Layer.effect(HeldHooks, make);
infra/relay/src/hooks/HookForwarder.test.ts+2
...@@ -37,6 +37,7 @@ import {...@@ -37,6 +37,7 @@ import {
37} from "../http/Api.ts";37} from "../http/Api.ts";
38import * as HookForwarder from "./HookForwarder.ts";38import * as HookForwarder from "./HookForwarder.ts";
39import { RELAY_HOOK_DELIVERY_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt";39import { RELAY_HOOK_DELIVERY_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt";
40import * as HeldHooks from "./HeldHooks.ts";
40import * as HookInbox from "./HookInbox.ts";41import * as HookInbox from "./HookInbox.ts";
41import type { HeldHook } from "./HookInboxStore.ts";42import type { HeldHook } from "./HookInboxStore.ts";
42import { RELAY_HOOK_UPSTREAM_TIMEOUT_MS } from "./upstream.ts";43import { RELAY_HOOK_UPSTREAM_TIMEOUT_MS } from "./upstream.ts";
...@@ -114,6 +115,7 @@ function makeHarness(options: Harness = {}) {...@@ -114,6 +115,7 @@ function makeHarness(options: Harness = {}) {
114 ),115 ),
115 ));116 ));
116 const forwarderLayer = HookForwarder.layer.pipe(117 const forwarderLayer = HookForwarder.layer.pipe(
118 Layer.provideMerge(HeldHooks.layer),
117 Layer.provide(119 Layer.provide(
118 Layer.mergeAll(120 Layer.mergeAll(
119 Layer.succeed(RelayConfiguration.RelayConfiguration, settings),121 Layer.succeed(RelayConfiguration.RelayConfiguration, settings),
infra/relay/src/hooks/HookForwarder.ts+31-84
...@@ -23,13 +23,8 @@ import {...@@ -23,13 +23,8 @@ import {
23} from "@t3tools/shared/relayJwt";23} from "@t3tools/shared/relayJwt";
2424
25import * as RelayConfiguration from "../Config.ts";25import * as RelayConfiguration from "../Config.ts";
26import {26import { MANAGED_ENDPOINT_KEY_PATTERN } from "../deploymentConfig.ts";
27 MANAGED_ENDPOINT_KEY_PATTERN,27import * as HeldHooks from "./HeldHooks.ts";
28 managedEndpointTunnelNameForKey,
29} from "../deploymentConfig.ts";
30import { validateManagedEndpoint } from "../environments/EnvironmentConnector.ts";
31import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
32import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts";
33import * as HookInbox from "./HookInbox.ts";28import * as HookInbox from "./HookInbox.ts";
34import { sendUpstream, TUNNEL_OFFLINE_STATUS } from "./upstream.ts";29import { sendUpstream, TUNNEL_OFFLINE_STATUS } from "./upstream.ts";
3530
...@@ -143,34 +138,6 @@ const hookNotFound = () => errorResponse(404, "hook_not_found");...@@ -143,34 +138,6 @@ const hookNotFound = () => errorResponse(404, "hook_not_found");
143/** Longer than the inbox holds a request, so a held delivery's proof still verifies. */138/** Longer than the inbox holds a request, so a held delivery's proof still verifies. */
144const DELIVERY_PROOF_LIFETIME_SECONDS = 25 * 60 * 60;139const DELIVERY_PROOF_LIFETIME_SECONDS = 25 * 60 * 60;
145140
146const signDeliveryProof = (input: {
147 readonly settings: RelayConfiguration.RelayConfiguration["Service"];
148 readonly environmentId: string;
149 readonly deliveryId: string;
150 readonly receivedAt: string;
151 readonly hookId: string;
152 readonly jti: string;
153}) =>
154 Effect.gen(function* () {
155 const now = Math.floor((yield* Clock.currentTimeMillis) / 1_000);
156 return yield* signRelayJwt({
157 privateKey: Redacted.value(input.settings.cloudMintPrivateKey),
158 typ: RELAY_HOOK_DELIVERY_TYP,
159 payload: {
160 iss: normalizeRelayIssuer(input.settings.relayIssuer),
161 aud: `t3-env:${input.environmentId}`,
162 sub: input.environmentId,
163 jti: input.jti,
164 iat: now,
165 exp: now + DELIVERY_PROOF_LIFETIME_SECONDS,
166 environmentId: EnvironmentId.make(input.environmentId),
167 deliveryId: input.deliveryId,
168 receivedAt: input.receivedAt,
169 hookId: input.hookId,
170 } satisfies RelayHookDeliveryProofPayload,
171 });
172 }).pipe(Effect.orDie);
173
174/** Methods a webhook can arrive with; HEAD reaches the GET route and is refused. */141/** Methods a webhook can arrive with; HEAD reaches the GET route and is refused. */
175const FORWARDED_METHODS = new Set(["GET", "POST", "PUT", "PATCH"]);142const FORWARDED_METHODS = new Set(["GET", "POST", "PUT", "PATCH"]);
176143
...@@ -266,57 +233,41 @@ const readCappedBody = (request: HttpServerRequest.HttpServerRequest) =>...@@ -266,57 +233,41 @@ const readCappedBody = (request: HttpServerRequest.HttpServerRequest) =>
266 );233 );
267 });234 });
268235
269/**
270 * The ready managed endpoint a webhook URL's endpoint key names, with whether
271 * its link opted in to holding webhooks while offline. The key is the tunnel
272 * name's hash of user and environment, so it names exactly one allocation and
273 * at most one active link; nobody else can link their way onto it.
274 */
275export const resolveHookEndpoint = Effect.fn("relay.hooks.resolve_endpoint")(function* (
276 endpointKey: string,
277) {
278 const links = yield* EnvironmentLinks.EnvironmentLinks;
279 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
280 const settings = yield* RelayConfiguration.RelayConfiguration;
281 if (!settings.managedEndpointNamespace) return null;
282 const allocation = yield* allocations.getByTunnelName(
283 managedEndpointTunnelNameForKey(settings.managedEndpointNamespace, endpointKey),
284 );
285 if (allocation === null) return null;
286 const [link] = yield* links.findActiveManagedForEnvironment({
287 environmentId: allocation.environmentId,
288 userId: allocation.userId,
289 });
290 if (!link) return null;
291 const result = validateManagedEndpoint({
292 link,
293 allocation,
294 baseDomain: settings.managedEndpointBaseDomain,
295 });
296 if (Result.isFailure(result)) return null;
297 return {
298 ...result.success,
299 environmentId: allocation.environmentId,
300 holdWhileOffline: link.holdWebhooksWhileOffline,
301 };
302});
303
304/** The endpoint key of an environment's own managed endpoint, for authenticated callers. */
305export const endpointKeyForTunnelName = (namespace: string, tunnelName: string): string | null => {
306 const prefix = managedEndpointTunnelNameForKey(namespace, "");
307 const key = tunnelName.startsWith(prefix) ? tunnelName.slice(prefix.length) : "";
308 return MANAGED_ENDPOINT_KEY_PATTERN.test(key) ? key : null;
309};
310
311const make = Effect.gen(function* () {236const make = Effect.gen(function* () {
312 const links = yield* EnvironmentLinks.EnvironmentLinks;237 const heldHooks = yield* HeldHooks.HeldHooks;
313 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
314 const settings = yield* RelayConfiguration.RelayConfiguration;238 const settings = yield* RelayConfiguration.RelayConfiguration;
315 const httpClient = yield* HttpClient.HttpClient;239 const httpClient = yield* HttpClient.HttpClient;
316 const rateLimiter = yield* HookRateLimiter;240 const rateLimiter = yield* HookRateLimiter;
317 const inbox = yield* HookInbox.HookInbox;241 const inbox = yield* HookInbox.HookInbox;
318 const crypto = yield* Crypto.Crypto;242 const crypto = yield* Crypto.Crypto;
319243
244 const signDeliveryProof = (input: {
245 readonly environmentId: string;
246 readonly deliveryId: string;
247 readonly receivedAt: string;
248 readonly hookId: string;
249 readonly jti: string;
250 }) =>
251 Effect.gen(function* () {
252 const now = Math.floor((yield* Clock.currentTimeMillis) / 1_000);
253 return yield* signRelayJwt({
254 privateKey: Redacted.value(settings.cloudMintPrivateKey),
255 typ: RELAY_HOOK_DELIVERY_TYP,
256 payload: {
257 iss: normalizeRelayIssuer(settings.relayIssuer),
258 aud: `t3-env:${input.environmentId}`,
259 sub: input.environmentId,
260 jti: input.jti,
261 iat: now,
262 exp: now + DELIVERY_PROOF_LIFETIME_SECONDS,
263 environmentId: EnvironmentId.make(input.environmentId),
264 deliveryId: input.deliveryId,
265 receivedAt: input.receivedAt,
266 hookId: input.hookId,
267 } satisfies RelayHookDeliveryProofPayload,
268 });
269 }).pipe(Effect.orDie);
270
320 const handle = Effect.fn("relay.hooks.forward")(function* (271 const handle = Effect.fn("relay.hooks.forward")(function* (
321 request: HttpServerRequest.HttpServerRequest,272 request: HttpServerRequest.HttpServerRequest,
322 ) {273 ) {
...@@ -357,10 +308,7 @@ const make = Effect.gen(function* () {...@@ -357,10 +308,7 @@ const make = Effect.gen(function* () {
357 return errorResponse(413, "payload_too_large");308 return errorResponse(413, "payload_too_large");
358 }309 }
359310
360 const endpoint = yield* resolveHookEndpoint(parsed.endpointKey).pipe(311 const endpoint = yield* heldHooks.resolveEndpoint(parsed.endpointKey).pipe(
361 Effect.provideService(EnvironmentLinks.EnvironmentLinks, links),
362 Effect.provideService(ManagedEndpointAllocations.ManagedEndpointAllocations, allocations),
363 Effect.provideService(RelayConfiguration.RelayConfiguration, settings),
364 Effect.catch((error) =>312 Effect.catch((error) =>
365 Effect.logWarning("Failed to resolve hook endpoint", {313 Effect.logWarning("Failed to resolve hook endpoint", {
366 endpointKey: parsed.endpointKey,314 endpointKey: parsed.endpointKey,
...@@ -398,7 +346,6 @@ const make = Effect.gen(function* () {...@@ -398,7 +346,6 @@ const make = Effect.gen(function* () {
398 // trace context came from the relay. Signed once here and stored with a346 // trace context came from the relay. Signed once here and stored with a
399 // held request, so the inbox never needs the signing key.347 // held request, so the inbox never needs the signing key.
400 const proof = yield* signDeliveryProof({348 const proof = yield* signDeliveryProof({
401 settings,
402 environmentId: endpoint.environmentId,349 environmentId: endpoint.environmentId,
403 deliveryId,350 deliveryId,
404 receivedAt,351 receivedAt,
infra/relay/src/http/Api.test.ts+2-8
...@@ -61,6 +61,7 @@ import {...@@ -61,6 +61,7 @@ import {
61import * as RelayConfiguration from "../Config.ts";61import * as RelayConfiguration from "../Config.ts";
62import * as RelayDb from "../db.ts";62import * as RelayDb from "../db.ts";
63import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";63import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";
64import * as HeldHooks from "../hooks/HeldHooks.ts";
64import * as HookInbox from "../hooks/HookInbox.ts";65import * as HookInbox from "../hooks/HookInbox.ts";
65import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";66import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
66import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts";67import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts";
...@@ -1269,14 +1270,7 @@ describe("relay routing fallback", () => {...@@ -1269,14 +1270,7 @@ describe("relay routing fallback", () => {
1269 Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}),1270 Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}),
1270 ),1271 ),
1271 ),1272 ),
1272 Layer.provide([1273 Layer.provide([publisher, signatures, Layer.mock(HeldHooks.HeldHooks, {})]),
1273 publisher,
1274 signatures,
1275 Layer.mock(EnvironmentLinks.EnvironmentLinks, {}),
1276 Layer.mock(HookInbox.HookInbox, {}),
1277 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations, {}),
1278 Layer.succeed(RelayConfiguration.RelayConfiguration, relaySettings),
1279 ]),
1280 ),1274 ),
1281 ),1275 ),
1282 Layer.provide(auth),1276 Layer.provide(auth),
infra/relay/src/http/Api.ts+9-51
...@@ -67,6 +67,7 @@ import * as RelayTokens from "../auth/RelayTokens.ts";...@@ -67,6 +67,7 @@ import * as RelayTokens from "../auth/RelayTokens.ts";
67import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";67import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";
68import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";68import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
69import * as HookForwarder from "../hooks/HookForwarder.ts";69import * as HookForwarder from "../hooks/HookForwarder.ts";
70import * as HeldHooks from "../hooks/HeldHooks.ts";
70import * as HookInbox from "../hooks/HookInbox.ts";71import * as HookInbox from "../hooks/HookInbox.ts";
71import * as LiveActivities from "../agentActivity/LiveActivities.ts";72import * as LiveActivities from "../agentActivity/LiveActivities.ts";
72import * as RelayConfiguration from "../Config.ts";73import * as RelayConfiguration from "../Config.ts";
...@@ -554,7 +555,7 @@ export const unlinkEnvironmentRecord = Effect.fn("relay.api.client.unlinkEnviron...@@ -554,7 +555,7 @@ export const unlinkEnvironmentRecord = Effect.fn("relay.api.client.unlinkEnviron
554 // is already gone, and its inbox drops anything left after its TTL.555 // is already gone, and its inbox drops anything left after its TTL.
555 const endpointKey =556 const endpointKey =
556 deprovisionTarget && input.managedEndpointNamespace557 deprovisionTarget && input.managedEndpointNamespace
557 ? HookForwarder.endpointKeyForTunnelName(558 ? HeldHooks.endpointKeyForTunnelName(
558 input.managedEndpointNamespace,559 input.managedEndpointNamespace,
559 deprovisionTarget.tunnelName,560 deprovisionTarget.tunnelName,
560 )561 )
...@@ -1176,10 +1177,7 @@ export const serverApi = HttpApiBuilder.group(...@@ -1176,10 +1177,7 @@ export const serverApi = HttpApiBuilder.group(
1176 Effect.fnUntraced(function* (handlers) {1177 Effect.fnUntraced(function* (handlers) {
1177 const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;1178 const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;
1178 const publishSignatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures;1179 const publishSignatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures;
1179 const links = yield* EnvironmentLinks.EnvironmentLinks;1180 const heldHooks = yield* HeldHooks.HeldHooks;
1180 const inbox = yield* HookInbox.HookInbox;
1181 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
1182 const settings = yield* RelayConfiguration.RelayConfiguration;
1183 const requireOwnEnvironment = (environmentId: string) =>1181 const requireOwnEnvironment = (environmentId: string) =>
1184 Effect.gen(function* () {1182 Effect.gen(function* () {
1185 const principal = yield* RelayEnvironmentPrincipal;1183 const principal = yield* RelayEnvironmentPrincipal;
...@@ -1187,29 +1185,6 @@ export const serverApi = HttpApiBuilder.group(...@@ -1187,29 +1185,6 @@ export const serverApi = HttpApiBuilder.group(
1187 return yield* new HttpApiError.Unauthorized({});1185 return yield* new HttpApiError.Unauthorized({});
1188 }1186 }
1189 });1187 });
1190 /**
1191 * Endpoint keys of the managed links the calling environment key proved.
1192 * The environment id alone would also match other accounts' links of it.
1193 */
1194 const ownEndpointKeys = (environmentId: string) =>
1195 Effect.gen(function* () {
1196 const principal = yield* RelayEnvironmentPrincipal;
1197 const namespace = settings.managedEndpointNamespace;
1198 if (!namespace) return [];
1199 const ownLinks = yield* links.findActiveManagedForEnvironment({
1200 environmentId,
1201 environmentPublicKey: principal.environmentPublicKey,
1202 });
1203 const keys: Array<string> = [];
1204 for (const link of ownLinks) {
1205 const allocation = yield* allocations.get({ userId: link.userId, environmentId });
1206 const key = allocation
1207 ? HookForwarder.endpointKeyForTunnelName(namespace, allocation.tunnelName)
1208 : null;
1209 if (key !== null) keys.push(key);
1210 }
1211 return keys;
1212 });
1213 const activityHandlers = handlers.handle(1188 const activityHandlers = handlers.handle(
1214 "publishAgentActivity",1189 "publishAgentActivity",
1215 Effect.fn("relay.api.server.publishAgentActivity")(1190 Effect.fn("relay.api.server.publishAgentActivity")(
...@@ -1425,18 +1400,11 @@ export const serverApi = HttpApiBuilder.group(...@@ -1425,18 +1400,11 @@ export const serverApi = HttpApiBuilder.group(
1425 Effect.fn("relay.api.server.updateLinkPreferences")(function* ({ params, payload }) {1400 Effect.fn("relay.api.server.updateLinkPreferences")(function* ({ params, payload }) {
1426 yield* requireOwnEnvironment(params.environmentId);1401 yield* requireOwnEnvironment(params.environmentId);
1427 const principal = yield* RelayEnvironmentPrincipal;1402 const principal = yield* RelayEnvironmentPrincipal;
1428 yield* links.setHoldWebhooksWhileOffline({1403 yield* heldHooks.setHoldWhileOffline({
1429 environmentId: params.environmentId,1404 environmentId: params.environmentId,
1430 environmentPublicKey: principal.environmentPublicKey,1405 environmentPublicKey: principal.environmentPublicKey,
1431 holdWebhooksWhileOffline: payload.holdWebhooksWhileOffline,1406 holdWebhooksWhileOffline: payload.holdWebhooksWhileOffline,
1432 });1407 });
1433 // Opting out also drops what is already held, rather than delivering
1434 // it later to an environment that said it does not want it.
1435 if (!payload.holdWebhooksWhileOffline) {
1436 for (const endpointKey of yield* ownEndpointKeys(params.environmentId)) {
1437 yield* inbox.clear({ endpointKey });
1438 }
1439 }
1440 return payload;1408 return payload;
1441 }, mapRelayCommonApiErrors("not_authorized")),1409 }, mapRelayCommonApiErrors("not_authorized")),
1442 )1410 )
...@@ -1444,21 +1412,11 @@ export const serverApi = HttpApiBuilder.group(...@@ -1444,21 +1412,11 @@ export const serverApi = HttpApiBuilder.group(
1444 "wakeHeldHooks",1412 "wakeHeldHooks",
1445 Effect.fn("relay.api.server.wakeHeldHooks")(function* ({ params }) {1413 Effect.fn("relay.api.server.wakeHeldHooks")(function* ({ params }) {
1446 yield* requireOwnEnvironment(params.environmentId);1414 yield* requireOwnEnvironment(params.environmentId);
1447 let pending = false;1415 const principal = yield* RelayEnvironmentPrincipal;
1448 for (const endpointKey of yield* ownEndpointKeys(params.environmentId)) {1416 const pending = yield* heldHooks.wake({
1449 const endpoint = yield* HookForwarder.resolveHookEndpoint(endpointKey).pipe(1417 environmentId: params.environmentId,
1450 Effect.provideService(EnvironmentLinks.EnvironmentLinks, links),1418 environmentPublicKey: principal.environmentPublicKey,
1451 Effect.provideService(1419 });
1452 ManagedEndpointAllocations.ManagedEndpointAllocations,
1453 allocations,
1454 ),
1455 Effect.provideService(RelayConfiguration.RelayConfiguration, settings),
1456 );
1457 if (endpoint === null) continue;
1458 if (yield* inbox.wake({ endpointKey, baseUrl: endpoint.httpBaseUrl })) {
1459 pending = true;
1460 }
1461 }
1462 return { pending };1420 return { pending };
1463 }, mapRelayCommonApiErrors("not_authorized")),1421 }, mapRelayCommonApiErrors("not_authorized")),
1464 );1422 );
infra/relay/src/worker.ts+6-1
...@@ -76,6 +76,7 @@ import * as ManagedEndpointReaper from "./environments/ManagedEndpointReaper.ts"...@@ -76,6 +76,7 @@ import * as ManagedEndpointReaper from "./environments/ManagedEndpointReaper.ts"
76import * as ManagedTunnelLimits from "./environments/ManagedTunnelLimits.ts";76import * as ManagedTunnelLimits from "./environments/ManagedTunnelLimits.ts";
77import * as MobileRegistrations from "./agentActivity/MobileRegistrations.ts";77import * as MobileRegistrations from "./agentActivity/MobileRegistrations.ts";
78import * as HookForwarder from "./hooks/HookForwarder.ts";78import * as HookForwarder from "./hooks/HookForwarder.ts";
79import * as HeldHooks from "./hooks/HeldHooks.ts";
79import * as HookInbox from "./hooks/HookInbox.ts";80import * as HookInbox from "./hooks/HookInbox.ts";
80import { HookInboxObject, HookInboxObjectLive } from "./hooks/HookInboxObject.ts";81import { HookInboxObject, HookInboxObjectLive } from "./hooks/HookInboxObject.ts";
8182
...@@ -273,7 +274,11 @@ export const ApiLive = Api.make(...@@ -273,7 +274,11 @@ export const ApiLive = Api.make(
273 Layer.provideMerge(EnvironmentConnector.layer),274 Layer.provideMerge(EnvironmentConnector.layer),
274 Layer.provideMerge(EnvironmentLinker.layer),275 Layer.provideMerge(EnvironmentLinker.layer),
275 Layer.provideMerge(276 Layer.provideMerge(
276 Layer.merge(EnvironmentPublishSignatures.layer, ManagedEndpointReaper.layer),277 Layer.mergeAll(
278 EnvironmentPublishSignatures.layer,
279 ManagedEndpointReaper.layer,
280 HeldHooks.layer,
281 ),
277 ),282 ),
278 Layer.provideMerge(283 Layer.provideMerge(
279 ManagedEndpointProvider.layerCloudflareBindings(284 ManagedEndpointProvider.layerCloudflareBindings(