create-ailk 0.25.1 → 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 +14 -5
- package/dist/lib/fetch-template-snapshot.d.ts +2 -1
- package/dist/lib/fetch-template-snapshot.js +6 -5
- package/dist/lib/module-license-gate.js +2 -3
- package/dist/lib/module-tier.d.ts +2 -8
- package/dist/lib/module-tier.js +13 -12
- package/dist/module-architecture.js +14 -2
- package/dist/starter-grid.d.ts +6 -6
- package/dist/starter-grid.js +12 -5
- package/dist/surfaces.js +36 -2
- package/dist/template-record.d.ts +11 -3
- package/dist/template-record.js +20 -7
- package/dist/topology.js +23 -4
- package/dist/update.d.ts +1 -1
- package/dist/update.js +240 -20
- package/package.json +3 -3
- package/release-scope.json +1 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
- package/site-templates/marketing-site/ailk-template.json +7 -1
- package/site-templates/marketing-site/provenance.json +7 -1
- package/site-templates/saas-product/ailk-template.json +2 -1
- package/site-templates/saas-product/provenance.json +1 -0
- package/templates/.env.example +22 -0
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +28 -0
- package/templates/apps/api/CLAUDE.md +35 -9
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
- package/templates/apps/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +27 -10
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +34 -6
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
- package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
- 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 +168 -8
- 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 +22 -39
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +301 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -18
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
- package/templates/apps/web/app/[locale]/layout.tsx +48 -13
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -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/auth.ts +8 -4
- package/templates/apps/web/i18n/request.ts +49 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +289 -1
- package/templates/apps/web/lib/auth-email.ts +102 -20
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +92 -23
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +97 -70
- package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
- package/templates/apps/web/lib/relay.ts +116 -1
- package/templates/apps/web/lib/sign-up-agreements.ts +302 -28
- package/templates/apps/web/lib/site-contributions.ts +18 -0
- package/templates/apps/web/messages/en.json +271 -20
- package/templates/apps/web/messages/site/en.json +1 -0
- 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 +48 -48
|
@@ -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
|
+
});
|
|
@@ -37,12 +37,24 @@ import { createHmac } from "node:crypto";
|
|
|
37
37
|
|
|
38
38
|
import { authEmail } from "@working-theory/api-client";
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
import { isSignUpOpen } from "./auth-env";
|
|
41
|
+
import {
|
|
42
|
+
bindLinkAgreements,
|
|
43
|
+
signUpMetadata,
|
|
44
|
+
type LinkAgreements,
|
|
45
|
+
type LinkAgreementsStore,
|
|
46
|
+
} from "./sign-up-agreements";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The five fixed kinds the relay will send. A caller picks no copy: a sign-up
|
|
50
|
+
* link sends agreement ids and a locale, which the API names itself (#9720).
|
|
51
|
+
*/
|
|
41
52
|
export type AuthEmailKind =
|
|
42
53
|
| "password-reset"
|
|
43
54
|
| "email-verification"
|
|
44
55
|
| "password-changed"
|
|
45
|
-
| "magic-link"
|
|
56
|
+
| "magic-link"
|
|
57
|
+
| "magic-link-sign-up";
|
|
46
58
|
|
|
47
59
|
export interface AuthEmailPayload {
|
|
48
60
|
kind: AuthEmailKind;
|
|
@@ -50,6 +62,8 @@ export interface AuthEmailPayload {
|
|
|
50
62
|
to: string;
|
|
51
63
|
url?: string;
|
|
52
64
|
expiresInMinutes?: number;
|
|
65
|
+
agreements?: string[];
|
|
66
|
+
locale?: string;
|
|
53
67
|
}
|
|
54
68
|
|
|
55
69
|
export interface AuthEmailRequest {
|
|
@@ -116,6 +130,10 @@ export function buildAuthEmailRequest(
|
|
|
116
130
|
...(payload.expiresInMinutes !== undefined
|
|
117
131
|
? { expiresInMinutes: payload.expiresInMinutes }
|
|
118
132
|
: {}),
|
|
133
|
+
...(payload.agreements !== undefined
|
|
134
|
+
? { agreements: payload.agreements }
|
|
135
|
+
: {}),
|
|
136
|
+
...(payload.locale !== undefined ? { locale: payload.locale } : {}),
|
|
119
137
|
});
|
|
120
138
|
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
121
139
|
const signature = `sha256=${createHmac("sha256", secret)
|
|
@@ -365,7 +383,7 @@ export const MAGIC_LINK_TTL_SECONDS = 300;
|
|
|
365
383
|
*/
|
|
366
384
|
export interface MagicLinkEndpointContext {
|
|
367
385
|
context: {
|
|
368
|
-
internalAdapter: {
|
|
386
|
+
internalAdapter: LinkAgreementsStore & {
|
|
369
387
|
findUserByEmail(
|
|
370
388
|
email: string,
|
|
371
389
|
): Promise<{ user: { id: string; email: string } } | null>;
|
|
@@ -390,14 +408,32 @@ export interface MagicLinkEndpointContext {
|
|
|
390
408
|
* branches cost one indexed read; the send goes to `runInBackgroundOrAwait`,
|
|
391
409
|
* which `auth.ts`'s `backgroundTasks.handler` runs after the response.
|
|
392
410
|
*
|
|
411
|
+
* A SIGN-UP REQUEST (#9667 D4) — one whose `metadata` carries the agreements,
|
|
412
|
+
* which `lib/sign-up-agreements.ts`'s plugin has already checked — binds them
|
|
413
|
+
* to the token, then mails the address whether or not it has an account: a
|
|
414
|
+
* sign-in link to an existing account, a sign-up link to a new address (whose
|
|
415
|
+
* `userId` is the fixed sentinel `"sign-up"`; the relay only logs it). The
|
|
416
|
+
* sign-up link is its own kind, `magic-link-sign-up`, carrying the agreement
|
|
417
|
+
* ids and locale the binding holds, so the email says an account will be
|
|
418
|
+
* created and names what it records (#9720 D6). The kind is picked from two
|
|
419
|
+
* values already in hand, inside the same deferred send, so the answer and
|
|
420
|
+
* its timing do not depend on it. A failed binding mails only an existing
|
|
421
|
+
* account. A sign-in request is unchanged: an unknown address gets nothing.
|
|
422
|
+
*
|
|
393
423
|
* NOTHING HERE THROWS, so a known and an unknown address both reach the
|
|
394
424
|
* plugin's `{ status: true }`.
|
|
395
425
|
*/
|
|
396
|
-
function makeSendMagicLink(deps:
|
|
426
|
+
function makeSendMagicLink(deps: MagicLinkDeps) {
|
|
397
427
|
const { send } = deps;
|
|
398
428
|
const log = deps.log ?? defaultLog;
|
|
429
|
+
const bind = deps.bind ?? bindLinkAgreements;
|
|
399
430
|
return async function sendMagicLink(
|
|
400
|
-
data: {
|
|
431
|
+
data: {
|
|
432
|
+
email: string;
|
|
433
|
+
url: string;
|
|
434
|
+
token: string;
|
|
435
|
+
metadata?: Record<string, unknown>;
|
|
436
|
+
},
|
|
401
437
|
ctx?: MagicLinkEndpointContext,
|
|
402
438
|
): Promise<void> {
|
|
403
439
|
let found: { user: { id: string; email: string } } | null;
|
|
@@ -413,37 +449,83 @@ function makeSendMagicLink(deps: DispatchDeps) {
|
|
|
413
449
|
return;
|
|
414
450
|
}
|
|
415
451
|
|
|
416
|
-
|
|
452
|
+
// A sign-up request (#9667 D2, D4): `metadata` carries the agreements,
|
|
453
|
+
// which the sign-up-agreements plugin has already checked. The binding
|
|
454
|
+
// is written whether or not the address has an account, so the work
|
|
455
|
+
// before the response never depends on it.
|
|
456
|
+
let binding: LinkAgreements | null = null;
|
|
457
|
+
const signUp = isSignUpOpen() ? signUpMetadata(data) : null;
|
|
458
|
+
if (signUp) {
|
|
459
|
+
try {
|
|
460
|
+
binding = await bind(
|
|
461
|
+
ctx.context.internalAdapter,
|
|
462
|
+
data.token,
|
|
463
|
+
signUp,
|
|
464
|
+
MAGIC_LINK_TTL_SECONDS,
|
|
465
|
+
);
|
|
466
|
+
} catch {
|
|
467
|
+
log.warn({
|
|
468
|
+
kind: "magic-link",
|
|
469
|
+
reason: "send_failed",
|
|
470
|
+
errorKind: "bind_failed",
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// A new address has no user yet; the relay only logs `userId`, so a
|
|
476
|
+
// sign-up link carries the fixed sentinel and the binding's ids, never
|
|
477
|
+
// the request's (#9720 D6).
|
|
478
|
+
let payload: AuthEmailPayload;
|
|
479
|
+
if (found) {
|
|
480
|
+
payload = {
|
|
481
|
+
kind: "magic-link",
|
|
482
|
+
userId: found.user.id,
|
|
483
|
+
to: found.user.email,
|
|
484
|
+
url: data.url,
|
|
485
|
+
};
|
|
486
|
+
} else if (binding) {
|
|
487
|
+
payload = {
|
|
488
|
+
kind: "magic-link-sign-up",
|
|
489
|
+
userId: "sign-up",
|
|
490
|
+
to: data.email,
|
|
491
|
+
url: data.url,
|
|
492
|
+
agreements: binding.agreements,
|
|
493
|
+
locale: binding.locale,
|
|
494
|
+
};
|
|
495
|
+
} else {
|
|
417
496
|
// No address in the line — the PII boundary holds for unknown addresses
|
|
418
497
|
// too, and they are the ones an enumerator types.
|
|
419
498
|
log.info({ kind: "magic-link", reason: "no_account" });
|
|
420
499
|
return;
|
|
421
500
|
}
|
|
422
501
|
|
|
423
|
-
const { user } = found;
|
|
424
502
|
// Not awaited: `runInBackgroundOrAwait` never rejects, and `dispatch`
|
|
425
503
|
// swallows its own failures into a log line.
|
|
426
|
-
void ctx.context.runInBackgroundOrAwait(
|
|
427
|
-
dispatch(
|
|
428
|
-
{ kind: "magic-link", userId: user.id, to: user.email, url: data.url },
|
|
429
|
-
{ send, log },
|
|
430
|
-
),
|
|
431
|
-
);
|
|
504
|
+
void ctx.context.runInBackgroundOrAwait(dispatch(payload, { send, log }));
|
|
432
505
|
};
|
|
433
506
|
}
|
|
434
507
|
|
|
508
|
+
interface MagicLinkDeps extends DispatchDeps {
|
|
509
|
+
/** Binds a sign-up request's agreements to its token (#9667 D4). */
|
|
510
|
+
bind?: typeof bindLinkAgreements;
|
|
511
|
+
}
|
|
512
|
+
|
|
435
513
|
/**
|
|
436
514
|
* The options `auth.ts` hands Better Auth's `magicLink()` plugin (#7435 § D2).
|
|
437
515
|
*
|
|
438
|
-
* `disableSignUp
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
516
|
+
* `disableSignUp` follows `isSignUpOpen()`, read when `createAuth()` builds
|
|
517
|
+
* the plugin — the moment and the predicate `emailAndPassword.disableSignUp`
|
|
518
|
+
* uses, so the two methods never disagree (#9667 D1). Closed, it is defence
|
|
519
|
+
* in depth behind § D3: even a leaked token for an address with no account
|
|
520
|
+
* cannot create one. Open, the verify creates the account only for a token
|
|
521
|
+
* with an agreements binding (`lib/sign-up-agreements.ts`). `storeToken:
|
|
522
|
+
* "hashed"` keeps a database read from yielding a live sign-in token.
|
|
523
|
+
* `allowedAttempts` is left unset — the plugin consumes atomically regardless
|
|
524
|
+
* and warns on any value but 1.
|
|
443
525
|
*/
|
|
444
|
-
export function makeMagicLinkOptions(deps:
|
|
526
|
+
export function makeMagicLinkOptions(deps: MagicLinkDeps) {
|
|
445
527
|
return {
|
|
446
|
-
disableSignUp:
|
|
528
|
+
disableSignUp: !isSignUpOpen(),
|
|
447
529
|
expiresIn: MAGIC_LINK_TTL_SECONDS,
|
|
448
530
|
storeToken: "hashed" as const,
|
|
449
531
|
sendMagicLink: makeSendMagicLink(deps),
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HubSignInGate (#7894 D10) as #8790 AC-1/AC-2 shape it: the site's
|
|
3
|
-
* header (back arrow
|
|
4
|
-
*
|
|
2
|
+
* HubSignInGate (#7894 D10) as #8790 AC-1/AC-2 and #9662 shape it: the site's
|
|
3
|
+
* inner-page header (the back arrow), then the one hub sign-in action inside
|
|
4
|
+
* the one card's frame — the mark in the tile, "Sign in to <site>", the toggle
|
|
5
|
+
* line to the hub's /sign-up (or the site's own prospect line when its
|
|
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.
|
|
5
11
|
*/
|
|
6
|
-
import { render, screen } from "@testing-library/react";
|
|
12
|
+
import { render, screen, within } from "@testing-library/react";
|
|
7
13
|
import en from "../../../messages/en.json";
|
|
14
|
+
import { SITE_NAME } from "../../site-brand";
|
|
8
15
|
import { HubSignInGate } from "../sign-in-gate";
|
|
9
16
|
|
|
10
17
|
type Gate = Record<string, string>;
|
|
@@ -12,9 +19,15 @@ let gateMessages: Gate;
|
|
|
12
19
|
|
|
13
20
|
jest.mock("next-intl/server", () => ({
|
|
14
21
|
__esModule: true,
|
|
15
|
-
getTranslations: jest.fn(async () => {
|
|
16
|
-
const
|
|
17
|
-
|
|
22
|
+
getTranslations: jest.fn(async ({ namespace }: { namespace: string }) => {
|
|
23
|
+
const messages: Gate =
|
|
24
|
+
namespace === "signIn" ? (en.signIn as Gate) : gateMessages;
|
|
25
|
+
const t = (key: string, values?: Record<string, string>) =>
|
|
26
|
+
(messages[key] ?? `copy:${key}`).replace(
|
|
27
|
+
/\{(\w+)\}/g,
|
|
28
|
+
(_match, name: string) => values?.[name] ?? `{${name}}`,
|
|
29
|
+
);
|
|
30
|
+
t.has = (key: string) => key in messages;
|
|
18
31
|
return t;
|
|
19
32
|
}),
|
|
20
33
|
}));
|
|
@@ -42,58 +55,113 @@ async function renderGate(pathname?: string): Promise<void> {
|
|
|
42
55
|
|
|
43
56
|
beforeEach(() => {
|
|
44
57
|
gateMessages = { ...(en.account.gate as Gate) };
|
|
45
|
-
mockConfig
|
|
58
|
+
mockConfig
|
|
59
|
+
.mockReset()
|
|
60
|
+
.mockReturnValue({ clientId: "spoke_1", hubOrigin: "https://hub.example" });
|
|
46
61
|
});
|
|
47
62
|
|
|
48
|
-
describe("<HubSignInGate/> (#8790 AC-1)", () => {
|
|
49
|
-
it("wears the site's inner-page header: a back arrow to the home
|
|
63
|
+
describe("<HubSignInGate/> (#8790 AC-1; #9662)", () => {
|
|
64
|
+
it("wears the site's inner-page header: a back arrow to the home; the mark is the card's tile (#9662)", async () => {
|
|
50
65
|
await renderGate("/account");
|
|
51
66
|
const header = screen.getByTestId("account-sign-in-gate-header");
|
|
52
67
|
const back = screen.getByTestId("nav-back-link");
|
|
53
68
|
expect(header).toContainElement(back);
|
|
54
69
|
expect(back).toHaveAttribute("href", "/");
|
|
55
70
|
expect(back).toHaveAccessibleName(en.account.gate.homeLink);
|
|
56
|
-
expect(screen.getByTestId("
|
|
71
|
+
expect(header).not.toContainElement(screen.getByTestId("site-mark"));
|
|
72
|
+
expect(screen.getByTestId("auth-card-logo")).toContainElement(
|
|
57
73
|
screen.getByTestId("site-mark"),
|
|
58
74
|
);
|
|
59
75
|
});
|
|
60
76
|
|
|
61
|
-
it("
|
|
77
|
+
it("titles the card 'Sign in to <site>' from the deployment's own name and offers the one sign-in action inside it", async () => {
|
|
62
78
|
await renderGate("/account/settings");
|
|
63
|
-
|
|
64
|
-
expect(
|
|
65
|
-
|
|
66
|
-
|
|
79
|
+
// The gate is the whole page, so the card's heading is its one h1.
|
|
80
|
+
expect(
|
|
81
|
+
screen.getByRole("heading", {
|
|
82
|
+
level: 1,
|
|
83
|
+
name: `Sign in to ${SITE_NAME}`,
|
|
84
|
+
}),
|
|
85
|
+
).toBeInTheDocument();
|
|
86
|
+
const action = screen.getByTestId("account-hub-sign-in");
|
|
87
|
+
expect(screen.getByTestId("auth-card")).toContainElement(action);
|
|
88
|
+
expect(action).toHaveAttribute(
|
|
67
89
|
"href",
|
|
68
90
|
"/api/hub-session/sign-in?next=%2Faccount%2Fsettings",
|
|
69
91
|
);
|
|
70
92
|
});
|
|
71
93
|
|
|
72
|
-
it("
|
|
94
|
+
it("carries the toggle line to the hub's /sign-up and the acknowledgement on this site's policy routes (#9662)", async () => {
|
|
73
95
|
await renderGate();
|
|
74
|
-
expect(screen.
|
|
96
|
+
expect(screen.getByTestId("auth-card-prospect")).toHaveTextContent(
|
|
97
|
+
/^Don't have an account\? Sign up$/,
|
|
98
|
+
);
|
|
99
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
100
|
+
"href",
|
|
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",
|
|
106
|
+
);
|
|
107
|
+
const footnote = screen.getByTestId("auth-card-footnote");
|
|
108
|
+
expect(footnote).toHaveTextContent(
|
|
109
|
+
/^You acknowledge that you read, and agree to our Terms of Service and our Privacy Policy\.$/,
|
|
110
|
+
);
|
|
111
|
+
expect(
|
|
112
|
+
within(footnote).getByRole("link", { name: "Terms of Service" }),
|
|
113
|
+
).toHaveAttribute("href", "/terms");
|
|
114
|
+
expect(
|
|
115
|
+
within(footnote).getByRole("link", { name: "Privacy Policy" }),
|
|
116
|
+
).toHaveAttribute("href", "/privacy");
|
|
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
|
+
);
|
|
75
129
|
});
|
|
76
130
|
});
|
|
77
131
|
|
|
78
132
|
describe("<HubSignInGate/> prospect line (#8790 AC-2)", () => {
|
|
79
|
-
it("
|
|
133
|
+
it("replaces the toggle line with the site's own when it fills all three keys", async () => {
|
|
80
134
|
gateMessages.prospectPrompt = "New here?";
|
|
81
135
|
gateMessages.prospectLink = "See if we fit";
|
|
82
136
|
gateMessages.prospectHref = "/fit";
|
|
83
137
|
await renderGate();
|
|
84
|
-
|
|
85
|
-
expect(
|
|
138
|
+
// The card's line ends on its link — no trailing period (#9662).
|
|
139
|
+
expect(screen.getByTestId("auth-card-prospect")).toHaveTextContent(
|
|
140
|
+
/^New here\? See if we fit$/,
|
|
141
|
+
);
|
|
86
142
|
expect(screen.getByRole("link", { name: "See if we fit" })).toHaveAttribute(
|
|
87
143
|
"href",
|
|
88
144
|
"/fit",
|
|
89
145
|
);
|
|
90
146
|
});
|
|
91
147
|
|
|
92
|
-
it("
|
|
148
|
+
it("keeps the toggle line when any of the three keys is absent", async () => {
|
|
149
|
+
gateMessages.prospectPrompt = "New here?";
|
|
150
|
+
gateMessages.prospectLink = "See if we fit";
|
|
151
|
+
await renderGate();
|
|
152
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
153
|
+
"href",
|
|
154
|
+
"/api/hub-session/sign-in?next=%2Faccount&prompt=create",
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("draws no prospect line in the not-configured state, even with the site's three keys", async () => {
|
|
159
|
+
mockConfig.mockReturnValue(null);
|
|
93
160
|
gateMessages.prospectPrompt = "New here?";
|
|
94
161
|
gateMessages.prospectLink = "See if we fit";
|
|
162
|
+
gateMessages.prospectHref = "/fit";
|
|
95
163
|
await renderGate();
|
|
96
|
-
expect(screen.queryByTestId("
|
|
164
|
+
expect(screen.queryByTestId("auth-card-prospect")).toBeNull();
|
|
97
165
|
});
|
|
98
166
|
|
|
99
167
|
it("keeps the not-configured state when the hub is not configured", async () => {
|
|
@@ -102,6 +170,7 @@ describe("<HubSignInGate/> prospect line (#8790 AC-2)", () => {
|
|
|
102
170
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
|
103
171
|
en.account.gate.emptyHeadline,
|
|
104
172
|
);
|
|
173
|
+
expect(screen.queryByTestId("auth-card-prospect")).toBeNull();
|
|
105
174
|
expect(screen.getByTestId("account-sign-in-gate-home")).toHaveAttribute(
|
|
106
175
|
"href",
|
|
107
176
|
"/",
|