@10x-media/dual-session 0.1.0-beta.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/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/auth/authorization.js +49 -0
- package/dist/auth/authorization.js.map +1 -0
- package/dist/auth/cookies.d.ts +27 -0
- package/dist/auth/cookies.js +53 -0
- package/dist/auth/cookies.js.map +1 -0
- package/dist/auth/csrf.js +18 -0
- package/dist/auth/csrf.js.map +1 -0
- package/dist/auth/endpoints.js +284 -0
- package/dist/auth/endpoints.js.map +1 -0
- package/dist/auth/misclassification.js +31 -0
- package/dist/auth/misclassification.js.map +1 -0
- package/dist/auth/runtime.d.ts +59 -0
- package/dist/auth/runtime.js +91 -0
- package/dist/auth/runtime.js.map +1 -0
- package/dist/auth/strategy.js +100 -0
- package/dist/auth/strategy.js.map +1 -0
- package/dist/exports/client.d.ts +1 -0
- package/dist/exports/client.js +2 -0
- package/dist/exports/i18n.d.ts +3 -0
- package/dist/exports/i18n.js +3 -0
- package/dist/exports/proxy.d.ts +4 -0
- package/dist/exports/proxy.js +3 -0
- package/dist/exports/types.d.ts +2 -0
- package/dist/exports/types.js +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin/constants.d.ts +6 -0
- package/dist/plugin/constants.js +7 -0
- package/dist/plugin/constants.js.map +1 -0
- package/dist/plugin/registerTranslations.js +19 -0
- package/dist/plugin/registerTranslations.js.map +1 -0
- package/dist/plugin/resolveCollections.js +27 -0
- package/dist/plugin/resolveCollections.js.map +1 -0
- package/dist/plugin/selectCollections.js +54 -0
- package/dist/plugin/selectCollections.js.map +1 -0
- package/dist/scope/proxy.d.ts +35 -0
- package/dist/scope/proxy.js +42 -0
- package/dist/scope/proxy.js.map +1 -0
- package/dist/scope/resolveAuthScope.d.ts +43 -0
- package/dist/scope/resolveAuthScope.js +43 -0
- package/dist/scope/resolveAuthScope.js.map +1 -0
- package/dist/translations/en.js +12 -0
- package/dist/translations/en.js.map +1 -0
- package/dist/translations/index.d.ts +14 -0
- package/dist/translations/index.js +27 -0
- package/dist/translations/index.js.map +1 -0
- package/dist/translations/keys.d.ts +13 -0
- package/dist/translations/keys.js +11 -0
- package/dist/translations/keys.js.map +1 -0
- package/dist/types.d.ts +78 -0
- package/package.json +124 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"selectCollections.js","names":[],"sources":["../../src/plugin/selectCollections.ts"],"sourcesContent":["import type { CollectionConfig, Config } from 'payload'\n\nimport type { DualSessionPluginOptions, ResolvedIsolatedCollection } from '../types'\nimport { resolveCollections } from './resolveCollections'\n\n/**\n * The slug Payload will treat as the admin panel's user collection.\n *\n * Mirrors `sanitizeConfig`: an explicit `admin.user` wins, otherwise the first collection\n * declaring `auth`, otherwise the `users` collection core appends for you. Guessing\n * `'users'` outright would miss the case the check exists for: a project whose admin\n * collection is named something else and never set `admin.user`, where isolating it would\n * take the admin panel down.\n */\nexport const resolveAdminUserSlug = (config: Config): string =>\n\tconfig.admin?.user ??\n\t(config.collections ?? []).find(({ auth }) => Boolean(auth))?.slug ??\n\t'users'\n\n/**\n * Picks the collections this plugin will actually isolate, dropping the ones it cannot.\n *\n * Plugins run before `sanitizeConfig` and in array order, so a collection contributed by a\n * later plugin is genuinely absent here. That is a load-order problem, not a broken config,\n * and refusing to boot over it would be worse than saying so and moving on. Isolating the\n * admin collection wholesale stays fatal: it is the one mistake that silently breaks the\n * admin panel.\n *\n * Warnings are returned rather than logged: `payload.logger` does not exist while the config\n * is being built, so the caller replays them from `onInit`.\n */\nexport const selectCollections = ({\n\tadminUserSlug,\n\tcollections,\n\tcookiePrefix,\n\tincoming,\n}: {\n\tadminUserSlug: string\n\tcollections: DualSessionPluginOptions['collections']\n\tcookiePrefix: string\n\tincoming: CollectionConfig[]\n}): { collections: ResolvedIsolatedCollection[]; warnings: string[] } => {\n\tconst resolved = resolveCollections({ collections, cookiePrefix })\n\tconst warnings: string[] = []\n\tconst adminEntry = resolved.find(({ slug }) => slug === adminUserSlug)\n\n\tif (adminEntry && !adminEntry.isolate) {\n\t\tthrow new Error(\n\t\t\t`@10x-media/dual-session: \"${adminUserSlug}\" backs the admin panel and owns the shared \"${cookiePrefix}-token\" cookie, so isolating all of it takes the admin panel down. Give the entry an \\`isolate\\` predicate to move only some of its users onto a second cookie, or list the other auth collections instead.`\n\t\t)\n\t}\n\n\tif (adminEntry?.scopes.includes('admin')) {\n\t\t// The isolated strategy runs ahead of core's `local-jwt`, so an isolated cookie that\n\t\t// is allowed to answer admin-scoped requests would outrank the admin's own shared\n\t\t// cookie on the very collection the panel authenticates against.\n\t\tthrow new Error(\n\t\t\t`@10x-media/dual-session: \"${adminUserSlug}\" backs the admin panel, so its isolated cookie must not carry the \"admin\" scope. It would shadow the admin session it is supposed to sit beside. Use \\`scopes: ['frontend']\\`.`\n\t\t)\n\t}\n\n\tconst selected = resolved.filter(({ slug }) => {\n\t\tconst collection = incoming.find((entry) => entry.slug === slug)\n\n\t\tif (!collection) {\n\t\t\twarnings.push(\n\t\t\t\t`@10x-media/dual-session: collection \"${slug}\" is not in the config, so it was skipped. If another plugin adds it, list dualSession after that plugin.`\n\t\t\t)\n\t\t\treturn false\n\t\t}\n\n\t\tif (!collection.auth) {\n\t\t\twarnings.push(\n\t\t\t\t`@10x-media/dual-session: collection \"${slug}\" does not have auth enabled, so it was skipped.`\n\t\t\t)\n\t\t\treturn false\n\t\t}\n\n\t\tif (collection.endpoints === false) {\n\t\t\t// Kept, not skipped: core answers 501 for every route on such a collection with or\n\t\t\t// without this plugin, and a custom login route can still mint the isolated cookie.\n\t\t\twarnings.push(\n\t\t\t\t`@10x-media/dual-session: collection \"${slug}\" sets \"endpoints: false\", so it has no REST auth routes to shadow. Sessions for it can only be established outside REST, via generateIsolatedAuthCookie.`\n\t\t\t)\n\t\t}\n\n\t\treturn true\n\t})\n\n\treturn { collections: selected, warnings }\n}\n"],"mappings":";;;;;;;;;;;AAcA,MAAa,wBAAwB,WACpC,OAAO,OAAO,SACb,OAAO,eAAe,CAAC,GAAG,MAAM,EAAE,WAAW,QAAQ,IAAI,CAAC,GAAG,QAC9D;;;;;;;;;;;;;AAcD,MAAa,qBAAqB,EACjC,eACA,aACA,cACA,eAMwE;CACxE,MAAM,WAAW,mBAAmB;EAAE;EAAa;CAAa,CAAC;CACjE,MAAM,WAAqB,CAAC;CAC5B,MAAM,aAAa,SAAS,MAAM,EAAE,WAAW,SAAS,aAAa;CAErE,IAAI,cAAc,CAAC,WAAW,SAC7B,MAAM,IAAI,MACT,6BAA6B,cAAc,+CAA+C,aAAa,4MACxG;CAGD,IAAI,YAAY,OAAO,SAAS,OAAO,GAItC,MAAM,IAAI,MACT,6BAA6B,cAAc,gLAC5C;CA+BD,OAAO;EAAE,aA5BQ,SAAS,QAAQ,EAAE,WAAW;GAC9C,MAAM,aAAa,SAAS,MAAM,UAAU,MAAM,SAAS,IAAI;GAE/D,IAAI,CAAC,YAAY;IAChB,SAAS,KACR,wCAAwC,KAAK,0GAC9C;IACA,OAAO;GACR;GAEA,IAAI,CAAC,WAAW,MAAM;IACrB,SAAS,KACR,wCAAwC,KAAK,iDAC9C;IACA,OAAO;GACR;GAEA,IAAI,WAAW,cAAc,OAG5B,SAAS,KACR,wCAAwC,KAAK,0JAC9C;GAGD,OAAO;EACR,CAE6B;EAAG;CAAS;AAC1C"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { AuthScope } from "../types.js";
|
|
2
|
+
import { AUTH_SCOPE_HEADER, resolveAuthScope } from "./resolveAuthScope.js";
|
|
3
|
+
import { NextRequest, NextResponse } from "next/server.js";
|
|
4
|
+
|
|
5
|
+
//#region src/scope/proxy.d.ts
|
|
6
|
+
type AuthScopeProxyOptions = {
|
|
7
|
+
/** Admin panel route prefix. @default '/admin' */adminRoute?: string; /** Payload REST/GraphQL route prefix. @default '/api' */
|
|
8
|
+
apiRoute?: string; /** Override the default rule entirely. Return `undefined` to fall back to it. */
|
|
9
|
+
resolveScope?: (request: NextRequest) => AuthScope | undefined; /** Header to write the resolved scope into. @default 'x-payload-auth-scope' */
|
|
10
|
+
scopeHeader?: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Next.js proxy (`proxy.ts`, called middleware before Next 16) that stamps every
|
|
14
|
+
* request with its auth scope, so the isolated auth strategies know whether they are
|
|
15
|
+
* allowed to authenticate it.
|
|
16
|
+
*
|
|
17
|
+
* The header is replaced on every request, never merged, and is removed when the
|
|
18
|
+
* request cannot be attributed, so the scope a strategy reads is always this proxy's
|
|
19
|
+
* own answer.
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* // proxy.ts (Next 16) or middleware.ts (Next 15)
|
|
23
|
+
* import { createAuthScopeProxy } from '@10x-media/dual-session/proxy'
|
|
24
|
+
*
|
|
25
|
+
* export default createAuthScopeProxy()
|
|
26
|
+
*
|
|
27
|
+
* export const config = {
|
|
28
|
+
* matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
declare const createAuthScopeProxy: (options?: AuthScopeProxyOptions) => (request: NextRequest) => NextResponse<unknown>;
|
|
33
|
+
//#endregion
|
|
34
|
+
export { AuthScopeProxyOptions, createAuthScopeProxy };
|
|
35
|
+
//# sourceMappingURL=proxy.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { AUTH_SCOPE_HEADER, resolveAuthScope } from "./resolveAuthScope.js";
|
|
2
|
+
import { NextResponse } from "next/server.js";
|
|
3
|
+
//#region src/scope/proxy.ts
|
|
4
|
+
/**
|
|
5
|
+
* Next.js proxy (`proxy.ts`, called middleware before Next 16) that stamps every
|
|
6
|
+
* request with its auth scope, so the isolated auth strategies know whether they are
|
|
7
|
+
* allowed to authenticate it.
|
|
8
|
+
*
|
|
9
|
+
* The header is replaced on every request, never merged, and is removed when the
|
|
10
|
+
* request cannot be attributed, so the scope a strategy reads is always this proxy's
|
|
11
|
+
* own answer.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* // proxy.ts (Next 16) or middleware.ts (Next 15)
|
|
15
|
+
* import { createAuthScopeProxy } from '@10x-media/dual-session/proxy'
|
|
16
|
+
*
|
|
17
|
+
* export default createAuthScopeProxy()
|
|
18
|
+
*
|
|
19
|
+
* export const config = {
|
|
20
|
+
* matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
const createAuthScopeProxy = (options = {}) => (request) => {
|
|
25
|
+
const { adminRoute, apiRoute, resolveScope, scopeHeader = AUTH_SCOPE_HEADER } = options;
|
|
26
|
+
const scope = resolveScope?.(request) ?? resolveAuthScope({
|
|
27
|
+
adminRoute,
|
|
28
|
+
apiRoute,
|
|
29
|
+
origin: request.nextUrl.origin,
|
|
30
|
+
pathname: request.nextUrl.pathname,
|
|
31
|
+
referer: request.headers.get("Referer"),
|
|
32
|
+
secFetchSite: request.headers.get("Sec-Fetch-Site")
|
|
33
|
+
});
|
|
34
|
+
const headers = new Headers(request.headers);
|
|
35
|
+
if (scope) headers.set(scopeHeader, scope);
|
|
36
|
+
else headers.delete(scopeHeader);
|
|
37
|
+
return NextResponse.next({ request: { headers } });
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|
|
40
|
+
export { createAuthScopeProxy };
|
|
41
|
+
|
|
42
|
+
//# sourceMappingURL=proxy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proxy.js","names":[],"sources":["../../src/scope/proxy.ts"],"sourcesContent":["import { type NextRequest, NextResponse } from 'next/server'\nimport type { AuthScope } from '../types'\nimport { AUTH_SCOPE_HEADER, resolveAuthScope } from './resolveAuthScope'\n\nexport type AuthScopeProxyOptions = {\n\t/** Admin panel route prefix. @default '/admin' */\n\tadminRoute?: string\n\t/** Payload REST/GraphQL route prefix. @default '/api' */\n\tapiRoute?: string\n\t/** Override the default rule entirely. Return `undefined` to fall back to it. */\n\tresolveScope?: (request: NextRequest) => AuthScope | undefined\n\t/** Header to write the resolved scope into. @default 'x-payload-auth-scope' */\n\tscopeHeader?: string\n}\n\n/**\n * Next.js proxy (`proxy.ts`, called middleware before Next 16) that stamps every\n * request with its auth scope, so the isolated auth strategies know whether they are\n * allowed to authenticate it.\n *\n * The header is replaced on every request, never merged, and is removed when the\n * request cannot be attributed, so the scope a strategy reads is always this proxy's\n * own answer.\n *\n * ```ts\n * // proxy.ts (Next 16) or middleware.ts (Next 15)\n * import { createAuthScopeProxy } from '@10x-media/dual-session/proxy'\n *\n * export default createAuthScopeProxy()\n *\n * export const config = {\n * matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],\n * }\n * ```\n */\nexport const createAuthScopeProxy =\n\t(options: AuthScopeProxyOptions = {}) =>\n\t(request: NextRequest) => {\n\t\tconst { adminRoute, apiRoute, resolveScope, scopeHeader = AUTH_SCOPE_HEADER } = options\n\n\t\tconst scope =\n\t\t\tresolveScope?.(request) ??\n\t\t\tresolveAuthScope({\n\t\t\t\tadminRoute,\n\t\t\t\tapiRoute,\n\t\t\t\torigin: request.nextUrl.origin,\n\t\t\t\tpathname: request.nextUrl.pathname,\n\t\t\t\treferer: request.headers.get('Referer'),\n\t\t\t\tsecFetchSite: request.headers.get('Sec-Fetch-Site'),\n\t\t\t})\n\n\t\tconst headers = new Headers(request.headers)\n\n\t\t// An unattributable request carries no scope at all. Stamping a guess here would\n\t\t// override `adminSessionPriority`, which answers the same question better by\n\t\t// looking at whether an admin session actually exists.\n\t\tif (scope) {\n\t\t\theaders.set(scopeHeader, scope)\n\t\t} else {\n\t\t\theaders.delete(scopeHeader)\n\t\t}\n\n\t\treturn NextResponse.next({ request: { headers } })\n\t}\n\nexport type { AuthScope } from '../types'\nexport { AUTH_SCOPE_HEADER, resolveAuthScope } from './resolveAuthScope'\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAmCA,MAAa,wBACX,UAAiC,CAAC,OAClC,YAAyB;CACzB,MAAM,EAAE,YAAY,UAAU,cAAc,cAAc,sBAAsB;CAEhF,MAAM,QACL,eAAe,OAAO,KACtB,iBAAiB;EAChB;EACA;EACA,QAAQ,QAAQ,QAAQ;EACxB,UAAU,QAAQ,QAAQ;EAC1B,SAAS,QAAQ,QAAQ,IAAI,SAAS;EACtC,cAAc,QAAQ,QAAQ,IAAI,gBAAgB;CACnD,CAAC;CAEF,MAAM,UAAU,IAAI,QAAQ,QAAQ,OAAO;CAK3C,IAAI,OACH,QAAQ,IAAI,aAAa,KAAK;MAE9B,QAAQ,OAAO,WAAW;CAG3B,OAAO,aAAa,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC;AAClD"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AuthScope } from "../types.js";
|
|
2
|
+
|
|
3
|
+
//#region src/scope/resolveAuthScope.d.ts
|
|
4
|
+
/** Request header carrying the resolved {@link AuthScope} for a request. */
|
|
5
|
+
declare const AUTH_SCOPE_HEADER = "x-payload-auth-scope";
|
|
6
|
+
/**
|
|
7
|
+
* Decides which session a request may authenticate against, or `undefined` when the
|
|
8
|
+
* request carries no signal to decide by.
|
|
9
|
+
*
|
|
10
|
+
* Admin panel pages and their server actions live under `adminRoute`, so those are
|
|
11
|
+
* unambiguous. Payload's REST namespace is shared between the admin panel and the
|
|
12
|
+
* website, so those calls are attributed by `Referer`, which on same-origin browser fetches
|
|
13
|
+
* carry the full originating path.
|
|
14
|
+
*
|
|
15
|
+
* A `Referer` only says something about the admin panel when it belongs to the same
|
|
16
|
+
* origin the API is served from: a frontend on another origin may well have an
|
|
17
|
+
* `/admin` route of its own. `Sec-Fetch-Site` is what distinguishes the two, and a
|
|
18
|
+
* page cannot forge it. Clients that send no `Sec-Fetch-Site` are held to the same
|
|
19
|
+
* rule by comparing the `Referer` against `origin`, so pass it whenever the caller
|
|
20
|
+
* knows it. Without both signals a `Referer` cannot be trusted to mean the admin
|
|
21
|
+
* panel, and the request resolves to `frontend`.
|
|
22
|
+
*
|
|
23
|
+
* `undefined` means "no attribution", not "admin": callers should leave the scope
|
|
24
|
+
* header unset so the strategies fall back to `adminSessionPriority`.
|
|
25
|
+
*/
|
|
26
|
+
declare const resolveAuthScope: ({
|
|
27
|
+
adminRoute,
|
|
28
|
+
apiRoute,
|
|
29
|
+
origin,
|
|
30
|
+
pathname,
|
|
31
|
+
referer,
|
|
32
|
+
secFetchSite
|
|
33
|
+
}: {
|
|
34
|
+
adminRoute?: string;
|
|
35
|
+
apiRoute?: string; /** The origin the request was served from, used to vet a `Referer` from another site. */
|
|
36
|
+
origin?: null | string;
|
|
37
|
+
pathname: string;
|
|
38
|
+
referer?: null | string; /** The request's `Sec-Fetch-Site` header, when it has one. */
|
|
39
|
+
secFetchSite?: null | string;
|
|
40
|
+
}) => AuthScope | undefined;
|
|
41
|
+
//#endregion
|
|
42
|
+
export { AUTH_SCOPE_HEADER, resolveAuthScope };
|
|
43
|
+
//# sourceMappingURL=resolveAuthScope.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/scope/resolveAuthScope.ts
|
|
2
|
+
/** Request header carrying the resolved {@link AuthScope} for a request. */
|
|
3
|
+
const AUTH_SCOPE_HEADER = "x-payload-auth-scope";
|
|
4
|
+
const isWithin = (pathname, base) => pathname === base || pathname.startsWith(`${base}/`);
|
|
5
|
+
/**
|
|
6
|
+
* Decides which session a request may authenticate against, or `undefined` when the
|
|
7
|
+
* request carries no signal to decide by.
|
|
8
|
+
*
|
|
9
|
+
* Admin panel pages and their server actions live under `adminRoute`, so those are
|
|
10
|
+
* unambiguous. Payload's REST namespace is shared between the admin panel and the
|
|
11
|
+
* website, so those calls are attributed by `Referer`, which on same-origin browser fetches
|
|
12
|
+
* carry the full originating path.
|
|
13
|
+
*
|
|
14
|
+
* A `Referer` only says something about the admin panel when it belongs to the same
|
|
15
|
+
* origin the API is served from: a frontend on another origin may well have an
|
|
16
|
+
* `/admin` route of its own. `Sec-Fetch-Site` is what distinguishes the two, and a
|
|
17
|
+
* page cannot forge it. Clients that send no `Sec-Fetch-Site` are held to the same
|
|
18
|
+
* rule by comparing the `Referer` against `origin`, so pass it whenever the caller
|
|
19
|
+
* knows it. Without both signals a `Referer` cannot be trusted to mean the admin
|
|
20
|
+
* panel, and the request resolves to `frontend`.
|
|
21
|
+
*
|
|
22
|
+
* `undefined` means "no attribution", not "admin": callers should leave the scope
|
|
23
|
+
* header unset so the strategies fall back to `adminSessionPriority`.
|
|
24
|
+
*/
|
|
25
|
+
const resolveAuthScope = ({ adminRoute = "/admin", apiRoute = "/api", origin, pathname, referer, secFetchSite }) => {
|
|
26
|
+
if (isWithin(pathname, adminRoute)) return "admin";
|
|
27
|
+
if (isWithin(pathname, apiRoute)) {
|
|
28
|
+
if (!referer) return;
|
|
29
|
+
if (secFetchSite === "cross-site" || secFetchSite === "same-site") return "frontend";
|
|
30
|
+
try {
|
|
31
|
+
const url = new URL(referer);
|
|
32
|
+
if (secFetchSite !== "same-origin" && url.origin !== origin) return "frontend";
|
|
33
|
+
return isWithin(url.pathname, adminRoute) ? "admin" : "frontend";
|
|
34
|
+
} catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return "frontend";
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { AUTH_SCOPE_HEADER, resolveAuthScope };
|
|
42
|
+
|
|
43
|
+
//# sourceMappingURL=resolveAuthScope.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolveAuthScope.js","names":[],"sources":["../../src/scope/resolveAuthScope.ts"],"sourcesContent":["import type { AuthScope } from '../types'\n\n/** Request header carrying the resolved {@link AuthScope} for a request. */\nexport const AUTH_SCOPE_HEADER = 'x-payload-auth-scope'\n\nconst isWithin = (pathname: string, base: string) =>\n\tpathname === base || pathname.startsWith(`${base}/`)\n\n/**\n * Decides which session a request may authenticate against, or `undefined` when the\n * request carries no signal to decide by.\n *\n * Admin panel pages and their server actions live under `adminRoute`, so those are\n * unambiguous. Payload's REST namespace is shared between the admin panel and the\n * website, so those calls are attributed by `Referer`, which on same-origin browser fetches\n * carry the full originating path.\n *\n * A `Referer` only says something about the admin panel when it belongs to the same\n * origin the API is served from: a frontend on another origin may well have an\n * `/admin` route of its own. `Sec-Fetch-Site` is what distinguishes the two, and a\n * page cannot forge it. Clients that send no `Sec-Fetch-Site` are held to the same\n * rule by comparing the `Referer` against `origin`, so pass it whenever the caller\n * knows it. Without both signals a `Referer` cannot be trusted to mean the admin\n * panel, and the request resolves to `frontend`.\n *\n * `undefined` means \"no attribution\", not \"admin\": callers should leave the scope\n * header unset so the strategies fall back to `adminSessionPriority`.\n */\nexport const resolveAuthScope = ({\n\tadminRoute = '/admin',\n\tapiRoute = '/api',\n\torigin,\n\tpathname,\n\treferer,\n\tsecFetchSite,\n}: {\n\tadminRoute?: string\n\tapiRoute?: string\n\t/** The origin the request was served from, used to vet a `Referer` from another site. */\n\torigin?: null | string\n\tpathname: string\n\treferer?: null | string\n\t/** The request's `Sec-Fetch-Site` header, when it has one. */\n\tsecFetchSite?: null | string\n}): AuthScope | undefined => {\n\tif (isWithin(pathname, adminRoute)) {\n\t\treturn 'admin'\n\t}\n\n\tif (isWithin(pathname, apiRoute)) {\n\t\tif (!referer) {\n\t\t\treturn undefined\n\t\t}\n\n\t\tif (secFetchSite === 'cross-site' || secFetchSite === 'same-site') {\n\t\t\treturn 'frontend'\n\t\t}\n\n\t\ttry {\n\t\t\tconst url = new URL(referer)\n\n\t\t\t// Without `Sec-Fetch-Site` the origin is the only thing separating our own admin\n\t\t\t// panel from another site's `/admin` route, so an unverifiable origin means the\n\t\t\t// path is not evidence of anything.\n\t\t\tif (secFetchSite !== 'same-origin' && url.origin !== origin) {\n\t\t\t\treturn 'frontend'\n\t\t\t}\n\n\t\t\treturn isWithin(url.pathname, adminRoute) ? 'admin' : 'frontend'\n\t\t} catch {\n\t\t\treturn undefined\n\t\t}\n\t}\n\n\treturn 'frontend'\n}\n"],"mappings":";;AAGA,MAAa,oBAAoB;AAEjC,MAAM,YAAY,UAAkB,SACnC,aAAa,QAAQ,SAAS,WAAW,GAAG,KAAK,EAAE;;;;;;;;;;;;;;;;;;;;;AAsBpD,MAAa,oBAAoB,EAChC,aAAa,UACb,WAAW,QACX,QACA,UACA,SACA,mBAU4B;CAC5B,IAAI,SAAS,UAAU,UAAU,GAChC,OAAO;CAGR,IAAI,SAAS,UAAU,QAAQ,GAAG;EACjC,IAAI,CAAC,SACJ;EAGD,IAAI,iBAAiB,gBAAgB,iBAAiB,aACrD,OAAO;EAGR,IAAI;GACH,MAAM,MAAM,IAAI,IAAI,OAAO;GAK3B,IAAI,iBAAiB,iBAAiB,IAAI,WAAW,QACpD,OAAO;GAGR,OAAO,SAAS,IAAI,UAAU,UAAU,IAAI,UAAU;EACvD,QAAQ;GACP;EACD;CACD;CAEA,OAAO;AACR"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { keys } from "./keys.js";
|
|
2
|
+
//#region src/translations/en.ts
|
|
3
|
+
/**
|
|
4
|
+
* English values, keyed by the typed constants in `keys.ts` so the two stay in
|
|
5
|
+
* lockstep. The `Record<TranslationKey, string>` annotation makes a missing or
|
|
6
|
+
* unknown key a type error. `translations/index.ts` nests these for Payload.
|
|
7
|
+
*/
|
|
8
|
+
const en = { [keys.pluginName]: "Dual Session" };
|
|
9
|
+
//#endregion
|
|
10
|
+
export { en };
|
|
11
|
+
|
|
12
|
+
//# sourceMappingURL=en.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"en.js","names":[],"sources":["../../src/translations/en.ts"],"sourcesContent":["import { keys, type TranslationKey } from './keys'\n\n/**\n * English values, keyed by the typed constants in `keys.ts` so the two stay in\n * lockstep. The `Record<TranslationKey, string>` annotation makes a missing or\n * unknown key a type error. `translations/index.ts` nests these for Payload.\n */\nexport const en: Record<TranslationKey, string> = {\n\t[keys.pluginName]: 'Dual Session',\n}\n"],"mappings":";;;;;;;AAOA,MAAa,KAAqC,GAChD,KAAK,aAAa,eACpB"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { TranslationKey, keys } from "./keys.js";
|
|
2
|
+
|
|
3
|
+
//#region src/translations/index.d.ts
|
|
4
|
+
/** Per-locale string overrides keyed by this plugin's typed translation keys. */
|
|
5
|
+
type TranslationsOption = {
|
|
6
|
+
[locale: string]: Partial<Record<TranslationKey, string>>;
|
|
7
|
+
};
|
|
8
|
+
/** Per-locale messages merged into `config.i18n.translations`. English only for now. */
|
|
9
|
+
declare const translations: {
|
|
10
|
+
en: Record<string, Record<string, string>>;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { TranslationsOption, translations };
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import "./keys.js";
|
|
2
|
+
import { en } from "./en.js";
|
|
3
|
+
//#region src/translations/index.ts
|
|
4
|
+
/**
|
|
5
|
+
* Flat `dualSession:foo` entries to the nested `{ dualSession: { foo } }`
|
|
6
|
+
* shape Payload resolves `t('dualSession:foo')` against (it splits on `:`).
|
|
7
|
+
* Undefined values are skipped so `Partial` override maps pass through.
|
|
8
|
+
*/
|
|
9
|
+
const toNested = (flat) => {
|
|
10
|
+
const out = {};
|
|
11
|
+
for (const [fullKey, value] of Object.entries(flat)) {
|
|
12
|
+
if (typeof value !== "string") continue;
|
|
13
|
+
const separator = fullKey.indexOf(":");
|
|
14
|
+
if (separator < 1) continue;
|
|
15
|
+
const namespace = fullKey.slice(0, separator);
|
|
16
|
+
const bucket = out[namespace] ?? {};
|
|
17
|
+
bucket[fullKey.slice(separator + 1)] = value;
|
|
18
|
+
out[namespace] = bucket;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
};
|
|
22
|
+
/** Per-locale messages merged into `config.i18n.translations`. English only for now. */
|
|
23
|
+
const translations = { en: toNested(en) };
|
|
24
|
+
//#endregion
|
|
25
|
+
export { toNested, translations };
|
|
26
|
+
|
|
27
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/translations/index.ts"],"sourcesContent":["import { en } from './en'\nimport type { TranslationKey } from './keys'\n\nexport type { TranslationKey } from './keys'\nexport { keys } from './keys'\n\n/** Per-locale string overrides keyed by this plugin's typed translation keys. */\nexport type TranslationsOption = {\n\t[locale: string]: Partial<Record<TranslationKey, string>>\n}\n\n/**\n * Flat `dualSession:foo` entries to the nested `{ dualSession: { foo } }`\n * shape Payload resolves `t('dualSession:foo')` against (it splits on `:`).\n * Undefined values are skipped so `Partial` override maps pass through.\n */\nexport const toNested = (flat: {\n\t[key: string]: string | undefined\n}): Record<string, Record<string, string>> => {\n\tconst out: Record<string, Record<string, string>> = {}\n\tfor (const [fullKey, value] of Object.entries(flat)) {\n\t\tif (typeof value !== 'string') {\n\t\t\tcontinue\n\t\t}\n\t\tconst separator = fullKey.indexOf(':')\n\t\t// An unnamespaced key has no bucket to go in, and `slice(0, -1)` would invent one\n\t\t// out of the key with its last character cut off.\n\t\tif (separator < 1) {\n\t\t\tcontinue\n\t\t}\n\t\tconst namespace = fullKey.slice(0, separator)\n\t\tconst bucket = out[namespace] ?? {}\n\t\tbucket[fullKey.slice(separator + 1)] = value\n\t\tout[namespace] = bucket\n\t}\n\treturn out\n}\n\n/** Per-locale messages merged into `config.i18n.translations`. English only for now. */\nexport const translations = {\n\ten: toNested(en),\n}\n"],"mappings":";;;;;;;;AAgBA,MAAa,YAAY,SAEqB;CAC7C,MAAM,MAA8C,CAAC;CACrD,KAAK,MAAM,CAAC,SAAS,UAAU,OAAO,QAAQ,IAAI,GAAG;EACpD,IAAI,OAAO,UAAU,UACpB;EAED,MAAM,YAAY,QAAQ,QAAQ,GAAG;EAGrC,IAAI,YAAY,GACf;EAED,MAAM,YAAY,QAAQ,MAAM,GAAG,SAAS;EAC5C,MAAM,SAAS,IAAI,cAAc,CAAC;EAClC,OAAO,QAAQ,MAAM,YAAY,CAAC,KAAK;EACvC,IAAI,aAAa;CAClB;CACA,OAAO;AACR;;AAGA,MAAa,eAAe,EAC3B,IAAI,SAAS,EAAE,EAChB"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/translations/keys.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Typed translation keys. Lookups must go through these constants, not string
|
|
4
|
+
* literals (enforced by requireI18nKeysTyped.grit). Every key here must have a
|
|
5
|
+
* value in every locale (`en.ts`), or it is a type error.
|
|
6
|
+
*/
|
|
7
|
+
declare const keys: {
|
|
8
|
+
readonly pluginName: "dualSession:pluginName";
|
|
9
|
+
};
|
|
10
|
+
type TranslationKey = (typeof keys)[keyof typeof keys];
|
|
11
|
+
//#endregion
|
|
12
|
+
export { TranslationKey, keys };
|
|
13
|
+
//# sourceMappingURL=keys.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/translations/keys.ts
|
|
2
|
+
/**
|
|
3
|
+
* Typed translation keys. Lookups must go through these constants, not string
|
|
4
|
+
* literals (enforced by requireI18nKeysTyped.grit). Every key here must have a
|
|
5
|
+
* value in every locale (`en.ts`), or it is a type error.
|
|
6
|
+
*/
|
|
7
|
+
const keys = { pluginName: "dualSession:pluginName" };
|
|
8
|
+
//#endregion
|
|
9
|
+
export { keys };
|
|
10
|
+
|
|
11
|
+
//# sourceMappingURL=keys.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keys.js","names":[],"sources":["../../src/translations/keys.ts"],"sourcesContent":["/**\n * Typed translation keys. Lookups must go through these constants, not string\n * literals (enforced by requireI18nKeysTyped.grit). Every key here must have a\n * value in every locale (`en.ts`), or it is a type error.\n */\nexport const keys = {\n\tpluginName: 'dualSession:pluginName',\n} as const\n\nexport type TranslationKey = (typeof keys)[keyof typeof keys]\n"],"mappings":";;;;;;AAKA,MAAa,OAAO,EACnB,YAAY,yBACb"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { TranslationsOption } from "./translations/index.js";
|
|
2
|
+
import { CollectionSlug, TypedUser } from "payload";
|
|
3
|
+
|
|
4
|
+
//#region src/types.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Which session a request is allowed to be authenticated against.
|
|
7
|
+
*
|
|
8
|
+
* - `admin`: only the admin panel's own cookie (`${cookiePrefix}-token`) may populate `req.user`
|
|
9
|
+
* - `frontend`: isolated collection cookies may populate `req.user`
|
|
10
|
+
*/
|
|
11
|
+
type AuthScope = 'admin' | 'frontend';
|
|
12
|
+
type IsolatedCollection = {
|
|
13
|
+
/**
|
|
14
|
+
* Full cookie name holding this collection's token.
|
|
15
|
+
* @default `${cookiePrefix}-${slug}-token`
|
|
16
|
+
*/
|
|
17
|
+
cookieName?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Which of this collection's users get a cookie of their own. Users it returns `false`
|
|
20
|
+
* for stay on the shared `${cookiePrefix}-token`, so the admin panel is untouched.
|
|
21
|
+
*
|
|
22
|
+
* This is what lets one collection with roles back both sessions: the admin panel keeps
|
|
23
|
+
* the shared cookie byte for byte as core writes it, and only the users this predicate
|
|
24
|
+
* claims are moved onto a second one. It is also the only way to list the collection
|
|
25
|
+
* named by `admin.user`.
|
|
26
|
+
*
|
|
27
|
+
* Roles live in the user document, so the predicate should read them from there. It is
|
|
28
|
+
* never asked about a request, only about a user.
|
|
29
|
+
*
|
|
30
|
+
* @default every session of the collection is isolated
|
|
31
|
+
*/
|
|
32
|
+
isolate?: (user: TypedUser) => boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Scopes in which this collection's cookie is allowed to authenticate a request.
|
|
35
|
+
* Only enforced when the auth-scope proxy is installed.
|
|
36
|
+
* @default ['frontend']
|
|
37
|
+
*/
|
|
38
|
+
scopes?: AuthScope[];
|
|
39
|
+
slug: CollectionSlug;
|
|
40
|
+
};
|
|
41
|
+
type DualSessionPluginOptions = {
|
|
42
|
+
/**
|
|
43
|
+
* When no scope header is present on a request, ignore isolated cookies as long as a
|
|
44
|
+
* valid admin-collection token is also present. Keeps the admin panel usable in
|
|
45
|
+
* projects that have not installed the auth-scope proxy.
|
|
46
|
+
* @default true
|
|
47
|
+
*/
|
|
48
|
+
adminSessionPriority?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Auth collections to move off the shared `${cookiePrefix}-token` cookie.
|
|
51
|
+
* The collection configured as `admin.user` may only be listed together with an
|
|
52
|
+
* `isolate` predicate. Without one it would lose the shared cookie it owns.
|
|
53
|
+
*
|
|
54
|
+
* Order is priority: when a visitor holds sessions for more than one of these at
|
|
55
|
+
* once, the earliest listed collection wins.
|
|
56
|
+
*/
|
|
57
|
+
collections: (CollectionSlug | IsolatedCollection)[];
|
|
58
|
+
/**
|
|
59
|
+
* Disable the plugin entirely (incoming config returned untouched).
|
|
60
|
+
* Useful for opting out per environment without removing the plugin call.
|
|
61
|
+
*/
|
|
62
|
+
disabled?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Request header the auth-scope proxy writes the resolved scope into.
|
|
65
|
+
* @default 'x-payload-auth-scope'
|
|
66
|
+
*/
|
|
67
|
+
scopeHeader?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Per-locale overrides for this plugin's UI strings, keyed by the typed
|
|
70
|
+
* translation keys exported from `@10x-media/dual-session/i18n`. Values win
|
|
71
|
+
* over the built-in locales key-by-key; locales the plugin does not ship are
|
|
72
|
+
* added whole. App-level `i18n.translations` still wins over both.
|
|
73
|
+
*/
|
|
74
|
+
translations?: TranslationsOption;
|
|
75
|
+
};
|
|
76
|
+
//#endregion
|
|
77
|
+
export { AuthScope, DualSessionPluginOptions, IsolatedCollection };
|
|
78
|
+
//# sourceMappingURL=types.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@10x-media/dual-session",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Give each Payload auth collection its own session cookie, so an admin session and a frontend session can coexist.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/10x-media/payload-plugins.git",
|
|
9
|
+
"directory": "packages/dual-session"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/10x-media/payload-plugins/tree/main/packages/dual-session",
|
|
12
|
+
"bugs": "https://github.com/10x-media/payload-plugins/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"payload",
|
|
15
|
+
"payloadcms",
|
|
16
|
+
"plugin",
|
|
17
|
+
"auth",
|
|
18
|
+
"session",
|
|
19
|
+
"cookie"
|
|
20
|
+
],
|
|
21
|
+
"author": "10x-media",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22.18.0"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./types": {
|
|
34
|
+
"types": "./dist/exports/types.d.ts",
|
|
35
|
+
"import": "./dist/exports/types.js",
|
|
36
|
+
"default": "./dist/exports/types.js"
|
|
37
|
+
},
|
|
38
|
+
"./client": {
|
|
39
|
+
"types": "./dist/exports/client.d.ts",
|
|
40
|
+
"import": "./dist/exports/client.js",
|
|
41
|
+
"default": "./dist/exports/client.js"
|
|
42
|
+
},
|
|
43
|
+
"./i18n": {
|
|
44
|
+
"types": "./dist/exports/i18n.d.ts",
|
|
45
|
+
"import": "./dist/exports/i18n.js",
|
|
46
|
+
"default": "./dist/exports/i18n.js"
|
|
47
|
+
},
|
|
48
|
+
"./proxy": {
|
|
49
|
+
"types": "./dist/exports/proxy.d.ts",
|
|
50
|
+
"import": "./dist/exports/proxy.js",
|
|
51
|
+
"default": "./dist/exports/proxy.js"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"dist",
|
|
56
|
+
"README.md",
|
|
57
|
+
"CHANGELOG.md",
|
|
58
|
+
"LICENSE"
|
|
59
|
+
],
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"jose": "^5.10.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"@payloadcms/ui": "^3.83.0",
|
|
65
|
+
"next": "^15.0.0 || ^16.0.0",
|
|
66
|
+
"payload": "^3.83.0",
|
|
67
|
+
"react": "^19.0.0",
|
|
68
|
+
"react-dom": "^19.0.0"
|
|
69
|
+
},
|
|
70
|
+
"peerDependenciesMeta": {
|
|
71
|
+
"next": {
|
|
72
|
+
"optional": true
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"devDependencies": {
|
|
76
|
+
"@payloadcms/db-mongodb": "3.85.0",
|
|
77
|
+
"@payloadcms/db-postgres": "3.85.0",
|
|
78
|
+
"@payloadcms/ui": "3.85.0",
|
|
79
|
+
"@playwright/test": "1.60.0",
|
|
80
|
+
"@types/node": "22.19.19",
|
|
81
|
+
"@types/react": "19.2.15",
|
|
82
|
+
"@types/react-dom": "19.2.3",
|
|
83
|
+
"next": "16.2.6",
|
|
84
|
+
"payload": "3.85.0",
|
|
85
|
+
"playwright": "1.60.0",
|
|
86
|
+
"react": "19.2.6",
|
|
87
|
+
"react-dom": "19.2.6",
|
|
88
|
+
"tsdown": "0.22.1",
|
|
89
|
+
"typescript": "5.9.3",
|
|
90
|
+
"vitest": "4.1.7",
|
|
91
|
+
"@10x-media/tsconfig": "0.0.0",
|
|
92
|
+
"@10x-media/vitest-config": "0.0.0",
|
|
93
|
+
"@10x-media/tsdown-config": "0.0.0",
|
|
94
|
+
"@10x-media/payload-test-harness": "0.0.0"
|
|
95
|
+
},
|
|
96
|
+
"publishConfig": {
|
|
97
|
+
"access": "public"
|
|
98
|
+
},
|
|
99
|
+
"scripts": {
|
|
100
|
+
"build": "tsdown",
|
|
101
|
+
"lint": "biome check src tests dev",
|
|
102
|
+
"lint:fix": "biome check --write src tests dev",
|
|
103
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
104
|
+
"test": "vitest run",
|
|
105
|
+
"test:unit": "vitest run src",
|
|
106
|
+
"test:int": "vitest run tests/int",
|
|
107
|
+
"test:matrix": "DB_MATRIX=mongo vitest run tests/int/matrix.int.spec.ts && DB_MATRIX=postgres vitest run tests/int/matrix.int.spec.ts",
|
|
108
|
+
"test:container": "TEST_DB=container DB_MATRIX=mongo vitest run tests/int/matrix.int.spec.ts && TEST_DB=container DB_MATRIX=postgres vitest run tests/int/matrix.int.spec.ts",
|
|
109
|
+
"test:e2e": "bash scripts/e2e.sh",
|
|
110
|
+
"dev": "pnpm --filter @10x-media/dual-session-dev dev",
|
|
111
|
+
"start": "pnpm --filter @10x-media/dual-session-dev start",
|
|
112
|
+
"generate": "pnpm --filter @10x-media/dual-session-dev generate",
|
|
113
|
+
"generate:types": "pnpm --filter @10x-media/dual-session-dev generate:types",
|
|
114
|
+
"generate:importmap": "pnpm --filter @10x-media/dual-session-dev generate:importmap",
|
|
115
|
+
"migrate": "pnpm --filter @10x-media/dual-session-dev migrate",
|
|
116
|
+
"migrate:create": "pnpm --filter @10x-media/dual-session-dev migrate:create",
|
|
117
|
+
"migrate:down": "pnpm --filter @10x-media/dual-session-dev migrate:down",
|
|
118
|
+
"migrate:refresh": "pnpm --filter @10x-media/dual-session-dev migrate:refresh",
|
|
119
|
+
"migrate:reset": "pnpm --filter @10x-media/dual-session-dev migrate:reset",
|
|
120
|
+
"migrate:status": "pnpm --filter @10x-media/dual-session-dev migrate:status",
|
|
121
|
+
"migrate:fresh": "pnpm --filter @10x-media/dual-session-dev migrate:fresh",
|
|
122
|
+
"clean": "rm -rf dist *.tsbuildinfo dev/.next"
|
|
123
|
+
}
|
|
124
|
+
}
|