create-ailk 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +2 -2
  2. package/dist/module-architecture.js +2 -0
  3. package/dist/surfaces.js +7 -2
  4. package/dist/update.js +52 -2
  5. package/package.json +3 -3
  6. package/templates/.env.example +7 -0
  7. package/templates/CLAUDE.md +20 -20
  8. package/templates/README.md +19 -19
  9. package/templates/apps/api/.env.example +7 -0
  10. package/templates/apps/api/CLAUDE.md +21 -5
  11. package/templates/apps/api/vercel.json +7 -1
  12. package/templates/apps/web/.env.example +13 -0
  13. package/templates/apps/web/CLAUDE.md +13 -6
  14. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  15. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
  16. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  17. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
  18. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
  19. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
  20. package/templates/apps/web/app/[locale]/layout.tsx +15 -0
  21. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +118 -0
  22. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +14 -0
  23. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  24. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  25. package/templates/apps/web/lib/__tests__/relay.test.ts +289 -1
  26. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
  27. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
  28. package/templates/apps/web/lib/relay.ts +116 -1
  29. package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
  30. package/templates/apps/web/messages/en.json +86 -1
  31. package/templates/apps/web/middleware.ts +8 -1
  32. package/templates/apps/web/next.config.mjs +9 -1
  33. package/templates/apps/web/package.json +2 -1
  34. package/versions.json +47 -47
@@ -10,10 +10,17 @@
10
10
  //
11
11
  // Spec: docs/specs/7644-the-browser-talks-only-to-spec.md §5.1 AC-1 to AC-4.
12
12
 
13
- import { handleRelay, RELAY_ROUTES } from "../relay";
13
+ import { BOT_CHECKED_ROUTES, handleRelay, RELAY_ROUTES } from "../relay";
14
14
  import { __resetRelayRateLimit, __relayRateLimitBucketCount } from "../relay";
15
15
  import { TOPOLOGY } from "../topology.generated";
16
16
 
17
+ // #9889 — the booking bot check's verdict. `checkBotId` reads Vercel's
18
+ // request context, which jest has none of, so every case decides it here.
19
+ const checkBotIdMock = jest.fn();
20
+ jest.mock("botid/server", () => ({
21
+ checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
22
+ }));
23
+
17
24
  const HUB = "https://hub.example";
18
25
  const SITE = "https://spoke.example";
19
26
  const K1 = "K1";
@@ -27,6 +34,8 @@ const ENV_KEYS = [
27
34
  "NEXT_PUBLIC_API_URL",
28
35
  "AILK_SPOKE_KEY",
29
36
  "AILK_VISITOR_FORWARD_KEY",
37
+ "VERCEL",
38
+ "AILK_BOOKING_BOT_CHECK",
30
39
  ] as const;
31
40
  const priorEnv = new Map<string, string | undefined>(
32
41
  ENV_KEYS.map((name) => [name, process.env[name]]),
@@ -885,3 +894,282 @@ describe("the per-visitor rate limit (#7707)", () => {
885
894
  );
886
895
  });
887
896
  });
