diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 2effae896fc6..b3a949e65e59 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -37,6 +37,9 @@ import type { ComposerEditorSelection } from "../../components/ComposerEditor"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { SegmentedControl } from "../../components/SegmentedControl"; import { ThemedSwitch } from "../../components/ThemedSwitch"; +import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; +import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { usePreparedConnection } from "../../state/session"; import { buildModelOptions } from "../../lib/modelOptions"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useEnvironmentServerConfig } from "../../state/entities"; @@ -56,6 +59,7 @@ import { SettingsSection } from "./components/SettingsSection"; import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; import { editDraft, + DEFAULT_WEBHOOK_PROMPT, scheduledTaskDefaultModel, scheduleFromDraft, type ScheduledTaskDraft as Draft, @@ -599,7 +603,14 @@ function TaskForm({ environmentUnavailable ) return; - const schedule = scheduleFromDraft(draft.schedule); + // Signatures are edited on desktop and web; send the task's current one, + // not the copy taken when this form opened, so a newer edit survives. + const liveTask = tasks.data?.tasks.find((task) => task.id === draft.task?.id); + const schedule = scheduleFromDraft( + draft.schedule.mode === "webhook" && liveTask?.schedule.type === "webhook" + ? { ...draft.schedule, signature: liveTask.schedule.signature } + : draft.schedule, + ); if ( !draft.title.trim() || !draft.prompt.trim() || @@ -811,12 +822,20 @@ function TaskForm({ { setTimePickerOpen(false); - setDraft({ ...draft, schedule: { ...draft.schedule, mode } }); + setDraft({ + ...draft, + prompt: + mode === "webhook" && !draft.prompt.trim() + ? DEFAULT_WEBHOOK_PROMPT + : draft.prompt, + schedule: { ...draft.schedule, mode }, + }); }} /> @@ -890,6 +909,14 @@ function TaskForm({ }} /> + ) : draft.schedule.mode === "webhook" ? ( + task.id === draft.task?.id) ?? draft.task ?? null + } + signatureConfigured={draft.schedule.signature !== null} + /> ) : ( <> + + { + "The prompt can use {{body.a.b}}, {{headers.name}}, {{query.name}}, {{body}} and {{request}}. The filled-in prompt is all the agent sees." + } + + {task === null || resolved === null ? ( + Save the task to get its webhook URL. + ) : ( + <> + void tryCopyTextWithHaptic(resolved.address)} + className="gap-1 active:opacity-70" + > + + {resolved.copyable ? "Webhook URL" : "Webhook path"} + + + {resolved.address} + + + {resolved.note !== null ? ( + {resolved.note} + ) : null} + + Alert.alert("Rotate URL?", "The current URL stops working immediately.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Rotate", + style: "destructive", + onPress: () => + void rotate({ environmentId, input: { id: task.id } }).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + Alert.alert( + "Could not rotate URL", + String(squashAtomCommandFailure(result)), + ); + } + }), + }, + ]) + } + className="min-h-11 justify-center active:opacity-70" + > + Rotate URL + + + )} + {signatureConfigured ? ( + + Signature check configured on desktop/web. + + ) : null} + + ); +} + function EnvironmentTasks({ environment, now, @@ -1025,8 +1136,6 @@ function EnvironmentTasks({ { onEdit(task); }} @@ -1051,9 +1160,10 @@ function EnvironmentTasks({ { diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts index 01961597c3ad..022a357404ed 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts @@ -28,6 +28,25 @@ describe("scheduleDraftForTask", () => { const schedule = { type: "interval" as const, everyMs: 65_000 }; expect(scheduleFromDraft(scheduleDraftForTask({ schedule }))).toEqual(schedule); }); + + it("round-trips a webhook schedule without a signature", () => { + const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null } }); + expect(draft.mode).toBe("webhook"); + expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature: null }); + }); + + it("keeps a webhook signature on save without sending a secret", () => { + const signature = { + header: "x-hub-signature-256", + encoding: "hex" as const, + prefix: "sha256=", + }; + const saved = scheduleFromDraft( + scheduleDraftForTask({ schedule: { type: "webhook", signature } }), + ); + expect(saved).toEqual({ type: "webhook", signature }); + expect(saved?.type === "webhook" && saved.signature && "secret" in saved.signature).toBe(false); + }); }); describe("hasScheduledTaskDraftChanges", () => { diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 9ec77b585ac8..2843700620e8 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -5,6 +5,7 @@ import type { RuntimeMode, ScheduledTask, ScheduledTaskUpsertSchedule, + ScheduledTaskWebhookSignature, } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; @@ -37,10 +38,12 @@ export function scheduledTaskDefaultModel( } export type ScheduleDraft = { - readonly mode: "fixed_time" | "interval"; + readonly mode: "fixed_time" | "interval" | "webhook"; readonly timeOfDay: string; readonly weekdays: ReadonlyArray; readonly intervalMinutes: string; + /** A webhook signature check configured elsewhere; mobile keeps it but does not edit it. */ + readonly signature: ScheduledTaskWebhookSignature | null; }; export const DEFAULT_SCHEDULE: ScheduleDraft = { @@ -48,27 +51,48 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = { timeOfDay: "09:00", weekdays: [1, 2, 3, 4, 5], intervalMinutes: "15", + signature: null, }; +/** Prompt a new webhook task starts with: the whole request, which the user can narrow down. */ +export const DEFAULT_WEBHOOK_PROMPT = "Handle this webhook:\n{{request}}"; + export function scheduleDraftForTask(task: Pick): ScheduleDraft { - // Webhook tasks cannot be edited here yet; show them as the default schedule. - if (task.schedule.type === "webhook") return DEFAULT_SCHEDULE; - return task.schedule.type === "fixed_time" - ? { + switch (task.schedule.type) { + case "fixed_time": + return { ...DEFAULT_SCHEDULE, timeOfDay: task.schedule.timeOfDay, weekdays: task.schedule.weekdays?.length ? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b) : [0, 1, 2, 3, 4, 5, 6], - } - : { + }; + case "interval": + return { ...DEFAULT_SCHEDULE, mode: "interval", intervalMinutes: String(Math.max(1, task.schedule.everyMs / 60_000)), }; + case "webhook": + return { ...DEFAULT_SCHEDULE, mode: "webhook", signature: task.schedule.signature }; + } } export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSchedule | null { + if (draft.mode === "webhook") { + // No secret is sent, so the server keeps the stored one. + return { + type: "webhook", + signature: + draft.signature === null + ? null + : { + header: draft.signature.header, + encoding: draft.signature.encoding, + prefix: draft.signature.prefix, + }, + }; + } if (draft.mode === "interval") { const minutes = Number(draft.intervalMinutes); // Undo floating-point noise from displaying existing millisecond intervals as minutes. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index b812495cb3e2..7063fed96ca8 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -131,6 +131,10 @@ "types": "./src/textPaste.ts", "default": "./src/textPaste.ts" }, + "./webhook-address": { + "types": "./src/webhookAddress.ts", + "default": "./src/webhookAddress.ts" + }, "./delayed-status": { "types": "./src/delayedStatus.ts", "default": "./src/delayedStatus.ts" diff --git a/packages/client-runtime/src/webhookAddress.test.ts b/packages/client-runtime/src/webhookAddress.test.ts new file mode 100644 index 000000000000..2284ee8fb150 --- /dev/null +++ b/packages/client-runtime/src/webhookAddress.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { webhookAddress } from "./webhookAddress.ts"; + +const path = "/api/hooks/scheduled-task%3Ahook/token"; +const endpoint = (url: string | null) => ({ path, url, hasSecret: false }); + +describe("webhookAddress", () => { + it("uses the T3 Connect URL when the server has one", () => { + expect(webhookAddress(endpoint("https://relay.t3.codes/v1/hooks/k/t/x"), null)).toEqual({ + address: "https://relay.t3.codes/v1/hooks/k/t/x", + copyable: true, + note: null, + }); + }); + + it("builds a direct URL on the environment's address without T3 Connect", () => { + const result = webhookAddress(endpoint(null), "https://mac.tail1234.ts.net/"); + expect(result.address).toBe(`https://mac.tail1234.ts.net${path}`); + expect(result.copyable).toBe(true); + expect(result.note).toContain("Tailscale"); + }); + + it("says only this computer can call a loopback address", () => { + const result = webhookAddress(endpoint(null), "http://127.0.0.1:3773/"); + expect(result.copyable).toBe(true); + expect(result.note).toContain("Only this computer"); + }); + + it("falls back to the path when the address is unknown", () => { + expect(webhookAddress(endpoint(null), null)).toMatchObject({ + address: path, + copyable: false, + }); + }); +}); diff --git a/packages/client-runtime/src/webhookAddress.ts b/packages/client-runtime/src/webhookAddress.ts new file mode 100644 index 000000000000..cd1424fd6f86 --- /dev/null +++ b/packages/client-runtime/src/webhookAddress.ts @@ -0,0 +1,40 @@ +import type { ScheduledTaskWebhookEndpoint } from "@t3tools/contracts"; +import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification"; + +/** + * Where a sender can call a webhook task, as a client shows it. With T3 + * Connect the server returns a public URL; without it the path is resolved on + * the address this client reaches the environment at. + */ +export interface WebhookAddress { + /** The URL to give a sender, or the bare path when no address is known. */ + readonly address: string; + /** Whether `address` is a full URL a sender can call. */ + readonly copyable: boolean; + /** One line on who can reach `address`; null for a T3 Connect URL. */ + readonly note: string | null; +} + +export function webhookAddress( + endpoint: ScheduledTaskWebhookEndpoint, + httpBaseUrl: string | null, +): WebhookAddress { + if (endpoint.url !== null) { + return { address: endpoint.url, copyable: true, note: null }; + } + if (httpBaseUrl === null) { + return { + address: endpoint.path, + copyable: false, + note: "Link this environment to T3 Connect for a public URL.", + }; + } + const url = new URL(endpoint.path, httpBaseUrl); + return { + address: url.href, + copyable: true, + note: isLocalLoopbackHost(url.hostname) + ? "Only this computer can call this address. Link T3 Connect for a public URL." + : "Works wherever this environment's address is reachable, for example over Tailscale or your own proxy. Link T3 Connect for a public URL.", + }; +}