authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 15:14:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 15:14:53-07:00
logf33b060cafc96d7c3229744315c230a3b8d1f631
tree0d84f75282264d93612e1103ebc56c2cfd5ad8cf
parentea4b44343d3c8f71cf49a72a266d00eac7389435
signature Signed by PGP key B5690EEEBB952194

feat(relay,server,web,mobile): opt-in to hold webhooks while offline (#15487)

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

73 files changed, 5491 insertions(+), 436 deletions(-)

apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx+59-15
......@@ -4,7 +4,10 @@ import type {
44 ScheduledTask,
55 ScheduledTaskUpsertInput,
66} from "@t3tools/contracts";
7import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
7import {
8 MAX_WEBHOOK_DELIVERY_AGE_MINUTES,
9 resolveEnvironmentMachineKind,
10} from "@t3tools/contracts";
811import type { MenuAction } from "@react-native-menu/menu";
912import { DateTimePicker } from "@expo/ui/community/datetime-picker";
1013import {
......@@ -12,6 +15,10 @@ import {
1215 squashAtomCommandFailure,
1316 type AtomCommandResult,
1417} from "@t3tools/client-runtime/state/runtime";
18import {
19 DEFAULT_WEBHOOK_PROMPT,
20 parseMaxDeliveryAge,
21} from "@t3tools/client-runtime/scheduled-task-webhook";
1522import {
1623 useCallback,
1724 useEffect,
......@@ -59,7 +66,6 @@ import { SettingsSection } from "./components/SettingsSection";
5966import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter";
6067import {
6168 editDraft,
62 DEFAULT_WEBHOOK_PROMPT,
6369 scheduledTaskDefaultModel,
6470 scheduleFromDraft,
6571 type ScheduledTaskDraft as Draft,
......@@ -132,7 +138,7 @@ function FormField(props: {
132138 readonly label: string;
133139 readonly value: string;
134140 readonly onChange: (value: string) => void;
135 readonly keyboardType?: "decimal-pad";
141 readonly keyboardType?: "decimal-pad" | "number-pad";
136142 readonly disabled?: boolean;
137143 readonly placeholder?: string;
138144 readonly borderTop?: boolean;
......@@ -611,6 +617,16 @@ function TaskForm({
611617 ? { ...draft.schedule, signature: liveTask.schedule.signature }
612618 : draft.schedule,
613619 );
620 if (
621 draft.schedule.mode === "webhook" &&
622 parseMaxDeliveryAge(draft.schedule.maxDeliveryAgeMinutes) === undefined
623 ) {
624 Alert.alert(
625 "Invalid age limit",
626 `Enter whole minutes from 1 to ${MAX_WEBHOOK_DELIVERY_AGE_MINUTES}, or leave it blank.`,
627 );
628 return;
629 }
614630 if (
615631 !draft.title.trim() ||
616632 !draft.prompt.trim() ||
......@@ -823,7 +839,7 @@ function TaskForm({
823839 options={[
824840 { value: "fixed_time", label: "At a time" },
825841 { value: "interval", label: "Interval" },
826 { value: "webhook", label: "Webhook" },
842 { value: "webhook", label: "On webhook" },
827843 ]}
828844 selected={draft.schedule.mode}
829845 onSelect={(mode) => {
......@@ -910,13 +926,31 @@ function TaskForm({
910926 />
911927 </>
912928 ) : draft.schedule.mode === "webhook" ? (
913 <WebhookScheduleDetails
914 environmentId={environmentId}
915 task={
916 tasks.data?.tasks.find((task) => task.id === draft.task?.id) ?? draft.task ?? null
917 }
918 signatureConfigured={draft.schedule.signature !== null}
919 />
929 <>
930 <WebhookScheduleDetails
931 environmentId={environmentId}
932 // The live row, so a rotated URL shows up without reopening the form.
933 // Once the list has loaded, a missing task is gone; don't keep showing its URL.
934 task={
935 tasks.data
936 ? (tasks.data.tasks.find((task) => task.id === draft.task?.id) ?? null)
937 : draft.task
938 }
939 signatureConfigured={draft.schedule.signature !== null}
940 disabled={saving || environmentUnavailable}
941 />
942 <FormField
943 label="Skip requests older than (minutes)"
944 value={draft.schedule.maxDeliveryAgeMinutes}
945 placeholder="Run every request"
946 keyboardType="number-pad"
947 disabled={saving}
948 borderTop
949 onChange={(maxDeliveryAgeMinutes) =>
950 setDraft({ ...draft, schedule: { ...draft.schedule, maxDeliveryAgeMinutes } })
951 }
952 />
953 </>
920954 ) : (
921955 <>
922956 <FormField
......@@ -976,11 +1010,14 @@ function WebhookScheduleDetails({
9761010 environmentId,
9771011 task,
9781012 signatureConfigured,
1013 disabled,
9791014}: {
9801015 readonly environmentId: EnvironmentId;
9811016 readonly task: ScheduledTask | null;
9821017 readonly signatureConfigured: boolean;
1018 readonly disabled: boolean;
9831019}) {
1020 const [rotating, setRotating] = useState(false);
9841021 const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, {
9851022 label: "scheduled task rotate webhook token",
9861023 reportFailure: false,
......@@ -1023,27 +1060,34 @@ function WebhookScheduleDetails({
10231060 ) : null}
10241061 <Pressable
10251062 accessibilityRole="button"
1063 accessibilityState={{ disabled: disabled || rotating }}
1064 disabled={disabled || rotating}
10261065 onPress={() =>
10271066 Alert.alert("Rotate URL?", "The current URL stops working immediately.", [
10281067 { text: "Cancel", style: "cancel" },
10291068 {
10301069 text: "Rotate",
10311070 style: "destructive",
1032 onPress: () =>
1071 onPress: () => {
1072 setRotating(true);
10331073 void rotate({ environmentId, input: { id: task.id } }).then((result) => {
1074 setRotating(false);
10341075 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
10351076 Alert.alert(
10361077 "Could not rotate URL",
10371078 String(squashAtomCommandFailure(result)),
10381079 );
10391080 }
1040 }),
1081 });
1082 },
10411083 },
10421084 ])
10431085 }
1044 className="min-h-11 justify-center active:opacity-70"
1086 className="min-h-11 justify-center active:opacity-70 disabled:opacity-50"
10451087 >
1046 <Text className="text-base text-danger-foreground">Rotate URL</Text>
1088 <Text className="text-base text-danger-foreground">
1089 {rotating ? "Rotating…" : "Rotate URL"}
1090 </Text>
10471091 </Pressable>
10481092 </>
10491093 )}
apps/mobile/src/features/settings/scheduledTaskDraft.test.ts+20-2
......@@ -32,7 +32,25 @@ describe("scheduleDraftForTask", () => {
3232 it("round-trips a webhook schedule without a signature", () => {
3333 const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null } });
3434 expect(draft.mode).toBe("webhook");
35 expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature: null });
35 expect(scheduleFromDraft(draft)).toEqual({
36 type: "webhook",
37 signature: null,
38 maxDeliveryAgeMinutes: null,
39 });
40 });
41
42 it("round-trips a webhook max age and treats blank input as no limit", () => {
43 const draft = scheduleDraftForTask({
44 schedule: { type: "webhook", signature: null, maxDeliveryAgeMinutes: 45 },
45 });
46 expect(draft.maxDeliveryAgeMinutes).toBe("45");
47 expect(scheduleFromDraft(draft)).toMatchObject({ maxDeliveryAgeMinutes: 45 });
48 // An invalid limit is an invalid schedule, never a silently removed one.
49 expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: "1.5" })).toBeNull();
50 expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: "0" })).toBeNull();
51 expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: "" })).toMatchObject({
52 maxDeliveryAgeMinutes: null,
53 });
3654 });
3755
3856 it("keeps a webhook signature on save without sending a secret", () => {
......@@ -44,7 +62,7 @@ describe("scheduleDraftForTask", () => {
4462 const saved = scheduleFromDraft(
4563 scheduleDraftForTask({ schedule: { type: "webhook", signature } }),
4664 );
47 expect(saved).toEqual({ type: "webhook", signature });
65 expect(saved).toEqual({ type: "webhook", signature, maxDeliveryAgeMinutes: null });
4866 expect(saved?.type === "webhook" && saved.signature && "secret" in saved.signature).toBe(false);
4967 });
5068});
apps/mobile/src/features/settings/scheduledTaskDraft.ts+17-4
......@@ -9,6 +9,7 @@ import type {
99} from "@t3tools/contracts";
1010
1111import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
12import { parseMaxDeliveryAge } from "@t3tools/client-runtime/scheduled-task-webhook";
1213import {
1314 resolveProjectSettings,
1415 type LegacyProjectSettingsFields,
......@@ -44,6 +45,8 @@ export type ScheduleDraft = {
4445 readonly intervalMinutes: string;
4546 /** A webhook signature check configured elsewhere; mobile keeps it but does not edit it. */
4647 readonly signature: ScheduledTaskWebhookSignature | null;
48 /** Minutes as typed; empty runs every held request regardless of age. */
49 readonly maxDeliveryAgeMinutes: string;
4750};
4851
4952export const DEFAULT_SCHEDULE: ScheduleDraft = {
......@@ -52,11 +55,9 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = {
5255 weekdays: [1, 2, 3, 4, 5],
5356 intervalMinutes: "15",
5457 signature: null,
58 maxDeliveryAgeMinutes: "",
5559};
5660
57/** Prompt a new webhook task starts with: the whole request, which the user can narrow down. */
58export const DEFAULT_WEBHOOK_PROMPT = "Handle this webhook:\n{{request}}";
59
6061export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): ScheduleDraft {
6162 switch (task.schedule.type) {
6263 case "fixed_time":
......@@ -74,12 +75,22 @@ export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): Sch
7475 intervalMinutes: String(Math.max(1, task.schedule.everyMs / 60_000)),
7576 };
7677 case "webhook":
77 return { ...DEFAULT_SCHEDULE, mode: "webhook", signature: task.schedule.signature };
78 return {
79 ...DEFAULT_SCHEDULE,
80 mode: "webhook",
81 signature: task.schedule.signature,
82 maxDeliveryAgeMinutes:
83 task.schedule.maxDeliveryAgeMinutes == null
84 ? ""
85 : String(task.schedule.maxDeliveryAgeMinutes),
86 };
7887 }
7988}
8089
8190export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSchedule | null {
8291 if (draft.mode === "webhook") {
92 const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes);
93 if (maxDeliveryAgeMinutes === undefined) return null;
8394 // No secret is sent, so the server keeps the stored one.
8495 return {
8596 type: "webhook",
......@@ -91,6 +102,7 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche
91102 encoding: draft.signature.encoding,
92103 prefix: draft.signature.prefix,
93104 },
105 maxDeliveryAgeMinutes,
94106 };
95107 }
96108 if (draft.mode === "interval") {
......@@ -145,6 +157,7 @@ function draftSignature(draft: ScheduledTaskDraft): string {
145157 draft.schedule.timeOfDay,
146158 [...draft.schedule.weekdays].sort((a, b) => a - b),
147159 draft.schedule.intervalMinutes,
160 draft.schedule.maxDeliveryAgeMinutes,
148161 draft.workspace,
149162 draft.baseRef,
150163 draft.checkoutPath,
apps/server/src/cloud/ManagedEndpointRuntime.test.ts+30
......@@ -209,6 +209,36 @@ describe("CloudManagedEndpointRuntime", () => {
209209 }),
210210 );
211211
212 it.effect("signals each registered tunnel connection", () =>
213 Effect.gen(function* () {
214 const output = yield* Queue.unbounded<Uint8Array>();
215 const spawner = ChildProcessSpawner.make(() =>
216 Effect.gen(function* () {
217 const handle = makeHandle({
218 pid: 700,
219 onKill: () => {},
220 output: Stream.fromQueue(output),
221 });
222 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
223 return handle;
224 }),
225 );
226 const runtime = yield* buildCloudManagedEndpointRuntime(spawner);
227 yield* runtime.applyConfig({
228 providerKind: "cloudflare_tunnel",
229 connectorToken: "token",
230 tunnelId: "tunnel-1",
231 });
232 yield* Queue.offer(
233 output,
234 new TextEncoder().encode(
235 "2026-10-04T06:30:43Z INF Registered tunnel connection connIndex=0 event=0\n",
236 ),
237 );
238 expect(Option.isSome(yield* Stream.runHead(runtime.tunnelConnected))).toBe(true);
239 }),
240 );
241
212242 it.effect("recovers a rejected tunnel without waiting for the connector to exit", () =>
213243 Effect.gen(function* () {
214244 const output = yield* Queue.unbounded<Uint8Array>();
apps/server/src/cloud/ManagedEndpointRuntime.ts+8-1
......@@ -47,6 +47,8 @@ export class CloudManagedEndpointRuntime extends Context.Service<
4747 ) => Effect.Effect<CloudManagedEndpointRuntimeStatus>;
4848 readonly recoveryRequests: Stream.Stream<RelayManagedEndpointRuntimeConfig>;
4949 readonly requestRecovery: (config: RelayManagedEndpointRuntimeConfig) => Effect.Effect<void>;
50 /** Emits when the connector registers a tunnel connection, i.e. the relay can reach us again. */
51 readonly tunnelConnected: Stream.Stream<void>;
5052 readonly withLinkStateLock: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
5153 }
5254>()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {}
......@@ -134,6 +136,7 @@ export const make = Effect.gen(function* () {
134136 const activeRef = yield* Ref.make<ActiveConnector | null>(null);
135137 const desiredConfigRef = yield* Ref.make<RelayManagedEndpointRuntimeConfig | null>(null);
136138 const recoveryRequests = yield* Queue.sliding<RelayManagedEndpointRuntimeConfig>(1);
139 const tunnelConnections = yield* Queue.sliding<void>(1);
137140 const reconcileSemaphore = yield* Semaphore.make(1);
138141 const restartDelayRef = yield* Ref.make(0);
139142 const linkStateSemaphore = yield* Semaphore.make(1);
......@@ -236,7 +239,10 @@ export const make = Effect.gen(function* () {
236239 switch (classifyRelayClientOutput(line)) {
237240 case "connected":
238241 rejectedRegistrations = 0;
239 return Effect.logInfo("Relay client tunnel connection registered", attributes);
242 return Effect.logInfo("Relay client tunnel connection registered", attributes).pipe(
243 Effect.andThen(Queue.offer(tunnelConnections, undefined)),
244 Effect.asVoid,
245 );
240246 case "warning":
241247 if (isRejectedRelayClientTunnelOutput(line)) {
242248 rejectedRegistrations += 1;
......@@ -412,6 +418,7 @@ export const make = Effect.gen(function* () {
412418 applyConfig,
413419 recoveryRequests: Stream.fromQueue(recoveryRequests),
414420 requestRecovery: (config) => Queue.offer(recoveryRequests, config).pipe(Effect.asVoid),
421 tunnelConnected: Stream.fromQueue(tunnelConnections),
415422 withLinkStateLock: linkStateSemaphore.withPermits(1),
416423 });
417424
apps/server/src/cloud/config.ts+31
......@@ -16,6 +16,7 @@ export const RELAY_URL_SECRET = "cloud-relay-url";
1616export const RELAY_ISSUER_SECRET = "cloud-relay-issuer";
1717export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-credential";
1818export const PUBLISH_AGENT_ACTIVITY_SECRET = "cloud-publish-agent-activity";
19export const HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET = "cloud-hold-webhooks-while-offline";
1920
2021export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect(
2122 Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),
......@@ -73,3 +74,33 @@ export const readAgentActivityPublishingActive = (
7374 environmentCredential !== ""
7475 );
7576 }).pipe(Effect.orElseSucceed(() => false));
77
78const readSecretString = (
79 secrets: ServerSecretStore.ServerSecretStore["Service"],
80 name: string,
81): Effect.Effect<string | null> =>
82 secrets.get(name).pipe(
83 Effect.map((bytes) =>
84 Option.isSome(bytes) && bytes.value.length > 0 ? new TextDecoder().decode(bytes.value) : null,
85 ),
86 Effect.orElseSucceed(() => null),
87 );
88
89/** The relay URL and environment credential, or null when not linked to T3 Connect. */
90export const readRelayConnection = (secrets: ServerSecretStore.ServerSecretStore["Service"]) =>
91 Effect.all([
92 readSecretString(secrets, RELAY_URL_SECRET),
93 readSecretString(secrets, RELAY_ENVIRONMENT_CREDENTIAL_SECRET),
94 ]).pipe(
95 Effect.map(([url, environmentCredential]) =>
96 url && environmentCredential ? { url, environmentCredential } : null,
97 ),
98 );
99
100/** Whether this environment opted in to T3 Connect holding webhooks while it is offline. */
101export const readHoldWebhooksWhileOffline = (
102 secrets: ServerSecretStore.ServerSecretStore["Service"],
103) =>
104 readSecretString(secrets, HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET).pipe(
105 Effect.map((value) => value === "true"),
106 );
apps/server/src/cloud/http.test.ts+2
......@@ -249,6 +249,7 @@ describe("reconcileDesiredCloudLink", () => {
249249 applyConfig: unusedSecretStoreOperation,
250250 recoveryRequests: Stream.empty,
251251 requestRecovery: () => Effect.void,
252 tunnelConnected: Stream.empty,
252253 withLinkStateLock: (effect) => effect,
253254 } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]),
254255 ),
......@@ -410,6 +411,7 @@ describe("releaseManagedTunnelOnShutdown", () => {
410411 }),
411412 recoveryRequests: Stream.empty,
412413 requestRecovery: () => Effect.void,
414 tunnelConnected: Stream.empty,
413415 withLinkStateLock: (effect) => effect,
414416 }),
415417 ),
apps/server/src/cloud/http.ts+71-16
......@@ -8,6 +8,7 @@ import {
88 EnvironmentCloudRelayConfigResult,
99 EnvironmentHttpApi,
1010 EnvironmentHttpBadRequestError,
11 type EnvironmentCloudPreferencesRequest,
1112 EnvironmentHttpConflictError,
1213 EnvironmentHttpInternalServerError,
1314 EnvironmentHttpUnauthorizedError,
......@@ -69,6 +70,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
6970import { requireEnvironmentScope } from "../auth/http.ts";
7071import * as ServerConfig from "../config.ts";
7172import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
73import { makeRelayEnvironmentClient } from "../relay/relayEnvironmentClient.ts";
7274import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
7375import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
7476import {
......@@ -86,6 +88,9 @@ import {
8688 encodeEndpointRuntimeConfigJson,
8789 encodeConfirmedOriginJson,
8890 PUBLISH_AGENT_ACTIVITY_SECRET,
91 HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET,
92 readHoldWebhooksWhileOffline,
93 readRelayConnection,
8994 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
9095 RELAY_ISSUER_SECRET,
9196 RELAY_URL_SECRET,
......@@ -1277,17 +1282,24 @@ export const releaseManagedTunnelOnShutdown = Effect.fn(
12771282const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* (
12781283 dependencies: CloudHttpDependencies,
12791284) {
1280 const [cloudUserId, relayUrl, relayIssuer, endpointRuntimeConfig, publishAgentActivity] =
1281 yield* Effect.all(
1282 [
1283 dependencies.secrets.get(CLOUD_LINKED_USER_ID),
1284 dependencies.secrets.get(RELAY_URL_SECRET),
1285 dependencies.secrets.get(RELAY_ISSUER_SECRET),
1286 dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG),
1287 dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
1288 ],
1289 { concurrency: 5 },
1290 );
1285 const [
1286 cloudUserId,
1287 relayUrl,
1288 relayIssuer,
1289 endpointRuntimeConfig,
1290 publishAgentActivity,
1291 holdWebhooks,
1292 ] = yield* Effect.all(
1293 [
1294 dependencies.secrets.get(CLOUD_LINKED_USER_ID),
1295 dependencies.secrets.get(RELAY_URL_SECRET),
1296 dependencies.secrets.get(RELAY_ISSUER_SECRET),
1297 dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG),
1298 dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
1299 dependencies.secrets.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET),
1300 ],
1301 { concurrency: 6 },
1302 );
12911303 return {
12921304 linked: Option.isSome(cloudUserId),
12931305 cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null,
......@@ -1299,6 +1311,8 @@ const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function
12991311 publishAgentActivity: Option.isSome(publishAgentActivity)
13001312 ? bytesToString(publishAgentActivity.value) === "true"
13011313 : false,
1314 holdWebhooksWhileOffline:
1315 Option.isSome(holdWebhooks) && bytesToString(holdWebhooks.value) === "true",
13021316 } satisfies EnvironmentCloudLinkStateResult;
13031317});
13041318
......@@ -1329,8 +1343,9 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")(
13291343 dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG),
13301344 dependencies.secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN),
13311345 dependencies.secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET),
1346 dependencies.secrets.remove(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET),
13321347 ],
1333 { concurrency: 8 },
1348 { concurrency: 9 },
13341349 );
13351350 yield* setCliDesiredCloudLink(false);
13361351 return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult;
......@@ -1343,12 +1358,52 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")(
13431358 ),
13441359);
13451360
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
13461385const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")(
1347 function* (
1348 dependencies: CloudHttpDependencies,
1349 payload: { readonly publishAgentActivity: boolean },
1350 ) {
1386 function* (dependencies: CloudHttpDependencies, payload: EnvironmentCloudPreferencesRequest) {
13511387 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 }
13521407 yield* dependencies.secrets.set(
13531408 PUBLISH_AGENT_ACTIVITY_SECRET,
13541409 stringToBytes(String(payload.publishAgentActivity)),
apps/server/src/observability/Metrics.ts+24
......@@ -70,6 +70,30 @@ export const terminalRestartsTotal = Metric.counter("t3_terminal_restarts_total"
7070 description: "Total terminal restart requests handled.",
7171});
7272
73/**
74 * One per webhook request that reached a task, by `outcome` (accepted,
75 * not_found, rejected_signature, disabled, rate_limited, queue_full, expired,
76 * prompt_too_long, error) and `source` (relay or direct).
77 */
78export const webhookDeliveriesTotal = Metric.counter("t3_webhook_deliveries_total", {
79 description: "Webhook requests handled, by outcome and source.",
80});
81
82export const webhookDeliveryDuration = Metric.timer("t3_webhook_delivery_duration", {
83 description: "Time to verify, log, and enqueue one webhook request.",
84});
85
86/** How long a relay-held request waited before this environment got it. */
87export const webhookHeldDelay = Metric.timer("t3_webhook_held_delay", {
88 description:
89 "Time between the relay receiving a webhook request and the environment handling it.",
90});
91
92/** Runs started by webhook deliveries, by `outcome` (started, skipped, failed). */
93export const webhookRunsTotal = Metric.counter("t3_webhook_runs_total", {
94 description: "Runs started from webhook deliveries, by outcome.",
95});
96
7397export const metricAttributes = (
7498 attributes: Readonly<Record<string, unknown>>,
7599): ReadonlyArray<[string, string]> => Object.entries(compactMetricAttributes(attributes));
apps/server/src/persistence/Migrations.ts+2
......@@ -71,6 +71,7 @@ import Migration0054 from "./Migrations/054_ProjectionThreadsAutoSettleDisabledA
7171import Migration0055 from "./Migrations/055_OrchestrationV2.ts";
7272import Migration0056 from "./Migrations/056_RemoveRedundantProjectionIndexes.ts";
7373import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts";
74import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts";
7475
7576/**
7677 * Migration loader with all migrations defined inline.
......@@ -142,6 +143,7 @@ export const migrationEntries = [
142143 [55, "OrchestrationV2", Migration0055],
143144 [56, "RemoveRedundantProjectionIndexes", Migration0056],
144145 [57, "ScheduledTaskWebhooks", Migration0057],
146 [58, "WebhookRelayDeliveries", Migration0058],
145147] as const;
146148
147149export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts+3-1
......@@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => {
1313 Effect.sync(() => {
1414 assert.deepStrictEqual(
1515 migrationEntries.map(([id]) => id),
16 Array.from({ length: 57 }, (_, index) => index + 1),
16 Array.from({ length: 58 }, (_, index) => index + 1),
1717 );
1818 }),
1919 );
......@@ -29,6 +29,7 @@ layer("055_OrchestrationV2", (it) => {
2929 [55, "OrchestrationV2"],
3030 [56, "RemoveRedundantProjectionIndexes"],
3131 [57, "ScheduledTaskWebhooks"],
32 [58, "WebhookRelayDeliveries"],
3233 ]);
3334 assert.deepStrictEqual(yield* runMigrations(), []);
3435
......@@ -52,6 +53,7 @@ layer("055_OrchestrationV2", (it) => {
5253 { migration_id: 55, name: "OrchestrationV2" },
5354 { migration_id: 56, name: "RemoveRedundantProjectionIndexes" },
5455 { migration_id: 57, name: "ScheduledTaskWebhooks" },
56 { migration_id: 58, name: "WebhookRelayDeliveries" },
5557 ]);
5658
5759 const tables = yield* sql<{ readonly name: string }>`
apps/server/src/persistence/Migrations/058_WebhookRelayDeliveries.ts created+22
......@@ -0,0 +1,22 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 // Relay delivery ids already handled, kept longer than the relay holds a
8 // request so a replay can never run twice. The delivery log keeps only the
9 // newest 50 rows per task, which is too short for that.
10 yield* sql`
11 CREATE TABLE IF NOT EXISTS scheduled_task_webhook_relay_deliveries (
12 relay_delivery_id TEXT PRIMARY KEY,
13 task_id TEXT NOT NULL,
14 seen_at TEXT NOT NULL
15 )
16 `;
17
18 yield* sql`
19 CREATE INDEX IF NOT EXISTS idx_scheduled_task_webhook_relay_deliveries_seen
20 ON scheduled_task_webhook_relay_deliveries(seen_at)
21 `;
22});
apps/server/src/persistence/reconcileV2PreviewMigration.test.ts+2
......@@ -38,6 +38,7 @@ describe("V2 preview upgrade", () => {
3838 [54, "ProjectionThreadsAutoSettleDisabledAt"],
3939 [56, "RemoveRedundantProjectionIndexes"],
4040 [57, "ScheduledTaskWebhooks"],
41 [58, "WebhookRelayDeliveries"],
4142 ]);
4243 assert.deepStrictEqual(yield* runMigrations(), []);
4344 assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
......@@ -118,6 +119,7 @@ describe("V2 preview upgrade", () => {
118119 [54, "ProjectionThreadsAutoSettleDisabledAt"],
119120 [56, "RemoveRedundantProjectionIndexes"],
120121 [57, "ScheduledTaskWebhooks"],
122 [58, "WebhookRelayDeliveries"],
121123 ]);
122124 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
123125 );
apps/server/src/relay/HeldHooksWaker.ts created+52
......@@ -0,0 +1,52 @@
1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5
6import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
7import * as CloudManagedEndpointRuntime from "../cloud/ManagedEndpointRuntime.ts";
8import { readHoldWebhooksWhileOffline, readRelayConnection } from "../cloud/config.ts";
9import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
10import { makeRelayEnvironmentClient } from "./relayEnvironmentClient.ts";
11
12/**
13 * Tells T3 Connect this environment is reachable again, so the relay delivers
14 * the webhook requests it held while we were offline now rather than at its
15 * next backoff step. Nothing happens unless the environment opted in.
16 */
17const wakeHeldHooks = Effect.fn("HeldHooksWaker.wake")(function* () {
18 const secrets = yield* ServerSecretStore.ServerSecretStore;
19 if (!(yield* readHoldWebhooksWhileOffline(secrets))) return false;
20 const connection = yield* readRelayConnection(secrets);
21 if (connection === null) return false;
22 const environmentId = yield* (yield* ServerEnvironment.ServerEnvironment).getEnvironmentId;
23 const client = yield* makeRelayEnvironmentClient(connection);
24 const { pending } = yield* client.server.wakeHeldHooks({ params: { environmentId } });
25 yield* Effect.annotateCurrentSpan({ "relay.inbox.pending": pending });
26 return pending;
27});
28
29/** Wakes held webhooks each time the managed tunnel connects. */
30export const layer = Layer.effectDiscard(
31 Effect.gen(function* () {
32 const runtime = yield* CloudManagedEndpointRuntime.CloudManagedEndpointRuntime;
33 const wake = wakeHeldHooks().pipe(
34 Effect.timeout("10 seconds"),
35 // The relay retries on its own schedule too, so a few tries are enough.
36 Effect.retry({ schedule: Schedule.exponential("2 seconds"), times: 3 }),
37 Effect.tap((pending) =>
38 pending ? Effect.logInfo("T3 Connect is delivering held webhook requests") : Effect.void,
39 ),
40 Effect.catchCause((cause) =>
41 Effect.logWarning("Could not ask T3 Connect to deliver held webhook requests", { cause }),
42 ),
43 );
44 yield* runtime.tunnelConnected.pipe(
45 // cloudflared registers several connections per (re)connect within a
46 // few seconds; they are one wake.
47 Stream.debounce("3 seconds"),
48 Stream.runForEach(() => wake),
49 Effect.forkScoped,
50 );
51 }),
52);
apps/server/src/relay/relayEnvironmentClient.ts created+21
......@@ -0,0 +1,21 @@
1import { RelayApi } from "@t3tools/contracts/relay";
2import * as Effect from "effect/Effect";
3import * as FetchHttpClient from "effect/http/FetchHttpClient";
4import * as HttpClient from "effect/http/HttpClient";
5import * as HttpClientRequest from "effect/http/HttpClientRequest";
6import * as HttpApiClient from "effect/http-api/HttpApiClient";
7
8/**
9 * A typed RelayApi client that authenticates as this environment, for the
10 * environment-credential endpoints (link preferences, held webhooks).
11 */
12export const makeRelayEnvironmentClient = (connection: {
13 readonly url: string;
14 readonly environmentCredential: string;
15}) =>
16 HttpApiClient.make(RelayApi, {
17 baseUrl: connection.url,
18 transformClient: HttpClient.mapRequest(
19 HttpClientRequest.setHeader("authorization", `Bearer ${connection.environmentCredential}`),
20 ),
21 }).pipe(Effect.provide(FetchHttpClient.layer));
apps/server/src/scheduledTasks/ScheduledTaskService.ts+345-125
......@@ -1,6 +1,7 @@
11import {
22 CommandId,
33 MessageId,
4 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
45 ScheduledTask,
56 ScheduledTaskError,
67 ScheduledTaskId,
......@@ -27,6 +28,8 @@ import * as Crypto from "effect/Crypto";
2728import * as Data from "effect/Data";
2829import * as DateTime from "effect/DateTime";
2930import * as Effect from "effect/Effect";
31import * as Duration from "effect/Duration";
32import * as Metric from "effect/Metric";
3033import * as Layer from "effect/Layer";
3134import * as Option from "effect/Option";
3235import * as PubSub from "effect/PubSub";
......@@ -38,10 +41,16 @@ import * as Stream from "effect/Stream";
3841import * as SqlClient from "effect/sql/SqlClient";
3942
4043import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
44import * as Metrics from "../observability/Metrics.ts";
4145import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
4246import * as Scheduler from "../scheduling/Scheduler.ts";
4347import { isMissedFixedTimeRun, isSameSchedule, nextScheduledRunAt } from "./Schedule.ts";
44import { renderWebhookPrompt, type WebhookRequest } from "./webhookTemplate.ts";
48import {
49 redactHeaders,
50 redactQuery,
51 renderWebhookPrompt,
52 type WebhookRequest,
53} from "./webhookTemplate.ts";
4554import { constantTimeEquals, verifyWebhookSignature } from "./webhookVerification.ts";
4655
4756/** Path prefix of the environment route that receives webhook requests. */
......@@ -50,21 +59,44 @@ export const WEBHOOK_ROUTE_PREFIX = "/api/hooks";
5059const WEBHOOK_DELIVERY_RETENTION = 50;
5160/** Body text kept in the delivery log. Larger bodies are cut and flagged. */
5261const WEBHOOK_DELIVERY_LOG_BODY_LIMIT = 64 * 1024;
62/** Rendered prompt text kept in the delivery log. */
63const WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT = 64 * 1024;
5364/** Deliveries one task may hold at once, running or waiting their turn. */
5465const WEBHOOK_MAX_QUEUED_PER_TASK = 20;
5566/** Accepted deliveries per task per minute, enforced here as well as on the relay because the tunnel hostname is public too. */
5667const WEBHOOK_RATE_LIMIT_PER_MINUTE = 60;
5768
58/** Where a webhook task's public URL points. `relayUrl` is null when the environment is not linked to T3 Connect. */
69/**
70 * Where a webhook task's public URL points: `${relayHookBaseUrl}/${taskId}/${token}`.
71 * Null when the environment has no managed tunnel on T3 Connect; clients then show the path.
72 */
5973interface WebhookOrigin {
60 readonly environmentId: string;
61 readonly relayUrl: string | null;
74 readonly relayHookBaseUrl: string | null;
75}
76
77const ENDPOINT_KEY = /^[0-9a-f]{16}$/;
78
79/**
80 * The relay's hook URL prefix for this environment. The relay finds the
81 * environment by its managed tunnel's key (the tunnel name's last segment),
82 * so the URL never reveals the environment id.
83 */
84export function relayHookBaseUrl(input: {
85 readonly relayUrl: string;
86 readonly tunnelName: string | undefined;
87}): string | null {
88 const endpointKey = input.tunnelName?.split("-").at(-1);
89 const relayUrl = input.relayUrl.replace(/\/+$/, "");
90 if (relayUrl === "" || endpointKey === undefined || !ENDPOINT_KEY.test(endpointKey)) {
91 return null;
92 }
93 return `${relayUrl}/v1/hooks/${endpointKey}`;
6294}
6395
6496export class ScheduledTaskWebhookOrigin extends Context.Reference<Effect.Effect<WebhookOrigin>>(
6597 "t3/scheduledTasks/ScheduledTaskWebhookOrigin",
6698 {
67 defaultValue: () => Effect.succeed({ environmentId: "local", relayUrl: null }),
99 defaultValue: () => Effect.succeed({ relayHookBaseUrl: null }),
68100 },
69101) {}
70102
......@@ -83,15 +115,45 @@ export interface WebhookTriggerRequest extends WebhookRequest {
83115 readonly hookId: string;
84116 readonly token: string;
85117 readonly body: Uint8Array;
118 /** Set by T3 Connect; the same id is never dispatched twice. */
119 readonly relayDeliveryId?: string;
120 /** When the relay received a held request; defaults to now. */
121 readonly receivedAt?: string;
86122}
87123
88124/** What the HTTP route should answer. `not_found` covers unknown hooks and wrong tokens alike. */
125/**
126 * What happened to one webhook request, as recorded in metrics and spans.
127 * Sent back to the relay as `x-t3-hook-outcome`; never request contents.
128 */
129export type WebhookDeliveryOutcome =
130 | "accepted"
131 | "duplicate"
132 | "prompt_too_long"
133 | "queue_full"
134 | "rate_limited"
135 | "disabled"
136 | "rejected_signature"
137 | "expired"
138 | "not_found"
139 | "error";
140
89141export type WebhookTriggerResult =
90 | { readonly _tag: "accepted"; readonly deliveryId: ScheduledTaskWebhookDeliveryId }
142 | {
143 readonly _tag: "accepted";
144 readonly deliveryId: ScheduledTaskWebhookDeliveryId;
145 /** A 202 covers more than a started run; this says which. */
146 readonly outcome: "accepted" | "duplicate" | "prompt_too_long";
147 }
91148 | { readonly _tag: "not_found" }
92149 | { readonly _tag: "rejected_signature" }
93150 | { readonly _tag: "disabled" }
94 | { readonly _tag: "rate_limited" };
151 | {
152 readonly _tag: "rate_limited";
153 /** Too many requests to this hook, or too many runs already waiting. */
154 readonly outcome: "rate_limited" | "queue_full";
155 }
156 | { readonly _tag: "expired" };
95157
96158const decodeTask = Schema.decodeUnknownEffect(ScheduledTask);
97159const decodeTaskId = Schema.decodeUnknownOption(ScheduledTaskId);
......@@ -228,19 +290,6 @@ function errorMessage(error: unknown): string {
228290 return String(error);
229291}
230292
231/** Headers kept out of the delivery log because they commonly carry credentials. */
232const REDACTED_HEADER =
233 /^(authorization|proxy-authorization|cookie|set-cookie)$|token|secret|signature|key|password|auth/i;
234
235function redactHeaders(headers: Readonly<Record<string, string>>): Record<string, string> {
236 return Object.fromEntries(
237 Object.entries(headers).map(([name, value]) => [
238 name,
239 REDACTED_HEADER.test(name) ? "[redacted]" : value,
240 ]),
241 );
242}
243
244293function webhookPath(taskId: string, token: string): string {
245294 return `${WEBHOOK_ROUTE_PREFIX}/${encodeURIComponent(taskId)}/${token}`;
246295}
......@@ -250,13 +299,10 @@ function webhookEndpoint(
250299 origin: WebhookOrigin | null,
251300): ScheduledTask["webhook"] {
252301 if (row.webhook_token === null) return undefined;
253 const relayUrl = origin?.relayUrl?.replace(/\/+$/, "") ?? null;
302 const base = origin?.relayHookBaseUrl ?? null;
254303 return {
255304 path: webhookPath(row.task_id, row.webhook_token),
256 url:
257 relayUrl === null || origin === null
258 ? null
259 : `${relayUrl}/v1/hooks/${encodeURIComponent(origin.environmentId)}/${encodeURIComponent(row.task_id)}/${row.webhook_token}`,
305 url: base === null ? null : `${base}/${encodeURIComponent(row.task_id)}/${row.webhook_token}`,
260306 hasSecret: row.webhook_secret !== null,
261307 };
262308}
......@@ -985,6 +1031,7 @@ export const layer = Layer.effect(
9851031 encoding: input.schedule.signature.encoding,
9861032 prefix: input.schedule.signature.prefix,
9871033 },
1034 maxDeliveryAgeMinutes: input.schedule.maxDeliveryAgeMinutes ?? null,
9881035 }
9891036 : input.schedule;
9901037 const webhook =
......@@ -1197,6 +1244,7 @@ export const layer = Layer.effect(
11971244 readonly signatureVerified: boolean;
11981245 readonly missing: ReadonlyArray<string>;
11991246 readonly renderedPrompt: string | null;
1247 readonly error?: string;
12001248 }) => {
12011249 const truncated = input.request.body.byteLength > WEBHOOK_DELIVERY_LOG_BODY_LIMIT;
12021250 const loggedBody = truncated
......@@ -1215,12 +1263,22 @@ export const layer = Layer.effect(
12151263 )
12161264 SELECT
12171265 ${input.id}, ${input.taskId}, ${input.receivedAt}, ${input.request.method},
1218 ${input.request.query}, ${encodeHeadersJson(redactHeaders(input.request.headers))},
1266 ${redactQuery(input.request.query)},
1267 ${encodeHeadersJson(redactHeaders(input.request.headers))},
12191268 ${loggedBody},
12201269 ${input.request.body.byteLength}, ${truncated ? 1 : 0}, ${input.outcome},
12211270 ${input.signatureVerified ? 1 : 0}, ${encodeMissingFieldsJson(input.missing)},
1222 ${input.renderedPrompt}, NULL
1271 ${input.renderedPrompt?.slice(0, WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT) ?? null},
1272 ${input.error ?? null}
12231273 WHERE EXISTS (SELECT 1 FROM scheduled_tasks WHERE task_id = ${input.taskId})
1274 -- A held request retried after a rate limit reuses its relay
1275 -- delivery id; the newer attempt replaces the logged one.
1276 ON CONFLICT (delivery_id) DO UPDATE SET
1277 outcome = excluded.outcome,
1278 signature_verified = excluded.signature_verified,
1279 missing_fields_json = excluded.missing_fields_json,
1280 rendered_prompt = excluded.rendered_prompt,
1281 error = excluded.error
12241282 `;
12251283 yield* sql`
12261284 DELETE FROM scheduled_task_webhook_deliveries
......@@ -1295,10 +1353,55 @@ export const layer = Layer.effect(
12951353 // run; scoped to the service so shutdown interrupts it.
12961354 const serviceScope = yield* Effect.scope;
12971355
1356 /**
1357 * Records one handled request on the span and in metrics. `outcome` is
1358 * finer than the result tag: it separates a full queue and an oversized
1359 * prompt from the rest, which is what an operator needs to act on.
1360 */
1361 const observeDelivery = (
1362 request: WebhookTriggerRequest,
1363 outcome: WebhookDeliveryOutcome,
1364 receivedAt: DateTime.DateTime | undefined,
1365 now: DateTime.DateTime | undefined,
1366 ) =>
1367 Effect.gen(function* () {
1368 const source = request.relayDeliveryId === undefined ? "direct" : "relay";
1369 yield* Effect.annotateCurrentSpan({
1370 "scheduled_task.webhook.outcome": outcome,
1371 "scheduled_task.webhook.source": source,
1372 });
1373 yield* Metrics.increment(Metrics.webhookDeliveriesTotal, { outcome, source });
1374 if (request.receivedAt !== undefined && receivedAt !== undefined && now !== undefined) {
1375 const heldMs = Math.max(
1376 0,
1377 DateTime.toEpochMillis(now) - DateTime.toEpochMillis(receivedAt),
1378 );
1379 yield* Effect.annotateCurrentSpan({ "scheduled_task.webhook.held_ms": heldMs });
1380 yield* Metric.update(Metrics.webhookHeldDelay, Duration.millis(heldMs));
1381 }
1382 });
1383
12981384 const triggerWebhook: ScheduledTaskService["Service"]["triggerWebhook"] = (request) =>
1385 triggerWebhookUnobserved(request).pipe(
1386 Effect.tapError(() => observeDelivery(request, "error", undefined, undefined)),
1387 Metrics.withMetrics({ timer: Metrics.webhookDeliveryDuration }),
1388 Effect.withSpan("ScheduledTaskService.triggerWebhook", {
1389 attributes: {
1390 "scheduled_task.webhook.method": request.method,
1391 "scheduled_task.webhook.body_bytes": request.body.byteLength,
1392 "scheduled_task.webhook.source":
1393 request.relayDeliveryId === undefined ? "direct" : "relay",
1394 },
1395 }),
1396 );
1397
1398 const triggerWebhookUnobserved = (request: WebhookTriggerRequest) =>
12991399 Effect.gen(function* () {
13001400 const taskId = decodeTaskId(request.hookId);
1301 if (Option.isNone(taskId)) return { _tag: "not_found" as const };
1401 const notFound = observeDelivery(request, "not_found", undefined, undefined).pipe(
1402 Effect.as({ _tag: "not_found" as const }),
1403 );
1404 if (Option.isNone(taskId)) return yield* notFound;
13021405 const rows = yield* getRows(taskId.value).pipe(
13031406 Effect.mapError((cause) =>
13041407 taskError("Could not load schedule task.", { taskId: taskId.value, cause }),
......@@ -1312,110 +1415,227 @@ export const layer = Layer.effect(
13121415 row.webhook_token === null ||
13131416 !constantTimeEquals(request.token, row.webhook_token)
13141417 ) {
1315 return { _tag: "not_found" as const };
1418 return yield* notFound;
13161419 }
13171420 const task = yield* decodeRow(row);
1318 if (task.schedule.type !== "webhook") return { _tag: "not_found" as const };
1421 yield* Effect.annotateCurrentSpan({ "scheduled_task.id": task.id });
1422 const schedule = task.schedule;
1423 if (schedule.type !== "webhook") return yield* notFound;
13191424
1320 const receivedAt = yield* localNow;
1321 const deliveryUuid = yield* crypto.randomUUIDv4.pipe(
1322 Effect.mapError((cause) => taskError("Could not generate delivery id.", { cause })),
1425 const now = yield* localNow;
1426 // Anyone can reach the tunnel directly and set the relay's header, so
1427 // a receive time is never later than now: a future one would pin the
1428 // delivery in the log and slip past the task's max age.
1429 const receivedAt =
1430 request.receivedAt === undefined
1431 ? now
1432 : DateTime.min(
1433 Option.getOrElse(DateTime.make(request.receivedAt), () => now),
1434 now,
1435 );
1436 const observe = (outcome: WebhookDeliveryOutcome) =>
1437 observeDelivery(request, outcome, receivedAt, now);
1438 const deliveryId = ScheduledTaskWebhookDeliveryId.make(
1439 request.relayDeliveryId === undefined
1440 ? `delivery:${yield* crypto.randomUUIDv4.pipe(
1441 Effect.mapError((cause) => taskError("Could not generate delivery id.", { cause })),
1442 )}`
1443 : `delivery:relay:${request.relayDeliveryId}`,
13231444 );
1324 const deliveryId = ScheduledTaskWebhookDeliveryId.make(`delivery:${deliveryUuid}`);
1325 const log = (
1326 outcome: ScheduledTaskWebhookDeliveryOutcome,
1327 details: {
1328 readonly signatureVerified?: boolean;
1329 readonly missing?: ReadonlyArray<string>;
1330 readonly renderedPrompt?: string;
1331 } = {},
1332 ) =>
1333 recordDelivery({
1334 id: deliveryId,
1335 taskId: task.id,
1336 receivedAt: iso(receivedAt),
1337 request,
1338 outcome,
1339 signatureVerified: details.signatureVerified ?? false,
1340 missing: details.missing ?? [],
1341 renderedPrompt: details.renderedPrompt ?? null,
1342 });
1445 // A held request may already have reached this environment directly
1446 // before a timeout; it runs once. Claimed in its own table, kept longer
1447 // than the relay holds a request, because the delivery log is trimmed.
1448 // A rate-limited delivery stays held on the relay, so it must give up
1449 // its claim or the next pass would treat it as already delivered.
1450 const releaseClaim = <A>(result: A) =>
1451 request.relayDeliveryId === undefined
1452 ? Effect.succeed(result)
1453 : sql`
1454 DELETE FROM scheduled_task_webhook_relay_deliveries
1455 WHERE relay_delivery_id = ${request.relayDeliveryId}
1456 `.pipe(Effect.ignore, Effect.as(result));
1457 // From the claim until the run is forked nothing may interrupt: a
1458 // request dropped in between (the relay hangs up after its timeout)
1459 // would leave a claimed delivery that never runs, or a queue slot
1460 // that is never released. Everything in here is local and quick.
1461 return yield* Effect.uninterruptible(
1462 Effect.gen(function* () {
1463 if (request.relayDeliveryId !== undefined) {
1464 const claimed = yield* sql<{ relay_delivery_id: string }>`
1465 INSERT INTO scheduled_task_webhook_relay_deliveries
1466 (relay_delivery_id, task_id, seen_at)
1467 VALUES (${request.relayDeliveryId}, ${task.id}, ${iso(now)})
1468 ON CONFLICT (relay_delivery_id) DO NOTHING
1469 RETURNING relay_delivery_id
1470 `.pipe(
1471 Effect.mapError((cause) =>
1472 taskError("Could not record webhook delivery.", { taskId: task.id, cause }),
1473 ),
1474 );
1475 if (claimed.length === 0) {
1476 // A held request this environment already ran: accepted, not run twice.
1477 yield* observe("duplicate");
1478 return { _tag: "accepted" as const, deliveryId, outcome: "duplicate" as const };
1479 }
1480 // Older claims can no longer be replayed by the relay.
1481 yield* sql`
1482 DELETE FROM scheduled_task_webhook_relay_deliveries
1483 WHERE seen_at < ${iso(DateTime.subtract(now, { hours: 48 }))}
1484 `.pipe(Effect.ignore);
1485 }
1486 const log = (
1487 outcome: ScheduledTaskWebhookDeliveryOutcome,
1488 details: {
1489 readonly signatureVerified?: boolean;
1490 readonly missing?: ReadonlyArray<string>;
1491 readonly renderedPrompt?: string;
1492 readonly error?: string;
1493 } = {},
1494 ) =>
1495 recordDelivery({
1496 id: deliveryId,
1497 taskId: task.id,
1498 receivedAt: iso(receivedAt),
1499 request,
1500 outcome,
1501 signatureVerified: details.signatureVerified ?? false,
1502 missing: details.missing ?? [],
1503 renderedPrompt: details.renderedPrompt ?? null,
1504 ...(details.error === undefined ? {} : { error: details.error }),
1505 });
1506
1507 // Only the first rejected request in a window is logged, so a flood
1508 // cannot write rows or push the real deliveries out of the log.
1509 const slot = yield* takeRateSlot(task.id, DateTime.toEpochMillis(now));
1510 if (slot !== "allowed") {
1511 if (slot === "first_rejected") yield* log("rate_limited");
1512 yield* observe("rate_limited");
1513 return yield* releaseClaim({
1514 _tag: "rate_limited" as const,
1515 outcome: "rate_limited" as const,
1516 });
1517 }
1518 if (!task.enabled) {
1519 yield* log("disabled");
1520 yield* observe("disabled");
1521 return { _tag: "disabled" as const };
1522 }
1523 const signature = schedule.signature;
1524 if (signature !== null) {
1525 const verified =
1526 row.webhook_secret !== null &&
1527 verifyWebhookSignature({
1528 signature,
1529 secret: row.webhook_secret,
1530 headers: request.headers,
1531 body: request.body,
1532 });
1533 if (!verified) {
1534 yield* log("rejected_signature");
1535 yield* observe("rejected_signature");
1536 return { _tag: "rejected_signature" as const };
1537 }
1538 }
1539 const maxAgeMinutes = schedule.maxDeliveryAgeMinutes ?? null;
1540 if (
1541 maxAgeMinutes !== null &&
1542 DateTime.toEpochMillis(now) - DateTime.toEpochMillis(receivedAt) >
1543 maxAgeMinutes * 60_000
1544 ) {
1545 yield* log("expired", { signatureVerified: signature !== null });
1546 yield* observe("expired");
1547 return { _tag: "expired" as const };
1548 }
13431549
1344 // Only the first rejected request in a window is logged, so a flood
1345 // cannot write rows or push the real deliveries out of the log.
1346 const slot = yield* takeRateSlot(task.id, DateTime.toEpochMillis(receivedAt));
1347 if (slot !== "allowed") {
1348 if (slot === "first_rejected") yield* log("rate_limited");
1349 return { _tag: "rate_limited" as const };
1350 }
1351 if (!task.enabled) {
1352 yield* log("disabled");
1353 return { _tag: "disabled" as const };
1354 }
1355 const signature = task.schedule.signature;
1356 if (signature !== null) {
1357 const verified =
1358 row.webhook_secret !== null &&
1359 verifyWebhookSignature({
1360 signature,
1361 secret: row.webhook_secret,
1362 headers: request.headers,
1363 body: request.body,
1550 const rendered = renderWebhookPrompt(task.prompt, request);
1551 // A provider refuses a turn this long, so it is not started. The
1552 // delivery is not retryable, so the claim is kept. Providers trim
1553 // the prompt before checking, so whitespace around it is free.
1554 if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) {
1555 yield* log("dispatch_failed", {
1556 signatureVerified: signature !== null,
1557 missing: rendered.missing,
1558 renderedPrompt: rendered.prompt,
1559 error: "The filled-in prompt is too long.",
1560 });
1561 yield* observe("prompt_too_long");
1562 return { _tag: "accepted" as const, deliveryId, outcome: "prompt_too_long" as const };
1563 }
1564 // Bound the deliveries one task holds, so steady traffic to a stuck
1565 // task cannot pile up parked fibers. A refused request is not logged,
1566 // so it cannot push real deliveries out of the log.
1567 const queueKey = `${task.id}\u0000${task.createdAt}`;
1568 const queued = yield* Ref.modify(webhookQueued, (counts) => {
1569 const count = counts.get(queueKey) ?? 0;
1570 return count >= WEBHOOK_MAX_QUEUED_PER_TASK
1571 ? ([false, counts] as const)
1572 : ([true, new Map(counts).set(queueKey, count + 1)] as const);
13641573 });
1365 if (!verified) {
1366 yield* log("rejected_signature");
1367 return { _tag: "rejected_signature" as const };
1368 }
1369 }
1370
1371 const rendered = renderWebhookPrompt(task.prompt, request);
1372 // Bound the deliveries one task holds, so steady traffic to a stuck
1373 // task cannot pile up parked fibers. A refused request is not logged,
1374 // so it cannot push real deliveries out of the log.
1375 const queueKey = `${task.id}\u0000${task.createdAt}`;
1376 const queued = yield* Ref.modify(webhookQueued, (counts) => {
1377 const count = counts.get(queueKey) ?? 0;
1378 return count >= WEBHOOK_MAX_QUEUED_PER_TASK
1379 ? ([false, counts] as const)
1380 : ([true, new Map(counts).set(queueKey, count + 1)] as const);
1381 });
1382 if (!queued) return { _tag: "rate_limited" as const };
1383 // Entries leave the map when their count reaches zero, so a deleted
1384 // task's key does not linger once its last delivery finishes.
1385 const release = Ref.update(webhookQueued, (counts) => {
1386 const next = new Map(counts);
1387 const count = (next.get(queueKey) ?? 1) - 1;
1388 if (count <= 0) next.delete(queueKey);
1389 else next.set(queueKey, count);
1390 return next;
1391 });
1392 yield* log("accepted", {
1393 signatureVerified: signature !== null,
1394 missing: rendered.missing,
1395 renderedPrompt: rendered.prompt,
1396 }).pipe(Effect.onError(() => release));
1397 const permit = yield* webhookPermit(task.id);
1398 yield* runTask(task, "webhook", { deliveryId, prompt: rendered.prompt }).pipe(
1399 Effect.flatMap((completed) =>
1400 completed.lastRunStatus === "failed"
1401 ? markDeliveryFailed(deliveryId, "The run failed to start.")
1402 : Effect.void,
1403 ),
1404 Effect.catchTag("WebhookDeliverySkipped", (skipped) =>
1405 markDeliveryFailed(deliveryId, skipped.reason),
1406 ),
1407 // The log is readable over RPC, so it gets a fixed reason; the
1408 // cause, which can carry request data, stays in the server log.
1409 Effect.catchCause((cause) =>
1410 Effect.logWarning("Webhook dispatch failed", { taskId: task.id, cause }).pipe(
1411 Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")),
1412 ),
1413 ),
1414 permit.withPermits(1),
1415 Effect.ensuring(release),
1416 Effect.forkIn(serviceScope),
1574 if (!queued) {
1575 // The task is busy with WEBHOOK_MAX_QUEUED_PER_TASK deliveries already.
1576 yield* observe("queue_full");
1577 return yield* releaseClaim({
1578 _tag: "rate_limited" as const,
1579 outcome: "queue_full" as const,
1580 });
1581 }
1582 // Entries leave the map when their count reaches zero, so a deleted
1583 // task's key does not linger once its last delivery finishes.
1584 const release = Ref.update(webhookQueued, (counts) => {
1585 const next = new Map(counts);
1586 const count = (next.get(queueKey) ?? 1) - 1;
1587 if (count <= 0) next.delete(queueKey);
1588 else next.set(queueKey, count);
1589 return next;
1590 });
1591 yield* log("accepted", {
1592 signatureVerified: signature !== null,
1593 missing: rendered.missing,
1594 renderedPrompt: rendered.prompt,
1595 }).pipe(Effect.onError(() => release));
1596 yield* observe("accepted");
1597 const permit = yield* webhookPermit(task.id);
1598 const runOutcome = (outcome: "started" | "skipped" | "failed") =>
1599 Effect.all([
1600 Effect.annotateCurrentSpan({ "scheduled_task.webhook.run_outcome": outcome }),
1601 Metrics.increment(Metrics.webhookRunsTotal, { outcome }),
1602 ]);
1603 yield* runTask(task, "webhook", { deliveryId, prompt: rendered.prompt }).pipe(
1604 Effect.flatMap((completed) =>
1605 completed.lastRunStatus === "failed"
1606 ? runOutcome("failed").pipe(
1607 Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")),
1608 )
1609 : runOutcome("started"),
1610 ),
1611 Effect.catchTag("WebhookDeliverySkipped", (skipped) =>
1612 runOutcome("skipped").pipe(
1613 Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)),
1614 ),
1615 ),
1616 // The log is readable over RPC, so it gets a fixed reason; the
1617 // cause, which can carry request data, stays in the server log.
1618 Effect.catchCause((cause) =>
1619 Effect.logWarning("Webhook dispatch failed", { taskId: task.id, cause }).pipe(
1620 Effect.andThen(runOutcome("failed")),
1621 Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")),
1622 ),
1623 ),
1624 permit.withPermits(1),
1625 // Its own trace: the request that triggered it has already been answered.
1626 Effect.withSpan("ScheduledTaskService.runWebhookDelivery", {
1627 root: true,
1628 attributes: {
1629 "scheduled_task.id": task.id,
1630 "scheduled_task.webhook.delivery_id": deliveryId,
1631 },
1632 }),
1633 Effect.ensuring(release),
1634 Effect.forkIn(serviceScope),
1635 );
1636 return { _tag: "accepted" as const, deliveryId, outcome: "accepted" as const };
1637 }),
14171638 );
1418 return { _tag: "accepted" as const, deliveryId };
14191639 });
14201640
14211641 return ScheduledTaskService.of({
apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts+308-2
......@@ -3,10 +3,14 @@ import * as NodeCrypto from "node:crypto";
33import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto";
44import { assert, it } from "@effect/vitest";
55import { ScheduledTaskUpsertInput } from "@t3tools/contracts";
6import * as DateTime from "effect/DateTime";
67import * as Deferred from "effect/Deferred";
78import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
810import * as Layer from "effect/Layer";
11import * as Metric from "effect/Metric";
912import * as Queue from "effect/Queue";
13import * as EffectScheduler from "effect/Scheduler";
1014import * as Schema from "effect/Schema";
1115import * as TestClock from "effect/testing/TestClock";
1216
......@@ -63,7 +67,7 @@ const withService = <A, E>(
6367 readonly service: ScheduledTaskService.ScheduledTaskService["Service"];
6468 readonly launches: Queue.Queue<LaunchInput>;
6569 }) => Effect.Effect<A, E, never>,
66 options: { readonly gate?: Deferred.Deferred<void> } = {},
70 options: { readonly gate?: Deferred.Deferred<void>; readonly relayHookBaseUrl?: string } = {},
6771) =>
6872 Effect.gen(function* () {
6973 const launches = yield* Queue.unbounded<LaunchInput>();
......@@ -78,6 +82,10 @@ const withService = <A, E>(
7882 ),
7983 }),
8084 Layer.mock(ThreadManagementService.ThreadManagementService)({}),
85 Layer.succeed(
86 ScheduledTaskService.ScheduledTaskWebhookOrigin,
87 Effect.succeed({ relayHookBaseUrl: options.relayHookBaseUrl ?? null }),
88 ),
8189 );
8290 return yield* Effect.gen(function* () {
8391 const service = yield* ScheduledTaskService.ScheduledTaskService;
......@@ -120,6 +128,35 @@ it.effect("dispatches exactly the rendered prompt and logs the delivery", () =>
120128 ),
121129);
122130
131it("builds the relay hook URL from the managed tunnel's key, never the environment id", () => {
132 const relayUrl = "https://relay.example.com/";
133 assert.equal(
134 ScheduledTaskService.relayHookBaseUrl({
135 relayUrl,
136 tunnelName: "t3coderelay-managedendpoint-dev-julius-0123456789abcdef",
137 }),
138 "https://relay.example.com/v1/hooks/0123456789abcdef",
139 );
140 for (const tunnelName of [undefined, "t3coderelay-managedendpoint", "x-0123456789ABCDEF"]) {
141 assert.isNull(ScheduledTaskService.relayHookBaseUrl({ relayUrl, tunnelName }));
142 }
143});
144
145it.effect("gives webhook tasks a relay URL when the environment has a managed tunnel", () =>
146 withService(
147 ({ service }) =>
148 Effect.gen(function* () {
149 const { task } = yield* service.upsert(yield* webhookTaskInput());
150 const token = task.webhook!.path.split("/").at(-1);
151 assert.equal(
152 task.webhook?.url,
153 `https://relay.example.com/v1/hooks/0123456789abcdef/scheduled-task%3Ahook/${token}`,
154 );
155 }),
156 { relayHookBaseUrl: "https://relay.example.com/v1/hooks/0123456789abcdef" },
157 ),
158);
159
123160it.effect("answers not found for a wrong token or unknown hook without logging", () =>
124161 withService(({ service }) =>
125162 Effect.gen(function* () {
......@@ -168,6 +205,7 @@ it.effect("checks the configured signature and keeps the secret write-only", ()
168205 assert.deepEqual(task.schedule, {
169206 type: "webhook",
170207 signature: { header: "x-hub-signature-256", encoding: "hex", prefix: "sha256=" },
208 maxDeliveryAgeMinutes: null,
171209 });
172210 assert.isTrue(task.webhook?.hasSecret);
173211
......@@ -298,12 +336,13 @@ it.effect("keeps the newest 50 deliveries when they share a timestamp", () =>
298336 ),
299337);
300338
301it.effect("keeps credential headers out of the delivery log", () =>
339it.effect("keeps credential headers and query values out of the delivery log", () =>
302340 withService(({ service }) =>
303341 Effect.gen(function* () {
304342 const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false }));
305343 yield* service.triggerWebhook(
306344 requestFor(task, {
345 query: "page=2&api_key=k",
307346 headers: {
308347 "content-type": "application/json",
309348 authorization: "Bearer sender-token",
......@@ -317,6 +356,7 @@ it.effect("keeps credential headers out of the delivery log", () =>
317356 id: task.id,
318357 deliveryId: summary!.id,
319358 });
359 assert.equal(delivery.query, "page=2&api_key=[redacted]");
320360 assert.equal(delivery.headers.authorization, "[redacted]");
321361 assert.equal(delivery.headers["x-webhook-key"], "[redacted]");
322362 assert.equal(delivery.headers["x-github-event"], "push");
......@@ -454,6 +494,209 @@ it.effect("logs a body's first 64 KiB by bytes, not characters", () =>
454494 ),
455495);
456496
497it.effect("counts the prompt as the provider does, without surrounding whitespace", () =>
498 withService(({ service, launches }) =>
499 Effect.gen(function* () {
500 const { task } = yield* service.upsert(yield* webhookTaskInput({ prompt: "{{body.text}}" }));
501 // Over the limit as sent, within it once the padding is trimmed.
502 const text = `{"text":"${" ".repeat(1_000)}${"x".repeat(119_990)}${"\\n".repeat(1_000)}"}`;
503 const result = yield* service.triggerWebhook(
504 requestFor(task, { body: new TextEncoder().encode(text), bodyText: text }),
505 );
506 assert.equal(result._tag, "accepted");
507 yield* Queue.take(launches);
508 }),
509 ),
510);
511
512it.effect("does not start a run when the filled-in prompt is too long", () =>
513 withService(({ service, launches }) =>
514 Effect.gen(function* () {
515 const { task } = yield* service.upsert(yield* webhookTaskInput({ prompt: "{{body}}" }));
516 const text = "x".repeat(200_000);
517 const body = new TextEncoder().encode(text);
518 const result = yield* service.triggerWebhook(requestFor(task, { body, bodyText: text }));
519 assert.equal(result._tag, "accepted");
520 assert.equal(yield* Queue.size(launches), 0);
521 const { delivery } = yield* service.getWebhookDelivery({
522 id: task.id,
523 deliveryId: result._tag === "accepted" ? result.deliveryId : ("" as never),
524 });
525 assert.equal(delivery.outcome, "dispatch_failed");
526 assert.equal(delivery.error, "The filled-in prompt is too long.");
527 assert.equal(delivery.renderedPrompt?.length, 64 * 1024);
528 // The queue slot was never taken: a normal delivery still runs.
529 const ok = yield* service.triggerWebhook(requestFor(task));
530 assert.equal(ok._tag, "accepted");
531 yield* Queue.take(launches);
532 }),
533 ),
534);
535
536it.effect("a held request already delivered directly runs only once", () =>
537 withService(({ service, launches }) =>
538 Effect.gen(function* () {
539 const { task } = yield* service.upsert(yield* webhookTaskInput());
540 const direct = yield* service.triggerWebhook(
541 requestFor(task, { relayDeliveryId: "relay-1" }),
542 );
543 const replayed = yield* service.triggerWebhook(
544 requestFor(task, { relayDeliveryId: "relay-1", receivedAt: "2026-10-04T10:00:00.000Z" }),
545 );
546 assert.equal(direct._tag, "accepted");
547 // Same delivery, answered the same way, but recorded as a duplicate.
548 assert.deepEqual(replayed, { ...direct, outcome: "duplicate" } as typeof replayed);
549 yield* Queue.take(launches);
550 const logged = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries;
551 assert.equal(logged.length, 1);
552 }),
553 ),
554);
555
556it.effect("a held request whose sender hung up mid-request still runs", () =>
557 withService(({ service, launches }) =>
558 Effect.gen(function* () {
559 const { task } = yield* service.upsert(yield* webhookTaskInput());
560 // The relay times out and the request fiber is interrupted. A small
561 // operation budget makes the request yield often, so stepping the
562 // interrupt one yield later each time lands it at every point in the
563 // request (one takes about 40 yields), then the relay retries.
564 for (let step = 0; step < 60; step++) {
565 const request = requestFor(task, { relayDeliveryId: `hung-up-${step}` });
566 const fiber = yield* service
567 .triggerWebhook(request)
568 .pipe(Effect.provideService(EffectScheduler.MaxOpsBeforeYield, 8), Effect.forkChild);
569 for (let yields = 0; yields < step; yields++) yield* Effect.yieldNow;
570 yield* Fiber.interrupt(fiber);
571 const retried = yield* service.triggerWebhook(request);
572 assert.equal(retried._tag, "accepted");
573 const deliveryId = retried._tag === "accepted" ? retried.deliveryId : undefined;
574 // Logged and run exactly once, whether or not the first attempt got through.
575 yield* service.getWebhookDelivery({ id: task.id, deliveryId: deliveryId! });
576 const launch = yield* Queue.take(launches);
577 assert.include(launch.commandId, `hung-up-${step}`);
578 // Keep each step in a fresh rate-limit window.
579 yield* TestClock.adjust("61 seconds");
580 }
581 assert.equal(yield* Queue.size(launches), 0);
582 }),
583 ),
584);
585
586it.effect("a held request runs once even after the log has trimmed it", () =>
587 withService(({ service, launches }) =>
588 Effect.gen(function* () {
589 const { task } = yield* service.upsert(yield* webhookTaskInput());
590 const first = yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "kept" }));
591 assert.equal(first._tag, "accepted");
592 yield* Queue.take(launches);
593 // Push the original row out of the 50-row delivery log.
594 const paused = yield* service.upsert(yield* webhookTaskInput({ enabled: false }));
595 yield* Effect.forEach(Array.from({ length: 55 }), () =>
596 service.triggerWebhook(requestFor(paused.task)),
597 );
598 yield* service.upsert(yield* webhookTaskInput());
599 const replay = yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "kept" }));
600 assert.equal(replay._tag, "accepted");
601 assert.equal(yield* Queue.size(launches), 0);
602 }),
603 ),
604);
605
606it.effect("a rate-limited held request can run on a later pass", () =>
607 withService(({ service, launches }) =>
608 Effect.gen(function* () {
609 const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false }));
610 // Spend the task's 60-a-minute budget.
611 yield* Effect.forEach(Array.from({ length: 60 }), () =>
612 service.triggerWebhook(requestFor(task)),
613 );
614 const limited = yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "later" }));
615 assert.equal(limited._tag, "rate_limited");
616 yield* service.upsert(yield* webhookTaskInput());
617 yield* TestClock.adjust("61 seconds");
618 const retried = yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "later" }));
619 assert.equal(retried._tag, "accepted");
620 yield* Queue.take(launches);
621 }),
622 ),
623);
624
625it.effect("logs a held request at the time the relay received it", () =>
626 withService(({ service }) =>
627 Effect.gen(function* () {
628 const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false }));
629 const receivedAt = DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { minutes: 5 }));
630 yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "relay-2", receivedAt }));
631 const [delivery] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries;
632 assert.equal(delivery?.receivedAt, receivedAt);
633 }),
634 ),
635);
636
637it.effect("a receive time in the future counts as now", () =>
638 withService(({ service, launches }) =>
639 Effect.gen(function* () {
640 const { task } = yield* service.upsert(
641 yield* webhookTaskInput({ schedule: { type: "webhook", maxDeliveryAgeMinutes: 30 } }),
642 );
643 const now = yield* DateTime.now;
644 const result = yield* service.triggerWebhook(
645 requestFor(task, {
646 relayDeliveryId: "future",
647 receivedAt: DateTime.formatIso(DateTime.add(now, { days: 365 })),
648 }),
649 );
650 assert.equal(result._tag, "accepted");
651 yield* Queue.take(launches);
652 const [delivery] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries;
653 assert.equal(delivery?.receivedAt, DateTime.formatIso(now));
654 }),
655 ),
656);
657
658it.effect("skips a held request older than the task's max age", () =>
659 withService(({ service, launches }) =>
660 Effect.gen(function* () {
661 const { task } = yield* service.upsert(
662 yield* webhookTaskInput({ schedule: { type: "webhook", maxDeliveryAgeMinutes: 30 } }),
663 );
664 const now = yield* DateTime.now;
665 const old = DateTime.formatIso(DateTime.subtract(now, { minutes: 31 }));
666 const fresh = DateTime.formatIso(DateTime.subtract(now, { minutes: 5 }));
667 const tooOld = yield* service.triggerWebhook(
668 requestFor(task, { relayDeliveryId: "old", receivedAt: old }),
669 );
670 assert.equal(tooOld._tag, "expired");
671 assert.equal(yield* Queue.size(launches), 0);
672 const ok = yield* service.triggerWebhook(
673 requestFor(task, { relayDeliveryId: "fresh", receivedAt: fresh }),
674 );
675 assert.equal(ok._tag, "accepted");
676 const outcomes = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.map(
677 (delivery) => delivery.outcome,
678 );
679 assert.includeMembers(outcomes, ["expired", "accepted"]);
680 }),
681 ),
682);
683
684it.effect("runs a held request of any age when no max age is set", () =>
685 withService(({ service }) =>
686 Effect.gen(function* () {
687 const { task } = yield* service.upsert(yield* webhookTaskInput());
688 const now = yield* DateTime.now;
689 const result = yield* service.triggerWebhook(
690 requestFor(task, {
691 relayDeliveryId: "ancient",
692 receivedAt: DateTime.formatIso(DateTime.subtract(now, { hours: 23 })),
693 }),
694 );
695 assert.equal(result._tag, "accepted");
696 }),
697 ),
698);
699
457700it.effect("deleting a task removes its delivery log", () =>
458701 withService(({ service }) =>
459702 Effect.gen(function* () {
......@@ -464,3 +707,66 @@ it.effect("deleting a task removes its delivery log", () =>
464707 }),
465708 ),
466709);
710
711const signatureFor = (secret: string) =>
712 `sha256=${NodeCrypto.createHmac("sha256", secret).update(pullRequestBody).digest("hex")}`;
713
714/** Count recorded by `t3_webhook_deliveries_total` for one outcome and source. */
715const deliveriesCounted = (outcome: string, source: "relay" | "direct") =>
716 Metric.snapshot.pipe(
717 Effect.map((snapshots) => {
718 const found = snapshots.find(
719 (snapshot) =>
720 snapshot.id === "t3_webhook_deliveries_total" &&
721 snapshot.attributes?.outcome === outcome &&
722 snapshot.attributes?.source === source,
723 );
724 return found?.type === "Counter" ? Number(found.state.count) : 0;
725 }),
726 );
727
728it.effect("counts each handled request by what happened to it", () =>
729 withService(({ service, launches }) =>
730 Effect.gen(function* () {
731 const { task } = yield* service.upsert(
732 yield* webhookTaskInput({
733 schedule: {
734 type: "webhook",
735 signature: {
736 header: "x-hub-signature-256",
737 encoding: "hex",
738 prefix: "sha256=",
739 secret: "github-secret",
740 },
741 },
742 }),
743 );
744 const before = {
745 accepted: yield* deliveriesCounted("accepted", "direct"),
746 rejected: yield* deliveriesCounted("rejected_signature", "direct"),
747 notFound: yield* deliveriesCounted("not_found", "direct"),
748 relayAccepted: yield* deliveriesCounted("accepted", "relay"),
749 duplicate: yield* deliveriesCounted("duplicate", "relay"),
750 };
751 const signed = {
752 "content-type": "application/json",
753 "x-hub-signature-256": signatureFor("github-secret"),
754 };
755 yield* service.triggerWebhook(requestFor(task, { headers: signed }));
756 yield* Queue.take(launches);
757 yield* service.triggerWebhook(requestFor(task));
758 yield* service.triggerWebhook(requestFor(task, { token: "wrong" }));
759 // A request the relay held, then the same request again.
760 const relayed = requestFor(task, { headers: signed, relayDeliveryId: "relay-1" });
761 yield* service.triggerWebhook(relayed);
762 yield* Queue.take(launches);
763 yield* service.triggerWebhook(relayed);
764
765 assert.equal((yield* deliveriesCounted("accepted", "direct")) - before.accepted, 1);
766 assert.equal((yield* deliveriesCounted("rejected_signature", "direct")) - before.rejected, 1);
767 assert.equal((yield* deliveriesCounted("not_found", "direct")) - before.notFound, 1);
768 assert.equal((yield* deliveriesCounted("accepted", "relay")) - before.relayAccepted, 1);
769 assert.equal((yield* deliveriesCounted("duplicate", "relay")) - before.duplicate, 1);
770 }),
771 ),
772);
apps/server/src/scheduledTasks/relayDeliveryProof.ts created+74
......@@ -0,0 +1,74 @@
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+204-20
......@@ -1,6 +1,9 @@
11import { describe, expect, it } from "@effect/vitest";
2import * as NodeCrypto from "node:crypto";
3import * as Clock from "effect/Clock";
24import * as Effect from "effect/Effect";
35import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
47import * as NodeServices from "@effect/platform-node/NodeServices";
58import * as Etag from "effect/http/Etag";
69import * as HttpPlatform from "effect/http/HttpPlatform";
......@@ -8,8 +11,10 @@ import * as HttpRouter from "effect/http/HttpRouter";
811import * as HttpApi from "effect/http-api/HttpApi";
912import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
1013
14import { RELAY_HOOK_DELIVERY_TYP, signRelayJwt } from "@t3tools/shared/relayJwt";
1115import {
1216 EnvironmentHttpApi,
17 EnvironmentId,
1318 ScheduledTaskWebhookDeliveryId,
1419 ScheduledTaskError,
1520} from "@t3tools/contracts";
......@@ -18,19 +23,76 @@ import {
1823 type WebhookTriggerRequest,
1924 type WebhookTriggerResult,
2025} from "./ScheduledTaskService.ts";
26import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
27import { CLOUD_MINT_PUBLIC_KEY, RELAY_ISSUER_SECRET } from "../cloud/config.ts";
28import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
2129import { WEBHOOK_MAX_BODY_BYTES, webhookHttpApiLayer } from "./webhookRoute.ts";
2230
2331class WebhookTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.webhooks) {}
2432
33const environmentId = EnvironmentId.make("env-1");
34const relayIssuer = "https://relay.example.test";
35const mintKeys = NodeCrypto.generateKeyPairSync("ed25519", {
36 privateKeyEncoding: { format: "pem", type: "pkcs8" },
37 publicKeyEncoding: { format: "pem", type: "spki" },
38});
39/** The secrets a T3 Connect-linked environment holds, keyed by name. */
40const linkedSecrets: ReadonlyMap<string, string> = new Map([
41 [CLOUD_MINT_PUBLIC_KEY, mintKeys.publicKey],
42 [RELAY_ISSUER_SECRET, relayIssuer],
43]);
44
45/** A proof as the relay signs it for one delivery. */
46const relayProof = Effect.fn("relayProof")(function* (claims: {
47 readonly deliveryId: string;
48 readonly receivedAt: string;
49 readonly hookId: string;
50 readonly privateKey?: string;
51}) {
52 const now = Math.floor((yield* Clock.currentTimeMillis) / 1_000);
53 return yield* signRelayJwt({
54 privateKey: claims.privateKey ?? mintKeys.privateKey,
55 typ: RELAY_HOOK_DELIVERY_TYP,
56 payload: {
57 iss: relayIssuer,
58 aud: `t3-env:${environmentId}`,
59 sub: environmentId,
60 jti: `proof:${claims.deliveryId}`,
61 iat: now,
62 exp: now + 3_600,
63 environmentId,
64 deliveryId: claims.deliveryId,
65 receivedAt: claims.receivedAt,
66 hookId: claims.hookId,
67 },
68 });
69});
70
2571const handlerFor = (
2672 trigger: (
2773 request: WebhookTriggerRequest,
2874 ) => Effect.Effect<WebhookTriggerResult, ScheduledTaskError>,
75 secrets: ReadonlyMap<string, string> = linkedSecrets,
2976) =>
3077 HttpRouter.toWebHandler(
3178 HttpApiBuilder.layer(WebhookTestApi).pipe(
3279 Layer.provide(webhookHttpApiLayer),
3380 Layer.provide(Layer.mock(ScheduledTaskService)({ triggerWebhook: trigger })),
81 Layer.provide(
82 Layer.mock(ServerSecretStore.ServerSecretStore)({
83 get: (name) =>
84 Effect.succeed(
85 Option.map(Option.fromUndefinedOr(secrets.get(name)), (value) =>
86 new TextEncoder().encode(value),
87 ),
88 ),
89 }),
90 ),
91 Layer.provide(
92 Layer.mock(ServerEnvironment.ServerEnvironment)({
93 getEnvironmentId: Effect.succeed(environmentId),
94 }),
95 ),
3496 Layer.provide(
3597 HttpPlatform.layer.pipe(
3698 Layer.provideMerge(NodeServices.layer),
......@@ -56,6 +118,7 @@ describe("webhook route", () => {
56118 return Effect.succeed({
57119 _tag: "accepted",
58120 deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:1"),
121 outcome: "accepted",
59122 });
60123 });
61124 try {
......@@ -77,25 +140,142 @@ describe("webhook route", () => {
77140 }
78141 });
79142
80 it("maps service outcomes to status codes", async () => {
81 const cases: ReadonlyArray<[WebhookTriggerResult["_tag"], number]> = [
82 ["not_found", 404],
83 ["rejected_signature", 401],
84 ["disabled", 409],
85 ["rate_limited", 429],
86 ];
87 for (const [tag, status] of cases) {
88 const { handler, dispose } = handlerFor(() =>
89 Effect.succeed({ _tag: tag } as WebhookTriggerResult),
143 // Live clock: the server checks proofs against real time.
144 it.live("trusts the relay's delivery id and receive time only with its signed proof", () =>
145 Effect.gen(function* () {
146 const received: Array<WebhookTriggerRequest> = [];
147 const { handler, dispose } = handlerFor((request) => {
148 received.push(request);
149 return Effect.succeed({
150 _tag: "accepted",
151 deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:1"),
152 outcome: "accepted",
153 });
154 });
155 const send = (path: string, headers: Record<string, string>) =>
156 Effect.promise(() => handler(post(path, "{}", headers)));
157 const receivedAt = "2026-10-04T10:00:00.000Z";
158 const relayHeaders = {
159 "x-t3-relay-delivery-id": "relay-1",
160 "x-t3-relay-received-at": receivedAt,
161 };
162 const forged = NodeCrypto.generateKeyPairSync("ed25519", {
163 privateKeyEncoding: { format: "pem", type: "pkcs8" },
164 publicKeyEncoding: { format: "pem", type: "spki" },
165 });
166 const proof = yield* relayProof({ deliveryId: "relay-1", receivedAt, hookId: "id" });
167 const forgedProof = yield* relayProof({
168 deliveryId: "relay-1",
169 receivedAt,
170 hookId: "id",
171 privateKey: forged.privateKey,
172 });
173 // 0: signed by the relay for exactly this delivery.
174 yield* send("/api/hooks/id/tok", { ...relayHeaders, "x-t3-relay-delivery": proof });
175 // 1: a direct caller claiming to be the relay, without a proof.
176 yield* send("/api/hooks/id/tok", relayHeaders);
177 // 2: a proof signed with any other key.
178 yield* send("/api/hooks/id/tok", { ...relayHeaders, "x-t3-relay-delivery": forgedProof });
179 // 3: a real proof lifted onto another delivery id.
180 yield* send("/api/hooks/id/tok", {
181 ...relayHeaders,
182 "x-t3-relay-delivery-id": "relay-2",
183 "x-t3-relay-delivery": proof,
184 });
185 // 4: a real proof for another hook.
186 yield* send("/api/hooks/other/tok", { ...relayHeaders, "x-t3-relay-delivery": proof });
187 yield* Effect.promise(() => dispose());
188 expect(received.map((request) => request.relayDeliveryId)).toEqual([
189 "relay-1",
190 undefined,
191 undefined,
192 undefined,
193 undefined,
194 ]);
195 expect(received[0]?.receivedAt).toBe(receivedAt);
196 expect(received.slice(1).every((request) => request.receivedAt === undefined)).toBe(true);
197 }),
198 );
199
200 // Live clock: the server checks proofs against real time.
201 it.live("trusts no relay headers on an environment not linked to T3 Connect", () =>
202 Effect.gen(function* () {
203 const received: Array<WebhookTriggerRequest> = [];
204 const { handler, dispose } = handlerFor((request) => {
205 received.push(request);
206 return Effect.succeed({ _tag: "not_found" });
207 }, new Map());
208 const receivedAt = "2026-10-04T10:00:00.000Z";
209 const proof = yield* relayProof({ deliveryId: "relay-1", receivedAt, hookId: "id" });
210 yield* Effect.promise(() =>
211 handler(
212 post("/api/hooks/id/tok", "{}", {
213 "x-t3-relay-delivery-id": "relay-1",
214 "x-t3-relay-received-at": receivedAt,
215 "x-t3-relay-delivery": proof,
216 }),
217 ),
90218 );
219 yield* Effect.promise(() => dispose());
220 expect(received[0]?.relayDeliveryId).toBeUndefined();
221 }),
222 );
223
224 it("maps service outcomes to status codes and names each outcome for the relay", async () => {
225 const deliveryId = ScheduledTaskWebhookDeliveryId.make("delivery:1");
226 const cases: ReadonlyArray<[WebhookTriggerResult, number, string]> = [
227 [{ _tag: "accepted", deliveryId, outcome: "accepted" }, 202, "accepted"],
228 // Same status as a started run; only the header tells them apart.
229 [{ _tag: "accepted", deliveryId, outcome: "duplicate" }, 202, "duplicate"],
230 [{ _tag: "accepted", deliveryId, outcome: "prompt_too_long" }, 202, "prompt_too_long"],
231 [{ _tag: "not_found" }, 404, "not_found"],
232 [{ _tag: "rejected_signature" }, 401, "rejected_signature"],
233 [{ _tag: "disabled" }, 409, "disabled"],
234 [{ _tag: "rate_limited", outcome: "rate_limited" }, 429, "rate_limited"],
235 [{ _tag: "rate_limited", outcome: "queue_full" }, 429, "queue_full"],
236 [{ _tag: "expired" }, 410, "expired"],
237 ];
238 for (const [result, status, outcome] of cases) {
239 const { handler, dispose } = handlerFor(() => Effect.succeed(result));
91240 try {
92 expect((await handler(post("/api/hooks/id/tok", "{}"))).status).toBe(status);
241 const response = await handler(post("/api/hooks/id/tok", "{}"));
242 expect(response.status).toBe(status);
243 expect(response.headers.get("x-t3-hook-outcome")).toBe(outcome);
93244 } finally {
94245 await dispose();
95246 }
96247 }
97248 });
98249
250 // Live clock: the server checks proofs against real time.
251 it.live("joins the relay's trace only for requests the relay forwarded", () =>
252 Effect.gen(function* () {
253 const parents: Array<string | undefined> = [];
254 const { handler, dispose } = handlerFor(() =>
255 Effect.gen(function* () {
256 const span = yield* Effect.currentParentSpan.pipe(Effect.option);
257 parents.push(span._tag === "Some" ? span.value.traceId : undefined);
258 return { _tag: "not_found" } as const;
259 }),
260 );
261 const send = (headers: Record<string, string>) =>
262 Effect.promise(() => handler(post("/api/hooks/id/tok", "{}", headers)));
263 const traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
264 const receivedAt = "2026-10-04T10:00:00.000Z";
265 const relayHeaders = {
266 "x-t3-relay-delivery-id": "relay-1",
267 "x-t3-relay-received-at": receivedAt,
268 };
269 const proof = yield* relayProof({ deliveryId: "relay-1", receivedAt, hookId: "id" });
270 yield* send({ ...relayHeaders, traceparent, "x-t3-relay-delivery": proof });
271 // Claiming to be the relay without its proof cannot attach to our traces.
272 yield* send({ ...relayHeaders, traceparent });
273 yield* send({ traceparent });
274 yield* Effect.promise(() => dispose());
275 expect(parents).toEqual(["0af7651916cd43dd8448eb211c80319c", undefined, undefined]);
276 }),
277 );
278
99279 it("rejects oversized bodies and malformed paths before reaching the service", async () => {
100280 let calls = 0;
101281 const { handler, dispose } = handlerFor(() => {
......@@ -132,16 +312,20 @@ describe("webhook route", () => {
132312 }
133313 });
134314
135 it("hides service failures behind a 500", async () => {
136 const { handler, dispose } = handlerFor(() =>
315 it("hides service failures and defects behind a fixed 500", async () => {
316 const failures = [
137317 Effect.fail(new ScheduledTaskError({ message: "database locked" })),
138 );
139 try {
140 const response = await handler(post("/api/hooks/id/tok", "{}"));
141 expect(response.status).toBe(500);
142 expect(await response.text()).not.toContain("database");
143 } finally {
144 await dispose();
318 Effect.die(new Error("database exploded")),
319 ];
320 for (const failure of failures) {
321 const { handler, dispose } = handlerFor(() => failure);
322 try {
323 const response = await handler(post("/api/hooks/id/tok", "{}"));
324 expect(response.status).toBe(500);
325 expect(await response.json()).toEqual({ error: "internal_error" });
326 } finally {
327 await dispose();
328 }
145329 }
146330 });
147331});
apps/server/src/scheduledTasks/webhookRoute.ts+56-15
......@@ -2,27 +2,39 @@ import { EnvironmentHttpApi } from "@t3tools/contracts";
22import * as ByteSize from "effect/ByteSize";
33import * as Effect from "effect/Effect";
44import * as Option from "effect/Option";
5import * as Tracer from "effect/Tracer";
56import * as HttpIncomingMessage from "effect/http/HttpIncomingMessage";
67import type * as HttpServerRequest from "effect/http/HttpServerRequest";
78import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import * as HttpTraceContext from "effect/http/HttpTraceContext";
10import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
811import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
912
13import * as Metrics from "../observability/Metrics.ts";
14import { makeRelayDeliveryVerifier } from "./relayDeliveryProof.ts";
1015import * as ScheduledTaskService from "./ScheduledTaskService.ts";
1116
1217/** Largest request body a webhook accepts. The relay enforces the same cap. */
1318export const WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
1419
15const json = (status: number, body: Record<string, string>) =>
16 HttpServerResponse.jsonUnsafe(body, { status });
20/** Response header naming what happened to the request; the relay records it. */
21const WEBHOOK_OUTCOME_HEADER = "x-t3-hook-outcome";
22
23const json = (status: number, body: Record<string, string>, outcome: string) =>
24 HttpServerResponse.jsonUnsafe(body, { status, headers: { [WEBHOOK_OUTCOME_HEADER]: outcome } });
1725
1826/**
1927 * Handles `/api/hooks/:hookId/:token` for every accepted method. The endpoint
2028 * is raw so the signature is checked over the exact body bytes; the service
2129 * checks the token and signature. It is reachable directly, over the managed
22 * tunnel, or through the relay's stable `/v1/hooks/...` URL.
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.
2332 */
2433const handleWebhook =
25 (scheduledTasks: ScheduledTaskService.ScheduledTaskService["Service"]) =>
34 (
35 scheduledTasks: ScheduledTaskService.ScheduledTaskService["Service"],
36 verifyRelayDelivery: Effect.Success<typeof makeRelayDeliveryVerifier>,
37 ) =>
2638 ({
2739 params,
2840 request,
......@@ -33,7 +45,7 @@ const handleWebhook =
3345 Effect.gen(function* () {
3446 const contentLength = Number(request.headers["content-length"] ?? "0");
3547 if (!Number.isFinite(contentLength) || contentLength > WEBHOOK_MAX_BODY_BYTES) {
36 return json(413, { error: "body_too_large" });
48 return json(413, { error: "body_too_large" }, "body_too_large");
3749 }
3850 // Chunked requests carry no content-length, so the reader itself is capped.
3951 const body = yield* request.arrayBuffer.pipe(
......@@ -44,9 +56,15 @@ const handleWebhook =
4456 ),
4557 Effect.option,
4658 );
47 if (Option.isNone(body)) return json(413, { error: "body_too_large_or_unreadable" });
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");
4866 if (body.value.byteLength > WEBHOOK_MAX_BODY_BYTES) {
49 return json(413, { error: "body_too_large" });
67 return yield* tooLarge("body_too_large");
5068 }
5169
5270 const headers: Record<string, string> = {};
......@@ -54,7 +72,18 @@ const handleWebhook =
5472 if (typeof value === "string") headers[name.toLowerCase()] = value;
5573 }
5674 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;
5781
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>();
5887 const result = yield* scheduledTasks
5988 .triggerWebhook({
6089 hookId: params.hookId,
......@@ -65,9 +94,16 @@ const handleWebhook =
6594 headers,
6695 body: body.value,
6796 bodyText: new TextDecoder().decode(body.value),
97 ...(relayDeliveryId ? { relayDeliveryId } : {}),
98 ...(relayReceivedAt ? { receivedAt: relayReceivedAt } : {}),
6899 })
69100 .pipe(
70 Effect.catch((cause) =>
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) =>
71107 Effect.logWarning("Webhook delivery failed").pipe(
72108 Effect.annotateLogs({ hookId: params.hookId }),
73109 Effect.andThen(Effect.logDebug("Webhook delivery failure cause", { cause })),
......@@ -78,17 +114,19 @@ const handleWebhook =
78114
79115 switch (result._tag) {
80116 case "accepted":
81 return json(202, { deliveryId: result.deliveryId });
117 return json(202, { deliveryId: result.deliveryId }, result.outcome);
82118 case "not_found":
83 return json(404, { error: "hook_not_found" });
119 return json(404, { error: "hook_not_found" }, "not_found");
84120 case "rejected_signature":
85 return json(401, { error: "invalid_signature" });
121 return json(401, { error: "invalid_signature" }, "rejected_signature");
86122 case "disabled":
87 return json(409, { error: "hook_disabled" });
123 return json(409, { error: "hook_disabled" }, "disabled");
88124 case "rate_limited":
89 return json(429, { error: "rate_limited" });
125 return json(429, { error: "rate_limited" }, result.outcome);
126 case "expired":
127 return json(410, { error: "delivery_too_old" }, "expired");
90128 case "error":
91 return json(500, { error: "internal_error" });
129 return json(500, { error: "internal_error" }, "error");
92130 }
93131 });
94132
......@@ -96,7 +134,10 @@ export const webhookHttpApiLayer = HttpApiBuilder.group(
96134 EnvironmentHttpApi,
97135 "webhooks",
98136 Effect.fnUntraced(function* (handlers) {
99 const handler = handleWebhook(yield* ScheduledTaskService.ScheduledTaskService);
137 const handler = handleWebhook(
138 yield* ScheduledTaskService.ScheduledTaskService,
139 yield* makeRelayDeliveryVerifier,
140 );
100141 return handlers
101142 .handleRaw("webhookPost", handler)
102143 .handleRaw("webhookPut", handler)
apps/server/src/scheduledTasks/webhookTemplate.test.ts+25
......@@ -69,6 +69,31 @@ describe("renderWebhookPrompt", () => {
6969 assert.deepEqual(plain.missing, ["body.field"]);
7070 });
7171
72 it("redacts credentials in whole-request placeholders but not named ones", () => {
73 const request: WebhookRequest = {
74 ...githubPullRequest,
75 query: "source=github&access_token=q-secret",
76 headers: {
77 ...githubPullRequest.headers,
78 authorization: "Bearer h-secret",
79 "x-hub-signature-256": "sha256=abc",
80 },
81 };
82 const whole = renderWebhookPrompt("{{request}}|{{headers}}|{{query}}", request).prompt;
83 for (const secret of ["q-secret", "h-secret", "sha256=abc"]) {
84 assert.notInclude(whole, secret);
85 }
86 assert.include(whole, "?source=github&access_token=[redacted]\n");
87 assert.include(whole, "authorization: [redacted]");
88 assert.include(whole, "x-github-event: pull_request");
89
90 const named = renderWebhookPrompt(
91 "{{headers.authorization}} {{query.access_token}}",
92 request,
93 ).prompt;
94 assert.equal(named, "Bearer h-secret q-secret");
95 });
96
7297 it("does not resolve inherited object properties", () => {
7398 const rendered = renderWebhookPrompt(
7499 "{{body.constructor}}{{body.__proto__}}",
apps/server/src/scheduledTasks/webhookTemplate.ts+48-6
......@@ -14,6 +14,10 @@
1414 *
1515 * Strings and numbers render as text, objects and arrays as JSON. A path with
1616 * no value renders empty and is reported in `missing`.
17 *
18 * Credential-looking headers and query parameters are redacted wherever the
19 * whole set renders (`{{request}}`, `{{headers}}`, `{{query}}`), as in the
20 * delivery log. Naming one (`{{headers.authorization}}`) gives its raw value.
1721 */
1822
1923export interface WebhookRequest {
......@@ -31,6 +35,40 @@ export interface RenderedWebhookPrompt {
3135 readonly missing: ReadonlyArray<string>;
3236}
3337
38/** Header and query parameter names that commonly carry credentials. */
39const CREDENTIAL_NAME =
40 /^(authorization|proxy-authorization|cookie|set-cookie)$|token|secret|signature|key|password|auth/i;
41const REDACTED = "[redacted]";
42
43export function redactHeaders(headers: Readonly<Record<string, string>>): Record<string, string> {
44 return Object.fromEntries(
45 Object.entries(headers).map(([name, value]) => [
46 name,
47 CREDENTIAL_NAME.test(name) ? REDACTED : value,
48 ]),
49 );
50}
51
52/** Redacts credential-named values in a raw query string, keeping the rest as sent. */
53export function redactQuery(query: string): string {
54 if (query === "") return query;
55 return query
56 .split("&")
57 .map((part) => {
58 const separator = part.indexOf("=");
59 if (separator === -1) return part;
60 const name = part.slice(0, separator);
61 let decoded = name;
62 try {
63 decoded = decodeURIComponent(name.replaceAll("+", " "));
64 } catch {
65 // A malformed escape is matched as sent.
66 }
67 return CREDENTIAL_NAME.test(decoded) ? `${name}=${REDACTED}` : part;
68 })
69 .join("&");
70}
71
3472const PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
3573
3674function parseBody(request: WebhookRequest): unknown {
......@@ -64,9 +102,12 @@ function stringify(value: unknown): string {
64102}
65103
66104function formatWebhookRequest(request: WebhookRequest): string {
67 const headerLines = Object.entries(request.headers).map(([name, value]) => `${name}: ${value}`);
105 const headerLines = Object.entries(redactHeaders(request.headers)).map(
106 ([name, value]) => `${name}: ${value}`,
107 );
108 const query = redactQuery(request.query);
68109 return [
69 `${request.method} ${request.path}${request.query ? `?${request.query}` : ""}`,
110 `${request.method} ${request.path}${query ? `?${query}` : ""}`,
70111 ...headerLines,
71112 "",
72113 request.bodyText,
......@@ -90,12 +131,13 @@ export function renderWebhookPrompt(
90131 return segments.length === 0 ? request.bodyText : lookup(parsedBody(), segments);
91132 case "headers":
92133 return segments.length === 0
93 ? request.headers
134 ? redactHeaders(request.headers)
94135 : request.headers[segments.join(".").toLowerCase()];
95136 case "query": {
96 const params = new URLSearchParams(request.query);
97 if (segments.length === 0) return Object.fromEntries(params);
98 return params.get(segments.join(".")) ?? undefined;
137 if (segments.length === 0) {
138 return Object.fromEntries(new URLSearchParams(redactQuery(request.query)));
139 }
140 return new URLSearchParams(request.query).get(segments.join(".")) ?? undefined;
99141 }
100142 default:
101143 return undefined;
apps/server/src/server.ts+32-20
......@@ -119,8 +119,16 @@ import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http
119119import * as ReplayMarkers from "./auth/replayMarkers.ts";
120120import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
121121import { webhookHttpApiLayer } from "./scheduledTasks/webhookRoute.ts";
122import { ScheduledTaskWebhookOrigin } from "./scheduledTasks/ScheduledTaskService.ts";
123import { CLOUD_ENDPOINT_RUNTIME_CONFIG, RELAY_URL_SECRET } from "./cloud/config.ts";
122import * as HeldHooksWaker from "./relay/HeldHooksWaker.ts";
123import {
124 relayHookBaseUrl,
125 ScheduledTaskWebhookOrigin,
126} from "./scheduledTasks/ScheduledTaskService.ts";
127import {
128 CLOUD_ENDPOINT_RUNTIME_CONFIG,
129 decodeRuntimeConfig,
130 RELAY_URL_SECRET,
131} from "./cloud/config.ts";
124132import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
125133import {
126134 connectHttpApiLayer,
......@@ -452,24 +460,26 @@ const CloudManagedEndpointRuntimeLive = Layer.mergeAll(
452460// to is configured; otherwise clients show the environment-relative path.
453461const ScheduledTaskWebhookOriginLive = Layer.effect(
454462 ScheduledTaskWebhookOrigin,
455 Effect.map(
456 Effect.all([ServerEnvironment.ServerEnvironment, ServerSecretStore.ServerSecretStore]),
457 ([environment, secrets]) =>
458 // The reference holds an effect so each read sees the current link state.
459 Effect.gen(function* () {
460 const [relayUrl, tunnelConfig] = yield* Effect.all([
461 secrets.get(RELAY_URL_SECRET),
462 secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG),
463 ]).pipe(Effect.orElseSucceed(() => [Option.none(), Option.none()] as const));
464 return {
465 environmentId: yield* environment.getEnvironmentId,
466 relayUrl:
467 Option.isSome(relayUrl) && Option.isSome(tunnelConfig)
468 ? new TextDecoder().decode(relayUrl.value) || null
469 : null,
470 };
471 }),
472 ),
463 Effect.gen(function* () {
464 const secrets = yield* ServerSecretStore.ServerSecretStore;
465 // The reference holds an effect so each read sees the current link state.
466 return Effect.gen(function* () {
467 const [relayUrl, tunnelConfig] = yield* Effect.all([
468 secrets.get(RELAY_URL_SECRET),
469 secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG),
470 ]).pipe(Effect.orElseSucceed(() => [Option.none(), Option.none()] as const));
471 if (Option.isNone(relayUrl) || Option.isNone(tunnelConfig)) {
472 return { relayHookBaseUrl: null };
473 }
474 const config = decodeRuntimeConfig(new TextDecoder().decode(tunnelConfig.value));
475 return {
476 relayHookBaseUrl: relayHookBaseUrl({
477 relayUrl: new TextDecoder().decode(relayUrl.value),
478 tunnelName: Option.isSome(config) ? config.value.tunnelName : undefined,
479 }),
480 };
481 });
482 }),
473483);
474484
475485const OrchestrationV2RuntimeLayerLive = OrchestrationV2ProductionLayerLive.pipe(
......@@ -537,6 +547,8 @@ const ProviderInstallationRefreshLive = Layer.effectDiscard(
537547
538548const RuntimeCoreDependenciesBaseLive = Layer.mergeAll(
539549 AgentAwarenessRelay.layer,
550 // Asks T3 Connect to deliver webhooks it held while this environment was offline.
551 HeldHooksWaker.layer,
540552 ThreadSettlementWorkerLive,
541553 Layer.effectDiscard(StorageCleanup.make.pipe(Effect.flatMap((service) => service.start()))).pipe(
542554 Layer.provide(ProjectionStoreV2.layer),
apps/web/src/cloud/linkEnvironment.ts+3-1
......@@ -199,13 +199,15 @@ export function readPrimaryCloudLinkState(input: {
199199export function updatePrimaryCloudPreferences(input: {
200200 readonly target: CloudLinkTarget;
201201 readonly publishAgentActivity: boolean;
202 readonly holdWebhooksWhileOffline?: boolean;
202203}): Effect.Effect<CloudLinkState, CloudEnvironmentLinkError, HttpClient.HttpClient> {
203204 return Effect.gen(function* () {
204205 const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
206 const { target: _target, ...payload } = input;
205207 return yield* client.connect
206208 .preferences({
207209 headers: {},
208 payload: input,
210 payload,
209211 })
210212 .pipe(
211213 Effect.mapError(environmentApiError("Could not update environment cloud preferences.")),
apps/web/src/cloud/linkEnvironmentAtoms.ts+5-2
......@@ -41,6 +41,9 @@ export const updatePrimaryEnvironmentPreferences = createRuntimeCommand(connecti
4141 label: "web:cloud:update-primary-environment-preferences",
4242 scheduler: cloudLinkScheduler,
4343 concurrency: cloudLinkConcurrency,
44 execute: (input: { readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean }) =>
45 updatePrimaryCloudPreferences(input),
44 execute: (input: {
45 readonly target: CloudLinkTarget;
46 readonly publishAgentActivity: boolean;
47 readonly holdWebhooksWhileOffline?: boolean;
48 }) => updatePrimaryCloudPreferences(input),
4649});
apps/web/src/cloud/primaryCloudLinkState.ts+6-3
......@@ -13,6 +13,7 @@ import { usePrimaryEnvironment } from "../state/environments";
1313import { runtime } from "../lib/runtime";
1414import { appAtomRegistry } from "../rpc/atomRegistry";
1515import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment";
16import { hasCloudPublicConfig } from "./publicConfig";
1617
1718const primaryCloudLinkAtomRuntime = Atom.runtime(
1819 Layer.effect(
......@@ -62,9 +63,11 @@ export function usePrimaryCloudLinkState() {
6263 : null,
6364 [primary],
6465 );
65 const atom = target
66 ? primaryCloudLinkStateAtom(targetKey(target))
67 : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM;
66 // Builds without T3 Connect have no link to read; skip the request.
67 const atom =
68 target && hasCloudPublicConfig()
69 ? primaryCloudLinkStateAtom(targetKey(target))
70 : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM;
6871 const result = useAtomValue(atom);
6972 const refresh = useCallback(() => {
7073 refreshPrimaryCloudLinkState(target);
apps/web/src/cloud/useCloudLinkController.ts+7
......@@ -21,6 +21,8 @@ import { resolveRelayClerkTokenOptions } from "./publicConfig";
2121export interface CloudLinkDesiredState {
2222 readonly managedTunnel: boolean;
2323 readonly publish: boolean;
24 /** Omit to leave the webhook-hold setting as it is. */
25 readonly holdWebhooksWhileOffline?: boolean;
2426}
2527
2628/**
......@@ -75,6 +77,7 @@ export function useCloudLinkController() {
7577 const managedTunnelActive =
7678 primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false;
7779 const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false;
80 const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false;
7881 const linked = primaryCloudLinkState.data?.linked ?? false;
7982
8083 const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => {
......@@ -132,6 +135,9 @@ export function useCloudLinkController() {
132135 const prefResult = await updatePrimaryEnvironmentPreferences({
133136 target,
134137 publishAgentActivity: desired.publish,
138 ...(desired.holdWebhooksWhileOffline === undefined
139 ? {}
140 : { holdWebhooksWhileOffline: desired.holdWebhooksWhileOffline }),
135141 });
136142 if (prefResult._tag === "Failure") {
137143 if (!isAtomCommandInterrupted(prefResult)) {
......@@ -157,6 +163,7 @@ export function useCloudLinkController() {
157163 linked,
158164 managedTunnelActive,
159165 publishAgentActivity,
166 holdWebhooksWhileOffline,
160167 operationError,
161168 reconcileCloudState,
162169 };
apps/web/src/components/chat/ThreadAutomationsPanel.tsx+20-22
......@@ -186,28 +186,26 @@ export function ThreadAutomationsPanel(props: {
186186 />
187187 <TooltipPopup>Edit automation</TooltipPopup>
188188 </Tooltip>
189 <Tooltip>
190 <TooltipTrigger
191 render={
192 <ThreadDetailsControl
193 size="icon-xs"
194 variant="ghost"
195 part="icon"
196 aria-label={`Run ${task.title} now`}
197 // A webhook task runs from its URL; there is no request to run it with.
198 disabled={
199 busyTaskId !== null ||
200 task.lastRunStatus === "running" ||
201 task.schedule.type === "webhook"
202 }
203 onClick={() => void runNow(task)}
204 >
205 <PlayIcon className="size-3.5" />
206 </ThreadDetailsControl>
207 }
208 />
209 <TooltipPopup>Run now</TooltipPopup>
210 </Tooltip>
189 {/* A webhook task runs from its URL; there is no request to run it with. */}
190 {task.schedule.type === "webhook" ? null : (
191 <Tooltip>
192 <TooltipTrigger
193 render={
194 <ThreadDetailsControl
195 size="icon-xs"
196 variant="ghost"
197 part="icon"
198 aria-label={`Run ${task.title} now`}
199 disabled={busyTaskId !== null || task.lastRunStatus === "running"}
200 onClick={() => void runNow(task)}
201 >
202 <PlayIcon className="size-3.5" />
203 </ThreadDetailsControl>
204 }
205 />
206 <TooltipPopup>Run now</TooltipPopup>
207 </Tooltip>
208 )}
211209 <Switch
212210 checked={task.enabled}
213211 disabled={busyTaskId !== null}
apps/web/src/components/settings/ConnectionsSettings.tsx+35
......@@ -1761,6 +1761,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
17611761 linkState: primaryCloudLinkState,
17621762 managedTunnelActive,
17631763 publishAgentActivity,
1764 holdWebhooksWhileOffline,
17641765 operationError,
17651766 reconcileCloudState,
17661767 } = useCloudLinkController();
......@@ -1812,6 +1813,25 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
18121813 setIsUpdatingPreference(false);
18131814 };
18141815
1816 const updateHoldWebhooks = async (enabled: boolean) => {
1817 setIsUpdatingPreference(true);
1818 const ok = await reconcileCloudState({
1819 managedTunnel: managedTunnelActive,
1820 publish: publishAgentActivity,
1821 holdWebhooksWhileOffline: enabled,
1822 });
1823 if (ok) {
1824 toastManager.add({
1825 type: "success",
1826 title: enabled ? "Webhooks held while offline" : "Webhooks no longer held",
1827 description: enabled
1828 ? "T3 Connect keeps webhook requests for up to 24 hours while this environment is offline."
1829 : "Requests to an offline environment now fail. Anything already held is still delivered.",
1830 });
1831 }
1832 setIsUpdatingPreference(false);
1833 };
1834
18151835 return (
18161836 <>
18171837 {window.desktopBridge ? (
......@@ -1846,6 +1866,21 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
18461866 />
18471867 }
18481868 />
1869 {managedTunnelActive ? (
1870 <SettingsRow
1871 title={searchableSetting("hold-webhooks-while-offline").title}
1872 description="Keep webhook requests for up to 24 hours while this environment is offline, then deliver them. Off: T3 Connect only forwards requests and stores nothing."
1873 control={
1874 <CloudLinkSwitch
1875 ariaLabel="Hold webhook requests while this environment is offline"
1876 checked={holdWebhooksWhileOffline}
1877 disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy}
1878 disabledReason={disabledReason}
1879 onCheckedChange={(enabled) => void updateHoldWebhooks(enabled)}
1880 />
1881 }
1882 />
1883 ) : null}
18491884 </>
18501885 );
18511886}
apps/web/src/components/settings/ScheduledTasksSettings.tsx+58-2
......@@ -23,7 +23,9 @@ import type {
2323 ScheduledTaskWebhookDeliverySummary,
2424 ThreadId,
2525} from "@t3tools/contracts";
26import { DEFAULT_WEBHOOK_PROMPT } from "@t3tools/client-runtime/scheduled-task-webhook";
2627import {
28 MAX_WEBHOOK_DELIVERY_AGE_MINUTES,
2729 MIN_SCHEDULED_TASK_INTERVAL_MS,
2830 ProviderInstanceId,
2931 resolveEnvironmentMachineKind,
......@@ -41,8 +43,10 @@ import {
4143 deriveProviderInstanceEntries,
4244 sortProviderInstanceEntries,
4345} from "../../providerInstances";
46import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState";
4447import { requestConfirmDialog } from "../../confirmDialog";
4548import { webhookAddress } from "@t3tools/client-runtime/webhook-address";
49import { Link } from "@tanstack/react-router";
4650import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
4751import {
4852 useEnvironment,
......@@ -57,7 +61,6 @@ import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker";
5761import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
5862import { useSettingsScope } from "./SettingsScopeContext";
5963import {
60 DEFAULT_WEBHOOK_PROMPT,
6164 WEBHOOK_SIGNATURE_DEFAULTS,
6265 matchesScheduledTaskScope,
6366 scheduleFromDraft,
......@@ -130,6 +133,7 @@ const EMPTY_DRAFT: DraftState = {
130133 signatureEnabled: false,
131134 ...WEBHOOK_SIGNATURE_DEFAULTS,
132135 signatureSecret: "",
136 maxDeliveryAgeMinutes: "",
133137};
134138
135139/** Labelled field: a caption sitting above its control. */
......@@ -211,11 +215,14 @@ const DELIVERY_OUTCOME_LABELS: Record<ScheduledTaskWebhookDeliveryOutcome, strin
211215 rejected_signature: "Bad signature",
212216 disabled: "Task paused",
213217 rate_limited: "Rate limited",
218 expired: "Too old",
214219};
215220
216221function deliveryOutcomeVariant(outcome: ScheduledTaskWebhookDeliveryOutcome) {
217222 if (outcome === "accepted") return "success";
218 if (outcome === "disabled" || outcome === "rate_limited") return "warning";
223 if (outcome === "disabled" || outcome === "rate_limited" || outcome === "expired") {
224 return "warning";
225 }
219226 return "error";
220227}
221228
......@@ -725,10 +732,31 @@ function WebhookEndpointField({
725732 </Button>
726733 </div>
727734 {note !== null ? <p className="text-xs text-muted-foreground">{note}</p> : null}
735 {endpoint.url !== null ? <WebhookDeliveryMode environmentId={environmentId} /> : null}
728736 </div>
729737 );
730738}
731739
740/**
741 * Whether T3 Connect forwards requests live or holds them while the
742 * environment is offline. The setting is per environment and only readable
743 * for this machine's own environment, so other environments show nothing.
744 */
745function WebhookDeliveryMode({ environmentId }: { readonly environmentId: EnvironmentId }) {
746 const cloudLink = usePrimaryCloudLinkState();
747 if (cloudLink.target?.environmentId !== environmentId || cloudLink.data === null) return null;
748 return (
749 <p className="text-xs text-muted-foreground">
750 {cloudLink.data.holdWebhooksWhileOffline
751 ? "Held for up to 24 hours while this environment is offline. "
752 : "Forwarded live. Requests fail while this environment is offline. "}
753 <Link to="/settings/connections" className="underline underline-offset-2">
754 Change in Connections
755 </Link>
756 </p>
757 );
758}
759
732760function ScheduledTaskEditorDialog({
733761 initialEnvironmentId,
734762 task,
......@@ -830,6 +858,13 @@ function ScheduledTaskEditorDialog({
830858 return;
831859 }
832860 const schedule = scheduleFromDraft(draft);
861 if (schedule === null) {
862 reportFailure(
863 "Invalid age limit",
864 `Enter whole minutes from 1 to ${MAX_WEBHOOK_DELIVERY_AGE_MINUTES}, or leave it blank.`,
865 );
866 return;
867 }
833868 if (
834869 schedule.type === "webhook" &&
835870 schedule.signature &&
......@@ -1126,6 +1161,27 @@ function ScheduledTaskEditorDialog({
11261161 "Each request runs the prompt. Use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}} or {{request}} in the prompt; only what it names reaches the agent."
11271162 }
11281163 </p>
1164 <Field
1165 label="Skip requests older than"
1166 hint="minutes, optional"
1167 htmlFor="scheduled-task-max-age"
1168 >
1169 <Input
1170 id="scheduled-task-max-age"
1171 type="number"
1172 nativeInput
1173 min={1}
1174 max={MAX_WEBHOOK_DELIVERY_AGE_MINUTES}
1175 placeholder="Run every request"
1176 value={draft.maxDeliveryAgeMinutes}
1177 onChange={(event) =>
1178 setDraft((current) => ({
1179 ...current,
1180 maxDeliveryAgeMinutes: event.target.value,
1181 }))
1182 }
1183 />
1184 </Field>
11291185 <div className="flex items-center justify-between gap-4">
11301186 <div className="min-w-0 space-y-1">
11311187 <Label htmlFor="scheduled-task-signature">Require signature</Label>
apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts+28-2
......@@ -181,10 +181,15 @@ describe("webhook scheduled tasks", () => {
181181 const draft = taskToDraft(webhookTask);
182182 expect(draft.scheduleMode).toBe("webhook");
183183 expect(draft.signatureSecret).toBe("");
184 expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature });
184 expect(scheduleFromDraft(draft)).toEqual({
185 type: "webhook",
186 signature,
187 maxDeliveryAgeMinutes: null,
188 });
185189 expect(scheduleFromDraft({ ...draft, signatureSecret: " new " })).toEqual({
186190 type: "webhook",
187191 signature: { ...signature, secret: "new" },
192 maxDeliveryAgeMinutes: null,
188193 });
189194 });
190195
......@@ -192,7 +197,28 @@ describe("webhook scheduled tasks", () => {
192197 const draft = taskToDraft({ ...webhookTask, schedule: { type: "webhook", signature: null } });
193198 expect(draft.signatureEnabled).toBe(false);
194199 expect(draft.signatureHeader).toBe("x-hub-signature-256");
195 expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature: null });
200 expect(scheduleFromDraft(draft)).toEqual({
201 type: "webhook",
202 signature: null,
203 maxDeliveryAgeMinutes: null,
204 });
205 });
206
207 it("round-trips the max age, treats blank as no limit, and rejects an invalid limit", () => {
208 const draft = taskToDraft({
209 ...webhookTask,
210 schedule: { type: "webhook", signature: null, maxDeliveryAgeMinutes: 90 },
211 });
212 expect(draft.maxDeliveryAgeMinutes).toBe("90");
213 expect(scheduleFromDraft(draft)).toMatchObject({ maxDeliveryAgeMinutes: 90 });
214 for (const blank of ["", " "]) {
215 expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: blank })).toMatchObject({
216 maxDeliveryAgeMinutes: null,
217 });
218 }
219 for (const invalid of ["0", "-5", "1.5", "abc", "1441"]) {
220 expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: invalid })).toBeNull();
221 }
196222 });
197223});
198224
apps/web/src/components/settings/scheduledTasksSettings.logic.ts+12-4
......@@ -9,6 +9,7 @@ import {
99 type ProviderInteractionMode,
1010 type ServerSettings,
1111} from "@t3tools/contracts";
12import { parseMaxDeliveryAge } from "@t3tools/client-runtime/scheduled-task-webhook";
1213
1314import {
1415 resolveProjectSettings,
......@@ -78,6 +79,8 @@ export interface DraftState {
7879 readonly signaturePrefix: string;
7980 /** Write-only: empty keeps the secret already stored on the server. */
8081 readonly signatureSecret: string;
82 /** Minutes as typed; empty runs every held request regardless of age. */
83 readonly maxDeliveryAgeMinutes: string;
8184}
8285
8386/** GitHub's signature settings, the most common sender. */
......@@ -87,11 +90,11 @@ export const WEBHOOK_SIGNATURE_DEFAULTS = {
8790 signaturePrefix: "sha256=",
8891} as const;
8992
90/** Prompt a new webhook task starts with: the whole request, which the user can narrow down. */
91export const DEFAULT_WEBHOOK_PROMPT = "Handle this webhook:\n{{request}}";
92
93export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedule {
93/** Null when the draft's webhook age limit is invalid; the caller reports it and does not save. */
94export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedule | null {
9495 if (draft.scheduleMode === "webhook") {
96 const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes);
97 if (maxDeliveryAgeMinutes === undefined) return null;
9598 const secret = draft.signatureSecret.trim();
9699 return {
97100 type: "webhook",
......@@ -103,6 +106,7 @@ export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedul
103106 ...(secret ? { secret } : {}),
104107 }
105108 : null,
109 maxDeliveryAgeMinutes,
106110 };
107111 }
108112 if (draft.scheduleMode === "interval") {
......@@ -159,6 +163,10 @@ export function taskToDraft(task: ScheduledTask): DraftState {
159163 }
160164 : { signatureEnabled: false, ...WEBHOOK_SIGNATURE_DEFAULTS }),
161165 signatureSecret: "",
166 maxDeliveryAgeMinutes:
167 schedule.type === "webhook" && schedule.maxDeliveryAgeMinutes != null
168 ? String(schedule.maxDeliveryAgeMinutes)
169 : "",
162170 };
163171}
164172
apps/web/src/components/settings/settingsSearch.test.ts+19
......@@ -170,6 +170,7 @@ describe("searchSettings", () => {
170170 "source-control-writer-model",
171171 "source-control-writing-style",
172172 "t3-connect",
173 "hold-webhooks-while-offline",
173174 "tailscale-https",
174175 "wsl-backend",
175176 "auto-settle-inactive-threads",
......@@ -221,6 +222,24 @@ describe("searchSettings", () => {
221222 expect(browser).toContain("publish-agent-activity");
222223 });
223224
225 it("offers webhook holding only while the managed tunnel is on, like its row", () => {
226 const availability = {
227 hasCloudPublicConfig: true,
228 hasEnvironment: true,
229 hasProviderSettingsEnvironment: true,
230 hasMacProviderSettingsEnvironment: false,
231 canManageLocalBackend: true,
232 isWslSettingsRowVisible: false,
233 hasThreadAutoSettlement: false,
234 };
235 const itemIds = (managedTunnelActive: boolean) =>
236 filterAvailableSettingsSearchItems({ ...availability, managedTunnelActive }).map(
237 (item) => item.id,
238 );
239 expect(itemIds(false)).not.toContain("hold-webhooks-while-offline");
240 expect(itemIds(true)).toContain("hold-webhooks-while-offline");
241 });
242
224243 it("shows automatic settlement settings when the server supports them", () => {
225244 const available = filterAvailableSettingsSearchItems({
226245 hasCloudPublicConfig: false,
apps/web/src/components/settings/settingsSearch.ts+15-1
......@@ -60,6 +60,8 @@ export interface SettingsSearchItem {
6060 readonly localBackendManagementOnly?: boolean;
6161 readonly localEnvironmentOnly?: boolean;
6262 readonly wslAvailableOnly?: boolean;
63 // Its row only renders while this environment's T3 Connect managed tunnel is on.
64 readonly managedTunnelOnly?: boolean;
6365 /**
6466 * Sorts after every other match. Keybinding commands mirror rows on other
6567 * surfaces, so "model" must still lead with Default model, not Model Picker.
......@@ -77,6 +79,7 @@ export interface SettingsSearchAvailability {
7779 readonly canManageLocalBackend: boolean;
7880 readonly isWslSettingsRowVisible: boolean;
7981 readonly hasThreadAutoSettlement: boolean;
82 readonly managedTunnelActive?: boolean;
8083}
8184
8285/**
......@@ -836,6 +839,16 @@ export const SETTINGS_SEARCH_ITEMS = [
836839 desktopOnly: true,
837840 cloudOnly: true,
838841 },
842 {
843 id: "hold-webhooks-while-offline",
844 localEnvironmentOnly: true,
845 title: "Hold webhooks while offline",
846 to: "/settings/connections",
847 targetId: "connections-environment",
848 searchTerms: ["webhook automations offline queue mailbox t3 connect"],
849 cloudOnly: true,
850 managedTunnelOnly: true,
851 },
839852 {
840853 id: "publish-agent-activity",
841854 localEnvironmentOnly: true,
......@@ -1018,7 +1031,8 @@ export function filterAvailableSettingsSearchItems(
10181031 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
10191032 (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) &&
10201033 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
1021 (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement),
1034 (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement) &&
1035 (!item.managedTunnelOnly || availability.managedTunnelActive === true),
10221036 );
10231037}
10241038
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+7
......@@ -1,6 +1,7 @@
11import { useMemo } from "react";
22import { AuthAccessWriteScope } from "@t3tools/contracts";
33
4import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState";
45import { hasCloudPublicConfig } from "~/cloud/publicConfig";
56import { isElectron } from "~/env";
67import { isLocalEnvironmentDisabled } from "~/localEnvironment";
......@@ -23,6 +24,10 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch
2324 const desktopWsl = useEnvironmentQuery(
2425 isElectron && !localEnvironmentDisabled ? desktopWslStateAtom : null,
2526 );
27 const cloudLinkState = usePrimaryCloudLinkState().data;
28 // Same fallback as the Connections row: older servers imply a tunnel from `linked`.
29 const managedTunnelActive =
30 cloudLinkState?.managedTunnelActive ?? cloudLinkState?.linked ?? false;
2631 const canManageLocalBackend =
2732 !localEnvironmentDisabled &&
2833 (isElectron ||
......@@ -59,8 +64,10 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch
5964 }),
6065 hasThreadAutoSettlement:
6166 getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0,
67 managedTunnelActive,
6268 }),
6369 [
70 managedTunnelActive,
6471 canManageLocalBackend,
6572 desktopWsl.data,
6673 desktopWsl.error,
docs/internals/t3-connect.md+22-4
......@@ -5,12 +5,30 @@ credentials for reaching environments, and managed tunnel allocations. After
55bootstrap, clients send application traffic through the environment's tunnel
66hostname; the relay Worker does not proxy their HTTP or WebSocket sessions.
77The one exception is automation webhooks: the relay forwards
8`/v1/hooks/:environmentId/:hookId/:token` statelessly to the environment's
9tunnel so senders get a stable URL. It stores nothing, keeps bodies and tokens
10out of its traces, and leaves token and signature verification to the
11environment
8`/v1/hooks/:environmentId/:hookId/:token` to the environment's tunnel so
9senders get a stable URL. It keeps bodies and tokens out of its traces and
10leaves token and signature verification to the environment
1211([forwarder](../../infra/relay/src/hooks/HookForwarder.ts)).
1312
13By default the forwarder stores nothing. An environment can opt in to having
14the relay hold requests while it is offline
15(`hold_webhooks_while_offline` on its link). Only then does the relay store the
16raw request, including the hook token in the path, in a Durable Object for
17that environment, with SQLite storage. The object pushes held requests back
18through the tunnel from its alarm, oldest first, and backs off while the
19environment stays away. When the tunnel reconnects, the environment asks the
20relay to deliver right away. Requests are deleted once the environment
21answers, after 24 hours, or when no user has the environment linked. The relay
22still never checks the token; delivery goes through the same environment route.
23Every forward carries `x-t3-relay-delivery-id`, so a request that reached the
24environment before a timeout and is delivered again later runs once
25([inbox object](../../infra/relay/src/hooks/HookInboxObject.ts)).
26
27A Durable Object, not Postgres or Queues, because held requests are write-once,
28read-once bodies of up to 1 MiB that need per-environment order, caps, and
29retry timing. Queues cap messages at 128 KB and cannot hold one environment's
30requests back while it is away.
31
1432Clerk, deployment, and native authentication setup live in the
1533[Connect setup runbook](../operations/connect-setup.md).
1634
docs/operations/observability.md+15
......@@ -386,6 +386,21 @@ Counters tell you volume and failure rate:
386386- `t3_provider_turns_total`
387387- `t3_git_commands_total`
388388
389Webhooks have their own families:
390
391- `t3_webhook_deliveries_total` by `outcome` and `source` (`relay` or `direct`). Beyond what the
392 sender sees, `queue_full` means a task already had its limit of deliveries waiting,
393 `prompt_too_long` means the filled-in prompt passed the provider limit, and `duplicate` means
394 the relay delivered a request this environment had already run.
395- `t3_webhook_runs_total` by `outcome` (`started`, `skipped`, `failed`) for the runs those
396 deliveries start, which happen after the sender has its answer.
397- `t3_webhook_held_delay` for how long requests the relay held waited before arriving.
398
399`ScheduledTaskService.triggerWebhook` spans carry the same outcome per request, and each run
400started from a delivery is its own `ScheduledTaskService.runWebhookDelivery` trace. For a request
401the relay forwarded, the span also goes to the T3 Connect trace export as a child of the relay's
402span; requests that reach the environment directly never join a sender's trace.
403
389404Use metrics when the question is:
390405
391406- "is this always slow?"
docs/operations/relay-observability.md+51
......@@ -57,3 +57,54 @@ failure means that a signed proof was too old or too far in the future for the r
5757window. It can point to a date or time problem on either device, but it can also result from a
5858delayed request. The client uses this category, and the absence of a category from an older relay,
5959to decide whether clock skew is confirmed or only one possible cause.
60
61## Webhooks
62
63A public webhook request is one `relay.hooks.forward` span. Its `relay.hook.outcome` says what
64happened: `forwarded`, `held`, `rate_limited`, `inbox_full`, `not_found`, `payload_too_large`,
65`environment_unavailable`, or `environment_timeout`. `relay.hook.endpoint_key` identifies the
66managed endpoint, and with it the environment. On a forward, `relay.hook.upstream_status` or
67`relay.hook.upstream_error` records the environment's answer, and
68`relay.hook.upstream_outcome` what it did with the request (`accepted`, `duplicate`,
69`prompt_too_long`, `queue_full`, `rejected_signature`, `expired`, `disabled`, ...), from its
70`x-t3-hook-outcome` response header. A held request's delivery records the same on its
71`relay.inbox.deliver` span. The relay sends its own `traceparent` with each forward and drops any a
72sender supplied, and signs each forward (`x-t3-relay-delivery`) so the environment trusts the
73relay's delivery id, receive time and trace context only from the relay, so on environments that export to T3 Connect, the environment's
74`ScheduledTaskService.triggerWebhook` span lands in the same trace. `relay.hook.rate_limit` says which
75budget ran out: `endpoint` or `hook`. `relay.hook.rate_limiter_failed_open` is set when the
76Cloudflare rate limiter was unavailable and the request went through unlimited.
77
78Held requests are handled in the endpoint's `HookInboxObject`, and each call into it is its own
79trace, not a child of the forward span: `relay.inbox.hold`, `relay.inbox.wake`, and
80`relay.inbox.deliver` for each alarm run. Each carries `relay.hook.endpoint_key`, so they join to
81forwards. The details are on the child spans: `HookInboxStore.hold` carries
82`relay.inbox.refused` when the inbox refused a request (`max_per_hook`, `max_requests`,
83`max_bytes`, or `already_held`). `HookInboxStore.deliverDue` carries `relay.inbox.run_result`
84(`drained`, `more_pending`, `busy`, or `unreachable`), how many requests it sent and delivered,
85the consecutive failures behind the retry delay, the longest time a delivered request waited, and
86the backlog left. A run that errored outright has `relay.inbox.run_result = failed` on the root
87`relay.inbox.deliver` span instead.
88
89Questions these answer:
90
91```apl
92// Webhook outcomes per hour
93['t3-code-relay-traces-prod']
94| where name == 'relay.hooks.forward'
95| summarize count() by bin(_time, 1h), outcome = tostring(['attributes.custom']['relay.hook.outcome'])
96
97// Inboxes stuck behind an unreachable environment, by endpoint
98['t3-code-relay-traces-prod']
99| where name == 'HookInboxStore.deliverDue'
100| extend c = ['attributes.custom']
101| where tostring(c['relay.inbox.run_result']) == 'unreachable'
102| project trace_id, failures = toint(c['relay.inbox.consecutive_failures']),
103 backlog = toint(c['relay.inbox.held_count'])
104| join kind=inner (
105 ['t3-code-relay-traces-prod']
106 | where name == 'relay.inbox.deliver'
107 | project trace_id, endpoint = tostring(['attributes.custom']['relay.hook.endpoint_key'])
108 ) on trace_id
109| summarize runs = count(), failures = max(failures), backlog = max(backlog) by endpoint
110```
docs/user/project-settings.md+21-10
......@@ -61,16 +61,18 @@ using its project, model, and workspace settings. Fixed-time schedules use that
6161environment's time zone, which may differ from your phone's.
6262
6363You can edit, pause, resume, run immediately, or delete a task from the list.
64Webhook tasks only run when their URL is called, so they can't be run
65immediately.
6466Leaving an edited form asks before discarding unsaved changes.
6567
6668## Webhook automations
6769
68In **Settings → Scheduled tasks**, choose **On webhook** as a task's schedule to run it whenever another service
69calls its URL, such as GitHub on a new pull request or a CI job that failed.
70After you save the task, copy its URL from the editor. If the environment uses a
71[T3 Connect](remote-access.md) managed tunnel, the URL is public; otherwise it
72works anywhere the environment itself is reachable. **Rotate** replaces the URL and
73the old one stops working.
70In **Settings → Scheduled tasks**, choose **On webhook**
71as a task's schedule to run it whenever another service calls its URL, such as
72GitHub on a new pull request or a CI job that failed. A public URL needs a
73[T3 Connect](remote-access.md) managed tunnel; after you save the task, copy
74its URL from the editor. Without one, the editor shows only the URL's path.
75**Rotate** replaces the URL and the old one stops working.
7476
7577The prompt decides what the agent sees. Placeholders pull values out of the
7678request: `{{body.path}}` for a JSON or form field, `{{headers.name}}`,
......@@ -81,12 +83,21 @@ pull request link. A placeholder with no value is left empty.
8183For GitHub, turn on **Require signature**, keep the header
8284`x-hub-signature-256`, hex encoding and the `sha256=` prefix, and enter the
8385same secret in the repository's webhook settings with content type
84`application/json`. Requests without a valid signature are rejected.
86`application/json`. Requests without a valid signature are rejected. Set this
87up on desktop or web; mobile keeps an existing signature check but can't turn
88one on.
8589
8690On desktop and web, pick **Deliveries** from a task's menu to see recent
87requests and the prompt each one produced. If the environment is offline, the sender gets an error and
88nothing runs; redeliver from the sender, such as GitHub's **Recent Deliveries**,
89once it is back.
91requests and the prompt each one produced.
92
93If the environment is offline, the sender gets an error and nothing runs;
94redeliver from the sender, such as GitHub's **Recent Deliveries**, once it is
95back. To have T3 Connect keep requests instead, turn on **Hold webhooks while
96offline** in **Settings → Connections**. T3 Connect then stores requests to a
97T3 Connect URL for up to 24 hours and delivers them when the environment
98returns. Leave it off if you don't want request bodies stored outside your
99machine. To skip requests that waited too long, set **Skip requests older
100than** on the task.
90101
91102## Defaults and inheritance
92103
infra/relay/migrations/postgres/20261004061459_hold_webhooks_while_offline/migration.sql created+1
......@@ -0,0 +1 @@
1ALTER TABLE "relay_environment_links" ADD COLUMN "hold_webhooks_while_offline" boolean DEFAULT false NOT NULL;
\ No newline at end of file
infra/relay/migrations/postgres/20261004061459_hold_webhooks_while_offline/snapshot.json created+1581
......@@ -0,0 +1,1581 @@
1{
2 "version": "8",
3 "dialect": "postgres",
4 "id": "13972659-45db-4ce9-9818-523b450bceca",
5 "prevIds": ["3809c51e-3821-4a08-818d-e5d28dd3e9b4"],
6 "ddl": [
7 {
8 "isRlsEnabled": false,
9 "name": "relay_agent_activity_rows",
10 "entityType": "tables",
11 "schema": "public"
12 },
13 {
14 "isRlsEnabled": false,
15 "name": "relay_delivery_attempts",
16 "entityType": "tables",
17 "schema": "public"
18 },
19 {
20 "isRlsEnabled": false,
21 "name": "relay_dpop_proofs",
22 "entityType": "tables",
23 "schema": "public"
24 },
25 {
26 "isRlsEnabled": false,
27 "name": "relay_environment_credentials",
28 "entityType": "tables",
29 "schema": "public"
30 },
31 {
32 "isRlsEnabled": false,
33 "name": "relay_environment_links",
34 "entityType": "tables",
35 "schema": "public"
36 },
37 {
38 "isRlsEnabled": false,
39 "name": "relay_live_activities",
40 "entityType": "tables",
41 "schema": "public"
42 },
43 {
44 "isRlsEnabled": false,
45 "name": "relay_managed_endpoint_allocations",
46 "entityType": "tables",
47 "schema": "public"
48 },
49 {
50 "isRlsEnabled": false,
51 "name": "relay_managed_tunnel_limits",
52 "entityType": "tables",
53 "schema": "public"
54 },
55 {
56 "isRlsEnabled": false,
57 "name": "relay_mobile_devices",
58 "entityType": "tables",
59 "schema": "public"
60 },
61 {
62 "type": "varchar(191)",
63 "typeSchema": null,
64 "notNull": true,
65 "dimensions": 0,
66 "default": null,
67 "generated": null,
68 "identity": null,
69 "name": "environment_id",
70 "entityType": "columns",
71 "schema": "public",
72 "table": "relay_agent_activity_rows"
73 },
74 {
75 "type": "text",
76 "typeSchema": null,
77 "notNull": true,
78 "dimensions": 0,
79 "default": null,
80 "generated": null,
81 "identity": null,
82 "name": "environment_public_key",
83 "entityType": "columns",
84 "schema": "public",
85 "table": "relay_agent_activity_rows"
86 },
87 {
88 "type": "varchar(512)",
89 "typeSchema": null,
90 "notNull": true,
91 "dimensions": 0,
92 "default": null,
93 "generated": null,
94 "identity": null,
95 "name": "thread_id",
96 "entityType": "columns",
97 "schema": "public",
98 "table": "relay_agent_activity_rows"
99 },
100 {
101 "type": "jsonb",
102 "typeSchema": null,
103 "notNull": true,
104 "dimensions": 0,
105 "default": null,
106 "generated": null,
107 "identity": null,
108 "name": "state_json",
109 "entityType": "columns",
110 "schema": "public",
111 "table": "relay_agent_activity_rows"
112 },
113 {
114 "type": "varchar(64)",
115 "typeSchema": null,
116 "notNull": true,
117 "dimensions": 0,
118 "default": null,
119 "generated": null,
120 "identity": null,
121 "name": "updated_at",
122 "entityType": "columns",
123 "schema": "public",
124 "table": "relay_agent_activity_rows"
125 },
126 {
127 "type": "varchar(64)",
128 "typeSchema": null,
129 "notNull": true,
130 "dimensions": 0,
131 "default": null,
132 "generated": null,
133 "identity": null,
134 "name": "created_at",
135 "entityType": "columns",
136 "schema": "public",
137 "table": "relay_agent_activity_rows"
138 },
139 {
140 "type": "varchar(36)",
141 "typeSchema": null,
142 "notNull": true,
143 "dimensions": 0,
144 "default": null,
145 "generated": null,
146 "identity": null,
147 "name": "id",
148 "entityType": "columns",
149 "schema": "public",
150 "table": "relay_delivery_attempts"
151 },
152 {
153 "type": "varchar(64)",
154 "typeSchema": null,
155 "notNull": true,
156 "dimensions": 0,
157 "default": null,
158 "generated": null,
159 "identity": null,
160 "name": "created_at",
161 "entityType": "columns",
162 "schema": "public",
163 "table": "relay_delivery_attempts"
164 },
165 {
166 "type": "varchar(255)",
167 "typeSchema": null,
168 "notNull": false,
169 "dimensions": 0,
170 "default": null,
171 "generated": null,
172 "identity": null,
173 "name": "user_id",
174 "entityType": "columns",
175 "schema": "public",
176 "table": "relay_delivery_attempts"
177 },
178 {
179 "type": "varchar(191)",
180 "typeSchema": null,
181 "notNull": false,
182 "dimensions": 0,
183 "default": null,
184 "generated": null,
185 "identity": null,
186 "name": "environment_id",
187 "entityType": "columns",
188 "schema": "public",
189 "table": "relay_delivery_attempts"
190 },
191 {
192 "type": "varchar(512)",
193 "typeSchema": null,
194 "notNull": false,
195 "dimensions": 0,
196 "default": null,
197 "generated": null,
198 "identity": null,
199 "name": "thread_id",
200 "entityType": "columns",
201 "schema": "public",
202 "table": "relay_delivery_attempts"
203 },
204 {
205 "type": "varchar(255)",
206 "typeSchema": null,
207 "notNull": false,
208 "dimensions": 0,
209 "default": null,
210 "generated": null,
211 "identity": null,
212 "name": "device_id",
213 "entityType": "columns",
214 "schema": "public",
215 "table": "relay_delivery_attempts"
216 },
217 {
218 "type": "varchar(64)",
219 "typeSchema": null,
220 "notNull": true,
221 "dimensions": 0,
222 "default": null,
223 "generated": null,
224 "identity": null,
225 "name": "kind",
226 "entityType": "columns",
227 "schema": "public",
228 "table": "relay_delivery_attempts"
229 },
230 {
231 "type": "varchar(64)",
232 "typeSchema": null,
233 "notNull": false,
234 "dimensions": 0,
235 "default": null,
236 "generated": null,
237 "identity": null,
238 "name": "source_job_id",
239 "entityType": "columns",
240 "schema": "public",
241 "table": "relay_delivery_attempts"
242 },
243 {
244 "type": "varchar(16)",
245 "typeSchema": null,
246 "notNull": false,
247 "dimensions": 0,
248 "default": null,
249 "generated": null,
250 "identity": null,
251 "name": "token_suffix",
252 "entityType": "columns",
253 "schema": "public",
254 "table": "relay_delivery_attempts"
255 },
256 {
257 "type": "integer",
258 "typeSchema": null,
259 "notNull": false,
260 "dimensions": 0,
261 "default": null,
262 "generated": null,
263 "identity": null,
264 "name": "apns_status",
265 "entityType": "columns",
266 "schema": "public",
267 "table": "relay_delivery_attempts"
268 },
269 {
270 "type": "text",
271 "typeSchema": null,
272 "notNull": false,
273 "dimensions": 0,
274 "default": null,
275 "generated": null,
276 "identity": null,
277 "name": "apns_reason",
278 "entityType": "columns",
279 "schema": "public",
280 "table": "relay_delivery_attempts"
281 },
282 {
283 "type": "varchar(128)",
284 "typeSchema": null,
285 "notNull": false,
286 "dimensions": 0,
287 "default": null,
288 "generated": null,
289 "identity": null,
290 "name": "apns_id",
291 "entityType": "columns",
292 "schema": "public",
293 "table": "relay_delivery_attempts"
294 },
295 {
296 "type": "text",
297 "typeSchema": null,
298 "notNull": false,
299 "dimensions": 0,
300 "default": null,
301 "generated": null,
302 "identity": null,
303 "name": "transport_error",
304 "entityType": "columns",
305 "schema": "public",
306 "table": "relay_delivery_attempts"
307 },
308 {
309 "type": "varchar(128)",
310 "typeSchema": null,
311 "notNull": true,
312 "dimensions": 0,
313 "default": null,
314 "generated": null,
315 "identity": null,
316 "name": "thumbprint",
317 "entityType": "columns",
318 "schema": "public",
319 "table": "relay_dpop_proofs"
320 },
321 {
322 "type": "varchar(255)",
323 "typeSchema": null,
324 "notNull": true,
325 "dimensions": 0,
326 "default": null,
327 "generated": null,
328 "identity": null,
329 "name": "jti",
330 "entityType": "columns",
331 "schema": "public",
332 "table": "relay_dpop_proofs"
333 },
334 {
335 "type": "integer",
336 "typeSchema": null,
337 "notNull": true,
338 "dimensions": 0,
339 "default": null,
340 "generated": null,
341 "identity": null,
342 "name": "iat",
343 "entityType": "columns",
344 "schema": "public",
345 "table": "relay_dpop_proofs"
346 },
347 {
348 "type": "varchar(64)",
349 "typeSchema": null,
350 "notNull": true,
351 "dimensions": 0,
352 "default": null,
353 "generated": null,
354 "identity": null,
355 "name": "expires_at",
356 "entityType": "columns",
357 "schema": "public",
358 "table": "relay_dpop_proofs"
359 },
360 {
361 "type": "varchar(64)",
362 "typeSchema": null,
363 "notNull": true,
364 "dimensions": 0,
365 "default": null,
366 "generated": null,
367 "identity": null,
368 "name": "created_at",
369 "entityType": "columns",
370 "schema": "public",
371 "table": "relay_dpop_proofs"
372 },
373 {
374 "type": "varchar(64)",
375 "typeSchema": null,
376 "notNull": true,
377 "dimensions": 0,
378 "default": null,
379 "generated": null,
380 "identity": null,
381 "name": "credential_id",
382 "entityType": "columns",
383 "schema": "public",
384 "table": "relay_environment_credentials"
385 },
386 {
387 "type": "varchar(191)",
388 "typeSchema": null,
389 "notNull": true,
390 "dimensions": 0,
391 "default": null,
392 "generated": null,
393 "identity": null,
394 "name": "environment_id",
395 "entityType": "columns",
396 "schema": "public",
397 "table": "relay_environment_credentials"
398 },
399 {
400 "type": "text",
401 "typeSchema": null,
402 "notNull": true,
403 "dimensions": 0,
404 "default": null,
405 "generated": null,
406 "identity": null,
407 "name": "environment_public_key",
408 "entityType": "columns",
409 "schema": "public",
410 "table": "relay_environment_credentials"
411 },
412 {
413 "type": "varchar(191)",
414 "typeSchema": null,
415 "notNull": true,
416 "dimensions": 0,
417 "default": null,
418 "generated": null,
419 "identity": null,
420 "name": "credential_hash",
421 "entityType": "columns",
422 "schema": "public",
423 "table": "relay_environment_credentials"
424 },
425 {
426 "type": "varchar(64)",
427 "typeSchema": null,
428 "notNull": false,
429 "dimensions": 0,
430 "default": null,
431 "generated": null,
432 "identity": null,
433 "name": "revoked_at",
434 "entityType": "columns",
435 "schema": "public",
436 "table": "relay_environment_credentials"
437 },
438 {
439 "type": "varchar(64)",
440 "typeSchema": null,
441 "notNull": true,
442 "dimensions": 0,
443 "default": null,
444 "generated": null,
445 "identity": null,
446 "name": "created_at",
447 "entityType": "columns",
448 "schema": "public",
449 "table": "relay_environment_credentials"
450 },
451 {
452 "type": "varchar(64)",
453 "typeSchema": null,
454 "notNull": true,
455 "dimensions": 0,
456 "default": null,
457 "generated": null,
458 "identity": null,
459 "name": "updated_at",
460 "entityType": "columns",
461 "schema": "public",
462 "table": "relay_environment_credentials"
463 },
464 {
465 "type": "varchar(191)",
466 "typeSchema": null,
467 "notNull": true,
468 "dimensions": 0,
469 "default": null,
470 "generated": null,
471 "identity": null,
472 "name": "user_id",
473 "entityType": "columns",
474 "schema": "public",
475 "table": "relay_environment_links"
476 },
477 {
478 "type": "varchar(191)",
479 "typeSchema": null,
480 "notNull": true,
481 "dimensions": 0,
482 "default": null,
483 "generated": null,
484 "identity": null,
485 "name": "environment_id",
486 "entityType": "columns",
487 "schema": "public",
488 "table": "relay_environment_links"
489 },
490 {
491 "type": "text",
492 "typeSchema": null,
493 "notNull": true,
494 "dimensions": 0,
495 "default": "'T3 Environment'",
496 "generated": null,
497 "identity": null,
498 "name": "environment_label",
499 "entityType": "columns",
500 "schema": "public",
501 "table": "relay_environment_links"
502 },
503 {
504 "type": "text",
505 "typeSchema": null,
506 "notNull": true,
507 "dimensions": 0,
508 "default": null,
509 "generated": null,
510 "identity": null,
511 "name": "environment_public_key",
512 "entityType": "columns",
513 "schema": "public",
514 "table": "relay_environment_links"
515 },
516 {
517 "type": "text",
518 "typeSchema": null,
519 "notNull": true,
520 "dimensions": 0,
521 "default": null,
522 "generated": null,
523 "identity": null,
524 "name": "endpoint_http_base_url",
525 "entityType": "columns",
526 "schema": "public",
527 "table": "relay_environment_links"
528 },
529 {
530 "type": "text",
531 "typeSchema": null,
532 "notNull": true,
533 "dimensions": 0,
534 "default": null,
535 "generated": null,
536 "identity": null,
537 "name": "endpoint_ws_base_url",
538 "entityType": "columns",
539 "schema": "public",
540 "table": "relay_environment_links"
541 },
542 {
543 "type": "varchar(32)",
544 "typeSchema": null,
545 "notNull": true,
546 "dimensions": 0,
547 "default": null,
548 "generated": null,
549 "identity": null,
550 "name": "endpoint_provider_kind",
551 "entityType": "columns",
552 "schema": "public",
553 "table": "relay_environment_links"
554 },
555 {
556 "type": "boolean",
557 "typeSchema": null,
558 "notNull": true,
559 "dimensions": 0,
560 "default": "true",
561 "generated": null,
562 "identity": null,
563 "name": "notifications_enabled",
564 "entityType": "columns",
565 "schema": "public",
566 "table": "relay_environment_links"
567 },
568 {
569 "type": "boolean",
570 "typeSchema": null,
571 "notNull": true,
572 "dimensions": 0,
573 "default": "true",
574 "generated": null,
575 "identity": null,
576 "name": "live_activities_enabled",
577 "entityType": "columns",
578 "schema": "public",
579 "table": "relay_environment_links"
580 },
581 {
582 "type": "boolean",
583 "typeSchema": null,
584 "notNull": true,
585 "dimensions": 0,
586 "default": "false",
587 "generated": null,
588 "identity": null,
589 "name": "managed_tunnels_enabled",
590 "entityType": "columns",
591 "schema": "public",
592 "table": "relay_environment_links"
593 },
594 {
595 "type": "boolean",
596 "typeSchema": null,
597 "notNull": true,
598 "dimensions": 0,
599 "default": "false",
600 "generated": null,
601 "identity": null,
602 "name": "hold_webhooks_while_offline",
603 "entityType": "columns",
604 "schema": "public",
605 "table": "relay_environment_links"
606 },
607 {
608 "type": "varchar(191)",
609 "typeSchema": null,
610 "notNull": false,
611 "dimensions": 0,
612 "default": null,
613 "generated": null,
614 "identity": null,
615 "name": "created_by_device_id",
616 "entityType": "columns",
617 "schema": "public",
618 "table": "relay_environment_links"
619 },
620 {
621 "type": "varchar(64)",
622 "typeSchema": null,
623 "notNull": false,
624 "dimensions": 0,
625 "default": null,
626 "generated": null,
627 "identity": null,
628 "name": "revoked_at",
629 "entityType": "columns",
630 "schema": "public",
631 "table": "relay_environment_links"
632 },
633 {
634 "type": "varchar(64)",
635 "typeSchema": null,
636 "notNull": true,
637 "dimensions": 0,
638 "default": null,
639 "generated": null,
640 "identity": null,
641 "name": "created_at",
642 "entityType": "columns",
643 "schema": "public",
644 "table": "relay_environment_links"
645 },
646 {
647 "type": "varchar(64)",
648 "typeSchema": null,
649 "notNull": true,
650 "dimensions": 0,
651 "default": null,
652 "generated": null,
653 "identity": null,
654 "name": "updated_at",
655 "entityType": "columns",
656 "schema": "public",
657 "table": "relay_environment_links"
658 },
659 {
660 "type": "varchar(255)",
661 "typeSchema": null,
662 "notNull": true,
663 "dimensions": 0,
664 "default": null,
665 "generated": null,
666 "identity": null,
667 "name": "user_id",
668 "entityType": "columns",
669 "schema": "public",
670 "table": "relay_live_activities"
671 },
672 {
673 "type": "varchar(255)",
674 "typeSchema": null,
675 "notNull": true,
676 "dimensions": 0,
677 "default": null,
678 "generated": null,
679 "identity": null,
680 "name": "device_id",
681 "entityType": "columns",
682 "schema": "public",
683 "table": "relay_live_activities"
684 },
685 {
686 "type": "text",
687 "typeSchema": null,
688 "notNull": false,
689 "dimensions": 0,
690 "default": null,
691 "generated": null,
692 "identity": null,
693 "name": "activity_push_token",
694 "entityType": "columns",
695 "schema": "public",
696 "table": "relay_live_activities"
697 },
698 {
699 "type": "varchar(64)",
700 "typeSchema": null,
701 "notNull": false,
702 "dimensions": 0,
703 "default": null,
704 "generated": null,
705 "identity": null,
706 "name": "remote_start_queued_at",
707 "entityType": "columns",
708 "schema": "public",
709 "table": "relay_live_activities"
710 },
711 {
712 "type": "varchar(64)",
713 "typeSchema": null,
714 "notNull": false,
715 "dimensions": 0,
716 "default": null,
717 "generated": null,
718 "identity": null,
719 "name": "remote_started_at",
720 "entityType": "columns",
721 "schema": "public",
722 "table": "relay_live_activities"
723 },
724 {
725 "type": "varchar(64)",
726 "typeSchema": null,
727 "notNull": false,
728 "dimensions": 0,
729 "default": null,
730 "generated": null,
731 "identity": null,
732 "name": "ended_at",
733 "entityType": "columns",
734 "schema": "public",
735 "table": "relay_live_activities"
736 },
737 {
738 "type": "jsonb",
739 "typeSchema": null,
740 "notNull": false,
741 "dimensions": 0,
742 "default": null,
743 "generated": null,
744 "identity": null,
745 "name": "last_aggregate_json",
746 "entityType": "columns",
747 "schema": "public",
748 "table": "relay_live_activities"
749 },
750 {
751 "type": "varchar(64)",
752 "typeSchema": null,
753 "notNull": false,
754 "dimensions": 0,
755 "default": null,
756 "generated": null,
757 "identity": null,
758 "name": "last_live_activity_delivery_at",
759 "entityType": "columns",
760 "schema": "public",
761 "table": "relay_live_activities"
762 },
763 {
764 "type": "varchar(64)",
765 "typeSchema": null,
766 "notNull": true,
767 "dimensions": 0,
768 "default": null,
769 "generated": null,
770 "identity": null,
771 "name": "created_at",
772 "entityType": "columns",
773 "schema": "public",
774 "table": "relay_live_activities"
775 },
776 {
777 "type": "varchar(64)",
778 "typeSchema": null,
779 "notNull": true,
780 "dimensions": 0,
781 "default": null,
782 "generated": null,
783 "identity": null,
784 "name": "updated_at",
785 "entityType": "columns",
786 "schema": "public",
787 "table": "relay_live_activities"
788 },
789 {
790 "type": "varchar(191)",
791 "typeSchema": null,
792 "notNull": true,
793 "dimensions": 0,
794 "default": null,
795 "generated": null,
796 "identity": null,
797 "name": "user_id",
798 "entityType": "columns",
799 "schema": "public",
800 "table": "relay_managed_endpoint_allocations"
801 },
802 {
803 "type": "varchar(191)",
804 "typeSchema": null,
805 "notNull": true,
806 "dimensions": 0,
807 "default": null,
808 "generated": null,
809 "identity": null,
810 "name": "environment_id",
811 "entityType": "columns",
812 "schema": "public",
813 "table": "relay_managed_endpoint_allocations"
814 },
815 {
816 "type": "text",
817 "typeSchema": null,
818 "notNull": true,
819 "dimensions": 0,
820 "default": null,
821 "generated": null,
822 "identity": null,
823 "name": "hostname",
824 "entityType": "columns",
825 "schema": "public",
826 "table": "relay_managed_endpoint_allocations"
827 },
828 {
829 "type": "varchar(191)",
830 "typeSchema": null,
831 "notNull": false,
832 "dimensions": 0,
833 "default": null,
834 "generated": null,
835 "identity": null,
836 "name": "tunnel_id",
837 "entityType": "columns",
838 "schema": "public",
839 "table": "relay_managed_endpoint_allocations"
840 },
841 {
842 "type": "text",
843 "typeSchema": null,
844 "notNull": true,
845 "dimensions": 0,
846 "default": null,
847 "generated": null,
848 "identity": null,
849 "name": "tunnel_name",
850 "entityType": "columns",
851 "schema": "public",
852 "table": "relay_managed_endpoint_allocations"
853 },
854 {
855 "type": "varchar(191)",
856 "typeSchema": null,
857 "notNull": false,
858 "dimensions": 0,
859 "default": null,
860 "generated": null,
861 "identity": null,
862 "name": "dns_record_id",
863 "entityType": "columns",
864 "schema": "public",
865 "table": "relay_managed_endpoint_allocations"
866 },
867 {
868 "type": "varchar(64)",
869 "typeSchema": null,
870 "notNull": false,
871 "dimensions": 0,
872 "default": null,
873 "generated": null,
874 "identity": null,
875 "name": "ready_at",
876 "entityType": "columns",
877 "schema": "public",
878 "table": "relay_managed_endpoint_allocations"
879 },
880 {
881 "type": "varchar(64)",
882 "typeSchema": null,
883 "notNull": false,
884 "dimensions": 0,
885 "default": null,
886 "generated": null,
887 "identity": null,
888 "name": "recovery_enabled_at",
889 "entityType": "columns",
890 "schema": "public",
891 "table": "relay_managed_endpoint_allocations"
892 },
893 {
894 "type": "text",
895 "typeSchema": null,
896 "notNull": false,
897 "dimensions": 0,
898 "default": null,
899 "generated": null,
900 "identity": null,
901 "name": "recovery_environment_public_key",
902 "entityType": "columns",
903 "schema": "public",
904 "table": "relay_managed_endpoint_allocations"
905 },
906 {
907 "type": "jsonb",
908 "typeSchema": null,
909 "notNull": false,
910 "dimensions": 0,
911 "default": null,
912 "generated": null,
913 "identity": null,
914 "name": "origin",
915 "entityType": "columns",
916 "schema": "public",
917 "table": "relay_managed_endpoint_allocations"
918 },
919 {
920 "type": "integer",
921 "typeSchema": null,
922 "notNull": true,
923 "dimensions": 0,
924 "default": "0",
925 "generated": null,
926 "identity": null,
927 "name": "generation",
928 "entityType": "columns",
929 "schema": "public",
930 "table": "relay_managed_endpoint_allocations"
931 },
932 {
933 "type": "varchar(64)",
934 "typeSchema": null,
935 "notNull": true,
936 "dimensions": 0,
937 "default": null,
938 "generated": null,
939 "identity": null,
940 "name": "created_at",
941 "entityType": "columns",
942 "schema": "public",
943 "table": "relay_managed_endpoint_allocations"
944 },
945 {
946 "type": "varchar(64)",
947 "typeSchema": null,
948 "notNull": true,
949 "dimensions": 0,
950 "default": null,
951 "generated": null,
952 "identity": null,
953 "name": "updated_at",
954 "entityType": "columns",
955 "schema": "public",
956 "table": "relay_managed_endpoint_allocations"
957 },
958 {
959 "type": "varchar(191)",
960 "typeSchema": null,
961 "notNull": true,
962 "dimensions": 0,
963 "default": null,
964 "generated": null,
965 "identity": null,
966 "name": "user_id",
967 "entityType": "columns",
968 "schema": "public",
969 "table": "relay_managed_tunnel_limits"
970 },
971 {
972 "type": "integer",
973 "typeSchema": null,
974 "notNull": true,
975 "dimensions": 0,
976 "default": null,
977 "generated": null,
978 "identity": null,
979 "name": "max_tunnels",
980 "entityType": "columns",
981 "schema": "public",
982 "table": "relay_managed_tunnel_limits"
983 },
984 {
985 "type": "varchar(64)",
986 "typeSchema": null,
987 "notNull": true,
988 "dimensions": 0,
989 "default": null,
990 "generated": null,
991 "identity": null,
992 "name": "created_at",
993 "entityType": "columns",
994 "schema": "public",
995 "table": "relay_managed_tunnel_limits"
996 },
997 {
998 "type": "varchar(64)",
999 "typeSchema": null,
1000 "notNull": true,
1001 "dimensions": 0,
1002 "default": null,
1003 "generated": null,
1004 "identity": null,
1005 "name": "updated_at",
1006 "entityType": "columns",
1007 "schema": "public",
1008 "table": "relay_managed_tunnel_limits"
1009 },
1010 {
1011 "type": "varchar(255)",
1012 "typeSchema": null,
1013 "notNull": true,
1014 "dimensions": 0,
1015 "default": null,
1016 "generated": null,
1017 "identity": null,
1018 "name": "user_id",
1019 "entityType": "columns",
1020 "schema": "public",
1021 "table": "relay_mobile_devices"
1022 },
1023 {
1024 "type": "varchar(255)",
1025 "typeSchema": null,
1026 "notNull": true,
1027 "dimensions": 0,
1028 "default": null,
1029 "generated": null,
1030 "identity": null,
1031 "name": "device_id",
1032 "entityType": "columns",
1033 "schema": "public",
1034 "table": "relay_mobile_devices"
1035 },
1036 {
1037 "type": "text",
1038 "typeSchema": null,
1039 "notNull": true,
1040 "dimensions": 0,
1041 "default": "'iOS device'",
1042 "generated": null,
1043 "identity": null,
1044 "name": "label",
1045 "entityType": "columns",
1046 "schema": "public",
1047 "table": "relay_mobile_devices"
1048 },
1049 {
1050 "type": "varchar(16)",
1051 "typeSchema": null,
1052 "notNull": true,
1053 "dimensions": 0,
1054 "default": null,
1055 "generated": null,
1056 "identity": null,
1057 "name": "platform",
1058 "entityType": "columns",
1059 "schema": "public",
1060 "table": "relay_mobile_devices"
1061 },
1062 {
1063 "type": "integer",
1064 "typeSchema": null,
1065 "notNull": false,
1066 "dimensions": 0,
1067 "default": null,
1068 "generated": null,
1069 "identity": null,
1070 "name": "ios_major_version",
1071 "entityType": "columns",
1072 "schema": "public",
1073 "table": "relay_mobile_devices"
1074 },
1075 {
1076 "type": "integer",
1077 "typeSchema": null,
1078 "notNull": false,
1079 "dimensions": 0,
1080 "default": null,
1081 "generated": null,
1082 "identity": null,
1083 "name": "android_api_level",
1084 "entityType": "columns",
1085 "schema": "public",
1086 "table": "relay_mobile_devices"
1087 },
1088 {
1089 "type": "varchar(64)",
1090 "typeSchema": null,
1091 "notNull": false,
1092 "dimensions": 0,
1093 "default": null,
1094 "generated": null,
1095 "identity": null,
1096 "name": "app_version",
1097 "entityType": "columns",
1098 "schema": "public",
1099 "table": "relay_mobile_devices"
1100 },
1101 {
1102 "type": "varchar(255)",
1103 "typeSchema": null,
1104 "notNull": false,
1105 "dimensions": 0,
1106 "default": null,
1107 "generated": null,
1108 "identity": null,
1109 "name": "bundle_id",
1110 "entityType": "columns",
1111 "schema": "public",
1112 "table": "relay_mobile_devices"
1113 },
1114 {
1115 "type": "varchar(16)",
1116 "typeSchema": null,
1117 "notNull": false,
1118 "dimensions": 0,
1119 "default": null,
1120 "generated": null,
1121 "identity": null,
1122 "name": "aps_environment",
1123 "entityType": "columns",
1124 "schema": "public",
1125 "table": "relay_mobile_devices"
1126 },
1127 {
1128 "type": "text",
1129 "typeSchema": null,
1130 "notNull": false,
1131 "dimensions": 0,
1132 "default": null,
1133 "generated": null,
1134 "identity": null,
1135 "name": "push_token",
1136 "entityType": "columns",
1137 "schema": "public",
1138 "table": "relay_mobile_devices"
1139 },
1140 {
1141 "type": "text",
1142 "typeSchema": null,
1143 "notNull": false,
1144 "dimensions": 0,
1145 "default": null,
1146 "generated": null,
1147 "identity": null,
1148 "name": "push_to_start_token",
1149 "entityType": "columns",
1150 "schema": "public",
1151 "table": "relay_mobile_devices"
1152 },
1153 {
1154 "type": "jsonb",
1155 "typeSchema": null,
1156 "notNull": true,
1157 "dimensions": 0,
1158 "default": null,
1159 "generated": null,
1160 "identity": null,
1161 "name": "preferences_json",
1162 "entityType": "columns",
1163 "schema": "public",
1164 "table": "relay_mobile_devices"
1165 },
1166 {
1167 "type": "varchar(64)",
1168 "typeSchema": null,
1169 "notNull": true,
1170 "dimensions": 0,
1171 "default": null,
1172 "generated": null,
1173 "identity": null,
1174 "name": "created_at",
1175 "entityType": "columns",
1176 "schema": "public",
1177 "table": "relay_mobile_devices"
1178 },
1179 {
1180 "type": "varchar(64)",
1181 "typeSchema": null,
1182 "notNull": true,
1183 "dimensions": 0,
1184 "default": null,
1185 "generated": null,
1186 "identity": null,
1187 "name": "updated_at",
1188 "entityType": "columns",
1189 "schema": "public",
1190 "table": "relay_mobile_devices"
1191 },
1192 {
1193 "nameExplicit": true,
1194 "columns": [
1195 {
1196 "value": "updated_at",
1197 "isExpression": false,
1198 "asc": true,
1199 "nullsFirst": false,
1200 "opclass": null
1201 }
1202 ],
1203 "isUnique": false,
1204 "where": null,
1205 "with": "",
1206 "method": "btree",
1207 "concurrently": false,
1208 "name": "idx_relay_agent_activity_rows_updated",
1209 "entityType": "indexes",
1210 "schema": "public",
1211 "table": "relay_agent_activity_rows"
1212 },
1213 {
1214 "nameExplicit": true,
1215 "columns": [
1216 {
1217 "value": "environment_id",
1218 "isExpression": false,
1219 "asc": true,
1220 "nullsFirst": false,
1221 "opclass": null
1222 },
1223 {
1224 "value": "thread_id",
1225 "isExpression": false,
1226 "asc": true,
1227 "nullsFirst": false,
1228 "opclass": null
1229 },
1230 {
1231 "value": "created_at",
1232 "isExpression": false,
1233 "asc": true,
1234 "nullsFirst": false,
1235 "opclass": null
1236 }
1237 ],
1238 "isUnique": false,
1239 "where": null,
1240 "with": "",
1241 "method": "btree",
1242 "concurrently": false,
1243 "name": "idx_relay_delivery_attempts_environment",
1244 "entityType": "indexes",
1245 "schema": "public",
1246 "table": "relay_delivery_attempts"
1247 },
1248 {
1249 "nameExplicit": true,
1250 "columns": [
1251 {
1252 "value": "source_job_id",
1253 "isExpression": false,
1254 "asc": true,
1255 "nullsFirst": false,
1256 "opclass": null
1257 }
1258 ],
1259 "isUnique": true,
1260 "where": null,
1261 "with": "",
1262 "method": "btree",
1263 "concurrently": false,
1264 "name": "idx_relay_delivery_attempts_source_job",
1265 "entityType": "indexes",
1266 "schema": "public",
1267 "table": "relay_delivery_attempts"
1268 },
1269 {
1270 "nameExplicit": true,
1271 "columns": [
1272 {
1273 "value": "expires_at",
1274 "isExpression": false,
1275 "asc": true,
1276 "nullsFirst": false,
1277 "opclass": null
1278 }
1279 ],
1280 "isUnique": false,
1281 "where": null,
1282 "with": "",
1283 "method": "btree",
1284 "concurrently": false,
1285 "name": "idx_relay_dpop_proofs_expires_at",
1286 "entityType": "indexes",
1287 "schema": "public",
1288 "table": "relay_dpop_proofs"
1289 },
1290 {
1291 "nameExplicit": true,
1292 "columns": [
1293 {
1294 "value": "credential_hash",
1295 "isExpression": false,
1296 "asc": true,
1297 "nullsFirst": false,
1298 "opclass": null
1299 }
1300 ],
1301 "isUnique": true,
1302 "where": null,
1303 "with": "",
1304 "method": "btree",
1305 "concurrently": false,
1306 "name": "idx_relay_environment_credentials_hash",
1307 "entityType": "indexes",
1308 "schema": "public",
1309 "table": "relay_environment_credentials"
1310 },
1311 {
1312 "nameExplicit": true,
1313 "columns": [
1314 {
1315 "value": "environment_id",
1316 "isExpression": false,
1317 "asc": true,
1318 "nullsFirst": false,
1319 "opclass": null
1320 },
1321 {
1322 "value": "revoked_at",
1323 "isExpression": false,
1324 "asc": true,
1325 "nullsFirst": false,
1326 "opclass": null
1327 }
1328 ],
1329 "isUnique": false,
1330 "where": null,
1331 "with": "",
1332 "method": "btree",
1333 "concurrently": false,
1334 "name": "idx_relay_environment_credentials_environment",
1335 "entityType": "indexes",
1336 "schema": "public",
1337 "table": "relay_environment_credentials"
1338 },
1339 {
1340 "nameExplicit": true,
1341 "columns": [
1342 {
1343 "value": "environment_id",
1344 "isExpression": false,
1345 "asc": true,
1346 "nullsFirst": false,
1347 "opclass": null
1348 },
1349 {
1350 "value": "environment_public_key",
1351 "isExpression": false,
1352 "asc": true,
1353 "nullsFirst": false,
1354 "opclass": null
1355 },
1356 {
1357 "value": "revoked_at",
1358 "isExpression": false,
1359 "asc": true,
1360 "nullsFirst": false,
1361 "opclass": null
1362 }
1363 ],
1364 "isUnique": false,
1365 "where": null,
1366 "with": "",
1367 "method": "btree",
1368 "concurrently": false,
1369 "name": "idx_relay_environment_credentials_environment_key",
1370 "entityType": "indexes",
1371 "schema": "public",
1372 "table": "relay_environment_credentials"
1373 },
1374 {
1375 "nameExplicit": true,
1376 "columns": [
1377 {
1378 "value": "environment_id",
1379 "isExpression": false,
1380 "asc": true,
1381 "nullsFirst": false,
1382 "opclass": null
1383 },
1384 {
1385 "value": "revoked_at",
1386 "isExpression": false,
1387 "asc": true,
1388 "nullsFirst": false,
1389 "opclass": null
1390 }
1391 ],
1392 "isUnique": false,
1393 "where": null,
1394 "with": "",
1395 "method": "btree",
1396 "concurrently": false,
1397 "name": "idx_relay_environment_links_environment",
1398 "entityType": "indexes",
1399 "schema": "public",
1400 "table": "relay_environment_links"
1401 },
1402 {
1403 "nameExplicit": true,
1404 "columns": [
1405 {
1406 "value": "activity_push_token",
1407 "isExpression": false,
1408 "asc": true,
1409 "nullsFirst": false,
1410 "opclass": null
1411 }
1412 ],
1413 "isUnique": true,
1414 "where": null,
1415 "with": "",
1416 "method": "btree",
1417 "concurrently": false,
1418 "name": "idx_relay_live_activities_activity_push_token",
1419 "entityType": "indexes",
1420 "schema": "public",
1421 "table": "relay_live_activities"
1422 },
1423 {
1424 "nameExplicit": true,
1425 "columns": [
1426 {
1427 "value": "hostname",
1428 "isExpression": false,
1429 "asc": true,
1430 "nullsFirst": false,
1431 "opclass": null
1432 }
1433 ],
1434 "isUnique": true,
1435 "where": null,
1436 "with": "",
1437 "method": "btree",
1438 "concurrently": false,
1439 "name": "idx_relay_managed_endpoint_allocations_hostname",
1440 "entityType": "indexes",
1441 "schema": "public",
1442 "table": "relay_managed_endpoint_allocations"
1443 },
1444 {
1445 "nameExplicit": true,
1446 "columns": [
1447 {
1448 "value": "tunnel_name",
1449 "isExpression": false,
1450 "asc": true,
1451 "nullsFirst": false,
1452 "opclass": null
1453 }
1454 ],
1455 "isUnique": true,
1456 "where": null,
1457 "with": "",
1458 "method": "btree",
1459 "concurrently": false,
1460 "name": "idx_relay_managed_endpoint_allocations_tunnel_name",
1461 "entityType": "indexes",
1462 "schema": "public",
1463 "table": "relay_managed_endpoint_allocations"
1464 },
1465 {
1466 "nameExplicit": true,
1467 "columns": [
1468 {
1469 "value": "push_token",
1470 "isExpression": false,
1471 "asc": true,
1472 "nullsFirst": false,
1473 "opclass": null
1474 }
1475 ],
1476 "isUnique": true,
1477 "where": null,
1478 "with": "",
1479 "method": "btree",
1480 "concurrently": false,
1481 "name": "idx_relay_mobile_devices_push_token",
1482 "entityType": "indexes",
1483 "schema": "public",
1484 "table": "relay_mobile_devices"
1485 },
1486 {
1487 "nameExplicit": true,
1488 "columns": [
1489 {
1490 "value": "push_to_start_token",
1491 "isExpression": false,
1492 "asc": true,
1493 "nullsFirst": false,
1494 "opclass": null
1495 }
1496 ],
1497 "isUnique": true,
1498 "where": null,
1499 "with": "",
1500 "method": "btree",
1501 "concurrently": false,
1502 "name": "idx_relay_mobile_devices_push_to_start_token",
1503 "entityType": "indexes",
1504 "schema": "public",
1505 "table": "relay_mobile_devices"
1506 },
1507 {
1508 "columns": ["environment_id", "environment_public_key", "thread_id"],
1509 "nameExplicit": false,
1510 "name": "relay_agent_activity_rows_pkey",
1511 "entityType": "pks",
1512 "schema": "public",
1513 "table": "relay_agent_activity_rows"
1514 },
1515 {
1516 "columns": ["thumbprint", "jti"],
1517 "nameExplicit": false,
1518 "name": "relay_dpop_proofs_pkey",
1519 "entityType": "pks",
1520 "schema": "public",
1521 "table": "relay_dpop_proofs"
1522 },
1523 {
1524 "columns": ["user_id", "environment_id"],
1525 "nameExplicit": false,
1526 "name": "relay_environment_links_pkey",
1527 "entityType": "pks",
1528 "schema": "public",
1529 "table": "relay_environment_links"
1530 },
1531 {
1532 "columns": ["user_id", "device_id"],
1533 "nameExplicit": false,
1534 "name": "relay_live_activities_pkey",
1535 "entityType": "pks",
1536 "schema": "public",
1537 "table": "relay_live_activities"
1538 },
1539 {
1540 "columns": ["user_id", "environment_id"],
1541 "nameExplicit": false,
1542 "name": "relay_managed_endpoint_allocations_pkey",
1543 "entityType": "pks",
1544 "schema": "public",
1545 "table": "relay_managed_endpoint_allocations"
1546 },
1547 {
1548 "columns": ["user_id", "device_id"],
1549 "nameExplicit": false,
1550 "name": "relay_mobile_devices_pkey",
1551 "entityType": "pks",
1552 "schema": "public",
1553 "table": "relay_mobile_devices"
1554 },
1555 {
1556 "columns": ["id"],
1557 "nameExplicit": false,
1558 "name": "relay_delivery_attempts_pkey",
1559 "schema": "public",
1560 "table": "relay_delivery_attempts",
1561 "entityType": "pks"
1562 },
1563 {
1564 "columns": ["credential_id"],
1565 "nameExplicit": false,
1566 "name": "relay_environment_credentials_pkey",
1567 "schema": "public",
1568 "table": "relay_environment_credentials",
1569 "entityType": "pks"
1570 },
1571 {
1572 "columns": ["user_id"],
1573 "nameExplicit": false,
1574 "name": "relay_managed_tunnel_limits_pkey",
1575 "schema": "public",
1576 "table": "relay_managed_tunnel_limits",
1577 "entityType": "pks"
1578 }
1579 ],
1580 "renames": []
1581}
infra/relay/package.json+1
......@@ -13,6 +13,7 @@
1313 "dependencies": {
1414 "@clerk/backend": "catalog:",
1515 "@effect/sql-pg": "catalog:",
16 "@effect/sql-sqlite-do": "catalog:",
1617 "@noble/curves": "catalog:",
1718 "@noble/hashes": "catalog:",
1819 "@t3tools/client-runtime": "workspace:*",
infra/relay/src/agentActivity/AgentActivityPublisher.test.ts+1
......@@ -95,6 +95,7 @@ function makeEnvironmentLinks(
9595 listForUser: () => Effect.succeed([]),
9696 getForUser: () => Effect.succeed(null),
9797 findActiveManagedForEnvironment: () => Effect.succeed([]),
98 setHoldWebhooksWhileOffline: () => Effect.void,
9899 revokeForUser: () => Effect.succeed(false),
99100 ...overrides,
100101 };
infra/relay/src/agentActivity/FcmDeliveries.test.ts+1
......@@ -172,6 +172,7 @@ function harness() {
172172 ),
173173 listForUser: () => Effect.succeed([]),
174174 findActiveManagedForEnvironment: () => Effect.succeed([]),
175 setHoldWebhooksWhileOffline: () => Effect.void,
175176 revokeForUser: () => Effect.succeed(false),
176177 getForUser: (input) =>
177178 Effect.sync(() =>
infra/relay/src/agentActivity/MobileRegistrations.test.ts+1
......@@ -117,6 +117,7 @@ function makeEnvironmentLinks(
117117 listForUser: () => Effect.succeed([]),
118118 getForUser: () => Effect.succeed(null),
119119 findActiveManagedForEnvironment: () => Effect.succeed([]),
120 setHoldWebhooksWhileOffline: () => Effect.void,
120121 revokeForUser: () => Effect.succeed(false),
121122 ...overrides,
122123 };
infra/relay/src/deploymentConfig.ts+13
......@@ -124,3 +124,16 @@ export function managedEndpointTunnelNamePrefix(stage: string): string {
124124export function managedEndpointTunnelName(stage: string, hash: string): string {
125125 return `${managedEndpointTunnelNamePrefix(stage)}${stableSuffix(hash)}`;
126126}
127
128/**
129 * A managed endpoint's public key in webhook URLs: the hash suffix its tunnel
130 * name ends with. The hash covers user and environment, so one key names
131 * exactly one link, unlike the environment id, which any account can claim.
132 */
133export const MANAGED_ENDPOINT_KEY_PATTERN = new RegExp(
134 `^[0-9a-f]{${MANAGED_ENDPOINT_HASH_LENGTH}}$`,
135);
136
137export function managedEndpointTunnelNameForKey(stage: string, endpointKey: string): string {
138 return `${managedEndpointTunnelNamePrefix(stage)}${endpointKey}`;
139}
infra/relay/src/environments/EnvironmentConnector.test.ts+2
......@@ -194,6 +194,7 @@ function makeAllocations(
194194 },
195195): ManagedEndpointAllocations.ManagedEndpointAllocations["Service"] {
196196 return {
197 getByTunnelName: () => Effect.die("unused getByTunnelName"),
197198 get: () => Effect.succeed(allocation),
198199 reserve: () => Effect.die("unused"),
199200 recordTunnel: () => Effect.die("unused"),
......@@ -230,6 +231,7 @@ function makeLinks(
230231 ...overrides,
231232 }),
232233 findActiveManagedForEnvironment: () => Effect.succeed([]),
234 setHoldWebhooksWhileOffline: () => Effect.void,
233235 revokeForUser: () => Effect.succeed(false),
234236 };
235237}
infra/relay/src/environments/EnvironmentLinker.test.ts+1
......@@ -127,6 +127,7 @@ function testLayer(input?: {
127127 listForUser: () => Effect.succeed([]),
128128 getForUser: () => Effect.succeed(null),
129129 findActiveManagedForEnvironment: () => Effect.succeed([]),
130 setHoldWebhooksWhileOffline: () => Effect.void,
130131 revokeForUser: () => Effect.succeed(false),
131132 }),
132133 Layer.succeed(EnvironmentCredentials.EnvironmentCredentials, {
infra/relay/src/environments/EnvironmentLinks.test.ts+39
......@@ -122,6 +122,45 @@ describe("EnvironmentLinks", () => {
122122 );
123123 });
124124
125 it.effect("carries the webhook-hold opt-in over only from links with the same key", () => {
126 const inserted: Array<Record<string, unknown>> = [];
127 const fakeDb = {
128 insert: () => ({
129 values: (values: Record<string, unknown>) => {
130 inserted.push(values);
131 return { onConflictDoUpdate: () => Effect.void };
132 },
133 }),
134 } as unknown as RelayDb.RelayDb["Service"];
135
136 return Effect.gen(function* () {
137 const links = yield* EnvironmentLinks.EnvironmentLinks;
138 yield* links.upsert({
139 userId: "user-1",
140 request: {
141 notificationsEnabled: false,
142 liveActivitiesEnabled: false,
143 managedTunnelsEnabled: true,
144 } as never,
145 proof: {
146 environmentId: "env-1",
147 environmentPublicKey: "public-key-1",
148 descriptor: { label: "Laptop" },
149 } as never,
150 endpoint: { httpBaseUrl: "https://a.example", wsBaseUrl: "wss://a.example" } as never,
151 });
152 const query = new PgDialect().sqlToQuery(inserted[0]?.holdWebhooksWhileOffline as never);
153 // An environment id is public: another account linking it with its own
154 // key must not switch the opt-in on for this one.
155 expect(query.sql).toContain("environment_public_key");
156 expect(query.params).toEqual(["env-1", "public-key-1"]);
157 }).pipe(
158 Effect.provide(
159 EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
160 ),
161 );
162 });
163
125164 it.effect("revokes only the active link owned by the requesting user", () => {
126165 const updateValues: Array<Record<string, unknown>> = [];
127166 const whereConditions: Array<unknown> = [];
infra/relay/src/environments/EnvironmentLinks.ts+65-5
......@@ -9,7 +9,7 @@ import * as DateTime from "effect/DateTime";
99import * as Effect from "effect/Effect";
1010import * as Layer from "effect/Layer";
1111import * as Schema from "effect/Schema";
12import { and, eq, isNull, or } from "drizzle-orm";
12import { and, eq, isNull, or, sql } from "drizzle-orm";
1313
1414import * as RelayDb from "../db.ts";
1515import { relayEnvironmentLinks } from "../persistence/schema.ts";
......@@ -126,13 +126,30 @@ export class EnvironmentLinks extends Context.Service<
126126 readonly userId: string;
127127 readonly environmentId: string;
128128 }) => Effect.Effect<RelayLinkedEnvironmentRecord | null, EnvironmentLinkLookupPersistenceError>;
129 /** Active relay-managed links for an environment, across all users (webhook forwarding). */
129 /**
130 * Active relay-managed links for an environment, narrowed to one user or to
131 * links proven by one environment key. The environment id alone is public
132 * and any account can link it, so callers acting on it must narrow.
133 */
130134 readonly findActiveManagedForEnvironment: (input: {
131135 readonly environmentId: string;
136 readonly userId?: string;
137 readonly environmentPublicKey?: string;
132138 }) => Effect.Effect<
133 ReadonlyArray<RelayLinkedEnvironmentRecord & { readonly userId: string }>,
139 ReadonlyArray<
140 RelayLinkedEnvironmentRecord & {
141 readonly userId: string;
142 readonly holdWebhooksWhileOffline: boolean;
143 }
144 >,
134145 EnvironmentLinkEnvironmentLookupPersistenceError
135146 >;
147 /** Sets the webhook-hold opt-in on the active links proven by one environment key. */
148 readonly setHoldWebhooksWhileOffline: (input: {
149 readonly environmentId: string;
150 readonly environmentPublicKey: string;
151 readonly holdWebhooksWhileOffline: boolean;
152 }) => Effect.Effect<void, EnvironmentLinkEnvironmentLookupPersistenceError>;
136153 readonly revokeForUser: (input: {
137154 readonly userId: string;
138155 readonly environmentId: string;
......@@ -166,12 +183,24 @@ const make = Effect.gen(function* () {
166183 const now = DateTime.formatIso(yield* DateTime.now);
167184 const { request, proof } = input;
168185 const environmentId = proof.environmentId;
186 // The webhook-hold opt-in belongs to the environment: a new or re-made
187 // link carries it over from the environment's other active links. Only
188 // links proven by the same key count; an environment id is public, so
189 // anyone can link one and switch the opt-in on for their own link.
190 const inheritedHoldWebhooks = sql<boolean>`EXISTS (
191 SELECT 1 FROM ${relayEnvironmentLinks} AS other
192 WHERE other.environment_id = ${environmentId}
193 AND other.environment_public_key = ${proof.environmentPublicKey}
194 AND other.revoked_at IS NULL
195 AND other.hold_webhooks_while_offline
196 )`;
169197 const { endpoint } = input;
170198 yield* db
171199 .insert(relayEnvironmentLinks)
172200 .values({
173201 userId: input.userId,
174202 environmentId,
203 holdWebhooksWhileOffline: inheritedHoldWebhooks,
175204 environmentLabel: proof.descriptor.label,
176205 environmentPublicKey: proof.environmentPublicKey,
177206 endpointHttpBaseUrl: endpoint.httpBaseUrl,
......@@ -199,6 +228,7 @@ const make = Effect.gen(function* () {
199228 createdByDeviceId: request.deviceId ?? null,
200229 revokedAt: null,
201230 updatedAt: now,
231 holdWebhooksWhileOffline: inheritedHoldWebhooks,
202232 },
203233 })
204234 .pipe(
......@@ -355,6 +385,7 @@ const make = Effect.gen(function* () {
355385 endpointWsBaseUrl: relayEnvironmentLinks.endpointWsBaseUrl,
356386 endpointProviderKind: relayEnvironmentLinks.endpointProviderKind,
357387 createdAt: relayEnvironmentLinks.createdAt,
388 holdWebhooksWhileOffline: relayEnvironmentLinks.holdWebhooksWhileOffline,
358389 })
359390 .from(relayEnvironmentLinks)
360391 .where(
......@@ -363,10 +394,13 @@ const make = Effect.gen(function* () {
363394 isNull(relayEnvironmentLinks.revokedAt),
364395 eq(relayEnvironmentLinks.endpointProviderKind, "cloudflare_tunnel"),
365396 eq(relayEnvironmentLinks.managedTunnelsEnabled, true),
397 input.userId === undefined ? undefined : eq(relayEnvironmentLinks.userId, input.userId),
398 input.environmentPublicKey === undefined
399 ? undefined
400 : eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey),
366401 ),
367402 )
368 // One row per user who linked this environment; every row is checked
369 // until one has a ready endpoint, so none may be cut off.
403 // At most one row per user who linked this environment.
370404 .pipe(
371405 Effect.map((rows) =>
372406 rows.map((row) => ({
......@@ -382,6 +416,7 @@ const make = Effect.gen(function* () {
382416 },
383417 environmentPublicKey: row.environmentPublicKey,
384418 linkedAt: row.createdAt,
419 holdWebhooksWhileOffline: row.holdWebhooksWhileOffline,
385420 })),
386421 ),
387422 Effect.mapError(
......@@ -394,6 +429,31 @@ const make = Effect.gen(function* () {
394429 );
395430 }),
396431
432 setHoldWebhooksWhileOffline: Effect.fn(
433 "relay.environment_links.set_hold_webhooks_while_offline",
434 )(function* (input) {
435 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
436 yield* db
437 .update(relayEnvironmentLinks)
438 .set({ holdWebhooksWhileOffline: input.holdWebhooksWhileOffline })
439 .where(
440 and(
441 eq(relayEnvironmentLinks.environmentId, input.environmentId),
442 eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey),
443 isNull(relayEnvironmentLinks.revokedAt),
444 ),
445 )
446 .pipe(
447 Effect.mapError(
448 (cause) =>
449 new EnvironmentLinkEnvironmentLookupPersistenceError({
450 environmentId: input.environmentId,
451 cause,
452 }),
453 ),
454 );
455 }),
456
397457 revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")(function* (input) {
398458 yield* Effect.annotateCurrentSpan({
399459 "relay.environment_id": input.environmentId,
infra/relay/src/environments/ManagedEndpointAllocations.ts+28
......@@ -64,6 +64,7 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro
6464 "claim-deprovision",
6565 "remove",
6666 "remove-claimed",
67 "get-by-tunnel-name",
6768 ]),
6869 stage: Schema.Literals(["database-request", "resolve-reservation"]),
6970 userId: Schema.String,
......@@ -132,6 +133,10 @@ export class ManagedEndpointAllocations extends Context.Service<
132133 readonly get: (
133134 input: ManagedEndpointAllocationKey,
134135 ) => Effect.Effect<ManagedEndpointAllocation | null, ManagedEndpointAllocationPersistenceError>;
136 /** The allocation that owns a tunnel name; tunnel names are unique. */
137 readonly getByTunnelName: (
138 tunnelName: string,
139 ) => Effect.Effect<ManagedEndpointAllocation | null, ManagedEndpointAllocationPersistenceError>;
135140 readonly reserve: (
136141 input: ReserveManagedEndpointAllocationInput,
137142 ) => Effect.Effect<ManagedEndpointAllocation, ManagedEndpointAllocationPersistenceError>;
......@@ -230,6 +235,29 @@ export const make = Effect.gen(function* () {
230235 ),
231236 );
232237 }),
238 getByTunnelName: Effect.fn("relay.managed_endpoint_allocations.get_by_tunnel_name")(function* (
239 tunnelName: string,
240 ) {
241 return yield* db
242 .select(allocationSelection)
243 .from(relayManagedEndpointAllocations)
244 .where(eq(relayManagedEndpointAllocations.tunnelName, tunnelName))
245 .limit(1)
246 .pipe(
247 Effect.map((rows) => rows[0] ?? null),
248 Effect.mapError(
249 (cause) =>
250 new ManagedEndpointAllocationPersistenceError({
251 operation: "get-by-tunnel-name",
252 stage: "database-request",
253 userId: "",
254 environmentId: "",
255 tunnelName,
256 cause,
257 }),
258 ),
259 );
260 }),
233261 reserve: Effect.fn("relay.managed_endpoint_allocations.reserve")(function* (
234262 input: ReserveManagedEndpointAllocationInput,
235263 ) {
infra/relay/src/environments/ManagedEndpointProvider.test.ts+1
......@@ -204,6 +204,7 @@ function makeAllocations(calls: AllocationCall[] = []) {
204204 }
205205 };
206206 return ManagedEndpointAllocations.ManagedEndpointAllocations.of({
207 getByTunnelName: () => Effect.die("unused getByTunnelName"),
207208 get: (input) =>
208209 Effect.sync(() => {
209210 calls.push({ operation: "get", input });
infra/relay/src/environments/ManagedEndpointReaper.test.ts+1
......@@ -166,6 +166,7 @@ function harness(input?: {
166166 }),
167167 });
168168 const allocationService = ManagedEndpointAllocations.ManagedEndpointAllocations.of({
169 getByTunnelName: () => Effect.die("unused"),
169170 get: () => Effect.die("unused"),
170171 reserve: () => Effect.die("unused"),
171172 recordTunnel: () => Effect.die("unused"),
infra/relay/src/hooks/HookForwarder.test.ts+364-19
......@@ -1,9 +1,13 @@
11import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
22import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeCrypto from "node:crypto";
4import * as EffectNodeCrypto from "@effect/platform-node/NodeCrypto";
35import { describe, expect, it } from "@effect/vitest";
46import { RelayApi } from "@t3tools/contracts/relay";
7import * as DateTime from "effect/DateTime";
58import * as Deferred from "effect/Deferred";
69import * as Duration from "effect/Duration";
10import * as Clock from "effect/Clock";
711import * as Effect from "effect/Effect";
812import * as Fiber from "effect/Fiber";
913import * as Layer from "effect/Layer";
......@@ -18,6 +22,7 @@ import * as Etag from "effect/http/Etag";
1822import * as HttpRouter from "effect/http/HttpRouter";
1923import * as HttpApi from "effect/http-api/HttpApi";
2024import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
25import * as HttpMiddleware from "effect/http/HttpMiddleware";
2126import * as HttpServerRequest from "effect/http/HttpServerRequest";
2227import * as HttpServerResponse from "effect/http/HttpServerResponse";
2328
......@@ -31,6 +36,15 @@ import {
3136 traceRelayHttpRequestWith,
3237} from "../http/Api.ts";
3338import * as HookForwarder from "./HookForwarder.ts";
39import { RELAY_HOOK_DELIVERY_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt";
40import * as HookInbox from "./HookInbox.ts";
41import type { HeldHook } from "./HookInboxStore.ts";
42import { RELAY_HOOK_UPSTREAM_TIMEOUT_MS } from "./upstream.ts";
43
44const mintKeys = NodeCrypto.generateKeyPairSync("ed25519", {
45 privateKeyEncoding: { format: "pem", type: "pkcs8" },
46 publicKeyEncoding: { format: "pem", type: "spki" },
47});
3448
3549const settings: RelayConfiguration.RelayConfiguration["Service"] = {
3650 relayIssuer: "https://relay.example.test",
......@@ -39,19 +53,20 @@ const settings: RelayConfiguration.RelayConfiguration["Service"] = {
3953 clerkPublishableKey: "pk_test_test",
4054 clerkJwtAudience: "t3-code-relay",
4155 apnsDeliveryJobSigningSecret: Redacted.make("apns-delivery-secret"),
42 cloudMintPrivateKey: Redacted.make("cloud-mint-private-key"),
43 cloudMintPublicKey: "cloud-mint-public-key",
56 cloudMintPrivateKey: Redacted.make(mintKeys.privateKey),
57 cloudMintPublicKey: mintKeys.publicKey,
4458 managedEndpointBaseDomain: "example.test",
45 managedEndpointNamespace: undefined,
59 managedEndpointNamespace: "dev",
4660};
4761
4862const environmentId = "env-hook";
63const endpointKey = "0123456789abcdef";
4964const readyAllocation: ManagedEndpointAllocations.ManagedEndpointAllocation = {
5065 userId: "user_1",
5166 environmentId,
5267 hostname: "env.example.test",
5368 tunnelId: "tunnel-id",
54 tunnelName: "tunnel-name",
69 tunnelName: `t3coderelay-managedendpoint-dev-${endpointKey}`,
5570 dnsRecordId: "dns-record-id",
5671 readyAt: "2026-05-25T00:00:00.000Z",
5772 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
......@@ -70,6 +85,7 @@ const managedLink = {
7085 },
7186 environmentPublicKey: "public-key",
7287 linkedAt: "2026-05-25T00:00:00.000Z",
88 holdWebhooksWhileOffline: false,
7389};
7490
7591interface Harness {
......@@ -79,11 +95,15 @@ interface Harness {
7995 readonly links?: ReadonlyArray<typeof managedLink>;
8096 readonly allocation?: ManagedEndpointAllocations.ManagedEndpointAllocation | null;
8197 readonly allow?: (key: string) => boolean;
98 readonly allowEndpoint?: (endpointKey: string) => boolean;
99 /** Inbox capacity; hold reports full once this many requests are held. */
100 readonly inboxCapacity?: number;
82101}
83102
84103function makeHarness(options: Harness = {}) {
85104 const sent: Array<HttpClientRequest.HttpClientRequest> = [];
86105 const rateLimitKeys: Array<string> = [];
106 const held: Array<HeldHook & { readonly baseUrl: string }> = [];
87107 const execute =
88108 options.execute ??
89109 ((request: HttpClientRequest.HttpClientRequest) =>
......@@ -100,12 +120,19 @@ function makeHarness(options: Harness = {}) {
100120 Layer.mock(EnvironmentLinks.EnvironmentLinks, {
101121 findActiveManagedForEnvironment: (input) =>
102122 Effect.succeed(
103 input.environmentId === environmentId ? (options.links ?? [managedLink]) : [],
123 (options.links ?? [managedLink]).filter(
124 (link) =>
125 link.environmentId === input.environmentId &&
126 (input.userId === undefined || link.userId === input.userId),
127 ),
104128 ),
105129 }),
106130 Layer.mock(ManagedEndpointAllocations.ManagedEndpointAllocations, {
107 get: () =>
108 Effect.succeed(options.allocation === undefined ? readyAllocation : options.allocation),
131 getByTunnelName: (tunnelName) => {
132 const allocation =
133 options.allocation === undefined ? readyAllocation : options.allocation;
134 return Effect.succeed(allocation?.tunnelName === tunnelName ? allocation : null);
135 },
109136 }),
110137 Layer.succeed(
111138 HttpClient.HttpClient,
......@@ -114,12 +141,23 @@ function makeHarness(options: Harness = {}) {
114141 return execute(request);
115142 }),
116143 ),
144 Layer.mock(HookInbox.HookInbox, {
145 hold: ({ hook, baseUrl }) =>
146 Effect.sync(() => {
147 if (held.length >= (options.inboxCapacity ?? Infinity)) return false;
148 held.push({ ...hook, baseUrl });
149 return true;
150 }),
151 }),
152 EffectNodeCrypto.layer,
117153 Layer.succeed(HookForwarder.HookRateLimiter, {
118 allow: (key) =>
154 allowHook: (key) =>
119155 Effect.sync(() => {
120156 rateLimitKeys.push(key);
121157 return options.allow ? options.allow(key) : true;
122158 }),
159 allowEndpoint: (key) =>
160 Effect.sync(() => (options.allowEndpoint ? options.allowEndpoint(key) : true)),
123161 }),
124162 ),
125163 ),
......@@ -144,11 +182,11 @@ function makeHarness(options: Harness = {}) {
144182 ),
145183 );
146184 });
147 return { sent, rateLimitKeys, send, httpEffect };
185 return { sent, rateLimitKeys, held, send, httpEffect };
148186}
149187
150188const hookUrl = (path = "hook-1/secret-token", query = "") =>
151 `https://relay.test/v1/hooks/${environmentId}/${path}${query}`;
189 `https://relay.test/v1/hooks/${endpointKey}/${path}${query}`;
152190
153191const readBody = (response: HttpServerResponse.HttpServerResponse) =>
154192 Effect.promise(() => HttpServerResponse.toWeb(response).arrayBuffer()).pipe(
......@@ -210,6 +248,36 @@ describe("HookForwarder", () => {
210248 }),
211249 );
212250
251 it.effect("signs each forward for the environment, replacing any copy a sender sent", () =>
252 Effect.gen(function* () {
253 const harness = makeHarness();
254 yield* harness.send(
255 new Request(hookUrl("hook-1/tok"), {
256 method: "POST",
257 headers: { "x-t3-relay-delivery": "forged", "x-t3-relay-delivery-id": "forged" },
258 body: "{}",
259 }),
260 );
261 const sent = harness.sent[0]!;
262 const payload = yield* verifyRelayJwt({
263 publicKey: mintKeys.publicKey,
264 token: sent.headers["x-t3-relay-delivery"]!,
265 typ: RELAY_HOOK_DELIVERY_TYP,
266 issuer: settings.relayIssuer,
267 audience: `t3-env:${environmentId}`,
268 nowEpochSeconds: Math.floor((yield* Clock.currentTimeMillis) / 1_000),
269 });
270 // The proof names exactly the delivery the environment receives.
271 expect(payload).toMatchObject({
272 environmentId,
273 hookId: "hook-1",
274 deliveryId: sent.headers["x-t3-relay-delivery-id"],
275 receivedAt: sent.headers["x-t3-relay-received-at"],
276 });
277 expect(sent.headers["x-t3-relay-delivery-id"]).not.toBe("forged");
278 }),
279 );
280
213281 it.effect("passes upstream status, body and content-type through", () =>
214282 Effect.gen(function* () {
215283 const harness = makeHarness({
......@@ -229,11 +297,24 @@ describe("HookForwarder", () => {
229297 expect(response.headers["content-type"]).toBe("application/json");
230298 expect(response.headers["set-cookie"]).toBeUndefined();
231299 expect(response.headers["access-control-allow-origin"]).toBeUndefined();
300 // Served from the relay's origin, so it may never render or run there.
301 expect(response.headers["x-content-type-options"]).toBe("nosniff");
302 expect(response.headers["content-security-policy"]).toBe("sandbox; default-src 'none'");
232303 expect(new TextDecoder().decode(yield* readBody(response))).toBe('{"error":"bad_signature"}');
233304 expect(harness.sent[0]?.method).toBe("GET");
234305 }),
235306 );
236307
308 it.effect("never forwards or holds HEAD, which can carry no body", () =>
309 Effect.gen(function* () {
310 const harness = makeHarness({ execute: () => Effect.die("must not be sent") });
311 const response = yield* harness.send(new Request(hookUrl(), { method: "HEAD" }));
312 expect(response.status).toBeGreaterThanOrEqual(400);
313 expect(response.status).toBeLessThan(500);
314 expect(harness.held).toHaveLength(0);
315 }),
316 );
317
237318 it.effect("does not follow or relay upstream redirects", () =>
238319 Effect.gen(function* () {
239320 const harness = makeHarness({
......@@ -284,14 +365,17 @@ describe("HookForwarder", () => {
284365 }),
285366 );
286367
287 it.effect("returns 404 for unknown environments and unready endpoints", () =>
368 it.effect("returns 404 for unknown endpoints and unready endpoints", () =>
288369 Effect.gen(function* () {
289370 const unknown = makeHarness();
290 const response = yield* unknown.send(
291 new Request("https://relay.test/v1/hooks/other-env/hook-1/token", { method: "POST" }),
292 );
293 expect(response.status).toBe(404);
294 expect(yield* readJson(response)).toEqual({ error: "hook_not_found" });
371 for (const key of ["fedcba9876543210", environmentId]) {
372 const response = yield* unknown.send(
373 new Request(`https://relay.test/v1/hooks/${key}/hook-1/token`, { method: "POST" }),
374 );
375 expect(response.status).toBe(404);
376 expect(yield* readJson(response)).toEqual({ error: "hook_not_found" });
377 }
378 expect(unknown.sent).toHaveLength(0);
295379
296380 const unready = makeHarness({ allocation: { ...readyAllocation, readyAt: null } });
297381 const unreadyResponse = yield* unready.send(new Request(hookUrl(), { method: "POST" }));
......@@ -300,6 +384,42 @@ describe("HookForwarder", () => {
300384 }),
301385 );
302386
387 it.effect("forwards only to the link that owns the endpoint key", () =>
388 Effect.gen(function* () {
389 // Another account linked the same environment id under its own key; its
390 // link must not receive this endpoint's hooks, whatever order rows come in.
391 const intruder = {
392 ...managedLink,
393 userId: "user_attacker",
394 environmentPublicKey: "attacker-key",
395 endpoint: { ...managedLink.endpoint, httpBaseUrl: "https://attacker.example.test/" },
396 };
397 const harness = makeHarness({ links: [intruder, managedLink] });
398 const response = yield* harness.send(new Request(hookUrl(), { method: "POST", body: "{}" }));
399 expect(response.status).toBe(200);
400 expect(harness.sent.map((request) => new URL(request.url).host)).toEqual([
401 "env.example.test",
402 ]);
403
404 // Without the owner's link, the key resolves to nothing at all.
405 const orphaned = makeHarness({ links: [intruder] });
406 const orphanedResponse = yield* orphaned.send(
407 new Request(hookUrl(), { method: "POST", body: "{}" }),
408 );
409 expect(orphanedResponse.status).toBe(404);
410 expect(orphaned.sent).toHaveLength(0);
411 }),
412 );
413
414 it.effect("returns 429 when the endpoint's overall budget is spent", () =>
415 Effect.gen(function* () {
416 const harness = makeHarness({ allowEndpoint: () => false });
417 const response = yield* harness.send(new Request(hookUrl(), { method: "POST" }));
418 expect(response.status).toBe(429);
419 expect(harness.sent).toHaveLength(0);
420 }),
421 );
422
303423 it.effect("maps tunnel-offline and network failures to 503", () =>
304424 Effect.gen(function* () {
305425 const offline = makeHarness({
......@@ -353,13 +473,32 @@ describe("HookForwarder", () => {
353473 .send(new Request(hookUrl(), { method: "POST", body: "{}" }))
354474 .pipe(Effect.forkChild);
355475 yield* Deferred.await(reachedUpstream);
356 yield* TestClock.adjust(Duration.millis(HookForwarder.RELAY_HOOK_UPSTREAM_TIMEOUT_MS));
476 yield* TestClock.adjust(Duration.millis(RELAY_HOOK_UPSTREAM_TIMEOUT_MS));
357477 const response = yield* Fiber.join(fiber);
358478 expect(response.status).toBe(504);
359479 expect(yield* readJson(response)).toEqual({ error: "environment_timeout" });
360480 }),
361481 );
362482
483 it.effect("holds a timed-out request with the time it arrived", () =>
484 Effect.gen(function* () {
485 const reachedUpstream = yield* Deferred.make<void>();
486 const harness = makeHarness({
487 links: [{ ...managedLink, holdWebhooksWhileOffline: true }],
488 execute: () =>
489 Deferred.succeed(reachedUpstream, undefined).pipe(Effect.andThen(Effect.never)),
490 });
491 const arrivedAt = DateTime.formatIso(yield* DateTime.now);
492 const fiber = yield* harness
493 .send(new Request(hookUrl(), { method: "POST", body: "{}" }))
494 .pipe(Effect.forkChild);
495 yield* Deferred.await(reachedUpstream);
496 yield* TestClock.adjust(Duration.millis(RELAY_HOOK_UPSTREAM_TIMEOUT_MS));
497 expect((yield* Fiber.join(fiber)).status).toBe(202);
498 expect(harness.held[0]?.receivedAt).toBe(arrivedAt);
499 }),
500 );
501
363502 it.effect("answers OPTIONS without a CORS preflight or forwarding", () =>
364503 Effect.gen(function* () {
365504 const harness = makeHarness();
......@@ -454,9 +593,215 @@ describe("HookForwarder", () => {
454593 expect(serialized).not.toContain("also-secret");
455594 expect(serialized).not.toContain("header-secret");
456595 const server = spans.find((span) => span.kind === "server");
457 expect(server?.attributes.get("url.path")).toBe(
458 `/v1/hooks/${environmentId}/hook-1/<redacted>`,
596 expect(server?.attributes.get("url.path")).toBe(`/v1/hooks/${endpointKey}/hook-1/<redacted>`);
597 }),
598 );
599
600 it.effect("records one redacted server span even inside the worker's own HTTP tracer", () =>
601 Effect.gen(function* () {
602 const spans: Array<Tracer.NativeSpan> = [];
603 const tracer = Tracer.make({
604 span: (options) => {
605 const span = new Tracer.NativeSpan(options);
606 spans.push(span);
607 return span;
608 },
609 });
610 const harness = makeHarness();
611 const handler = yield* harness.httpEffect;
612 // As the worker runtime runs it: its own tracer around ours, off for hook
613 // paths. This checks the predicate; whether alchemy applies it per event
614 // is only visible on a deployed worker (see worker.ts).
615 yield* HttpMiddleware.tracer(
616 traceRelayHttpRequestWith(handler, Layer.succeed(Tracer.Tracer, tracer)),
617 ).pipe(
618 Effect.provideService(HttpMiddleware.TracerDisabledWhen, (request) =>
619 HookForwarder.isRelayHookPath(request.url),
620 ),
621 Effect.withTracer(tracer),
622 Effect.provideService(
623 HttpServerRequest.HttpServerRequest,
624 HttpServerRequest.fromWeb(
625 new Request(hookUrl("hook-1/super-secret-token"), {
626 method: "POST",
627 headers: { traceparent: "00-11111111111111111111111111111111-2222222222222222-01" },
628 body: "{}",
629 }),
630 ),
631 ),
632 );
633 yield* Effect.yieldNow;
634 const servers = spans.filter((span) => span.kind === "server");
635 expect(servers).toHaveLength(1);
636 expect(servers[0]?.attributes.get("url.path")).toBe(
637 `/v1/hooks/${endpointKey}/hook-1/<redacted>`,
459638 );
639 expect(spans.every((span) => span.traceId !== "11111111111111111111111111111111")).toBe(true);
640 }),
641 );
642
643 it.effect("joins the environment to its trace and records what the environment did", () =>
644 Effect.gen(function* () {
645 const spans: Array<Tracer.NativeSpan> = [];
646 const tracer = Tracer.make({
647 span: (options) => {
648 const span = new Tracer.NativeSpan(options);
649 spans.push(span);
650 return span;
651 },
652 });
653 const respondWith = (outcome: string) =>
654 makeHarness({
655 execute: (request) =>
656 Effect.succeed(
657 HttpClientResponse.fromWeb(
658 request,
659 new Response('{"deliveryId":"d"}', {
660 status: 202,
661 headers: { "x-t3-hook-outcome": outcome },
662 }),
663 ),
664 ),
665 });
666 const forward = (harness: ReturnType<typeof makeHarness>) =>
667 Effect.gen(function* () {
668 const handler = yield* harness.httpEffect;
669 return yield* traceRelayHttpRequestWith(
670 handler,
671 Layer.succeed(Tracer.Tracer, tracer),
672 ).pipe(
673 Effect.provideService(
674 HttpServerRequest.HttpServerRequest,
675 HttpServerRequest.fromWeb(
676 new Request(hookUrl(), {
677 method: "POST",
678 // A sender's own trace context never reaches the environment.
679 headers: {
680 traceparent: "00-11111111111111111111111111111111-2222222222222222-01",
681 "x-b3-traceid": "33333333333333333333333333333333",
682 },
683 body: "{}",
684 }),
685 ),
686 ),
687 );
688 });
689
690 const duplicate = respondWith("duplicate");
691 expect((yield* forward(duplicate)).status).toBe(202);
692 yield* Effect.yieldNow;
693 const forwardSpan = spans.find((span) => span.name === "relay.hooks.forward");
694 expect(forwardSpan?.attributes.get("relay.hook.upstream_outcome")).toBe("duplicate");
695 const sent = duplicate.sent[0]!;
696 expect(sent.headers.traceparent).toContain(forwardSpan!.traceId);
697 expect(sent.headers.traceparent).not.toContain("1111111111");
698 expect(sent.headers["x-b3-traceid"]).toBeUndefined();
699
700 // Only a plain outcome name is recorded.
701 spans.length = 0;
702 yield* forward(respondWith("<script>alert(1)</script>"));
703 yield* Effect.yieldNow;
704 expect(
705 spans
706 .find((span) => span.name === "relay.hooks.forward")
707 ?.attributes.has("relay.hook.upstream_outcome"),
708 ).toBe(false);
460709 }),
461710 );
711
712 describe("holding requests while the environment is offline", () => {
713 const offline = (request: HttpClientRequest.HttpClientRequest) =>
714 Effect.fail(
715 new HttpClientError.HttpClientError({
716 reason: new HttpClientError.TransportError({ request, cause: new Error("offline") }),
717 }),
718 );
719
720 it.effect("holds when cloudflared answers that the local server is down", () =>
721 Effect.gen(function* () {
722 for (const status of [502, 503, 504, 530]) {
723 const harness = makeHarness({
724 links: [{ ...managedLink, holdWebhooksWhileOffline: true }],
725 execute: (request) =>
726 Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status }))),
727 });
728 const response = yield* harness.send(
729 new Request(hookUrl(), { method: "POST", body: "{}" }),
730 );
731 expect(response.status).toBe(202);
732 expect(harness.held).toHaveLength(1);
733 }
734 }),
735 );
736
737 it.effect("stays a plain proxy when the environment has not opted in", () =>
738 Effect.gen(function* () {
739 const harness = makeHarness({ execute: offline });
740 const response = yield* harness.send(
741 new Request(hookUrl(), { method: "POST", body: "{}" }),
742 );
743 expect(response.status).toBe(503);
744 expect(harness.held).toHaveLength(0);
745 }),
746 );
747
748 it.effect("holds the exact request and answers 202 once opted in", () =>
749 Effect.gen(function* () {
750 const harness = makeHarness({
751 execute: offline,
752 links: [{ ...managedLink, holdWebhooksWhileOffline: true }],
753 });
754 const body = new Uint8Array([0, 255, 10]);
755 const response = yield* harness.send(
756 new Request(hookUrl("%68ook-1/tok%2Fen", "?a=1"), {
757 method: "POST",
758 body,
759 headers: { "x-t3-relay-delivery-id": "forged", "x-sig": "s" },
760 }),
761 );
762 expect(response.status).toBe(202);
763 const [hook] = harness.held;
764 expect(hook?.baseUrl).toBe("https://env.example.test/");
765 expect(hook?.rawToken).toBe("tok%2Fen");
766 expect(hook?.query).toBe("a=1");
767 expect([...(hook?.body ?? [])]).toEqual([0, 255, 10]);
768 expect(hook?.headers["x-sig"]).toBe("s");
769 // Every spelling of the hook id shares one per-hook cap.
770 expect(hook?.hookKey).toBe("hook-1");
771 // The sender cannot choose the delivery id.
772 expect(hook?.headers["x-t3-relay-delivery-id"]).toBeUndefined();
773 expect(hook?.id).not.toBe("forged");
774 }),
775 );
776
777 it.effect("answers 503 inbox_full when the environment's inbox is full", () =>
778 Effect.gen(function* () {
779 const harness = makeHarness({
780 execute: offline,
781 links: [{ ...managedLink, holdWebhooksWhileOffline: true }],
782 inboxCapacity: 0,
783 });
784 const response = yield* harness.send(
785 new Request(hookUrl(), { method: "POST", body: "{}" }),
786 );
787 expect(response.status).toBe(503);
788 expect(yield* readJson(response)).toEqual({ error: "inbox_full" });
789 }),
790 );
791
792 it.effect("tags every forward with a relay delivery id", () =>
793 Effect.gen(function* () {
794 const harness = makeHarness();
795 yield* harness.send(
796 new Request(hookUrl(), {
797 method: "POST",
798 body: "{}",
799 headers: { "x-t3-relay-delivery-id": "forged" },
800 }),
801 );
802 const id = harness.sent[0]?.headers["x-t3-relay-delivery-id"];
803 expect(id).toMatch(/^[0-9a-f-]{36}$/);
804 }),
805 );
806 });
462807});
infra/relay/src/hooks/HookForwarder.ts+253-105
......@@ -1,28 +1,57 @@
1import * as Clock from "effect/Clock";
12import * as Context from "effect/Context";
2import * as Duration from "effect/Duration";
3import * as Crypto from "effect/Crypto";
4import * as DateTime from "effect/DateTime";
35import * as Effect from "effect/Effect";
46import * as Layer from "effect/Layer";
57import * as Option from "effect/Option";
8import * as Redacted from "effect/Redacted";
69import * as Result from "effect/Result";
710import * as Schema from "effect/Schema";
811import * as Stream from "effect/Stream";
912import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import type * as HttpClientResponse from "effect/http/HttpClientResponse";
1213import type * as HttpServerRequest from "effect/http/HttpServerRequest";
1314import * as HttpServerResponse from "effect/http/HttpServerResponse";
1415import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
15import { RelayApi } from "@t3tools/contracts/relay";
16import { EnvironmentId } from "@t3tools/contracts";
17import { RelayApi, type RelayHookDeliveryProofPayload } from "@t3tools/contracts/relay";
18import {
19 normalizeRelayIssuer,
20 RELAY_HOOK_DELIVERY_HEADER,
21 RELAY_HOOK_DELIVERY_TYP,
22 signRelayJwt,
23} from "@t3tools/shared/relayJwt";
1624
1725import * as RelayConfiguration from "../Config.ts";
18import { validateManagedEndpoint, withoutRedirects } from "../environments/EnvironmentConnector.ts";
26import {
27 MANAGED_ENDPOINT_KEY_PATTERN,
28 managedEndpointTunnelNameForKey,
29} from "../deploymentConfig.ts";
30import { validateManagedEndpoint } from "../environments/EnvironmentConnector.ts";
1931import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
2032import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts";
33import * as HookInbox from "./HookInbox.ts";
34import { sendUpstream, TUNNEL_OFFLINE_STATUS } from "./upstream.ts";
2135
2236export const RELAY_HOOK_PATH_PREFIX = "/v1/hooks/";
2337export const RELAY_HOOK_MAX_BODY_BYTES = 1_048_576;
24export const RELAY_HOOK_UPSTREAM_TIMEOUT_MS = 8_000;
2538export const RELAY_HOOK_RATE_LIMIT = { limit: 60, periodSeconds: 60 } as const;
39/**
40 * Hook budgets are per URL, and a sender who knows an endpoint key can mint
41 * new URLs for free, so every endpoint also has one overall budget.
42 */
43export const RELAY_HOOK_ENDPOINT_RATE_LIMIT = { limit: 600, periodSeconds: 60 } as const;
44/**
45 * Upstream statuses that mean the environment did not take the request: the
46 * tunnel has no origin (530) or cloudflared cannot reach the local server
47 * (502, 503, 504) while it restarts.
48 */
49export const ENVIRONMENT_UNREACHABLE_STATUSES: ReadonlySet<number> = new Set([
50 502,
51 503,
52 504,
53 TUNNEL_OFFLINE_STATUS,
54]);
2655
2756const DROPPED_REQUEST_HEADERS = new Set([
2857 "host",
......@@ -34,12 +63,16 @@ const DROPPED_REQUEST_HEADERS = new Set([
3463 "content-length",
3564 "cookie",
3665 "x-real-ip",
66 // Only the relay may set this; a sender could otherwise collide delivery ids.
67 "x-t3-relay-delivery-id",
68 "x-t3-relay-received-at",
69 RELAY_HOOK_DELIVERY_HEADER,
70 // The environment trusts trace context only from the relay, which sets its own.
71 "traceparent",
72 "tracestate",
73 "b3",
3774]);
38const DROPPED_REQUEST_HEADER_PREFIXES = ["proxy-", "cf-", "x-forwarded-"];
39// Cloudflare answers 530 when the tunnel for a hostname has no connected origin.
40const TUNNEL_OFFLINE_STATUS = 530;
41/** The environment answers with a small JSON status; anything past this is cut off. */
42const MAX_RESPONSE_BYTES = 64 * 1024;
75const DROPPED_REQUEST_HEADER_PREFIXES = ["proxy-", "cf-", "x-forwarded-", "x-b3-"];
4376
4477export const isRelayHookPath = (url: string): boolean => url.startsWith(RELAY_HOOK_PATH_PREFIX);
4578
......@@ -55,13 +88,13 @@ export const redactRelayHookUrl = (url: string): string => {
5588
5689/**
5790 * Request budget for public hook forwarding, keyed by a hash of the hook URL
58 * (environment, hook and token). Requests with a wrong token get their own
91 * (endpoint, hook and token). Requests with a wrong token get their own
5992 * budget, so they cannot use up a real sender's; the environment rejects them.
6093 * Built from decoded segments, because the environment decodes them too: two
6194 * spellings of one token (`token`, `%74oken`) must share one budget.
6295 */
6396const hookBudgetKey = (hook: {
64 readonly environmentId: string;
97 readonly endpointKey: string;
6598 readonly hookId: string;
6699 readonly token: string;
67100}) =>
......@@ -70,7 +103,7 @@ const hookBudgetKey = (hook: {
70103 "SHA-256",
71104 // Length-prefixed, so no segment contents can make two keys collide.
72105 new TextEncoder().encode(
73 [hook.environmentId, hook.hookId, hook.token]
106 [hook.endpointKey, hook.hookId, hook.token]
74107 .map((part) => `${part.length}:${part}`)
75108 .join(""),
76109 ),
......@@ -83,7 +116,12 @@ const hookBudgetKey = (hook: {
83116
84117export class HookRateLimiter extends Context.Service<
85118 HookRateLimiter,
86 { readonly allow: (key: string) => Effect.Effect<boolean> }
119 {
120 /** One hook URL's budget, keyed by `hookBudgetKey`. */
121 readonly allowHook: (key: string) => Effect.Effect<boolean>;
122 /** One endpoint's overall budget, keyed by its endpoint key. */
123 readonly allowEndpoint: (endpointKey: string) => Effect.Effect<boolean>;
124 }
87125>()("t3code-relay/hooks/HookForwarder/HookRateLimiter") {}
88126
89127export class HookForwarder extends Context.Service<
......@@ -96,25 +134,69 @@ export class HookForwarder extends Context.Service<
96134>()("t3code-relay/hooks/HookForwarder") {}
97135
98136class HookBodyTooLarge extends Schema.TaggedError<HookBodyTooLarge>()("HookBodyTooLarge", {}) {}
99class ResponseTooLarge extends Schema.TaggedError<ResponseTooLarge>()("ResponseTooLarge", {}) {}
100137
101138const errorResponse = (status: number, error: string, headers?: Record<string, string>) =>
102139 HttpServerResponse.jsonUnsafe({ error }, { status, ...(headers ? { headers } : {}) });
103140
104141const hookNotFound = () => errorResponse(404, "hook_not_found");
105142
143/** Longer than the inbox holds a request, so a held delivery's proof still verifies. */
144const DELIVERY_PROOF_LIFETIME_SECONDS = 25 * 60 * 60;
145
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. */
175const FORWARDED_METHODS = new Set(["GET", "POST", "PUT", "PATCH"]);
176
177/**
178 * Whatever the environment answers is served from the relay's own origin, so
179 * a body must never render or run there.
180 */
181const SANDBOXED_RESPONSE_HEADERS = {
182 "x-content-type-options": "nosniff",
183 "content-security-policy": "sandbox; default-src 'none'",
184} as const;
185
106186function parseHookPath(url: string) {
107187 const queryIndex = url.indexOf("?");
108188 const path = queryIndex === -1 ? url : url.slice(0, queryIndex);
109189 const search = queryIndex === -1 ? "" : url.slice(queryIndex);
110190 const segments = path.split("/");
111 // ["", "v1", "hooks", environmentId, hookId, token]
191 // ["", "v1", "hooks", endpointKey, hookId, token]
112192 if (segments.length !== 6) return null;
113 const [, , , rawEnvironmentId, rawHookId, rawToken] = segments;
114 if (!rawEnvironmentId || !rawHookId || !rawToken) return null;
193 const [, , , endpointKey, rawHookId, rawToken] = segments;
194 if (!endpointKey || !MANAGED_ENDPOINT_KEY_PATTERN.test(endpointKey) || !rawHookId || !rawToken) {
195 return null;
196 }
115197 try {
116198 return {
117 environmentId: decodeURIComponent(rawEnvironmentId),
199 endpointKey,
118200 hookId: decodeURIComponent(rawHookId),
119201 token: decodeURIComponent(rawToken),
120202 // Forward the encoded segments byte-for-byte; the environment decodes them.
......@@ -156,20 +238,18 @@ function forwardedHeaders(headers: Readonly<Record<string, string>>): Record<str
156238const hasNoBody = (request: HttpServerRequest.HttpServerRequest) =>
157239 request.source instanceof Request && request.source.body === null;
158240
159/** Collects a byte stream, failing with `tooLarge` once it passes `maxBytes` rather than buffering it all. */
160const collectCapped = <E, R, E2>(
161 stream: Stream.Stream<Uint8Array, E, R>,
162 maxBytes: number,
163 tooLarge: () => E2,
164) =>
241const readCappedBody = (request: HttpServerRequest.HttpServerRequest) =>
165242 Effect.suspend(() => {
243 if (hasNoBody(request)) {
244 return Effect.succeed(new Uint8Array(0));
245 }
166246 const chunks: Array<Uint8Array> = [];
167247 let total = 0;
168 return stream.pipe(
248 return request.stream.pipe(
169249 Stream.runForEach((chunk) => {
170250 total += chunk.length;
171 if (total > maxBytes) {
172 return Effect.fail(tooLarge());
251 if (total > RELAY_HOOK_MAX_BODY_BYTES) {
252 return Effect.fail(new HookBodyTooLarge());
173253 }
174254 chunks.push(chunk);
175255 return Effect.void;
......@@ -186,19 +266,47 @@ const collectCapped = <E, R, E2>(
186266 );
187267 });
188268
189const readCappedBody = (request: HttpServerRequest.HttpServerRequest) =>
190 hasNoBody(request)
191 ? Effect.succeed(new Uint8Array(0))
192 : collectCapped(request.stream, RELAY_HOOK_MAX_BODY_BYTES, () => new HookBodyTooLarge());
193
194const readCappedResponse = (response: HttpClientResponse.HttpClientResponse) =>
195 collectCapped(response.stream, MAX_RESPONSE_BYTES, () => new ResponseTooLarge()).pipe(
196 // A response without a body, such as a redirect, has no stream at all.
197 Effect.catchIf(
198 (error) => error._tag === "HttpClientError" && error.reason._tag === "EmptyBodyError",
199 () => Effect.succeed(new Uint8Array(0)),
200 ),
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),
201284 );
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};
202310
203311const make = Effect.gen(function* () {
204312 const links = yield* EnvironmentLinks.EnvironmentLinks;
......@@ -206,39 +314,38 @@ const make = Effect.gen(function* () {
206314 const settings = yield* RelayConfiguration.RelayConfiguration;
207315 const httpClient = yield* HttpClient.HttpClient;
208316 const rateLimiter = yield* HookRateLimiter;
209
210 const resolveEndpoint = Effect.fn("relay.hooks.resolve_endpoint")(function* (
211 environmentId: string,
212 ) {
213 const candidates = yield* links.findActiveManagedForEnvironment({ environmentId });
214 for (const link of candidates) {
215 const allocation = yield* allocations.get({ userId: link.userId, environmentId });
216 const result = validateManagedEndpoint({
217 link,
218 allocation,
219 baseDomain: settings.managedEndpointBaseDomain,
220 });
221 if (Result.isSuccess(result)) {
222 return result.success;
223 }
224 }
225 return null;
226 });
317 const inbox = yield* HookInbox.HookInbox;
318 const crypto = yield* Crypto.Crypto;
227319
228320 const handle = Effect.fn("relay.hooks.forward")(function* (
229321 request: HttpServerRequest.HttpServerRequest,
230322 ) {
231323 const outcome = (value: string) => Effect.annotateCurrentSpan({ "relay.hook.outcome": value });
324 if (!FORWARDED_METHODS.has(request.method)) {
325 yield* outcome("method_not_allowed");
326 return errorResponse(405, "method_not_allowed", { allow: "GET, POST, PUT, PATCH" });
327 }
328 // When the sender called, not when the environment failed to answer.
329 const receivedAt = DateTime.formatIso(yield* DateTime.now);
232330 const parsed = parseHookPath(request.url);
233331 if (!parsed) {
234332 yield* outcome("invalid_path");
235333 return hookNotFound();
236334 }
237335 yield* Effect.annotateCurrentSpan({
238 "relay.environment_id": parsed.environmentId,
336 "relay.hook.endpoint_key": parsed.endpointKey,
239337 "relay.hook_id": parsed.hookId,
240338 });
241 if (!(yield* rateLimiter.allow(yield* hookBudgetKey(parsed)))) {
339 // A coarse budget per endpoint first, so minting new hook ids or tokens
340 // cannot buy unlimited lookups and forwards, or fill the inbox.
341 const endpointAllowed = yield* rateLimiter.allowEndpoint(parsed.endpointKey);
342 const hookAllowed =
343 endpointAllowed && (yield* rateLimiter.allowHook(yield* hookBudgetKey(parsed)));
344 if (!endpointAllowed || !hookAllowed) {
345 // Which budget ran out: the whole endpoint's, or this one hook URL's.
346 yield* Effect.annotateCurrentSpan({
347 "relay.hook.rate_limit": endpointAllowed ? "hook" : "endpoint",
348 });
242349 yield* outcome("rate_limited");
243350 return errorResponse(429, "rate_limited", {
244351 "retry-after": String(RELAY_HOOK_RATE_LIMIT.periodSeconds),
......@@ -250,10 +357,13 @@ const make = Effect.gen(function* () {
250357 return errorResponse(413, "payload_too_large");
251358 }
252359
253 const endpoint = yield* resolveEndpoint(parsed.environmentId).pipe(
360 const endpoint = yield* resolveHookEndpoint(parsed.endpointKey).pipe(
361 Effect.provideService(EnvironmentLinks.EnvironmentLinks, links),
362 Effect.provideService(ManagedEndpointAllocations.ManagedEndpointAllocations, allocations),
363 Effect.provideService(RelayConfiguration.RelayConfiguration, settings),
254364 Effect.catch((error) =>
255365 Effect.logWarning("Failed to resolve hook endpoint", {
256 environmentId: parsed.environmentId,
366 endpointKey: parsed.endpointKey,
257367 errorTag: error._tag,
258368 }).pipe(Effect.as(null)),
259369 ),
......@@ -262,6 +372,10 @@ const make = Effect.gen(function* () {
262372 yield* outcome("not_found");
263373 return hookNotFound();
264374 }
375 yield* Effect.annotateCurrentSpan({
376 "relay.environment_id": endpoint.environmentId,
377 "relay.hook.hold_while_offline": endpoint.holdWhileOffline,
378 });
265379
266380 const body =
267381 request.method === "GET"
......@@ -276,67 +390,101 @@ const make = Effect.gen(function* () {
276390 return errorResponse(400, "invalid_body");
277391 }
278392
279 const baseUrl = endpoint.httpBaseUrl.endsWith("/")
280 ? endpoint.httpBaseUrl
281 : `${endpoint.httpBaseUrl}/`;
282 const headers = forwardedHeaders(request.headers);
283 let upstreamRequest = HttpClientRequest.make(
284 request.method as "GET" | "POST" | "PUT" | "PATCH",
285 )(`${baseUrl}api/hooks/${parsed.rawHookId}/${parsed.rawToken}${parsed.search}`, { headers });
286 if (request.method !== "GET") {
287 upstreamRequest = HttpClientRequest.bodyUint8Array(
288 upstreamRequest,
289 body.success,
290 headers["content-type"],
291 );
292 }
393 // One id per request, so a request that reached the environment before a
394 // timeout and is later delivered from the inbox runs only once.
395 yield* Effect.annotateCurrentSpan({ "relay.hook.body_bytes": body.success.byteLength });
396 const deliveryId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
397 // Proves to the environment that this delivery id, receive time and
398 // trace context came from the relay. Signed once here and stored with a
399 // held request, so the inbox never needs the signing key.
400 const proof = yield* signDeliveryProof({
401 settings,
402 environmentId: endpoint.environmentId,
403 deliveryId,
404 receivedAt,
405 hookId: parsed.hookId,
406 jti: yield* crypto.randomUUIDv4.pipe(Effect.orDie),
407 });
408 const hook = {
409 id: deliveryId,
410 receivedAt,
411 method: request.method,
412 rawHookId: parsed.rawHookId,
413 rawToken: parsed.rawToken,
414 hookKey: parsed.hookId,
415 query: parsed.search.replace(/^\?/, ""),
416 headers: { ...forwardedHeaders(request.headers), [RELAY_HOOK_DELIVERY_HEADER]: proof },
417 body: body.success,
418 };
419 // Held only for environments that opted in; otherwise the relay is a plain proxy.
420 const holdOrFail = (status: 503 | 504, error: string) =>
421 Effect.gen(function* () {
422 if (!endpoint.holdWhileOffline) {
423 yield* outcome(error);
424 return errorResponse(status, error);
425 }
426 const stored = yield* inbox
427 .hold({
428 endpointKey: parsed.endpointKey,
429 baseUrl: endpoint.httpBaseUrl,
430 hook,
431 })
432 .pipe(
433 Effect.catch((cause) =>
434 Effect.logWarning("Could not hold webhook request", {
435 environmentId: endpoint.environmentId,
436 errorTag: cause._tag,
437 }).pipe(Effect.as(null)),
438 ),
439 );
440 if (stored === null) {
441 yield* outcome(error);
442 return errorResponse(status, error);
443 }
444 if (!stored) {
445 // The inbox span carries which cap refused it (relay.inbox.refused).
446 yield* outcome("inbox_full");
447 return errorResponse(503, "inbox_full");
448 }
449 yield* outcome("held");
450 return HttpServerResponse.jsonUnsafe({ queued: true }, { status: 202 });
451 });
293452
294 const upstream = yield* httpClient.execute(upstreamRequest).pipe(
295 Effect.flatMap((response) =>
296 readCappedResponse(response).pipe(
297 Effect.map((body) => ({
298 status: response.status,
299 contentType: response.headers["content-type"],
300 body,
301 })),
302 ),
303 ),
304 withoutRedirects,
305 // The client span would record url.full, which carries the token.
306 Effect.provideService(HttpClient.TracerDisabledWhen, () => true),
307 Effect.timeoutOption(Duration.millis(RELAY_HOOK_UPSTREAM_TIMEOUT_MS)),
453 const upstream = yield* sendUpstream(endpoint.httpBaseUrl, hook).pipe(
454 Effect.provideService(HttpClient.HttpClient, httpClient),
308455 Effect.result,
309456 );
310457 if (Result.isFailure(upstream)) {
311 yield* outcome("environment_unavailable");
312 return errorResponse(503, "environment_unavailable");
458 yield* Effect.annotateCurrentSpan({ "relay.hook.upstream_error": upstream.failure._tag });
459 return yield* holdOrFail(503, "environment_unavailable");
313460 }
314461 if (Option.isNone(upstream.success)) {
315 yield* outcome("environment_timeout");
316 return errorResponse(504, "environment_timeout");
462 return yield* holdOrFail(504, "environment_timeout");
317463 }
318464 const response = upstream.success.value;
319 if (response.status === TUNNEL_OFFLINE_STATUS) {
320 yield* outcome("environment_unavailable");
321 return errorResponse(503, "environment_unavailable");
465 if (ENVIRONMENT_UNREACHABLE_STATUSES.has(response.status)) {
466 yield* Effect.annotateCurrentSpan({ "relay.hook.upstream_status": response.status });
467 return yield* holdOrFail(503, "environment_unavailable");
322468 }
323469 yield* Effect.annotateCurrentSpan({
324470 "relay.hook.outcome": "forwarded",
325471 "relay.hook.upstream_status": response.status,
472 ...(response.outcome === undefined
473 ? {}
474 : { "relay.hook.upstream_outcome": response.outcome }),
326475 });
327476 // Only content-type is passed through: no location (redirects are never
328477 // followed or relayed), no cookies, no upstream infrastructure headers.
329 const contentTypeHeaders = response.contentType
330 ? { "content-type": response.contentType }
331 : undefined;
478 const headers = {
479 ...SANDBOXED_RESPONSE_HEADERS,
480 ...(response.contentType ? { "content-type": response.contentType } : {}),
481 };
332482 if (response.body.length === 0) {
333 return HttpServerResponse.empty({
334 status: response.status,
335 ...(contentTypeHeaders ? { headers: contentTypeHeaders } : {}),
336 });
483 return HttpServerResponse.empty({ status: response.status, headers });
337484 }
338485 return HttpServerResponse.uint8Array(response.body, {
339486 status: response.status,
487 headers,
340488 ...(response.contentType ? { contentType: response.contentType } : {}),
341489 });
342490 });
infra/relay/src/hooks/HookInbox.ts created+40
......@@ -0,0 +1,40 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4
5import type { HeldHook } from "./HookInboxStore.ts";
6
7export class HookInboxError extends Schema.TaggedError<HookInboxError>()("HookInboxError", {
8 operation: Schema.Literals(["hold", "wake", "clear"]),
9 endpointKey: Schema.String,
10 cause: Schema.Defect(),
11}) {
12 override get message(): string {
13 return `Hook inbox '${this.operation}' failed for endpoint '${this.endpointKey}'`;
14 }
15}
16
17/**
18 * Webhook requests held for environments that opted in, while they are
19 * offline. Each managed endpoint's requests live in its own `HookInboxObject`,
20 * named by endpoint key, which delivers them itself once the environment is back.
21 */
22export class HookInbox extends Context.Service<
23 HookInbox,
24 {
25 /** False when the environment's inbox is full and nothing was stored. */
26 readonly hold: (input: {
27 readonly endpointKey: string;
28 readonly baseUrl: string;
29 readonly hook: HeldHook;
30 }) => Effect.Effect<boolean, HookInboxError>;
31 /** Delivers what is waiting now; true when anything was waiting. */
32 readonly wake: (input: {
33 readonly endpointKey: string;
34 readonly baseUrl: string;
35 }) => Effect.Effect<boolean, HookInboxError>;
36 readonly clear: (input: {
37 readonly endpointKey: string;
38 }) => Effect.Effect<void, HookInboxError>;
39 }
40>()("t3code-relay/hooks/HookInbox") {}
infra/relay/src/hooks/HookInboxObject.ts created+154
......@@ -0,0 +1,154 @@
1import * as SqliteClient from "@effect/sql-sqlite-do/SqliteClient";
2import type * as Alchemy from "alchemy";
3import * as Cloudflare from "alchemy/Cloudflare";
4import * as Clock from "effect/Clock";
5import * as Effect from "effect/Effect";
6import * as Option from "effect/Option";
7import * as Result from "effect/Result";
8import * as FetchHttpClient from "effect/http/FetchHttpClient";
9
10import * as HookInboxStore from "./HookInboxStore.ts";
11import { sendUpstream, TUNNEL_OFFLINE_STATUS } from "./upstream.ts";
12
13/** Statuses that mean the environment is not there; the whole inbox waits and backs off. */
14const UNREACHABLE_STATUSES = new Set([502, 503, 504, TUNNEL_OFFLINE_STATUS]);
15/**
16 * Statuses that mean the environment is there but did not take this request
17 * yet: its task's queue is full (429) or it failed while handling it (500).
18 * The environment drops a delivery id it has already run, so retrying is safe.
19 */
20const BUSY_STATUSES = new Set([429, 500]);
21/** When a run itself fails, the next one is tried after this long. */
22const RUN_FAILURE_RETRY_MS = 60_000;
23
24type Call<A> = Effect.Effect<A, never, Alchemy.RuntimeContext>;
25
26export interface HookInboxObjectShape {
27 /** Holds a request for `baseUrl`; false when the inbox is full and nothing was stored. */
28 readonly hold: (hook: HookInboxStore.HeldHook, baseUrl: string) => Call<boolean>;
29 /** The environment is back at `baseUrl`: deliver what is waiting now. */
30 readonly wake: (baseUrl: string) => Call<boolean>;
31 readonly clear: () => Call<void>;
32}
33
34/**
35 * One per managed endpoint, addressed by endpoint key. Holds webhook requests
36 * the environment could not take, in SQLite, and pushes them back through
37 * its tunnel from the alarm, oldest first, backing off while it stays away.
38 */
39export class HookInboxObject extends Cloudflare.DurableObject<
40 HookInboxObject,
41 HookInboxObjectShape
42>()("HookInboxObject") {}
43
44/**
45 * Each call into an inbox is its own trace: the alarm has no parent, and a
46 * hold arrives over Durable Object RPC without the forwarding span. The
47 * object's name is the endpoint key, which the forward spans carry too.
48 */
49const withInboxSpan =
50 (name: string, inboxId: string) =>
51 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
52 effect.pipe(
53 Effect.withSpan(name, { root: true, attributes: { "relay.hook.endpoint_key": inboxId } }),
54 );
55
56const deliver = (baseUrl: string, hook: HookInboxStore.HeldHook) =>
57 sendUpstream(baseUrl, hook).pipe(
58 Effect.result,
59 Effect.map((result) => {
60 // Unreachable or timed out: a timeout may still have run it, and the
61 // environment drops a delivery id it has already seen.
62 if (Result.isFailure(result)) {
63 return { outcome: "unreachable" as const, reason: result.failure._tag };
64 }
65 if (Option.isNone(result.success))
66 return { outcome: "unreachable" as const, reason: "timeout" };
67 const status = result.success.value.status;
68 const upstreamOutcome = result.success.value.outcome;
69 const outcome: HookInboxStore.DeliveryOutcome = UNREACHABLE_STATUSES.has(status)
70 ? "unreachable"
71 : BUSY_STATUSES.has(status)
72 ? "busy"
73 : "delivered";
74 return { outcome, reason: `status ${status}`, upstreamOutcome };
75 }),
76 Effect.tap(({ upstreamOutcome }) =>
77 upstreamOutcome === undefined
78 ? Effect.void
79 : Effect.annotateCurrentSpan({ "relay.hook.upstream_outcome": upstreamOutcome }),
80 ),
81 Effect.tap(({ outcome, reason }) =>
82 outcome === "delivered"
83 ? Effect.void
84 : Effect.logInfo("Held webhook not delivered yet", {
85 outcome,
86 reason,
87 deliveryId: hook.id,
88 }),
89 ),
90 Effect.map(({ outcome }) => outcome),
91 Effect.provide(FetchHttpClient.layer),
92 );
93
94export const HookInboxObjectLive = HookInboxObject.make(
95 Effect.gen(function* () {
96 const state = yield* Cloudflare.DurableObjectState;
97 // The init phase returns the per-instance Effect, which alchemy runs once
98 // per object; only that inner Effect may touch storage.
99 // @effect-diagnostics-next-line returnEffectInGen:off
100 return Effect.gen(function* () {
101 const sql = SqliteClient.layer({ storage: state.raw.storage });
102 // Inboxes are opened by endpoint key, so spans line up with the
103 // forward spans that held their requests.
104 const inboxId = state.raw.id.name ?? state.raw.id.toString();
105 const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
106 effect.pipe(Effect.provide(sql), Effect.orDie);
107 yield* run(HookInboxStore.migrate);
108
109 /** Moves the alarm to `at`, unless one is already due sooner. */
110 const scheduleBy = (at: number) =>
111 Effect.gen(function* () {
112 const current = yield* state.storage.getAlarm();
113 if (current === null || current > at) yield* state.storage.setAlarm(at);
114 });
115
116 return {
117 hold: (hook: HookInboxStore.HeldHook, baseUrl: string) =>
118 Effect.gen(function* () {
119 const dueAt = yield* run(HookInboxStore.hold(hook, baseUrl));
120 yield* Effect.annotateCurrentSpan({ "relay.inbox.stored": dueAt !== null });
121 if (dueAt === null) return false;
122 yield* scheduleBy(dueAt);
123 return true;
124 }).pipe(withInboxSpan("relay.inbox.hold", inboxId)),
125 wake: (baseUrl: string) =>
126 Effect.gen(function* () {
127 const pending = yield* run(HookInboxStore.wake(baseUrl));
128 yield* Effect.annotateCurrentSpan({ "relay.inbox.pending": pending });
129 if (pending) yield* state.storage.setAlarm(yield* Clock.currentTimeMillis);
130 return pending;
131 }).pipe(withInboxSpan("relay.inbox.wake", inboxId)),
132 clear: () =>
133 Effect.gen(function* () {
134 yield* run(HookInboxStore.clear);
135 yield* state.storage.deleteAlarm();
136 }).pipe(withInboxSpan("relay.inbox.clear", inboxId)),
137 alarm: () =>
138 run(HookInboxStore.deliverDue(deliver)).pipe(
139 // A wake during this run may already have asked for an earlier
140 // run; a backoff must not push it out.
141 Effect.flatMap((nextAt) => (nextAt === null ? Effect.void : scheduleBy(nextAt))),
142 Effect.catchCause((cause) =>
143 Effect.logWarning("Held webhook delivery run failed", { cause }).pipe(
144 Effect.andThen(Effect.annotateCurrentSpan({ "relay.inbox.run_result": "failed" })),
145 Effect.andThen(Clock.currentTimeMillis),
146 Effect.flatMap((now) => scheduleBy(now + RUN_FAILURE_RETRY_MS)),
147 ),
148 ),
149 withInboxSpan("relay.inbox.deliver", inboxId),
150 ),
151 };
152 });
153 }),
154);
infra/relay/src/hooks/HookInboxStore.test.ts created+260
......@@ -0,0 +1,260 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
3import * as Clock from "effect/Clock";
4import * as DateTime from "effect/DateTime";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as SqlClient from "effect/sql/SqlClient";
8import * as TestClock from "effect/testing/TestClock";
9
10import * as HookInboxStore from "./HookInboxStore.ts";
11
12const BASE_URL = "https://env.example.test/";
13
14const hook = (id: string, overrides: Partial<HookInboxStore.HeldHook> = {}) =>
15 Effect.map(Clock.currentTimeMillis, (now): HookInboxStore.HeldHook => ({
16 id,
17 receivedAt: DateTime.formatIso(DateTime.makeUnsafe(now)),
18 method: "POST",
19 rawHookId: "hook-1",
20 hookKey: "hook-1",
21 rawToken: "tok%2Fen",
22 query: "a=1",
23 headers: { "content-type": "application/json", "x-sig": "s" },
24 body: new Uint8Array([0, 255, 10]),
25 ...overrides,
26 }));
27
28/** Delivers with `outcome` per request and records what the environment was sent. */
29const deliverer = (outcome: (hook: HookInboxStore.HeldHook) => HookInboxStore.DeliveryOutcome) => {
30 const sent: Array<{ readonly baseUrl: string; readonly hook: HookInboxStore.HeldHook }> = [];
31 const send = (baseUrl: string, held: HookInboxStore.HeldHook) =>
32 Effect.sync(() => {
33 sent.push({ baseUrl, hook: held });
34 return outcome(held);
35 });
36 return { sent, send };
37};
38
39const withInbox = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
40 HookInboxStore.migrate.pipe(
41 Effect.andThen(effect),
42 Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" })),
43 );
44
45describe("HookInboxStore", () => {
46 it.effect("delivers held requests oldest first, byte for byte", () =>
47 withInbox(
48 Effect.gen(function* () {
49 const first = yield* hook("first");
50 expect(yield* HookInboxStore.hold(first, BASE_URL)).not.toBeNull();
51 yield* HookInboxStore.hold(yield* hook("second"), BASE_URL);
52 const { sent, send } = deliverer(() => "delivered");
53 expect(yield* HookInboxStore.deliverDue(send)).toBeNull();
54 expect(sent.map((entry) => entry.hook.id)).toEqual(["first", "second"]);
55 expect(sent[0]).toEqual({ baseUrl: BASE_URL, hook: first });
56 // Delivered requests are gone.
57 expect(yield* HookInboxStore.deliverDue(send)).toBeNull();
58 expect(sent).toHaveLength(2);
59 }),
60 ),
61 );
62
63 it.effect("keeps a request the environment did not take and backs off", () =>
64 withInbox(
65 Effect.gen(function* () {
66 yield* HookInboxStore.hold(yield* hook("first"), BASE_URL);
67 yield* HookInboxStore.hold(yield* hook("second"), BASE_URL);
68 const offline = deliverer(() => "unreachable");
69 const now = yield* Clock.currentTimeMillis;
70 // Stops at the first failure, so order is kept.
71 expect(yield* HookInboxStore.deliverDue(offline.send)).toBe(now + 10_000);
72 expect(offline.sent.map((entry) => entry.hook.id)).toEqual(["first"]);
73 expect(yield* HookInboxStore.deliverDue(offline.send)).toBe(now + 10_000);
74
75 const online = deliverer(() => "delivered");
76 expect(yield* HookInboxStore.deliverDue(online.send)).toBeNull();
77 expect(online.sent.map((entry) => entry.hook.id)).toEqual(["first", "second"]);
78 }),
79 ),
80 );
81
82 it.effect("lets other hooks through while one hook's environment is busy", () =>
83 withInbox(
84 Effect.gen(function* () {
85 const stuck = { rawHookId: "stuck", hookKey: "stuck" };
86 yield* HookInboxStore.hold(yield* hook("stuck-1", stuck), BASE_URL);
87 yield* HookInboxStore.hold(yield* hook("other-1"), BASE_URL);
88 yield* HookInboxStore.hold(yield* hook("stuck-2", stuck), BASE_URL);
89 const busy = deliverer((held) => (held.hookKey === "stuck" ? "busy" : "delivered"));
90 const now = yield* Clock.currentTimeMillis;
91 // The other hook is delivered; the busy hook keeps its order and runs again soon.
92 expect(yield* HookInboxStore.deliverDue(busy.send)).toBe(now);
93 expect(busy.sent.map((entry) => entry.hook.id)).toEqual(["stuck-1", "other-1"]);
94 // Only the busy hook is left, so the next run waits rather than spinning.
95 expect(yield* HookInboxStore.deliverDue(busy.send)).toBe(now + 30_000);
96
97 const drained = deliverer(() => "delivered");
98 expect(yield* HookInboxStore.deliverDue(drained.send)).toBeNull();
99 expect(drained.sent.map((entry) => entry.hook.id)).toEqual(["stuck-1", "stuck-2"]);
100 }),
101 ),
102 );
103
104 it.effect("reaches a hook queued behind busy hooks' full backlogs in the same run", () =>
105 withInbox(
106 Effect.gen(function* () {
107 for (const name of ["stuck-a", "stuck-b"]) {
108 const stuck = { rawHookId: name, hookKey: name };
109 for (let index = 0; index < HookInboxStore.HOOK_INBOX_MAX_PER_HOOK; index++) {
110 yield* HookInboxStore.hold(yield* hook(`${name}-${index}`, stuck), BASE_URL);
111 }
112 }
113 yield* HookInboxStore.hold(yield* hook("other-1"), BASE_URL);
114 const busy = deliverer((held) => (held.hookKey.startsWith("stuck") ? "busy" : "delivered"));
115 yield* HookInboxStore.deliverDue(busy.send);
116 expect(busy.sent.map((entry) => entry.hook.id)).toEqual([
117 "stuck-a-0",
118 "stuck-b-0",
119 "other-1",
120 ]);
121 }),
122 ),
123 );
124
125 it.effect("drops a held request it cannot read instead of stalling on it", () =>
126 withInbox(
127 Effect.gen(function* () {
128 yield* HookInboxStore.hold(yield* hook("broken"), BASE_URL);
129 yield* HookInboxStore.hold(yield* hook("fine"), BASE_URL);
130 const sql = yield* SqlClient.SqlClient;
131 yield* sql`UPDATE held_hooks SET headers = 'not json' WHERE id = 'broken'`;
132 const { sent, send } = deliverer(() => "delivered");
133 expect(yield* HookInboxStore.deliverDue(send)).toBeNull();
134 expect(sent.map((entry) => entry.hook.id)).toEqual(["fine"]);
135 }),
136 ),
137 );
138
139 it.effect("retries every 10 s for 3 minutes, then backs off to 10 minutes", () =>
140 Effect.gen(function* () {
141 const delays = yield* Effect.forEach(
142 [1, 18, 19, 20, 21, 23, 24, 40],
143 HookInboxStore.retryDelayMs,
144 );
145 expect(delays).toEqual([10_000, 10_000, 30_000, 60_000, 120_000, 480_000, 600_000, 600_000]);
146 }),
147 );
148
149 it.effect("waking resets the backoff and reports whether anything waits", () =>
150 withInbox(
151 Effect.gen(function* () {
152 expect(yield* HookInboxStore.wake(BASE_URL)).toBe(false);
153 yield* HookInboxStore.hold(yield* hook("first"), "https://old.example.test/");
154 const offline = deliverer(() => "unreachable");
155 yield* HookInboxStore.deliverDue(offline.send);
156 yield* HookInboxStore.deliverDue(offline.send);
157
158 // Past the fast phase: the environment was away a while.
159 for (let failure = 0; failure < 20; failure++) {
160 yield* HookInboxStore.deliverDue(offline.send);
161 }
162 const now = yield* Clock.currentTimeMillis;
163 expect(yield* HookInboxStore.deliverDue(offline.send)).toBeGreaterThan(now + 60_000);
164
165 // A wake means its tunnel just connected, which Cloudflare may not
166 // route to for a few minutes: back to retrying every 10 s, sent to
167 // where the environment is now.
168 expect(yield* HookInboxStore.wake(BASE_URL)).toBe(true);
169 expect(yield* HookInboxStore.deliverDue(offline.send)).toBe(now + 10_000);
170 expect(offline.sent.at(-1)?.baseUrl).toBe(BASE_URL);
171 }),
172 ),
173 );
174
175 it.effect("starts the schedule over once the inbox drains", () =>
176 withInbox(
177 Effect.gen(function* () {
178 yield* HookInboxStore.hold(yield* hook("old"), BASE_URL);
179 const offline = deliverer(() => "unreachable");
180 for (let failure = 0; failure < 30; failure++) {
181 yield* HookInboxStore.deliverDue(offline.send);
182 }
183 // Nothing got through before the request expired.
184 yield* TestClock.adjust(Duration.hours(25));
185 expect(yield* HookInboxStore.deliverDue(offline.send)).toBeNull();
186
187 const now = yield* Clock.currentTimeMillis;
188 expect(yield* HookInboxStore.hold(yield* hook("new"), BASE_URL)).toBe(now + 10_000);
189 }),
190 ),
191 );
192
193 it.effect("drops requests older than 24 hours", () =>
194 withInbox(
195 Effect.gen(function* () {
196 yield* HookInboxStore.hold(yield* hook("old"), BASE_URL);
197 yield* TestClock.adjust(Duration.hours(1));
198 yield* HookInboxStore.hold(yield* hook("new"), BASE_URL);
199 yield* TestClock.adjust(Duration.minutes(23 * 60 + 1));
200 const { sent, send } = deliverer(() => "delivered");
201 yield* HookInboxStore.deliverDue(send);
202 expect(sent.map((entry) => entry.hook.id)).toEqual(["new"]);
203 }),
204 ),
205 );
206
207 it.effect("refuses requests past the per-hook cap", () =>
208 withInbox(
209 Effect.gen(function* () {
210 for (let index = 0; index < HookInboxStore.HOOK_INBOX_MAX_PER_HOOK; index++) {
211 expect(yield* HookInboxStore.hold(yield* hook(`a-${index}`), BASE_URL)).not.toBeNull();
212 }
213 expect(yield* HookInboxStore.hold(yield* hook("one-too-many"), BASE_URL)).toBeNull();
214 // Another hook still has room.
215 const other = yield* hook("other", { rawHookId: "hook-2", hookKey: "hook-2" });
216 expect(yield* HookInboxStore.hold(other, BASE_URL)).not.toBeNull();
217 }),
218 ),
219 );
220
221 it.effect("refuses a request that would pass the byte cap", () =>
222 withInbox(
223 Effect.gen(function* () {
224 const big = new Uint8Array(HookInboxStore.HOOK_INBOX_MAX_BYTES - 10);
225 expect(
226 yield* HookInboxStore.hold(yield* hook("big", { body: big }), BASE_URL),
227 ).not.toBeNull();
228 const small = new Uint8Array(11);
229 expect(
230 yield* HookInboxStore.hold(
231 yield* hook("small", { rawHookId: "x", hookKey: "x", body: small }),
232 BASE_URL,
233 ),
234 ).toBeNull();
235 }),
236 ),
237 );
238
239 it.effect("stores a request id once", () =>
240 withInbox(
241 Effect.gen(function* () {
242 const first = yield* hook("same");
243 expect(yield* HookInboxStore.hold(first, BASE_URL)).not.toBeNull();
244 expect(yield* HookInboxStore.hold(first, BASE_URL)).toBeNull();
245 }),
246 ),
247 );
248
249 it.effect("clear drops everything held", () =>
250 withInbox(
251 Effect.gen(function* () {
252 yield* HookInboxStore.hold(yield* hook("first"), BASE_URL);
253 yield* HookInboxStore.clear;
254 const { sent, send } = deliverer(() => "delivered");
255 expect(yield* HookInboxStore.deliverDue(send)).toBeNull();
256 expect(sent).toHaveLength(0);
257 }),
258 ),
259 );
260});
infra/relay/src/hooks/HookInboxStore.ts created+380
......@@ -0,0 +1,380 @@
1import * as Clock from "effect/Clock";
2import * as DateTime from "effect/DateTime";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Pull from "effect/Pull";
7import * as Schedule from "effect/Schedule";
8import * as Schema from "effect/Schema";
9import * as SqlClient from "effect/sql/SqlClient";
10
11/**
12 * The storage and delivery rules of one environment's held webhook requests.
13 * Runs inside the environment's `HookInboxObject` Durable Object against its SQLite
14 * storage; written against `SqlClient` so tests can run it on any SQLite.
15 */
16
17/** How long a held request waits for its environment. */
18const HOOK_INBOX_TTL_MS = 24 * 60 * 60 * 1000;
19/** Requests one environment may have waiting at once. */
20const HOOK_INBOX_MAX_REQUESTS = 1_000;
21/** Body bytes one environment may have waiting at once. */
22export const HOOK_INBOX_MAX_BYTES = 50 * 1_048_576;
23/**
24 * Requests one hook may have waiting at once. The relay cannot check tokens,
25 * so this keeps junk sent to one hook id from crowding out the others.
26 */
27export const HOOK_INBOX_MAX_PER_HOOK = 100;
28/** Requests pushed per alarm run; the next run starts right away while more wait. */
29const DELIVERIES_PER_RUN = 20;
30/** Requests read at a time; a run keeps reading past busy hooks' backlogs. */
31const ROWS_READ_PER_PAGE = 50;
32/** Wait before trying a hook whose environment answered busy again. */
33const BUSY_RETRY_MS = 30_000;
34const MAX_RETRY_DELAY = Duration.minutes(10);
35/** Past this many failures the schedule is at its cap, so stepping further changes nothing. */
36const MAX_COUNTED_FAILURES = 25;
37
38/**
39 * Delays between delivery attempts while the environment is unreachable,
40 * indexed by consecutive failures since it was last reached or woke us.
41 * A wake means its tunnel just connected, but Cloudflare can take a few
42 * minutes to route the hostname to it, so the first 3 minutes retry every
43 * 10 s. After that the environment is likely gone again: 30 s, 1 min, 2 min,
44 * ... up to 10 min.
45 */
46export const retrySchedule = Schedule.spaced("10 seconds").pipe(
47 Schedule.upTo({ times: 18 }),
48 Schedule.concat(
49 Schedule.exponential("30 seconds").pipe(
50 Schedule.modifyDelay(({ duration }) =>
51 Effect.succeed(Duration.min(Duration.fromInputUnsafe(duration), MAX_RETRY_DELAY)),
52 ),
53 ),
54 ),
55);
56
57export interface HeldHook {
58 readonly id: string;
59 readonly receivedAt: string;
60 readonly method: string;
61 /** Path segments exactly as the sender sent them; the environment decodes them. */
62 readonly rawHookId: string;
63 readonly rawToken: string;
64 /** The decoded hook id, so every spelling of one hook shares its cap. */
65 readonly hookKey: string;
66 /** Without the leading `?`. */
67 readonly query: string;
68 readonly headers: Readonly<Record<string, string>>;
69 readonly body: Uint8Array;
70}
71
72/**
73 * `delivered` deletes the request. `unreachable` keeps it and backs off the
74 * whole inbox, since nothing else will get through either. `busy` keeps it
75 * and its hook's later requests for the next run, but lets other hooks'
76 * requests go ahead, so one stuck task cannot hold up the rest.
77 */
78export type DeliveryOutcome = "delivered" | "busy" | "unreachable";
79
80/** The `retrySchedule` delay after `failures` consecutive unreachable attempts. */
81export const retryDelayMs = Effect.fn("HookInboxStore.retryDelayMs")(function* (failures: number) {
82 const step = yield* Schedule.toStep(retrySchedule);
83 let delay = MAX_RETRY_DELAY;
84 for (
85 let attempt = 0;
86 attempt < Math.min(Math.max(1, failures), MAX_COUNTED_FAILURES);
87 attempt++
88 ) {
89 const next = yield* step(0, undefined).pipe(
90 Effect.map(([, duration]) => Option.some(duration)),
91 // The schedule never ends; stay at the cap if it ever does.
92 Pull.catchDone(() => Effect.succeedNone),
93 );
94 if (Option.isNone(next)) break;
95 delay = next.value;
96 }
97 return Duration.toMillis(delay);
98});
99
100const HeadersJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String));
101const encodeHeaders = Schema.encodeSync(HeadersJson);
102const decodeHeaders = Schema.decodeUnknownOption(HeadersJson);
103
104interface HeldHookRow {
105 readonly seq: number;
106 readonly id: string;
107 readonly received_at: string;
108 readonly method: string;
109 readonly raw_hook_id: string;
110 readonly raw_token: string;
111 readonly hook_key: string;
112 readonly query: string;
113 readonly headers: string;
114 readonly body: Uint8Array;
115}
116
117interface TargetRow {
118 readonly base_url: string;
119 readonly failures: number;
120}
121
122const iso = (epochMillis: number) => DateTime.formatIso(DateTime.makeUnsafe(epochMillis));
123
124export const migrate = Effect.gen(function* () {
125 const sql = yield* SqlClient.SqlClient;
126 yield* sql`
127 CREATE TABLE IF NOT EXISTS held_hooks (
128 seq INTEGER PRIMARY KEY AUTOINCREMENT,
129 id TEXT NOT NULL UNIQUE,
130 received_at TEXT NOT NULL,
131 method TEXT NOT NULL,
132 raw_hook_id TEXT NOT NULL,
133 raw_token TEXT NOT NULL,
134 hook_key TEXT NOT NULL,
135 query TEXT NOT NULL,
136 headers TEXT NOT NULL,
137 body BLOB NOT NULL
138 )
139 `;
140 yield* sql`CREATE INDEX IF NOT EXISTS held_hooks_hook ON held_hooks (hook_key)`;
141 // Where to push, and how many attempts in a row have failed. One row.
142 yield* sql`
143 CREATE TABLE IF NOT EXISTS held_hooks_target (
144 id INTEGER PRIMARY KEY CHECK (id = 1),
145 base_url TEXT NOT NULL,
146 failures INTEGER NOT NULL DEFAULT 0
147 )
148 `;
149});
150
151const setTarget = (baseUrl: string, options: { readonly resetFailures: boolean }) =>
152 Effect.gen(function* () {
153 const sql = yield* SqlClient.SqlClient;
154 yield* options.resetFailures
155 ? sql`
156 INSERT INTO held_hooks_target (id, base_url) VALUES (1, ${baseUrl})
157 ON CONFLICT (id) DO UPDATE SET base_url = excluded.base_url, failures = 0
158 `
159 : sql`
160 INSERT INTO held_hooks_target (id, base_url) VALUES (1, ${baseUrl})
161 ON CONFLICT (id) DO UPDATE SET base_url = excluded.base_url
162 `;
163 });
164
165const readTarget = Effect.gen(function* () {
166 const sql = yield* SqlClient.SqlClient;
167 const rows = yield* sql<TargetRow>`SELECT base_url, failures FROM held_hooks_target WHERE id = 1`;
168 return rows[0] ?? null;
169});
170
171const resetFailures = Effect.gen(function* () {
172 const sql = yield* SqlClient.SqlClient;
173 yield* sql`UPDATE held_hooks_target SET failures = 0 WHERE id = 1`;
174});
175
176/**
177 * Counts one more unreachable attempt. Incremented in place, so a wake that
178 * reset the count while this run's request was in flight is not overwritten.
179 */
180const countFailure = Effect.gen(function* () {
181 const sql = yield* SqlClient.SqlClient;
182 const rows = yield* sql<{ readonly failures: number }>`
183 UPDATE held_hooks_target SET failures = min(failures + 1, ${MAX_COUNTED_FAILURES})
184 WHERE id = 1 RETURNING failures
185 `;
186 return rows[0]?.failures ?? 1;
187});
188
189const hasPending = Effect.gen(function* () {
190 const sql = yield* SqlClient.SqlClient;
191 const rows = yield* sql<{ readonly id: string }>`SELECT id FROM held_hooks LIMIT 1`;
192 return rows.length > 0;
193});
194
195/**
196 * Stores a request for later delivery to `baseUrl`. Returns the time the
197 * first attempt is due, or null when the inbox is full and nothing was stored.
198 */
199export const hold = Effect.fn("HookInboxStore.hold")(function* (hook: HeldHook, baseUrl: string) {
200 const sql = yield* SqlClient.SqlClient;
201 // One statement, so the caps hold however many requests arrive at once.
202 const inserted = yield* sql<{ readonly id: string }>`
203 INSERT INTO held_hooks
204 (id, received_at, method, raw_hook_id, raw_token, hook_key, query, headers, body)
205 SELECT ${hook.id}, ${hook.receivedAt}, ${hook.method}, ${hook.rawHookId}, ${hook.rawToken},
206 ${hook.hookKey}, ${hook.query}, ${encodeHeaders(hook.headers)}, ${hook.body}
207 WHERE (SELECT count(*) FROM held_hooks) < ${HOOK_INBOX_MAX_REQUESTS}
208 AND (SELECT count(*) FROM held_hooks WHERE hook_key = ${hook.hookKey})
209 < ${HOOK_INBOX_MAX_PER_HOOK}
210 AND (SELECT coalesce(sum(length(body)), 0) FROM held_hooks) + ${hook.body.byteLength}
211 <= ${HOOK_INBOX_MAX_BYTES}
212 ON CONFLICT (id) DO NOTHING
213 RETURNING id
214 `;
215 if (inserted.length === 0) {
216 yield* Effect.annotateCurrentSpan({ "relay.inbox.refused": yield* refusalReason(hook) });
217 return null;
218 }
219 yield* setTarget(baseUrl, { resetFailures: false });
220 const target = yield* readTarget;
221 const backlog = yield* backlogSize;
222 yield* Effect.annotateCurrentSpan({
223 "relay.inbox.held_count": backlog.count,
224 "relay.inbox.held_bytes": backlog.bytes,
225 });
226 return (yield* Clock.currentTimeMillis) + (yield* retryDelayMs(target?.failures ?? 0));
227});
228
229const backlogSize = Effect.gen(function* () {
230 const sql = yield* SqlClient.SqlClient;
231 const rows = yield* sql<{ readonly count: number; readonly bytes: number }>`
232 SELECT count(*) AS count, coalesce(sum(length(body)), 0) AS bytes FROM held_hooks
233 `;
234 return rows[0] ?? { count: 0, bytes: 0 };
235});
236
237/** Which cap refused a request, or that it was already held, for traces. */
238const refusalReason = (hook: HeldHook) =>
239 Effect.gen(function* () {
240 const sql = yield* SqlClient.SqlClient;
241 const existing = yield* sql<{ readonly id: string }>`
242 SELECT id FROM held_hooks WHERE id = ${hook.id}
243 `;
244 if (existing.length > 0) return "already_held";
245 const backlog = yield* backlogSize;
246 if (backlog.count >= HOOK_INBOX_MAX_REQUESTS) return "max_requests";
247 if (backlog.bytes + hook.body.byteLength > HOOK_INBOX_MAX_BYTES) return "max_bytes";
248 return "max_per_hook";
249 });
250
251/**
252 * The environment is reachable again at `baseUrl`. Returns whether anything
253 * is waiting, so the caller can deliver right away.
254 */
255export const wake = Effect.fn("HookInboxStore.wake")(function* (baseUrl: string) {
256 if (!(yield* hasPending)) return false;
257 yield* setTarget(baseUrl, { resetFailures: true });
258 return true;
259});
260
261export const clear = Effect.gen(function* () {
262 const sql = yield* SqlClient.SqlClient;
263 yield* sql`DELETE FROM held_hooks`;
264 yield* sql`DELETE FROM held_hooks_target`;
265});
266
267/**
268 * Pushes the oldest held requests to the environment, one at a time, in
269 * order per hook. Stops at the first sign the environment is unreachable;
270 * skips past a hook whose environment answered busy. Drops requests older
271 * than the TTL. Returns when the next run is due, or null when nothing is left.
272 */
273export const deliverDue = Effect.fn("HookInboxStore.deliverDue")(function* <R>(
274 send: (baseUrl: string, hook: HeldHook) => Effect.Effect<DeliveryOutcome, never, R>,
275) {
276 const sql = yield* SqlClient.SqlClient;
277 const startedAt = yield* Clock.currentTimeMillis;
278 const expired = yield* sql<{ readonly id: string }>`
279 DELETE FROM held_hooks WHERE received_at < ${iso(startedAt - HOOK_INBOX_TTL_MS)}
280 RETURNING id
281 `;
282 const target = yield* readTarget;
283 /** Oldest held requests after `afterSeq`, skipping hooks already found busy. */
284 const readPage = (afterSeq: number, skip: ReadonlySet<string>) =>
285 sql<HeldHookRow>`
286 SELECT seq, id, received_at, method, raw_hook_id, raw_token, hook_key, query, headers, body
287 FROM held_hooks
288 WHERE seq > ${afterSeq}
289 ${skip.size === 0 ? sql`` : sql`AND hook_key NOT IN ${sql.in([...skip])}`}
290 ORDER BY seq LIMIT ${ROWS_READ_PER_PAGE}
291 `;
292 let batch = yield* readPage(0, new Set());
293 if (batch.length === 0 || target === null) {
294 if (target === null) yield* sql`DELETE FROM held_hooks`;
295 // Empty, so the next request held starts the schedule from the top.
296 else yield* resetFailures;
297 yield* Effect.annotateCurrentSpan({ "relay.inbox.expired": expired.length });
298 return null;
299 }
300
301 let failures = target.failures;
302 let sent = 0;
303 let delivered = 0;
304 let unreadable = 0;
305 let longestWaitMs = 0;
306 const busyHooks = new Set<string>();
307 /** What this run did, for the alarm's span; never request contents. */
308 const annotateRun = (result: string) =>
309 Effect.gen(function* () {
310 const backlog = yield* backlogSize;
311 yield* Effect.annotateCurrentSpan({
312 "relay.inbox.run_result": result,
313 "relay.inbox.sent": sent,
314 "relay.inbox.delivered": delivered,
315 "relay.inbox.busy_hooks": busyHooks.size,
316 "relay.inbox.expired": expired.length,
317 "relay.inbox.unreadable": unreadable,
318 "relay.inbox.consecutive_failures": failures,
319 "relay.inbox.longest_wait_ms": longestWaitMs,
320 "relay.inbox.held_count": backlog.count,
321 "relay.inbox.held_bytes": backlog.bytes,
322 });
323 });
324 // Pages run out only once every non-busy request has been tried, so a
325 // backlog behind busy hooks never hides another hook's requests.
326 pages: while (batch.length > 0) {
327 for (const row of batch) {
328 if (sent === DELIVERIES_PER_RUN) break pages;
329 // Later requests to a busy hook wait their turn, so its order is kept.
330 if (busyHooks.has(row.hook_key)) continue;
331 const headers = decodeHeaders(row.headers);
332 if (Option.isNone(headers)) {
333 // Unreadable: it can never be delivered, and must not block the rest.
334 yield* sql`DELETE FROM held_hooks WHERE id = ${row.id}`;
335 unreadable += 1;
336 continue;
337 }
338 sent += 1;
339 const outcome = yield* send(target.base_url, {
340 id: row.id,
341 receivedAt: row.received_at,
342 method: row.method,
343 rawHookId: row.raw_hook_id,
344 rawToken: row.raw_token,
345 hookKey: row.hook_key,
346 query: row.query,
347 headers: headers.value,
348 body: row.body,
349 });
350 if (outcome === "unreachable") {
351 failures = yield* countFailure;
352 yield* annotateRun("unreachable");
353 return (yield* Clock.currentTimeMillis) + (yield* retryDelayMs(failures));
354 }
355 if (failures !== 0) {
356 failures = 0;
357 yield* resetFailures;
358 }
359 if (outcome === "busy") {
360 busyHooks.add(row.hook_key);
361 continue;
362 }
363 yield* sql`DELETE FROM held_hooks WHERE id = ${row.id}`;
364 delivered += 1;
365 const receivedAtMs = DateTime.toEpochMillis(DateTime.makeUnsafe(row.received_at));
366 longestWaitMs = Math.max(longestWaitMs, startedAt - receivedAtMs);
367 }
368 batch = yield* readPage(batch.at(-1)!.seq, busyHooks);
369 }
370 if (!(yield* hasPending)) {
371 yield* resetFailures;
372 yield* annotateRun("drained");
373 return null;
374 }
375 // Everything left this run was busy: give those tasks a moment to drain.
376 const now = yield* Clock.currentTimeMillis;
377 const idle = delivered === 0 && busyHooks.size > 0;
378 yield* annotateRun(idle ? "busy" : "more_pending");
379 return idle ? now + BUSY_RETRY_MS : now;
380});
infra/relay/src/hooks/upstream.ts created+125
......@@ -0,0 +1,125 @@
1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5import * as Stream from "effect/Stream";
6import * as HttpClient from "effect/http/HttpClient";
7import * as HttpClientRequest from "effect/http/HttpClientRequest";
8import * as HttpMethod from "effect/http/HttpMethod";
9import * as HttpTraceContext from "effect/http/HttpTraceContext";
10import type * as HttpClientResponse from "effect/http/HttpClientResponse";
11
12import { withoutRedirects } from "../environments/EnvironmentConnector.ts";
13
14/** Set by the relay on every forward; the environment uses it as the delivery id. */
15const RELAY_DELIVERY_ID_HEADER = "x-t3-relay-delivery-id";
16/** When the relay received the request; the environment trusts it only with the delivery id. */
17const RELAY_RECEIVED_AT_HEADER = "x-t3-relay-received-at";
18/** The environment answers with a small JSON status; anything past this is cut off. */
19const MAX_RESPONSE_BYTES = 64 * 1024;
20export const RELAY_HOOK_UPSTREAM_TIMEOUT_MS = 8_000;
21// Cloudflare answers 530 when the tunnel for a hostname has no connected origin.
22export const TUNNEL_OFFLINE_STATUS = 530;
23
24/** A webhook request as the relay sends it on to the environment. */
25export interface UpstreamHook {
26 readonly id: string;
27 readonly receivedAt: string;
28 readonly method: string;
29 /** Path segments exactly as the sender sent them; the environment decodes them. */
30 readonly rawHookId: string;
31 readonly rawToken: string;
32 /** Without the leading `?`. */
33 readonly query: string;
34 readonly headers: Readonly<Record<string, string>>;
35 readonly body: Uint8Array;
36}
37
38export interface UpstreamResponse {
39 readonly status: number;
40 readonly contentType: string | undefined;
41 /** What the environment did with the request (`x-t3-hook-outcome`). */
42 readonly outcome: string | undefined;
43 readonly body: Uint8Array;
44}
45
46/** Outcome names are short identifiers; anything else is not recorded. */
47const OUTCOME_PATTERN = /^[a-z_]{1,32}$/;
48
49class ResponseTooLarge extends Schema.TaggedError<ResponseTooLarge>()("ResponseTooLarge", {}) {}
50
51/** Reads a response body, failing once it passes the cap rather than buffering it all. */
52const readCapped = (response: HttpClientResponse.HttpClientResponse) =>
53 Effect.suspend(() => {
54 const chunks: Array<Uint8Array> = [];
55 let total = 0;
56 return response.stream.pipe(
57 Stream.runForEach((chunk) => {
58 total += chunk.length;
59 if (total > MAX_RESPONSE_BYTES) return Effect.fail(new ResponseTooLarge());
60 chunks.push(chunk);
61 return Effect.void;
62 }),
63 Effect.map(() => {
64 const body = new Uint8Array(total);
65 let offset = 0;
66 for (const chunk of chunks) {
67 body.set(chunk, offset);
68 offset += chunk.length;
69 }
70 return body;
71 }),
72 // A response without a body, such as a redirect, has no stream at all.
73 Effect.catchIf(
74 (error) => error._tag === "HttpClientError" && error.reason._tag === "EmptyBodyError",
75 () => Effect.succeed(new Uint8Array(0)),
76 ),
77 );
78 });
79
80/**
81 * Sends a webhook request through the environment's tunnel. Fails when the
82 * environment cannot be reached, and succeeds with None on timeout.
83 */
84export const sendUpstream = (baseUrl: string, hook: UpstreamHook) =>
85 Effect.gen(function* () {
86 const httpClient = yield* HttpClient.HttpClient;
87 const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
88 // The environment's span joins this trace. Set by hand: the client span
89 // that would propagate it is off, because it records the token in url.full.
90 const parent = yield* Effect.currentSpan.pipe(Effect.option);
91 // `hook.headers` carries the signed delivery proof, set once when the
92 // relay received the request, so a held request sends the same proof.
93 const headers: Record<string, string> = {
94 ...hook.headers,
95 ...(Option.isSome(parent) ? HttpTraceContext.toHeaders(parent.value) : {}),
96 [RELAY_DELIVERY_ID_HEADER]: hook.id,
97 [RELAY_RECEIVED_AT_HEADER]: hook.receivedAt,
98 };
99 let request = HttpClientRequest.make(hook.method as "GET" | "POST" | "PUT" | "PATCH")(
100 `${base}api/hooks/${hook.rawHookId}/${hook.rawToken}${hook.query ? `?${hook.query}` : ""}`,
101 { headers },
102 );
103 if (HttpMethod.hasBody(request.method)) {
104 request = HttpClientRequest.bodyUint8Array(request, hook.body, headers["content-type"]);
105 }
106 return yield* httpClient.execute(request).pipe(
107 Effect.flatMap((response) =>
108 readCapped(response).pipe(
109 Effect.map((body): UpstreamResponse => {
110 const outcome = response.headers["x-t3-hook-outcome"];
111 return {
112 status: response.status,
113 contentType: response.headers["content-type"],
114 outcome: outcome !== undefined && OUTCOME_PATTERN.test(outcome) ? outcome : undefined,
115 body,
116 };
117 }),
118 ),
119 ),
120 withoutRedirects,
121 // The client span would record url.full, which carries the token.
122 Effect.provideService(HttpClient.TracerDisabledWhen, () => true),
123 Effect.timeoutOption(Duration.millis(RELAY_HOOK_UPSTREAM_TIMEOUT_MS)),
124 );
125 });
infra/relay/src/http/Api.test.ts+63-2
......@@ -61,6 +61,7 @@ import {
6161import * as RelayConfiguration from "../Config.ts";
6262import * as RelayDb from "../db.ts";
6363import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";
64import * as HookInbox from "../hooks/HookInbox.ts";
6465import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
6566import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts";
6667import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts";
......@@ -124,6 +125,7 @@ describe("device listing compatibility", () => {
124125 Layer.mock(EnvironmentLinks.EnvironmentLinks, {}),
125126 Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}),
126127 Layer.mock(RelayDb.RelayTransactions, {}),
128 Layer.mock(HookInbox.HookInbox, {}),
127129 ),
128130 ),
129131 Layer.provide(
......@@ -317,8 +319,12 @@ function relayUnlinkTestLayer(input?: {
317319 readonly provision?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["provision"];
318320 readonly reconcileOrigin?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["reconcileOrigin"];
319321 readonly release?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["release"];
322 readonly clearInbox?: HookInbox.HookInbox["Service"]["clear"];
320323}) {
321324 return Layer.mergeAll(
325 Layer.mock(HookInbox.HookInbox, {
326 clear: input?.clearInbox ?? (() => Effect.void),
327 }),
322328 Layer.succeed(
323329 RelayDb.RelayTransactions,
324330 RelayDb.RelayTransactions.of({
......@@ -332,7 +338,8 @@ function relayUnlinkTestLayer(input?: {
332338 listDeliveryUsersForEnvironment: () => Effect.die("unused listDeliveryUsersForEnvironment"),
333339 listForUser: () => Effect.die("unused listForUser"),
334340 getForUser: input?.getForUser ?? (() => Effect.succeed(null)),
335 findActiveManagedForEnvironment: () => Effect.succeed([]),
341 findActiveManagedForEnvironment: () => Effect.die("unused findActiveManagedForEnvironment"),
342 setHoldWebhooksWhileOffline: () => Effect.die("unused setHoldWebhooksWhileOffline"),
336343 revokeForUser: input?.revokeForUser ?? (() => Effect.succeed(false)),
337344 }),
338345 ),
......@@ -899,6 +906,53 @@ describe("relay environment unlink", () => {
899906 );
900907 });
901908
909 it.effect("drops the unlinked endpoint's held webhooks, even if clearing fails", () => {
910 const cleared: Array<string> = [];
911 const endpointKey = "0123456789abcdef";
912 const unlink = (clearFails: boolean) =>
913 unlinkEnvironmentRecord({
914 userId: "user-1",
915 environmentId: "environment-1",
916 managedEndpointNamespace: "dev",
917 }).pipe(
918 Effect.provide(
919 relayUnlinkTestLayer({
920 getForUser: () => Effect.succeed(linkedEnvironmentRecord),
921 revokeForUser: () => Effect.succeed(true),
922 prepareDeprovision: () =>
923 Effect.succeed({
924 userId: "user-1",
925 environmentId: "environment-1",
926 hostname: "dev-0123456789abcdef.example.test",
927 tunnelId: "tunnel-1",
928 tunnelName: `t3coderelay-managedendpoint-dev-${endpointKey}`,
929 dnsRecordId: "dns-1",
930 readyAt: "2026-07-28T00:00:00.000Z",
931 origin: null,
932 updatedAt: "2026-07-28T00:00:00.000Z",
933 generation: 1,
934 }),
935 clearInbox: (input) =>
936 clearFails
937 ? Effect.fail(
938 new HookInbox.HookInboxError({
939 operation: "clear",
940 endpointKey: input.endpointKey,
941 cause: new Error("unavailable"),
942 }),
943 )
944 : Effect.sync(() => void cleared.push(input.endpointKey)),
945 }),
946 ),
947 );
948 return Effect.gen(function* () {
949 expect(yield* unlink(false)).toBe(true);
950 expect(cleared).toEqual([endpointKey]);
951 // The link is already revoked; a failed clear must not fail the unlink.
952 expect(yield* unlink(true)).toBe(true);
953 });
954 });
955
902956 it.effect("commits database revocation before deprovisioning the managed endpoint", () => {
903957 const calls: Array<string> = [];
904958 const deprovisionTarget = {
......@@ -1215,7 +1269,14 @@ describe("relay routing fallback", () => {
12151269 Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}),
12161270 ),
12171271 ),
1218 Layer.provide([publisher, signatures]),
1272 Layer.provide([
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 ]),
12191280 ),
12201281 ),
12211282 Layer.provide(auth),
infra/relay/src/http/Api.ts+136-9
......@@ -66,6 +66,8 @@ import * as DpopProofs from "../auth/DpopProofs.ts";
6666import * as RelayTokens from "../auth/RelayTokens.ts";
6767import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";
6868import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
69import * as HookForwarder from "../hooks/HookForwarder.ts";
70import * as HookInbox from "../hooks/HookInbox.ts";
6971import * as LiveActivities from "../agentActivity/LiveActivities.ts";
7072import * as RelayConfiguration from "../Config.ts";
7173import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher.ts";
......@@ -188,6 +190,12 @@ export const relayDocsRedirectRoute = HttpRouter.add(
188190// contains the exact child span that stalled, and the response still carries
189191// the traceparent back to the client.
190192export const RELAY_REQUEST_DEADLINE_MS = 9_000;
193/**
194 * Webhook forwarding reads a body of up to 1 MiB, waits up to the upstream
195 * timeout, and may then hold the request, so it needs more room than an API
196 * call; cutting it off before the hold would drop a request it should keep.
197 */
198export const RELAY_HOOK_REQUEST_DEADLINE_MS = 25_000;
191199
192200const relayRequestDeadline = <E, R>(
193201 httpEffect: Effect.Effect<
......@@ -195,9 +203,10 @@ const relayRequestDeadline = <E, R>(
195203 E,
196204 HttpServerRequest.HttpServerRequest | R
197205 >,
206 deadlineMs = RELAY_REQUEST_DEADLINE_MS,
198207) =>
199208 httpEffect.pipe(
200 Effect.timeoutOption(Duration.millis(RELAY_REQUEST_DEADLINE_MS)),
209 Effect.timeoutOption(Duration.millis(deadlineMs)),
201210 Effect.flatMap(
202211 Option.match({
203212 onNone: () =>
......@@ -206,7 +215,7 @@ const relayRequestDeadline = <E, R>(
206215 yield* Effect.logError("relay request exceeded deadline", {
207216 "http.method": request.method,
208217 "http.url": request.url,
209 "relay.request.deadline_ms": RELAY_REQUEST_DEADLINE_MS,
218 "relay.request.deadline_ms": deadlineMs,
210219 });
211220 yield* Effect.annotateCurrentSpan({
212221 "relay.request.deadline_exceeded": true,
......@@ -221,6 +230,9 @@ const relayRequestDeadline = <E, R>(
221230 ),
222231 );
223232
233/** Trace context headers in every format the tracer reads (W3C and B3). */
234const SENDER_TRACE_HEADER = /^(traceparent|tracestate|b3|x-b3-.*)$/i;
235
224236export const traceRelayHttpRequest = <E, R>(
225237 httpEffect: Effect.Effect<
226238 HttpServerResponse.HttpServerResponse,
......@@ -238,18 +250,29 @@ export const traceRelayHttpRequest = <E, R>(
238250 return yield* HttpMiddleware.tracer(traced).pipe(Effect.ensuring(Effect.yieldNow));
239251 }
240252 // Hook URLs carry a secret token: the tracer and deadline log see a redacted
241 // request, while the route itself still receives the original.
242 const redacted = request.modify({ url: redactRelayHookUrl(request.url) });
253 // request, while the route itself still receives the original. A webhook
254 // sender's trace context is dropped, so it cannot pick the trace our relay
255 // and environment spans land in.
256 const redacted = request.modify({
257 url: redactRelayHookUrl(request.url),
258 headers: Headers.removeMany(
259 request.headers,
260 Object.keys(request.headers).filter((name) => SENDER_TRACE_HEADER.test(name)),
261 ),
262 });
243263 return yield* HttpMiddleware.tracer(
244264 appendRelayTraceContextResponseHeader.pipe(
245265 Effect.andThen(
246266 relayRequestDeadline(
247267 httpEffect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request)),
268 RELAY_HOOK_REQUEST_DEADLINE_MS,
248269 ),
249270 ),
250271 ),
251272 ).pipe(
252273 Effect.provideService(HttpServerRequest.HttpServerRequest, redacted),
274 // The worker disables its own span for hook paths; this one is ours.
275 Effect.provideService(HttpMiddleware.TracerDisabledWhen, () => false),
253276 Effect.ensuring(Effect.yieldNow),
254277 );
255278 });
......@@ -494,7 +517,12 @@ export const revokeEnvironmentLinkRecord = Effect.fn(
494517});
495518
496519export const unlinkEnvironmentRecord = Effect.fn("relay.api.client.unlinkEnvironmentRecord")(
497 function* (input: { readonly userId: string; readonly environmentId: string }) {
520 function* (input: {
521 readonly userId: string;
522 readonly environmentId: string;
523 /** The stage's tunnel-name namespace, to find this link's held webhook requests. */
524 readonly managedEndpointNamespace?: string | undefined;
525 }) {
498526 const links = yield* EnvironmentLinks.EnvironmentLinks;
499527 const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
500528 const deprovisionTarget = yield* managedEndpointProvider.prepareDeprovision({
......@@ -513,7 +541,6 @@ export const unlinkEnvironmentRecord = Effect.fn("relay.api.client.unlinkEnviron
513541 environmentId: link.environmentId,
514542 environmentPublicKey: link.environmentPublicKey,
515543 });
516
517544 // External teardown cannot share the SQL transaction. Run it only after
518545 // revocation commits so a database failure leaves a fully usable active
519546 // link. Still run teardown when the link is already revoked, allowing a
......@@ -523,10 +550,31 @@ export const unlinkEnvironmentRecord = Effect.fn("relay.api.client.unlinkEnviron
523550 environmentId: input.environmentId,
524551 target: deprovisionTarget,
525552 });
553 // Requests held for this link's endpoint go with it. Best effort: the link
554 // is already gone, and its inbox drops anything left after its TTL.
555 const endpointKey =
556 deprovisionTarget && input.managedEndpointNamespace
557 ? HookForwarder.endpointKeyForTunnelName(
558 input.managedEndpointNamespace,
559 deprovisionTarget.tunnelName,
560 )
561 : null;
562 if (endpointKey !== null) {
563 const inbox = yield* HookInbox.HookInbox;
564 yield* inbox.clear({ endpointKey }).pipe(
565 Effect.catch((error) =>
566 Effect.logWarning("Could not clear held webhook requests", {
567 environmentId: input.environmentId,
568 errorTag: error._tag,
569 }),
570 ),
571 );
572 }
526573 if (!deprovisioned) {
527 const retryTarget = yield* managedEndpointProvider.prepareDeprovision(input);
528 if (retryTarget !== null && (yield* links.getForUser(input)) === null) {
529 yield* managedEndpointProvider.deprovision({ ...input, target: retryTarget });
574 const key = { userId: input.userId, environmentId: input.environmentId };
575 const retryTarget = yield* managedEndpointProvider.prepareDeprovision(key);
576 if (retryTarget !== null && (yield* links.getForUser(key)) === null) {
577 yield* managedEndpointProvider.deprovision({ ...key, target: retryTarget });
530578 }
531579 }
532580 return unlinked;
......@@ -926,6 +974,7 @@ export const clientApi = HttpApiBuilder.group(
926974 const unlinked = yield* unlinkEnvironmentRecord({
927975 userId,
928976 environmentId: params.environmentId,
977 managedEndpointNamespace: config.managedEndpointNamespace,
929978 }).pipe(
930979 Effect.catchTags({
931980 SqlError: () => relayInternalErrorResponse("internal_error"),
......@@ -1127,6 +1176,40 @@ export const serverApi = HttpApiBuilder.group(
11271176 Effect.fnUntraced(function* (handlers) {
11281177 const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;
11291178 const publishSignatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures;
1179 const links = yield* EnvironmentLinks.EnvironmentLinks;
1180 const inbox = yield* HookInbox.HookInbox;
1181 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
1182 const settings = yield* RelayConfiguration.RelayConfiguration;
1183 const requireOwnEnvironment = (environmentId: string) =>
1184 Effect.gen(function* () {
1185 const principal = yield* RelayEnvironmentPrincipal;
1186 if (principal.environmentId !== environmentId) {
1187 return yield* new HttpApiError.Unauthorized({});
1188 }
1189 });
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 });
11301213 const activityHandlers = handlers.handle(
11311214 "publishAgentActivity",
11321215 Effect.fn("relay.api.server.publishAgentActivity")(
......@@ -1336,6 +1419,48 @@ export const serverApi = HttpApiBuilder.group(
13361419 }),
13371420 mapRelayCommonApiErrors("not_authorized"),
13381421 ),
1422 )
1423 .handle(
1424 "updateLinkPreferences",
1425 Effect.fn("relay.api.server.updateLinkPreferences")(function* ({ params, payload }) {
1426 yield* requireOwnEnvironment(params.environmentId);
1427 const principal = yield* RelayEnvironmentPrincipal;
1428 yield* links.setHoldWebhooksWhileOffline({
1429 environmentId: params.environmentId,
1430 environmentPublicKey: principal.environmentPublicKey,
1431 holdWebhooksWhileOffline: payload.holdWebhooksWhileOffline,
1432 });
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;
1441 }, mapRelayCommonApiErrors("not_authorized")),
1442 )
1443 .handle(
1444 "wakeHeldHooks",
1445 Effect.fn("relay.api.server.wakeHeldHooks")(function* ({ params }) {
1446 yield* requireOwnEnvironment(params.environmentId);
1447 let pending = false;
1448 for (const endpointKey of yield* ownEndpointKeys(params.environmentId)) {
1449 const endpoint = yield* HookForwarder.resolveHookEndpoint(endpointKey).pipe(
1450 Effect.provideService(EnvironmentLinks.EnvironmentLinks, links),
1451 Effect.provideService(
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 };
1463 }, mapRelayCommonApiErrors("not_authorized")),
13391464 );
13401465 }),
13411466);
......@@ -1378,6 +1503,8 @@ const RelayCommonPersistenceError = Schema.Union([
13781503 AgentActivityRows.AgentActivityRowListPersistenceError,
13791504 LiveActivities.LiveActivityDeliveryMarkPersistenceError,
13801505 DeliveryAttempts.DeliveryAttemptRecordPersistenceError,
1506 EnvironmentLinks.EnvironmentLinkEnvironmentLookupPersistenceError,
1507 HookInbox.HookInboxError,
13811508]);
13821509type RelayCommonPersistenceError = typeof RelayCommonPersistenceError.Type;
13831510const isRelayCommonPersistenceError = Schema.is(RelayCommonPersistenceError);
infra/relay/src/persistence/schema.ts+2
......@@ -74,6 +74,8 @@ export const relayEnvironmentLinks = pgTable(
7474 notificationsEnabled: boolean("notifications_enabled").notNull().default(true),
7575 liveActivitiesEnabled: boolean("live_activities_enabled").notNull().default(true),
7676 managedTunnelsEnabled: boolean("managed_tunnels_enabled").notNull().default(false),
77 // Opt-in: hold webhook requests while the environment is offline.
78 holdWebhooksWhileOffline: boolean("hold_webhooks_while_offline").notNull().default(false),
7779 createdByDeviceId: varchar("created_by_device_id", { length: 191 }),
7880 revokedAt: varchar("revoked_at", { length: 64 }),
7981 createdAt: varchar("created_at", { length: 64 }).notNull(),
infra/relay/src/worker.ts+78-5
......@@ -1,4 +1,5 @@
11import * as Alchemy from "alchemy";
2import * as Axiom from "alchemy/Axiom";
23import * as Cloudflare from "alchemy/Cloudflare";
34import * as Drizzle from "alchemy/Drizzle/Postgres";
45import * as Config from "effect/Config";
......@@ -11,6 +12,7 @@ import * as Option from "effect/Option";
1112import * as Redacted from "effect/Redacted";
1213import * as Stream from "effect/Stream";
1314import * as Etag from "effect/http/Etag";
15import * as HttpMiddleware from "effect/http/HttpMiddleware";
1416import * as HttpPlatform from "effect/http/HttpPlatform";
1517import * as HttpRouter from "effect/http/HttpRouter";
1618import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
......@@ -74,6 +76,8 @@ import * as ManagedEndpointReaper from "./environments/ManagedEndpointReaper.ts"
7476import * as ManagedTunnelLimits from "./environments/ManagedTunnelLimits.ts";
7577import * as MobileRegistrations from "./agentActivity/MobileRegistrations.ts";
7678import * as HookForwarder from "./hooks/HookForwarder.ts";
79import * as HookInbox from "./hooks/HookInbox.ts";
80import { HookInboxObject, HookInboxObjectLive } from "./hooks/HookInboxObject.ts";
7781
7882const webcryptoLayer = Layer.succeed(
7983 Crypto.Crypto,
......@@ -190,6 +194,8 @@ export const ApiLive = Api.make(
190194 const managedEndpointDnsBinding = yield* Cloudflare.DNS.ReadWriteDns(managedEndpointZone);
191195 const managedEndpointZoneName = yield* managedEndpointZone.name;
192196 const managedEndpointCleanupMode = yield* RelayConfiguration.managedEndpointCleanupModeConfig;
197 // Keys are endpoint keys or hashes over them, which already differ per
198 // stage, so stages sharing an account cannot collide in these namespaces.
193199 const hookRateLimit = yield* Cloudflare.RateLimit("HOOK_RATE_LIMIT", {
194200 namespaceId: 1001,
195201 simple: {
......@@ -197,6 +203,14 @@ export const ApiLive = Api.make(
197203 period: HookForwarder.RELAY_HOOK_RATE_LIMIT.periodSeconds,
198204 },
199205 });
206 const hookEndpointRateLimit = yield* Cloudflare.RateLimit("HOOK_ENDPOINT_RATE_LIMIT", {
207 namespaceId: 1002,
208 simple: {
209 limit: HookForwarder.RELAY_HOOK_ENDPOINT_RATE_LIMIT.limit,
210 period: HookForwarder.RELAY_HOOK_ENDPOINT_RATE_LIMIT.periodSeconds,
211 },
212 });
213 const hookInboxes = yield* HookInboxObject;
200214
201215 //
202216 // 3. Runtime layers and app construction
......@@ -228,6 +242,31 @@ export const ApiLive = Api.make(
228242 }).pipe(Effect.map(makeRelayTraceLayer)),
229243 );
230244
245 // Each managed endpoint's held webhook requests live in its own Durable Object.
246 const inboxCall =
247 <A>(operation: HookInbox.HookInboxError["operation"], endpointKey: string) =>
248 (effect: Effect.Effect<A, never, Alchemy.RuntimeContext>) =>
249 effect.pipe(
250 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
251 Effect.catchCause((cause) =>
252 Effect.fail(
253 new HookInbox.HookInboxError({
254 operation,
255 endpointKey,
256 cause: Cause.squash(cause),
257 }),
258 ),
259 ),
260 );
261 const hookInboxLayer = Layer.succeed(HookInbox.HookInbox, {
262 hold: ({ endpointKey, baseUrl, hook }) =>
263 hookInboxes.getByName(endpointKey).hold(hook, baseUrl).pipe(inboxCall("hold", endpointKey)),
264 wake: ({ endpointKey, baseUrl }) =>
265 hookInboxes.getByName(endpointKey).wake(baseUrl).pipe(inboxCall("wake", endpointKey)),
266 clear: ({ endpointKey }) =>
267 hookInboxes.getByName(endpointKey).clear().pipe(inboxCall("clear", endpointKey)),
268 });
269
231270 const runtimeLayer = Layer.empty.pipe(
232271 Layer.provideMerge(MobileRegistrations.layer),
233272 Layer.provideMerge(AgentActivityPublisher.layer),
......@@ -269,7 +308,7 @@ export const ApiLive = Api.make(
269308 Layer.provideMerge(
270309 ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext),
271310 ),
272 Layer.provideMerge(Layer.mergeAll(AgentActivityRows.layer, Devices.layer)),
311 Layer.provideMerge(Layer.mergeAll(AgentActivityRows.layer, Devices.layer, hookInboxLayer)),
273312 Layer.provideMerge(EnvironmentCredentials.layer),
274313 Layer.provideMerge(
275314 Layer.mergeAll(
......@@ -291,17 +330,25 @@ export const ApiLive = Api.make(
291330 );
292331
293332 // Fails open: a limiter outage must not drop webhooks the environment would accept.
294 const hookRateLimiterLayer = Layer.succeed(HookForwarder.HookRateLimiter, {
295 allow: (key) =>
296 hookRateLimit.limit({ key }).pipe(
333 const allowWith =
334 (limiter: typeof hookRateLimit) =>
335 (key: string): Effect.Effect<boolean> =>
336 limiter.limit({ key }).pipe(
297337 Effect.map((result) => result.success),
298338 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
299339 Effect.catch((error) =>
300340 Effect.logWarning("Hook rate limiter unavailable", { error: error.message }).pipe(
341 // Visible on the forward span, so an outage that disables limits shows up.
342 Effect.andThen(
343 Effect.annotateCurrentSpan({ "relay.hook.rate_limiter_failed_open": true }),
344 ),
301345 Effect.as(true),
302346 ),
303347 ),
304 ),
348 );
349 const hookRateLimiterLayer = Layer.succeed(HookForwarder.HookRateLimiter, {
350 allowHook: allowWith(hookRateLimit),
351 allowEndpoint: allowWith(hookEndpointRateLimit),
305352 });
306353
307354 const appLayer = Layer.merge(
......@@ -426,6 +473,32 @@ export const ApiLive = Api.make(
426473 Layer.provideMerge(Cloudflare.Tunnel.ReadWriteTunnelBinding),
427474 Layer.provideMerge(Cloudflare.DNS.ReadWriteDnsHttp),
428475 Layer.provideMerge(Cloudflare.Workers.RateLimitBinding),
476 Layer.provideMerge(HookInboxObjectLive),
477 // The worker runtime opens its own HTTP span around ours. For webhook
478 // paths it would record the raw URL, token included, and adopt the
479 // sender's traceparent, so only our redacted span covers those.
480 // Registered as telemetry: request-time context is assembled per
481 // event, and only these layers are built into it.
482 Layer.provideMerge(
483 Alchemy.Telemetry.layer(
484 Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) =>
485 HookForwarder.isRelayHookPath(request.url),
486 ),
487 ),
488 ),
489 // Exports spans from events the HTTP tracer does not wrap, notably
490 // HookInboxObject calls and alarms, to the same Axiom dataset.
491 Layer.provideMerge(
492 Layer.unwrap(
493 Effect.map(RelayObservability, (observability) =>
494 Axiom.Telemetry({
495 serviceName: "t3code-relay",
496 token: observability.workerIngestToken,
497 traces: observability.traces,
498 }),
499 ),
500 ),
501 ),
429502 ),
430503 ),
431504 ),
packages/client-runtime/package.json+4
......@@ -35,6 +35,10 @@
3535 "types": "./src/userMessage.ts",
3636 "default": "./src/userMessage.ts"
3737 },
38 "./scheduled-task-webhook": {
39 "types": "./src/scheduledTaskWebhook.ts",
40 "default": "./src/scheduledTaskWebhook.ts"
41 },
3842 "./connection": {
3943 "types": "./src/connection/index.ts",
4044 "default": "./src/connection/index.ts"
packages/client-runtime/src/scheduledTaskWebhook.test.ts created+13
......@@ -0,0 +1,13 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { parseMaxDeliveryAge } from "./scheduledTaskWebhook.ts";
4
5describe("parseMaxDeliveryAge", () => {
6 it("treats blank as no limit and rejects values the server would not accept", () => {
7 expect(parseMaxDeliveryAge("")).toBeNull();
8 expect(parseMaxDeliveryAge(" 45 ")).toBe(45);
9 for (const invalid of ["0", "1.5", "-3", "abc", "1441"]) {
10 expect(parseMaxDeliveryAge(invalid)).toBeUndefined();
11 }
12 });
13});
packages/client-runtime/src/scheduledTaskWebhook.ts created+13
......@@ -0,0 +1,13 @@
1import { MAX_WEBHOOK_DELIVERY_AGE_MINUTES } from "@t3tools/contracts";
2
3/** Prompt a new webhook task starts with: just the body, so request headers stay out unless the user adds them. */
4export const DEFAULT_WEBHOOK_PROMPT = "Handle this webhook:\n{{body}}";
5
6/** Blank means "no limit"; undefined means the input is not a valid limit, which blocks saving. */
7export function parseMaxDeliveryAge(value: string): number | null | undefined {
8 if (value.trim() === "") return null;
9 const minutes = Number(value.trim());
10 return Number.isInteger(minutes) && minutes > 0 && minutes <= MAX_WEBHOOK_DELIVERY_AGE_MINUTES
11 ? minutes
12 : undefined;
13}
packages/contracts/src/environmentHttp.ts+5
......@@ -404,11 +404,16 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({
404404 // Optional so newer clients tolerate older environment servers.
405405 managedTunnelActive: Schema.optional(Schema.Boolean),
406406 publishAgentActivity: Schema.Boolean,
407 // Opt-in: T3 Connect holds webhook requests while this environment is
408 // offline. Optional so newer clients tolerate older environment servers.
409 holdWebhooksWhileOffline: Schema.optional(Schema.Boolean),
407410});
408411export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type;
409412
410413export const EnvironmentCloudPreferencesRequest = Schema.Struct({
411414 publishAgentActivity: Schema.Boolean,
415 // Omit to leave the current value unchanged.
416 holdWebhooksWhileOffline: Schema.optional(Schema.Boolean),
412417});
413418export type EnvironmentCloudPreferencesRequest = typeof EnvironmentCloudPreferencesRequest.Type;
414419
packages/contracts/src/relay.ts+50-3
......@@ -864,6 +864,21 @@ export const RelayCloudEnvironmentHealthProofPayload = Schema.Struct({
864864export type RelayCloudEnvironmentHealthProofPayload =
865865 typeof RelayCloudEnvironmentHealthProofPayload.Type;
866866
867/**
868 * Sent with every webhook the relay forwards, signed with the relay's mint key
869 * (`x-t3-relay-delivery`). The environment trusts the relay's delivery id,
870 * receive time and trace context only when this verifies, since its webhook
871 * URL can also be called directly.
872 */
873export const RelayHookDeliveryProofPayload = Schema.Struct({
874 ...RelaySignedJwtRegisteredClaims,
875 environmentId: EnvironmentId,
876 deliveryId: TrimmedNonEmptyString,
877 receivedAt: TrimmedNonEmptyString,
878 hookId: TrimmedNonEmptyString,
879});
880export type RelayHookDeliveryProofPayload = typeof RelayHookDeliveryProofPayload.Type;
881
867882export const RelayCloudEnvironmentHealthProof = TrimmedNonEmptyString;
868883export type RelayCloudEnvironmentHealthProof = typeof RelayCloudEnvironmentHealthProof.Type;
869884
......@@ -1136,6 +1151,18 @@ const RelayDpopClientGroup = HttpApiGroup.make("dpopClient")
11361151 .annotate(OpenApi.Description, "DPoP-authenticated client access to linked environments.")
11371152 .middleware(RelayDpopClientAuth);
11381153
1154export const RelayEnvironmentLinkPreferencesRequest = Schema.Struct({
1155 holdWebhooksWhileOffline: Schema.Boolean,
1156});
1157export type RelayEnvironmentLinkPreferencesRequest =
1158 typeof RelayEnvironmentLinkPreferencesRequest.Type;
1159
1160export const RelayWakeHeldHooksResponse = Schema.Struct({
1161 /** True when the relay was holding requests and has started delivering them. */
1162 pending: Schema.Boolean,
1163});
1164export type RelayWakeHeldHooksResponse = typeof RelayWakeHeldHooksResponse.Type;
1165
11391166const RelayServerGroup = HttpApiGroup.make("server")
11401167 .add(
11411168 HttpApiEndpoint.post(
......@@ -1171,6 +1198,24 @@ const RelayServerGroup = HttpApiGroup.make("server")
11711198 error: RelayAgentActivityPublishErrors,
11721199 },
11731200 ).annotate(OpenApi.Summary, "Publish agent activity"),
1201 HttpApiEndpoint.post(
1202 "updateLinkPreferences",
1203 "/v1/environments/:environmentId/link-preferences",
1204 {
1205 params: Schema.Struct({ environmentId: EnvironmentId }),
1206 payload: RelayEnvironmentLinkPreferencesRequest,
1207 success: RelayEnvironmentLinkPreferencesRequest,
1208 error: RelayAuthAndInternalErrors,
1209 },
1210 ).annotate(OpenApi.Summary, "Update an environment's link preferences"),
1211 HttpApiEndpoint.post("wakeHeldHooks", "/v1/environments/:environmentId/hooks/wake", {
1212 params: Schema.Struct({ environmentId: EnvironmentId }),
1213 success: RelayWakeHeldHooksResponse,
1214 error: RelayAuthAndInternalErrors,
1215 }).annotate(
1216 OpenApi.Summary,
1217 "Deliver webhook requests held while the environment was offline now",
1218 ),
11741219 )
11751220 .annotate(OpenApi.Description, "Environment-authenticated activity publication.")
11761221 .middleware(RelayEnvironmentAuth);
......@@ -1179,14 +1224,16 @@ const RelayServerGroup = HttpApiGroup.make("server")
11791224 * Public, stateless webhook forwarding to an environment's managed tunnel.
11801225 * Unauthenticated: the token in the path is the environment's credential, and
11811226 * the relay only routes and forwards. Raw, so the body reaches the
1182 * environment byte for byte and signatures still verify there.
1227 * environment byte for byte and signatures still verify there. `endpointKey`
1228 * names one managed endpoint (its tunnel's hash of user and environment), not
1229 * the environment id, which any account can link.
11831230 */
11841231const RelayHookParams = Schema.Struct({
1185 environmentId: Schema.String,
1232 endpointKey: Schema.String,
11861233 hookId: Schema.String,
11871234 token: Schema.String,
11881235});
1189const RELAY_HOOK_PATH = "/v1/hooks/:environmentId/:hookId/:token";
1236const RELAY_HOOK_PATH = "/v1/hooks/:endpointKey/:hookId/:token";
11901237const relayHookEndpoint = { params: RelayHookParams } as const;
11911238const RelayHooksGroup = HttpApiGroup.make("hooks")
11921239 .add(
packages/contracts/src/scheduledTask.ts+14
......@@ -73,11 +73,23 @@ export const ScheduledTaskWebhookSignature = Schema.Struct(
7373).annotate({ description: "Optional HMAC-SHA256 signature check over the raw request body." });
7474export type ScheduledTaskWebhookSignature = typeof ScheduledTaskWebhookSignature.Type;
7575
76/** Matches how long the relay holds a request for an offline environment. */
77export const MAX_WEBHOOK_DELIVERY_AGE_MINUTES = 24 * 60;
78
79const WebhookMaxDeliveryAgeMinutes = Schema.Int.check(
80 Schema.isBetween({ minimum: 1, maximum: MAX_WEBHOOK_DELIVERY_AGE_MINUTES }),
81).annotate({
82 description:
83 "Skip requests the relay held longer than this many minutes while the environment was offline. Null runs every request.",
84});
85
7686const ScheduledTaskWebhookSchedule = Schema.Struct({
7787 type: Schema.Literal("webhook").annotate({
7888 description: "Run when the task's webhook URL receives a request.",
7989 }),
8090 signature: Schema.NullOr(ScheduledTaskWebhookSignature),
91 // Optional so rows saved before this setting existed still decode.
92 maxDeliveryAgeMinutes: Schema.optional(Schema.NullOr(WebhookMaxDeliveryAgeMinutes)),
8193}).annotate({
8294 description:
8395 "Run on each request to the task's webhook URL. The prompt may use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}} and {{request}} placeholders.",
......@@ -99,6 +111,7 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({
99111 ).annotate({
100112 description: "Signature check; omit or null to accept requests by URL token only.",
101113 }),
114 maxDeliveryAgeMinutes: Schema.optional(Schema.NullOr(WebhookMaxDeliveryAgeMinutes)),
102115}).annotate({
103116 description:
104117 "Run on each request to the task's webhook URL. The prompt may use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}} and {{request}} placeholders.",
......@@ -241,6 +254,7 @@ export const ScheduledTaskWebhookDeliveryOutcome = Schema.Literals([
241254 "rejected_signature",
242255 "disabled",
243256 "rate_limited",
257 "expired",
244258]);
245259export type ScheduledTaskWebhookDeliveryOutcome = typeof ScheduledTaskWebhookDeliveryOutcome.Type;
246260
packages/shared/src/relayJwt.ts+3
......@@ -11,6 +11,9 @@ export const RELAY_MINT_RESPONSE_TYP = "t3-env-mint+jwt";
1111export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt";
1212export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt";
1313export const RELAY_MANAGED_TUNNEL_RECOVERY_TYP = "t3-env-managed-tunnel-recovery+jwt";
14export const RELAY_HOOK_DELIVERY_TYP = "t3-relay-hook-delivery+jwt";
15/** Header carrying the signed proof that a webhook request came from the relay. */
16export const RELAY_HOOK_DELIVERY_HEADER = "x-t3-relay-delivery";
1417
1518export class RelayJwtError extends Schema.TaggedError<RelayJwtError>()("RelayJwtError", {
1619 operation: Schema.Literals(["sign", "verify"]),
pnpm-lock.yaml+16-10
......@@ -9,6 +9,9 @@ catalogs:
99 '@effect/openapi-generator':
1010 specifier: 4.0.1
1111 version: 4.0.1
12 '@effect/sql-sqlite-do':
13 specifier: 4.0.1
14 version: 4.0.1
1215 '@effect/tsgo':
1316 specifier: 0.41.0
1417 version: 0.41.0
......@@ -837,6 +840,9 @@ importers:
837840 '@effect/sql-pg':
838841 specifier: 4.0.1
839842 version: 4.0.1(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
843 '@effect/sql-sqlite-do':
844 specifier: 'catalog:'
845 version: 4.0.1(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
840846 '@noble/curves':
841847 specifier: 'catalog:'
842848 version: 1.9.1
......@@ -854,10 +860,10 @@ importers:
854860 version: link:../../packages/shared
855861 alchemy:
856862 specifier: 2.0.0-beta.80
857 version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(824b7c95c4257cf54626a40f00f69c9b)
863 version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(d02b4e60a6a3c6b542dfa700209cf5ce)
858864 drizzle-orm:
859865 specifier: 1.0.0-rc.5-ab785fc
860 version: 1.0.0-rc.5-ab785fc(0a256ab99caa2c53eee62f60929ef39d)
866 version: 1.0.0-rc.5-ab785fc(74e00612579436038985093f7f46ef5c)
861867 effect:
862868 specifier: 4.0.1
863869 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
......@@ -2359,8 +2365,8 @@ packages:
23592365 peerDependencies:
23602366 effect: 4.0.1
23612367
2362 '@effect/sql-sqlite-do@4.0.0':
2363 resolution: {integrity: sha512-XkRZmQOPblzwjmAJ0RWSOiLlTsKkf1EigZsxUNuIuk4uZ/pnufSqvDpXubCwv8iyRTbOLFMmM+EyOEH5V5XqAw==}
2368 '@effect/sql-sqlite-do@4.0.1':
2369 resolution: {integrity: sha512-tS+qQok3xKsycnnhvIDX+6nvlM4Y2ouvru3voOzxtDQqX96NSOMX2xReLf4Feb6GN3jnzgEEtVGDDWk3YLrpsg==}
23642370 peerDependencies:
23652371 effect: 4.0.1
23662372
......@@ -13036,7 +13042,7 @@ snapshots:
1303613042 effect: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
1303713043 optional: true
1303813044
13039 '@effect/sql-sqlite-do@4.0.0(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))':
13045 '@effect/sql-sqlite-do@4.0.1(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))':
1304013046 dependencies:
1304113047 effect: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
1304213048
......@@ -16619,7 +16625,7 @@ snapshots:
1661916625 json-schema-traverse: 1.0.0
1662016626 require-from-string: 2.0.2
1662116627
16622 alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(824b7c95c4257cf54626a40f00f69c9b):
16628 alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(d02b4e60a6a3c6b542dfa700209cf5ce):
1662316629 dependencies:
1662416630 '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)))(@effect/platform-node@4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(rolldown@1.2.5)(typescript@7.0.2)
1662516631 '@alchemy.run/floci': 2.0.0-beta.80(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
......@@ -16642,7 +16648,7 @@ snapshots:
1664216648 '@distilled.cloud/stripe': 1.0.0-rc.13(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
1664316649 '@distilled.cloud/zerossl': 1.0.0-rc.13(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
1664416650 '@effect/sql-d1': 4.0.0(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
16645 '@effect/sql-sqlite-do': 4.0.0(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
16651 '@effect/sql-sqlite-do': 4.0.1(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
1664616652 '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1664716653 '@neon/functions': 0.11.0(hono@4.13.7)
1664816654 '@octokit/rest': 22.0.1
......@@ -16662,7 +16668,7 @@ snapshots:
1666216668 '@effect/sql-pg': 4.0.1(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
1666316669 '@effect/vitest': 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1666416670 drizzle-kit: 1.0.0-rc.5-ab785fc
16665 drizzle-orm: 1.0.0-rc.5-ab785fc(0a256ab99caa2c53eee62f60929ef39d)
16671 drizzle-orm: 1.0.0-rc.5-ab785fc(74e00612579436038985093f7f46ef5c)
1666616672 mongodb: 6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9)
1666716673 pg: 8.23.0
1666816674 vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.4)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)'
......@@ -17817,13 +17823,13 @@ snapshots:
1781717823 get-tsconfig: 4.14.3
1781817824 jiti: 2.7.0
1781917825
17820 drizzle-orm@1.0.0-rc.5-ab785fc(0a256ab99caa2c53eee62f60929ef39d):
17826 drizzle-orm@1.0.0-rc.5-ab785fc(74e00612579436038985093f7f46ef5c):
1782117827 optionalDependencies:
1782217828 '@cloudflare/workers-types': 4.20260604.1
1782317829 '@effect/sql-d1': 4.0.0(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
1782417830 '@effect/sql-pg': 4.0.1(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
1782517831 '@effect/sql-sqlite-bun': 4.0.0-rc.112(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
17826 '@effect/sql-sqlite-do': 4.0.0(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
17832 '@effect/sql-sqlite-do': 4.0.1(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))
1782717833 '@electric-sql/pglite': 0.3.15
1782817834 '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1782917835 bun-types: 1.3.14
pnpm-workspace.yaml+2
......@@ -35,6 +35,7 @@ catalog:
3535 "@effect/platform-node": 4.0.1
3636 "@effect/platform-node-shared": 4.0.1
3737 "@effect/sql-pg": 4.0.1
38 "@effect/sql-sqlite-do": 4.0.1
3839 "@effect/tsgo": 0.41.0
3940 "@effect/vitest": 4.0.1
4041 "@legendapp/list": 3.3.5
......@@ -75,6 +76,7 @@ minimumReleaseAgeExclude:
7576 - "@effect/platform-node-shared@4.0.1"
7677 - "@effect/platform-node@4.0.1"
7778 - "@effect/sql-pg@4.0.1"
79 - "@effect/sql-sqlite-do@4.0.1"
7880 - "@effect/vitest@4.0.1"
7981 - alchemy@2.0.0-beta.80
8082 - effect@4.0.1