897
+
898
+ // The booking bot check (#9889, spec
899
+ // docs/specs/9889-the-booking-bot-check-vercel-spec.md §5.1 AC-1 to AC-5).
900
+ describe("the booking bot check (#9889)", () => {
901
+ const BOOK = "/v1/schedule/book";
902
+ const BOT = {
903
+ isHuman: false,
904
+ isBot: true,
905
+ isVerifiedBot: false,
906
+ bypassed: false,
907
+ };
908
+ const HUMAN = {
909
+ isHuman: true,
910
+ isBot: false,
911
+ isVerifiedBot: false,
912
+ bypassed: false,
913
+ };
914
+
915
+ let warnSpy: jest.SpyInstance;
916
+
917
+ beforeEach(() => {
918
+ checkBotIdMock.mockReset();
919
+ process.env.VERCEL = "1";
920
+ warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
921
+ });
922
+
923
+ afterEach(() => {
924
+ warnSpy.mockRestore();
925
+ });
926
+
927
+ describe("AC-1 — a bot verdict is refused before the hub is called", () => {
928
+ it.each([
929
+ ["a bot", BOT],
930
+ ["a verified bot", { ...BOT, isVerifiedBot: true }],
931
+ ])("%s answers 403 bot_detected", async (_label, verdict) => {
932
+ checkBotIdMock.mockResolvedValue(verdict);
933
+ const res = await handleRelay(relayRequest(BOOK));
934
+ expect(res.status).toBe(403);
935
+ expect(await errorCode(res)).toBe("bot_detected");
936
+ expect(res.headers.get("cache-control")).toBe("no-store");
937
+ expect(fetchMock).not.toHaveBeenCalled();
938
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
939
+ expect(checkBotIdMock.mock.calls[0]).toEqual([]);
940
+ expect(warnSpy).toHaveBeenCalledTimes(1);
941
+ expect(warnSpy).toHaveBeenCalledWith("[relay] bot refused", {
942
+ path: BOOK,
943
+ outcome: "bot-detected",
944
+ });
945
+ });
946
+ });
947
+
948
+ describe("AC-2 — a human verdict forwards exactly as with the check off", () => {
949
+ it("the same URL, method, body and headers, and no BotID header", async () => {
950
+ checkBotIdMock.mockResolvedValue(HUMAN);
951
+ const botIdHeaders = {
952
+ "x-is-human": "{}",
953
+ "x-path": BOOK,
954
+ "x-method": "POST",
955
+ };
956
+
957
+ delete process.env.VERCEL;
958
+ expect((await handleRelay(relayRequest(BOOK, { headers: botIdHeaders }))).status).toBe(201);
959
+ process.env.VERCEL = "1";
960
+ expect((await handleRelay(relayRequest(BOOK, { headers: botIdHeaders }))).status).toBe(201);
961
+
962
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
963
+ expect(fetchMock).toHaveBeenCalledTimes(2);
964
+ const [off, on] = fetchMock.mock.calls.map(([url, init]) => ({
965
+ url,
966
+ method: init.method,
967
+ body: init.body,
968
+ headers: [...new Headers(init.headers).entries()],
969
+ }));
970
+ expect(on).toEqual(off);
971
+ const names = (on?.headers ?? []).map(([name]) => name);
972
+ for (const name of Object.keys(botIdHeaders)) {
973
+ expect(names).not.toContain(name);
974
+ }
975
+ });
976
+ });
977
+
978
+ describe("AC-3 — a check that cannot decide fails closed", () => {
979
+ function expectUnavailable(error: string): void {
980
+ expect(fetchMock).not.toHaveBeenCalled();
981
+ expect(errorSpy).toHaveBeenCalledTimes(1);
982
+ expect(errorSpy).toHaveBeenCalledWith("[relay] bot check failed", {
983
+ path: BOOK,
984
+ outcome: "bot-check-failed",
985
+ error,
986
+ });
987
+ }
988
+
989
+ it("a throw answers 503 and logs the error's name, never its message", async () => {
990
+ checkBotIdMock.mockRejectedValue(new Error("token abc"));
991
+ const res = await handleRelay(relayRequest(BOOK));
992
+ expect(res.status).toBe(503);
993
+ expect(await errorCode(res)).toBe("bot_check_unavailable");
994
+ expectUnavailable("Error");
995
+ expect(JSON.stringify(errorSpy.mock.calls)).not.toContain("token abc");
996
+ });
997
+
998
+ it.each([
999
+ ["an empty verdict", {}],
1000
+ [
1001
+ "Vercel's error answer",
1002
+ {
1003
+ isHuman: undefined,
1004
+ isBot: undefined,
1005
+ isVerifiedBot: undefined,
1006
+ bypassed: undefined,
1007
+ },
1008
+ ],
1009
+ ["a non-boolean isBot", { ...HUMAN, isBot: "false" }],
1010
+ ])("%s answers 503 InvalidVerdictError", async (_label, verdict) => {
1011
+ checkBotIdMock.mockResolvedValue(verdict);
1012
+ const res = await handleRelay(relayRequest(BOOK));
1013
+ expect(res.status).toBe(503);
1014
+ expect(await errorCode(res)).toBe("bot_check_unavailable");
1015
+ expectUnavailable("InvalidVerdictError");
1016
+ });
1017
+
1018
+ describe("with fake timers", () => {
1019
+ beforeEach(() => {
1020
+ jest.useFakeTimers({
1021
+ doNotFake: ["nextTick", "queueMicrotask", "setImmediate"],
1022
+ });
1023
+ });
1024
+ afterEach(() => {
1025
+ jest.useRealTimers();
1026
+ });
1027
+
1028
+ it("no verdict within 5 000 ms answers 503 TimeoutError", async () => {
1029
+ checkBotIdMock.mockReturnValue(new Promise(() => {}));
1030
+ const pending = handleRelay(relayRequest(BOOK));
1031
+ await jest.advanceTimersByTimeAsync(5_000);
1032
+ const res = await pending;
1033
+ expect(res.status).toBe(503);
1034
+ expect(await errorCode(res)).toBe("bot_check_unavailable");
1035
+ expectUnavailable("TimeoutError");
1036
+ });
1037
+
1038
+ it("a check that settles in time leaves no timer pending", async () => {
1039
+ checkBotIdMock.mockResolvedValue(BOT);
1040
+ const res = await handleRelay(relayRequest(BOOK));
1041
+ expect(res.status).toBe(403);
1042
+ expect(jest.getTimerCount()).toBe(0);
1043
+ });
1044
+ });
1045
+ });
1046
+
1047
+ describe("AC-4 — the switch", () => {
1048
+ it.each([
1049
+ ["VERCEL=1, AILK_BOOKING_BOT_CHECK=off", "1", "off"],
1050
+ ['VERCEL=1, AILK_BOOKING_BOT_CHECK=" OFF "', "1", " OFF "],
1051
+ ["VERCEL unset, switch unset", undefined, undefined],
1052
+ ])("%s forwards with no BotID call", async (_label, vercel, check) => {
1053
+ checkBotIdMock.mockResolvedValue(BOT);
1054
+ if (vercel === undefined) delete process.env.VERCEL;
1055
+ else process.env.VERCEL = vercel;
1056
+ if (check === undefined) delete process.env.AILK_BOOKING_BOT_CHECK;
1057
+ else process.env.AILK_BOOKING_BOT_CHECK = check;
1058
+ const res = await handleRelay(relayRequest(BOOK));
1059
+ expect(res.status).toBe(201);
1060
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1061
+ expect(checkBotIdMock).not.toHaveBeenCalled();
1062
+ });
1063
+
1064
+ it.each(["on", "0"])(
1065
+ "VERCEL=1, AILK_BOOKING_BOT_CHECK=%s keeps the check on",
1066
+ async (check) => {
1067
+ checkBotIdMock.mockResolvedValue(BOT);
1068
+ process.env.AILK_BOOKING_BOT_CHECK = check;
1069
+ const res = await handleRelay(relayRequest(BOOK));
1070
+ expect(res.status).toBe(403);
1071
+ expect(await errorCode(res)).toBe("bot_detected");
1072
+ expect(fetchMock).not.toHaveBeenCalled();
1073
+ },
1074
+ );
1075
+
1076
+ describe("in production, off Vercel", () => {
1077
+ const env = process.env as Record<string, string | undefined>;
1078
+ const priorNodeEnv = env.NODE_ENV;
1079
+ beforeEach(() => {
1080
+ env.NODE_ENV = "production";
1081
+ delete process.env.VERCEL;
1082
+ });
1083
+ afterEach(() => {
1084
+ env.NODE_ENV = priorNodeEnv;
1085
+ });
1086
+
1087
+ async function twoBookings(): Promise<void> {
1088
+ await jest.isolateModulesAsync(async () => {
1089
+ const fresh = await import("../relay");
1090
+ for (let i = 0; i < 2; i++) {
1091
+ const res = await fresh.handleRelay(relayRequest(BOOK));
1092
+ expect(res.status).toBe(201);
1093
+ }
1094
+ });
1095
+ }
1096
+
1097
+ it("warns once per process that the check is not running", async () => {
1098
+ await twoBookings();
1099
+ expect(checkBotIdMock).not.toHaveBeenCalled();
1100
+ expect(warnSpy).toHaveBeenCalledTimes(1);
1101
+ expect(warnSpy).toHaveBeenCalledWith(
1102
+ "[relay] booking bot check inactive",
1103
+ { outcome: "not-on-vercel" },
1104
+ );
1105
+ });
1106
+
1107
+ it("with the switch off, warns nothing", async () => {
1108
+ process.env.AILK_BOOKING_BOT_CHECK = "off";
1109
+ await twoBookings();
1110
+ expect(checkBotIdMock).not.toHaveBeenCalled();
1111
+ expect(warnSpy).not.toHaveBeenCalled();
1112
+ });
1113
+ });
1114
+ });
1115
+
1116
+ describe("AC-5 — only the booking, and only after the local refusals", () => {
1117
+ beforeEach(() => {
1118
+ checkBotIdMock.mockResolvedValue(BOT);
1119
+ });
1120
+
1121
+ it.each(ENTRIES.filter((e) => e.path !== BOOK))(
1122
+ "$method $path forwards with no BotID call",
1123
+ async (entry) => {
1124
+ const res = await handleRelay(entryRequest(entry));
1125
+ expect(res.status).toBe(201);
1126
+ expect(fetchMock).toHaveBeenCalledTimes(1);
1127
+ expect(checkBotIdMock).not.toHaveBeenCalled();
1128
+ },
1129
+ );
1130
+
1131
+ it("a cross-site booking answers 403 cross_site with no BotID call", async () => {
1132
+ const res = await handleRelay(
1133
+ relayRequest(BOOK, { headers: { "sec-fetch-site": "cross-site" } }),
1134
+ );
1135
+ expect(res.status).toBe(403);
1136
+ expect(await errorCode(res)).toBe("cross_site");
1137
+ expect(checkBotIdMock).not.toHaveBeenCalled();
1138
+ });
1139
+
1140
+ it("an over-budget booking answers 429 with no BotID call", async () => {
1141
+ const visitor = { "x-forwarded-for": "203.0.113.9" };
1142
+ for (let i = 0; i < 8; i++) {
1143
+ await handleRelay(relayRequest("/v1/leads/submit", { headers: visitor }));
1144
+ }
1145
+ const res = await handleRelay(relayRequest(BOOK, { headers: visitor }));
1146
+ expect(res.status).toBe(429);
1147
+ expect(await errorCode(res)).toBe("rate_limited");
1148
+ expect(checkBotIdMock).not.toHaveBeenCalled();
1149
+ });
1150
+
1151
+ it("a non-JSON booking answers 415 with no BotID call", async () => {
1152
+ const res = await handleRelay(
1153
+ relayRequest(BOOK, { headers: { "content-type": "text/plain" } }),
1154
+ );
1155
+ expect(res.status).toBe(415);
1156
+ expect(checkBotIdMock).not.toHaveBeenCalled();
1157
+ });
1158
+
1159
+ it("an oversized booking answers 413 with no BotID call", async () => {
1160
+ const res = await handleRelay(
1161
+ relayRequest(BOOK, { body: "x".repeat(1_048_577) }),
1162
+ );
1163
+ expect(res.status).toBe(413);
1164
+ expect(checkBotIdMock).not.toHaveBeenCalled();
1165
+ });
1166
+
1167
+ it("BOT_CHECKED_ROUTES is the booking pair, frozen, and RELAY_ROUTES' own entry", () => {
1168
+ expect(BOT_CHECKED_ROUTES).toEqual([{ method: "POST", path: BOOK }]);
1169
+ expect(Object.isFrozen(BOT_CHECKED_ROUTES)).toBe(true);
1170
+ expect(BOT_CHECKED_ROUTES[0]).toBe(
1171
+ RELAY_ROUTES.find((r) => r.method === "POST" && r.path === BOOK),
1172
+ );
1173
+ });
1174
+ });
1175
+ });
@@ -4,6 +4,10 @@
4
4
  * the one card's frame — the mark in the tile, "Sign in to <site>", the toggle
