create-ailk 0.26.0 → 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 +2 -2
- package/dist/module-architecture.js +2 -0
- package/dist/surfaces.js +7 -2
- package/dist/update.js +52 -2
- package/package.json +3 -3
- package/templates/.env.example +7 -0
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +21 -5
- package/templates/apps/api/vercel.json +7 -1
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +13 -6
- 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 +51 -0
- 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 +10 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/layout.tsx +15 -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/lib/__tests__/relay.test.ts +289 -1
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
- package/templates/apps/web/lib/relay.ts +116 -1
- package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
- package/templates/apps/web/messages/en.json +86 -1
- 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 +47 -47
|
@@ -6,27 +6,41 @@
|
|
|
6
6
|
* provider body. A failed link lands back here with `?error=<code>`, mapped
|
|
7
7
|
* to fixed copy and never echoed.
|
|
8
8
|
*
|
|
9
|
+
* #9912 D4, D5: the page reads `?next=` through `resolveNextPath`, as
|
|
10
|
+
* `/sign-in` does. The "Sign in" toggle (open and closed card) carries it, and
|
|
11
|
+
* the password, Google and magic-link paths each hand it to Better Auth as
|
|
12
|
+
* `callbackURL`, the magic link's failed-link return keeping it too. A `next`
|
|
13
|
+
* resolveNextPath refuses, or none, gives `/account` and the bare links.
|
|
14
|
+
*
|
|
9
15
|
* `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
|
|
10
16
|
* the real en.json.
|
|
11
17
|
*/
|
|
12
18
|
import { render, screen } from "@testing-library/react";
|
|
19
|
+
import userEvent from "@testing-library/user-event";
|
|
13
20
|
|
|
14
21
|
const mockGetActiveProviderIds = jest.fn();
|
|
22
|
+
const mockIsSignUpOpen = jest.fn();
|
|
15
23
|
// eslint-disable-next-line no-restricted-syntax -- #507: auth-env.ts is a pure env-introspection module with no external boundary to mock instead.
|
|
16
24
|
jest.mock("../../../../../lib/auth-env", () => ({
|
|
17
25
|
__esModule: true,
|
|
18
26
|
getActiveProviderIds: () => mockGetActiveProviderIds(),
|
|
19
|
-
isSignUpOpen: () =>
|
|
27
|
+
isSignUpOpen: () => mockIsSignUpOpen(),
|
|
20
28
|
isResetAvailable: () => false,
|
|
21
29
|
resetSendConfigured: () => false,
|
|
22
30
|
}));
|
|
23
31
|
|
|
32
|
+
const mockSocial = jest.fn();
|
|
33
|
+
const mockSignUpEmail = jest.fn();
|
|
34
|
+
const mockFetch = jest.fn();
|
|
24
35
|
jest.mock("better-auth/react", () => ({
|
|
25
36
|
__esModule: true,
|
|
26
37
|
createAuthClient: () => ({
|
|
27
|
-
signIn: {
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
signIn: {
|
|
39
|
+
social: (...args: unknown[]) => mockSocial(...args),
|
|
40
|
+
email: jest.fn(),
|
|
41
|
+
},
|
|
42
|
+
signUp: { email: (...args: unknown[]) => mockSignUpEmail(...args) },
|
|
43
|
+
$fetch: (...args: unknown[]) => mockFetch(...args),
|
|
30
44
|
}),
|
|
31
45
|
}));
|
|
32
46
|
|
|
@@ -46,6 +60,10 @@ async function renderSignUp(
|
|
|
46
60
|
|
|
47
61
|
beforeEach(() => {
|
|
48
62
|
mockGetActiveProviderIds.mockReset();
|
|
63
|
+
mockIsSignUpOpen.mockReset().mockReturnValue(true);
|
|
64
|
+
mockSocial.mockReset().mockResolvedValue({ data: {}, error: null });
|
|
65
|
+
mockSignUpEmail.mockReset().mockResolvedValue({ data: {}, error: null });
|
|
66
|
+
mockFetch.mockReset().mockResolvedValue({ data: {}, error: null });
|
|
49
67
|
});
|
|
50
68
|
|
|
51
69
|
describe("the method (D6)", () => {
|
|
@@ -106,3 +124,178 @@ describe("the failed-link notice (D6)", () => {
|
|
|
106
124
|
expect(screen.queryByTestId("sign-up-link-notice")).toBeNull();
|
|
107
125
|
});
|
|
108
126
|
});
|
|
127
|
+
|
|
128
|
+
/** A continue path as the hub's authorize step hands it (#8146). */
|
|
129
|
+
const CONTINUATION = "/api/auth/hub/continue/AbC-123_xyz";
|
|
130
|
+
|
|
131
|
+
/** The three values `resolveNextPath` refuses that a toggle must never echo. */
|
|
132
|
+
const REFUSED: ReadonlyArray<[string, string]> = [
|
|
133
|
+
["a protocol-relative next", "//evil.example"],
|
|
134
|
+
["an absolute next", "https://evil.example/x"],
|
|
135
|
+
["a backslash next", "/\\evil.example"],
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
/** Each query that carries no `next` a toggle may echo: the refused three, and none. */
|
|
139
|
+
const UNCARRIED: Array<[string, Record<string, string>]> = [
|
|
140
|
+
...REFUSED.map(([label, next]): [string, Record<string, string>] => [
|
|
141
|
+
label,
|
|
142
|
+
{ next },
|
|
143
|
+
]),
|
|
144
|
+
["no next", {}],
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
/** The completion cases with no `next` to honour: one refused, and none. */
|
|
148
|
+
const TO_ACCOUNT: Array<[string, Record<string, string>]> = [
|
|
149
|
+
["a refused next", { next: "//evil.example" }],
|
|
150
|
+
["no next", {}],
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
describe("the Sign in toggle keeps the next (#9912 D4)", () => {
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("carries a validated next on the open card", async () => {
|
|
159
|
+
await renderSignUp({ next: "/account/billing" });
|
|
160
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
161
|
+
"href",
|
|
162
|
+
"/sign-in?next=%2Faccount%2Fbilling",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("carries a validated next on the closed (invitation) card", async () => {
|
|
167
|
+
mockIsSignUpOpen.mockReturnValue(false);
|
|
168
|
+
await renderSignUp({ next: "/account/billing" });
|
|
169
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
170
|
+
"href",
|
|
171
|
+
"/sign-in?next=%2Faccount%2Fbilling",
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it.each(UNCARRIED)(
|
|
176
|
+
"%s gives the bare /sign-in on the open card",
|
|
177
|
+
async (_label, query) => {
|
|
178
|
+
await renderSignUp(query);
|
|
179
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
180
|
+
"href",
|
|
181
|
+
"/sign-in",
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
it.each(UNCARRIED)(
|
|
187
|
+
"%s gives the bare /sign-in on the closed card",
|
|
188
|
+
async (_label, query) => {
|
|
189
|
+
mockIsSignUpOpen.mockReturnValue(false);
|
|
190
|
+
await renderSignUp(query);
|
|
191
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
192
|
+
"href",
|
|
193
|
+
"/sign-in",
|
|
194
|
+
);
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("sign-up completion follows the validated next (#9912 D5)", () => {
|
|
200
|
+
async function tickTerms(): Promise<void> {
|
|
201
|
+
await userEvent.setup().click(screen.getByTestId("consent-account_terms"));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function submitPassword(): Promise<void> {
|
|
205
|
+
const user = userEvent.setup();
|
|
206
|
+
await tickTerms();
|
|
207
|
+
await user.type(screen.getByTestId("auth-email"), "ada@example.com");
|
|
208
|
+
await user.type(
|
|
209
|
+
screen.getByTestId("auth-password"),
|
|
210
|
+
"correct horse battery staple",
|
|
211
|
+
);
|
|
212
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function clickGoogle(): Promise<void> {
|
|
216
|
+
await tickTerms();
|
|
217
|
+
await userEvent.setup().click(screen.getByTestId("auth-social-google"));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function submitLink(): Promise<void> {
|
|
221
|
+
const user = userEvent.setup();
|
|
222
|
+
await tickTerms();
|
|
223
|
+
await user.type(screen.getByTestId("auth-email"), "ada@example.com");
|
|
224
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
describe("with a continuation next", () => {
|
|
228
|
+
it("password: signUp.email's callbackURL is the next", async () => {
|
|
229
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
230
|
+
await renderSignUp({ next: CONTINUATION });
|
|
231
|
+
await submitPassword();
|
|
232
|
+
expect(mockSignUpEmail).toHaveBeenCalledTimes(1);
|
|
233
|
+
expect(mockSignUpEmail.mock.calls[0][0]).toMatchObject({
|
|
234
|
+
callbackURL: CONTINUATION,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("Google: signIn.social's callbackURL is the next", async () => {
|
|
239
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
240
|
+
await renderSignUp({ next: CONTINUATION });
|
|
241
|
+
await clickGoogle();
|
|
242
|
+
expect(mockSocial).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(mockSocial.mock.calls[0][0]).toMatchObject({
|
|
244
|
+
provider: "google",
|
|
245
|
+
callbackURL: CONTINUATION,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("magic link: the request's callbackURL is the next and its failed-link return keeps it", async () => {
|
|
250
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "magic-link"]);
|
|
251
|
+
await renderSignUp({ next: CONTINUATION });
|
|
252
|
+
await submitLink();
|
|
253
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
254
|
+
const [path, init] = mockFetch.mock.calls[0] as [
|
|
255
|
+
string,
|
|
256
|
+
{ body: Record<string, unknown> },
|
|
257
|
+
];
|
|
258
|
+
expect(path).toBe("/sign-in/magic-link");
|
|
259
|
+
expect(init.body.callbackURL).toBe(CONTINUATION);
|
|
260
|
+
expect(init.body.errorCallbackURL).toBe(
|
|
261
|
+
`/sign-up?next=${encodeURIComponent(CONTINUATION)}`,
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe.each(TO_ACCOUNT)("with %s", (_label, query) => {
|
|
267
|
+
it("password: callbackURL /account", async () => {
|
|
268
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
269
|
+
await renderSignUp(query);
|
|
270
|
+
await submitPassword();
|
|
271
|
+
expect(mockSignUpEmail).toHaveBeenCalledTimes(1);
|
|
272
|
+
expect(mockSignUpEmail.mock.calls[0][0]).toMatchObject({
|
|
273
|
+
callbackURL: "/account",
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("Google: callbackURL /account", async () => {
|
|
278
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
279
|
+
await renderSignUp(query);
|
|
280
|
+
await clickGoogle();
|
|
281
|
+
expect(mockSocial).toHaveBeenCalledTimes(1);
|
|
282
|
+
expect(mockSocial.mock.calls[0][0]).toMatchObject({
|
|
283
|
+
provider: "google",
|
|
284
|
+
callbackURL: "/account",
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("magic link: callbackURL /account and the bare /sign-up return", async () => {
|
|
289
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "magic-link"]);
|
|
290
|
+
await renderSignUp(query);
|
|
291
|
+
await submitLink();
|
|
292
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
293
|
+
const [, init] = mockFetch.mock.calls[0] as [
|
|
294
|
+
string,
|
|
295
|
+
{ body: Record<string, unknown> },
|
|
296
|
+
];
|
|
297
|
+
expect(init.body.callbackURL).toBe("/account");
|
|
298
|
+
expect(init.body.errorCallbackURL).toBe("/sign-up");
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
* keeps its invitation copy inside that frame — never a form the server
|
|
20
20
|
* would refuse (operator, 2026-10-24).
|
|
21
21
|
*
|
|
22
|
+
* `?next=` (#9912 D4, D5) is read through `resolveNextPath`, the call
|
|
23
|
+
* `/sign-in` makes. The validated `next` is where every method completes —
|
|
24
|
+
* the password verification link, the magic link and Google — and both the
|
|
25
|
+
* "Sign in" toggle and the failed-link return carry it, so a buyer sent here
|
|
26
|
+
* by a spoke's round trip ends back on the spoke. A refused or absent `next`
|
|
27
|
+
* gives `/account` and the bare links, as before.
|
|
28
|
+
*
|
|
22
29
|
* `AuthShell` (the (auth) group's layout) owns the 400px column; the card
|
|
23
30
|
* carries the site's mark (#8790 D3).
|
|
24
31
|
*/
|
|
@@ -38,13 +45,17 @@ import { getActiveProviderIds, isSignUpOpen } from "../../../../lib/auth-env";
|
|
|
38
45
|
import { consentPoliciesFor } from "../../../../lib/consent-policies";
|
|
39
46
|
import { siteLogo } from "../../../../lib/site-logo";
|
|
40
47
|
import { legalFootnote } from "../legal-footnote";
|
|
48
|
+
import { hrefWithNext, resolveNextPath } from "../sign-in/next-path";
|
|
41
49
|
import { resolveSignInCardText } from "../sign-in/sign-in-text";
|
|
42
50
|
|
|
43
51
|
import { SignUpCard, type SignUpProvider } from "./sign-up-card";
|
|
44
52
|
|
|
45
53
|
interface Props {
|
|
46
54
|
params: Promise<{ locale: string }>;
|
|
47
|
-
searchParams?: Promise<{
|
|
55
|
+
searchParams?: Promise<{
|
|
56
|
+
next?: string | string[];
|
|
57
|
+
error?: string | string[];
|
|
58
|
+
}>;
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
/**
|
|
@@ -79,13 +90,15 @@ export default async function SignUpPage({
|
|
|
79
90
|
searchParams,
|
|
80
91
|
}: Props): Promise<ReactElement> {
|
|
81
92
|
const { locale } = await params;
|
|
93
|
+
const { next: rawNext, error } = (await searchParams) ?? {};
|
|
82
94
|
const t = await getTranslations({ locale, namespace: "auth" });
|
|
83
|
-
const
|
|
95
|
+
const fallback = localeHref(locale, "/account");
|
|
96
|
+
const next = resolveNextPath(rawNext, fallback);
|
|
84
97
|
// The toggle line to /sign-in, and the acknowledgement /sign-in shares.
|
|
85
98
|
const prospect = {
|
|
86
99
|
prompt: t("signUpFooterPrompt"),
|
|
87
100
|
linkLabel: t("signUpFooterLink"),
|
|
88
|
-
href:
|
|
101
|
+
href: hrefWithNext(localeHref(locale, "/sign-in"), next, fallback),
|
|
89
102
|
};
|
|
90
103
|
const footnote = legalFootnote(
|
|
91
104
|
locale,
|
|
@@ -122,8 +135,7 @@ export default async function SignUpPage({
|
|
|
122
135
|
: active.includes("email")
|
|
123
136
|
? "password"
|
|
124
137
|
: "sso";
|
|
125
|
-
const noticeKey =
|
|
126
|
-
method === "magic-link" ? linkNoticeKey((await searchParams)?.error) : null;
|
|
138
|
+
const noticeKey = method === "magic-link" ? linkNoticeKey(error) : null;
|
|
127
139
|
|
|
128
140
|
const labels: AuthCardLabels = {
|
|
129
141
|
signUpHeading: t("signUpHeading"),
|
|
@@ -156,7 +168,7 @@ export default async function SignUpPage({
|
|
|
156
168
|
logo={siteLogo()}
|
|
157
169
|
providers={providers}
|
|
158
170
|
method={method}
|
|
159
|
-
next={
|
|
171
|
+
next={next}
|
|
160
172
|
prospect={prospect}
|
|
161
173
|
footnote={footnote}
|
|
162
174
|
labels={labels}
|
|
@@ -170,7 +182,11 @@ export default async function SignUpPage({
|
|
|
170
182
|
agreementPolicies={consentPoliciesFor(locale)}
|
|
171
183
|
agreementsLegend={t("signUpAgreementsLegend")}
|
|
172
184
|
locale={locale}
|
|
173
|
-
errorCallbackURL={
|
|
185
|
+
errorCallbackURL={hrefWithNext(
|
|
186
|
+
localeHref(locale, "/sign-up"),
|
|
187
|
+
next,
|
|
188
|
+
fallback,
|
|
189
|
+
)}
|
|
174
190
|
{...(noticeKey ? { linkNotice: t(noticeKey) } : {})}
|
|
175
191
|
linkLabels={{
|
|
176
192
|
sentHeading: t("sentHeading"),
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
getStealthRobots,
|
|
49
49
|
readLocales,
|
|
50
50
|
} from "@working-theory/web-i18n";
|
|
51
|
+
import { BotIdClient } from "botid/client";
|
|
51
52
|
import type { Metadata, Viewport } from "next";
|
|
52
53
|
import { cookies, headers } from "next/headers";
|
|
53
54
|
import { NextIntlClientProvider } from "next-intl";
|
|
@@ -77,6 +78,7 @@ import { SCHEDULING_PAGES } from "../../lib/modules.generated";
|
|
|
77
78
|
import { footerColumns, navItems } from "../../lib/nav-config";
|
|
78
79
|
import { getOgImageUrl } from "../../lib/og";
|
|
79
80
|
import { backLinkHref } from "../../lib/profile-back-link";
|
|
81
|
+
import { BOT_CHECKED_ROUTES, bookingBotCheckOn } from "../../lib/relay";
|
|
80
82
|
import {
|
|
81
83
|
COPYRIGHT_HOLDER,
|
|
82
84
|
SITE_DESCRIPTION,
|
|
@@ -704,6 +706,19 @@ export default async function LocaleLayout({
|
|
|
704
706
|
dangerouslySetInnerHTML={{ __html: FLASH_PREVENTION_SCRIPT }}
|
|
705
707
|
/>
|
|
706
708
|
)}
|
|
709
|
+
{/* #9889 D5 — BotID's browser half: one inline script, nothing
|
|
710
|
+
visible, protecting exactly the pairs the relay checks. Mounted
|
|
711
|
+
only where the server check runs, so a site with it off is
|
|
712
|
+
byte-identical and one off Vercel never loads a challenge it
|
|
713
|
+
cannot complete. */}
|
|
714
|
+
{bookingBotCheckOn() ? (
|
|
715
|
+
<BotIdClient
|
|
716
|
+
protect={BOT_CHECKED_ROUTES.map((r) => ({
|
|
717
|
+
path: r.path,
|
|
718
|
+
method: r.method,
|
|
719
|
+
}))}
|
|
720
|
+
/>
|
|
721
|
+
) : null}
|
|
707
722
|
{/* Inline CSS-variable declarations for the active theme mode */}
|
|
708
723
|
<style
|
|
709
724
|
data-ailk="theme-vars"
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* #8757 D9 (AC-6): an optional `login_hint` reaches the sign-in page as
|
|
15
15
|
* `email` on the no-session branch only; it never enters the continuation ref
|
|
16
16
|
* or the code redirect, and a repeated one is a 400 like any repeated key.
|
|
17
|
+
*
|
|
18
|
+
* #9912 D3 (AC-1): a single `prompt=create` sends a visitor with no session to
|
|
19
|
+
* `/sign-up` with the same continuation; any other value gives `/sign-in`, a
|
|
20
|
+
* session ignores it, and it never enters the ref or the code request.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
// --- Mocks set up BEFORE imports ---
|
|
@@ -459,3 +463,117 @@ describe("the login_hint (#8757 D9)", () => {
|
|
|
459
463
|
);
|
|
460
464
|
});
|
|
461
465
|
});
|
|
466
|
+
|
|
467
|
+
describe("the sign-up hint, prompt=create (#9912 D3)", () => {
|
|
468
|
+
/** The `next` value of the 303, and the ref inside it. */
|
|
469
|
+
function continuationOf(res: Response): { next: string; ref: string } {
|
|
470
|
+
const location = res.headers.get("location") ?? "";
|
|
471
|
+
const next = new URL(location, HUB).searchParams.get("next") ?? "";
|
|
472
|
+
return { next, ref: next.slice(`${CONTINUE}/`.length) };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
it("no session + prompt=create: 303 to /sign-up?next=<continue path>; the ref opens to the same six-key query as without the prompt", async () => {
|
|
476
|
+
mockGetSession.mockResolvedValue(null);
|
|
477
|
+
const canonical = query();
|
|
478
|
+
|
|
479
|
+
const res = await GET(request(`${canonical}&prompt=create`));
|
|
480
|
+
const plain = await GET(request(canonical));
|
|
481
|
+
|
|
482
|
+
expect(res.status).toBe(303);
|
|
483
|
+
const location = new URL(res.headers.get("location") ?? "", HUB);
|
|
484
|
+
expect(location.pathname).toBe("/sign-up");
|
|
485
|
+
expect([...location.searchParams.keys()]).toEqual(["next"]);
|
|
486
|
+
const { next, ref } = continuationOf(res);
|
|
487
|
+
expect(res.headers.get("location")).toBe(
|
|
488
|
+
`/sign-up?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}`,
|
|
489
|
+
);
|
|
490
|
+
// The sign-up page keeps it, as the sign-in page does.
|
|
491
|
+
expect(resolveNextPath(next, "/account")).toBe(next);
|
|
492
|
+
const opened = openContinuation(ref, mockSecret);
|
|
493
|
+
expect(opened).toBe(canonical);
|
|
494
|
+
expect(opened).toBe(
|
|
495
|
+
openContinuation(continuationOf(plain).ref, mockSecret),
|
|
496
|
+
);
|
|
497
|
+
expect(opened).not.toContain("prompt");
|
|
498
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
499
|
+
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
|
500
|
+
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("no session + prompt=create + a valid login_hint: /sign-up carries no email", async () => {
|
|
504
|
+
mockGetSession.mockResolvedValue(null);
|
|
505
|
+
|
|
506
|
+
const res = await GET(
|
|
507
|
+
request(`${query()}&prompt=create&login_hint=a%40b.example`),
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
expect(res.status).toBe(303);
|
|
511
|
+
const location = new URL(res.headers.get("location") ?? "", HUB);
|
|
512
|
+
expect(location.pathname).toBe("/sign-up");
|
|
513
|
+
expect(location.searchParams.has("email")).toBe(false);
|
|
514
|
+
expect(res.headers.get("location")).not.toContain("b.example");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it.each([
|
|
518
|
+
["prompt=login", "login"],
|
|
519
|
+
["prompt=none", "none"],
|
|
520
|
+
["an empty prompt", ""],
|
|
521
|
+
["a cased prompt", "Create"],
|
|
522
|
+
])("no session + %s: today's /sign-in", async (_name, prompt) => {
|
|
523
|
+
mockGetSession.mockResolvedValue(null);
|
|
524
|
+
const canonical = query();
|
|
525
|
+
|
|
526
|
+
const res = await GET(
|
|
527
|
+
request(`${canonical}&prompt=${encodeURIComponent(prompt)}`),
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
expect(res.status).toBe(303);
|
|
531
|
+
const { ref } = continuationOf(res);
|
|
532
|
+
expect(res.headers.get("location")).toBe(
|
|
533
|
+
`/sign-in?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}`,
|
|
534
|
+
);
|
|
535
|
+
expect(openContinuation(ref, mockSecret)).toBe(canonical);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it.each([
|
|
539
|
+
["no session", null],
|
|
540
|
+
["a session", { user: { id: "u1" } }],
|
|
541
|
+
])(
|
|
542
|
+
"a repeated prompt is a 400 invalid_request (%s)",
|
|
543
|
+
async (_name, session) => {
|
|
544
|
+
mockGetSession.mockResolvedValue(session);
|
|
545
|
+
|
|
546
|
+
const res = await GET(request(`${query()}&prompt=create&prompt=create`));
|
|
547
|
+
|
|
548
|
+
expect(res.status).toBe(400);
|
|
549
|
+
expect(res.headers.get("location")).toBeNull();
|
|
550
|
+
expect(await res.json()).toEqual({ error: "invalid_request" });
|
|
551
|
+
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
552
|
+
expect(mockGetSession).not.toHaveBeenCalled();
|
|
553
|
+
},
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
it("with a session: prompt=create is ignored — the same code redirect and the same API call", async () => {
|
|
557
|
+
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
|
|
558
|
+
mockCreateCode.mockResolvedValue({
|
|
559
|
+
ok: true,
|
|
560
|
+
body: { code: CODE, redirectUri: SPOKE_CALLBACK },
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const withPrompt = await GET(
|
|
564
|
+
request(`${query()}&prompt=create`, {
|
|
565
|
+
cookie: "better-auth.session_token=tok",
|
|
566
|
+
}),
|
|
567
|
+
);
|
|
568
|
+
const without = await GET(
|
|
569
|
+
request(query(), { cookie: "better-auth.session_token=tok" }),
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
expect(withPrompt.status).toBe(303);
|
|
573
|
+
const location = withPrompt.headers.get("location") ?? "";
|
|
574
|
+
expect(location).toBe(without.headers.get("location"));
|
|
575
|
+
expect(location).toBe(`${SPOKE_CALLBACK}?code=${CODE}&state=${STATE}`);
|
|
576
|
+
expect(mockCreateCode).toHaveBeenCalledTimes(2);
|
|
577
|
+
expect(mockCreateCode.mock.calls[0]).toEqual(mockCreateCode.mock.calls[1]);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
@@ -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();
|