kernelcms 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.
@@ -0,0 +1,285 @@
1
+ import {
2
+ BadRequestError,
3
+ NotFoundError,
4
+ UnauthorizedError,
5
+ describeConfig,
6
+ isKernelError
7
+ } from "./chunk-O5TO5JFA.js";
8
+
9
+ // ../server/src/index.ts
10
+ import { createServer } from "http";
11
+ function createRequestHandler(kernel, options = {}) {
12
+ const apiBase = kernel.config.routes.api;
13
+ return async function handle(request) {
14
+ if (request.method === "OPTIONS") return withCors(new Response(null, { status: 204 }), options, request);
15
+ try {
16
+ const response = await route(kernel, options, request, apiBase);
17
+ return withCors(response, options, request);
18
+ } catch (err) {
19
+ return withCors(errorResponse(err), options, request);
20
+ }
21
+ };
22
+ }
23
+ async function route(kernel, options, request, apiBase) {
24
+ const url = new URL(request.url);
25
+ if (!url.pathname.startsWith(apiBase)) {
26
+ return json({ error: { code: "NOT_FOUND", message: `No route for ${url.pathname}` } }, 404);
27
+ }
28
+ const segments = url.pathname.slice(apiBase.length).split("/").filter(Boolean);
29
+ const { user, overrideAccess } = await resolveAuth(kernel, options, request);
30
+ const locale = url.searchParams.get("locale") ?? void 0;
31
+ const depth = toNum(url.searchParams.get("depth"));
32
+ const base = { req: { user, ...locale ? { locale } : {} }, overrideAccess, ...depth !== void 0 ? { depth } : {} };
33
+ const method = request.method;
34
+ if (segments.length === 0) {
35
+ return json({
36
+ name: "KernelCMS",
37
+ collections: kernel.config.collections.map((c) => c.slug),
38
+ globals: kernel.config.globals.map((g) => g.slug)
39
+ });
40
+ }
41
+ if (segments.length === 1 && segments[0] === "health") {
42
+ const health = await kernel.db.health();
43
+ return json({ status: "ok", db: health }, health.status === "ok" ? 200 : 503);
44
+ }
45
+ if (segments.length === 1 && segments[0] === "_config") {
46
+ return json(describeConfig(kernel.config));
47
+ }
48
+ if (segments[0] === "globals" && segments.length === 2) {
49
+ const slug = segments[1];
50
+ if (method === "GET") return json(await kernel.findGlobal({ slug, ...base }));
51
+ if (method === "POST" || method === "PATCH") {
52
+ const data = await readBody(request);
53
+ return json(await kernel.updateGlobal({ slug, data, ...base }));
54
+ }
55
+ return methodNotAllowed();
56
+ }
57
+ const collection = segments[0];
58
+ if (segments.length === 2 && (segments[1] === "login" || segments[1] === "me")) {
59
+ const authColl = kernel.config.collectionsBySlug[collection];
60
+ if (authColl?.auth) {
61
+ if (segments[1] === "login" && method === "POST") {
62
+ const body = await readBody(request);
63
+ return json(
64
+ await kernel.login({
65
+ collection,
66
+ email: String(body.email ?? ""),
67
+ password: String(body.password ?? "")
68
+ })
69
+ );
70
+ }
71
+ if (segments[1] === "me" && method === "GET") {
72
+ if (!user) throw new UnauthorizedError();
73
+ return json({ user });
74
+ }
75
+ }
76
+ }
77
+ if (segments.length === 1) {
78
+ if (method === "GET") {
79
+ const result = await kernel.find({
80
+ collection,
81
+ where: parseWhere(url.searchParams),
82
+ sort: url.searchParams.get("sort") ?? void 0,
83
+ limit: toNum(url.searchParams.get("limit")),
84
+ page: toNum(url.searchParams.get("page")),
85
+ ...base
86
+ });
87
+ return json(result);
88
+ }
89
+ if (method === "POST") {
90
+ const data = await readBody(request);
91
+ return json(await kernel.create({ collection, data, ...base }), 201);
92
+ }
93
+ return methodNotAllowed();
94
+ }
95
+ if (segments.length === 2) {
96
+ const id = segments[1];
97
+ if (method === "GET") {
98
+ const doc = await kernel.findByID({ collection, id, ...base });
99
+ if (!doc) throw new NotFoundError();
100
+ return json(doc);
101
+ }
102
+ if (method === "PATCH" || method === "PUT") {
103
+ const data = await readBody(request);
104
+ const doc = await kernel.update({ collection, id, data, ...base });
105
+ if (!doc) throw new NotFoundError();
106
+ return json(doc);
107
+ }
108
+ if (method === "DELETE") {
109
+ const doc = await kernel.delete({ collection, id, ...base });
110
+ if (!doc) throw new NotFoundError();
111
+ return json(doc);
112
+ }
113
+ return methodNotAllowed();
114
+ }
115
+ return json({ error: { code: "NOT_FOUND", message: `No route for ${url.pathname}` } }, 404);
116
+ }
117
+ async function resolveAuth(kernel, options, request) {
118
+ const auth = request.headers.get("authorization");
119
+ if (options.apiKey && auth === `Bearer ${options.apiKey}`) {
120
+ return { user: { id: "system", roles: ["admin"], collection: "system" }, overrideAccess: true };
121
+ }
122
+ if (options.getUser) {
123
+ const user = await options.getUser(request);
124
+ if (user) return { user, overrideAccess: false };
125
+ }
126
+ if (auth?.startsWith("Bearer ")) {
127
+ const user = await kernel.authenticate(auth.slice("Bearer ".length));
128
+ if (user) return { user, overrideAccess: false };
129
+ }
130
+ return { user: null, overrideAccess: false };
131
+ }
132
+ async function readBody(request) {
133
+ let parsed;
134
+ try {
135
+ parsed = await request.json();
136
+ } catch {
137
+ throw new BadRequestError("Request body must be valid JSON.");
138
+ }
139
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
140
+ throw new BadRequestError("Request body must be a JSON object.");
141
+ }
142
+ return parsed;
143
+ }
144
+ function coerce(value) {
145
+ if (value === "true") return true;
146
+ if (value === "false") return false;
147
+ if (value === "null") return null;
148
+ if (value !== "" && /^-?\d+(\.\d+)?$/.test(value)) return Number(value);
149
+ return value;
150
+ }
151
+ function setDeep(root, path, value) {
152
+ let node = root;
153
+ for (let i = 0; i < path.length - 1; i++) {
154
+ const key = path[i];
155
+ if (typeof node[key] !== "object" || node[key] === null) node[key] = {};
156
+ node = node[key];
157
+ }
158
+ node[path[path.length - 1]] = value;
159
+ }
160
+ function parseWhere(params) {
161
+ const raw = params.get("where");
162
+ if (raw) {
163
+ try {
164
+ return JSON.parse(raw);
165
+ } catch {
166
+ throw new BadRequestError("`where` must be valid JSON.");
167
+ }
168
+ }
169
+ const root = {};
170
+ let found = false;
171
+ for (const [key, value] of params) {
172
+ if (!key.startsWith("where[")) continue;
173
+ found = true;
174
+ const path = key.slice("where".length).split(/[[\]]+/).filter(Boolean);
175
+ setDeep(root, path, coerce(value));
176
+ }
177
+ return found ? root : void 0;
178
+ }
179
+ function json(data, status = 200) {
180
+ return new Response(JSON.stringify(data), {
181
+ status,
182
+ headers: { "content-type": "application/json; charset=utf-8" }
183
+ });
184
+ }
185
+ function methodNotAllowed() {
186
+ return json({ error: { code: "BAD_REQUEST", message: "Method not allowed." } }, 405);
187
+ }
188
+ function errorResponse(err) {
189
+ if (isKernelError(err)) {
190
+ const response = json(err.toJSON(), err.status);
191
+ const retryAfter = err.retryAfter;
192
+ if (typeof retryAfter === "number") response.headers.set("retry-after", String(retryAfter));
193
+ return response;
194
+ }
195
+ console.error("[kernel] unhandled error:", err);
196
+ return json({ error: { code: "INTERNAL", message: "Internal server error." } }, 500);
197
+ }
198
+ function withCors(response, options, request) {
199
+ if (!options.cors) return response;
200
+ const origin = request.headers.get("origin");
201
+ let allow = null;
202
+ let credentials = false;
203
+ if (Array.isArray(options.cors)) {
204
+ if (origin && options.cors.includes(origin)) {
205
+ allow = origin;
206
+ credentials = true;
207
+ }
208
+ } else {
209
+ allow = origin ?? "*";
210
+ }
211
+ if (!allow) return response;
212
+ response.headers.set("access-control-allow-origin", allow);
213
+ if (credentials) response.headers.set("access-control-allow-credentials", "true");
214
+ response.headers.set("access-control-allow-headers", "Content-Type, Authorization");
215
+ response.headers.set("access-control-allow-methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
216
+ response.headers.set("vary", "Origin");
217
+ return response;
218
+ }
219
+ function toNum(value) {
220
+ if (value === null) return void 0;
221
+ const n = Number(value);
222
+ return Number.isNaN(n) ? void 0 : n;
223
+ }
224
+ function toNodeListener(handler) {
225
+ return (req, res) => {
226
+ const chunks = [];
227
+ req.on("data", (chunk) => chunks.push(chunk));
228
+ req.on("error", () => {
229
+ res.statusCode = 400;
230
+ res.end();
231
+ });
232
+ req.on("end", () => {
233
+ void (async () => {
234
+ const headers = new Headers();
235
+ for (const [key, value] of Object.entries(req.headers)) {
236
+ if (typeof value === "string") headers.set(key, value);
237
+ else if (Array.isArray(value)) headers.set(key, value.join(", "));
238
+ }
239
+ const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
240
+ const method = req.method ?? "GET";
241
+ const hasBody = method !== "GET" && method !== "HEAD" && chunks.length > 0;
242
+ const request = new Request(url, {
243
+ method,
244
+ headers,
245
+ body: hasBody ? Buffer.concat(chunks) : void 0
246
+ });
247
+ try {
248
+ const response = await handler(request);
249
+ res.statusCode = response.status;
250
+ response.headers.forEach((value, key) => res.setHeader(key, value));
251
+ const body = Buffer.from(await response.arrayBuffer());
252
+ res.end(body);
253
+ } catch (err) {
254
+ console.error("[kernel] listener error:", err);
255
+ res.statusCode = 500;
256
+ res.setHeader("content-type", "application/json");
257
+ res.end(JSON.stringify({ error: { code: "INTERNAL", message: "Internal server error." } }));
258
+ }
259
+ })();
260
+ });
261
+ };
262
+ }
263
+ async function serve(kernel, options = {}) {
264
+ const handler = createRequestHandler(kernel, options);
265
+ const server = createServer(toNodeListener(handler));
266
+ const requested = options.port ?? 3e3;
267
+ await new Promise((resolve, reject) => {
268
+ server.once("error", reject);
269
+ server.listen(requested, () => resolve());
270
+ });
271
+ const address = server.address();
272
+ const port = typeof address === "object" && address ? address.port : requested;
273
+ return {
274
+ url: `http://localhost:${port}`,
275
+ port,
276
+ close: () => new Promise((resolve) => server.close(() => resolve()))
277
+ };
278
+ }
279
+
280
+ export {
281
+ createRequestHandler,
282
+ parseWhere,
283
+ toNodeListener,
284
+ serve
285
+ };
@@ -0,0 +1,57 @@
1
+ import { W as Where, R as Row, P as PaginatedResult } from './index-BxvPeUO2.js';
2
+
3
+ /**
4
+ * @kernel/client — a tiny, typed fetch client for the KernelCMS REST API.
5
+ * Works in the browser, on the edge, and in Node (global fetch).
6
+ */
7
+
8
+ interface ClientOptions {
9
+ /** Origin of the KernelCMS server, e.g. "http://localhost:3000". */
10
+ baseURL: string;
11
+ /** API route prefix. Default "/api". */
12
+ apiRoute?: string;
13
+ /** System API key (sent as a bearer token). */
14
+ apiKey?: string;
15
+ /** End-user auth token (sent as a bearer token). Takes precedence over apiKey. */
16
+ token?: string;
17
+ /** Custom fetch implementation (defaults to global fetch). */
18
+ fetch?: typeof fetch;
19
+ /** Extra headers attached to every request. */
20
+ headers?: Record<string, string>;
21
+ }
22
+ interface FindQuery {
23
+ where?: Where;
24
+ sort?: string | string[];
25
+ limit?: number;
26
+ page?: number;
27
+ depth?: number;
28
+ locale?: string;
29
+ }
30
+ interface ByIdQuery {
31
+ depth?: number;
32
+ locale?: string;
33
+ }
34
+ declare class KernelClientError extends Error {
35
+ readonly status: number;
36
+ readonly code: string;
37
+ readonly details: unknown;
38
+ constructor(status: number, code: string, message: string, details: unknown);
39
+ }
40
+ interface KernelClient {
41
+ find: <T extends Row = Row>(collection: string, query?: FindQuery) => Promise<PaginatedResult<T>>;
42
+ findByID: <T extends Row = Row>(collection: string, id: string, query?: ByIdQuery) => Promise<T>;
43
+ create: <T extends Row = Row>(collection: string, data: Row, query?: ByIdQuery) => Promise<T>;
44
+ update: <T extends Row = Row>(collection: string, id: string, data: Row, query?: ByIdQuery) => Promise<T>;
45
+ remove: <T extends Row = Row>(collection: string, id: string) => Promise<T>;
46
+ findGlobal: <T extends Row = Row>(slug: string, query?: ByIdQuery) => Promise<T>;
47
+ updateGlobal: <T extends Row = Row>(slug: string, data: Row) => Promise<T>;
48
+ health: () => Promise<{
49
+ status: string;
50
+ db: {
51
+ status: string;
52
+ };
53
+ }>;
54
+ }
55
+ declare function createClient(options: ClientOptions): KernelClient;
56
+
57
+ export { type ByIdQuery, type ClientOptions, type FindQuery, type KernelClient, KernelClientError, createClient };
package/dist/client.js ADDED
@@ -0,0 +1,61 @@
1
+ // ../client/src/index.ts
2
+ var KernelClientError = class extends Error {
3
+ status;
4
+ code;
5
+ details;
6
+ constructor(status, code, message, details) {
7
+ super(message);
8
+ this.name = "KernelClientError";
9
+ this.status = status;
10
+ this.code = code;
11
+ this.details = details;
12
+ }
13
+ };
14
+ function buildSearch(query) {
15
+ if (!query) return "";
16
+ const params = new URLSearchParams();
17
+ if ("where" in query && query.where) params.set("where", JSON.stringify(query.where));
18
+ if ("sort" in query && query.sort) params.set("sort", Array.isArray(query.sort) ? query.sort.join(",") : query.sort);
19
+ if ("limit" in query && query.limit !== void 0) params.set("limit", String(query.limit));
20
+ if ("page" in query && query.page !== void 0) params.set("page", String(query.page));
21
+ if (query.depth !== void 0) params.set("depth", String(query.depth));
22
+ if (query.locale) params.set("locale", query.locale);
23
+ const qs = params.toString();
24
+ return qs ? `?${qs}` : "";
25
+ }
26
+ function createClient(options) {
27
+ const doFetch = options.fetch ?? globalThis.fetch;
28
+ const apiRoute = options.apiRoute ?? "/api";
29
+ const root = options.baseURL.replace(/\/$/, "") + apiRoute;
30
+ async function request(method, path, body) {
31
+ const headers = { "content-type": "application/json", ...options.headers };
32
+ const bearer = options.token ?? options.apiKey;
33
+ if (bearer) headers.authorization = `Bearer ${bearer}`;
34
+ const response = await doFetch(root + path, {
35
+ method,
36
+ headers,
37
+ body: body === void 0 ? void 0 : JSON.stringify(body)
38
+ });
39
+ const text = await response.text();
40
+ const payload = text ? JSON.parse(text) : null;
41
+ if (!response.ok) {
42
+ const err = payload?.error ?? {};
43
+ throw new KernelClientError(response.status, err.code ?? "ERROR", err.message ?? response.statusText, err.details);
44
+ }
45
+ return payload;
46
+ }
47
+ return {
48
+ find: (collection, query) => request("GET", `/${collection}${buildSearch(query)}`),
49
+ findByID: (collection, id, query) => request("GET", `/${collection}/${id}${buildSearch(query)}`),
50
+ create: (collection, data, query) => request("POST", `/${collection}${buildSearch(query)}`, data),
51
+ update: (collection, id, data, query) => request("PATCH", `/${collection}/${id}${buildSearch(query)}`, data),
52
+ remove: (collection, id) => request("DELETE", `/${collection}/${id}`),
53
+ findGlobal: (slug, query) => request("GET", `/globals/${slug}${buildSearch(query)}`),
54
+ updateGlobal: (slug, data) => request("POST", `/globals/${slug}`, data),
55
+ health: () => request("GET", "/health")
56
+ };
57
+ }
58
+ export {
59
+ KernelClientError,
60
+ createClient
61
+ };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @kernel/db — the persistence adapter contract and the storage-facing schema.
3
+ *
4
+ * The operation core (@kernel/core) is written entirely against these types and
5
+ * never imports a driver. Concrete backends (@kernel/db-sqlite, ...) implement
6
+ * `DatabaseAdapter`. This is the contract that makes the "choose everything"
7
+ * promise real.
8
+ */
9
+ type StorageType = 'text' | 'integer' | 'real' | 'boolean' | 'json' | 'timestamp';
10
+ interface ColumnSchema {
11
+ /** Column name (matches the field name). */
12
+ name: string;
13
+ /** Physical storage type the adapter must provision. */
14
+ type: StorageType;
15
+ required: boolean;
16
+ unique: boolean;
17
+ indexed: boolean;
18
+ /** For relationship columns, the collection slug this points at. */
19
+ relationTo?: string;
20
+ /** When true, the stored value is a JSON map of locale -> value. */
21
+ localized: boolean;
22
+ }
23
+ interface TableSchema {
24
+ /** Physical table name. */
25
+ table: string;
26
+ /** Logical slug (collection slug, or the global slug). */
27
+ slug: string;
28
+ /** Columns excluding the system id/timestamp columns the adapter owns. */
29
+ columns: ColumnSchema[];
30
+ timestamps: boolean;
31
+ /** Single-row table backing a global. */
32
+ singleton: boolean;
33
+ }
34
+ interface KernelSchema {
35
+ tables: TableSchema[];
36
+ }
37
+ type AdapterKind = 'db' | 'storage' | 'auth' | 'email' | 'search' | 'cache' | 'queue';
38
+ interface Logger {
39
+ debug: (msg: string, meta?: unknown) => void;
40
+ info: (msg: string, meta?: unknown) => void;
41
+ warn: (msg: string, meta?: unknown) => void;
42
+ error: (msg: string, meta?: unknown) => void;
43
+ }
44
+ interface AdapterContext {
45
+ logger: Logger;
46
+ }
47
+ interface HealthStatus {
48
+ status: 'ok' | 'degraded' | 'down';
49
+ detail?: string;
50
+ }
51
+ interface Adapter {
52
+ readonly kind: AdapterKind;
53
+ readonly name: string;
54
+ readonly contractVersion: `${number}.${number}`;
55
+ init(ctx: AdapterContext): Promise<void>;
56
+ health(): Promise<HealthStatus>;
57
+ destroy(): Promise<void>;
58
+ }
59
+ type WhereOperator = 'equals' | 'not_equals' | 'in' | 'not_in' | 'greater_than' | 'greater_than_equal' | 'less_than' | 'less_than_equal' | 'like' | 'contains' | 'exists';
60
+ type WhereCondition = Partial<Record<WhereOperator, unknown>>;
61
+ interface Where {
62
+ and?: Where[];
63
+ or?: Where[];
64
+ [field: string]: WhereCondition | Where[] | undefined;
65
+ }
66
+ type SortDirection = 'asc' | 'desc';
67
+ interface SortSpec {
68
+ field: string;
69
+ direction: SortDirection;
70
+ }
71
+ type Row = Record<string, unknown>;
72
+ interface FindArgs {
73
+ collection: string;
74
+ where?: Where;
75
+ sort?: SortSpec[];
76
+ limit: number;
77
+ page: number;
78
+ }
79
+ interface PaginatedResult<T = Row> {
80
+ docs: T[];
81
+ totalDocs: number;
82
+ limit: number;
83
+ page: number;
84
+ totalPages: number;
85
+ hasNextPage: boolean;
86
+ hasPrevPage: boolean;
87
+ prevPage: number | null;
88
+ nextPage: number | null;
89
+ pagingCounter: number;
90
+ }
91
+ interface MigrationReport {
92
+ createdTables: string[];
93
+ addedColumns: string[];
94
+ statements: string[];
95
+ }
96
+ interface DatabaseCapabilities {
97
+ transactions: boolean;
98
+ joins: 'native' | 'application';
99
+ jsonQuery: boolean;
100
+ fullTextSearch: boolean;
101
+ returning: boolean;
102
+ }
103
+ interface DatabaseAdapter extends Adapter {
104
+ readonly kind: 'db';
105
+ readonly capabilities: DatabaseCapabilities;
106
+ /** Diff the schema against the live database and apply the changes. */
107
+ migrate(schema: KernelSchema): Promise<MigrationReport>;
108
+ find(args: FindArgs): Promise<PaginatedResult<Row>>;
109
+ findByID(args: {
110
+ collection: string;
111
+ id: string;
112
+ }): Promise<Row | null>;
113
+ create(args: {
114
+ collection: string;
115
+ data: Row;
116
+ }): Promise<Row>;
117
+ update(args: {
118
+ collection: string;
119
+ id: string;
120
+ data: Row;
121
+ }): Promise<Row | null>;
122
+ delete(args: {
123
+ collection: string;
124
+ id: string;
125
+ }): Promise<Row | null>;
126
+ count(args: {
127
+ collection: string;
128
+ where?: Where;
129
+ }): Promise<number>;
130
+ transaction<R>(fn: (tx: DatabaseAdapter) => Promise<R>): Promise<R>;
131
+ }
132
+ type DatabaseAdapterFactory = () => DatabaseAdapter;
133
+
134
+ export type { Adapter as A, ColumnSchema as C, DatabaseAdapter as D, KernelSchema as K, Logger as L, MigrationReport as M, PaginatedResult as P, Row as R, SortSpec as S, TableSchema as T, Where as W, AdapterContext as a, DatabaseAdapterFactory as b, DatabaseCapabilities as c, StorageType as d, WhereCondition as e, WhereOperator as f };