5
5
  * line to the hub's /sign-up (or the site's own prospect line when its
6
6
  * messages carry one), and the Terms + Privacy acknowledgement.
7
+ *
8
+ * #9912 D1: that toggle reaches the hub's /sign-up through the same round trip
9
+ * as Sign in — this site's own sign-in route with `prompt=create` and the
10
+ * same `next` — so completing sign-up returns the buyer here.
7
11
  */
8
12
  import { render, screen, within } from "@testing-library/react";
9
13
  import en from "../../../messages/en.json";
@@ -94,7 +98,11 @@ describe("<HubSignInGate/> (#8790 AC-1; #9662)", () => {
94
98
  );
95
99
  expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
96
100
  "href",
97
- "https://hub.example/sign-up",
101
+ "/api/hub-session/sign-in?next=%2Faccount&prompt=create",
102
+ );
103
+ expect(screen.getByTestId("account-hub-sign-in")).toHaveAttribute(
104
+ "href",
105
+ "/api/hub-session/sign-in?next=%2Faccount",
98
106
  );
99
107
  const footnote = screen.getByTestId("auth-card-footnote");
100
108
  expect(footnote).toHaveTextContent(
@@ -107,6 +115,18 @@ describe("<HubSignInGate/> (#8790 AC-1; #9662)", () => {
107
115
  within(footnote).getByRole("link", { name: "Privacy Policy" }),
108
116
  ).toHaveAttribute("href", "/privacy");
109
117
  });
