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
@@ -12,6 +12,7 @@
12
12
  * ?response_type=code&client_id=…&redirect_uri=…&state=…
13
13
  * &code_challenge=…&code_challenge_method=S256
14
14
  * [&login_hint=<email>] (optional, #8757 D9 — see below)
15
+ * [&prompt=create] (optional, #9912 D3 — see below)
15
16
  *
16
17
  * - a malformed query is a 400 and NEVER a redirect;
17
18
  * - no Better Auth session: the six validated keys are sealed into a short
@@ -41,6 +42,15 @@
41
42
  * route rebuilds the query from the ref, so the hint is gone after sign-in —
42
43
  * and with a session it is ignored.
43
44
  *
45
+ * `prompt=create` (#9912 D3) is the spoke's "Sign up": read beside
46
+ * `login_hint` by the same once-only rule (repeated → 400, before the session
47
+ * read). On the no-session branch exactly `create` sends the visitor to
48
+ * `/sign-up?next=<the same continue path>` instead of `/sign-in`, with no
49
+ * `email`; any other value does nothing. It never enters the ref — so a
50
+ * password sign-up, which ends with no session, comes back through the
51
+ * continuation to `/sign-in` — nor a code or a cookie, and with a session it
52
+ * is ignored: the person has an account, and the spoke signs them in.
53
+ *
44
54
  * `BETTER_AUTH_URL` pins the `Origin` sent with the cookie-authenticated write
45
55
  * (Node's `fetch` sets none). Unset, the step answers 503: it needs a pinned
46
56
  * origin, exactly as the emailed link does.
@@ -124,6 +134,9 @@ export async function GET(request: Request): Promise<Response> {
124
134
  // #8757 D9 — the optional hint: the same once-only rule; refused → dropped.
125
135
  const hints = url.searchParams.getAll("login_hint");
126
136
  if (hints.length > 1) return json(400, "invalid_request");
137
+ // #9912 D3 — the sign-up hint, by the same rule; only `create` acts.
138
+ const prompts = url.searchParams.getAll("prompt");
139
+ if (prompts.length > 1) return json(400, "invalid_request");
127
140
  const parsed = hubAuthorizeQuerySchema.safeParse(raw);
128
141
  if (!parsed.success) return json(400, "invalid_request");
129
142
  const query = parsed.data;
@@ -138,6 +151,7 @@ export async function GET(request: Request): Promise<Response> {
138
151
  const ref = mintContinuation(canonical.toString(), resolveSecret());
139
152
  if (ref.length > CONTINUATION_REF_MAX) return json(400, "invalid_request");
140
153
  const signIn = new URLSearchParams({ next: `${CONTINUE_PATH}/${ref}` });
154
+ if (prompts[0] === "create") return see(`/sign-up?${signIn.toString()}`);
141
155
  if (hint.success) signIn.set("email", hint.data);
142
156
  return see(`/sign-in?${signIn.toString()}`);
143
157
  }
@@ -16,6 +16,10 @@
16
16
  *
17
17
  * #8757 D9 (AC-6): a valid single `?email=` on the sign-in route becomes the
18
18
  * authorize URL's `login_hint`; the sealed flow cookie never carries it.
19
+ *
20
+ * #9912 D2 (AC-1): exactly one `?prompt=create` rides to the hub as
21
+ * `prompt=create`, so the hub sends a visitor with no session to /sign-up;
22
+ * any other value adds nothing, and the flow cookie's keys are unchanged.
19
23
  */
20
24
 
21
25
  import { createHash } from "node:crypto";
@@ -204,6 +208,53 @@ describe("GET /api/hub-session/sign-in — the hinted email (#8757 D9)", () => {
204
208
  });
205
209
  });
206
210
 
211
+ describe("GET /api/hub-session/sign-in — the sign-up hint (#9912 D2)", () => {
212
+ function startWith(query: string): { authorize: URL; payload: Record<string, unknown> } {
213
+ const res = signIn(new Request(`${SITE}/api/hub-session/sign-in?${query}`));
214
+ expect(res.status).toBe(303);
215
+ const flow = cookieValue(res, "ailk-hub-flow");
216
+ if (flow === undefined) throw new Error("no flow cookie");
217
+ const payload = unseal(flow, SESSION_SECRET, "flow", "ailk-hub-flow");
218
+ if (payload === null || typeof payload !== "object") throw new Error("flow did not open");
219
+ return {
220
+ authorize: new URL(res.headers.get("location") ?? ""),
221
+ payload: payload as Record<string, unknown>,
222
+ };
223
+ }
224
+
225
+ it("?next=/account&prompt=create → the authorize URL carries prompt=create; the flow cookie keeps exactly its five keys", () => {
226
+ const { authorize, payload } = startWith("next=/account&prompt=create");
227
+ expect(authorize.pathname).toBe("/api/auth/hub/authorize");
228
+ expect(authorize.searchParams.getAll("prompt")).toEqual(["create"]);
229
+ // The six keys are untouched beside it.
230
+ expect(authorize.searchParams.get("response_type")).toBe("code");
231
+ expect(authorize.searchParams.get("code_challenge_method")).toBe("S256");
232
+ expect(Object.keys(payload).sort()).toEqual(
233
+ ["expiresAt", "next", "state", "v", "verifier"],
234
+ );
235
+ expect(payload.next).toBe("/account");
236
+ expect(JSON.stringify(payload)).not.toContain("create");
237
+ });
238
+
239
+ it("forwards the hint beside a valid login_hint", () => {
240
+ const { authorize } = startWith("next=/account&prompt=create&email=a%40b.example");
241
+ expect(authorize.searchParams.get("prompt")).toBe("create");
242
+ expect(authorize.searchParams.get("login_hint")).toBe("a@b.example");
243
+ });
244
+
245
+ it.each([
246
+ ["another OIDC value", "next=/account&prompt=login"],
247
+ ["an empty value", "next=/account&prompt="],
248
+ ["a repeated value", "next=/account&prompt=create&prompt=create"],
249
+ ["a cased value", "next=/account&prompt=Create"],
250
+ ["no value", "next=/account"],
251
+ ])("%s → no prompt", (_label, query) => {
252
+ const { authorize, payload } = startWith(query);
253
+ expect(authorize.searchParams.has("prompt")).toBe(false);
254
+ expect(payload).not.toHaveProperty("prompt");
255
+ });
256
+ });
257
+
207
258
  describe("GET /api/hub-session/callback", () => {
208
259
  function exchangeOk(): void {
209
260
  mockExchange.mockResolvedValue({
@@ -15,6 +15,12 @@
15
15
  * the address typed. A refused or repeated value is dropped. The hint goes on
16
16
  * the authorize URL only — never into the sealed flow cookie.
17
17
  *
18
+ * Sign-up hint (#9912 D2): exactly one `?prompt=create` (the gate's "Sign up")
19
+ * rides to the hub as `prompt=create`, so a visitor with no hub session lands
20
+ * on its sign-up screen instead of sign-in; the round trip is otherwise the
21
+ * same. Any other value, an empty or a repeated one adds nothing, and the hint
22
+ * never enters the flow cookie.
23
+ *
18
24
  * Nothing here logs the state, the verifier or the challenge.
19
25
  */
20
26
  import { createHash, randomBytes } from "node:crypto";
@@ -55,6 +61,12 @@ function emailHint(params: URLSearchParams): string | undefined {
55
61
  return parsed.success ? parsed.data : undefined;
56
62
  }
57
63
 
64
+ /** #9912 D2 — `"create"` when the one `prompt` is exactly that, else none. */
65
+ function signUpHint(params: URLSearchParams): "create" | undefined {
66
+ const values = params.getAll("prompt");
67
+ return values.length === 1 && values[0] === "create" ? "create" : undefined;
68
+ }
69
+
58
70
  // platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
59
71
  export function GET(request: Request): Response {
60
72
  if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
@@ -84,6 +96,7 @@ export function GET(request: Request): Response {
84
96
  code_challenge: challenge,
85
97
  code_challenge_method: "S256",
86
98
  });
99
+ if (signUpHint(params) !== undefined) query.set("prompt", "create");
87
100
  const loginHint = emailHint(params);
88
101
  if (loginHint !== undefined) query.set("login_hint", loginHint);
89
102
  authorize.search = query.toString();
@@ -459,7 +459,9 @@ export function createAuth(deps: CreateAuthDeps = {}) {
459
459
  /**
460
460
  * The sign-up grants (#8765 D4): the user hook decides whether the new
461
461
  * user carries agreements, the account hook writes them on the captured
462
- * transaction client. No other database hook is installed.
462
+ * transaction client. A magic-link sign-up creates no Account, so the
463
+ * session hook writes its grants, in their own transaction, before the
464
+ * session exists (#9667 D5). No other database hook is installed.
463
465
  */
464
466
  databaseHooks: signUpAgreementHooks(),
465
467
  secret: resolveSecret(),
@@ -671,9 +673,11 @@ export function createAuth(deps: CreateAuthDeps = {}) {
671
673
  /**
672
674
  * `magicLink` only when its predicate holds (#7435 § D2): absent, its
673
675
  * `/sign-in/magic-link` and `/magic-link/verify` endpoints do not exist.
674
- * Sign-in only — the callback mails existing users and nobody else, and
675
- * `disableSignUp` refuses to create one even for a live token
676
- * (lib/auth-email.ts).
676
+ * Sign-in only while sign-up is closed — the callback mails existing users
677
+ * and nobody else, and `disableSignUp` refuses to create one even for a
678
+ * live token (lib/auth-email.ts). While `isSignUpOpen()` a request
679
+ * carrying the agreements is a sign-up: its token is bound to them, and
680
+ * the verify creates the account only for a token so bound (#9667).
677
681
  *
678
682
  * `signUpAgreementsPlugin` always (#8765 D3): while sign-up is open,
679
683
  * `/sign-up/email` without every sign-up agreement is refused before the
@@ -4,6 +4,14 @@
4
4
  // English when no catalog exists (e.g. a locale is enabled in project.yaml
5
5
  // but its messages/<locale>.json has not been translated yet).
6
6
  //
7
+ // The site's own strings (#9401 D8) live in messages/site/<locale>.json, an
8
+ // overlay the template ships as `{}` and never changes, so `create-ailk
9
+ // update` keeps a site's edit of it. It falls back to the English overlay,
10
+ // then to nothing, and merges add-only: a key the template catalog already
11
+ // has keeps the template's value. The merge builds new objects, descends only
12
+ // into plain objects, takes only string leaves and skips prototype keys, so
13
+ // it can neither mutate the imported catalog nor pollute `Object.prototype`.
14
+ //
7
15
  // Spec: docs/internal/specs/i18n-phase1-s2-next-intl-foundation-spec.md
8
16
 
9
17
  import type { AbstractIntlMessages } from "next-intl";
@@ -11,6 +19,42 @@ import { getRequestConfig } from "next-intl/server";
11
19
 
12
20
  const DEFAULT_LOCALE = "en";
13
21
 
22
+ const UNSAFE_KEYS: ReadonlySet<string> = new Set([
23
+ "__proto__",
24
+ "constructor",
25
+ "prototype",
26
+ ]);
27
+
28
+ type Catalog = Record<string, unknown>;
29
+
30
+ function isCatalog(value: unknown): value is Catalog {
31
+ return typeof value === "object" && value !== null && !Array.isArray(value);
32
+ }
33
+
34
+ /** `base` with every key of `overlay` it lacks added — new objects, never a mutation. */
35
+ function mergeAddOnly(base: Catalog, overlay: Catalog): Catalog {
36
+ const out: Catalog = { ...base };
37
+ for (const [key, value] of Object.entries(overlay)) {
38
+ if (UNSAFE_KEYS.has(key)) continue;
39
+ const has = Object.prototype.hasOwnProperty.call(base, key);
40
+ if (typeof value === "string") {
41
+ if (!has) out[key] = value;
42
+ } else if (isCatalog(value)) {
43
+ if (!has) out[key] = mergeAddOnly({}, value);
44
+ else if (isCatalog(base[key])) out[key] = mergeAddOnly(base[key], value);
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ async function loadSiteOverlay(locale: string): Promise<Catalog> {
51
+ const mod =
52
+ (await import(`../messages/site/${locale}.json`).catch(() => null)) ??
53
+ (await import(`../messages/site/${DEFAULT_LOCALE}.json`).catch(() => null));
54
+ const overlay: unknown = mod ? (mod.default ?? mod) : null;
55
+ return isCatalog(overlay) ? overlay : {};
56
+ }
57
+
14
58
  export default getRequestConfig(async ({ requestLocale }) => {
15
59
  // requestLocale is the modern API (next-intl >= 3.22). It resolves the
16
60
  // active locale from the request, validated by middleware.
@@ -32,5 +76,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
32
76
  messages = (fallback.default ?? fallback) as AbstractIntlMessages;
33
77
  }
34
78
 
35
- return { locale, messages };
79
+ const overlay = await loadSiteOverlay(locale);
80
+ return {
81
+ locale,
82
+ messages: mergeAddOnly(messages, overlay) as AbstractIntlMessages,
83
+ };
36
84
  });
@@ -26,6 +26,8 @@
26
26
  * 401/403) is `false`, any other failure rejects. The popups are bound
27
27
  * in the route (`account/new-popups.tsx`, tested beside it): this file
28
28
  * imports nothing under `(authed)`.
29
+ * 8. #9401 AC-11 — the template's `site-contributions.ts` stub ships no
30
+ * Billing section either; a site fills it.
29
31
  */
30
32
 
31
33
  const mockSignupMe = jest.fn();
@@ -68,6 +70,7 @@ import {
68
70
  accountSettingsSections,
69
71
  type AccountNewRead,
70
72
  } from "../account-config";
73
+ import { siteBillingSections } from "../site-contributions";
71
74
 
72
75
  const INIT = { headers: { cookie: "session=abc" } };
73
76
 
@@ -166,6 +169,10 @@ describe("account-config exports (AC-6)", () => {
166
169
  expect(accountBillingSections).toEqual([]);
167
170
  });
168
171
 
172
+ it("ships no site Billing section either: the stub is the site's to fill (#9401 AC-11, D7)", () => {
173
+ expect(siteBillingSections).toEqual([]);
174
+ });
175
+
169
176
  it("ships the base kinds and the member kinds in their sections (#8231 D4–D6)", () => {
170
177
  expect(accountInboxKinds.map((k) => [k.kind, k.section])).toEqual([
171
178
  ["result", "updates"],
@@ -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
+ });