create-ailk 0.25.1 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -5
- package/dist/lib/fetch-template-snapshot.d.ts +2 -1
- package/dist/lib/fetch-template-snapshot.js +6 -5
- package/dist/lib/module-license-gate.js +2 -3
- package/dist/lib/module-tier.d.ts +2 -8
- package/dist/lib/module-tier.js +13 -12
- package/dist/module-architecture.js +14 -2
- package/dist/starter-grid.d.ts +6 -6
- package/dist/starter-grid.js +12 -5
- package/dist/surfaces.js +36 -2
- package/dist/template-record.d.ts +11 -3
- package/dist/template-record.js +20 -7
- package/dist/topology.js +23 -4
- package/dist/update.d.ts +1 -1
- package/dist/update.js +240 -20
- package/package.json +3 -3
- package/release-scope.json +1 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
- package/site-templates/marketing-site/ailk-template.json +7 -1
- package/site-templates/marketing-site/provenance.json +7 -1
- package/site-templates/saas-product/ailk-template.json +2 -1
- package/site-templates/saas-product/provenance.json +1 -0
- package/templates/.env.example +22 -0
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +28 -0
- package/templates/apps/api/CLAUDE.md +35 -9
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
- package/templates/apps/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +27 -10
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +34 -6
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
- package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +168 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +22 -39
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +301 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -18
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
- package/templates/apps/web/app/[locale]/layout.tsx +48 -13
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +118 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +14 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- package/templates/apps/web/auth.ts +8 -4
- package/templates/apps/web/i18n/request.ts +49 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +289 -1
- package/templates/apps/web/lib/auth-email.ts +102 -20
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +92 -23
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +97 -70
- package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
- package/templates/apps/web/lib/relay.ts +116 -1
- package/templates/apps/web/lib/sign-up-agreements.ts +302 -28
- package/templates/apps/web/lib/site-contributions.ts +18 -0
- package/templates/apps/web/messages/en.json +271 -20
- package/templates/apps/web/messages/site/en.json +1 -0
- package/templates/apps/web/middleware.ts +8 -1
- package/templates/apps/web/next.config.mjs +9 -1
- package/templates/apps/web/package.json +2 -1
- package/versions.json +48 -48
|
@@ -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.
|
|
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
|
|
675
|
-
* `disableSignUp` refuses to create one even for a
|
|
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
|
-
|
|
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
|
+
});
|