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.
- package/README.md +2 -2
- package/dist/module-architecture.js +2 -0
- package/dist/surfaces.js +7 -2
- package/dist/update.js +52 -2
- package/package.json +3 -3
- package/templates/.env.example +7 -0
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +21 -5
- package/templates/apps/api/vercel.json +7 -1
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +13 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/layout.tsx +15 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +118 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +14 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +289 -1
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
- package/templates/apps/web/lib/relay.ts +116 -1
- package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
- package/templates/apps/web/messages/en.json +86 -1
- package/templates/apps/web/middleware.ts +8 -1
- package/templates/apps/web/next.config.mjs +9 -1
- package/templates/apps/web/package.json +2 -1
- 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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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: `${
|
|
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={
|
|
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
|
-
|
|
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>`.
|
|
521
|
-
*
|
|
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
|
|
531
|
-
|
|
532
|
-
|
|
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
|
}
|