kilo-cms 0.1.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.
Files changed (179) hide show
  1. package/README.md +186 -0
  2. package/drizzle.kilo.config.ts +17 -0
  3. package/package.json +97 -0
  4. package/src/admin/AiSettingsEditor.tsx +48 -0
  5. package/src/admin/AiWritingAssistant.tsx +159 -0
  6. package/src/admin/ApiTokensEditor.tsx +69 -0
  7. package/src/admin/ArrayField.tsx +118 -0
  8. package/src/admin/BlocksField.tsx +131 -0
  9. package/src/admin/Button.tsx +31 -0
  10. package/src/admin/CloseButton.tsx +18 -0
  11. package/src/admin/CmsLocalesProvider.tsx +19 -0
  12. package/src/admin/CmsSidebar.tsx +147 -0
  13. package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
  14. package/src/admin/CmsThemeProvider.tsx +100 -0
  15. package/src/admin/CollectionListEditor.tsx +278 -0
  16. package/src/admin/CollectionRecordEditor.tsx +420 -0
  17. package/src/admin/ContentEditorForm.tsx +21 -0
  18. package/src/admin/ContentViewSettings.tsx +55 -0
  19. package/src/admin/DashboardBuilder.tsx +284 -0
  20. package/src/admin/DragHandle.tsx +3 -0
  21. package/src/admin/DynamicField.tsx +517 -0
  22. package/src/admin/EditorHeader.tsx +13 -0
  23. package/src/admin/EditorSkeleton.tsx +27 -0
  24. package/src/admin/EmailSettingsEditor.tsx +80 -0
  25. package/src/admin/Field.tsx +215 -0
  26. package/src/admin/FilterBuilder.tsx +62 -0
  27. package/src/admin/GroupField.tsx +61 -0
  28. package/src/admin/ImageUploader.tsx +198 -0
  29. package/src/admin/ListingSettingsModal.tsx +68 -0
  30. package/src/admin/ListingView.tsx +123 -0
  31. package/src/admin/LocalesEditor.tsx +86 -0
  32. package/src/admin/LocalizedField.tsx +46 -0
  33. package/src/admin/MapPicker.tsx +53 -0
  34. package/src/admin/MediaLibrary.tsx +483 -0
  35. package/src/admin/MediaUploader.tsx +55 -0
  36. package/src/admin/Portal.tsx +10 -0
  37. package/src/admin/PublishingPanel.tsx +102 -0
  38. package/src/admin/RecordMeta.tsx +24 -0
  39. package/src/admin/ReviewQueueEditor.tsx +103 -0
  40. package/src/admin/RichTextEditor.tsx +49 -0
  41. package/src/admin/RoleEditor.tsx +121 -0
  42. package/src/admin/RolesEditor.tsx +42 -0
  43. package/src/admin/RowActionsMenu.tsx +64 -0
  44. package/src/admin/ScheduledEditor.tsx +155 -0
  45. package/src/admin/SeoSettingsEditor.tsx +53 -0
  46. package/src/admin/SingleRecordEditor.tsx +215 -0
  47. package/src/admin/SiteSettingsEditor.tsx +96 -0
  48. package/src/admin/Toast.tsx +45 -0
  49. package/src/admin/UserEditor.tsx +64 -0
  50. package/src/admin/UsersEditor.tsx +37 -0
  51. package/src/admin/WebhooksEditor.tsx +115 -0
  52. package/src/admin/admin.css +6637 -0
  53. package/src/admin/globals.css +14 -0
  54. package/src/admin/pages/admin-index.tsx +5 -0
  55. package/src/admin/pages/ai-settings.tsx +9 -0
  56. package/src/admin/pages/api-tokens.tsx +10 -0
  57. package/src/admin/pages/collection-list.tsx +22 -0
  58. package/src/admin/pages/collection-record.tsx +31 -0
  59. package/src/admin/pages/dashboard.tsx +10 -0
  60. package/src/admin/pages/email-settings.tsx +10 -0
  61. package/src/admin/pages/layout.tsx +24 -0
  62. package/src/admin/pages/locales-settings.tsx +10 -0
  63. package/src/admin/pages/login.tsx +25 -0
  64. package/src/admin/pages/media.tsx +9 -0
  65. package/src/admin/pages/review-queue.tsx +20 -0
  66. package/src/admin/pages/role-edit.tsx +11 -0
  67. package/src/admin/pages/roles.tsx +10 -0
  68. package/src/admin/pages/scheduled.tsx +17 -0
  69. package/src/admin/pages/seo-settings.tsx +9 -0
  70. package/src/admin/pages/setup-admin.tsx +76 -0
  71. package/src/admin/pages/single-record.tsx +24 -0
  72. package/src/admin/pages/site-settings.tsx +9 -0
  73. package/src/admin/pages/user-edit.tsx +11 -0
  74. package/src/admin/pages/user-new.tsx +10 -0
  75. package/src/admin/pages/users.tsx +10 -0
  76. package/src/admin/pages/webhooks-settings.tsx +10 -0
  77. package/src/admin/useKeyedRows.ts +35 -0
  78. package/src/ai-config.ts +35 -0
  79. package/src/ai-secrets.ts +17 -0
  80. package/src/ai-types.ts +10 -0
  81. package/src/ai-writer.ts +84 -0
  82. package/src/api-tokens.ts +25 -0
  83. package/src/auth-client.ts +5 -0
  84. package/src/auth.ts +33 -0
  85. package/src/calendar-grid.ts +12 -0
  86. package/src/cli/index.mjs +149 -0
  87. package/src/cms-appearance.ts +55 -0
  88. package/src/collections/content-view.ts +69 -0
  89. package/src/collections/define.ts +17 -0
  90. package/src/collections/filters.ts +92 -0
  91. package/src/collections/index.ts +11 -0
  92. package/src/collections/join.ts +64 -0
  93. package/src/collections/locale.ts +22 -0
  94. package/src/collections/query.ts +160 -0
  95. package/src/collections/registry.ts +88 -0
  96. package/src/collections/schedule.ts +12 -0
  97. package/src/collections/server.ts +12 -0
  98. package/src/collections/single.ts +26 -0
  99. package/src/collections/table.ts +14 -0
  100. package/src/collections/types.ts +336 -0
  101. package/src/collections/validation.ts +217 -0
  102. package/src/collections/values.ts +181 -0
  103. package/src/collections/workflow.ts +90 -0
  104. package/src/config.ts +34 -0
  105. package/src/content-types.ts +218 -0
  106. package/src/dashboard.ts +39 -0
  107. package/src/db.ts +50 -0
  108. package/src/email.ts +39 -0
  109. package/src/format.ts +4 -0
  110. package/src/listQuery.ts +23 -0
  111. package/src/locale-settings.ts +23 -0
  112. package/src/media-storage.ts +19 -0
  113. package/src/permissions.ts +37 -0
  114. package/src/publishing-workflow-db.ts +11 -0
  115. package/src/publishing-workflow.ts +20 -0
  116. package/src/richtext.ts +5 -0
  117. package/src/routes/admin/ai/settings/route.ts +20 -0
  118. package/src/routes/admin/ai/write/route.ts +21 -0
  119. package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
  120. package/src/routes/admin/api-tokens/route.ts +26 -0
  121. package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
  122. package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
  123. package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
  124. package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
  125. package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
  126. package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
  127. package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
  128. package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
  129. package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
  130. package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
  131. package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
  132. package/src/routes/admin/collections/[collection]/route.ts +88 -0
  133. package/src/routes/admin/collections/route.ts +13 -0
  134. package/src/routes/admin/content-views/[type]/route.ts +25 -0
  135. package/src/routes/admin/dashboard/data/route.ts +39 -0
  136. package/src/routes/admin/dashboard/route.ts +30 -0
  137. package/src/routes/admin/email-settings/route.ts +49 -0
  138. package/src/routes/admin/email-settings/test/route.ts +18 -0
  139. package/src/routes/admin/locales/[code]/route.ts +38 -0
  140. package/src/routes/admin/locales/reorder/route.ts +18 -0
  141. package/src/routes/admin/locales/route.ts +46 -0
  142. package/src/routes/admin/media/[id]/restore/route.ts +13 -0
  143. package/src/routes/admin/media/[id]/route.ts +56 -0
  144. package/src/routes/admin/media/bulk/route.ts +46 -0
  145. package/src/routes/admin/media/folders/[id]/route.ts +34 -0
  146. package/src/routes/admin/media/route.ts +41 -0
  147. package/src/routes/admin/media/stats/route.ts +14 -0
  148. package/src/routes/admin/roles/[id]/route.ts +43 -0
  149. package/src/routes/admin/roles/route.ts +34 -0
  150. package/src/routes/admin/settings/route.ts +45 -0
  151. package/src/routes/admin/single/[type]/route.ts +54 -0
  152. package/src/routes/admin/singles/route.ts +15 -0
  153. package/src/routes/admin/upload/route.ts +55 -0
  154. package/src/routes/admin/users/[id]/route.ts +24 -0
  155. package/src/routes/admin/users/route.ts +24 -0
  156. package/src/routes/admin/webhooks/[id]/route.ts +38 -0
  157. package/src/routes/admin/webhooks/route.ts +26 -0
  158. package/src/routes/cron/publish-scheduled.ts +37 -0
  159. package/src/routes/setup/admin.ts +19 -0
  160. package/src/schema/admin-ui.ts +18 -0
  161. package/src/schema/auth.ts +49 -0
  162. package/src/schema/developer-tools.ts +44 -0
  163. package/src/schema/helpers.ts +35 -0
  164. package/src/schema/index.ts +33 -0
  165. package/src/schema/locales.ts +13 -0
  166. package/src/schema/media.ts +22 -0
  167. package/src/schema/rbac.ts +11 -0
  168. package/src/schema/settings.ts +28 -0
  169. package/src/secrets.ts +26 -0
  170. package/src/seo.ts +69 -0
  171. package/src/storage/index.ts +20 -0
  172. package/src/storage/local.ts +27 -0
  173. package/src/storage/s3-compatible.ts +31 -0
  174. package/src/storage/types.ts +8 -0
  175. package/src/storage/vercel-blob.ts +17 -0
  176. package/src/useBodyScrollLock.ts +12 -0
  177. package/src/useLocalStorageState.ts +26 -0
  178. package/src/webhook-events.ts +12 -0
  179. package/src/webhooks.ts +30 -0