118
+
119
+ it("starts Sign up with the requested path as next, the Sign in button's own URL plus prompt=create (#9912 D1, AC-1)", async () => {
120
+ await renderGate("/account/billing");
121
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
122
+ "href",
123
+ "/api/hub-session/sign-in?next=%2Faccount%2Fbilling&prompt=create",
124
+ );
125
+ expect(screen.getByTestId("account-hub-sign-in")).toHaveAttribute(
126
+ "href",
127
+ "/api/hub-session/sign-in?next=%2Faccount%2Fbilling",
128
+ );
129
+ });
110
130
  });
111
131
 
112
132
  describe("<HubSignInGate/> prospect line (#8790 AC-2)", () => {
@@ -131,7 +151,7 @@ describe("<HubSignInGate/> prospect line (#8790 AC-2)", () => {
131
151
  await renderGate();
132
152
  expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
133
153
  "href",
134
- "https://hub.example/sign-up",
154
+ "/api/hub-session/sign-in?next=%2Faccount&prompt=create",
135
155
  );
136
156
  });
137
157
 
@@ -17,12 +17,14 @@
17
17
  * The one card (#9662, operator 2026-10-24): the single hub action sits in
18
18
  * `AuthCard`'s frame — the site's mark in the 48px tile, "Sign in to <site>"
19
19
  * (`SITE_NAME`, the deployment's own name, never the request's), the
20
- * "Don't have an account? Sign up" line to the hub's `/sign-up`, and the
21
- * Terms + Privacy acknowledgement on this site's own policy routes. A site
22
- * that fills `account.gate.prospectPrompt` + `prospectLink` + `prospectHref`
23
- * replaces the toggle line with its own. The acknowledgement is built here
24
- * rather than from the `(auth)` group's builder: a spoke carries no `(auth)`
25
- * group (create-ailk's `SPOKE_REMOVAL_SEEDS`).
20
+ * "Don't have an account? Sign up" line to this site's own sign-in route with
21
+ * `prompt=create`, which lands on the hub's sign-up screen and returns here
22
+ * (#9912 D1), and the Terms + Privacy acknowledgement on this site's own
23
+ * policy routes. A site that fills `account.gate.prospectPrompt` +
24
+ * `prospectLink` + `prospectHref` replaces the toggle line with its own. The
25
+ * acknowledgement is built here rather than from the `(auth)` group's
26
+ * builder: a spoke carries no `(auth)` group (create-ailk's
27
+ * `SPOKE_REMOVAL_SEEDS`).
26
28
  *
27
29
  * Not configured (`hubSessionConfig()` is `null`: a client id, secret or
28
30
  * session secret missing), the card says "sign-in isn't available yet" and
@@ -60,6 +62,7 @@ export async function HubSignInGate({
60
62
  const next =
61
63
  requestHeaders.get("x-pathname") ??
62
64
  localeHref(locale, HUB_SESSION_FALLBACK_PATH);
65
+ const signInHref = `${HUB_SIGN_IN_ROUTE}?next=${encodeURIComponent(next)}`;
63
66
  const home = localeHref(locale, "/");
64
67
  // The three prospect keys are optional extensions of the typed catalog (the
65
68
  // shipped en.json carries none — every leaf must be a non-empty string), so
@@ -78,7 +81,7 @@ export async function HubSignInGate({
78
81
  : {
79
82
  prompt: signIn("prospectPrompt"),
80
83
  linkLabel: signIn("prospectLink"),
81
- href: `${config.hubOrigin}/sign-up`,
84
+ href: `${signInHref}&prompt=create`,
82
85
  };
83
86
 
84
87
  return (
@@ -114,7 +117,7 @@ export async function HubSignInGate({
114
117
  // The primary action: full width at `big` (44px).
115
118
  <Button asChild size="big" className="w-full">
116
119
  <Link
117
- href={`${HUB_SIGN_IN_ROUTE}?next=${encodeURIComponent(next)}`}
120
+ href={signInHref}
118
121
  variant="bare"
119
122
  data-testid="account-hub-sign-in"
120
123
  >
@@ -35,12 +35,20 @@
35
35
  * and only as strong as the platform: it holds only where the site's edge
36
36
  * overwrites `x-forwarded-for` and `x-real-ip` (Vercel does; a bare
37
37
  * self-hosted Next server does not).
38
+ * - A BOT CHECK ON THE BOOKING (#9889). `POST /v1/schedule/book` asks
39
+ * Vercel BotID (`checkBotId`) for a verdict after every refusal above and
40
+ * before the hub is called. Only a human verdict forwards; a bot is
41
+ * `403 bot_detected`, and a check that throws, answers no boolean or
42
+ * takes over 5 seconds is `503 bot_check_unavailable` — either way the
43
+ * hub is never called. It runs only on Vercel (`VERCEL=1`), unless
44
+ * `AILK_BOOKING_BOT_CHECK=off`.
38
45
  *
39
46
  * NO SECRET IS LOGGED. A failure logs `{ path, outcome }` and nothing else.
40
47
  */
41
48
  import { isIP } from "node:net";
42
49
 
43
50
  import { relayToApi, visitorForwardHeaders } from "@working-theory/api-client";
51
+ import { checkBotId } from "botid/server";
44
52
 
45
53
  import { browserApiOrigin } from "./api-origin.generated";
46
54
  import { readConsentVisitorFromCookieHeader } from "./consent-visitor";
@@ -69,15 +77,92 @@ function route(method: RelayMethod, path: string): RelayRoute {
69
77
  */
70
78
  const SESSION_ROUTE = route("POST", "/v1/commerce/invoices");
71
79
 
80
+ /** The booking pair, the one route the bot check guards (#9889 D1). */
81
+ const BOOK_ROUTE = route("POST", "/v1/schedule/book");
82
+
72
83
  export const RELAY_ROUTES: readonly RelayRoute[] = Object.freeze([
73
84
  route("POST", "/v1/leads/submit"),
74
85
  route("POST", "/v1/leads/subscribe"),
75
86
  route("POST", "/v1/leads/callback"),
76
87
  route("GET", "/v1/schedule/availability"),
77
- route("POST", "/v1/schedule/book"),
88
+ BOOK_ROUTE,
78
89
  SESSION_ROUTE,
79
90
  ]);
80
91
 
92
+ /**
93
+ * The entries the bot check guards (#9889 D1, D4). The locale layout passes
94
+ * the same list to `BotIdClient`, so the browser protects exactly the pairs
95
+ * the server checks: a checked pair the client does not protect would carry
96
+ * no challenge header and be refused as a bot.
97
+ */
98
+ export const BOT_CHECKED_ROUTES: readonly RelayRoute[] = Object.freeze([
99
+ BOOK_ROUTE,
100
+ ]);
101
+
102
+ /** The longest the relay waits for a verdict; the library's own fetch has no bound. */
103
+ const BOT_CHECK_TIMEOUT_MS = 5_000;
104
+
105
+ /**
106
+ * True when the booking bot check runs (#9889 D2): on Vercel, unless
107
+ * `AILK_BOOKING_BOT_CHECK` is `off`. Unset, empty or any other value leaves
108
+ * it on, so a typo fails toward protection. Read on every call, never cached.
109
+ */
110
+ export function bookingBotCheckOn(): boolean {
111
+ return process.env.VERCEL === "1" && !botCheckSwitchedOff();
112
+ }
113
+
114
+ function botCheckSwitchedOff(): boolean {
115
+ return process.env.AILK_BOOKING_BOT_CHECK?.trim().toLowerCase() === "off";
116
+ }
117
+
118
+ let warnedBotCheckInactive = false;
119
+
120
+ /**
121
+ * Called when the check is off. Off Vercel it cannot run (D2): a production
122
+ * site whose switch is not `off` logs that once per process, so an owner who
123
+ * believes it is on can find out. It never refuses.
124
+ */
125
+ function warnBotCheckInactiveOnce(): void {
126
+ if (warnedBotCheckInactive || process.env.NODE_ENV !== "production") return;
127
+ if (botCheckSwitchedOff()) return;
128
+ warnedBotCheckInactive = true;
129
+ console.warn("[relay] booking bot check inactive", {
130
+ outcome: "not-on-vercel",
131
+ });
132
+ }
133
+
134
+ function namedError(name: string): Error {
135
+ const err = new Error(name);
136
+ err.name = name;
137
+ return err;
138
+ }
139
+
140
+ /**
141
+ * BotID's verdict on this request (D1, D3): `true` for a bot, `false` for a
142
+ * human. Throws `TimeoutError` past `BOT_CHECK_TIMEOUT_MS` and
143
+ * `InvalidVerdictError` when `isBot` is not a boolean, as `checkBotId` returns
144
+ * for an error answer from Vercel.
145
+ */
146
+ async function isBotVerdict(): Promise<boolean> {
147
+ let timer: ReturnType<typeof setTimeout> | undefined;
148
+ try {
149
+ const verdict = await Promise.race([
150
+ checkBotId(),
151
+ new Promise<never>((_resolve, reject) => {
152
+ timer = setTimeout(
153
+ () => reject(namedError("TimeoutError")),
154
+ BOT_CHECK_TIMEOUT_MS,
155
+ );
156
+ }),
157
+ ]);
158
+ const isBot = (verdict as { isBot?: unknown } | undefined)?.isBot;
159
+ if (typeof isBot !== "boolean") throw namedError("InvalidVerdictError");
160
+ return isBot;
161
+ } finally {
162
+ clearTimeout(timer);
163
+ }
164
+ }
165
+
81
166
  /**
82
167
  * The three lead routes (#8438 D11, D12): a visitor's banner choice is
83
168
  * linked to a Person only through these — a booking has no consent writer.
@@ -466,6 +551,36 @@ export async function handleRelay(request: Request): Promise<Response> {
466
551
  return refusal(502, "relay_unreachable", "The site could not reach its API");
467
552
  }
468
553
 
554
+ if (BOT_CHECKED_ROUTES.includes(entry)) {
555
+ if (bookingBotCheckOn()) {
556
+ let isBot: boolean;
557
+ try {
558
+ isBot = await isBotVerdict();
559
+ } catch (err) {
560
+ // The error's name only — never its message (#9889 D1).
561
+ console.error("[relay] bot check failed", {
562
+ path: entry.path,
563
+ outcome: "bot-check-failed",
564
+ error: err instanceof Error ? err.name : "unknown",
565
+ });
566
+ return refusal(
567
+ 503,
568
+ "bot_check_unavailable",
569
+ "The booking check is unavailable",
570
+ );
571
+ }
572
+ if (isBot) {
573
+ console.warn("[relay] bot refused", {
574
+ path: entry.path,
575
+ outcome: "bot-detected",
576
+ });
577
+ return refusal(403, "bot_detected", "Request refused");
578
+ }
579
+ } else {
580
+ warnBotCheckInactiveOnce();
581
+ }
582
+ }
583
+
469
584
  try {
470
585
  const result = await relayToApi(
471
586
  entry.method === "GET" ? `${entry.path}${url.search}` : entry.path,
@@ -517,8 +517,13 @@ type HookContext = Parameters<
517
517
  /**
518
518
  * A refused magic-link verify: the redirect the plugin's own
519
519
  * `redirectWithError` builds — the request's `errorCallbackURL`, else its
520
- * `callbackURL`, else `/`, against the base URL, with `error=<code>`. The
521
- * plugin's origin check has already vetted both URLs.
520
+ * `callbackURL`, else `/`, against the base URL, with `error=<code>`.
521
+ *
522
+ * The query value is used as the router decoded it, never decoded again
523
+ * (#9896): a second decode turns `/%2F%2Fevil.com` into a URL on another
524
+ * host, and a malformed `%` into a throw. Whatever the plugin's origin check
525
+ * allowed, a value that does not resolve on the base URL's origin — or does
526
+ * not parse — is replaced by `/`.
522
527
  */
523
528
  function verifyRefusal(
524
529
  context: NonNullable<HookContext>,
@@ -527,10 +532,16 @@ function verifyRefusal(
527
532
  const raw =
528
533
  bodyField(context.query, "errorCallbackURL") ??
529
534
  bodyField(context.query, "callbackURL");
530
- const url = new URL(
531
- typeof raw === "string" ? decodeURIComponent(raw) : "/",
532
- context.context.baseURL,
533
- );
535
+ const { baseURL } = context.context;
536
+ let url = new URL("/", baseURL);
537
+ if (typeof raw === "string") {
538
+ try {
539
+ const resolved = new URL(raw, baseURL);
540
+ if (resolved.origin === url.origin) url = resolved;
541
+ } catch {
542
+ // Unparseable: stays `/`.
543
+ }
544
+ }
534
545
  url.searchParams.set("error", code);
535
546
  return context.redirect(url.toString());
536
547
  }