| author | |
| committer | |
| log | aee889b0e043fdc652e48850698d08ebc563d5bc |
| tree | e8a308f4e94945ea61d580bc15fcf4ef6b5103f9 |
| parent | b070e52d3e6b49c98117249e5b3f0d46edce173a |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 247 insertions(+), 14 deletions(-)
apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx+117-7| ... | @@ -37,6 +37,9 @@ import type { ComposerEditorSelection } from "../../components/ComposerEditor"; | ... | @@ -37,6 +37,9 @@ import type { ComposerEditorSelection } from "../../components/ComposerEditor"; |
| 37 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | 37 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 38 | import { SegmentedControl } from "../../components/SegmentedControl"; | 38 | import { SegmentedControl } from "../../components/SegmentedControl"; |
| 39 | import { ThemedSwitch } from "../../components/ThemedSwitch"; | 39 | import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 40 | import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; | ||
| 41 | import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | ||
| 42 | import { usePreparedConnection } from "../../state/session"; | ||
| 40 | import { buildModelOptions } from "../../lib/modelOptions"; | 43 | import { buildModelOptions } from "../../lib/modelOptions"; |
| 41 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 44 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 42 | import { useProjects, useEnvironmentServerConfig } from "../../state/entities"; | 45 | import { useProjects, useEnvironmentServerConfig } from "../../state/entities"; |
| ... | @@ -56,6 +59,7 @@ import { SettingsSection } from "./components/SettingsSection"; | ... | @@ -56,6 +59,7 @@ import { SettingsSection } from "./components/SettingsSection"; |
| 56 | import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; | 59 | import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; |
| 57 | import { | 60 | import { |
| 58 | editDraft, | 61 | editDraft, |
| 62 | DEFAULT_WEBHOOK_PROMPT, | ||
| 59 | scheduledTaskDefaultModel, | 63 | scheduledTaskDefaultModel, |
| 60 | scheduleFromDraft, | 64 | scheduleFromDraft, |
| 61 | type ScheduledTaskDraft as Draft, | 65 | type ScheduledTaskDraft as Draft, |
| ... | @@ -599,7 +603,14 @@ function TaskForm({ | ... | @@ -599,7 +603,14 @@ function TaskForm({ |
| 599 | environmentUnavailable | 603 | environmentUnavailable |
| 600 | ) | 604 | ) |
| 601 | return; | 605 | return; |
| 602 | const schedule = scheduleFromDraft(draft.schedule); | 606 | // Signatures are edited on desktop and web; send the task's current one, |
| 607 | // not the copy taken when this form opened, so a newer edit survives. | ||
| 608 | const liveTask = tasks.data?.tasks.find((task) => task.id === draft.task?.id); | ||
| 609 | const schedule = scheduleFromDraft( | ||
| 610 | draft.schedule.mode === "webhook" && liveTask?.schedule.type === "webhook" | ||
| 611 | ? { ...draft.schedule, signature: liveTask.schedule.signature } | ||
| 612 | : draft.schedule, | ||
| 613 | ); | ||
| 603 | if ( | 614 | if ( |
| 604 | !draft.title.trim() || | 615 | !draft.title.trim() || |
| 605 | !draft.prompt.trim() || | 616 | !draft.prompt.trim() || |
| ... | @@ -811,12 +822,20 @@ function TaskForm({ | ... | @@ -811,12 +822,20 @@ function TaskForm({ |
| 811 | <SegmentedControl | 822 | <SegmentedControl |
| 812 | options={[ | 823 | options={[ |
| 813 | { value: "fixed_time", label: "At a time" }, | 824 | { value: "fixed_time", label: "At a time" }, |
| 814 | { value: "interval", label: "Every interval" }, | 825 | { value: "interval", label: "Interval" }, |
| 826 | { value: "webhook", label: "Webhook" }, | ||
| 815 | ]} | 827 | ]} |
| 816 | selected={draft.schedule.mode} | 828 | selected={draft.schedule.mode} |
| 817 | onSelect={(mode) => { | 829 | onSelect={(mode) => { |
| 818 | setTimePickerOpen(false); | 830 | setTimePickerOpen(false); |
| 819 | setDraft({ ...draft, schedule: { ...draft.schedule, mode } }); | 831 | setDraft({ |
| 832 | ...draft, | ||
| 833 | prompt: | ||
| 834 | mode === "webhook" && !draft.prompt.trim() | ||
| 835 | ? DEFAULT_WEBHOOK_PROMPT | ||
| 836 | : draft.prompt, | ||
| 837 | schedule: { ...draft.schedule, mode }, | ||
| 838 | }); | ||
| 820 | }} | 839 | }} |
| 821 | /> | 840 | /> |
| 822 | </View> | 841 | </View> |
| ... | @@ -890,6 +909,14 @@ function TaskForm({ | ... | @@ -890,6 +909,14 @@ function TaskForm({ |
| 890 | }} | 909 | }} |
| 891 | /> | 910 | /> |
| 892 | </> | 911 | </> |
| 912 | ) : 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 | /> | ||
| 893 | ) : ( | 920 | ) : ( |
| 894 | <> | 921 | <> |
| 895 | <FormField | 922 | <FormField |
| ... | @@ -945,6 +972,90 @@ function TaskForm({ | ... | @@ -945,6 +972,90 @@ function TaskForm({ |
| 945 | ); | 972 | ); |
| 946 | } | 973 | } |
| 947 | 974 | ||
| 975 | function WebhookScheduleDetails({ | ||
| 976 | environmentId, | ||
| 977 | task, | ||
| 978 | signatureConfigured, | ||
| 979 | }: { | ||
| 980 | readonly environmentId: EnvironmentId; | ||
| 981 | readonly task: ScheduledTask | null; | ||
| 982 | readonly signatureConfigured: boolean; | ||
| 983 | }) { | ||
| 984 | const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, { | ||
| 985 | label: "scheduled task rotate webhook token", | ||
| 986 | reportFailure: false, | ||
| 987 | }); | ||
| 988 | const preparedConnection = usePreparedConnection(environmentId); | ||
| 989 | const httpBaseUrl = | ||
| 990 | preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null; | ||
| 991 | const webhook = task?.schedule.type === "webhook" ? task.webhook : undefined; | ||
| 992 | // Without T3 Connect, the path is resolved on the address this phone uses. | ||
| 993 | const resolved = webhook ? webhookAddress(webhook, httpBaseUrl) : null; | ||
| 994 | return ( | ||
| 995 | <View className="gap-2 border-t border-border-subtle px-4 py-3"> | ||
| 996 | <Text className="text-sm text-foreground-muted"> | ||
| 997 | { | ||
| 998 | "The prompt can use {{body.a.b}}, {{headers.name}}, {{query.name}}, {{body}} and {{request}}. The filled-in prompt is all the agent sees." | ||
| 999 | } | ||
| 1000 | </Text> | ||
| 1001 | {task === null || resolved === null ? ( | ||
| 1002 | <Text className="text-sm text-foreground-muted">Save the task to get its webhook URL.</Text> | ||
| 1003 | ) : ( | ||
| 1004 | <> | ||
| 1005 | <Pressable | ||
| 1006 | accessibilityRole="button" | ||
| 1007 | accessibilityLabel="Copy webhook URL" | ||
| 1008 | accessibilityHint="Copies the URL to the clipboard" | ||
| 1009 | // A bare path is not something a sender can call, so only full URLs copy. | ||
| 1010 | disabled={!resolved.copyable} | ||
| 1011 | onPress={() => void tryCopyTextWithHaptic(resolved.address)} | ||
| 1012 | className="gap-1 active:opacity-70" | ||
| 1013 | > | ||
| 1014 | <Text className="text-lg text-foreground"> | ||
| 1015 | {resolved.copyable ? "Webhook URL" : "Webhook path"} | ||
| 1016 | </Text> | ||
| 1017 | <Text className="text-sm text-foreground-muted" numberOfLines={2} selectable> | ||
| 1018 | {resolved.address} | ||
| 1019 | </Text> | ||
| 1020 | </Pressable> | ||
| 1021 | {resolved.note !== null ? ( | ||
| 1022 | <Text className="text-sm text-foreground-muted">{resolved.note}</Text> | ||
| 1023 | ) : null} | ||
| 1024 | <Pressable | ||
| 1025 | accessibilityRole="button" | ||
| 1026 | onPress={() => | ||
| 1027 | Alert.alert("Rotate URL?", "The current URL stops working immediately.", [ | ||
| 1028 | { text: "Cancel", style: "cancel" }, | ||
| 1029 | { | ||
| 1030 | text: "Rotate", | ||
| 1031 | style: "destructive", | ||
| 1032 | onPress: () => | ||
| 1033 | void rotate({ environmentId, input: { id: task.id } }).then((result) => { | ||
| 1034 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| 1035 | Alert.alert( | ||
| 1036 | "Could not rotate URL", | ||
| 1037 | String(squashAtomCommandFailure(result)), | ||
| 1038 | ); | ||
| 1039 | } | ||
| 1040 | }), | ||
| 1041 | }, | ||
| 1042 | ]) | ||
| 1043 | } | ||
| 1044 | className="min-h-11 justify-center active:opacity-70" | ||
| 1045 | > | ||
| 1046 | <Text className="text-base text-danger-foreground">Rotate URL</Text> | ||
| 1047 | </Pressable> | ||
| 1048 | </> | ||
| 1049 | )} | ||
| 1050 | {signatureConfigured ? ( | ||
| 1051 | <Text className="text-sm text-foreground-muted"> | ||
| 1052 | Signature check configured on desktop/web. | ||
| 1053 | </Text> | ||
| 1054 | ) : null} | ||
| 1055 | </View> | ||
| 1056 | ); | ||
| 1057 | } | ||
| 1058 | |||
| 948 | function EnvironmentTasks({ | 1059 | function EnvironmentTasks({ |
| 949 | environment, | 1060 | environment, |
| 950 | now, | 1061 | now, |
| ... | @@ -1025,8 +1136,6 @@ function EnvironmentTasks({ | ... | @@ -1025,8 +1136,6 @@ function EnvironmentTasks({ |
| 1025 | <Pressable | 1136 | <Pressable |
| 1026 | accessibilityRole="button" | 1137 | accessibilityRole="button" |
| 1027 | accessibilityLabel={`Edit ${task.title}`} | 1138 | accessibilityLabel={`Edit ${task.title}`} |
| 1028 | // Webhook tasks are not editable here yet; saving would drop their URL. | ||
| 1029 | disabled={task.schedule.type === "webhook"} | ||
| 1030 | onPress={() => { | 1139 | onPress={() => { |
| 1031 | onEdit(task); | 1140 | onEdit(task); |
| 1032 | }} | 1141 | }} |
| ... | @@ -1051,9 +1160,10 @@ function EnvironmentTasks({ | ... | @@ -1051,9 +1160,10 @@ function EnvironmentTasks({ |
| 1051 | </Pressable> | 1160 | </Pressable> |
| 1052 | <ControlPillMenu | 1161 | <ControlPillMenu |
| 1053 | actions={[ | 1162 | actions={[ |
| 1054 | ...(task.schedule.type === "webhook" ? [] : [{ id: "edit", title: "Edit" }]), | 1163 | { id: "edit", title: "Edit" }, |
| 1055 | { id: "toggle", title: task.enabled ? "Pause" : "Resume" }, | 1164 | { id: "toggle", title: task.enabled ? "Pause" : "Resume" }, |
| 1056 | { id: "run", title: "Run now" }, | 1165 | // A webhook task has no request to run without. |
| 1166 | ...(task.schedule.type === "webhook" ? [] : [{ id: "run", title: "Run now" }]), | ||
| 1057 | { id: "delete", title: "Delete", attributes: { destructive: true } }, | 1167 | { id: "delete", title: "Delete", attributes: { destructive: true } }, |
| 1058 | ]} | 1168 | ]} |
| 1059 | onPressAction={({ nativeEvent }) => { | 1169 | onPressAction={({ nativeEvent }) => { |
apps/mobile/src/features/settings/scheduledTaskDraft.test.ts+19| ... | @@ -28,6 +28,25 @@ describe("scheduleDraftForTask", () => { | ... | @@ -28,6 +28,25 @@ describe("scheduleDraftForTask", () => { |
| 28 | const schedule = { type: "interval" as const, everyMs: 65_000 }; | 28 | const schedule = { type: "interval" as const, everyMs: 65_000 }; |
| 29 | expect(scheduleFromDraft(scheduleDraftForTask({ schedule }))).toEqual(schedule); | 29 | expect(scheduleFromDraft(scheduleDraftForTask({ schedule }))).toEqual(schedule); |
| 30 | }); | 30 | }); |
| 31 | |||
| 32 | it("round-trips a webhook schedule without a signature", () => { | ||
| 33 | const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null } }); | ||
| 34 | expect(draft.mode).toBe("webhook"); | ||
| 35 | expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature: null }); | ||
| 36 | }); | ||
| 37 | |||
| 38 | it("keeps a webhook signature on save without sending a secret", () => { | ||
| 39 | const signature = { | ||
| 40 | header: "x-hub-signature-256", | ||
| 41 | encoding: "hex" as const, | ||
| 42 | prefix: "sha256=", | ||
| 43 | }; | ||
| 44 | const saved = scheduleFromDraft( | ||
| 45 | scheduleDraftForTask({ schedule: { type: "webhook", signature } }), | ||
| 46 | ); | ||
| 47 | expect(saved).toEqual({ type: "webhook", signature }); | ||
| 48 | expect(saved?.type === "webhook" && saved.signature && "secret" in saved.signature).toBe(false); | ||
| 49 | }); | ||
| 31 | }); | 50 | }); |
| 32 | 51 | ||
| 33 | describe("hasScheduledTaskDraftChanges", () => { | 52 | describe("hasScheduledTaskDraftChanges", () => { |
apps/mobile/src/features/settings/scheduledTaskDraft.ts+31-7| ... | @@ -5,6 +5,7 @@ import type { | ... | @@ -5,6 +5,7 @@ import type { |
| 5 | RuntimeMode, | 5 | RuntimeMode, |
| 6 | ScheduledTask, | 6 | ScheduledTask, |
| 7 | ScheduledTaskUpsertSchedule, | 7 | ScheduledTaskUpsertSchedule, |
| 8 | ScheduledTaskWebhookSignature, | ||
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 9 | 10 | ||
| 10 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; | 11 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; |
| ... | @@ -37,10 +38,12 @@ export function scheduledTaskDefaultModel( | ... | @@ -37,10 +38,12 @@ export function scheduledTaskDefaultModel( |
| 37 | } | 38 | } |
| 38 | 39 | ||
| 39 | export type ScheduleDraft = { | 40 | export type ScheduleDraft = { |
| 40 | readonly mode: "fixed_time" | "interval"; | 41 | readonly mode: "fixed_time" | "interval" | "webhook"; |
| 41 | readonly timeOfDay: string; | 42 | readonly timeOfDay: string; |
| 42 | readonly weekdays: ReadonlyArray<number>; | 43 | readonly weekdays: ReadonlyArray<number>; |
| 43 | readonly intervalMinutes: string; | 44 | readonly intervalMinutes: string; |
| 45 | /** A webhook signature check configured elsewhere; mobile keeps it but does not edit it. */ | ||
| 46 | readonly signature: ScheduledTaskWebhookSignature | null; | ||
| 44 | }; | 47 | }; |
| 45 | 48 | ||
| 46 | export const DEFAULT_SCHEDULE: ScheduleDraft = { | 49 | export const DEFAULT_SCHEDULE: ScheduleDraft = { |
| ... | @@ -48,27 +51,48 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = { | ... | @@ -48,27 +51,48 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = { |
| 48 | timeOfDay: "09:00", | 51 | timeOfDay: "09:00", |
| 49 | weekdays: [1, 2, 3, 4, 5], | 52 | weekdays: [1, 2, 3, 4, 5], |
| 50 | intervalMinutes: "15", | 53 | intervalMinutes: "15", |
| 54 | signature: null, | ||
| 51 | }; | 55 | }; |
| 52 | 56 | ||
| 57 | /** Prompt a new webhook task starts with: the whole request, which the user can narrow down. */ | ||
| 58 | export const DEFAULT_WEBHOOK_PROMPT = "Handle this webhook:\n{{request}}"; | ||
| 59 | |||
| 53 | export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): ScheduleDraft { | 60 | export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): ScheduleDraft { |
| 54 | // Webhook tasks cannot be edited here yet; show them as the default schedule. | 61 | switch (task.schedule.type) { |
| 55 | if (task.schedule.type === "webhook") return DEFAULT_SCHEDULE; | 62 | case "fixed_time": |
| 56 | return task.schedule.type === "fixed_time" | 63 | return { |
| 57 | ? { | ||
| 58 | ...DEFAULT_SCHEDULE, | 64 | ...DEFAULT_SCHEDULE, |
| 59 | timeOfDay: task.schedule.timeOfDay, | 65 | timeOfDay: task.schedule.timeOfDay, |
| 60 | weekdays: task.schedule.weekdays?.length | 66 | weekdays: task.schedule.weekdays?.length |
| 61 | ? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b) | 67 | ? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b) |
| 62 | : [0, 1, 2, 3, 4, 5, 6], | 68 | : [0, 1, 2, 3, 4, 5, 6], |
| 63 | } | 69 | }; |
| 64 | : { | 70 | case "interval": |
| 71 | return { | ||
| 65 | ...DEFAULT_SCHEDULE, | 72 | ...DEFAULT_SCHEDULE, |
| 66 | mode: "interval", | 73 | mode: "interval", |
| 67 | intervalMinutes: String(Math.max(1, task.schedule.everyMs / 60_000)), | 74 | intervalMinutes: String(Math.max(1, task.schedule.everyMs / 60_000)), |
| 68 | }; | 75 | }; |
| 76 | case "webhook": | ||
| 77 | return { ...DEFAULT_SCHEDULE, mode: "webhook", signature: task.schedule.signature }; | ||
| 78 | } | ||
| 69 | } | 79 | } |
| 70 | 80 | ||
| 71 | export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSchedule | null { | 81 | export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSchedule | null { |
| 82 | if (draft.mode === "webhook") { | ||
| 83 | // No secret is sent, so the server keeps the stored one. | ||
| 84 | return { | ||
| 85 | type: "webhook", | ||
| 86 | signature: | ||
| 87 | draft.signature === null | ||
| 88 | ? null | ||
| 89 | : { | ||
| 90 | header: draft.signature.header, | ||
| 91 | encoding: draft.signature.encoding, | ||
| 92 | prefix: draft.signature.prefix, | ||
| 93 | }, | ||
| 94 | }; | ||
| 95 | } | ||
| 72 | if (draft.mode === "interval") { | 96 | if (draft.mode === "interval") { |
| 73 | const minutes = Number(draft.intervalMinutes); | 97 | const minutes = Number(draft.intervalMinutes); |
| 74 | // Undo floating-point noise from displaying existing millisecond intervals as minutes. | 98 | // Undo floating-point noise from displaying existing millisecond intervals as minutes. |
packages/client-runtime/package.json+4| ... | @@ -131,6 +131,10 @@ | ... | @@ -131,6 +131,10 @@ |
| 131 | "types": "./src/textPaste.ts", | 131 | "types": "./src/textPaste.ts", |
| 132 | "default": "./src/textPaste.ts" | 132 | "default": "./src/textPaste.ts" |
| 133 | }, | 133 | }, |
| 134 | "./webhook-address": { | ||
| 135 | "types": "./src/webhookAddress.ts", | ||
| 136 | "default": "./src/webhookAddress.ts" | ||
| 137 | }, | ||
| 134 | "./delayed-status": { | 138 | "./delayed-status": { |
| 135 | "types": "./src/delayedStatus.ts", | 139 | "types": "./src/delayedStatus.ts", |
| 136 | "default": "./src/delayedStatus.ts" | 140 | "default": "./src/delayedStatus.ts" |
packages/client-runtime/src/webhookAddress.test.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { webhookAddress } from "./webhookAddress.ts"; | ||
| 4 | |||
| 5 | const path = "/api/hooks/scheduled-task%3Ahook/token"; | ||
| 6 | const endpoint = (url: string | null) => ({ path, url, hasSecret: false }); | ||
| 7 | |||
| 8 | describe("webhookAddress", () => { | ||
| 9 | it("uses the T3 Connect URL when the server has one", () => { | ||
| 10 | expect(webhookAddress(endpoint("https://relay.t3.codes/v1/hooks/k/t/x"), null)).toEqual({ | ||
| 11 | address: "https://relay.t3.codes/v1/hooks/k/t/x", | ||
| 12 | copyable: true, | ||
| 13 | note: null, | ||
| 14 | }); | ||
| 15 | }); | ||
| 16 | |||
| 17 | it("builds a direct URL on the environment's address without T3 Connect", () => { | ||
| 18 | const result = webhookAddress(endpoint(null), "https://mac.tail1234.ts.net/"); | ||
| 19 | expect(result.address).toBe(`https://mac.tail1234.ts.net${path}`); | ||
| 20 | expect(result.copyable).toBe(true); | ||
| 21 | expect(result.note).toContain("Tailscale"); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("says only this computer can call a loopback address", () => { | ||
| 25 | const result = webhookAddress(endpoint(null), "http://127.0.0.1:3773/"); | ||
| 26 | expect(result.copyable).toBe(true); | ||
| 27 | expect(result.note).toContain("Only this computer"); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("falls back to the path when the address is unknown", () => { | ||
| 31 | expect(webhookAddress(endpoint(null), null)).toMatchObject({ | ||
| 32 | address: path, | ||
| 33 | copyable: false, | ||
| 34 | }); | ||
| 35 | }); | ||
| 36 | }); | ||
packages/client-runtime/src/webhookAddress.ts created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | import type { ScheduledTaskWebhookEndpoint } from "@t3tools/contracts"; | ||
| 2 | import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification"; | ||
| 3 | |||
| 4 | /** | ||
| 5 | * Where a sender can call a webhook task, as a client shows it. With T3 | ||
| 6 | * Connect the server returns a public URL; without it the path is resolved on | ||
| 7 | * the address this client reaches the environment at. | ||
| 8 | */ | ||
| 9 | export interface WebhookAddress { | ||
| 10 | /** The URL to give a sender, or the bare path when no address is known. */ | ||
| 11 | readonly address: string; | ||
| 12 | /** Whether `address` is a full URL a sender can call. */ | ||
| 13 | readonly copyable: boolean; | ||
| 14 | /** One line on who can reach `address`; null for a T3 Connect URL. */ | ||
| 15 | readonly note: string | null; | ||
| 16 | } | ||
| 17 | |||
| 18 | export function webhookAddress( | ||
| 19 | endpoint: ScheduledTaskWebhookEndpoint, | ||
| 20 | httpBaseUrl: string | null, | ||
| 21 | ): WebhookAddress { | ||
| 22 | if (endpoint.url !== null) { | ||
| 23 | return { address: endpoint.url, copyable: true, note: null }; | ||
| 24 | } | ||
| 25 | if (httpBaseUrl === null) { | ||
| 26 | return { | ||
| 27 | address: endpoint.path, | ||
| 28 | copyable: false, | ||
| 29 | note: "Link this environment to T3 Connect for a public URL.", | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | const url = new URL(endpoint.path, httpBaseUrl); | ||
| 33 | return { | ||
| 34 | address: url.href, | ||
| 35 | copyable: true, | ||
| 36 | note: isLocalLoopbackHost(url.hostname) | ||
| 37 | ? "Only this computer can call this address. Link T3 Connect for a public URL." | ||
| 38 | : "Works wherever this environment's address is reachable, for example over Tailscale or your own proxy. Link T3 Connect for a public URL.", | ||
| 39 | }; | ||
| 40 | } | ||