canopycms-next 0.0.0 → 0.0.2

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.
@@ -2,13 +2,18 @@ import { type CanopyContext } from 'canopycms/server';
2
2
  import type { CanopyConfig, AuthPlugin, FieldConfig } from 'canopycms';
3
3
  export interface NextCanopyOptions {
4
4
  config: CanopyConfig;
5
- authPlugin: AuthPlugin;
5
+ /** Auth plugin for user authentication. Optional for static deployments (deployedAs: 'static'). */
6
+ authPlugin?: AuthPlugin;
6
7
  entrySchemaRegistry: Record<string, readonly FieldConfig[]>;
7
8
  }
8
9
  /**
9
10
  * Create Next.js-specific wrapper around core context.
10
11
  * Adds React cache() for per-request memoization and API handler.
11
12
  * This function is async because it needs to load .collection.json meta files.
13
+ *
14
+ * In prod/prod-sim mode, if the provided authPlugin implements verifyTokenOnly(),
15
+ * it is automatically wrapped with CachingAuthPlugin + FileBasedAuthCache so that
16
+ * auth works without network access (Lambda). The cache is populated by the worker daemon.
12
17
  */
13
18
  export declare function createNextCanopyContext(options: NextCanopyOptions): Promise<{
14
19
  getCanopy: () => Promise<CanopyContext>;
@@ -1 +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"}
1
+ {"version":3,"file":"context-wrapper.d.ts","sourceRoot":"","sources":["../src/context-wrapper.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,aAAa,EAKnB,MAAM,kBAAkB,CAAA;AACzB,OAAO,KAAK,EAAE,YAAY,EAAE,UAAU,EAAc,WAAW,EAAE,MAAM,WAAW,CAAA;AA+BlF,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,YAAY,CAAA;IACpB,mGAAmG;IACnG,UAAU,CAAC,EAAE,UAAU,CAAA;IACvB,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC,CAAA;CAC5D;AAED;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAAC,OAAO,EAAE,iBAAiB;qBAqF1C,OAAO,CAAC,aAAa,CAAC;;cAnEnC,CAAC;qBAA+B,CAAC;;;qBAC/B,CAAC;;;;;GAkFnB"}
@@ -1,16 +1,69 @@
1
+ import path from 'node:path';
1
2
  import { cache } from 'react';
2
3
  import { headers } from 'next/headers';
3
- import { createCanopyContext, createCanopyServices } from 'canopycms/server';
4
+ import { createCanopyContext, createCanopyServices, operatingStrategy, loadInternalGroups, loadBranchContext, } from 'canopycms/server';
4
5
  import { authResultToCanopyUser } from 'canopycms';
5
- import { loadInternalGroups, loadBranchContext } from 'canopycms/server';
6
+ import { CachingAuthPlugin, FileBasedAuthCache } from 'canopycms/auth/cache';
6
7
  import { createCanopyCatchAllHandler } from './adapter';
7
8
  let warnedNoAdmins = false;
9
+ let warnedStaticMode = false;
10
+ /**
11
+ * Stub auth plugin for static deployments where no real auth is needed.
12
+ * Returns unauthenticated for all requests — API routes will return 401.
13
+ */
14
+ const staticDeployAuthPlugin = {
15
+ async authenticate() {
16
+ return { success: false, error: 'No auth plugin configured (static deployment)' };
17
+ },
18
+ async searchUsers() {
19
+ return [];
20
+ },
21
+ async getUserMetadata() {
22
+ return null;
23
+ },
24
+ async getGroupMetadata() {
25
+ return null;
26
+ },
27
+ async listGroups() {
28
+ return [];
29
+ },
30
+ };
8
31
  /**
9
32
  * Create Next.js-specific wrapper around core context.
10
33
  * Adds React cache() for per-request memoization and API handler.
11
34
  * This function is async because it needs to load .collection.json meta files.
35
+ *
36
+ * In prod/prod-sim mode, if the provided authPlugin implements verifyTokenOnly(),
37
+ * it is automatically wrapped with CachingAuthPlugin + FileBasedAuthCache so that
38
+ * auth works without network access (Lambda). The cache is populated by the worker daemon.
12
39
  */
13
40
  export async function createNextCanopyContext(options) {
41
+ // Fail fast: authPlugin is required for server deployments
42
+ if (options.config.deployedAs !== 'static' && !options.authPlugin) {
43
+ throw new Error('CanopyCMS: authPlugin is required when deployedAs is "server". ' +
44
+ 'Set deployedAs: "static" in your canopy config, or provide an authPlugin.');
45
+ }
46
+ // Warn when running in static deployment mode so it is not accidentally set in a server build
47
+ if (options.config.deployedAs === 'static' && !warnedStaticMode) {
48
+ console.warn('CanopyCMS: running in static deployment mode — all CMS API requests will return 401. ' +
49
+ 'Do not set deployedAs: "static" in a server deployment.');
50
+ warnedStaticMode = true;
51
+ }
52
+ // Resolve the auth plugin: auto-wrap with CachingAuthPlugin for prod/prod-sim when
53
+ // the plugin supports token-only verification. This keeps auth networkless (required for
54
+ // Lambda) without exposing caching internals to adopters.
55
+ // For static deployments, use the stub that returns 401 for all requests.
56
+ const { mode } = options.config;
57
+ const authPlugin = (() => {
58
+ if (!options.authPlugin)
59
+ return staticDeployAuthPlugin;
60
+ if ((mode === 'prod' || mode === 'prod-sim') && options.authPlugin.verifyTokenOnly) {
61
+ const cachePath = process.env.CANOPY_AUTH_CACHE_PATH ??
62
+ path.join(operatingStrategy(mode).getWorkspaceRoot(), '.cache');
63
+ return new CachingAuthPlugin((ctx) => options.authPlugin.verifyTokenOnly(ctx), new FileBasedAuthCache(cachePath));
64
+ }
65
+ return options.authPlugin;
66
+ })();
14
67
  // Create services ONCE at initialization
15
68
  const services = await createCanopyServices(options.config, {
16
69
  entrySchemaRegistry: options.entrySchemaRegistry,
@@ -18,7 +71,7 @@ export async function createNextCanopyContext(options) {
18
71
  // User extractor: passes Next.js headers to auth plugin, loads internal groups, applies authorization
19
72
  const extractUser = async () => {
20
73
  const headersList = await headers();
21
- const authResult = await options.authPlugin.authenticate(headersList);
74
+ const authResult = await authPlugin.authenticate(headersList);
22
75
  // Load internal groups from main branch
23
76
  const baseBranch = services.config.defaultBaseBranch ?? 'main';
24
77
  const operatingMode = services.config.mode ?? 'dev';
@@ -53,6 +106,7 @@ export async function createNextCanopyContext(options) {
53
106
  // Create API handler using same services
54
107
  const handler = createCanopyCatchAllHandler({
55
108
  ...options,
109
+ authPlugin,
56
110
  services,
57
111
  });
58
112
  return {
@@ -1 +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"}
1
+ {"version":3,"file":"context-wrapper.js","sourceRoot":"","sources":["../src/context-wrapper.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopycms-next",
3
- "version": "0.0.0",
3
+ "version": "0.0.2",
4
4
  "description": "Next.js adapter for CanopyCMS",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,8 +17,7 @@
17
17
  "./client": "./src/client.tsx"
18
18
  },
19
19
  "files": [
20
- "dist",
21
- "src"
20
+ "dist"
22
21
  ],
23
22
  "publishConfig": {
24
23
  "exports": {
@@ -41,8 +40,8 @@
41
40
  "node": ">=18"
42
41
  },
43
42
  "peerDependencies": {
44
- "canopycms": "*",
45
- "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
43
+ "canopycms": "^0.0.2",
44
+ "next": "^0.0.2",
46
45
  "react": "^18.0.0 || ^19.0.0"
47
46
  },
48
47
  "devDependencies": {
@@ -1,194 +0,0 @@
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 DELETED
@@ -1,105 +0,0 @@
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 DELETED
@@ -1,36 +0,0 @@
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
- }
@@ -1,87 +0,0 @@
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 DELETED
@@ -1,5 +0,0 @@
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'
package/src/test-utils.ts DELETED
@@ -1,42 +0,0 @@
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
- })