create-ailk 0.25.1 → 0.26.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 (60) hide show
  1. package/README.md +12 -3
  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 +12 -2
  8. package/dist/starter-grid.d.ts +6 -6
  9. package/dist/starter-grid.js +12 -5
  10. package/dist/surfaces.js +29 -0
  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 +188 -18
  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 +15 -0
  25. package/templates/apps/api/.env.example +21 -0
  26. package/templates/apps/api/CLAUDE.md +18 -8
  27. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
  28. package/templates/apps/mcp/CLAUDE.md +27 -10
  29. package/templates/apps/web/CLAUDE.md +22 -1
  30. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
  31. package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
  32. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +117 -8
  33. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +14 -37
  34. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
  35. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
  36. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +108 -0
  37. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
  38. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +78 -16
  39. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
  40. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
  41. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
  43. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
  44. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
  45. package/templates/apps/web/app/[locale]/layout.tsx +33 -13
  46. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
  47. package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
  48. package/templates/apps/web/auth.ts +8 -4
  49. package/templates/apps/web/i18n/request.ts +49 -1
  50. package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
  51. package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
  52. package/templates/apps/web/lib/auth-email.ts +102 -20
  53. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +72 -23
  54. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +94 -70
  55. package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
  56. package/templates/apps/web/lib/sign-up-agreements.ts +291 -28
  57. package/templates/apps/web/lib/site-contributions.ts +18 -0
  58. package/templates/apps/web/messages/en.json +186 -20
  59. package/templates/apps/web/messages/site/en.json +1 -0
  60. package/versions.json +48 -48
@@ -451,3 +451,279 @@ describe("sendMagicLink — existing users only, deferred (#7435 § D3, AC-2)",
451
451
  });
452
452
  });
453
453
  });
