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.
Files changed (75) hide show
  1. package/README.md +14 -5
  2. package/dist/lib/fetch-template-snapshot.d.ts +2 -1
  3. package/dist/lib/fetch-template-snapshot.js +6 -5
  4. package/dist/lib/module-license-gate.js +2 -3
  5. package/dist/lib/module-tier.d.ts +2 -8
  6. package/dist/lib/module-tier.js +13 -12
  7. package/dist/module-architecture.js +14 -2
  8. package/dist/starter-grid.d.ts +6 -6
  9. package/dist/starter-grid.js +12 -5
  10. package/dist/surfaces.js +36 -2
  11. package/dist/template-record.d.ts +11 -3
  12. package/dist/template-record.js +20 -7
  13. package/dist/topology.js +23 -4
  14. package/dist/update.d.ts +1 -1
  15. package/dist/update.js +240 -20
  16. package/package.json +3 -3
  17. package/release-scope.json +1 -1
  18. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
  19. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
  20. package/site-templates/marketing-site/ailk-template.json +7 -1
  21. package/site-templates/marketing-site/provenance.json +7 -1
  22. package/site-templates/saas-product/ailk-template.json +2 -1
  23. package/site-templates/saas-product/provenance.json +1 -0
  24. package/templates/.env.example +22 -0
  25. package/templates/CLAUDE.md +20 -20
  26. package/templates/README.md +19 -19
  27. package/templates/apps/api/.env.example +28 -0
  28. package/templates/apps/api/CLAUDE.md +35 -9
  29. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
  30. package/templates/apps/api/vercel.json +7 -1
  31. package/templates/apps/mcp/CLAUDE.md +27 -10
  32. package/templates/apps/web/.env.example +13 -0
  33. package/templates/apps/web/CLAUDE.md +34 -6
  34. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
  35. package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
  36. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  37. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +168 -8
  38. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  39. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +22 -39
  40. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
  41. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
  42. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +301 -0
  43. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
  44. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -18
  45. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
  46. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
  47. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
  48. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
  49. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
  50. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
  51. package/templates/apps/web/app/[locale]/layout.tsx +48 -13
  52. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
  53. package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
  54. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +118 -0
  55. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +14 -0
  56. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  57. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  58. package/templates/apps/web/auth.ts +8 -4
  59. package/templates/apps/web/i18n/request.ts +49 -1
  60. package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
  61. package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
  62. package/templates/apps/web/lib/__tests__/relay.test.ts +289 -1
  63. package/templates/apps/web/lib/auth-email.ts +102 -20
  64. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +92 -23
  65. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +97 -70
  66. package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
  67. package/templates/apps/web/lib/relay.ts +116 -1
  68. package/templates/apps/web/lib/sign-up-agreements.ts +302 -28
  69. package/templates/apps/web/lib/site-contributions.ts +18 -0
  70. package/templates/apps/web/messages/en.json +271 -20
  71. package/templates/apps/web/messages/site/en.json +1 -0
  72. package/templates/apps/web/middleware.ts +8 -1
  73. package/templates/apps/web/next.config.mjs +9 -1
  74. package/templates/apps/web/package.json +2 -1
  75. 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
- /** The four fixed kinds the relay will send. A caller picks no copy. */
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: DispatchDeps) {
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: { email: string; url: string; token: string },
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
- if (!found) {
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: true` is defence in depth behind § D3: even a leaked token
439
- * for an address with no account cannot create one. `storeToken: "hashed"`
440
- * keeps a database read from yielding a live sign-in token. `allowedAttempts`
441
- * is left unset — the plugin consumes atomically regardless and warns on any
442
- * value but 1.
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: DispatchDeps) {
526
+ export function makeMagicLinkOptions(deps: MagicLinkDeps) {
445
527
  return {
446
- disableSignUp: true,
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 inner-page
3
- * header (back arrow + mark), a label-size heading, the one hub sign-in
4
- * action, and a prospect line only when the site's messages carry it.
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 t = (key: string) => gateMessages[key] ?? `copy:${key}`;
17
- t.has = (key: string) => key in gateMessages;
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.mockReset().mockReturnValue({ clientId: "spoke_1" });
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 and the mark", async () => {
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("account-sign-in-gate-mark")).toContainElement(
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("sets the heading at the label size and offers the one sign-in action", async () => {
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
- const heading = screen.getByRole("heading", { level: 1 });
64
- expect(heading).toHaveTextContent(en.account.gate.hubHeadline);
65
- expect(heading).toHaveClass("text-heading-2");
66
- expect(screen.getByTestId("account-hub-sign-in")).toHaveAttribute(
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("renders no prospect line by default (the shipped messages carry no prospect keys)", async () => {
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.queryByTestId("account-sign-in-gate-prospect")).toBeNull();
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("renders the line and its link when the site fills all three keys", async () => {
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
- const line = screen.getByTestId("account-sign-in-gate-prospect");
85
- expect(line).toHaveTextContent("New here? See if we fit.");
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("renders nothing when any of the three keys is absent", async () => {
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("account-sign-in-gate-prospect")).toBeNull();
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
  "/",