kilo-cms 0.1.0 → 0.2.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
@@ -135,6 +135,33 @@ import '@/lib/db'
135
135
  export { GET, PUT } from 'kilo-cms/routes/admin/dashboard'
136
136
  ```
137
137
 
138
+ ### Middleware
139
+
140
+ Kilo CMS ships an Edge-safe, cookie-presence-only pre-filter that redirects unauthenticated
141
+ visitors away from `/admin/*` before a page even renders — a fast filter, not the real
142
+ security boundary (every admin page/route still validates the session and RBAC permissions
143
+ for real). Your own `middleware.ts` re-exports it and defines `matcher` itself (Next.js
144
+ statically analyzes `matcher` in the file it scans — it can't be re-exported through another
145
+ module):
146
+
147
+ ```ts
148
+ // middleware.ts
149
+ export { kiloAdminMiddleware as middleware } from 'kilo-cms/middleware'
150
+
151
+ export const config = {
152
+ matcher: ['/admin/:path*'],
153
+ }
154
+ ```
155
+
156
+ Admin pages use `requireAdminSession()` from `kilo-cms/admin/require-session` instead of
157
+ hand-rolling `getSessionWithPermissions()` + `redirect()`:
158
+
159
+ ```ts
160
+ import { requireAdminSession } from 'kilo-cms/admin/require-session'
161
+
162
+ const result = await requireAdminSession((r) => r.can('projects', 'read'), '/admin/dashboard')
163
+ ```
164
+
138
165
  ## CLI
139
166
 
140
167
  Run from your own app's directory:
@@ -143,6 +170,7 @@ Run from your own app's directory:
143
170
  npx kilo-cms add-collection <slug> # scaffold a new collection's fields.ts + table.ts
144
171
  npx kilo-cms sync # regenerate .kilo/types.gen.ts from src/collections/*
145
172
  npx kilo-cms migrate # apply Kilo CMS's own package-owned migrations
173
+ npx kilo-cms init # migrate, then create the first admin user
146
174
  ```
147
175
 
148
176
  `add-collection` scaffolds the two files and prints the exact lines to paste into
@@ -154,6 +182,14 @@ locales — see `src/schema/`) against your `DATABASE_URL`, tracked in its own
154
182
  `kilo_cms_migrations` table, independent of your own app's migration history for its content
155
183
  tables.
156
184
 
185
+ `init` runs `migrate`, then creates your first admin user via the existing
186
+ `/api/setup/admin` route — it needs your app already running (`npm run dev` in another
187
+ terminal) since that route executes inside your app's own Next.js runtime:
188
+
189
+ ```sh
190
+ CMS_SETUP_TOKEN=... npx kilo-cms init --name "Your Name" --email you@example.com --password "at least 12 characters" [--url http://localhost:3000]
191
+ ```
192
+
157
193
  ## Package layout
158
194
 
159
195
  | Export | What it is |
@@ -165,6 +201,8 @@ tables.
165
201
  | `kilo-cms/schema` | Kilo CMS's own drizzle tables (auth, RBAC, media, settings, dev-tools, admin-ui, locales) |
166
202
  | `kilo-cms/auth`, `kilo-cms/auth-client` | `createKiloAuth()` factory + the better-auth React client |
167
203
  | `kilo-cms/admin/*` | Admin UI pages and shared components |
204
+ | `kilo-cms/admin/require-session` | `requireAdminSession()` — the shared session/RBAC guard for admin pages |
205
+ | `kilo-cms/middleware` | `kiloAdminMiddleware` — Edge-safe cookie-presence pre-filter for `/admin/*` |
168
206
  | `kilo-cms/routes/*` | Route Handler logic for the admin API, public API helpers, auth, cron |
169
207
 
170
208
  ## Status / roadmap
@@ -173,10 +211,9 @@ Built by extracting a working, in-production CMS out of the app it was originall
173
211
  in — see [`the-usual-dev`'s `WORKLOG.md`](https://github.com/revaldyas/the-usual-dev) for the
174
212
  full history of that extraction. Not yet done:
175
213
 
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).
214
+ - A single catch-all admin route instead of one thin file per page (deliberately skipped for
215
+ now today's thin per-page files are already minimal; a hand-rolled catch-all router would
216
+ trade that for real complexity with no proven need yet).
180
217
  - A second, independent consumer project to prove the install story beyond the one app this
181
218
  was extracted from.
182
219
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kilo-cms",
3
- "version": "0.1.0",
3
+ "version": "0.2.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"
@@ -78,7 +80,9 @@
78
80
  "nodemailer": "^10.0.9",
79
81
  "react-easy-crop": "^6.2.3",
80
82
  "react-leaflet": "^5.0.0",
81
- "zod": "^4.0.0"
83
+ "zod": "^4.0.0",
84
+ "@types/leaflet": "^1.9.22",
85
+ "@types/nodemailer": "^8.0.1"
82
86
  },
83
87
  "peerDependencies": {
84
88
  "next": "^16.2.6",
@@ -86,9 +90,7 @@
86
90
  "react-dom": "^19.2.0"
87
91
  },
88
92
  "devDependencies": {
89
- "@types/leaflet": "^1.9.22",
90
93
  "@types/node": "^22.15.0",
91
- "@types/nodemailer": "^8.0.1",
92
94
  "@types/react": "^19.1.0",
93
95
  "@types/react-dom": "^19.1.0",
94
96
  "drizzle-kit": "^0.31.0",
@@ -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
@@ -128,6 +128,49 @@ function migrate() {
128
128
  console.log('\nRun your own `db:generate`/`db:migrate` next for this app\'s own content tables.')
129
129
  }
130
130
 
131
+ // --- init -----------------------------------------------------------------------------------
132
+
133
+ function parseFlags(argv) {
134
+ const flags = {}
135
+ for (let i = 0; i < argv.length; i++) {
136
+ const arg = argv[i]
137
+ if (arg.startsWith('--')) flags[arg.slice(2)] = argv[i + 1]
138
+ }
139
+ return flags
140
+ }
141
+
142
+ async function init(argv) {
143
+ const flags = parseFlags(argv)
144
+ const url = flags.url ?? 'http://localhost:3000'
145
+ const token = flags.token ?? process.env.CMS_SETUP_TOKEN
146
+ const { name, email, password } = flags
147
+
148
+ if (!token) fail('missing --token (or set CMS_SETUP_TOKEN in your environment before running this).')
149
+ if (!name || !email || !password) fail('usage: kilo-cms init --name "..." --email you@example.com --password "..." [--url http://localhost:3000] [--token ...]')
150
+
151
+ migrate()
152
+
153
+ console.log(`\nCreating first admin at ${url}/api/setup/admin ...`)
154
+ console.log('(this requires your app to already be running — start it in another terminal first if it isn\'t.)')
155
+
156
+ let response
157
+ try {
158
+ response = await fetch(`${url}/api/setup/admin`, {
159
+ method: 'POST',
160
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
161
+ body: JSON.stringify({ name, email, password }),
162
+ })
163
+ } catch (error) {
164
+ fail(`could not reach ${url} — is the app running? (${error instanceof Error ? error.message : String(error)})`)
165
+ return
166
+ }
167
+
168
+ const body = await response.json().catch(() => null)
169
+ if (!response.ok) fail(`setup failed (${response.status}): ${body?.error ?? 'unknown error'}`)
170
+
171
+ console.log(`\nDone — admin user created (${email}). You can log in at ${url}/admin/login.`)
172
+ }
173
+
131
174
  // --- dispatch -------------------------------------------------------------------------------
132
175
 
133
176
  switch (command) {
@@ -140,10 +183,15 @@ switch (command) {
140
183
  case 'migrate':
141
184
  migrate()
142
185
  break
186
+ case 'init':
187
+ await init(args)
188
+ break
143
189
  default:
144
190
  console.log(`kilo-cms — usage:
145
191
  kilo-cms add-collection <slug> scaffold a new collection's fields.ts + table.ts
146
192
  kilo-cms sync regenerate .kilo/types.gen.ts from src/collections/*
147
- kilo-cms migrate apply kilo-cms's own package-owned migrations`)
193
+ kilo-cms migrate apply kilo-cms's own package-owned migrations
194
+ kilo-cms init migrate, then create the first admin user
195
+ (--name, --email, --password, [--url], [--token or $CMS_SETUP_TOKEN])`)
148
196
  process.exit(command ? 1 : 0)
149
197
  }
@@ -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
+ }