@@ -0,0 +1,14 @@
1
+ import { isNotNull, isNull, sql } from 'drizzle-orm'
2
+ import { NextResponse } from 'next/server'
3
+ import { db } from '../../../../db'
4
+ import { media } from '../../../../schema'
5
+ import { getSessionWithPermissions } from '../../../../permissions'
6
+
7
+ export async function GET() {
8
+ if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
9
+ const [[active], [trash]] = await Promise.all([
10
+ db.select({ count: sql<number>`count(*)`, bytes: sql<number>`coalesce(sum(${media.size}), 0)` }).from(media).where(isNull(media.deletedAt)),
11
+ db.select({ count: sql<number>`count(*)` }).from(media).where(isNotNull(media.deletedAt)),
12
+ ])
13
+ return NextResponse.json({ count: Number(active?.count ?? 0), bytes: Number(active?.bytes ?? 0), trashCount: Number(trash?.count ?? 0) })
14
+ }
@@ -0,0 +1,43 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { NextResponse } from 'next/server'
3
+ import { z } from 'zod'
4
+ import { db } from '../../../../db'
5
+ import { roles } from '../../../../schema'
6
+ import { requireSystem } from '../../../../permissions'
7
+ import { contentTypeKeys, systemPermissions } from '../../../../content-types'
8
+
9
+ const contentTypePermissionSchema = z.object({ actions: z.array(z.enum(['create', 'read', 'update', 'delete', 'publish', 'submit_review', 'review', 'approve'])), fields: z.array(z.string().max(60)) })
10
+ const permissionsSchema = z.object({
11
+ contentTypes: z.record(z.string(), contentTypePermissionSchema).default({}).refine((value) => Object.keys(value).every((key) => (contentTypeKeys as string[]).includes(key)), 'Unknown content type.'),
12
+ system: z.array(z.enum(systemPermissions)).default([]),
13
+ })
14
+ const updateSchema = z.object({ name: z.string().trim().min(1).max(80), permissions: permissionsSchema })
15
+
16
+ export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
17
+ if (!await requireSystem('roles.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
18
+ const { id } = await params
19
+ const [role] = await db.select().from(roles).where(eq(roles.id, id)).limit(1)
20
+ return role ? NextResponse.json(role) : NextResponse.json({ error: 'Not found' }, { status: 404 })
21
+ }
22
+
23
+ export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
24
+ if (!await requireSystem('roles.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
25
+ const { id } = await params
26
+ const [existing] = await db.select({ isSystem: roles.isSystem }).from(roles).where(eq(roles.id, id)).limit(1)
27
+ if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
28
+ if (existing.isSystem) return NextResponse.json({ error: 'The Administrator role cannot be changed.' }, { status: 403 })
29
+ const parsed = updateSchema.safeParse(await request.json())
30
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((issue) => `${issue.path.join(' ')}: ${issue.message}`).join(' · ') }, { status: 400 })
31
+ const [role] = await db.update(roles).set({ name: parsed.data.name, permissions: parsed.data.permissions, updatedAt: new Date() }).where(eq(roles.id, id)).returning()
32
+ return NextResponse.json(role)
33
+ }
34
+
35
+ export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
36
+ if (!await requireSystem('roles.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
37
+ const { id } = await params
38
+ const [existing] = await db.select({ isSystem: roles.isSystem }).from(roles).where(eq(roles.id, id)).limit(1)
39
+ if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
40
+ if (existing.isSystem) return NextResponse.json({ error: 'The Administrator role cannot be deleted.' }, { status: 403 })
41
+ await db.delete(roles).where(eq(roles.id, id))
42
+ return NextResponse.json({ ok: true })
43
+ }
@@ -0,0 +1,34 @@
1
+ import { asc, sql } from 'drizzle-orm'
2
+ import { NextResponse } from 'next/server'
3
+ import { z } from 'zod'
4
+ import { db } from '../../../db'
5
+ import { roles, user } from '../../../schema'
6
+ import { requireSystem } from '../../../permissions'
7
+ import { contentTypeKeys, systemPermissions } from '../../../content-types'
8
+
9
+ export async function GET() {
10
+ if (!await requireSystem('roles.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
11
+ const [roleRows, userCounts] = await Promise.all([
12
+ db.select().from(roles).orderBy(asc(roles.name)),
13
+ db.select({ role: user.role, count: sql<number>`count(*)::int` }).from(user).groupBy(user.role),
14
+ ])
15
+ const counts = new Map(userCounts.map((row: any) => [row.role, row.count]))
16
+ return NextResponse.json(roleRows.map((role: any) => ({ ...role, userCount: counts.get(role.id) ?? 0 })))
17
+ }
18
+
19
+ const contentTypePermissionSchema = z.object({ actions: z.array(z.enum(['create', 'read', 'update', 'delete', 'publish', 'submit_review', 'review', 'approve'])), fields: z.array(z.string().max(60)) })
20
+ const permissionsSchema = z.object({
21
+ contentTypes: z.record(z.string(), contentTypePermissionSchema).default({}).refine((value) => Object.keys(value).every((key) => (contentTypeKeys as string[]).includes(key)), 'Unknown content type.'),
22
+ system: z.array(z.enum(systemPermissions)).default([]),
23
+ })
24
+ const createSchema = z.object({ id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Use lowercase letters, numbers, and hyphens only').max(60), name: z.string().trim().min(1).max(80), permissions: permissionsSchema.default({ contentTypes: {}, system: [] }) })
25
+
26
+ export async function POST(request: Request) {
27
+ if (!await requireSystem('roles.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
28
+ const parsed = createSchema.safeParse(await request.json())
29
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((issue) => `${issue.path.join(' ')}: ${issue.message}`).join(' · ') }, { status: 400 })
30
+ try {
31
+ const [role] = await db.insert(roles).values({ id: parsed.data.id, name: parsed.data.name, permissions: parsed.data.permissions, isSystem: false }).returning()
32
+ return NextResponse.json(role, { status: 201 })
33
+ } catch { return NextResponse.json({ error: 'A role with that ID already exists.' }, { status: 409 }) }
34
+ }
@@ -0,0 +1,45 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { eq } from 'drizzle-orm'
3
+ import { z } from 'zod'
4
+ import { db } from '../../../db'
5
+ import { siteSettings } from '../../../schema'
6
+ import { defaultSiteSettings } from '../../../seo'
7
+ import { requireAction } from '../../../permissions'
8
+ import { applyFieldPermissions } from '../../../content-types'
9
+ import { defaultCmsAppearance, normalizeCmsAppearance } from '../../../cms-appearance'
10
+ import { allApprovalContentTypes, defaultPublishingWorkflow, normalizePublishingWorkflow } from '../../../publishing-workflow'
11
+
12
+ const optionalImage = z.union([z.string().url(), z.string().regex(/^\/uploads\/(?:[a-z0-9-]+\/)?[a-z0-9-]+\.(jpg|png|webp)$/), z.literal(''), z.null()]).transform((value) => value || null)
13
+ const tokenMap = z.record(z.string(), z.union([z.string().regex(/^#[0-9a-fA-F]{6}$/), z.number().int().min(0).max(64)])).default({})
14
+ const appearanceSchema = z.object({ preset: z.string().max(40), light: tokenMap, dark: tokenMap }).transform(normalizeCmsAppearance)
15
+ const workflowSchema = z.object({ approvalRequired: z.array(z.enum(allApprovalContentTypes as [string, ...string[]])).default([]) }).transform(normalizePublishingWorkflow)
16
+ const settingsSchema = z.object({
17
+ siteName: z.string().trim().min(1, 'Site name is required').max(80),
18
+ logoLight: optionalImage,
19
+ logoDark: optionalImage,
20
+ metaTitle: z.string().max(160).default(''),
21
+ metaDescription: z.string().max(300).default(''),
22
+ metaImage: optionalImage,
23
+ cmsAppearance: appearanceSchema.default(defaultCmsAppearance),
24
+ publishingWorkflow: workflowSchema.default(defaultPublishingWorkflow),
25
+ })
26
+
27
+ export async function GET() {
28
+ if (!await requireAction('settings', 'read')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
29
+ const [settings] = await db.select().from(siteSettings).where(eq(siteSettings.id, 'default')).limit(1)
30
+ return NextResponse.json(settings || defaultSiteSettings)
31
+ }
32
+
33
+ export async function PUT(request: Request) {
34
+ const result = await requireAction('settings', 'update')
35
+ if (!result) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
36
+ const body = await request.json()
37
+ const parsed = settingsSchema.safeParse(body)
38
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((issue) => `${issue.path.join(' ')}: ${issue.message}`).join(' · ') }, { status: 400 })
39
+
40
+ const [existing] = await db.select().from(siteSettings).where(eq(siteSettings.id, 'default')).limit(1)
41
+ const submitted = applyFieldPermissions('settings', parsed.data, result.allowedFields('settings'), existing ?? defaultSiteSettings)
42
+ const data = { ...submitted, updatedAt: new Date() }
43
+ const [settings] = await db.insert(siteSettings).values({ id: 'default', ...data }).onConflictDoUpdate({ target: siteSettings.id, set: data }).returning()
44
+ return NextResponse.json(settings)
45
+ }
@@ -0,0 +1,54 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { requireAction } from '../../../../permissions'
3
+ import { applyFieldPermissions, blankFieldValues, redactFieldsForRead } from '../../../../content-types'
4
+ import { getSingle } from '../../../../collections/registry'
5
+ import { findInvalidRelation, findRowBy, isUniqueViolation } from '../../../../collections/query'
6
+ import { resolveJoinFields } from '../../../../collections/join'
7
+ import { readSingle, writeSingle } from '../../../../collections/single'
8
+ import { parseCollectionBody, type ParseMode } from '../../../../collections/validation'
9
+ import { fireWebhook, updateWorkflowValues } from '../../../../collections/workflow'
10
+ import { getLocaleConfig } from '../../../../locale-settings'
11
+
12
+ const unauthorized = () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
13
+ const notFound = () => NextResponse.json({ error: 'Not found' }, { status: 404 })
14
+
15
+ export async function GET(_request: Request, { params }: { params: Promise<{ type: string }> }) {
16
+ const { type } = await params
17
+ const config = getSingle(type)
18
+ if (!config) return notFound()
19
+ const result = await requireAction(config.slug, 'read')
20
+ if (!result) return unauthorized()
21
+ const row = await resolveJoinFields(config, await readSingle(config))
22
+ return NextResponse.json(redactFieldsForRead(config.slug, row, result.allowedFields(config.slug)))
23
+ }
24
+
25
+ export async function PUT(request: Request, { params }: { params: Promise<{ type: string }> }) {
26
+ const { type } = await params
27
+ const config = getSingle(type)
28
+ if (!config) return notFound()
29
+ const result = await requireAction(config.slug, 'update')
30
+ if (!result) return unauthorized()
31
+
32
+ const body = await request.json()
33
+ const wantsPublished = config.workflow?.publish ? Boolean(body.published) : null
34
+ if (wantsPublished && !result.can(config.slug, 'publish')) return unauthorized()
35
+
36
+ const mode: ParseMode = config.workflow?.publish ? (wantsPublished ? 'live' : 'draft') : 'live'
37
+ const parsed = parseCollectionBody(config, body, mode, result.allowedFields(config.slug), await getLocaleConfig())
38
+ if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 })
39
+
40
+ const existing = await findRowBy(config.slug, config.fixedKey.column, config.fixedKey.value)
41
+ const data = applyFieldPermissions(config.slug, parsed.data, result.allowedFields(config.slug), existing ?? blankFieldValues[config.slug])
42
+
43
+ const relationError = await findInvalidRelation(config, data)
44
+ if (relationError) return NextResponse.json({ error: relationError }, { status: 400 })
45
+
46
+ try {
47
+ const row = await writeSingle(config, data, updateWorkflowValues(config, result.session.user.id, wantsPublished))
48
+ fireWebhook(config, existing ? 'updated' : 'created', row)
49
+ return NextResponse.json(row)
50
+ } catch (error) {
51
+ if (isUniqueViolation(error)) return NextResponse.json({ error: 'A record with one of those unique values already exists.' }, { status: 409 })
52
+ throw error
53
+ }
54
+ }
@@ -0,0 +1,15 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { getSessionWithPermissions } from '../../../permissions'
3
+ import { getSingle, singleSlugs } from '../../../collections/registry'
4
+ import type { SingleConfig } from '../../../collections/types'
5
+
6
+ export async function GET() {
7
+ const result = await getSessionWithPermissions()
8
+ if (!result) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
9
+ const visible = (singleSlugs as string[])
10
+ .map((slug) => getSingle(slug))
11
+ .filter((config): config is SingleConfig => Boolean(config))
12
+ .filter((config) => result.can(config.slug, 'read'))
13
+ .map((config) => ({ slug: config.slug, label: config.label }))
14
+ return NextResponse.json(visible)
15
+ }
@@ -0,0 +1,55 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { eq } from 'drizzle-orm'
3
+ import { db } from '../../../db'
4
+ import { media, mediaFolders } from '../../../schema'
5
+ import { getSessionWithPermissions } from '../../../permissions'
6
+ import { uploadFile } from '../../../media-storage'
7
+
8
+ export const runtime = 'nodejs'
9
+
10
+ const extensions: Record<string, string> = {
11
+ 'image/jpeg': 'jpg',
12
+ 'image/png': 'png',
13
+ 'image/webp': 'webp',
14
+ 'image/gif': 'gif',
15
+ 'image/svg+xml': 'svg',
16
+ 'video/mp4': 'mp4',
17
+ 'video/webm': 'webm',
18
+ 'video/quicktime': 'mov',
19
+ 'audio/mpeg': 'mp3',
20
+ 'audio/wav': 'wav',
21
+ 'audio/ogg': 'ogg',
22
+ 'application/pdf': 'pdf',
23
+ }
24
+
25
+ // Per-category ceilings — video/audio need far more headroom than a compressed image.
26
+ const maxBytesByPrefix: Array<[string, number]> = [
27
+ ['image/', 8 * 1024 * 1024],
28
+ ['video/', 80 * 1024 * 1024],
29
+ ['audio/', 25 * 1024 * 1024],
30
+ ]
31
+ const defaultMaxBytes = 20 * 1024 * 1024 // pdf and anything else in the allowlist
32
+
33
+ function maxBytesFor(mimeType: string) {
34
+ return maxBytesByPrefix.find(([prefix]) => mimeType.startsWith(prefix))?.[1] ?? defaultMaxBytes
35
+ }
36
+
37
+ export async function POST(request: Request) {
38
+ if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
39
+
40
+ const formData = await request.formData()
41
+ const file = formData.get('file')
42
+ const requestedFolder = String(formData.get('folder') || 'uploads')
43
+ if (!file || typeof file === 'string' || !('arrayBuffer' in file) || !('type' in file) || !(file.type in extensions)) return NextResponse.json({ error: 'Unsupported file type.' }, { status: 400 })
44
+ const maxBytes = maxBytesFor(file.type)
45
+ if (file.size > maxBytes) return NextResponse.json({ error: `File must be smaller than ${Math.round(maxBytes / (1024 * 1024))} MB.` }, { status: 400 })
46
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(requestedFolder)) return NextResponse.json({ error: 'Invalid media folder.' }, { status: 400 })
47
+ if (requestedFolder !== 'uploads') { const [folder] = await db.select().from(mediaFolders).where(eq(mediaFolders.slug, requestedFolder)).limit(1); if (!folder) return NextResponse.json({ error: 'Media folder not found.' }, { status: 400 }) }
48
+
49
+ // Storage is flat — `folder` is pure metadata for organizing the media library, never part
50
+ // of the file's key, so moving a file between folders later can never break its URL.
51
+ const { url } = await uploadFile(Buffer.from(await file.arrayBuffer()), file.type, extensions[file.type])
52
+ const filename = url.split('/').pop() ?? url
53
+ await db.insert(media).values({ id: crypto.randomUUID(), filename, url, folder: requestedFolder, mimeType: file.type, size: file.size })
54
+ return NextResponse.json({ url })
55
+ }
@@ -0,0 +1,24 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { NextResponse } from 'next/server'
3
+ import { z } from 'zod'
4
+ import { db } from '../../../../db'
5
+ import { user } from '../../../../schema'
6
+ import { requireSystem } from '../../../../permissions'
7
+
8
+ export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
9
+ if (!await requireSystem('users.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
10
+ const { id } = await params
11
+ const [row] = await db.select({ id: user.id, name: user.name, email: user.email, role: user.role }).from(user).where(eq(user.id, id)).limit(1)
12
+ return row ? NextResponse.json(row) : NextResponse.json({ error: 'Not found' }, { status: 404 })
13
+ }
14
+
15
+ const schema = z.object({ name: z.string().trim().min(1).max(120).optional(), email: z.string().trim().email().optional(), role: z.string().min(1).max(60).optional() })
16
+
17
+ export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
18
+ if (!await requireSystem('users.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
19
+ const { id } = await params
20
+ const parsed = schema.safeParse(await request.json())
21
+ if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
22
+ const [row] = await db.update(user).set(parsed.data).where(eq(user.id, id)).returning({ id: user.id, name: user.name, email: user.email, role: user.role })
23
+ return row ? NextResponse.json(row) : NextResponse.json({ error: 'Not found' }, { status: 404 })
24
+ }
@@ -0,0 +1,24 @@
1
+ import { asc, eq } from 'drizzle-orm'
2
+ import { NextResponse } from 'next/server'
3
+ import { z } from 'zod'
4
+ import { auth, db } from '../../../db'
5
+ import { user } from '../../../schema'
6
+ import { getSessionWithPermissions, requireSystem } from '../../../permissions'
7
+
8
+ export async function GET() {
9
+ if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
10
+ const rows = await db.select({ id: user.id, name: user.name, email: user.email, role: user.role }).from(user).orderBy(asc(user.name))
11
+ return NextResponse.json(rows)
12
+ }
13
+
14
+ const createSchema = z.object({ name: z.string().trim().min(1).max(120), email: z.string().trim().email(), password: z.string().min(12).max(200), role: z.string().min(1).max(60) })
15
+
16
+ export async function POST(request: Request) {
17
+ if (!await requireSystem('users.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
18
+ const parsed = createSchema.safeParse(await request.json())
19
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((issue) => `${issue.path.join(' ')}: ${issue.message}`).join(' · ') }, { status: 400 })
20
+ const result = await auth.api.signUpEmail({ body: { name: parsed.data.name, email: parsed.data.email, password: parsed.data.password } })
21
+ if (!result.user) return NextResponse.json({ error: 'Could not create user — email may already be in use.' }, { status: 400 })
22
+ await db.update(user).set({ role: parsed.data.role }).where(eq(user.id, result.user.id))
23
+ return NextResponse.json({ id: result.user.id, name: parsed.data.name, email: parsed.data.email, role: parsed.data.role }, { status: 201 })
24
+ }
@@ -0,0 +1,38 @@
1
+ import { desc, eq } from 'drizzle-orm'
2
+ import { NextResponse } from 'next/server'
3
+ import { z } from 'zod'
4
+ import { db } from '../../../../db'
5
+ import { webhookDeliveries, webhooks } from '../../../../schema'
6
+ import { requireSystem } from '../../../../permissions'
7
+ import { decryptSecret } from '../../../../secrets'
8
+ import { allWebhookEvents, WEBHOOK_SECRET_DOMAIN } from '../../../../webhooks'
9
+
10
+ export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
11
+ if (!await requireSystem('developers.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
12
+ const { id } = await params
13
+ const [webhook] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1)
14
+ if (!webhook) return NextResponse.json({ error: 'Not found' }, { status: 404 })
15
+ const deliveries = await db.select().from(webhookDeliveries).where(eq(webhookDeliveries.webhookId, id)).orderBy(desc(webhookDeliveries.createdAt)).limit(20)
16
+ let secret = ''
17
+ try { secret = decryptSecret(webhook.encryptedSecret, WEBHOOK_SECRET_DOMAIN) } catch { /* stored before secrets existed, or corrupted — leave blank */ }
18
+ return NextResponse.json({ id: webhook.id, name: webhook.name, url: webhook.url, events: webhook.events, enabled: webhook.enabled, secret, deliveries })
19
+ }
20
+
21
+ const schema = z.object({ name: z.string().trim().min(1).max(80), url: z.string().url(), events: z.array(z.enum(allWebhookEvents as [string, ...string[]])).max(allWebhookEvents.length), enabled: z.boolean() })
22
+
23
+ export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
24
+ if (!await requireSystem('developers.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
25
+ const { id } = await params
26
+ const parsed = schema.safeParse(await request.json())
27
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((issue) => `${issue.path.join(' ')}: ${issue.message}`).join(' · ') }, { status: 400 })
28
+ const [webhook] = await db.update(webhooks).set({ name: parsed.data.name, url: parsed.data.url, events: parsed.data.events, enabled: parsed.data.enabled, updatedAt: new Date() }).where(eq(webhooks.id, id)).returning()
29
+ if (!webhook) return NextResponse.json({ error: 'Not found' }, { status: 404 })
30
+ return NextResponse.json({ id: webhook.id, name: webhook.name, url: webhook.url, events: webhook.events, enabled: webhook.enabled })
31
+ }
32
+
33
+ export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
34
+ if (!await requireSystem('developers.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
35
+ const { id } = await params
36
+ await db.delete(webhooks).where(eq(webhooks.id, id))
37
+ return NextResponse.json({ ok: true })
38
+ }
@@ -0,0 +1,26 @@
1
+ import { desc } from 'drizzle-orm'
2
+ import { NextResponse } from 'next/server'
3
+ import { z } from 'zod'
4
+ import { randomBytes } from 'node:crypto'
5
+ import { db } from '../../../db'
6
+ import { webhooks } from '../../../schema'
7
+ import { requireSystem } from '../../../permissions'
8
+ import { encryptSecret } from '../../../secrets'
9
+ import { allWebhookEvents, WEBHOOK_SECRET_DOMAIN } from '../../../webhooks'
10
+
11
+ export async function GET() {
12
+ if (!await requireSystem('developers.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
13
+ const rows = await db.select({ id: webhooks.id, name: webhooks.name, url: webhooks.url, events: webhooks.events, enabled: webhooks.enabled, createdAt: webhooks.createdAt }).from(webhooks).orderBy(desc(webhooks.createdAt))
14
+ return NextResponse.json(rows)
15
+ }
16
+
17
+ const schema = z.object({ name: z.string().trim().min(1).max(80), url: z.string().url(), events: z.array(z.enum(allWebhookEvents as [string, ...string[]])).max(allWebhookEvents.length), enabled: z.boolean().default(true) })
18
+
19
+ export async function POST(request: Request) {
20
+ if (!await requireSystem('developers.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
21
+ const parsed = schema.safeParse(await request.json())
22
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((issue) => `${issue.path.join(' ')}: ${issue.message}`).join(' · ') }, { status: 400 })
23
+ const secret = randomBytes(24).toString('base64url')
24
+ const [webhook] = await db.insert(webhooks).values({ id: crypto.randomUUID(), name: parsed.data.name, url: parsed.data.url, events: parsed.data.events, enabled: parsed.data.enabled, encryptedSecret: encryptSecret(secret, WEBHOOK_SECRET_DOMAIN) }).returning()
25
+ return NextResponse.json({ id: webhook.id, name: webhook.name, url: webhook.url, events: webhook.events, enabled: webhook.enabled, secret }, { status: 201 })
26
+ }
@@ -0,0 +1,37 @@
1
+ import { and, eq, isNotNull, isNull, lte } from 'drizzle-orm'
2
+ import { NextResponse } from 'next/server'
3
+ import { entries, workflowSlugs } from '../../collections/registry'
4
+ import { collectionColumn, updateWhere } from '../../collections/query'
5
+
6
+ export const runtime = 'nodejs'
7
+
8
+ export async function GET(request: Request) {
9
+ const token = request.headers.get('authorization')?.replace('Bearer ', '')
10
+ if (!token || token !== process.env.CRON_SECRET) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
11
+
12
+ const now = new Date()
13
+ const byType: Array<{ slug: string; published: number; ids: string[] }> = []
14
+
15
+ for (const slug of workflowSlugs) {
16
+ const config = entries[slug]
17
+ if (!config.workflow?.scheduling) continue
18
+ const scheduledAt = collectionColumn(slug, 'scheduledAt')
19
+ const published = collectionColumn(slug, 'published')
20
+ if (!scheduledAt || !published) continue
21
+ const deletedAt = config.workflow.trash ? collectionColumn(slug, 'deletedAt') : null
22
+
23
+ const rows = await updateWhere(
24
+ slug,
25
+ and(eq(published, false), ...(deletedAt ? [isNull(deletedAt)] : []), isNotNull(scheduledAt), lte(scheduledAt, now))!,
26
+ { published: true, publishedAt: now, scheduledAt: null },
27
+ )
28
+ byType.push({ slug, published: rows.length, ids: rows.map((row) => String(row.id)) })
29
+ }
30
+
31
+ return NextResponse.json({
32
+ ok: true,
33
+ published: byType.reduce((sum, item) => sum + item.published, 0),
34
+ ids: byType.flatMap((item) => item.ids),
35
+ byType,
36
+ })
37
+ }
@@ -0,0 +1,19 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { eq } from 'drizzle-orm'
3
+ import { auth, db } from '../../db'
4
+ import { user } from '../../schema'
5
+
6
+ export async function POST(request: Request) {
7
+ const token = request.headers.get('authorization')?.replace('Bearer ', '')
8
+ if (!token || token !== process.env.CMS_SETUP_TOKEN) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
9
+
10
+ const body = await request.json() as { name?: string; email?: string; password?: string }
11
+ if (!body.name || !body.email || !body.password || body.password.length < 12) {
12
+ return NextResponse.json({ error: 'name, email, and a password of at least 12 characters are required' }, { status: 400 })
13
+ }
14
+
15
+ const result = await auth.api.signUpEmail({ body: { name: body.name, email: body.email, password: body.password } })
16
+ if (!result.user) return NextResponse.json({ error: 'Could not create user' }, { status: 400 })
17
+ await db.update(user).set({ role: 'admin' }).where(eq(user.id, result.user.id))
18
+ return NextResponse.json({ ok: true, userId: result.user.id })
19
+ }
@@ -0,0 +1,18 @@
1
+ import { jsonb, pgTable, text } from 'drizzle-orm/pg-core'
2
+ import { timestamps } from './helpers'
3
+ import type { ContentViewSettings } from '../collections/content-view'
4
+ import type { DashboardConfig } from '../dashboard'
5
+
6
+ export const contentViews = pgTable('cms_content_views', {
7
+ contentType: text('content_type').primaryKey(),
8
+ config: jsonb('config').$type<ContentViewSettings>().notNull(),
9
+ ...timestamps,
10
+ })
11
+
12
+ export const dashboards = pgTable('cms_dashboards', {
13
+ id: text('id').primaryKey(),
14
+ name: text('name').notNull().default('Main dashboard'),
15
+ config: jsonb('config').$type<DashboardConfig>().notNull(),
16
+ createdBy: text('created_by'),
17
+ ...timestamps,
18
+ })
@@ -0,0 +1,49 @@
1
+ import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
2
+ import { timestamps } from './helpers'
3
+
4
+ // Better Auth tables (user/session/account/verification) — column shapes are dictated by the
5
+ // auth library, not by this project's conventions. Keep them together; they only ever change
6
+ // together when the auth adapter changes.
7
+
8
+ export const user = pgTable('user', {
9
+ id: text('id').primaryKey(),
10
+ name: text('name').notNull(),
11
+ email: text('email').notNull().unique(),
12
+ emailVerified: boolean('email_verified').default(false).notNull(),
13
+ image: text('image'),
14
+ role: text('role').default('editor').notNull(),
15
+ ...timestamps,
16
+ })
17
+
18
+ export const session = pgTable('session', {
19
+ id: text('id').primaryKey(),
20
+ expiresAt: timestamp('expires_at').notNull(),
21
+ token: text('token').notNull().unique(),
22
+ ipAddress: text('ip_address'),
23
+ userAgent: text('user_agent'),
24
+ userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
25
+ ...timestamps,
26
+ })
27
+
28
+ export const account = pgTable('account', {
29
+ id: text('id').primaryKey(),
30
+ accountId: text('account_id').notNull(),
31
+ providerId: text('provider_id').notNull(),
32
+ userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
33
+ accessToken: text('access_token'),
34
+ refreshToken: text('refresh_token'),
35
+ idToken: text('id_token'),
36
+ accessTokenExpiresAt: timestamp('access_token_expires_at'),
37
+ refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
38
+ scope: text('scope'),
39
+ password: text('password'),
40
+ ...timestamps,
41
+ })
42
+
43
+ export const verification = pgTable('verification', {
44
+ id: text('id').primaryKey(),
45
+ identifier: text('identifier').notNull(),
46
+ value: text('value').notNull(),
47
+ expiresAt: timestamp('expires_at').notNull(),
48
+ ...timestamps,
49
+ })
@@ -0,0 +1,44 @@
1
+ import { boolean, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
2
+ import { timestamps } from './helpers'
3
+
4
+ export const emailSettings = pgTable('cms_email_settings', {
5
+ id: text('id').primaryKey(),
6
+ host: text('host'),
7
+ port: integer('port'),
8
+ secure: boolean('secure').notNull().default(true),
9
+ username: text('username'),
10
+ encryptedPassword: text('encrypted_password'),
11
+ fromAddress: text('from_address'),
12
+ fromName: text('from_name'),
13
+ ...timestamps,
14
+ })
15
+
16
+ export const apiTokens = pgTable('cms_api_tokens', {
17
+ id: text('id').primaryKey(),
18
+ name: text('name').notNull(),
19
+ tokenHash: text('token_hash').notNull().unique(),
20
+ tokenPrefix: text('token_prefix').notNull(),
21
+ lastUsedAt: timestamp('last_used_at'),
22
+ createdBy: text('created_by'),
23
+ ...timestamps,
24
+ })
25
+
26
+ export const webhooks = pgTable('cms_webhooks', {
27
+ id: text('id').primaryKey(),
28
+ name: text('name').notNull(),
29
+ url: text('url').notNull(),
30
+ encryptedSecret: text('encrypted_secret').notNull(),
31
+ events: jsonb('events').$type<string[]>().notNull().default([]),
32
+ enabled: boolean('enabled').notNull().default(true),
33
+ ...timestamps,
34
+ })
35
+
36
+ export const webhookDeliveries = pgTable('cms_webhook_deliveries', {
37
+ id: text('id').primaryKey(),
38
+ webhookId: text('webhook_id').notNull().references(() => webhooks.id, { onDelete: 'cascade' }),
39
+ event: text('event').notNull(),
40
+ statusCode: integer('status_code'),
41
+ success: boolean('success').notNull(),
42
+ error: text('error'),
43
+ createdAt: timestamp('created_at').defaultNow().notNull(),
44
+ })
@@ -0,0 +1,35 @@
1
+ import { boolean, integer, text, timestamp } from 'drizzle-orm/pg-core'
2
+
3
+ export const timestamps = {
4
+ createdAt: timestamp('created_at').defaultNow().notNull(),
5
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
6
+ }
7
+
8
+ export const workflowColumns = {
9
+ createdBy: text('created_by'),
10
+ ownerId: text('owner_id'),
11
+ updatedBy: text('updated_by'),
12
+ reviewerId: text('reviewer_id'),
13
+ approverId: text('approver_id'),
14
+ reviewStatus: text('review_status').notNull().default('draft'),
15
+ reviewedAt: timestamp('reviewed_at'),
16
+ approvedAt: timestamp('approved_at'),
17
+ published: boolean('published').notNull().default(false),
18
+ publishedAt: timestamp('published_at'),
19
+ deletedAt: timestamp('deleted_at'),
20
+ sortOrder: integer('sort_order').notNull().default(0),
21
+ }
22
+
23
+ export const publishColumns = {
24
+ createdBy: text('created_by'),
25
+ ownerId: text('owner_id'),
26
+ updatedBy: text('updated_by'),
27
+ published: boolean('published').notNull().default(false),
28
+ publishedAt: timestamp('published_at'),
29
+ }
30
+
31
+ /** Engine-owned scheduling column. Spread into any table whose entry declares
32
+ * `workflow.scheduling`. Never declare `scheduledAt` as a FieldConfig. */
33
+ export const schedulingColumns = {
34
+ scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
35
+ }
@@ -0,0 +1,33 @@
1
+ // Barrel of kilo-cms's OWN tables only — auth, RBAC, media, settings, dev-tools, admin-ui,
2
+ // locales. A host app's own content tables (services/projects/whatever a project defines via
3
+ // defineCollectionTable) live in the HOST's own schema, never here. The host's own schema
4
+ // barrel re-exports this one (`export * from 'kilo-cms/schema'`) so drizzle-kit sees both sets
5
+ // of tables in one merged diff target — see apps/site/drizzle.app.config.ts's comment for why
6
+ // that matters.
7
+ export * from './helpers'
8
+ export * from './auth'
9
+ export * from './rbac'
10
+ export * from './media'
11
+ export * from './settings'
12
+ export * from './developer-tools'
13
+ export * from './admin-ui'
14
+ export * from './locales'
15
+
16
+ import { user, session, account, verification } from './auth'
17
+ import { roles } from './rbac'
18
+ import { mediaFolders, media } from './media'
19
+ import { siteSettings, aiSettings } from './settings'
20
+ import { emailSettings, apiTokens, webhooks, webhookDeliveries } from './developer-tools'
21
+ import { contentViews, dashboards } from './admin-ui'
22
+ import { cmsLocales } from './locales'
23
+
24
+ // The object better-auth's drizzle adapter needs, and the base every host schema barrel
25
+ // spreads into its own merged `schema` object.
26
+ export const schema = {
27
+ user, session, account, verification, roles,
28
+ mediaFolders, media,
29
+ siteSettings, aiSettings,
30
+ emailSettings, apiTokens, webhooks, webhookDeliveries,
31
+ contentViews, dashboards,
32
+ cmsLocales,
33
+ }