@plutocms/pluto 0.6.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.7.0](https://github.com/plutocms/pluto/compare/v0.6.0...v0.7.0) (2026-09-12)
5
+
6
+
7
+ ### Features
8
+
9
+ * **content:** add server-side content registry and generic CRUD routes (wave 2) ([#133](https://github.com/plutocms/pluto/issues/133)) ([7846340](https://github.com/plutocms/pluto/commit/784634081f5a267fbe33dde8264e6b848bf7bdda))
10
+
4
11
  ## [0.6.0](https://github.com/plutocms/pluto/compare/v0.5.0...v0.6.0) (2026-09-12)
5
12
 
6
13
 
package/FEATURES.md CHANGED
@@ -14,5 +14,6 @@ policies shared by every project here.
14
14
  permissions driver. @.claude/skills/permissions/SKILL.md
15
15
  - Content model: a typed content-type contract (`defineContentType`, field types, payload
16
16
  validation, field-to-column mapping) and a media-adapter contract, with registry wiring and
17
- consumer composables. Client-side contracts only; no server registry or generated UI yet.
18
- @.claude/skills/content-model/SKILL.md
17
+ consumer composables, plus a server-side content registry and generic list/get/create/update/
18
+ delete routes backed by a `PlutoContentAdapter` a backend layer implements. No real adapter or
19
+ generated UI yet. @.claude/skills/content-model/SKILL.md
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/pluto",
3
3
  "type": "module",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "description": "The layerable CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Deletes one item of one content type. Generic, core-internal route — see
3
+ * the content-model skill for the `/_pluto/` prefix and the
4
+ * delete-capability enforcement this route follows.
5
+ */
6
+ export default defineEventHandler(async (event) => {
7
+ const typeName = getRouterParam(event, 'type')
8
+ const type = requireContentType(typeName ?? '')
9
+
10
+ const id = getRouterParam(event, 'id') ?? ''
11
+
12
+ await deleteContentItem({ event, type }, id)
13
+
14
+ return { success: true }
15
+ })
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Gets one item of one content type, by id or slug. Generic, core-internal
3
+ * route — see the content-model skill for the `/_pluto/` prefix and the
4
+ * capability rule this route follows (no `read` capability check, ever).
5
+ */
6
+ export default defineEventHandler(async (event) => {
7
+ const typeName = getRouterParam(event, 'type')
8
+ const type = requireContentType(typeName ?? '')
9
+
10
+ const idOrSlug = getRouterParam(event, 'id') ?? ''
11
+
12
+ return getContentItem({ event, type }, idOrSlug)
13
+ })
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Updates one item of one content type. Generic, core-internal route — see
3
+ * the content-model skill for the `/_pluto/` prefix, partial-payload
4
+ * validation, and the write-capability enforcement this route follows.
5
+ */
6
+ export default defineEventHandler(async (event) => {
7
+ const typeName = getRouterParam(event, 'type')
8
+ const type = requireContentType(typeName ?? '')
9
+
10
+ const id = getRouterParam(event, 'id') ?? ''
11
+ const body = await readBody(event)
12
+
13
+ return updateContentItem({ event, type }, id, body)
14
+ })
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Lists items of one content type. Generic, core-internal route — see the
3
+ * content-model skill for the `/_pluto/` prefix and the capability rule
4
+ * this route follows (no `read` capability check, ever).
5
+ */
6
+ export default defineEventHandler(async (event) => {
7
+ const typeName = getRouterParam(event, 'type')
8
+ const type = requireContentType(typeName ?? '')
9
+
10
+ const query = getQuery(event)
11
+ const limit = query.limit === undefined ? undefined : Number.parseInt(query.limit as string, 10)
12
+ const offset = query.offset === undefined ? undefined : Number.parseInt(query.offset as string, 10)
13
+ const search = query.search === undefined ? undefined : String(query.search)
14
+
15
+ return listContentItems({ event, type }, { limit, offset, search })
16
+ })
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Creates one item of one content type. Generic, core-internal route — see
3
+ * the content-model skill for the `/_pluto/` prefix, payload validation,
4
+ * and the write-capability enforcement this route follows.
5
+ */
6
+ export default defineEventHandler(async (event) => {
7
+ const typeName = getRouterParam(event, 'type')
8
+ const type = requireContentType(typeName ?? '')
9
+
10
+ const body = await readBody(event)
11
+
12
+ return createContentItem({ event, type }, body)
13
+ })
@@ -0,0 +1,168 @@
1
+ import type { H3Event } from 'h3'
2
+ import { createError } from 'h3'
3
+ import { validateContentPayload } from '../../shared/utils/content-validate'
4
+ import { getContentAdapter, getContentType } from './pluto-content'
5
+
6
+ /**
7
+ * The real logic behind the generic content routes, kept in plain exported
8
+ * functions — not inline in a `server/api/**` route file — so it can be
9
+ * unit-tested by calling it directly, the same way this workspace already
10
+ * tests everything else: no live Nitro server, no `@nuxt/test-utils`.
11
+ */
12
+
13
+ export function requireContentType(name: string): PlutoContentType {
14
+ const type = getContentType(name)
15
+
16
+ if (!type) {
17
+ throw createError({ statusCode: 404, statusMessage: `Content type "${name}" not found.` })
18
+ }
19
+
20
+ return type
21
+ }
22
+
23
+ export function requireContentAdapter(): PlutoContentAdapter {
24
+ const adapter = getContentAdapter()
25
+
26
+ if (!adapter) {
27
+ throw createError({ statusCode: 500, statusMessage: 'No content adapter is registered.' })
28
+ }
29
+
30
+ return adapter
31
+ }
32
+
33
+ /**
34
+ * Checks `capability` against `event`, but only when there is something to
35
+ * check against. Exported because both write handlers below share it, and
36
+ * a test exercises it indirectly through them.
37
+ *
38
+ * Fails open on purpose, in two cases:
39
+ * - `capability` is undefined (the content type declares no capability for
40
+ * this operation): nothing to enforce, anyone who can reach the route
41
+ * may proceed.
42
+ * - The registered adapter has no `authorize` method: the same fail-open
43
+ * rule `usePlutoPermissions().can()` already documents for "no
44
+ * permissions backend registered at all" — with no boundary to enforce,
45
+ * the adapter's own backing store (RLS, or equivalent) is still the real
46
+ * gate.
47
+ */
48
+ export async function authorizeContentOperation(event: H3Event, capability: string | undefined): Promise<void> {
49
+ if (!capability) {
50
+ return
51
+ }
52
+
53
+ const adapter = requireContentAdapter()
54
+
55
+ if (!adapter.authorize) {
56
+ return
57
+ }
58
+
59
+ await adapter.authorize(event, capability)
60
+ }
61
+
62
+ /**
63
+ * Lists items for `ctx.type`. Never checks `type.capabilities.read` and
64
+ * never calls `adapter.authorize` — see the content-model skill for why:
65
+ * in short, "read" is not all-or-nothing for a type like `posts` (an
66
+ * anonymous visitor can read published posts; only drafts need a
67
+ * capability), and that distinction already lives in the adapter's backing
68
+ * store (RLS, for the one adapter that exists so far). This route always
69
+ * asks the adapter for everything, unfiltered by capability, because it is
70
+ * admin-surface-only in intent — a public-facing read path is a layer's
71
+ * own hand-written endpoint, not this generic one.
72
+ */
73
+ export async function listContentItems(
74
+ ctx: PlutoContentContext,
75
+ query: {
76
+ limit?: number
77
+ offset?: number
78
+ search?: string
79
+ }
80
+ ): Promise<PlutoContentListResult> {
81
+ return requireContentAdapter().list(ctx, {
82
+ limit: query.limit,
83
+ offset: query.offset,
84
+ search: query.search,
85
+ sort: ctx.type.defaultSort,
86
+ includeUnpublished: true,
87
+ })
88
+ }
89
+
90
+ /** Gets one item by id or slug. No capability check — see `listContentItems`. */
91
+ export async function getContentItem(ctx: PlutoContentContext, idOrSlug: string | number): Promise<PlutoContentItem> {
92
+ const item = await requireContentAdapter().get(ctx, idOrSlug)
93
+
94
+ if (!item) {
95
+ throw createError({ statusCode: 404, statusMessage: 'Not found.' })
96
+ }
97
+
98
+ return item
99
+ }
100
+
101
+ function requirePlainObjectBody(body: unknown): Record<string, unknown> {
102
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
103
+ throw createError({ statusCode: 400, statusMessage: 'Request body must be a JSON object.' })
104
+ }
105
+
106
+ return body as Record<string, unknown>
107
+ }
108
+
109
+ /**
110
+ * Creates one item. Validates the full payload (not partial), authorizes
111
+ * against `type.capabilities.write`, then calls the adapter, then runs
112
+ * `type.hooks.afterCreate` when one is registered. A hook error propagates
113
+ * — it is not swallowed.
114
+ */
115
+ export async function createContentItem(ctx: PlutoContentContext, body: unknown): Promise<PlutoContentItem> {
116
+ const payload = requirePlainObjectBody(body)
117
+
118
+ const errors = validateContentPayload(ctx.type, payload)
119
+ if (errors.length > 0) {
120
+ throw createError({ statusCode: 400, statusMessage: 'Validation failed.', data: { errors } })
121
+ }
122
+
123
+ await authorizeContentOperation(ctx.event, ctx.type.capabilities?.write)
124
+
125
+ const item = await requireContentAdapter().create(ctx, payload)
126
+
127
+ if (ctx.type.hooks?.afterCreate) {
128
+ await ctx.type.hooks.afterCreate(ctx, item, payload)
129
+ }
130
+
131
+ return item
132
+ }
133
+
134
+ /**
135
+ * Updates one item. Validates the payload as a partial update, authorizes
136
+ * against `type.capabilities.write`, then calls the adapter, then runs
137
+ * `type.hooks.afterUpdate` when one is registered. A hook error propagates
138
+ * — it is not swallowed.
139
+ */
140
+ export async function updateContentItem(
141
+ ctx: PlutoContentContext,
142
+ id: string | number,
143
+ body: unknown
144
+ ): Promise<PlutoContentItem> {
145
+ const payload = requirePlainObjectBody(body)
146
+
147
+ const errors = validateContentPayload(ctx.type, payload, { partial: true })
148
+ if (errors.length > 0) {
149
+ throw createError({ statusCode: 400, statusMessage: 'Validation failed.', data: { errors } })
150
+ }
151
+
152
+ await authorizeContentOperation(ctx.event, ctx.type.capabilities?.write)
153
+
154
+ const item = await requireContentAdapter().update(ctx, id, payload)
155
+
156
+ if (ctx.type.hooks?.afterUpdate) {
157
+ await ctx.type.hooks.afterUpdate(ctx, item, payload)
158
+ }
159
+
160
+ return item
161
+ }
162
+
163
+ /** Deletes one item. Authorizes against `type.capabilities.delete` before calling the adapter. */
164
+ export async function deleteContentItem(ctx: PlutoContentContext, id: string | number): Promise<void> {
165
+ await authorizeContentOperation(ctx.event, ctx.type.capabilities?.delete)
166
+
167
+ await requireContentAdapter().remove(ctx, id)
168
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The server-side content-type and adapter registry. Deliberately a plain
3
+ * module-scope singleton — NOT built like `usePlutoRegistry` (the client
4
+ * registry in `app/composables/pluto-registry.ts`), and that is on purpose,
5
+ * not an oversight.
6
+ *
7
+ * The client registry lives on `nuxtApp` instead of module scope because a
8
+ * module-scope object on the server would leak state between requests and
9
+ * between apps during SSR: one user's data could end up rendered into
10
+ * another user's response. That risk does not apply here, for three
11
+ * reasons:
12
+ *
13
+ * 1. Registration happens once, at Nitro startup, from a layer's own
14
+ * `server/plugins/*.ts` file — not per request.
15
+ * 2. The registered values (`PlutoContentType`, `PlutoContentAdapter`) are
16
+ * static definition objects. They hold zero per-request state.
17
+ * 3. Anything that actually varies per request — the caller's identity,
18
+ * their session — travels in `PlutoContentContext.event`, which is
19
+ * passed fresh into every adapter call. It never touches the registry.
20
+ *
21
+ * So a future reader who knows why the client registry avoids module scope
22
+ * should not assume the same warning applies here: it does not, because
23
+ * nothing stored here ever differs between two requests.
24
+ */
25
+
26
+ const contentTypes = new Map<string, PlutoContentType>()
27
+ let contentAdapter: PlutoContentAdapter | undefined
28
+
29
+ export function registerContentType(type: PlutoContentType): void {
30
+ contentTypes.set(type.name, type)
31
+ }
32
+
33
+ export function getContentType(name: string): PlutoContentType | undefined {
34
+ return contentTypes.get(name)
35
+ }
36
+
37
+ /** Replaces whatever adapter was registered before. Last-registered wins, the same rule `mediaAdapters`/`settingsDrivers` use client-side. */
38
+ export function registerContentAdapter(adapter: PlutoContentAdapter): void {
39
+ contentAdapter = adapter
40
+ }
41
+
42
+ export function getContentAdapter(): PlutoContentAdapter | undefined {
43
+ return contentAdapter
44
+ }
@@ -1,3 +1,5 @@
1
+ import type { H3Event } from 'h3'
2
+
1
3
  export type PlutoFieldType =
2
4
  | 'text'
3
5
  | 'textarea'
@@ -181,6 +183,19 @@ export interface PlutoContentType {
181
183
  basePath?: string
182
184
  navOrder?: number
183
185
  i18n?: PlutoContentI18n
186
+ /**
187
+ * Server-only lifecycle hooks, run by the generic write handlers
188
+ * (`server/utils/pluto-content-handlers.ts`) after the adapter call
189
+ * succeeds and before the response goes out. Lets a layer with extra
190
+ * logic around a write — for example `supabase-shop` reconciling
191
+ * `product_media` rows after a product write — hook in without a bespoke
192
+ * endpoint just for that, and without polluting the generic adapter
193
+ * contract with a per-layer concern.
194
+ */
195
+ hooks?: {
196
+ afterCreate?: (ctx: PlutoContentContext, item: PlutoContentItem, rawBody: Record<string, unknown>) => Promise<void> | void
197
+ afterUpdate?: (ctx: PlutoContentContext, item: PlutoContentItem, rawBody: Record<string, unknown>) => Promise<void> | void
198
+ }
184
199
  }
185
200
 
186
201
  /**
@@ -194,3 +209,45 @@ export interface PlutoContentType {
194
209
  export type PlutoContentItem = Record<string, unknown> & {
195
210
  id: string | number
196
211
  }
212
+
213
+ /** Options for a generic list read. See `server/utils/pluto-content-handlers.ts`. */
214
+ export interface PlutoContentQuery {
215
+ limit?: number
216
+ offset?: number
217
+ search?: string
218
+ sort?: {
219
+ field: string
220
+ direction: 'asc' | 'desc'
221
+ }
222
+ includeUnpublished?: boolean
223
+ }
224
+
225
+ export interface PlutoContentListResult {
226
+ data: PlutoContentItem[]
227
+ total?: number
228
+ }
229
+
230
+ /** Per-request context an adapter method receives: the H3 event, and the content type being read or written. */
231
+ export interface PlutoContentContext {
232
+ event: H3Event
233
+ type: PlutoContentType
234
+ }
235
+
236
+ /**
237
+ * Lets a backend layer (`@plutocms/supabase`, and so on) plug its own
238
+ * storage into the generic content server routes, without core knowing
239
+ * which backend is in use — the same role `PlutoMediaAdapter` plays for
240
+ * media. Exactly one adapter is meaningful per app, registered through
241
+ * `registerContentAdapter` (see `server/utils/pluto-content.ts`).
242
+ */
243
+ export interface PlutoContentAdapter {
244
+ id: string
245
+ list: (ctx: PlutoContentContext, query: PlutoContentQuery) => Promise<PlutoContentListResult>
246
+ get: (ctx: PlutoContentContext, idOrSlug: string | number) => Promise<PlutoContentItem | null>
247
+ create: (ctx: PlutoContentContext, values: Record<string, unknown>) => Promise<PlutoContentItem>
248
+ update: (ctx: PlutoContentContext, id: string | number, values: Record<string, unknown>) => Promise<PlutoContentItem>
249
+ remove: (ctx: PlutoContentContext, id: string | number) => Promise<void>
250
+ /** Throws when `event`'s caller does not hold `capability`. Optional — see the fail-open rule in the content-model skill. */
251
+ authorize?: (event: H3Event, capability: string) => Promise<void>
252
+ describe?: (ctx: PlutoContentContext) => Promise<string[] | null>
253
+ }