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.
- package/README.md +12 -3
- 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 +12 -2
- package/dist/starter-grid.d.ts +6 -6
- package/dist/starter-grid.js +12 -5
- package/dist/surfaces.js +29 -0
- 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 +188 -18
- 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 +15 -0
- package/templates/apps/api/.env.example +21 -0
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
- package/templates/apps/mcp/CLAUDE.md +27 -10
- package/templates/apps/web/CLAUDE.md +22 -1
- 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__/page.test.tsx +117 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +14 -37
- 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 +108 -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 +78 -16
- 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 +33 -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/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/auth-email.ts +102 -20
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +72 -23
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +94 -70
- package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
- package/templates/apps/web/lib/sign-up-agreements.ts +291 -28
- package/templates/apps/web/lib/site-contributions.ts +18 -0
- package/templates/apps/web/messages/en.json +186 -20
- package/templates/apps/web/messages/site/en.json +1 -0
- 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
|
-
|
|
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,13 @@
|
|
|
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.
|
|
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
|
|
17
|
-
|
|
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
|
|
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
|
|
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("
|
|
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("
|
|
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
|
-
|
|
64
|
-
expect(
|
|
65
|
-
|
|
66
|
-
|
|
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("
|
|
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.
|
|
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("
|
|
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
|
-
|
|
85
|
-
expect(
|
|
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("
|
|
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("
|
|
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
|
"/",
|