454
+
455
+ // ── #9667 — a magic link can create the account (D1, D4) ───────────────────
456
+
457
+ describe("sendMagicLink — the sign-up branch on an open site (#9667 D1, D4)", () => {
458
+ const AGREED = { agreements: ["account_terms"], locale: "en" };
459
+
460
+ it("disableSignUp follows AILK_AUTH_SIGNUP_OPEN", () => {
461
+ delete process.env.AILK_AUTH_SIGNUP_OPEN;
462
+ expect(makeMagicLinkOptions({ send: jest.fn() }).disableSignUp).toBe(true);
463
+ process.env.AILK_AUTH_SIGNUP_OPEN = "true";
464
+ expect(makeMagicLinkOptions({ send: jest.fn() }).disableSignUp).toBe(false);
465
+ });
466
+
467
+ it("binds, then mails ONE sign-up email to an unknown address with userId \"sign-up\" (#9720 AC-1)", async () => {
468
+ process.env.AILK_AUTH_SIGNUP_OPEN = "true";
469
+ const send = jest.fn().mockResolvedValue({ ok: true });
470
+ const bind = jest.fn().mockResolvedValue(AGREED);
471
+ const { lines, log } = makeLog();
472
+ const { ctx, background } = makeCtx(null);
473
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log, bind });
474
+
475
+ await sendMagicLink(
476
+ { email: "new@example.com", url: LINK, token: "MLTOKEN", metadata: AGREED },
477
+ ctx,
478
+ );
479
+ await Promise.all(background);
480
+
481
+ expect(bind).toHaveBeenCalledWith(
482
+ ctx.context.internalAdapter,
483
+ "MLTOKEN",
484
+ AGREED,
485
+ MAGIC_LINK_TTL_SECONDS,
486
+ );
487
+ expect(send).toHaveBeenCalledTimes(1);
488
+ expect(JSON.parse(send.mock.calls[0]![0] as string)).toEqual({
489
+ kind: "magic-link-sign-up",
490
+ userId: "sign-up",
491
+ to: "new@example.com",
492
+ url: LINK,
493
+ agreements: ["account_terms"],
494
+ locale: "en",
495
+ });
496
+ const serialized = JSON.stringify(lines);
497
+ expect(serialized).not.toContain("new@example.com");
498
+ expect(serialized).not.toContain("MLTOKEN");
499
+ });
500
+
501
+ it("binds, then mails the account, for a known address", async () => {
502
+ process.env.AILK_AUTH_SIGNUP_OPEN = "true";
503
+ const send = jest.fn().mockResolvedValue({ ok: true });
504
+ const bind = jest.fn().mockResolvedValue(AGREED);
505
+ const { log } = makeLog();
506
+ const { ctx, background } = makeCtx({ id: "user_1", email: "person@example.com" });
507
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log, bind });
508
+
509
+ await sendMagicLink(
510
+ { email: "Person@Example.com", url: LINK, token: "MLTOKEN", metadata: AGREED },
511
+ ctx,
512
+ );
513
+ await Promise.all(background);
514
+
515
+ expect(bind).toHaveBeenCalledTimes(1);
516
+ expect(JSON.parse(send.mock.calls[0]![0] as string)).toMatchObject({
517
+ userId: "user_1",
518
+ to: "person@example.com",
519
+ });
520
+ });
521
+
522
+ it("a failed binding sends nothing to an unknown address and logs bind_failed", async () => {
523
+ process.env.AILK_AUTH_SIGNUP_OPEN = "true";
524
+ const send = jest.fn();
525
+ const bind = jest.fn().mockRejectedValue(new Error("db down for new@example.com"));
526
+ const { lines, log } = makeLog();
527
+ const { ctx, runInBackgroundOrAwait } = makeCtx(null);
528
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log, bind });
529
+
530
+ await expect(
531
+ sendMagicLink(
532
+ { email: "new@example.com", url: LINK, token: "MLTOKEN", metadata: AGREED },
533
+ ctx,
534
+ ),
535
+ ).resolves.toBeUndefined();
536
+
537
+ expect(runInBackgroundOrAwait).not.toHaveBeenCalled();
538
+ expect(send).not.toHaveBeenCalled();
539
+ expect(lines[0]).toEqual({
540
+ level: "warn",
541
+ fields: { kind: "magic-link", reason: "send_failed", errorKind: "bind_failed" },
542
+ });
543
+ const serialized = JSON.stringify(lines);
544
+ expect(serialized).not.toContain("new@example.com");
545
+ expect(serialized).not.toContain("MLTOKEN");
546
+ });
547
+
548
+ it("a sign-in request (no metadata) on an open site still sends nothing to an unknown address", async () => {
549
+ process.env.AILK_AUTH_SIGNUP_OPEN = "true";
550
+ const send = jest.fn();
551
+ const bind = jest.fn();
552
+ const { lines, log } = makeLog();
553
+ const { ctx, runInBackgroundOrAwait } = makeCtx(null);
554
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log, bind });
555
+
556
+ await sendMagicLink({ email: "nobody@example.com", url: LINK, token: "MLTOKEN" }, ctx);
557
+
558
+ expect(bind).not.toHaveBeenCalled();
559
+ expect(runInBackgroundOrAwait).not.toHaveBeenCalled();
560
+ expect(send).not.toHaveBeenCalled();
561
+ expect(lines).toEqual([
562
+ { level: "info", fields: { kind: "magic-link", reason: "no_account" } },
563
+ ]);
564
+ });
565
+ });
566
+
567
+ // ── #9720 — the sign-up email names the agreements (D6) ────────────────────
568
+
569
+ describe("sendMagicLink — the sign-up kind (#9720 D6)", () => {
570
+ const AGREED = { agreements: ["account_terms"], locale: "en" };
571
+
572
+ it("takes the agreements and locale from the binding, not the request", async () => {
573
+ process.env.AILK_AUTH_SIGNUP_OPEN = "true";
574
+ const send = jest.fn().mockResolvedValue({ ok: true });
575
+ const bind = jest
576
+ .fn()
577
+ .mockResolvedValue({ agreements: ["account_terms"], locale: "fr" });
578
+ const { log } = makeLog();
579
+ const { ctx, background } = makeCtx(null);
580
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log, bind });
581
+
582
+ await sendMagicLink(
583
+ { email: "new@example.com", url: LINK, token: "MLTOKEN", metadata: AGREED },
584
+ ctx,
585
+ );
586
+ await Promise.all(background);
587
+
588
+ expect(JSON.parse(send.mock.calls[0]![0] as string)).toEqual({
589
+ kind: "magic-link-sign-up",
590
+ userId: "sign-up",
591
+ to: "new@example.com",
592
+ url: LINK,
593
+ agreements: ["account_terms"],
594
+ locale: "fr",
595
+ });
596
+ });
597
+
598
+ it("a known address on a sign-up request gets exactly the sign-in body (AC-2)", async () => {
599
+ process.env.AILK_AUTH_SIGNUP_OPEN = "true";
600
+ const send = jest.fn().mockResolvedValue({ ok: true });
601
+ const bind = jest.fn().mockResolvedValue(AGREED);
602
+ const { log } = makeLog();
603
+ const { ctx, background } = makeCtx({ id: "user_1", email: "person@example.com" });
604
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log, bind });
605
+
606
+ await sendMagicLink(
607
+ { email: "Person@Example.com", url: LINK, token: "MLTOKEN", metadata: AGREED },
608
+ ctx,
609
+ );
610
+ await Promise.all(background);
611
+
612
+ expect(send).toHaveBeenCalledTimes(1);
613
+ expect(JSON.parse(send.mock.calls[0]![0] as string)).toEqual({
614
+ kind: "magic-link",
615
+ userId: "user_1",
616
+ to: "person@example.com",
617
+ url: LINK,
618
+ });
619
+ });
620
+
621
+ it("on a closed site binds nothing: a known address gets the sign-in body, an unknown one nothing (AC-2)", async () => {
622
+ delete process.env.AILK_AUTH_SIGNUP_OPEN;
623
+ const bind = jest.fn().mockResolvedValue(AGREED);
624
+
625
+ const knownSend = jest.fn().mockResolvedValue({ ok: true });
626
+ const known = makeCtx({ id: "user_1", email: "person@example.com" });
627
+ await makeMagicLinkOptions({ send: knownSend, log: makeLog().log, bind }).sendMagicLink(
628
+ { email: "person@example.com", url: LINK, token: "MLTOKEN", metadata: AGREED },
629
+ known.ctx,
630
+ );
631
+ await Promise.all(known.background);
632
+
633
+ const unknownSend = jest.fn();
634
+ const unknown = makeCtx(null);
635
+ await makeMagicLinkOptions({ send: unknownSend, log: makeLog().log, bind }).sendMagicLink(
636
+ { email: "new@example.com", url: LINK, token: "MLTOKEN", metadata: AGREED },
637
+ unknown.ctx,
638
+ );
639
+
640
+ expect(bind).not.toHaveBeenCalled();
641
+ expect(JSON.parse(knownSend.mock.calls[0]![0] as string)).toEqual({
642
+ kind: "magic-link",
643
+ userId: "user_1",
644
+ to: "person@example.com",
645
+ url: LINK,
646
+ });
647
+ expect(unknownSend).not.toHaveBeenCalled();
648
+ expect(unknown.runInBackgroundOrAwait).not.toHaveBeenCalled();
649
+ });
650
+
651
+ it("a known and an unknown address answer the same, after the same work, while the send never settles (AC-2)", async () => {
652
+ process.env.AILK_AUTH_SIGNUP_OPEN = "true";
653
+ const never = () => new Promise(() => undefined);
654
+
655
+ for (const user of [{ id: "user_1", email: "person@example.com" }, null]) {
656
+ const send = jest.fn(never);
657
+ const bind = jest.fn().mockResolvedValue(AGREED);
658
+ const { ctx, findUserByEmail, runInBackgroundOrAwait } = makeCtx(user);
659
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log: makeLog().log, bind });
660
+
661
+ await expect(
662
+ sendMagicLink(
663
+ { email: "someone@example.com", url: LINK, token: "MLTOKEN", metadata: AGREED },
664
+ ctx,
665
+ ),
666
+ ).resolves.toBeUndefined();
667
+
668
+ expect(findUserByEmail).toHaveBeenCalledTimes(1);
669
+ expect(bind).toHaveBeenCalledTimes(1);
670
+ expect(runInBackgroundOrAwait).toHaveBeenCalledTimes(1);
671
+ }
672
+ });
673
+
674
+ it("logs the sign-up send with no address, token or agreement (AC-3)", async () => {
675
+ process.env.AILK_AUTH_SIGNUP_OPEN = "true";
676
+ const send = jest.fn().mockResolvedValue({ ok: true });
677
+ const bind = jest.fn().mockResolvedValue(AGREED);
678
+ const { lines, log } = makeLog();
679
+ const { ctx, background } = makeCtx(null);
680
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log, bind });
681
+
682
+ await sendMagicLink(
683
+ { email: "new@example.com", url: LINK, token: "MLTOKEN", metadata: AGREED },
684
+ ctx,
685
+ );
686
+ await Promise.all(background);
687
+
688
+ expect(lines).toEqual([
689
+ {
690
+ level: "info",
691
+ fields: { userId: "sign-up", kind: "magic-link-sign-up", reason: "sent" },
692
+ },
693
+ ]);
694
+ const serialized = JSON.stringify(lines);
695
+ expect(serialized).not.toContain("new@example.com");
696
+ expect(serialized).not.toContain("MLTOKEN");
697
+ expect(serialized).not.toContain("account_terms");
698
+ });
699
+
700
+ it("signs a sign-up body that carries the agreements and locale (AC-3)", () => {
701
+ const req = buildAuthEmailRequest(
702
+ {
703
+ kind: "magic-link-sign-up",
704
+ userId: "sign-up",
705
+ to: "new@example.com",
706
+ url: LINK,
707
+ agreements: ["account_terms"],
708
+ locale: "en",
709
+ },
710
+ "webhook-secret",
711
+ );
712
+
713
+ const { createHmac } = require("node:crypto") as typeof import("node:crypto");
714
+ const timestamp = req.headers["x-ailk-auth-email-timestamp"];
715
+ expect(req.headers["x-ailk-auth-email-signature"]).toBe(
716
+ `sha256=${createHmac("sha256", "webhook-secret")
717
+ .update(`${timestamp}.${req.rawBody}`, "utf8")
718
+ .digest("hex")}`,
719
+ );
720
+ expect(JSON.parse(req.rawBody)).toEqual({
721
+ kind: "magic-link-sign-up",
722
+ userId: "sign-up",
723
+ to: "new@example.com",
724
+ url: LINK,
725
+ agreements: ["account_terms"],
726
+ locale: "en",
727
+ });
728
+ });
729
+ });
@@ -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,13 @@
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.
5
7
  */
