cloudflare-next-intl 0.6.18 → 0.6.20

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.
@@ -7,7 +7,14 @@
7
7
  // Auto-prepending `/` here fixes the common typo (`'login'` instead of
8
8
  // `'/login'`) at the source, for every consumer, instead of requiring each
9
9
  // one to notice and fix it themselves.
10
- const FIREBASE_AUTH_PATH_FIELDS = ['redirectAuthPath', 'homePath', 'verifyEmailPath'];
10
+ const FIREBASE_AUTH_PATH_FIELDS = [
11
+ 'redirectAuthPath',
12
+ 'homePath',
13
+ 'verifyEmailPath',
14
+ 'resetPasswordPath',
15
+ 'recoverEmailPath',
16
+ 'actionLinkPath',
17
+ ];
11
18
  function normalizeFirebaseAuthPaths(config) {
12
19
  const fa = config.firebaseAuth;
13
20
  if (!fa)
@@ -9,6 +9,7 @@ import { getFirebaseAuthClient, getFirebaseAuthModule } from './firebase_client'
9
9
  import { setAuthUserCache } from './auth_user_cache';
10
10
  import { defaultEmailVerifiedHintCookieName, defaultRefreshTokenCookieName, defaultSessionCookieName } from '../middleware/update_session';
11
11
  import decodeJwtPayload from '../decode_jwt_payload';
12
+ import isWhitelisted from '../is_whitelisted';
12
13
  import setCookie from '../../client/functions/set_cookie';
13
14
  import getCookie from '../../client/functions/get_cookie';
14
15
  import clearSessionAction from '../server/clear_session_action';
@@ -87,7 +88,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
87
88
  const router = useRouter();
88
89
  const pathname = usePathname();
89
90
  const isAuthPage = fa.isAuthPath(pathname);
90
- const isWhiteListed = fa.whiteListPaths?.includes(pathname) ?? false;
91
+ const isWhiteListed = isWhitelisted(pathname, fa.whiteListPaths);
91
92
  const maxAge = fa.sessionCookieMaxAge ?? 60 * 60 * 24 * 5;
92
93
  const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
93
94
  const refreshTokenMaxAge = fa.refreshTokenCookieMaxAge ?? 60 * 60 * 24 * 365;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Whether `path` is exempt from auth redirects under `whiteListPaths`.
3
+ * Matches an entry exactly, OR as a path-segment prefix (`/bonds` also
4
+ * covers `/bonds/some-slug`, but NOT `/bonds-extra`) — a plain
5
+ * `startsWith` would let a differently-named sibling route slip through
6
+ * whenever one route's name happens to prefix another's.
7
+ */
8
+ export default function isWhitelisted(path: string, whiteListPaths: readonly string[] | undefined): boolean;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Whether `path` is exempt from auth redirects under `whiteListPaths`.
3
+ * Matches an entry exactly, OR as a path-segment prefix (`/bonds` also
4
+ * covers `/bonds/some-slug`, but NOT `/bonds-extra`) — a plain
5
+ * `startsWith` would let a differently-named sibling route slip through
6
+ * whenever one route's name happens to prefix another's.
7
+ */
8
+ export default function isWhitelisted(path, whiteListPaths) {
9
+ if (!whiteListPaths)
10
+ return false;
11
+ return whiteListPaths.some((entry) => path === entry || path.startsWith(`${entry}/`));
12
+ }
@@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from 'next/server';
2
2
  export declare const defaultSessionCookieName = "__fa_session__";
3
3
  export declare const defaultRefreshTokenCookieName = "__fa_refresh_token__";
4
4
  export declare const defaultEmailVerifiedHintCookieName = "__fa_email_verified_hint__";
5
+ export declare const defaultResetPasswordPath = "/reset-password";
5
6
  /**
6
7
  * Layers Firebase session-cookie validation/refresh and auth redirects onto
7
8
  * an already-built middleware response. Called internally by `intlMiddleware`
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import config from '@intl-config';
3
3
  import decodeJwtPayload from '../decode_jwt_payload';
4
+ import isWhitelisted from '../is_whitelisted';
4
5
  export const defaultSessionCookieName = '__fa_session__';
5
6
  export const defaultRefreshTokenCookieName = '__fa_refresh_token__';
6
7
  // Non-httpOnly: written by AuthUserProvider (client) every time it observes
@@ -10,6 +11,24 @@ export const defaultRefreshTokenCookieName = '__fa_refresh_token__';
10
11
  // — force one refresh). Readable client-side is fine: it carries no secret,
11
12
  // only a boolean mirror of a claim already inside the session JWT.
12
13
  export const defaultEmailVerifiedHintCookieName = '__fa_email_verified_hint__';
14
+ export const defaultResetPasswordPath = '/reset-password';
15
+ /**
16
+ * Firebase's console exposes ONE project-wide action URL, so every email
17
+ * template (password reset, email verification, email recovery) lands on that
18
+ * same URL and distinguishes itself only by `?mode=`. This maps those raw
19
+ * `mode` values onto the app's own pages so each link reaches the page that
20
+ * knows how to consume its `oobCode`.
21
+ */
22
+ function resolveActionModePaths(fa) {
23
+ const paths = {
24
+ resetPassword: fa.resetPasswordPath ?? defaultResetPasswordPath,
25
+ };
26
+ if (fa.verifyEmailPath)
27
+ paths.verifyEmail = fa.verifyEmailPath;
28
+ if (fa.recoverEmailPath)
29
+ paths.recoverEmail = fa.recoverEmailPath;
30
+ return { ...paths, ...fa.actionModePaths };
31
+ }
13
32
  const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 5;
14
33
  const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
15
34
  // Refresh slightly before the real expiry — treating a token as expired
@@ -175,7 +194,33 @@ export default async function updateSession(request, baseResponse, locale) {
175
194
  }
176
195
  const localePrefix = locale === config.defaultLocale ? '' : requestPrefix;
177
196
  const localeUrl = (target) => new URL(`${localePrefix}${target === '/' ? '' : target}` || '/', request.url);
178
- const isWhiteListed = fa.whiteListPaths?.includes(path) ?? false;
197
+ // Emailed Firebase action links all arrive on the single project-wide
198
+ // action URL carrying `?mode=<action>&oobCode=...`. Forward them to the
199
+ // page for that mode BEFORE any auth/whitelist check below: these links
200
+ // are followed by users who are typically signed OUT (a password reset,
201
+ // or verification opened in another browser), so letting the guest
202
+ // redirect run first would bounce them to `redirectAuthPath` and discard
203
+ // the `oobCode` they came to spend. The whole query string is preserved
204
+ // so the destination page still receives `oobCode`/`continueUrl`/`lang`.
205
+ // When `actionLinkPath` is set, only requests to that exact (locale-
206
+ // stripped) path are eligible — matches a Firebase Console action URL
207
+ // pinned to one static path (e.g. "https://example.com/auth/action")
208
+ // rather than the bare domain root.
209
+ const isEligibleActionPath = !fa.actionLinkPath || path === fa.actionLinkPath;
210
+ if (fa.actionLinkRedirectEnabled !== false && isEligibleActionPath) {
211
+ const mode = request.nextUrl.searchParams.get('mode');
212
+ if (mode) {
213
+ const target = resolveActionModePaths(fa)[mode];
214
+ // Skip when already on the destination — the forward sets the same
215
+ // `?mode=` it matched on, so redirecting again would loop forever.
216
+ if (target && target !== path) {
217
+ const url = localeUrl(target);
218
+ url.search = request.nextUrl.search;
219
+ return buildRedirect(baseResponse, url);
220
+ }
221
+ }
222
+ }
223
+ const isWhiteListed = isWhitelisted(path, fa.whiteListPaths);
179
224
  if (isWhiteListed)
180
225
  return baseResponse;
181
226
  const isAuthPage = fa.isAuthPath(path);
@@ -0,0 +1,73 @@
1
+ import { bench, describe } from 'vitest';
2
+ import { isDarkCookieKey } from '../../config/cookie_key';
3
+ const getCookieRegex = (name) => {
4
+ const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
5
+ return match ? decodeURIComponent(match[1]) : null;
6
+ };
7
+ const cache = new Map();
8
+ const getCookieCachedRegex = (name) => {
9
+ let re = cache.get(name);
10
+ if (!re) {
11
+ re = new RegExp(`(?:^|; )${name}=([^;]*)`);
12
+ cache.set(name, re);
13
+ }
14
+ const match = document.cookie.match(re);
15
+ return match ? decodeURIComponent(match[1]) : null;
16
+ };
17
+ const getCookieIndexOf = (name) => {
18
+ const raw = document.cookie;
19
+ const key = name + '=';
20
+ let i = raw.indexOf(key);
21
+ while (i !== -1) {
22
+ if (i === 0 || (raw[i - 1] === ' ' && raw[i - 2] === ';')) {
23
+ const end = raw.indexOf(';', i);
24
+ return raw.slice(i + key.length, end === -1 ? undefined : end);
25
+ }
26
+ i = raw.indexOf(key, i + 1);
27
+ }
28
+ return null;
29
+ };
30
+ document.cookie = `${isDarkCookieKey}=true`;
31
+ document.cookie = 'NEXT_LOCALE=uk';
32
+ document.cookie = 'session=abcdefghijklmnop';
33
+ describe('cookie read strategies', () => {
34
+ bench('regex constructed per call (current)', () => {
35
+ getCookieRegex(isDarkCookieKey);
36
+ });
37
+ bench('regex cached', () => {
38
+ getCookieCachedRegex(isDarkCookieKey);
39
+ });
40
+ bench('indexOf scan, no decode', () => {
41
+ getCookieIndexOf(isDarkCookieKey);
42
+ });
43
+ });
44
+ describe('theme apply', () => {
45
+ bench('guarded toggle (current)', () => {
46
+ const classList = document.documentElement.classList;
47
+ const isDark = true;
48
+ if (classList.contains('dark') !== isDark) {
49
+ classList.toggle('dark', isDark);
50
+ }
51
+ });
52
+ bench('unguarded toggle', () => {
53
+ document.documentElement.classList.toggle('dark', true);
54
+ });
55
+ });
56
+ describe('full inline script path', () => {
57
+ bench('current: read cookie + guarded toggle', () => {
58
+ const isDark = getCookieRegex(isDarkCookieKey);
59
+ const classList = document.documentElement.classList;
60
+ const want = isDark === 'true';
61
+ if (classList.contains('dark') !== want) {
62
+ classList.toggle('dark', want);
63
+ }
64
+ });
65
+ bench('optimized: indexOf + guarded toggle', () => {
66
+ const isDark = getCookieIndexOf(isDarkCookieKey);
67
+ const classList = document.documentElement.classList;
68
+ const want = isDark === 'true';
69
+ if (classList.contains('dark') !== want) {
70
+ classList.toggle('dark', want);
71
+ }
72
+ });
73
+ });
@@ -446,6 +446,48 @@ export interface FirebaseAuthRoutingConfig {
446
446
  homePath: string;
447
447
  /** Path to redirect unverified-email users to. Omit to skip email-verification redirects. Must start with "/" — `setIntlConfig` auto-corrects a missing leading slash with a warning. */
448
448
  verifyEmailPath?: string;
449
+ /**
450
+ * Path handling an emailed password-reset link. Firebase allows only ONE
451
+ * project-wide action URL, so every template lands on the same URL with
452
+ * a `?mode=` query param; the middleware reads that param and forwards
453
+ * the request (query string intact, `oobCode` included) to the path for
454
+ * that mode. Defaults to `'/reset-password'`. Must start with "/" —
455
+ * `setIntlConfig` auto-corrects a missing leading slash with a warning.
456
+ */
457
+ resetPasswordPath?: string;
458
+ /**
459
+ * Path handling an emailed `recoverEmail` action link (undo an email
460
+ * change). Omit to leave that mode unhandled — the request then falls
461
+ * through to normal routing instead of being forwarded. Must start with
462
+ * "/" — `setIntlConfig` auto-corrects a missing leading slash with a
463
+ * warning.
464
+ */
465
+ recoverEmailPath?: string;
466
+ /**
467
+ * Extra/overriding `?mode=` → path entries for the emailed-action-link
468
+ * forward described on {@link resetPasswordPath}. Merged over the
469
+ * defaults derived from `resetPasswordPath`/`verifyEmailPath`/
470
+ * `recoverEmailPath`, so this is how you handle a mode this config has
471
+ * no dedicated field for (e.g. `verifyAndChangeEmail`) or point one of
472
+ * the known modes somewhere else. Keys are raw Firebase `mode` values.
473
+ */
474
+ actionModePaths?: Readonly<Record<string, string>>;
475
+ /**
476
+ * Set `false` to disable the emailed-action-link forward entirely (see
477
+ * {@link resetPasswordPath}) and let `?mode=` URLs route normally.
478
+ * Defaults to `true`.
479
+ */
480
+ actionLinkRedirectEnabled?: boolean;
481
+ /**
482
+ * Restricts the emailed-action-link forward (see {@link resetPasswordPath})
483
+ * to this exact static path — set this to whatever path your Firebase
484
+ * Console "action URL" is pinned to (e.g. `'/auth/action'`) so a `?mode=`
485
+ * on any other page is left alone instead of being treated as an action
486
+ * link. Omit to match Firebase's bare-domain-root default: any path
487
+ * carrying `?mode=` is eligible. Must start with "/" — `setIntlConfig`
488
+ * auto-corrects a missing leading slash with a warning.
489
+ */
490
+ actionLinkPath?: string;
449
491
  /** Returns true if the given (locale-stripped) path is an auth page (login/signup/etc). */
450
492
  isAuthPath: (path: string) => boolean;
451
493
  /** Locale-stripped paths exempt from all auth redirects (e.g. public marketing pages). */
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vite").UserConfig;
2
+ export default _default;
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import path from 'node:path';
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'jsdom',
6
+ setupFiles: ['./vitest.setup.ts'],
7
+ benchmark: {
8
+ include: ['src/server/components/helper_script.bench.ts'],
9
+ outputJson: '/private/tmp/claude-501/-Volumes-External-own-projects-cloudflare-next-intl/5816d729-594e-4dc8-b38e-1378f1a159bd/scratchpad/bres.json',
10
+ },
11
+ },
12
+ resolve: {
13
+ alias: {
14
+ '@intl-config': path.resolve(__dirname, './src/test_utils/mock_intl_config.ts'),
15
+ '@locale-file': path.resolve(__dirname, './src/test_utils/mock_locale_file'),
16
+ },
17
+ },
18
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.6.18",
3
+ "version": "0.6.20",
4
4
  "description": "Optimized Next Intl Package Special for App Router and Cloudflare",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",