canopycms-next 0.0.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.
@@ -0,0 +1,50 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { NextResponse } from 'next/server';
3
+ import { type CanopyHandlerOptions, type CanopyRequest } from 'canopycms/http';
4
+ /**
5
+ * Options for creating a Canopy Next.js handler.
6
+ * Same as core CanopyHandlerOptions - re-exported for convenience.
7
+ */
8
+ export interface CanopyNextOptions extends CanopyHandlerOptions {
9
+ }
10
+ /**
11
+ * Wrap a Next.js request to implement the CanopyRequest interface.
12
+ */
13
+ export declare function wrapNextRequest(req: NextRequest): CanopyRequest;
14
+ /**
15
+ * Catch-all Next.js handler for a single API route (e.g., /api/canopycms/[...canopycms]).
16
+ *
17
+ * This is a thin adapter that:
18
+ * 1. Converts NextRequest to CanopyRequest
19
+ * 2. Extracts path segments from Next.js params
20
+ * 3. Delegates to the core handler
21
+ * 4. Converts CanopyResponse to NextResponse
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * // app/api/canopycms/[...canopycms]/route.ts
26
+ * import { createCanopyCatchAllHandler } from 'canopycms-next'
27
+ * import { createClerkAuthPlugin } from 'canopycms-auth-clerk'
28
+ * import config from '../../../../canopycms.config'
29
+ *
30
+ * const handler = createCanopyCatchAllHandler({
31
+ * config: config.server,
32
+ * authPlugin: createClerkAuthPlugin({ useOrganizationsAsGroups: true }),
33
+ * })
34
+ *
35
+ * export const GET = handler
36
+ * export const POST = handler
37
+ * export const PUT = handler
38
+ * export const DELETE = handler
39
+ * ```
40
+ */
41
+ export declare const createCanopyCatchAllHandler: (options: CanopyNextOptions) => (req: NextRequest, ctx?: {
42
+ params?: Promise<{
43
+ canopycms?: string[];
44
+ [key: string]: any;
45
+ }> | {
46
+ canopycms?: string[];
47
+ [key: string]: any;
48
+ };
49
+ }) => Promise<NextResponse<unknown>>;
50
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE1C,OAAO,EAEL,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAEnB,MAAM,gBAAgB,CAAA;AAEvB;;;GAGG;AACH,MAAM,WAAW,iBAAkB,SAAQ,oBAAoB;CAAG;AAElE;;GAEG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,WAAW,GAAG,aAAa,CAkB/D;AAwBD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,2BAA2B,GAAI,SAAS,iBAAiB,MAIlE,KAAK,WAAW,EAChB,MAAM;IACJ,MAAM,CAAC,EACH,OAAO,CAAC;QAAE,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAE,CAAC,GACrD;QAAE,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAE,CAAA;CACjD,mCAOJ,CAAA"}
@@ -0,0 +1,80 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { createCanopyRequestHandler, } from 'canopycms/http';
3
+ /**
4
+ * Wrap a Next.js request to implement the CanopyRequest interface.
5
+ */
6
+ export function wrapNextRequest(req) {
7
+ return {
8
+ method: req.method,
9
+ url: req.url,
10
+ header(name) {
11
+ return req.headers.get(name);
12
+ },
13
+ async json() {
14
+ if (req.method === 'GET')
15
+ return undefined;
16
+ try {
17
+ return await req.json();
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ },
23
+ };
24
+ }
25
+ /**
26
+ * Convert a CanopyResponse to a NextResponse.
27
+ */
28
+ function toNextResponse(response) {
29
+ return NextResponse.json(response.body, {
30
+ status: response.status,
31
+ headers: response.headers,
32
+ });
33
+ }
34
+ /**
35
+ * Extract path segments from Next.js catch-all route params.
36
+ * Handles both Next.js 14 (direct object) and Next.js 15 (Promise) params.
37
+ */
38
+ async function extractPathSegments(ctx) {
39
+ if (!ctx?.params)
40
+ return [];
41
+ const resolvedParams = ctx.params instanceof Promise ? await ctx.params : ctx.params;
42
+ return (resolvedParams?.canopycms ?? []).filter(Boolean);
43
+ }
44
+ /**
45
+ * Catch-all Next.js handler for a single API route (e.g., /api/canopycms/[...canopycms]).
46
+ *
47
+ * This is a thin adapter that:
48
+ * 1. Converts NextRequest to CanopyRequest
49
+ * 2. Extracts path segments from Next.js params
50
+ * 3. Delegates to the core handler
51
+ * 4. Converts CanopyResponse to NextResponse
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * // app/api/canopycms/[...canopycms]/route.ts
56
+ * import { createCanopyCatchAllHandler } from 'canopycms-next'
57
+ * import { createClerkAuthPlugin } from 'canopycms-auth-clerk'
58
+ * import config from '../../../../canopycms.config'
59
+ *
60
+ * const handler = createCanopyCatchAllHandler({
61
+ * config: config.server,
62
+ * authPlugin: createClerkAuthPlugin({ useOrganizationsAsGroups: true }),
63
+ * })
64
+ *
65
+ * export const GET = handler
66
+ * export const POST = handler
67
+ * export const PUT = handler
68
+ * export const DELETE = handler
69
+ * ```
70
+ */
71
+ export const createCanopyCatchAllHandler = (options) => {
72
+ const coreHandler = createCanopyRequestHandler(options);
73
+ return async (req, ctx) => {
74
+ const canopyReq = wrapNextRequest(req);
75
+ const segments = await extractPathSegments(ctx);
76
+ const response = await coreHandler(canopyReq, segments);
77
+ return toNextResponse(response);
78
+ };
79
+ };
80
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE1C,OAAO,EACL,0BAA0B,GAI3B,MAAM,gBAAgB,CAAA;AAQvB;;GAEG;AACH,MAAM,UAAU,eAAe,CAAC,GAAgB;IAC9C,OAAO;QACL,MAAM,EAAE,GAAG,CAAC,MAAM;QAClB,GAAG,EAAE,GAAG,CAAC,GAAG;QAEZ,MAAM,CAAC,IAAY;YACjB,OAAO,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QAC9B,CAAC;QAED,KAAK,CAAC,IAAI;YACR,IAAI,GAAG,CAAC,MAAM,KAAK,KAAK;gBAAE,OAAO,SAAS,CAAA;YAC1C,IAAI,CAAC;gBACH,OAAO,MAAM,GAAG,CAAC,IAAI,EAAE,CAAA;YACzB,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,SAAS,CAAA;YAClB,CAAC;QACH,CAAC;KACF,CAAA;AACH,CAAC;AAED;;GAEG;AACH,SAAS,cAAc,CAAC,QAAiC;IACvD,OAAO,YAAY,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE;QACtC,MAAM,EAAE,QAAQ,CAAC,MAAM;QACvB,OAAO,EAAE,QAAQ,CAAC,OAAO;KAC1B,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,KAAK,UAAU,mBAAmB,CAAC,GAElC;IACC,IAAI,CAAC,GAAG,EAAE,MAAM;QAAE,OAAO,EAAE,CAAA;IAC3B,MAAM,cAAc,GAAG,GAAG,CAAC,MAAM,YAAY,OAAO,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAA;IACpF,OAAO,CAAC,cAAc,EAAE,SAAS,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;AAC1D,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,OAA0B,EAAE,EAAE;IACxE,MAAM,WAAW,GAAG,0BAA0B,CAAC,OAAO,CAAC,CAAA;IAEvD,OAAO,KAAK,EACV,GAAgB,EAChB,GAIC,EACD,EAAE;QACF,MAAM,SAAS,GAAG,eAAe,CAAC,GAAG,CAAC,CAAA;QACtC,MAAM,QAAQ,GAAG,MAAM,mBAAmB,CAAC,GAAG,CAAC,CAAA;QAC/C,MAAM,QAAQ,GAAG,MAAM,WAAW,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAA;QACvD,OAAO,cAAc,CAAC,QAAQ,CAAC,CAAA;IACjC,CAAC,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,21 @@
1
+ import type { CanopyClientConfig } from 'canopycms/client';
2
+ /**
3
+ * Next.js-specific wrapper for CanopyEditorPage that automatically reads
4
+ * URL search params (branch, entry) using Next.js's useSearchParams hook.
5
+ *
6
+ * @example
7
+ * ```tsx
8
+ * // app/edit/page.tsx
9
+ * 'use client'
10
+ * import { NextCanopyEditorPage } from 'canopycms-next/client'
11
+ * import config from '../../canopycms.config'
12
+ *
13
+ * export default function EditPage() {
14
+ * const clientConfig = config.client()
15
+ * const EditorPage = NextCanopyEditorPage(clientConfig)
16
+ * return <EditorPage />
17
+ * }
18
+ * ```
19
+ */
20
+ export declare const NextCanopyEditorPage: (config: CanopyClientConfig) => () => import("react/jsx-runtime").JSX.Element;
21
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AAE1D;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,kBAAkB,kDAW9D,CAAA"}
package/dist/client.js ADDED
@@ -0,0 +1,34 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useSearchParams } from 'next/navigation';
4
+ import { CanopyEditorPage } from 'canopycms/client';
5
+ /**
6
+ * Next.js-specific wrapper for CanopyEditorPage that automatically reads
7
+ * URL search params (branch, entry) using Next.js's useSearchParams hook.
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * // app/edit/page.tsx
12
+ * 'use client'
13
+ * import { NextCanopyEditorPage } from 'canopycms-next/client'
14
+ * import config from '../../canopycms.config'
15
+ *
16
+ * export default function EditPage() {
17
+ * const clientConfig = config.client()
18
+ * const EditorPage = NextCanopyEditorPage(clientConfig)
19
+ * return <EditorPage />
20
+ * }
21
+ * ```
22
+ */
23
+ export const NextCanopyEditorPage = (config) => {
24
+ const CorePage = CanopyEditorPage(config);
25
+ return function NextEditorPage() {
26
+ const urlSearchParams = useSearchParams();
27
+ const searchParams = {
28
+ branch: urlSearchParams.get('branch') ?? undefined,
29
+ entry: urlSearchParams.get('entry') ?? undefined,
30
+ };
31
+ return _jsx(CorePage, { searchParams: searchParams });
32
+ };
33
+ };
34
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAA;AAGnD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,MAA0B,EAAE,EAAE;IACjE,MAAM,QAAQ,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAA;IAEzC,OAAO,SAAS,cAAc;QAC5B,MAAM,eAAe,GAAG,eAAe,EAAE,CAAA;QACzC,MAAM,YAAY,GAAG;YACnB,MAAM,EAAE,eAAe,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,SAAS;YAClD,KAAK,EAAE,eAAe,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,SAAS;SACjD,CAAA;QACD,OAAO,KAAC,QAAQ,IAAC,YAAY,EAAE,YAAY,GAAI,CAAA;IACjD,CAAC,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,26 @@
1
+ import { type CanopyContext } from 'canopycms/server';
2
+ import type { CanopyConfig, AuthPlugin, FieldConfig } from 'canopycms';
3
+ export interface NextCanopyOptions {
4
+ config: CanopyConfig;
5
+ authPlugin: AuthPlugin;
6
+ entrySchemaRegistry: Record<string, readonly FieldConfig[]>;
7
+ }
8
+ /**
9
+ * Create Next.js-specific wrapper around core context.
10
+ * Adds React cache() for per-request memoization and API handler.
11
+ * This function is async because it needs to load .collection.json meta files.
12
+ */
13
+ export declare function createNextCanopyContext(options: NextCanopyOptions): Promise<{
14
+ getCanopy: () => Promise<CanopyContext>;
15
+ handler: (req: import("next/server").NextRequest, ctx?: {
16
+ params?: Promise<{
17
+ canopycms?: string[];
18
+ [key: string]: any;
19
+ }> | {
20
+ canopycms?: string[];
21
+ [key: string]: any;
22
+ };
23
+ }) => Promise<import("next/server").NextResponse<unknown>>;
24
+ services: import("canopycms/server").CanopyServices;
25
+ }>;
26
+ //# sourceMappingURL=context-wrapper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-wrapper.d.ts","sourceRoot":"","sources":["../src/context-wrapper.ts"],"names":[],"mappings":"AAEA,OAAO,EAAuB,KAAK,aAAa,EAAwB,MAAM,kBAAkB,CAAA;AAChG,OAAO,KAAK,EAAE,YAAY,EAAE,UAAU,EAAc,WAAW,EAAE,MAAM,WAAW,CAAA;AAQlF,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,YAAY,CAAA;IACpB,UAAU,EAAE,UAAU,CAAA;IACtB,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC,CAAA;CAC5D;AAED;;;;GAIG;AACH,wBAAsB,uBAAuB,CAAC,OAAO,EAAE,iBAAiB;qBAiD1C,OAAO,CAAC,aAAa,CAAC;;cAJhD,CAAA;qBAGQ,CAAC;;;qBACE,CAAC;;;;;GAef"}
@@ -0,0 +1,64 @@
1
+ import { cache } from 'react';
2
+ import { headers } from 'next/headers';
3
+ import { createCanopyContext, createCanopyServices } from 'canopycms/server';
4
+ import { authResultToCanopyUser } from 'canopycms';
5
+ import { loadInternalGroups, loadBranchContext } from 'canopycms/server';
6
+ import { createCanopyCatchAllHandler } from './adapter';
7
+ let warnedNoAdmins = false;
8
+ /**
9
+ * Create Next.js-specific wrapper around core context.
10
+ * Adds React cache() for per-request memoization and API handler.
11
+ * This function is async because it needs to load .collection.json meta files.
12
+ */
13
+ export async function createNextCanopyContext(options) {
14
+ // Create services ONCE at initialization
15
+ const services = await createCanopyServices(options.config, {
16
+ entrySchemaRegistry: options.entrySchemaRegistry,
17
+ });
18
+ // User extractor: passes Next.js headers to auth plugin, loads internal groups, applies authorization
19
+ const extractUser = async () => {
20
+ const headersList = await headers();
21
+ const authResult = await options.authPlugin.authenticate(headersList);
22
+ // Load internal groups from main branch
23
+ const baseBranch = services.config.defaultBaseBranch ?? 'main';
24
+ const operatingMode = services.config.mode ?? 'dev';
25
+ const mainBranchContext = await loadBranchContext({
26
+ branchName: baseBranch,
27
+ mode: operatingMode,
28
+ });
29
+ const internalGroups = mainBranchContext
30
+ ? await loadInternalGroups(mainBranchContext.branchRoot, operatingMode, services.bootstrapAdminIds).catch((err) => {
31
+ console.warn('CanopyCMS: Failed to load internal groups from main branch:', err);
32
+ return [];
33
+ })
34
+ : [];
35
+ if (!warnedNoAdmins && Array.isArray(internalGroups)) {
36
+ const adminsGroup = internalGroups.find((g) => g.id === 'Admins');
37
+ if (!adminsGroup || adminsGroup.members.length === 0) {
38
+ console.warn('CanopyCMS: No admin users configured. Set CANOPY_BOOTSTRAP_ADMIN_IDS or add members to the Admins group.');
39
+ }
40
+ warnedNoAdmins = true;
41
+ }
42
+ return authResultToCanopyUser(authResult, services.bootstrapAdminIds, internalGroups);
43
+ };
44
+ // Create core context with pre-created services (framework-agnostic)
45
+ const coreContext = createCanopyContext({
46
+ services,
47
+ extractUser,
48
+ });
49
+ // Wrap with React cache() for per-request caching
50
+ const getCanopy = cache(() => {
51
+ return coreContext.getContext();
52
+ });
53
+ // Create API handler using same services
54
+ const handler = createCanopyCatchAllHandler({
55
+ ...options,
56
+ services,
57
+ });
58
+ return {
59
+ getCanopy,
60
+ handler,
61
+ services,
62
+ };
63
+ }
64
+ //# sourceMappingURL=context-wrapper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-wrapper.js","sourceRoot":"","sources":["../src/context-wrapper.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,OAAO,CAAA;AAC7B,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,OAAO,EAAE,mBAAmB,EAAsB,oBAAoB,EAAE,MAAM,kBAAkB,CAAA;AAEhG,OAAO,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAA;AAClD,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AAExE,OAAO,EAAE,2BAA2B,EAAE,MAAM,WAAW,CAAA;AAEvD,IAAI,cAAc,GAAG,KAAK,CAAA;AAQ1B;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAAC,OAA0B;IACtE,yCAAyC;IACzC,MAAM,QAAQ,GAAG,MAAM,oBAAoB,CAAC,OAAO,CAAC,MAAM,EAAE;QAC1D,mBAAmB,EAAE,OAAO,CAAC,mBAAmB;KACjD,CAAC,CAAA;IAEF,sGAAsG;IACtG,MAAM,WAAW,GAAG,KAAK,IAAyB,EAAE;QAClD,MAAM,WAAW,GAAG,MAAM,OAAO,EAAE,CAAA;QACnC,MAAM,UAAU,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC,YAAY,CAAC,WAAW,CAAC,CAAA;QAErE,wCAAwC;QACxC,MAAM,UAAU,GAAG,QAAQ,CAAC,MAAM,CAAC,iBAAiB,IAAI,MAAM,CAAA;QAC9D,MAAM,aAAa,GAAG,QAAQ,CAAC,MAAM,CAAC,IAAI,IAAI,KAAK,CAAA;QACnD,MAAM,iBAAiB,GAAG,MAAM,iBAAiB,CAAC;YAChD,UAAU,EAAE,UAAU;YACtB,IAAI,EAAE,aAAa;SACpB,CAAC,CAAA;QACF,MAAM,cAAc,GAAoB,iBAAiB;YACvD,CAAC,CAAC,MAAM,kBAAkB,CACtB,iBAAiB,CAAC,UAAU,EAC5B,aAAa,EACb,QAAQ,CAAC,iBAAiB,CAC3B,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;gBACvB,OAAO,CAAC,IAAI,CAAC,6DAA6D,EAAE,GAAG,CAAC,CAAA;gBAChF,OAAO,EAAqB,CAAA;YAC9B,CAAC,CAAC;YACJ,CAAC,CAAC,EAAE,CAAA;QAEN,IAAI,CAAC,cAAc,IAAI,KAAK,CAAC,OAAO,CAAC,cAAc,CAAC,EAAE,CAAC;YACrD,MAAM,WAAW,GAAG,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,QAAQ,CAAC,CAAA;YACjE,IAAI,CAAC,WAAW,IAAI,WAAW,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBACrD,OAAO,CAAC,IAAI,CACV,0GAA0G,CAC3G,CAAA;YACH,CAAC;YACD,cAAc,GAAG,IAAI,CAAA;QACvB,CAAC;QAED,OAAO,sBAAsB,CAAC,UAAU,EAAE,QAAQ,CAAC,iBAAiB,EAAE,cAAc,CAAC,CAAA;IACvF,CAAC,CAAA;IAED,qEAAqE;IACrE,MAAM,WAAW,GAAG,mBAAmB,CAAC;QACtC,QAAQ;QACR,WAAW;KACZ,CAAC,CAAA;IAEF,kDAAkD;IAClD,MAAM,SAAS,GAAG,KAAK,CAAC,GAA2B,EAAE;QACnD,OAAO,WAAW,CAAC,UAAU,EAAE,CAAA;IACjC,CAAC,CAAC,CAAA;IAEF,yCAAyC;IACzC,MAAM,OAAO,GAAG,2BAA2B,CAAC;QAC1C,GAAG,OAAO;QACV,QAAQ;KACT,CAAC,CAAA;IAEF,OAAO;QACL,SAAS;QACT,OAAO;QACP,QAAQ;KACT,CAAA;AACH,CAAC"}
@@ -0,0 +1,4 @@
1
+ export { createCanopyCatchAllHandler, wrapNextRequest, type CanopyNextOptions } from './adapter';
2
+ export { createNextCanopyContext, type NextCanopyOptions } from './context-wrapper';
3
+ export { createMockAuthPlugin, createRejectingAuthPlugin } from './test-utils';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,2BAA2B,EAAE,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAEhG,OAAO,EAAE,uBAAuB,EAAE,KAAK,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAEnF,OAAO,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,MAAM,cAAc,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { createCanopyCatchAllHandler, wrapNextRequest } from './adapter';
2
+ export { createNextCanopyContext } from './context-wrapper';
3
+ export { createMockAuthPlugin, createRejectingAuthPlugin } from './test-utils';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,2BAA2B,EAAE,eAAe,EAA0B,MAAM,WAAW,CAAA;AAEhG,OAAO,EAAE,uBAAuB,EAA0B,MAAM,mBAAmB,CAAA;AAEnF,OAAO,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,MAAM,cAAc,CAAA"}
@@ -0,0 +1,12 @@
1
+ import type { AuthPlugin } from 'canopycms/auth';
2
+ import type { AuthenticatedUser } from 'canopycms';
3
+ /**
4
+ * Create a mock AuthPlugin for testing.
5
+ * Returns a valid user by default (as Admin), or can be configured to return specific users.
6
+ */
7
+ export declare const createMockAuthPlugin: (user?: AuthenticatedUser) => AuthPlugin;
8
+ /**
9
+ * Create a mock AuthPlugin that rejects all authentication.
10
+ */
11
+ export declare const createRejectingAuthPlugin: (error?: string) => AuthPlugin;
12
+ //# sourceMappingURL=test-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-utils.d.ts","sourceRoot":"","sources":["../src/test-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAChD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAIlD;;;GAGG;AACH,eAAO,MAAM,oBAAoB,GAC/B,OAAM,iBAIL,KACA,UAeD,CAAA;AAEF;;GAEG;AACH,eAAO,MAAM,yBAAyB,GAAI,cAAsB,KAAG,UAMjE,CAAA"}
@@ -0,0 +1,36 @@
1
+ const ADMINS = 'Admins';
2
+ /**
3
+ * Create a mock AuthPlugin for testing.
4
+ * Returns a valid user by default (as Admin), or can be configured to return specific users.
5
+ */
6
+ export const createMockAuthPlugin = (user = {
7
+ type: 'authenticated',
8
+ userId: 'test-user',
9
+ groups: [ADMINS],
10
+ }) => ({
11
+ authenticate: async () => ({
12
+ success: true,
13
+ user: {
14
+ userId: user.userId,
15
+ email: user.email,
16
+ name: user.name,
17
+ avatarUrl: user.avatarUrl,
18
+ externalGroups: user.groups,
19
+ },
20
+ }),
21
+ searchUsers: async () => [],
22
+ getUserMetadata: async () => null,
23
+ getGroupMetadata: async () => null,
24
+ listGroups: async () => [],
25
+ });
26
+ /**
27
+ * Create a mock AuthPlugin that rejects all authentication.
28
+ */
29
+ export const createRejectingAuthPlugin = (error = 'Unauthorized') => ({
30
+ authenticate: async () => ({ success: false, error }),
31
+ searchUsers: async () => [],
32
+ getUserMetadata: async () => null,
33
+ getGroupMetadata: async () => null,
34
+ listGroups: async () => [],
35
+ });
36
+ //# sourceMappingURL=test-utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-utils.js","sourceRoot":"","sources":["../src/test-utils.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,GAAG,QAAQ,CAAA;AAEvB;;;GAGG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAClC,OAA0B;IACxB,IAAI,EAAE,eAAe;IACrB,MAAM,EAAE,WAAW;IACnB,MAAM,EAAE,CAAC,MAAM,CAAC;CACjB,EACW,EAAE,CAAC,CAAC;IAChB,YAAY,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC;QACzB,OAAO,EAAE,IAAI;QACb,IAAI,EAAE;YACJ,MAAM,EAAE,IAAI,CAAC,MAAM;YACnB,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,SAAS,EAAE,IAAI,CAAC,SAAS;YACzB,cAAc,EAAE,IAAI,CAAC,MAAM;SAC5B;KACF,CAAC;IACF,WAAW,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE;IAC3B,eAAe,EAAE,KAAK,IAAI,EAAE,CAAC,IAAI;IACjC,gBAAgB,EAAE,KAAK,IAAI,EAAE,CAAC,IAAI;IAClC,UAAU,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE;CAC3B,CAAC,CAAA;AAEF;;GAEG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,KAAK,GAAG,cAAc,EAAc,EAAE,CAAC,CAAC;IAChF,YAAY,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACrD,WAAW,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE;IAC3B,eAAe,EAAE,KAAK,IAAI,EAAE,CAAC,IAAI;IACjC,gBAAgB,EAAE,KAAK,IAAI,EAAE,CAAC,IAAI;IAClC,UAAU,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE;CAC3B,CAAC,CAAA"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "canopycms-next",
3
+ "version": "0.0.0",
4
+ "description": "Next.js adapter for CanopyCMS",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/safeinsights/canopycms.git",
9
+ "directory": "packages/canopycms-next"
10
+ },
11
+ "private": false,
12
+ "type": "module",
13
+ "main": "dist/index.js",
14
+ "types": "dist/index.d.ts",
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./client": "./src/client.tsx"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src"
22
+ ],
23
+ "publishConfig": {
24
+ "exports": {
25
+ ".": {
26
+ "import": "./dist/index.js",
27
+ "types": "./dist/index.d.ts"
28
+ },
29
+ "./client": {
30
+ "import": "./dist/client.js",
31
+ "types": "./dist/client.d.ts"
32
+ }
33
+ }
34
+ },
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.build.json",
37
+ "test": "vitest run --reporter=dot",
38
+ "typecheck": "tsc --noEmit"
39
+ },
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "peerDependencies": {
44
+ "canopycms": "*",
45
+ "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
46
+ "react": "^18.0.0 || ^19.0.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^22.9.0",
50
+ "typescript": "^5.6.3",
51
+ "vitest": "^1.6.0"
52
+ }
53
+ }
@@ -0,0 +1,194 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ // Mock next/server before any imports
4
+ vi.mock('next/server', () => {
5
+ return {
6
+ NextResponse: {
7
+ json: (body: any, init?: any) => ({
8
+ body,
9
+ status: init?.status ?? 200,
10
+ headers: init?.headers,
11
+ }),
12
+ },
13
+ }
14
+ })
15
+
16
+ // Mock canopycms/http to return a controlled response
17
+ vi.mock('canopycms/http', async () => {
18
+ return {
19
+ createCanopyRequestHandler: vi.fn(() => {
20
+ return async (req: any, segments: string[]) => {
21
+ // Return different responses based on segments
22
+ if (segments.length === 1 && segments[0] === 'branches') {
23
+ return {
24
+ status: 200,
25
+ body: { ok: true, status: 200, data: { branches: [] } },
26
+ }
27
+ }
28
+ if (segments.length === 0 || segments.includes('unknown')) {
29
+ return {
30
+ status: 404,
31
+ body: { ok: false, status: 404, error: 'Not found' },
32
+ }
33
+ }
34
+ return {
35
+ status: 200,
36
+ body: { ok: true, status: 200 },
37
+ }
38
+ }
39
+ }),
40
+ }
41
+ })
42
+
43
+ import { createCanopyCatchAllHandler } from './adapter'
44
+ import { createMockAuthPlugin } from './test-utils'
45
+
46
+ describe('Next.js adapter', () => {
47
+ const mockAuthPlugin = createMockAuthPlugin({
48
+ userId: 'test-user',
49
+ groups: ['Admins'],
50
+ })
51
+
52
+ describe('createCanopyCatchAllHandler', () => {
53
+ it('converts NextRequest to CanopyRequest and returns NextResponse', async () => {
54
+ const handler = createCanopyCatchAllHandler({
55
+ services: {} as any,
56
+ authPlugin: mockAuthPlugin,
57
+ })
58
+
59
+ const mockNextRequest = {
60
+ method: 'GET',
61
+ url: 'http://localhost:3000/api/canopycms/branches',
62
+ headers: { get: () => null },
63
+ json: async () => undefined,
64
+ } as any
65
+
66
+ const response: any = await handler(mockNextRequest, {
67
+ params: { canopycms: ['branches'] },
68
+ })
69
+
70
+ expect(response.status).toBe(200)
71
+ expect(response.body).toHaveProperty('ok', true)
72
+ })
73
+
74
+ it('handles Next.js 14 direct params object', async () => {
75
+ const handler = createCanopyCatchAllHandler({
76
+ services: {} as any,
77
+ authPlugin: mockAuthPlugin,
78
+ })
79
+
80
+ const mockNextRequest = {
81
+ method: 'GET',
82
+ url: 'http://localhost:3000/api/canopycms/branches',
83
+ headers: { get: () => null },
84
+ json: async () => undefined,
85
+ } as any
86
+
87
+ // Next.js 14 style - params is a direct object
88
+ const response: any = await handler(mockNextRequest, {
89
+ params: { canopycms: ['branches'] },
90
+ })
91
+
92
+ expect(response.status).toBe(200)
93
+ })
94
+
95
+ it('handles Next.js 15 async params Promise', async () => {
96
+ const handler = createCanopyCatchAllHandler({
97
+ services: {} as any,
98
+ authPlugin: mockAuthPlugin,
99
+ })
100
+
101
+ const mockNextRequest = {
102
+ method: 'GET',
103
+ url: 'http://localhost:3000/api/canopycms/branches',
104
+ headers: { get: () => null },
105
+ json: async () => undefined,
106
+ } as any
107
+
108
+ // Next.js 15 style - params is a Promise
109
+ const response: any = await handler(mockNextRequest, {
110
+ params: Promise.resolve({ canopycms: ['branches'] }),
111
+ })
112
+
113
+ expect(response.status).toBe(200)
114
+ })
115
+
116
+ it('handles missing params gracefully', async () => {
117
+ const handler = createCanopyCatchAllHandler({
118
+ services: {} as any,
119
+ authPlugin: mockAuthPlugin,
120
+ })
121
+
122
+ const mockNextRequest = {
123
+ method: 'GET',
124
+ url: 'http://localhost:3000/api/canopycms',
125
+ headers: { get: () => null },
126
+ json: async () => undefined,
127
+ } as any
128
+
129
+ // No params at all
130
+ const response: any = await handler(mockNextRequest, undefined)
131
+
132
+ expect(response.status).toBe(404)
133
+ })
134
+ })
135
+ })
136
+
137
+ describe('wrapNextRequest', () => {
138
+ it('wraps NextRequest correctly', async () => {
139
+ const { wrapNextRequest } = await import('./adapter')
140
+
141
+ const mockReq = {
142
+ method: 'POST',
143
+ url: 'http://localhost:3000/api/canopycms/branches',
144
+ headers: {
145
+ get: (name: string) =>
146
+ name.toLowerCase() === 'authorization' ? 'Bearer test-token' : null,
147
+ },
148
+ json: async () => ({ name: 'test-branch' }),
149
+ } as any
150
+
151
+ const wrapped = wrapNextRequest(mockReq)
152
+
153
+ expect(wrapped.method).toBe('POST')
154
+ expect(wrapped.url).toBe('http://localhost:3000/api/canopycms/branches')
155
+ expect(wrapped.header('Authorization')).toBe('Bearer test-token')
156
+ expect(await wrapped.json()).toEqual({ name: 'test-branch' })
157
+ })
158
+
159
+ it('returns null for missing headers', async () => {
160
+ const { wrapNextRequest } = await import('./adapter')
161
+
162
+ const mockReq = {
163
+ method: 'GET',
164
+ url: 'http://localhost:3000/api/test',
165
+ headers: {
166
+ get: () => null,
167
+ },
168
+ json: async () => undefined,
169
+ } as any
170
+
171
+ const wrapped = wrapNextRequest(mockReq)
172
+
173
+ expect(wrapped.header('X-Custom-Header')).toBeNull()
174
+ })
175
+
176
+ it('returns undefined for GET request body', async () => {
177
+ const { wrapNextRequest } = await import('./adapter')
178
+
179
+ const mockReq = {
180
+ method: 'GET',
181
+ url: 'http://localhost:3000/api/test',
182
+ headers: {
183
+ get: () => null,
184
+ },
185
+ json: async () => {
186
+ throw new Error('No body')
187
+ },
188
+ } as any
189
+
190
+ const wrapped = wrapNextRequest(mockReq)
191
+
192
+ expect(await wrapped.json()).toBeUndefined()
193
+ })
194
+ })
package/src/adapter.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { NextResponse } from 'next/server'
3
+
4
+ import {
5
+ createCanopyRequestHandler,
6
+ type CanopyHandlerOptions,
7
+ type CanopyRequest,
8
+ type CanopyResponse,
9
+ } from 'canopycms/http'
10
+
11
+ /**
12
+ * Options for creating a Canopy Next.js handler.
13
+ * Same as core CanopyHandlerOptions - re-exported for convenience.
14
+ */
15
+ export interface CanopyNextOptions extends CanopyHandlerOptions {}
16
+
17
+ /**
18
+ * Wrap a Next.js request to implement the CanopyRequest interface.
19
+ */
20
+ export function wrapNextRequest(req: NextRequest): CanopyRequest {
21
+ return {
22
+ method: req.method,
23
+ url: req.url,
24
+
25
+ header(name: string): string | null {
26
+ return req.headers.get(name)
27
+ },
28
+
29
+ async json(): Promise<unknown> {
30
+ if (req.method === 'GET') return undefined
31
+ try {
32
+ return await req.json()
33
+ } catch {
34
+ return undefined
35
+ }
36
+ },
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Convert a CanopyResponse to a NextResponse.
42
+ */
43
+ function toNextResponse(response: CanopyResponse<unknown>): ReturnType<typeof NextResponse.json> {
44
+ return NextResponse.json(response.body, {
45
+ status: response.status,
46
+ headers: response.headers,
47
+ })
48
+ }
49
+
50
+ /**
51
+ * Extract path segments from Next.js catch-all route params.
52
+ * Handles both Next.js 14 (direct object) and Next.js 15 (Promise) params.
53
+ */
54
+ async function extractPathSegments(ctx?: {
55
+ params?: Promise<{ canopycms?: string[] }> | { canopycms?: string[] }
56
+ }): Promise<string[]> {
57
+ if (!ctx?.params) return []
58
+ const resolvedParams = ctx.params instanceof Promise ? await ctx.params : ctx.params
59
+ return (resolvedParams?.canopycms ?? []).filter(Boolean)
60
+ }
61
+
62
+ /**
63
+ * Catch-all Next.js handler for a single API route (e.g., /api/canopycms/[...canopycms]).
64
+ *
65
+ * This is a thin adapter that:
66
+ * 1. Converts NextRequest to CanopyRequest
67
+ * 2. Extracts path segments from Next.js params
68
+ * 3. Delegates to the core handler
69
+ * 4. Converts CanopyResponse to NextResponse
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * // app/api/canopycms/[...canopycms]/route.ts
74
+ * import { createCanopyCatchAllHandler } from 'canopycms-next'
75
+ * import { createClerkAuthPlugin } from 'canopycms-auth-clerk'
76
+ * import config from '../../../../canopycms.config'
77
+ *
78
+ * const handler = createCanopyCatchAllHandler({
79
+ * config: config.server,
80
+ * authPlugin: createClerkAuthPlugin({ useOrganizationsAsGroups: true }),
81
+ * })
82
+ *
83
+ * export const GET = handler
84
+ * export const POST = handler
85
+ * export const PUT = handler
86
+ * export const DELETE = handler
87
+ * ```
88
+ */
89
+ export const createCanopyCatchAllHandler = (options: CanopyNextOptions) => {
90
+ const coreHandler = createCanopyRequestHandler(options)
91
+
92
+ return async (
93
+ req: NextRequest,
94
+ ctx?: {
95
+ params?:
96
+ | Promise<{ canopycms?: string[]; [key: string]: any }>
97
+ | { canopycms?: string[]; [key: string]: any }
98
+ },
99
+ ) => {
100
+ const canopyReq = wrapNextRequest(req)
101
+ const segments = await extractPathSegments(ctx)
102
+ const response = await coreHandler(canopyReq, segments)
103
+ return toNextResponse(response)
104
+ }
105
+ }
package/src/client.tsx ADDED
@@ -0,0 +1,36 @@
1
+ 'use client'
2
+
3
+ import { useSearchParams } from 'next/navigation'
4
+ import { CanopyEditorPage } from 'canopycms/client'
5
+ import type { CanopyClientConfig } from 'canopycms/client'
6
+
7
+ /**
8
+ * Next.js-specific wrapper for CanopyEditorPage that automatically reads
9
+ * URL search params (branch, entry) using Next.js's useSearchParams hook.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * // app/edit/page.tsx
14
+ * 'use client'
15
+ * import { NextCanopyEditorPage } from 'canopycms-next/client'
16
+ * import config from '../../canopycms.config'
17
+ *
18
+ * export default function EditPage() {
19
+ * const clientConfig = config.client()
20
+ * const EditorPage = NextCanopyEditorPage(clientConfig)
21
+ * return <EditorPage />
22
+ * }
23
+ * ```
24
+ */
25
+ export const NextCanopyEditorPage = (config: CanopyClientConfig) => {
26
+ const CorePage = CanopyEditorPage(config)
27
+
28
+ return function NextEditorPage() {
29
+ const urlSearchParams = useSearchParams()
30
+ const searchParams = {
31
+ branch: urlSearchParams.get('branch') ?? undefined,
32
+ entry: urlSearchParams.get('entry') ?? undefined,
33
+ }
34
+ return <CorePage searchParams={searchParams} />
35
+ }
36
+ }
@@ -0,0 +1,87 @@
1
+ import { cache } from 'react'
2
+ import { headers } from 'next/headers'
3
+ import { createCanopyContext, type CanopyContext, createCanopyServices } from 'canopycms/server'
4
+ import type { CanopyConfig, AuthPlugin, CanopyUser, FieldConfig } from 'canopycms'
5
+ import { authResultToCanopyUser } from 'canopycms'
6
+ import { loadInternalGroups, loadBranchContext } from 'canopycms/server'
7
+ import type { InternalGroup } from 'canopycms/server'
8
+ import { createCanopyCatchAllHandler } from './adapter'
9
+
10
+ let warnedNoAdmins = false
11
+
12
+ export interface NextCanopyOptions {
13
+ config: CanopyConfig
14
+ authPlugin: AuthPlugin
15
+ entrySchemaRegistry: Record<string, readonly FieldConfig[]>
16
+ }
17
+
18
+ /**
19
+ * Create Next.js-specific wrapper around core context.
20
+ * Adds React cache() for per-request memoization and API handler.
21
+ * This function is async because it needs to load .collection.json meta files.
22
+ */
23
+ export async function createNextCanopyContext(options: NextCanopyOptions) {
24
+ // Create services ONCE at initialization
25
+ const services = await createCanopyServices(options.config, {
26
+ entrySchemaRegistry: options.entrySchemaRegistry,
27
+ })
28
+
29
+ // User extractor: passes Next.js headers to auth plugin, loads internal groups, applies authorization
30
+ const extractUser = async (): Promise<CanopyUser> => {
31
+ const headersList = await headers()
32
+ const authResult = await options.authPlugin.authenticate(headersList)
33
+
34
+ // Load internal groups from main branch
35
+ const baseBranch = services.config.defaultBaseBranch ?? 'main'
36
+ const operatingMode = services.config.mode ?? 'dev'
37
+ const mainBranchContext = await loadBranchContext({
38
+ branchName: baseBranch,
39
+ mode: operatingMode,
40
+ })
41
+ const internalGroups: InternalGroup[] = mainBranchContext
42
+ ? await loadInternalGroups(
43
+ mainBranchContext.branchRoot,
44
+ operatingMode,
45
+ services.bootstrapAdminIds,
46
+ ).catch((err: unknown) => {
47
+ console.warn('CanopyCMS: Failed to load internal groups from main branch:', err)
48
+ return [] as InternalGroup[]
49
+ })
50
+ : []
51
+
52
+ if (!warnedNoAdmins && Array.isArray(internalGroups)) {
53
+ const adminsGroup = internalGroups.find((g) => g.id === 'Admins')
54
+ if (!adminsGroup || adminsGroup.members.length === 0) {
55
+ console.warn(
56
+ 'CanopyCMS: No admin users configured. Set CANOPY_BOOTSTRAP_ADMIN_IDS or add members to the Admins group.',
57
+ )
58
+ }
59
+ warnedNoAdmins = true
60
+ }
61
+
62
+ return authResultToCanopyUser(authResult, services.bootstrapAdminIds, internalGroups)
63
+ }
64
+
65
+ // Create core context with pre-created services (framework-agnostic)
66
+ const coreContext = createCanopyContext({
67
+ services,
68
+ extractUser,
69
+ })
70
+
71
+ // Wrap with React cache() for per-request caching
72
+ const getCanopy = cache((): Promise<CanopyContext> => {
73
+ return coreContext.getContext()
74
+ })
75
+
76
+ // Create API handler using same services
77
+ const handler = createCanopyCatchAllHandler({
78
+ ...options,
79
+ services,
80
+ })
81
+
82
+ return {
83
+ getCanopy,
84
+ handler,
85
+ services,
86
+ }
87
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { createCanopyCatchAllHandler, wrapNextRequest, type CanopyNextOptions } from './adapter'
2
+
3
+ export { createNextCanopyContext, type NextCanopyOptions } from './context-wrapper'
4
+
5
+ export { createMockAuthPlugin, createRejectingAuthPlugin } from './test-utils'
@@ -0,0 +1,42 @@
1
+ import type { AuthPlugin } from 'canopycms/auth'
2
+ import type { AuthenticatedUser } from 'canopycms'
3
+
4
+ const ADMINS = 'Admins'
5
+
6
+ /**
7
+ * Create a mock AuthPlugin for testing.
8
+ * Returns a valid user by default (as Admin), or can be configured to return specific users.
9
+ */
10
+ export const createMockAuthPlugin = (
11
+ user: AuthenticatedUser = {
12
+ type: 'authenticated',
13
+ userId: 'test-user',
14
+ groups: [ADMINS],
15
+ },
16
+ ): AuthPlugin => ({
17
+ authenticate: async () => ({
18
+ success: true,
19
+ user: {
20
+ userId: user.userId,
21
+ email: user.email,
22
+ name: user.name,
23
+ avatarUrl: user.avatarUrl,
24
+ externalGroups: user.groups,
25
+ },
26
+ }),
27
+ searchUsers: async () => [],
28
+ getUserMetadata: async () => null,
29
+ getGroupMetadata: async () => null,
30
+ listGroups: async () => [],
31
+ })
32
+
33
+ /**
34
+ * Create a mock AuthPlugin that rejects all authentication.
35
+ */
36
+ export const createRejectingAuthPlugin = (error = 'Unauthorized'): AuthPlugin => ({
37
+ authenticate: async () => ({ success: false, error }),
38
+ searchUsers: async () => [],
39
+ getUserMetadata: async () => null,
40
+ getGroupMetadata: async () => null,
41
+ listGroups: async () => [],
42
+ })