6
- import { render, screen } from "@testing-library/react";
8
+ import { render, screen, within } from "@testing-library/react";
7
9
  import en from "../../../messages/en.json";
10
+ import { SITE_NAME } from "../../site-brand";
8
11
  import { HubSignInGate } from "../sign-in-gate";
9
12
 
10
13
  type Gate = Record<string, string>;
@@ -12,9 +15,15 @@ let gateMessages: Gate;
12
15
 
13
16
  jest.mock("next-intl/server", () => ({
14
17
  __esModule: true,
15
- getTranslations: jest.fn(async () => {
16
- const t = (key: string) => gateMessages[key] ?? `copy:${key}`;
17
- t.has = (key: string) => key in gateMessages;
18
+ getTranslations: jest.fn(async ({ namespace }: { namespace: string }) => {
19
+ const messages: Gate =
20
+ namespace === "signIn" ? (en.signIn as Gate) : gateMessages;
21
+ const t = (key: string, values?: Record<string, string>) =>
22
+ (messages[key] ?? `copy:${key}`).replace(
23
+ /\{(\w+)\}/g,
24
+ (_match, name: string) => values?.[name] ?? `{${name}}`,
25
+ );
26
+ t.has = (key: string) => key in messages;
18
27
  return t;
19
28
  }),
20
29
  }));
@@ -42,58 +51,97 @@ async function renderGate(pathname?: string): Promise<void> {
42
51
 
43
52
  beforeEach(() => {
44
53
  gateMessages = { ...(en.account.gate as Gate) };
45
- mockConfig.mockReset().mockReturnValue({ clientId: "spoke_1" });
54
+ mockConfig
55
+ .mockReset()
56
+ .mockReturnValue({ clientId: "spoke_1", hubOrigin: "https://hub.example" });
46
57
  });
47
58
 
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 () => {
59
+ describe("<HubSignInGate/> (#8790 AC-1; #9662)", () => {
60
+ it("wears the site's inner-page header: a back arrow to the home; the mark is the card's tile (#9662)", async () => {
50
61
  await renderGate("/account");
51
62
  const header = screen.getByTestId("account-sign-in-gate-header");
52
63
  const back = screen.getByTestId("nav-back-link");
53
64
  expect(header).toContainElement(back);
54
65
  expect(back).toHaveAttribute("href", "/");
55
66
  expect(back).toHaveAccessibleName(en.account.gate.homeLink);
56
- expect(screen.getByTestId("account-sign-in-gate-mark")).toContainElement(
67
+ expect(header).not.toContainElement(screen.getByTestId("site-mark"));
68
+ expect(screen.getByTestId("auth-card-logo")).toContainElement(
57
69
  screen.getByTestId("site-mark"),
58
70
  );
59
71
  });
60
72
 
61
- it("sets the heading at the label size and offers the one sign-in action", async () => {
73
+ 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
74
  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(
75
+ // The gate is the whole page, so the card's heading is its one h1.
76
+ expect(
77
+ screen.getByRole("heading", {
78
+ level: 1,
79
+ name: `Sign in to ${SITE_NAME}`,
80
+ }),
81
+ ).toBeInTheDocument();
82
+ const action = screen.getByTestId("account-hub-sign-in");
83
+ expect(screen.getByTestId("auth-card")).toContainElement(action);
84
+ expect(action).toHaveAttribute(
67
85
  "href",
68
86
  "/api/hub-session/sign-in?next=%2Faccount%2Fsettings",
69
87
  );
70
88
  });
71
89
 
72
- it("renders no prospect line by default (the shipped messages carry no prospect keys)", async () => {
90
+ it("carries the toggle line to the hub's /sign-up and the acknowledgement on this site's policy routes (#9662)", async () => {
73
91
  await renderGate();
74
- expect(screen.queryByTestId("account-sign-in-gate-prospect")).toBeNull();
92
+ expect(screen.getByTestId("auth-card-prospect")).toHaveTextContent(
93
+ /^Don't have an account\? Sign up$/,
94
+ );
95
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
96
+ "href",
97
+ "https://hub.example/sign-up",
98
+ );
99
+ const footnote = screen.getByTestId("auth-card-footnote");
100
+ expect(footnote).toHaveTextContent(
101
+ /^You acknowledge that you read, and agree to our Terms of Service and our Privacy Policy\.$/,
102
+ );
103
+ expect(
104
+ within(footnote).getByRole("link", { name: "Terms of Service" }),
105
+ ).toHaveAttribute("href", "/terms");
106
+ expect(
107
+ within(footnote).getByRole("link", { name: "Privacy Policy" }),
108
+ ).toHaveAttribute("href", "/privacy");
75
109
  });
76
110
  });
77
111
 
78
112
  describe("<HubSignInGate/> prospect line (#8790 AC-2)", () => {
79
- it("renders the line and its link when the site fills all three keys", async () => {
113
+ it("replaces the toggle line with the site's own when it fills all three keys", async () => {
80
114
  gateMessages.prospectPrompt = "New here?";
81
115
  gateMessages.prospectLink = "See if we fit";
82
116
  gateMessages.prospectHref = "/fit";
83
117
  await renderGate();
84
- const line = screen.getByTestId("account-sign-in-gate-prospect");
85
- expect(line).toHaveTextContent("New here? See if we fit.");
118
+ // The card's line ends on its link — no trailing period (#9662).
119
+ expect(screen.getByTestId("auth-card-prospect")).toHaveTextContent(
120
+ /^New here\? See if we fit$/,
121
+ );
86
122
  expect(screen.getByRole("link", { name: "See if we fit" })).toHaveAttribute(
87
123
  "href",
88
124
  "/fit",
89
125
  );
90
126
  });
91
127
 
92
- it("renders nothing when any of the three keys is absent", async () => {
128
+ it("keeps the toggle line when any of the three keys is absent", async () => {
129
+ gateMessages.prospectPrompt = "New here?";
130
+ gateMessages.prospectLink = "See if we fit";
131
+ await renderGate();
132
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
133
+ "href",
134
+ "https://hub.example/sign-up",
135
+ );
136
+ });
137
+
138
+ it("draws no prospect line in the not-configured state, even with the site's three keys", async () => {
139
+ mockConfig.mockReturnValue(null);
93
140
  gateMessages.prospectPrompt = "New here?";
94
141
  gateMessages.prospectLink = "See if we fit";
142
+ gateMessages.prospectHref = "/fit";
95
143
  await renderGate();
96
- expect(screen.queryByTestId("account-sign-in-gate-prospect")).toBeNull();
144
+ expect(screen.queryByTestId("auth-card-prospect")).toBeNull();
97
145
  });
98
146
 
99
147
  it("keeps the not-configured state when the hub is not configured", async () => {
@@ -102,6 +150,7 @@ describe("<HubSignInGate/> prospect line (#8790 AC-2)", () => {
102
150
  expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
103
151
  en.account.gate.emptyHeadline,
104
152
  );
153
+ expect(screen.queryByTestId("auth-card-prospect")).toBeNull();
105
154
  expect(screen.getByTestId("account-sign-in-gate-home")).toHaveAttribute(
106
155
  "href",
107
156
  "/",