kilo-cms 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,13 +1,16 @@
1
1
  # Kilo CMS
2
2
 
3
- An installable, config-driven CMS engine for Next.js an admin panel, RBAC, a generic
4
- field-type engine, and an API, mounted into your own app. Define your content schema via
5
- config, not by hand-editing a registry. Modeled on how [Filament](https://filamentphp.com/)
6
- installs into a Laravel app.
3
+ A schema-driven, config-driven CMS engine, built specifically for Next.js. Instead of
4
+ generating code or hand-editing a central registry, you describe your content model as plain
5
+ config — fields, relations, validation, list views and Kilo CMS turns that into a working
6
+ admin panel, RBAC, and API, mounted directly inside your own app's routes.
7
7
 
8
8
  > **Status**: pre-1.0, proven end-to-end against one real production app
9
- > ([`the-usual-dev`](https://github.com/revaldyas/the-usual-dev)'s `apps/site`), not yet used
9
+ > ([`kilostudio.id`](https://github.com/kilostudio/kilostudio.id)'s `apps/site`), not yet used
10
10
  > by a second, independent project. API surface may still change.
11
+ >
12
+ > **Looking for collaborators.** The repo is currently private; reach out if you want in as a
13
+ > collaborator.
11
14
 
12
15
  ## What you get
13
16
 
@@ -112,27 +115,81 @@ export const auth = createKiloAuth({
112
115
  export * from 'kilo-cms/schema'
113
116
  export * from '../collections/projects/table'
114
117
 
115
- import { schema as kiloSchema } from 'kilo-cms/schema'
118
+ import { mergeSchema } from 'kilo-cms/schema'
116
119
  import { projects } from '../collections/projects/table'
117
- export const schema = { ...kiloSchema, projects }
120
+ export const schema = mergeSchema({ projects })
118
121
  ```
119
122
 
120
- Registration is tied to `db.ts`'s own first import (not a `register()`-hook-only approach) —
121
- Next.js's build-time static generation spawns separate worker processes, and a hook like
122
- `instrumentation.ts` isn't guaranteed to fire in every one of them before a page module
123
- evaluates. Every admin page/route stub should start with `import '@/lib/db'` before its
124
- re-export, for the same reason:
123
+ `mergeSchema({ ...yourTables })` is just `{ ...kiloSchema, ...yourTables }` it exists so
124
+ adding a content table only touches the object you pass in, never a second copy of `kiloSchema`
125
+ hand-spread somewhere else. The `export * from '../collections/projects/table'` line above it
126
+ still has to stay, though: drizzle-kit finds tables by scanning a schema file's *top-level
127
+ exports*, not by evaluating a merged object at its own config-load time. If you'd rather not
128
+ maintain that re-export line per collection either, point drizzle-kit's own `schema` option at
129
+ a glob instead — it accepts an array of paths/globs natively:
125
130
 
126
131
  ```ts
127
- // app/(admin)/admin/dashboard/page.tsx
128
- import '@/lib/db'
129
- export { default } from 'kilo-cms/admin/pages/dashboard'
132
+ // drizzle.config.ts
133
+ export default defineConfig({
134
+ schema: ['./src/lib/schema.ts', './src/collections/*/table.ts'],
135
+ // ...
136
+ })
130
137
  ```
131
138
 
139
+ That way `src/lib/schema.ts` only needs to exist for `export * from 'kilo-cms/schema'` and the
140
+ `mergeSchema(...)` runtime object — every host content table is picked up by the glob
141
+ automatically the moment its `table.ts` exists, with no per-collection edit to this file at all.
142
+
143
+ ### Registration: `instrumentation.ts`
144
+
145
+ Call `defineKiloConfig()` once, as early as possible, from Next.js's
146
+ [`instrumentation.ts`](https://nextjs.org/docs/app/guides/instrumentation) `register()` hook —
147
+ it runs once per server process, before any request is handled:
148
+
132
149
  ```ts
133
- // app/api/admin/dashboard/route.ts
134
- import '@/lib/db'
135
- export { GET, PUT } from 'kilo-cms/routes/admin/dashboard'
150
+ // instrumentation.ts
151
+ export async function register() {
152
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
153
+ await import('@/lib/db')
154
+ }
155
+ }
156
+ ```
157
+
158
+ This is enough for every *dynamic* route (every admin page/route, since none of them are
159
+ statically generated) — the process instrumentation ran in is the same one that later serves
160
+ those requests. It is **not** enough on its own for anything reachable from
161
+ `generateStaticParams` on your public-facing pages: Next's build-time static generation spawns
162
+ separate worker processes, and `instrumentation.ts` isn't guaranteed to have run in all of
163
+ them before a page module evaluates. For those pages, importing `@/lib/db` (directly, or
164
+ transitively through whatever data-fetching module they already import) is what actually
165
+ guarantees registration in that worker — `instrumentation.ts` is a convenience for the admin
166
+ side, not a replacement for that.
167
+
168
+ ### Middleware
169
+
170
+ Kilo CMS ships an Edge-safe, cookie-presence-only pre-filter that redirects unauthenticated
171
+ visitors away from `/admin/*` before a page even renders — a fast filter, not the real
172
+ security boundary (every admin page/route still validates the session and RBAC permissions
173
+ for real). Your own `middleware.ts` re-exports it and defines `matcher` itself (Next.js
174
+ statically analyzes `matcher` in the file it scans — it can't be re-exported through another
175
+ module):
176
+
177
+ ```ts
178
+ // middleware.ts
179
+ export { kiloAdminMiddleware as middleware } from 'kilo-cms/middleware'
180
+
181
+ export const config = {
182
+ matcher: ['/admin/:path*'],
183
+ }
184
+ ```
185
+
186
+ Admin pages use `requireAdminSession()` from `kilo-cms/admin/require-session` instead of
187
+ hand-rolling `getSessionWithPermissions()` + `redirect()`:
188
+
189
+ ```ts
190
+ import { requireAdminSession } from 'kilo-cms/admin/require-session'
191
+
192
+ const result = await requireAdminSession((r) => r.can('projects', 'read'), '/admin/dashboard')
136
193
  ```
137
194
 
138
195
  ## CLI
@@ -140,20 +197,52 @@ export { GET, PUT } from 'kilo-cms/routes/admin/dashboard'
140
197
  Run from your own app's directory:
141
198
 
142
199
  ```sh
143
- npx kilo-cms add-collection <slug> # scaffold a new collection's fields.ts + table.ts
144
- npx kilo-cms sync # regenerate .kilo/types.gen.ts from src/collections/*
145
- npx kilo-cms migrate # apply Kilo CMS's own package-owned migrations
200
+ npx kilo-cms add-collection <slug> [--fields "..."] # scaffold a new collection's fields.ts + table.ts
201
+ npx kilo-cms sync # regenerate .kilo/types.gen.ts from src/collections/*
202
+ npx kilo-cms migrate # apply Kilo CMS's own package-owned migrations
203
+ npx kilo-cms init # fill in missing .env secrets, migrate, create the first admin user
146
204
  ```
147
205
 
148
206
  `add-collection` scaffolds the two files and prints the exact lines to paste into
149
207
  `kilo.config.ts` — deliberately not an auto-codemod (a script that edits your config file
150
- wrong is worse than a 10-second copy-paste).
208
+ wrong is worse than a 10-second copy-paste). Without `--fields`, you get the same
209
+ title/slug/sortOrder starter as before. With `--fields`, it builds exactly the fields you list
210
+ instead — a plain-text shorthand for the common case, not a substitute for hand-editing
211
+ `fields.ts` afterward for anything it doesn't cover:
212
+
213
+ ```sh
214
+ npx kilo-cms add-collection testimonials \
215
+ --fields "quote:textarea(required,rows=4),author:text(required,listPrimary,maxLength=80),rating:rating(max=5),featured:boolean,logo:image"
216
+ ```
217
+
218
+ Each entry is `key:type` or `key:type(opt,opt2=value)`. Supported types: `text`, `textarea`,
219
+ `richtext`, `number`, `boolean`, `select`, `multiselect`, `tags`, `image`, `file`, `date`,
220
+ `datetime`, `color`, `rating`. Options: `required`, `nullable`, `listColumn`, `listPrimary`,
221
+ `searchable`, `sortable`, `filterable`, `readOnly`, `maxLength=N`, `rows=N`,
222
+ `format=slug|email|url|password|tel`, `options=a|b|c` (select/multiselect). Relations, joins,
223
+ arrays, blocks, groups, and JSON fields aren't part of this shorthand — add those by hand, same
224
+ as you'd tune anything else the generator scaffolds.
151
225
 
152
226
  `migrate` applies Kilo CMS's own schema (auth, RBAC, media, settings, dev-tools, admin-ui,
153
227
  locales — see `src/schema/`) against your `DATABASE_URL`, tracked in its own
154
228
  `kilo_cms_migrations` table, independent of your own app's migration history for its content
155
229
  tables.
156
230
 
231
+ `init` fills in `BETTER_AUTH_SECRET` and `CMS_SETUP_TOKEN` in your `.env` if either is missing
232
+ (generated, never overwriting a value that's already there), runs `migrate`, then creates your
233
+ first admin user via the existing `/api/setup/admin` route — it needs your app already running
234
+ (`npm run dev` in another terminal) since that route executes inside your app's own Next.js
235
+ runtime:
236
+
237
+ ```sh
238
+ npx kilo-cms init --name "Your Name" --email you@example.com --password "at least 12 characters" [--url http://localhost:3000]
239
+ ```
240
+
241
+ The only variable you're still expected to set yourself is `DATABASE_URL`. If `init` had to
242
+ generate a fresh `CMS_SETUP_TOKEN`, it stops after `migrate` and asks you to restart your dev
243
+ server first — the already-running process only read `.env` once, at its own startup, so it
244
+ can't see a token that didn't exist yet. Run the same `init` command again after restarting.
245
+
157
246
  ## Package layout
158
247
 
159
248
  | Export | What it is |
@@ -162,25 +251,35 @@ tables.
162
251
  | `kilo-cms/collections` | Client-safe: field types, `defineCollectionFields()`, the registry, value helpers |
163
252
  | `kilo-cms/collections/table` | Server-only: `defineCollectionTable()` |
164
253
  | `kilo-cms/collections/server` | Server-only: validation, workflow, filters, the query engine |
165
- | `kilo-cms/schema` | Kilo CMS's own drizzle tables (auth, RBAC, media, settings, dev-tools, admin-ui, locales) |
254
+ | `kilo-cms/schema` | Kilo CMS's own drizzle tables (auth, RBAC, media, settings, dev-tools, admin-ui, locales) + `mergeSchema()` |
166
255
  | `kilo-cms/auth`, `kilo-cms/auth-client` | `createKiloAuth()` factory + the better-auth React client |
167
256
  | `kilo-cms/admin/*` | Admin UI pages and shared components |
257
+ | `kilo-cms/admin/require-session` | `requireAdminSession()` — the shared session/RBAC guard for admin pages |
258
+ | `kilo-cms/middleware` | `kiloAdminMiddleware` — Edge-safe cookie-presence pre-filter for `/admin/*` |
168
259
  | `kilo-cms/routes/*` | Route Handler logic for the admin API, public API helpers, auth, cron |
169
260
 
170
261
  ## Status / roadmap
171
262
 
172
263
  Built by extracting a working, in-production CMS out of the app it was originally embedded
173
- in — see [`the-usual-dev`'s `WORKLOG.md`](https://github.com/revaldyas/the-usual-dev) for the
264
+ in — see [`kilostudio.id`'s `WORKLOG.md`](https://github.com/kilostudio/kilostudio.id) for the
174
265
  full history of that extraction. Not yet done:
175
266
 
176
- - Centralized auth middleware (every admin page currently does its own session check).
177
- - A single catch-all admin route instead of one thin file per page.
178
- - An interactive `kilo-cms init` (migrate + seed settings + create the first admin user in one
179
- command).
267
+ - A single catch-all admin route instead of one thin file per page (deliberately skipped for
268
+ now today's thin per-page files are already minimal; a hand-rolled catch-all router would
269
+ trade that for real complexity with no proven need yet).
180
270
  - A second, independent consumer project to prove the install story beyond the one app this
181
271
  was extracted from.
182
272
 
273
+ ## Versioning
274
+
275
+ Semantic versioning, while the major version stays `0`: a **minor** bump (`0.x.0`) means new or
276
+ changed public API surface (a new export, a new CLI command); a **patch** bump (`0.x.y`) means
277
+ a bug fix with no public API change. Every release is documented in
278
+ [`CHANGELOG.md`](./CHANGELOG.md) and tagged in git (`v0.1.0`, `v0.1.1`, `v0.2.0`, ...) — check
279
+ the changelog before upgrading a consumer's dependency range, especially across a minor bump.
280
+
183
281
  ## License
184
282
 
185
- Proprietary — all rights reserved. Not yet licensed for redistribution; contact the author
186
- before using this outside of an explicitly authorized project.
283
+ Proprietary — all rights reserved. This is not an open-source project: the repo is private,
284
+ and access is by invitation as a collaborator, not by public license. Contact the author before
285
+ using this outside of an explicitly authorized project.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kilo-cms",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "An installable, config-driven CMS engine for Next.js — admin panel, RBAC, a generic field-type engine, and an API, mounted into your own app. Define your content schema via config, not a hand-maintained registry.",
5
5
  "keywords": [
6
6
  "cms",
@@ -39,6 +39,8 @@
39
39
  "./dashboard": "./src/dashboard.ts",
40
40
  "./routes/admin/*": "./src/routes/admin/*/route.ts",
41
41
  "./routes/*": "./src/routes/*.ts",
42
+ "./middleware": "./src/middleware.ts",
43
+ "./admin/require-session": "./src/admin/require-session.ts",
42
44
  "./admin/*": "./src/admin/*.tsx",
43
45
  "./admin.css": "./src/admin/admin.css",
44
46
  "./globals.css": "./src/admin/globals.css"
@@ -1,9 +1,7 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { AiSettingsEditor } from '../AiSettingsEditor'
4
3
 
5
4
  export default async function AiSettingsPage() {
6
- const result = await getSessionWithPermissions()
7
- if (!result || !result.can('settings', 'read')) redirect('/admin/login')
5
+ const result = await requireAdminSession((r) => r.can('settings', 'read'), '/admin/login')
8
6
  return <AiSettingsEditor userName={result.session.user.name} canManageAi={result.hasSystem('ai.manage')} />
9
7
  }
@@ -1,10 +1,7 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { ApiTokensEditor } from '../ApiTokensEditor'
4
3
 
5
4
  export default async function ApiTokensPage() {
6
- const result = await getSessionWithPermissions()
7
- if (!result) redirect('/admin/login')
8
- if (!result.hasSystem('developers.manage')) redirect('/admin/settings')
5
+ const result = await requireAdminSession((r) => r.hasSystem('developers.manage'), '/admin/settings')
9
6
  return <ApiTokensEditor userName={result.session.user.name} />
10
7
  }
@@ -1,5 +1,5 @@
1
- import { notFound, redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { notFound } from 'next/navigation'
2
+ import { requireAdminSession } from '../require-session'
3
3
  import { getCollection } from '../../collections'
4
4
  import { CollectionListEditor } from '../CollectionListEditor'
5
5
 
@@ -7,9 +7,7 @@ export default async function CollectionListPage({ params }: { params: Promise<{
7
7
  const { collection } = await params
8
8
  const config = getCollection(collection)
9
9
  if (!config) notFound()
10
- const result = await getSessionWithPermissions()
11
- if (!result) redirect('/admin/login')
12
- if (!result.can(config.slug, 'read')) redirect('/admin/dashboard')
10
+ const result = await requireAdminSession((r) => r.can(config.slug, 'read'), '/admin/dashboard')
13
11
  return (
14
12
  <CollectionListEditor
15
13
  config={config}
@@ -1,5 +1,5 @@
1
- import { notFound, redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { notFound } from 'next/navigation'
2
+ import { requireAdminSession } from '../require-session'
3
3
  import { getCollection } from '../../collections'
4
4
  import { CollectionRecordEditor } from '../CollectionRecordEditor'
5
5
 
@@ -7,9 +7,7 @@ export default async function CollectionRecordPage({ params }: { params: Promise
7
7
  const { collection, id } = await params
8
8
  const config = getCollection(collection)
9
9
  if (!config) notFound()
10
- const result = await getSessionWithPermissions()
11
- if (!result) redirect('/admin/login')
12
- if (!result.can(config.slug, 'read')) redirect('/admin/dashboard')
10
+ const result = await requireAdminSession((r) => r.can(config.slug, 'read'), '/admin/dashboard')
13
11
  return (
14
12
  <CollectionRecordEditor
15
13
  config={config}
@@ -1,10 +1,8 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { getDashboardSources } from '../../dashboard'
4
3
  import { DashboardBuilder } from '../DashboardBuilder'
5
4
 
6
5
  export default async function DashboardPage() {
7
- const result = await getSessionWithPermissions()
8
- if (!result || !result.can('settings', 'read')) redirect('/admin/login')
6
+ const result = await requireAdminSession((r) => r.can('settings', 'read'), '/admin/login')
9
7
  return <DashboardBuilder userName={result.session.user.name} canUpdate={result.can('settings', 'update')} dashboardSources={getDashboardSources()} />
10
8
  }
@@ -1,10 +1,7 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { EmailSettingsEditor } from '../EmailSettingsEditor'
4
3
 
5
4
  export default async function EmailSettingsPage() {
6
- const result = await getSessionWithPermissions()
7
- if (!result) redirect('/admin/login')
8
- if (!result.hasSystem('developers.manage')) redirect('/admin/settings')
5
+ const result = await requireAdminSession((r) => r.hasSystem('developers.manage'), '/admin/settings')
9
6
  return <EmailSettingsEditor userName={result.session.user.name} />
10
7
  }
@@ -1,10 +1,7 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { LocalesEditor } from '../LocalesEditor'
4
3
 
5
4
  export default async function LocalesPage() {
6
- const result = await getSessionWithPermissions()
7
- if (!result) redirect('/admin/login')
8
- if (!result.hasSystem('locales.manage')) redirect('/admin/settings')
5
+ const result = await requireAdminSession((r) => r.hasSystem('locales.manage'), '/admin/settings')
9
6
  return <LocalesEditor userName={result.session.user.name} />
10
7
  }
@@ -1,9 +1,7 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { MediaLibrary } from '../MediaLibrary'
4
3
 
5
4
  export default async function MediaPage() {
6
- const result = await getSessionWithPermissions()
7
- if (!result) redirect('/admin/login')
5
+ const result = await requireAdminSession()
8
6
  return <MediaLibrary userName={result.session.user.name} />
9
7
  }
@@ -1,11 +1,10 @@
1
1
  import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
2
+ import { requireAdminSession } from '../require-session'
3
3
  import { getCollection, isCollectionSlug, reviewableSlugs, type CollectionSlug } from '../../collections'
4
4
  import { ReviewQueueEditor, type ReviewCollectionMeta } from '../ReviewQueueEditor'
5
5
 
6
6
  export default async function ReviewQueuePage() {
7
- const result = await getSessionWithPermissions()
8
- if (!result) redirect('/admin/login')
7
+ const result = await requireAdminSession()
9
8
  const reviewSlugs = reviewableSlugs.filter((slug): slug is CollectionSlug => isCollectionSlug(slug))
10
9
  const canReview = Object.fromEntries(reviewSlugs.map((slug) => [slug, result.can(slug, 'review')])) as Record<CollectionSlug, boolean>
11
10
  const canApprove = Object.fromEntries(reviewSlugs.map((slug) => [slug, result.can(slug, 'approve')])) as Record<CollectionSlug, boolean>
@@ -1,11 +1,8 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { RoleEditor } from '../RoleEditor'
4
3
 
5
4
  export default async function RoleEditPage({ params }: { params: Promise<{ id: string }> }) {
6
- const result = await getSessionWithPermissions()
7
- if (!result) redirect('/admin/login')
8
- if (!result.hasSystem('roles.manage')) redirect('/admin')
5
+ const result = await requireAdminSession((r) => r.hasSystem('roles.manage'), '/admin')
9
6
  const { id } = await params
10
7
  return <RoleEditor id={id} userName={result.session.user.name} />
11
8
  }
@@ -1,10 +1,7 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { RolesEditor } from '../RolesEditor'
4
3
 
5
4
  export default async function RolesPage() {
6
- const result = await getSessionWithPermissions()
7
- if (!result) redirect('/admin/login')
8
- if (!result.hasSystem('roles.manage')) redirect('/admin')
5
+ const result = await requireAdminSession((r) => r.hasSystem('roles.manage'), '/admin')
9
6
  return <RolesEditor userName={result.session.user.name} />
10
7
  }
@@ -1,12 +1,10 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { getCollection, schedulingSlugs, type CollectionSlug } from '../../collections'
4
3
  import { ScheduledEditor, type ScheduledCollectionMeta } from '../ScheduledEditor'
5
4
 
6
5
  export default async function ScheduledPage() {
7
- const result = await getSessionWithPermissions()
8
- const readableSlugs = schedulingSlugs.filter((slug): slug is CollectionSlug => Boolean(result?.can(slug, 'read')))
9
- if (!result || !readableSlugs.length) redirect('/admin/login')
6
+ const result = await requireAdminSession((r) => schedulingSlugs.some((slug) => r.can(slug, 'read')), '/admin/login')
7
+ const readableSlugs = schedulingSlugs.filter((slug): slug is CollectionSlug => result.can(slug, 'read'))
10
8
  const meta: ScheduledCollectionMeta = Object.fromEntries(
11
9
  readableSlugs.map((slug) => {
12
10
  const config = getCollection(slug)!
@@ -1,9 +1,7 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { SeoSettingsEditor } from '../SeoSettingsEditor'
4
3
 
5
4
  export default async function SeoSettingsPage() {
6
- const result = await getSessionWithPermissions()
7
- if (!result || !result.can('settings', 'read')) redirect('/admin/login')
5
+ const result = await requireAdminSession((r) => r.can('settings', 'read'), '/admin/login')
8
6
  return <SeoSettingsEditor userName={result.session.user.name} />
9
7
  }
@@ -1,5 +1,5 @@
1
- import { notFound, redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { notFound } from 'next/navigation'
2
+ import { requireAdminSession } from '../require-session'
3
3
  import { getSingle } from '../../collections'
4
4
  import { SingleRecordEditor } from '../SingleRecordEditor'
5
5
 
@@ -7,9 +7,7 @@ export default async function SingleRecordPage({ params }: { params: Promise<{ t
7
7
  const { type } = await params
8
8
  const config = getSingle(type)
9
9
  if (!config) notFound()
10
- const result = await getSessionWithPermissions()
11
- if (!result) redirect('/admin/login')
12
- if (!result.can(config.slug, 'read')) redirect('/admin/dashboard')
10
+ const result = await requireAdminSession((r) => r.can(config.slug, 'read'), '/admin/dashboard')
13
11
  return (
14
12
  <SingleRecordEditor
15
13
  config={config}
@@ -1,9 +1,7 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { SiteSettingsEditor } from '../SiteSettingsEditor'
4
3
 
5
4
  export default async function SettingsPage() {
6
- const result = await getSessionWithPermissions()
7
- if (!result || !result.can('settings', 'read')) redirect('/admin/login')
5
+ const result = await requireAdminSession((r) => r.can('settings', 'read'), '/admin/login')
8
6
  return <SiteSettingsEditor userName={result.session.user.name} />
9
7
  }
@@ -1,11 +1,8 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { UserEditor } from '../UserEditor'
4
3
 
5
4
  export default async function UserEditPage({ params }: { params: Promise<{ id: string }> }) {
6
- const result = await getSessionWithPermissions()
7
- if (!result) redirect('/admin/login')
8
- if (!result.hasSystem('users.manage')) redirect('/admin')
5
+ const result = await requireAdminSession((r) => r.hasSystem('users.manage'), '/admin')
9
6
  const { id } = await params
10
7
  return <UserEditor id={id} userName={result.session.user.name} />
11
8
  }
@@ -1,10 +1,7 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { UserEditor } from '../UserEditor'
4
3
 
5
4
  export default async function NewUserPage() {
6
- const result = await getSessionWithPermissions()
7
- if (!result) redirect('/admin/login')
8
- if (!result.hasSystem('users.manage')) redirect('/admin')
5
+ const result = await requireAdminSession((r) => r.hasSystem('users.manage'), '/admin')
9
6
  return <UserEditor id="new" userName={result.session.user.name} />
10
7
  }
@@ -1,10 +1,7 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { UsersEditor } from '../UsersEditor'
4
3
 
5
4
  export default async function UsersPage() {
6
- const result = await getSessionWithPermissions()
7
- if (!result) redirect('/admin/login')
8
- if (!result.hasSystem('users.manage')) redirect('/admin')
5
+ const result = await requireAdminSession((r) => r.hasSystem('users.manage'), '/admin')
9
6
  return <UsersEditor userName={result.session.user.name} />
10
7
  }
@@ -1,10 +1,7 @@
1
- import { redirect } from 'next/navigation'
2
- import { getSessionWithPermissions } from '../../permissions'
1
+ import { requireAdminSession } from '../require-session'
3
2
  import { WebhooksEditor } from '../WebhooksEditor'
4
3
 
5
4
  export default async function WebhooksPage() {
6
- const result = await getSessionWithPermissions()
7
- if (!result) redirect('/admin/login')
8
- if (!result.hasSystem('developers.manage')) redirect('/admin/settings')
5
+ const result = await requireAdminSession((r) => r.hasSystem('developers.manage'), '/admin/settings')
9
6
  return <WebhooksEditor userName={result.session.user.name} />
10
7
  }
@@ -0,0 +1,21 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions, type SessionWithPermissions } from '../permissions'
3
+
4
+ /** For Server Component admin pages only — `redirect()` from `next/navigation` only works
5
+ * inside the RSC render tree, never call this from a Route Handler (use `requireAction`/
6
+ * `requireSystem` from `../permissions` there instead, which return null on failure and let
7
+ * the route decide how to respond).
8
+ *
9
+ * Replaces the repeated
10
+ * const result = await getSessionWithPermissions()
11
+ * if (!result) redirect('/admin/login')
12
+ * if (!result.can(...)) redirect('/admin/dashboard')
13
+ * pattern with one call. `check` is optional — omit it for a page that only needs "is logged
14
+ * in" (its real authorization logic is more than a single predicate, e.g. it depends on data
15
+ * computed from the session first). */
16
+ export async function requireAdminSession(check?: (result: SessionWithPermissions) => boolean, redirectTo = '/admin/dashboard'): Promise<SessionWithPermissions> {
17
+ const result = await getSessionWithPermissions()
18
+ if (!result) redirect('/admin/login')
19
+ if (check && !check(result)) redirect(redirectTo)
20
+ return result
21
+ }
package/src/cli/index.mjs CHANGED
@@ -2,10 +2,11 @@
2
2
  // Plain ESM — deliberately not TypeScript, so this runs on any Node (22.6+) without a loader,
3
3
  // build step, or dependency on how the host project executes TS. Run from the HOST app's own
4
4
  // directory (e.g. `cd apps/site && npx kilo-cms <command>`), same way `drizzle-kit` is run.
5
- import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs'
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
6
6
  import { join, dirname } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { spawnSync } from 'node:child_process'
9
+ import { randomBytes } from 'node:crypto'
9
10
 
10
11
  const cliDir = dirname(fileURLToPath(import.meta.url))
11
12
  const packageRoot = join(cliDir, '..', '..') // packages/kilo-cms
@@ -29,8 +30,120 @@ function singularize(label) {
29
30
 
30
31
  // --- add-collection ---------------------------------------------------------------------
31
32
 
32
- function addCollection(slug) {
33
- if (!slug) fail('usage: kilo-cms add-collection <slug>')
33
+ function keyToLabel(key) {
34
+ const words = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[-_]/g, ' ').trim()
35
+ return words.charAt(0).toUpperCase() + words.slice(1)
36
+ }
37
+
38
+ // `title:text(required,listPrimary),summary:textarea(rows=4),cover:image` — a plain-text
39
+ // shorthand for the common case, not a replacement for hand-editing fields.ts for anything
40
+ // past what this covers (relation/join/array/blocks/group/json/point aren't supported here;
41
+ // add those by hand afterward, same as the reviewer's own guidance: generate the shape, then
42
+ // adjust it, rather than have a generator so clever it becomes its own thing to debug).
43
+ const SUPPORTED_FIELD_TYPES = new Set(['text', 'textarea', 'richtext', 'number', 'boolean', 'select', 'multiselect', 'tags', 'image', 'file', 'date', 'datetime', 'color', 'rating'])
44
+
45
+ // Splits on top-level commas only — a plain `.split(',')` would also cut apart the
46
+ // comma-separated options list inside a field's own `(...)`, e.g. "quote:textarea(required,rows=4)".
47
+ function splitTopLevel(raw) {
48
+ const parts = []
49
+ let depth = 0
50
+ let current = ''
51
+ for (const char of raw) {
52
+ if (char === '(') depth++
53
+ if (char === ')') depth--
54
+ if (char === ',' && depth === 0) {
55
+ parts.push(current)
56
+ current = ''
57
+ } else {
58
+ current += char
59
+ }
60
+ }
61
+ if (current) parts.push(current)
62
+ return parts
63
+ }
64
+
65
+ function parseFieldSpecs(raw) {
66
+ return splitTopLevel(raw).map((entry) => {
67
+ const match = entry.trim().match(/^([a-zA-Z][a-zA-Z0-9]*):([a-z]+)(?:\(([^)]*)\))?$/)
68
+ if (!match) fail(`could not parse field "${entry.trim()}" — expected "key:type" or "key:type(opt,opt2=value)".`)
69
+ const [, key, type, optsRaw] = match
70
+ if (!SUPPORTED_FIELD_TYPES.has(type)) fail(`"${type}" (on field "${key}") isn't a type this generator scaffolds — supported: ${[...SUPPORTED_FIELD_TYPES].join(', ')}. Add it by hand instead.`)
71
+ const opts = {}
72
+ for (const pair of (optsRaw ?? '').split(',').map((s) => s.trim()).filter(Boolean)) {
73
+ const [optKey, optValue] = pair.split('=')
74
+ opts[optKey] = optValue ?? true
75
+ }
76
+ return { key, type, opts }
77
+ })
78
+ }
79
+
80
+ // One field spec -> its `fields: [...]` entry (defineCollectionFields shape).
81
+ function fieldConfigFor(spec) {
82
+ const { key, type, opts } = spec
83
+ const base = { key, type, label: keyToLabel(key), group: 'overview' }
84
+ if (opts.required) base.required = true
85
+ if (opts.nullable) base.nullable = true
86
+ if (opts.listColumn || opts.listPrimary) base.listColumn = true
87
+ if (opts.listPrimary) base.listPrimary = true
88
+ if (opts.searchable) base.searchable = true
89
+ if (opts.sortable) base.sortable = true
90
+ if (opts.filterable) base.filterable = true
91
+ if (opts.readOnly) base.readOnly = true
92
+
93
+ if (type === 'text' || type === 'textarea') {
94
+ if (opts.maxLength) base.maxLength = Number(opts.maxLength)
95
+ if (type === 'textarea' && opts.rows) base.rows = Number(opts.rows)
96
+ if (type === 'text' && opts.format) base.format = opts.format
97
+ }
98
+ if (type === 'number' && opts.integer) base.integer = true
99
+ if (type === 'rating' && opts.max) base.max = Number(opts.max)
100
+ if ((type === 'select' || type === 'multiselect') && opts.options) {
101
+ const values = String(opts.options).split('|').filter(Boolean)
102
+ base.options = values.map((value) => ({ value, label: keyToLabel(value) }))
103
+ } else if (type === 'select' || type === 'multiselect') {
104
+ base.options = [{ value: 'value-one', label: 'Value one' }, { value: 'value-two', label: 'Value two' }]
105
+ }
106
+
107
+ const entries = Object.entries(base).map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
108
+ return ` { ${entries.join(', ')} }`
109
+ }
110
+
111
+ // One field spec -> its Drizzle column (table.ts shape). Deliberately conservative: every
112
+ // column defaults to nullable text/jsonb unless `required` was set, since guessing wrong here
113
+ // means a migration to fix later — safer to under-commit and let the user tighten it by hand.
114
+ function columnFor(spec) {
115
+ const { key, type, opts } = spec
116
+ const notNull = opts.required && !opts.nullable
117
+ switch (type) {
118
+ case 'text':
119
+ case 'select':
120
+ case 'image':
121
+ case 'file':
122
+ case 'color':
123
+ return ` ${key}: text('${key}')${notNull ? '.notNull()' : ''},`
124
+ case 'textarea':
125
+ return ` ${key}: text('${key}')${notNull ? '.notNull()' : ".default('')"},`
126
+ case 'richtext':
127
+ return ` ${key}: jsonb('${key}').$type<RichTextDocument>().notNull().default({ type: 'doc', content: [{ type: 'paragraph' }] }),`
128
+ case 'number':
129
+ case 'rating':
130
+ return ` ${key}: integer('${key}')${notNull ? '.notNull()' : '.default(0)'},`
131
+ case 'boolean':
132
+ return ` ${key}: boolean('${key}').notNull().default(false),`
133
+ case 'multiselect':
134
+ case 'tags':
135
+ return ` ${key}: jsonb('${key}').$type<string[]>().notNull().default([]),`
136
+ case 'date':
137
+ return ` ${key}: date('${key}')${notNull ? '.notNull()' : ''},`
138
+ case 'datetime':
139
+ return ` ${key}: timestamp('${key}')${notNull ? '.notNull()' : ''},`
140
+ default:
141
+ throw new Error(`kilo-cms internal error: field type "${type}" is in SUPPORTED_FIELD_TYPES but columnFor() doesn't handle it.`)
142
+ }
143
+ }
144
+
145
+ function addCollection(slug, flags) {
146
+ if (!slug) fail('usage: kilo-cms add-collection <slug> [--fields "key:type(opt,opt2),key2:type"]')
34
147
  if (!/^[a-z][a-zA-Z0-9]*$/.test(slug)) fail(`"${slug}" should be camelCase, starting with a lowercase letter (e.g. "projectCategories").`)
35
148
 
36
149
  const collectionsDir = join(cwd, 'src', 'collections')
@@ -42,6 +155,16 @@ function addCollection(slug) {
42
155
  const label = slugToLabel(slug)
43
156
  const labelSingular = singularize(label)
44
157
  const varName = `${slug}Fields`
158
+ const tableName = `cms_${slug.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()}`
159
+
160
+ const customSpecs = flags?.fields ? parseFieldSpecs(flags.fields) : null
161
+
162
+ const fieldsBody = customSpecs
163
+ ? customSpecs.map(fieldConfigFor).join(',\n')
164
+ : ` { key: 'title', type: 'text', label: 'Title', group: 'overview', required: true, maxLength: 120, listColumn: true, listPrimary: true, searchable: true, sortable: true },
165
+ { key: 'slug', type: 'text', label: 'Slug', group: 'overview', width: 'half', required: true, format: 'slug', slugFrom: 'title', maxLength: 120, listColumn: true, searchable: true, sortable: true },
166
+ { key: 'sortOrder', type: 'number', label: 'Sort order', group: 'overview', integer: true, readOnly: true }`
167
+ const titleFieldKey = customSpecs ? customSpecs[0]?.key ?? 'title' : 'title'
45
168
 
46
169
  const fieldsFile = `import { defineCollectionFields } from 'kilo-cms/collections'
47
170
 
@@ -50,7 +173,7 @@ export const ${varName} = defineCollectionFields({
50
173
  slug: '${slug}',
51
174
  label: '${label}',
52
175
  labelSingular: '${labelSingular}',
53
- titleField: 'title',
176
+ titleField: '${titleFieldKey}',
54
177
  defaultSort: { field: 'sortOrder', dir: 'asc' },
55
178
  touchUpdatedAt: true,
56
179
  groups: [
@@ -58,25 +181,36 @@ export const ${varName} = defineCollectionFields({
58
181
  { id: 'record', label: 'Record', description: 'Managed by the CMS' },
59
182
  ],
60
183
  fields: [
61
- { key: 'title', type: 'text', label: 'Title', group: 'overview', required: true, maxLength: 120, listColumn: true, listPrimary: true, searchable: true, sortable: true },
62
- { key: 'slug', type: 'text', label: 'Slug', group: 'overview', width: 'half', required: true, format: 'slug', slugFrom: 'title', maxLength: 120, listColumn: true, searchable: true, sortable: true },
63
- { key: 'sortOrder', type: 'number', label: 'Sort order', group: 'overview', integer: true, readOnly: true },
184
+ ${fieldsBody},
64
185
  { key: 'createdAt', type: 'datetime', label: 'Created', group: 'record', readOnly: true, nullable: true, width: 'half', sortable: true },
65
186
  { key: 'updatedAt', type: 'datetime', label: 'Last updated', group: 'record', readOnly: true, nullable: true, width: 'half', sortable: true, listColumn: true },
66
187
  ],
67
188
  })
68
189
  `
69
190
 
191
+ const needsRichText = customSpecs?.some((s) => s.type === 'richtext')
192
+ const needsJsonb = customSpecs?.some((s) => ['richtext', 'multiselect', 'tags'].includes(s.type))
193
+ const needsBoolean = customSpecs?.some((s) => s.type === 'boolean')
194
+ const needsDate = customSpecs?.some((s) => s.type === 'date')
195
+ const needsTimestamp = customSpecs?.some((s) => s.type === 'datetime')
196
+
197
+ const columnImports = ['pgTable', 'text', 'integer']
198
+ if (needsJsonb) columnImports.push('jsonb')
199
+ if (needsBoolean) columnImports.push('boolean')
200
+ if (needsDate) columnImports.push('date')
201
+ if (needsTimestamp) columnImports.push('timestamp')
202
+
203
+ const customColumns = customSpecs ? customSpecs.map(columnFor).join('\n') : null
204
+ const defaultColumns = ` slug: text('slug').notNull().unique(),\n title: text('title').notNull(),\n sortOrder: integer('sort_order').notNull().default(0),`
205
+
70
206
  const tableFile = `import 'server-only'
71
- import { pgTable, text, integer } from 'drizzle-orm/pg-core'
207
+ import { ${[...new Set(columnImports)].join(', ')} } from 'drizzle-orm/pg-core'
72
208
  import { timestamps } from 'kilo-cms/schema'
73
- import { defineCollectionTable } from 'kilo-cms/collections/table'
209
+ import { defineCollectionTable } from 'kilo-cms/collections/table'${needsRichText ? "\nimport type { RichTextDocument } from 'kilo-cms/richtext'" : ''}
74
210
 
75
- export const ${slug} = defineCollectionTable('${slug}', pgTable('cms_${slug.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()}', {
211
+ export const ${slug} = defineCollectionTable('${slug}', pgTable('${tableName}', {
76
212
  id: text('id').primaryKey(),
77
- slug: text('slug').notNull().unique(),
78
- title: text('title').notNull(),
79
- sortOrder: integer('sort_order').notNull().default(0),
213
+ ${customColumns ?? defaultColumns}
80
214
  ...timestamps,
81
215
  }))
82
216
  `
@@ -128,11 +262,92 @@ function migrate() {
128
262
  console.log('\nRun your own `db:generate`/`db:migrate` next for this app\'s own content tables.')
129
263
  }
130
264
 
265
+ // --- init -----------------------------------------------------------------------------------
266
+
267
+ function parseFlags(argv) {
268
+ const flags = {}
269
+ for (let i = 0; i < argv.length; i++) {
270
+ const arg = argv[i]
271
+ if (arg.startsWith('--')) flags[arg.slice(2)] = argv[i + 1]
272
+ }
273
+ return flags
274
+ }
275
+
276
+ /**
277
+ * Fills in `BETTER_AUTH_SECRET` and `CMS_SETUP_TOKEN` in the host's `.env` if either is
278
+ * missing — the two secrets a fresh install needs before it can even start, that otherwise
279
+ * have nothing meaningful to default to, so a new user has to know to invent them by hand.
280
+ * Never touches a key that's already present (in the file OR already set in the environment) —
281
+ * this only fills gaps, it doesn't rotate or overwrite anything.
282
+ */
283
+ function ensureEnvSecrets() {
284
+ const envPath = join(cwd, '.env')
285
+ const existing = existsSync(envPath) ? readFileSync(envPath, 'utf8') : ''
286
+ const hasKey = (key) => new RegExp(`^${key}=`, 'm').test(existing) || Boolean(process.env[key])
287
+
288
+ const missing = []
289
+ if (!hasKey('BETTER_AUTH_SECRET')) missing.push(['BETTER_AUTH_SECRET', randomBytes(32).toString('hex')])
290
+ if (!hasKey('CMS_SETUP_TOKEN')) missing.push(['CMS_SETUP_TOKEN', randomBytes(24).toString('hex')])
291
+
292
+ if (!missing.length) return []
293
+
294
+ const block = `\n# Added by \`npx kilo-cms init\` on ${new Date().toISOString().slice(0, 10)}\n${missing.map(([key, value]) => `${key}=${value}`).join('\n')}\n`
295
+ appendFileSync(envPath, block)
296
+ for (const [key, value] of missing) process.env[key] = value
297
+
298
+ const keys = missing.map(([key]) => key)
299
+ console.log(`Added ${keys.join(' and ')} to .env (generated — not written anywhere else, keep this file out of version control).`)
300
+ return keys
301
+ }
302
+
303
+ async function init(argv) {
304
+ const flags = parseFlags(argv)
305
+ const addedSecrets = ensureEnvSecrets()
306
+ const url = flags.url ?? 'http://localhost:3000'
307
+ const token = flags.token ?? process.env.CMS_SETUP_TOKEN
308
+ const { name, email, password } = flags
309
+
310
+ if (!token) fail('missing --token (or set CMS_SETUP_TOKEN in your environment before running this).')
311
+ if (!name || !email || !password) fail('usage: kilo-cms init --name "..." --email you@example.com --password "..." [--url http://localhost:3000] [--token ...]')
312
+
313
+ migrate()
314
+
315
+ // A freshly generated CMS_SETUP_TOKEN only exists in .env, not in whatever process is already
316
+ // running the host app — that process read its env once, at its own startup. Calling
317
+ // /api/setup/admin right now would fail with a confusing "Unauthorized" (it's still checking
318
+ // the OLD, unset value), so stop here and have the user restart the app first.
319
+ if (addedSecrets.includes('CMS_SETUP_TOKEN')) {
320
+ console.log('\nCMS_SETUP_TOKEN was just generated, so it\'s not loaded into your already-running app yet.')
321
+ console.log('Restart your dev server (so it picks up the new .env), then run this same `kilo-cms init` command again.')
322
+ return
323
+ }
324
+
325
+ console.log(`\nCreating first admin at ${url}/api/setup/admin ...`)
326
+ console.log('(this requires your app to already be running — start it in another terminal first if it isn\'t.)')
327
+
328
+ let response
329
+ try {
330
+ response = await fetch(`${url}/api/setup/admin`, {
331
+ method: 'POST',
332
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
333
+ body: JSON.stringify({ name, email, password }),
334
+ })
335
+ } catch (error) {
336
+ fail(`could not reach ${url} — is the app running? (${error instanceof Error ? error.message : String(error)})`)
337
+ return
338
+ }
339
+
340
+ const body = await response.json().catch(() => null)
341
+ if (!response.ok) fail(`setup failed (${response.status}): ${body?.error ?? 'unknown error'}`)
342
+
343
+ console.log(`\nDone — admin user created (${email}). You can log in at ${url}/admin/login.`)
344
+ }
345
+
131
346
  // --- dispatch -------------------------------------------------------------------------------
132
347
 
133
348
  switch (command) {
134
349
  case 'add-collection':
135
- addCollection(args[0])
350
+ addCollection(args[0], parseFlags(args.slice(1)))
136
351
  break
137
352
  case 'sync':
138
353
  sync()
@@ -140,10 +355,27 @@ switch (command) {
140
355
  case 'migrate':
141
356
  migrate()
142
357
  break
358
+ case 'init':
359
+ await init(args)
360
+ break
143
361
  default:
144
362
  console.log(`kilo-cms — usage:
145
- kilo-cms add-collection <slug> scaffold a new collection's fields.ts + table.ts
363
+ kilo-cms add-collection <slug> [--fields "key:type(opt,opt2),key2:type"]
364
+ scaffold a new collection's fields.ts + table.ts.
365
+ Without --fields: the default title/slug/sortOrder template.
366
+ With --fields: builds exactly the fields you list (plus id/
367
+ createdAt/updatedAt, always added). Supported types: text,
368
+ textarea, richtext, number, boolean, select, multiselect,
369
+ tags, image, file, date, datetime, color, rating. Options:
370
+ required, nullable, listColumn, listPrimary, searchable,
371
+ sortable, filterable, readOnly, maxLength=N, rows=N,
372
+ format=slug|email|url, options=a|b|c.
373
+ Example: --fields "title:text(required,listPrimary),
374
+ summary:textarea(rows=4),cover:image"
146
375
  kilo-cms sync regenerate .kilo/types.gen.ts from src/collections/*
147
- kilo-cms migrate apply kilo-cms's own package-owned migrations`)
376
+ kilo-cms migrate apply kilo-cms's own package-owned migrations
377
+ kilo-cms init generate missing secrets into .env, migrate, then create
378
+ the first admin user
379
+ (--name, --email, --password, [--url], [--token or $CMS_SETUP_TOKEN])`)
148
380
  process.exit(command ? 1 : 0)
149
381
  }
@@ -0,0 +1,28 @@
1
+ import { NextResponse, type NextRequest } from 'next/server'
2
+ import { getSessionCookie } from 'better-auth/cookies'
3
+
4
+ /** Optimistic, Edge-safe redirect for unauthenticated requests to admin pages — a fast
5
+ * pre-filter based on cookie PRESENCE only (`getSessionCookie` reads the cookie, no DB call,
6
+ * safe to run in Edge middleware where a raw Postgres connection isn't available). This is
7
+ * NOT the real security boundary: a forged or expired cookie passes this check. Every admin
8
+ * page still calls `requireAdminSession()` (pages) or `requireAction`/`requireSystem` (API
9
+ * routes), which validate the session for real and check actual RBAC permissions — this only
10
+ * saves an unauthenticated visitor the round trip of hitting a page that would reject them
11
+ * anyway, and centralizes what used to be an `if (!result) redirect('/admin/login')` repeated
12
+ * on every single admin page.
13
+ *
14
+ * The host's own `middleware.ts` re-exports this as `middleware` and defines `config.matcher`
15
+ * itself (Next.js statically analyzes `matcher` at build time — it has to be a literal array
16
+ * in the file Next.js is scanning, not re-exported through another module). */
17
+ export function kiloAdminMiddleware(request: NextRequest): NextResponse {
18
+ const { pathname } = request.nextUrl
19
+ if (pathname === '/admin/login') return NextResponse.next()
20
+
21
+ const sessionCookie = getSessionCookie(request)
22
+ if (!sessionCookie) {
23
+ const url = request.nextUrl.clone()
24
+ url.pathname = '/admin/login'
25
+ return NextResponse.redirect(url)
26
+ }
27
+ return NextResponse.next()
28
+ }
@@ -31,3 +31,21 @@ export const schema = {
31
31
  contentViews, dashboards,
32
32
  cmsLocales,
33
33
  }
34
+
35
+ /**
36
+ * Merges kilo-cms's own tables with a host's content tables into the one object both
37
+ * `drizzle({ client, schema })` and drizzle-kit's config need. Purely `{ ...schema, ...tables }`
38
+ * under the hood — the value isn't the logic, it's giving the host ONE call instead of
39
+ * hand-spreading `schema` themselves, so adding a content table only touches the `tables`
40
+ * object passed in here, never a second place in the host's own schema file.
41
+ *
42
+ * This does NOT replace `export * from '../collections/<slug>/table'` re-exports in the host's
43
+ * own schema barrel — drizzle-kit's schema introspection needs each table as a real top-level
44
+ * export it can find by scanning the module (or, more simply, point drizzle-kit's own `schema`
45
+ * config option at a glob like `['./src/lib/schema.ts', './src/collections/*\/table.ts']`,
46
+ * which drizzle-kit supports natively and removes the re-export lines entirely — see this
47
+ * package's README "Quickstart" section).
48
+ */
49
+ export function mergeSchema<T extends Record<string, unknown>>(tables: T): typeof schema & T {
50
+ return { ...schema, ...tables }
51
+ }