canopycms-auth-dev 0.0.0 → 0.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopycms-auth-dev",
3
- "version": "0.0.0",
3
+ "version": "0.0.1",
4
4
  "description": "Development authentication provider for CanopyCMS",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,8 +18,7 @@
18
18
  "./cache-writer": "./src/cache-writer.ts"
19
19
  },
20
20
  "files": [
21
- "dist",
22
- "src"
21
+ "dist"
23
22
  ],
24
23
  "publishConfig": {
25
24
  "exports": {
@@ -46,7 +45,7 @@
46
45
  "node": ">=18"
47
46
  },
48
47
  "peerDependencies": {
49
- "canopycms": "*",
48
+ "canopycms": "^0.0.1",
50
49
  "react": "^18.0.0 || ^19.0.0",
51
50
  "@mantine/core": "^7.0.0",
52
51
  "@mantine/hooks": "^7.0.0",
@@ -1,46 +0,0 @@
1
- 'use client'
2
-
3
- import { useState, useEffect } from 'react'
4
- import { ActionIcon, Avatar } from '@mantine/core'
5
- import { UserSwitcherModal } from './UserSwitcherModal'
6
- import { DEFAULT_USERS } from './dev-plugin'
7
- import { getDevUserCookie, DEFAULT_USER_ID } from './cookie-utils'
8
-
9
- /**
10
- * User switcher button component that shows current user avatar and opens modal
11
- */
12
- export function UserSwitcherButton() {
13
- const [opened, setOpened] = useState(false)
14
- const [mounted, setMounted] = useState(false)
15
-
16
- // Only read cookie after mount to avoid hydration mismatch
17
- useEffect(() => {
18
- setMounted(true)
19
- }, [])
20
-
21
- // Read current user from cookie (only on client)
22
- const currentUserId = mounted ? (getDevUserCookie() ?? DEFAULT_USER_ID) : DEFAULT_USER_ID
23
- const currentUser = DEFAULT_USERS.find((u) => u.userId === currentUserId)
24
-
25
- return (
26
- <>
27
- <ActionIcon
28
- variant="subtle"
29
- size="lg"
30
- radius="md"
31
- onClick={() => setOpened(true)}
32
- aria-label="Switch user"
33
- >
34
- <Avatar size="sm" color="blue">
35
- {currentUser?.name[0] ?? 'U'}
36
- </Avatar>
37
- </ActionIcon>
38
-
39
- <UserSwitcherModal
40
- opened={opened}
41
- onClose={() => setOpened(false)}
42
- currentUserId={currentUserId}
43
- />
44
- </>
45
- )
46
- }
@@ -1,63 +0,0 @@
1
- 'use client'
2
-
3
- import { Modal, Stack, Paper, Group, Avatar, Text, Badge } from '@mantine/core'
4
- import { MdCheck } from 'react-icons/md'
5
- import { DEFAULT_USERS } from './dev-plugin'
6
- import { setDevUserCookie } from './cookie-utils'
7
-
8
- interface Props {
9
- opened: boolean
10
- onClose: () => void
11
- currentUserId: string
12
- }
13
-
14
- /**
15
- * User switcher modal component that displays all available dev users
16
- */
17
- export function UserSwitcherModal({ opened, onClose, currentUserId }: Props) {
18
- const switchUser = (userId: string) => {
19
- // Set cookie for 7 days
20
- setDevUserCookie(userId)
21
- // Reload to apply new user
22
- window.location.reload()
23
- }
24
-
25
- return (
26
- <Modal opened={opened} onClose={onClose} title="Switch Development User">
27
- <Stack gap="sm">
28
- {DEFAULT_USERS.map((user) => (
29
- <Paper
30
- key={user.userId}
31
- p="md"
32
- withBorder
33
- style={{ cursor: 'pointer' }}
34
- onClick={() => switchUser(user.userId)}
35
- >
36
- <Group justify="space-between" mb="xs">
37
- <Group>
38
- <Avatar color="blue">{user.name[0]}</Avatar>
39
- <div>
40
- <Text fw={500}>{user.name}</Text>
41
- <Text size="sm" c="dimmed">
42
- {user.email}
43
- </Text>
44
- </div>
45
- </Group>
46
- {user.userId === currentUserId && <MdCheck size={20} />}
47
- </Group>
48
-
49
- {user.externalGroups.length > 0 && (
50
- <Group gap="xs">
51
- {user.externalGroups.map((g) => (
52
- <Badge key={g} variant="outline" size="sm">
53
- {g}
54
- </Badge>
55
- ))}
56
- </Group>
57
- )}
58
- </Paper>
59
- ))}
60
- </Stack>
61
- </Modal>
62
- )
63
- }
@@ -1,61 +0,0 @@
1
- import { writeAuthCacheSnapshot } from 'canopycms/auth/cache'
2
- import { DEFAULT_USERS, DEFAULT_GROUPS } from './dev-plugin'
3
- import type { DevUser, DevGroup } from './dev-plugin'
4
-
5
- export interface RefreshDevCacheOptions {
6
- /** Directory to write cache files to (e.g., .canopy-prod-sim/.cache) */
7
- cachePath: string
8
- /** Custom users (defaults to DEFAULT_USERS) */
9
- users?: DevUser[]
10
- /** Custom groups (defaults to DEFAULT_GROUPS) */
11
- groups?: DevGroup[]
12
- }
13
-
14
- /**
15
- * Write dev users/groups to cache files for FileBasedAuthCache.
16
- *
17
- * This is the dev-auth equivalent of refreshClerkCache() — it populates
18
- * the same JSON files that CachingAuthPlugin reads. Since dev users are
19
- * hardcoded, no API calls are needed.
20
- *
21
- * Used by the worker's `run-once` command in prod-sim mode with dev auth.
22
- */
23
- export async function refreshDevCache(
24
- options: RefreshDevCacheOptions,
25
- ): Promise<{ userCount: number; groupCount: number }> {
26
- const { cachePath } = options
27
- const users = options.users ?? DEFAULT_USERS
28
- const groups = options.groups ?? DEFAULT_GROUPS
29
-
30
- const usersData = {
31
- users: users.map((u) => ({
32
- id: u.userId,
33
- name: u.name,
34
- email: u.email,
35
- avatarUrl: u.avatarUrl,
36
- })),
37
- }
38
-
39
- const groupsData = {
40
- groups: groups.map((g) => ({
41
- id: g.id,
42
- name: g.name,
43
- description: g.description,
44
- })),
45
- }
46
-
47
- const membershipsData = {
48
- memberships: Object.fromEntries(
49
- users.filter((u) => u.externalGroups.length > 0).map((u) => [u.userId, u.externalGroups]),
50
- ),
51
- }
52
-
53
- // Write cache files atomically via snapshot directory + symlink swap
54
- await writeAuthCacheSnapshot(cachePath, {
55
- 'users.json': usersData,
56
- 'orgs.json': groupsData,
57
- 'memberships.json': membershipsData,
58
- })
59
-
60
- return { userCount: users.length, groupCount: groups.length }
61
- }
package/src/client.ts DELETED
@@ -1,34 +0,0 @@
1
- 'use client'
2
-
3
- import type { CanopyClientConfig } from 'canopycms/client'
4
- import { UserSwitcherButton } from './UserSwitcherButton'
5
- import { clearDevUserCookie } from './cookie-utils'
6
-
7
- /**
8
- * Hook that provides dev auth handlers and components for CanopyCMS editor.
9
- * Model after: packages/canopycms-auth-clerk/src/client.ts
10
- *
11
- * @example
12
- * ```tsx
13
- * import { useDevAuthConfig } from 'canopycms-auth-dev/client'
14
- * import config from '../../canopycms.config'
15
- *
16
- * export default function EditPage() {
17
- * const devAuth = useDevAuthConfig()
18
- * const editorConfig = config.client(devAuth)
19
- * return <CanopyEditorPage config={editorConfig} />
20
- * }
21
- * ```
22
- */
23
- export function useDevAuthConfig(): Pick<CanopyClientConfig, 'editor'> {
24
- return {
25
- editor: {
26
- AccountComponent: UserSwitcherButton,
27
- onLogoutClick: () => {
28
- // Reset to default user
29
- clearDevUserCookie()
30
- window.location.reload()
31
- },
32
- },
33
- }
34
- }
@@ -1,44 +0,0 @@
1
- import type { HeadersLike } from 'canopycms/auth'
2
-
3
- export const DEV_USER_COOKIE_NAME = 'canopy-dev-user'
4
- export const DEV_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 // 7 days
5
- export const DEFAULT_USER_ID = 'dev_user1_2nK8mP4xL9'
6
-
7
- /**
8
- * Server-side: Extract cookie value from HTTP headers
9
- */
10
- export function getDevUserCookieFromHeaders(headers: HeadersLike): string | null {
11
- const cookie = headers.get('Cookie')
12
- if (!cookie) return null
13
-
14
- const match = cookie.match(new RegExp(`${DEV_USER_COOKIE_NAME}=([^;]+)`))
15
- return match?.[1] ?? null
16
- }
17
-
18
- /**
19
- * Client-side: Read cookie from document.cookie
20
- */
21
- export function getDevUserCookie(): string | null {
22
- if (typeof document === 'undefined') return null
23
-
24
- const match = document.cookie.match(new RegExp(`${DEV_USER_COOKIE_NAME}=([^;]+)`))
25
- return match?.[1] ?? null
26
- }
27
-
28
- /**
29
- * Client-side: Set dev user cookie
30
- */
31
- export function setDevUserCookie(userId: string): void {
32
- if (typeof document === 'undefined') return
33
-
34
- document.cookie = `${DEV_USER_COOKIE_NAME}=${userId}; path=/; max-age=${DEV_USER_COOKIE_MAX_AGE}; SameSite=Lax`
35
- }
36
-
37
- /**
38
- * Client-side: Clear dev user cookie (logout)
39
- */
40
- export function clearDevUserCookie(): void {
41
- if (typeof document === 'undefined') return
42
-
43
- document.cookie = `${DEV_USER_COOKIE_NAME}=; path=/; max-age=0`
44
- }
@@ -1,470 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
- import {
3
- DevAuthPlugin,
4
- createDevAuthPlugin,
5
- DEFAULT_USERS,
6
- DEFAULT_GROUPS,
7
- DEV_ADMIN_USER_ID,
8
- } from './dev-plugin'
9
- import type { DevUser, DevGroup } from './dev-plugin'
10
- import type { AuthenticationResult } from 'canopycms/auth'
11
-
12
- // Type guard to assert successful authentication
13
- function assertSuccess(result: AuthenticationResult): asserts result is AuthenticationResult & {
14
- success: true
15
- user: NonNullable<AuthenticationResult['user']>
16
- } {
17
- expect(result.success).toBe(true)
18
- if (!result.success || !result.user) {
19
- throw new Error('Authentication failed')
20
- }
21
- }
22
-
23
- describe('DevAuthPlugin', () => {
24
- describe('authenticate', () => {
25
- it('returns default user when no headers provided', async () => {
26
- const plugin = new DevAuthPlugin({})
27
- const result = await plugin.authenticate(new Headers())
28
-
29
- assertSuccess(result)
30
- expect(result.user.userId).toBe('dev_user1_2nK8mP4xL9') // user1
31
- expect(result.user.name).toBe('User One')
32
- expect(result.user.email).toBe('user1@localhost.dev')
33
- expect(result.user.externalGroups).toEqual(['team-a', 'team-b'])
34
- })
35
-
36
- it('authenticates via X-Test-User header', async () => {
37
- const plugin = new DevAuthPlugin({})
38
- const headers = new Headers({ 'X-Test-User': 'admin' })
39
- const result = await plugin.authenticate(headers)
40
-
41
- assertSuccess(result)
42
- expect(result.user.userId).toBe('dev_admin_3xY6zW1qR5') // admin1
43
- expect(result.user.name).toBe('Admin One')
44
- })
45
-
46
- it('authenticates via x-dev-user-id header', async () => {
47
- const plugin = new DevAuthPlugin({})
48
- const headers = new Headers({ 'x-dev-user-id': 'dev_user2_7qR3tY6wN2' })
49
- const result = await plugin.authenticate(headers)
50
-
51
- assertSuccess(result)
52
- expect(result.user.userId).toBe('dev_user2_7qR3tY6wN2') // user2
53
- expect(result.user.name).toBe('User Two')
54
- })
55
-
56
- it('authenticates via canopy-dev-user cookie', async () => {
57
- const plugin = new DevAuthPlugin({})
58
- const headers = new Headers({
59
- cookie: 'canopy-dev-user=dev_user3_5vS1pM8kJ4; other=value',
60
- })
61
- const result = await plugin.authenticate(headers)
62
-
63
- assertSuccess(result)
64
- expect(result.user.userId).toBe('dev_user3_5vS1pM8kJ4') // user3
65
- expect(result.user.name).toBe('User Three')
66
- })
67
-
68
- it('prioritizes X-Test-User over cookie', async () => {
69
- const plugin = new DevAuthPlugin({})
70
- const headers = new Headers({
71
- 'X-Test-User': 'admin',
72
- cookie: 'canopy-dev-user=dev_user1_2nK8mP4xL9',
73
- })
74
- const result = await plugin.authenticate(headers)
75
-
76
- assertSuccess(result)
77
- expect(result.user.userId).toBe('dev_admin_3xY6zW1qR5') // admin1 from header
78
- })
79
-
80
- it('maps test user keys to dev user IDs', async () => {
81
- const plugin = new DevAuthPlugin({})
82
-
83
- const testCases = [
84
- { key: 'admin', expectedId: 'dev_admin_3xY6zW1qR5' },
85
- { key: 'editor', expectedId: 'dev_user1_2nK8mP4xL9' },
86
- { key: 'viewer', expectedId: 'dev_user2_7qR3tY6wN2' },
87
- { key: 'reviewer', expectedId: 'dev_reviewer_9aB4cD2eF7' },
88
- ]
89
-
90
- for (const { key, expectedId } of testCases) {
91
- const headers = new Headers({ 'X-Test-User': key })
92
- const result = await plugin.authenticate(headers)
93
-
94
- assertSuccess(result)
95
- expect(result.user.userId).toBe(expectedId)
96
- }
97
- })
98
-
99
- it('returns failure for unknown user ID', async () => {
100
- const plugin = new DevAuthPlugin({})
101
- const headers = new Headers({ 'x-dev-user-id': 'unknown_user' })
102
- const result = await plugin.authenticate(headers)
103
-
104
- expect(result.success).toBe(false)
105
- if (!result.success) {
106
- expect(result.error).toContain('Dev user not found')
107
- }
108
- })
109
-
110
- it('uses custom default user when specified', async () => {
111
- const plugin = new DevAuthPlugin({
112
- defaultUserId: 'dev_admin_3xY6zW1qR5', // admin1
113
- })
114
- const result = await plugin.authenticate(new Headers())
115
-
116
- assertSuccess(result)
117
- expect(result.user.userId).toBe('dev_admin_3xY6zW1qR5')
118
- })
119
- })
120
-
121
- describe('searchUsers', () => {
122
- it('returns all users when query is empty', async () => {
123
- const plugin = new DevAuthPlugin({})
124
- const results = await plugin.searchUsers('')
125
-
126
- expect(results).toHaveLength(5)
127
- expect(results[0].id).toBe('dev_user1_2nK8mP4xL9')
128
- expect(results[0].name).toBe('User One')
129
- expect(results[0].email).toBe('user1@localhost.dev')
130
- })
131
-
132
- it('filters users by name', async () => {
133
- const plugin = new DevAuthPlugin({})
134
- const results = await plugin.searchUsers('admin')
135
-
136
- expect(results).toHaveLength(1)
137
- expect(results[0].name).toBe('Admin One')
138
- })
139
-
140
- it('filters users by email', async () => {
141
- const plugin = new DevAuthPlugin({})
142
- const results = await plugin.searchUsers('reviewer1@')
143
-
144
- expect(results).toHaveLength(1)
145
- expect(results[0].id).toBe('dev_reviewer_9aB4cD2eF7')
146
- })
147
-
148
- it('is case insensitive', async () => {
149
- const plugin = new DevAuthPlugin({})
150
- const results = await plugin.searchUsers('USER')
151
-
152
- expect(results.length).toBeGreaterThan(0)
153
- expect(results.some((u) => u.name.toLowerCase().includes('user'))).toBe(true)
154
- })
155
-
156
- it('respects limit parameter', async () => {
157
- const plugin = new DevAuthPlugin({})
158
- const results = await plugin.searchUsers('', 2)
159
-
160
- expect(results).toHaveLength(2)
161
- })
162
-
163
- it('works with custom users', async () => {
164
- const customUsers: DevUser[] = [
165
- {
166
- userId: 'custom_1',
167
- name: 'Custom User',
168
- email: 'custom@test.com',
169
- externalGroups: [],
170
- },
171
- ]
172
- const plugin = new DevAuthPlugin({ users: customUsers })
173
- const results = await plugin.searchUsers('custom')
174
-
175
- expect(results).toHaveLength(1)
176
- expect(results[0].id).toBe('custom_1')
177
- })
178
- })
179
-
180
- describe('getUserMetadata', () => {
181
- it('returns user metadata for valid user', async () => {
182
- const plugin = new DevAuthPlugin({})
183
- const metadata = await plugin.getUserMetadata('dev_user1_2nK8mP4xL9')
184
-
185
- expect(metadata).toEqual({
186
- id: 'dev_user1_2nK8mP4xL9',
187
- name: 'User One',
188
- email: 'user1@localhost.dev',
189
- avatarUrl: undefined,
190
- })
191
- })
192
-
193
- it('returns null for unknown user', async () => {
194
- const plugin = new DevAuthPlugin({})
195
- const metadata = await plugin.getUserMetadata('unknown')
196
-
197
- expect(metadata).toBeNull()
198
- })
199
-
200
- it('includes avatarUrl when present', async () => {
201
- const customUsers: DevUser[] = [
202
- {
203
- userId: 'user_1',
204
- name: 'User',
205
- email: 'user@test.com',
206
- avatarUrl: 'https://example.com/avatar.jpg',
207
- externalGroups: [],
208
- },
209
- ]
210
- const plugin = new DevAuthPlugin({ users: customUsers })
211
- const metadata = await plugin.getUserMetadata('user_1')
212
-
213
- expect(metadata?.avatarUrl).toBe('https://example.com/avatar.jpg')
214
- })
215
- })
216
-
217
- describe('getGroupMetadata', () => {
218
- it('returns group metadata for valid group', async () => {
219
- const plugin = new DevAuthPlugin({})
220
- const metadata = await plugin.getGroupMetadata('team-a')
221
-
222
- expect(metadata).toEqual({
223
- id: 'team-a',
224
- name: 'Team A',
225
- description: 'Team A',
226
- })
227
- })
228
-
229
- it('returns null for unknown group', async () => {
230
- const plugin = new DevAuthPlugin({})
231
- const metadata = await plugin.getGroupMetadata('unknown')
232
-
233
- expect(metadata).toBeNull()
234
- })
235
-
236
- it('works with custom groups', async () => {
237
- const customGroups: DevGroup[] = [
238
- { id: 'team-x', name: 'Team X', description: 'Custom team' },
239
- ]
240
- const plugin = new DevAuthPlugin({ groups: customGroups })
241
- const metadata = await plugin.getGroupMetadata('team-x')
242
-
243
- expect(metadata).toEqual({
244
- id: 'team-x',
245
- name: 'Team X',
246
- description: 'Custom team',
247
- })
248
- })
249
- })
250
-
251
- describe('listGroups', () => {
252
- it('returns all groups by default', async () => {
253
- const plugin = new DevAuthPlugin({})
254
- const groups = await plugin.listGroups()
255
-
256
- expect(groups).toHaveLength(3)
257
- expect(groups.map((g) => g.id)).toEqual(['team-a', 'team-b', 'team-c'])
258
- })
259
-
260
- it('respects limit parameter', async () => {
261
- const plugin = new DevAuthPlugin({})
262
- const groups = await plugin.listGroups(2)
263
-
264
- expect(groups).toHaveLength(2)
265
- })
266
- })
267
-
268
- describe('searchExternalGroups', () => {
269
- it('returns all groups when query is empty', async () => {
270
- const plugin = new DevAuthPlugin({})
271
- const results = await plugin.searchExternalGroups('')
272
-
273
- expect(results).toHaveLength(3)
274
- })
275
-
276
- it('filters groups by name', async () => {
277
- const plugin = new DevAuthPlugin({})
278
- const results = await plugin.searchExternalGroups('team a')
279
-
280
- expect(results).toHaveLength(1)
281
- expect(results[0].id).toBe('team-a')
282
- expect(results[0].name).toBe('Team A')
283
- })
284
-
285
- it('is case insensitive', async () => {
286
- const plugin = new DevAuthPlugin({})
287
- const results = await plugin.searchExternalGroups('TEAM')
288
-
289
- expect(results.length).toBeGreaterThan(0)
290
- })
291
- })
292
-
293
- describe('custom configuration', () => {
294
- it('accepts custom users and groups', () => {
295
- const customUsers: DevUser[] = [
296
- {
297
- userId: 'custom_1',
298
- name: 'Custom',
299
- email: 'custom@test.com',
300
- externalGroups: ['custom-group'],
301
- },
302
- ]
303
- const customGroups: DevGroup[] = [{ id: 'custom-group', name: 'Custom Group' }]
304
-
305
- const plugin = new DevAuthPlugin({
306
- users: customUsers,
307
- groups: customGroups,
308
- defaultUserId: 'custom_1',
309
- })
310
-
311
- expect(plugin).toBeInstanceOf(DevAuthPlugin)
312
- })
313
- })
314
-
315
- describe('createDevAuthPlugin factory', () => {
316
- beforeEach(() => {
317
- vi.spyOn(console, 'info').mockImplementation(() => {})
318
- })
319
- afterEach(() => {
320
- vi.restoreAllMocks()
321
- })
322
-
323
- it('creates plugin with default config', () => {
324
- const plugin = createDevAuthPlugin()
325
- expect(plugin).toBeInstanceOf(DevAuthPlugin)
326
- })
327
-
328
- it('creates plugin with custom config', () => {
329
- const plugin = createDevAuthPlugin({
330
- defaultUserId: 'dev_admin_3xY6zW1qR5',
331
- })
332
- expect(plugin).toBeInstanceOf(DevAuthPlugin)
333
- })
334
-
335
- it('works without any arguments', () => {
336
- const plugin = createDevAuthPlugin()
337
- expect(plugin).toBeInstanceOf(DevAuthPlugin)
338
- })
339
- })
340
-
341
- describe('DEFAULT_USERS', () => {
342
- it('exports default users', () => {
343
- expect(DEFAULT_USERS).toHaveLength(5)
344
- expect(DEFAULT_USERS[0].userId).toBe('dev_user1_2nK8mP4xL9')
345
- expect(DEFAULT_USERS[4].userId).toBe('dev_admin_3xY6zW1qR5')
346
- })
347
-
348
- it('has correct user structure', () => {
349
- DEFAULT_USERS.forEach((user) => {
350
- expect(user).toHaveProperty('userId')
351
- expect(user).toHaveProperty('name')
352
- expect(user).toHaveProperty('email')
353
- expect(user).toHaveProperty('externalGroups')
354
- expect(Array.isArray(user.externalGroups)).toBe(true)
355
- })
356
- })
357
- })
358
-
359
- describe('DEFAULT_GROUPS', () => {
360
- it('exports default groups', () => {
361
- expect(DEFAULT_GROUPS).toHaveLength(3)
362
- expect(DEFAULT_GROUPS.map((g) => g.id)).toEqual(['team-a', 'team-b', 'team-c'])
363
- })
364
-
365
- it('has correct group structure', () => {
366
- DEFAULT_GROUPS.forEach((group) => {
367
- expect(group).toHaveProperty('id')
368
- expect(group).toHaveProperty('name')
369
- expect(group).toHaveProperty('description')
370
- })
371
- })
372
- })
373
-
374
- describe('cookie parsing', () => {
375
- it('extracts cookie from single cookie string', async () => {
376
- const plugin = new DevAuthPlugin({})
377
- const headers = new Headers({
378
- cookie: 'canopy-dev-user=dev_admin_3xY6zW1qR5',
379
- })
380
- const result = await plugin.authenticate(headers)
381
-
382
- assertSuccess(result)
383
- expect(result.user.userId).toBe('dev_admin_3xY6zW1qR5')
384
- })
385
-
386
- it('extracts cookie from multiple cookies', async () => {
387
- const plugin = new DevAuthPlugin({})
388
- const headers = new Headers({
389
- cookie: 'session=abc123; canopy-dev-user=dev_user2_7qR3tY6wN2; other=value',
390
- })
391
- const result = await plugin.authenticate(headers)
392
-
393
- assertSuccess(result)
394
- expect(result.user.userId).toBe('dev_user2_7qR3tY6wN2')
395
- })
396
-
397
- it('extracts cookie without semicolon separator', async () => {
398
- const plugin = new DevAuthPlugin({})
399
- const headers = new Headers({
400
- cookie: 'canopy-dev-user=dev_reviewer_9aB4cD2eF7',
401
- })
402
- const result = await plugin.authenticate(headers)
403
-
404
- assertSuccess(result)
405
- expect(result.user.userId).toBe('dev_reviewer_9aB4cD2eF7')
406
- })
407
-
408
- it('returns default user when cookie not found', async () => {
409
- const plugin = new DevAuthPlugin({})
410
- const headers = new Headers({
411
- cookie: 'session=abc123; other=value',
412
- })
413
- const result = await plugin.authenticate(headers)
414
-
415
- assertSuccess(result)
416
- expect(result.user.userId).toBe('dev_user1_2nK8mP4xL9') // default
417
- })
418
- })
419
-
420
- describe('auto-bootstrap admin', () => {
421
- let originalEnv: string | undefined
422
-
423
- beforeEach(() => {
424
- originalEnv = process.env.CANOPY_BOOTSTRAP_ADMIN_IDS
425
- vi.spyOn(console, 'info').mockImplementation(() => {})
426
- })
427
-
428
- afterEach(() => {
429
- vi.restoreAllMocks()
430
- if (originalEnv === undefined) {
431
- delete process.env.CANOPY_BOOTSTRAP_ADMIN_IDS
432
- } else {
433
- process.env.CANOPY_BOOTSTRAP_ADMIN_IDS = originalEnv
434
- }
435
- })
436
-
437
- it('auto-sets CANOPY_BOOTSTRAP_ADMIN_IDS when not already set', () => {
438
- delete process.env.CANOPY_BOOTSTRAP_ADMIN_IDS
439
- createDevAuthPlugin()
440
- expect(process.env.CANOPY_BOOTSTRAP_ADMIN_IDS).toBe(DEV_ADMIN_USER_ID)
441
- })
442
-
443
- it('does not override existing CANOPY_BOOTSTRAP_ADMIN_IDS', () => {
444
- process.env.CANOPY_BOOTSTRAP_ADMIN_IDS = 'custom_user_id'
445
- createDevAuthPlugin()
446
- expect(process.env.CANOPY_BOOTSTRAP_ADMIN_IDS).toBe('custom_user_id')
447
- })
448
-
449
- it('can be disabled via autoBootstrapAdmin: false', () => {
450
- delete process.env.CANOPY_BOOTSTRAP_ADMIN_IDS
451
- createDevAuthPlugin({ autoBootstrapAdmin: false })
452
- expect(process.env.CANOPY_BOOTSTRAP_ADMIN_IDS).toBeUndefined()
453
- })
454
-
455
- it('skips auto-bootstrap when custom users do not include admin user', () => {
456
- delete process.env.CANOPY_BOOTSTRAP_ADMIN_IDS
457
- createDevAuthPlugin({
458
- users: [
459
- {
460
- userId: 'custom_1',
461
- name: 'Custom',
462
- email: 'custom@test.com',
463
- externalGroups: [],
464
- },
465
- ],
466
- })
467
- expect(process.env.CANOPY_BOOTSTRAP_ADMIN_IDS).toBeUndefined()
468
- })
469
- })
470
- })
package/src/dev-plugin.ts DELETED
@@ -1,241 +0,0 @@
1
- import type { AuthPlugin } from 'canopycms/auth'
2
- import type { UserSearchResult, GroupMetadata, AuthenticationResult } from 'canopycms/auth'
3
- import { extractHeaders } from 'canopycms/auth'
4
- import type { CanopyUserId, CanopyGroupId } from 'canopycms'
5
- import { getDevUserCookieFromHeaders } from './cookie-utils'
6
-
7
- /**
8
- * WARNING: This plugin is for development and testing only!
9
- * Do not use in production environments.
10
- */
11
-
12
- export interface DevUser {
13
- userId: CanopyUserId
14
- name: string
15
- email: string
16
- avatarUrl?: string
17
- externalGroups: CanopyGroupId[]
18
- }
19
-
20
- export interface DevGroup {
21
- id: CanopyGroupId
22
- name: string
23
- description?: string
24
- }
25
-
26
- export const DEV_ADMIN_USER_ID: CanopyUserId = 'dev_admin_3xY6zW1qR5'
27
-
28
- export interface DevAuthConfig {
29
- /**
30
- * Custom mock users. If not provided, uses default users.
31
- */
32
- users?: DevUser[]
33
-
34
- /**
35
- * Custom mock groups. If not provided, uses default groups.
36
- */
37
- groups?: DevGroup[]
38
-
39
- /**
40
- * Default user ID when no user is selected.
41
- * @default 'dev_user1_2nK8mP4xL9' (user1)
42
- */
43
- defaultUserId?: CanopyUserId
44
-
45
- /**
46
- * Whether to auto-set CANOPY_BOOTSTRAP_ADMIN_IDS for the admin dev user
47
- * when the env var is not already set. Defaults to true.
48
- */
49
- autoBootstrapAdmin?: boolean
50
- }
51
-
52
- export const DEFAULT_USERS: DevUser[] = [
53
- {
54
- userId: 'dev_user1_2nK8mP4xL9',
55
- name: 'User One',
56
- email: 'user1@localhost.dev',
57
- externalGroups: ['team-a', 'team-b'],
58
- },
59
- {
60
- userId: 'dev_user2_7qR3tY6wN2',
61
- name: 'User Two',
62
- email: 'user2@localhost.dev',
63
- externalGroups: ['team-b'],
64
- },
65
- {
66
- userId: 'dev_user3_5vS1pM8kJ4',
67
- name: 'User Three',
68
- email: 'user3@localhost.dev',
69
- externalGroups: ['team-c'],
70
- },
71
- {
72
- userId: 'dev_reviewer_9aB4cD2eF7',
73
- name: 'Reviewer One',
74
- email: 'reviewer1@localhost.dev',
75
- externalGroups: ['team-a'],
76
- // Note: 'Reviewers' membership comes from internal groups file, not auth plugin
77
- },
78
- {
79
- userId: DEV_ADMIN_USER_ID,
80
- name: 'Admin One',
81
- email: 'admin1@localhost.dev',
82
- externalGroups: ['team-a', 'team-b', 'team-c'],
83
- // Note: Does NOT include 'Admins' - that's applied by bootstrap admin config or auto-bootstrap
84
- },
85
- ]
86
-
87
- export const DEFAULT_GROUPS: DevGroup[] = [
88
- { id: 'team-a', name: 'Team A', description: 'Team A' },
89
- { id: 'team-b', name: 'Team B', description: 'Team B' },
90
- { id: 'team-c', name: 'Team C', description: 'Team C' },
91
- ]
92
-
93
- /**
94
- * Dev authentication plugin implementation for CanopyCMS.
95
- * Supports both cookie-based (UI) and header-based (tests) authentication.
96
- */
97
- export class DevAuthPlugin implements AuthPlugin {
98
- private users: DevUser[]
99
- private groups: DevGroup[]
100
- private defaultUserId: CanopyUserId
101
-
102
- constructor(config: DevAuthConfig = {}) {
103
- this.users = config.users ?? DEFAULT_USERS
104
- this.groups = config.groups ?? DEFAULT_GROUPS
105
- this.defaultUserId = config.defaultUserId ?? 'dev_user1_2nK8mP4xL9'
106
- }
107
-
108
- async authenticate(context: unknown): Promise<AuthenticationResult> {
109
- // 1. Extract headers using extractHeaders() helper
110
- const headers = extractHeaders(context)
111
- if (!headers) {
112
- return { success: false, error: 'Invalid context' }
113
- }
114
-
115
- // 2. Check X-Test-User header (for test-app compatibility) FIRST
116
- let userId = headers.get('X-Test-User')
117
-
118
- // 3. If no test header, check x-dev-user-id header OR canopy-dev-user cookie
119
- if (!userId) {
120
- userId = headers.get('x-dev-user-id') ?? getDevUserCookieFromHeaders(headers)
121
- }
122
-
123
- // 4. Fall back to default user
124
- if (!userId) {
125
- userId = this.defaultUserId
126
- }
127
-
128
- // 5. Map test user keys to dev user IDs for test compatibility
129
- const userIdMapped = this.mapTestUserKey(userId)
130
-
131
- // 6. Find user in config
132
- const user = this.users.find((u) => u.userId === userIdMapped)
133
- if (!user) {
134
- return { success: false, error: `Dev user not found: ${userId}` }
135
- }
136
-
137
- // 7. Return AuthenticationResult with externalGroups
138
- return {
139
- success: true,
140
- user: {
141
- userId: user.userId,
142
- email: user.email,
143
- name: user.name,
144
- avatarUrl: user.avatarUrl,
145
- externalGroups: user.externalGroups,
146
- },
147
- }
148
- }
149
-
150
- /**
151
- * Map test-app user keys to dev user IDs for backward compatibility
152
- */
153
- private mapTestUserKey(key: string): CanopyUserId {
154
- const testUserMap: Record<string, CanopyUserId> = {
155
- admin: DEV_ADMIN_USER_ID, // admin1
156
- editor: 'dev_user1_2nK8mP4xL9', // user1
157
- viewer: 'dev_user2_7qR3tY6wN2', // user2
158
- reviewer: 'dev_reviewer_9aB4cD2eF7', // reviewer1
159
- }
160
- return testUserMap[key] ?? key
161
- }
162
-
163
- async searchUsers(query: string, limit?: number): Promise<UserSearchResult[]> {
164
- const lowerQuery = query.toLowerCase()
165
- const filtered = this.users.filter(
166
- (u) =>
167
- u.name.toLowerCase().includes(lowerQuery) || u.email.toLowerCase().includes(lowerQuery),
168
- )
169
-
170
- const results = filtered.slice(0, limit)
171
- return results.map((u) => ({
172
- id: u.userId,
173
- name: u.name,
174
- email: u.email,
175
- avatarUrl: u.avatarUrl,
176
- }))
177
- }
178
-
179
- async getUserMetadata(userId: CanopyUserId): Promise<UserSearchResult | null> {
180
- const user = this.users.find((u) => u.userId === userId)
181
- if (!user) return null
182
-
183
- return {
184
- id: user.userId,
185
- name: user.name,
186
- email: user.email,
187
- avatarUrl: user.avatarUrl,
188
- }
189
- }
190
-
191
- async getGroupMetadata(groupId: CanopyGroupId): Promise<GroupMetadata | null> {
192
- const group = this.groups.find((g) => g.id === groupId)
193
- if (!group) return null
194
-
195
- return {
196
- id: group.id,
197
- name: group.name,
198
- description: group.description,
199
- }
200
- }
201
-
202
- async listGroups(limit?: number): Promise<GroupMetadata[]> {
203
- const groups = limit ? this.groups.slice(0, limit) : this.groups
204
- return groups.map((g) => ({
205
- id: g.id,
206
- name: g.name,
207
- description: g.description,
208
- }))
209
- }
210
-
211
- async searchExternalGroups(query: string): Promise<Array<{ id: CanopyGroupId; name: string }>> {
212
- const lowerQuery = query.toLowerCase()
213
- return this.groups
214
- .filter((g) => g.name.toLowerCase().includes(lowerQuery))
215
- .map((g) => ({
216
- id: g.id,
217
- name: g.name,
218
- }))
219
- }
220
- }
221
-
222
- /**
223
- * Factory function for creating dev auth plugin.
224
- * By default, auto-sets CANOPY_BOOTSTRAP_ADMIN_IDS to the admin dev user
225
- * if the env var is not already set. Disable with { autoBootstrapAdmin: false }.
226
- */
227
- export function createDevAuthPlugin(config?: DevAuthConfig): AuthPlugin {
228
- const shouldAutoBootstrap = config?.autoBootstrapAdmin ?? true
229
- if (shouldAutoBootstrap && !process.env.CANOPY_BOOTSTRAP_ADMIN_IDS) {
230
- const users = config?.users ?? DEFAULT_USERS
231
- const adminUser = users.find((u) => u.userId === DEV_ADMIN_USER_ID)
232
- if (adminUser) {
233
- process.env.CANOPY_BOOTSTRAP_ADMIN_IDS = adminUser.userId
234
- console.info(
235
- `CanopyCMS dev-auth: Auto-configured ${adminUser.name} (${adminUser.userId}) as bootstrap admin. ` +
236
- `Set CANOPY_BOOTSTRAP_ADMIN_IDS env var or pass autoBootstrapAdmin: false to override.`,
237
- )
238
- }
239
- }
240
- return new DevAuthPlugin(config ?? {})
241
- }
package/src/index.ts DELETED
@@ -1,11 +0,0 @@
1
- export {
2
- createDevAuthPlugin,
3
- DevAuthPlugin,
4
- DEFAULT_USERS,
5
- DEFAULT_GROUPS,
6
- DEV_ADMIN_USER_ID,
7
- } from './dev-plugin'
8
- export type { DevAuthConfig, DevUser, DevGroup } from './dev-plugin'
9
- export { createDevTokenVerifier } from './jwt-verifier'
10
- export { refreshDevCache } from './cache-writer'
11
- export type { RefreshDevCacheOptions } from './cache-writer'
@@ -1,46 +0,0 @@
1
- import { extractHeaders } from 'canopycms/auth'
2
- import type { TokenVerifier } from 'canopycms/auth'
3
- import { getDevUserCookieFromHeaders, DEFAULT_USER_ID } from './cookie-utils'
4
- import { DEV_ADMIN_USER_ID } from './dev-plugin'
5
-
6
- /**
7
- * Test user key → dev user ID mapping.
8
- * Matches the mapping in DevAuthPlugin.mapTestUserKey().
9
- */
10
- const TEST_USER_MAP: Record<string, string> = {
11
- admin: DEV_ADMIN_USER_ID,
12
- editor: 'dev_user1_2nK8mP4xL9',
13
- viewer: 'dev_user2_7qR3tY6wN2',
14
- reviewer: 'dev_reviewer_9aB4cD2eF7',
15
- }
16
-
17
- /**
18
- * Creates a token verifier for dev auth.
19
- * Extracts userId from X-Test-User header, x-dev-user-id header,
20
- * or canopy-dev-user cookie — same logic as DevAuthPlugin.authenticate().
21
- *
22
- * Used with CachingAuthPlugin in prod-sim mode to simulate the prod
23
- * code path (token verification + cached metadata lookup) using dev users.
24
- */
25
- export function createDevTokenVerifier(options?: { defaultUserId?: string }): TokenVerifier {
26
- const defaultUserId = options?.defaultUserId ?? DEFAULT_USER_ID
27
-
28
- return async (context: unknown) => {
29
- const headers = extractHeaders(context)
30
- if (!headers) return null
31
-
32
- // Same extraction logic as DevAuthPlugin.authenticate()
33
- let userId = headers.get('X-Test-User')
34
- if (!userId) {
35
- userId = headers.get('x-dev-user-id') ?? getDevUserCookieFromHeaders(headers)
36
- }
37
- if (!userId) {
38
- userId = defaultUserId
39
- }
40
-
41
- // Map test user keys to dev user IDs
42
- const mapped = TEST_USER_MAP[userId] ?? userId
43
-
44
- return { userId: mapped }
45
- }
46
- }