koguma 0.4.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,27 @@
1
+ /**
2
+ * Koguma Admin Dashboard — served inline by the Worker.
3
+ *
4
+ * Imports the pre-built admin bundle from _bundle.ts (auto-generated
5
+ * by scripts/bundle-admin.ts after building the admin Vite app).
6
+ */
7
+
8
+ import { adminJS, adminCSS } from "./_bundle.ts";
9
+
10
+ export function adminHtml(siteName: string): string {
11
+ return `<!DOCTYPE html>
12
+ <html lang="en">
13
+ <head>
14
+ <meta charset="UTF-8" />
15
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
16
+ <title>Admin — ${siteName}</title>
17
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
18
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
19
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
20
+ <style>${adminCSS}</style>
21
+ </head>
22
+ <body>
23
+ <div id="root"></div>
24
+ <script>${adminJS}</script>
25
+ </body>
26
+ </html>`;
27
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Hono API router — auto-generates routes from the Koguma config.
3
+ *
4
+ * Public routes (no auth):
5
+ * GET /api/content/:type → list entries
6
+ * GET /api/content/:type/:id → get one entry
7
+ * GET /api/media/:key → serve media from R2
8
+ *
9
+ * Admin routes (auth required):
10
+ * POST /api/auth/login → login
11
+ * POST /api/auth/logout → logout
12
+ * GET /api/auth/me → check session
13
+ * GET /api/admin/:type → list entries
14
+ * GET /api/admin/:type/:id → get one entry
15
+ * POST /api/admin/:type → create entry
16
+ * PUT /api/admin/:type/:id → update entry
17
+ * DELETE /api/admin/:type/:id → delete entry
18
+ * GET /api/admin/media → list assets
19
+ * POST /api/admin/media → upload asset
20
+ * DELETE /api/admin/media/:id → delete asset
21
+ * GET /api/admin/schema → return content type schemas (for admin UI)
22
+ */
23
+ import { Hono } from 'hono';
24
+ import { cors } from 'hono/cors';
25
+ import { z } from 'zod/v4';
26
+ import type { KogumaConfig, ContentTypeConfig } from '../config/define.ts';
27
+ import {
28
+ getEntry,
29
+ getEntries,
30
+ createEntry,
31
+ updateEntry,
32
+ deleteEntry
33
+ } from '../db/queries.ts';
34
+ import { handleLogin, handleLogout, requireAuth } from '../auth/index.ts';
35
+ import {
36
+ serveMedia,
37
+ uploadMedia,
38
+ listMedia,
39
+ deleteMedia
40
+ } from '../media/index.ts';
41
+
42
+ export function createApiRouter(config: KogumaConfig): Hono {
43
+ const app = new Hono();
44
+
45
+ // CORS for local dev
46
+ app.use('/api/*', cors());
47
+
48
+ // Content type lookup
49
+ const ctMap = new Map<string, ContentTypeConfig>();
50
+ for (const ct of config.contentTypes) {
51
+ ctMap.set(ct.id, ct);
52
+ }
53
+
54
+ // ── Reference resolution ─────────────────────────────────────────
55
+ // Resolves ref, refs, and image fields from flat IDs into nested objects.
56
+ // depth=1 resolves the entry's refs; depth=2 resolves refs-of-refs.
57
+ async function resolveEntry(
58
+ db: Parameters<typeof getEntry>[0],
59
+ entry: Record<string, unknown>,
60
+ ct: ContentTypeConfig,
61
+ depth = 1
62
+ ): Promise<Record<string, unknown>> {
63
+ if (depth < 1) return entry;
64
+ const resolved = { ...entry };
65
+
66
+ for (const [fieldId, meta] of Object.entries(ct.fieldMeta)) {
67
+ if (meta.fieldType === 'image') {
68
+ const assetId = entry[fieldId] as string | null;
69
+ if (assetId) {
70
+ const asset = await db
71
+ .prepare('SELECT * FROM _assets WHERE id = ?')
72
+ .bind(assetId)
73
+ .first();
74
+ resolved[fieldId] = asset ?? null;
75
+ }
76
+ } else if (meta.fieldType === 'reference' && meta.refContentType) {
77
+ const refId = entry[fieldId] as string | null;
78
+ if (refId) {
79
+ const refCt = ctMap.get(meta.refContentType);
80
+ if (refCt) {
81
+ const refEntry = await getEntry(db, refCt, refId);
82
+ resolved[fieldId] = refEntry
83
+ ? await resolveEntry(db, refEntry, refCt, depth - 1)
84
+ : null;
85
+ }
86
+ }
87
+ } else if (meta.fieldType === 'references' && meta.refContentType) {
88
+ const ids = entry[fieldId] as string[] | undefined;
89
+ if (ids?.length) {
90
+ const refCt = ctMap.get(meta.refContentType);
91
+ if (refCt) {
92
+ const items = await Promise.all(
93
+ ids.map(async id => {
94
+ const refEntry = await getEntry(db, refCt, id);
95
+ return refEntry
96
+ ? resolveEntry(db, refEntry, refCt, depth - 1)
97
+ : null;
98
+ })
99
+ );
100
+ resolved[fieldId] = items.filter(Boolean);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ return resolved;
107
+ }
108
+
109
+ // ── Auth routes ──────────────────────────────────────────────────
110
+ app.post('/api/auth/login', handleLogin);
111
+ app.post('/api/auth/logout', handleLogout);
112
+ app.get('/api/auth/me', requireAuth, c => c.json({ authenticated: true }));
113
+
114
+ // ── Public content routes ────────────────────────────────────────
115
+
116
+ app.get('/api/content/:type', async c => {
117
+ const ct = ctMap.get(c.req.param('type'));
118
+ if (!ct) return c.json({ error: 'Unknown content type' }, 404);
119
+
120
+ const db = (c.env as Record<string, unknown>).DB as Parameters<
121
+ typeof getEntries
122
+ >[0];
123
+ const includeDrafts = c.req.query('drafts') === 'true';
124
+ const entries = await getEntries(db, ct, { publishedOnly: !includeDrafts });
125
+ // Always resolve references for public API
126
+ const resolved = await Promise.all(
127
+ entries.map(e => resolveEntry(db, e, ct, 2))
128
+ );
129
+ return c.json({ entries: resolved });
130
+ });
131
+
132
+ app.get('/api/content/:type/:id', async c => {
133
+ const ct = ctMap.get(c.req.param('type'));
134
+ if (!ct) return c.json({ error: 'Unknown content type' }, 404);
135
+
136
+ const db = (c.env as Record<string, unknown>).DB as Parameters<
137
+ typeof getEntry
138
+ >[0];
139
+ const entry = await getEntry(db, ct, c.req.param('id'));
140
+ if (!entry) return c.notFound();
141
+ const resolved = await resolveEntry(db, entry, ct, 2);
142
+ return c.json(resolved);
143
+ });
144
+
145
+ // ── Media serving (public) ───────────────────────────────────────
146
+
147
+ app.get('/api/media/:key', serveMedia);
148
+
149
+ // ── Admin routes (auth required) ─────────────────────────────────
150
+
151
+ app.use('/api/admin/*', requireAuth);
152
+
153
+ // Schema endpoint — admin UI uses this to render forms
154
+ app.get('/api/admin/schema', c => {
155
+ const schema = config.contentTypes.map(ct => ({
156
+ id: ct.id,
157
+ name: ct.name,
158
+ displayField: ct.displayField,
159
+ singleton: ct.singleton ?? false,
160
+ fieldMeta: ct.fieldMeta,
161
+ groups: ct.groups.map(g => ({
162
+ groupId: g.groupId,
163
+ name: g.name,
164
+ helpText: g.helpText,
165
+ collapsed: g.collapsed,
166
+ fieldIds: Object.keys(g.fields)
167
+ }))
168
+ }));
169
+ return c.json({ contentTypes: schema });
170
+ });
171
+
172
+ // ── Media management (admin) — must be before :type catch-all ────
173
+
174
+ app.get('/api/admin/media', listMedia);
175
+ app.post('/api/admin/media', uploadMedia);
176
+ app.delete('/api/admin/media/:id', deleteMedia);
177
+
178
+ // CRUD
179
+ app.get('/api/admin/:type', async c => {
180
+ const ct = ctMap.get(c.req.param('type'));
181
+ if (!ct) return c.json({ error: 'Unknown content type' }, 404);
182
+
183
+ const db = (c.env as Record<string, unknown>).DB as Parameters<
184
+ typeof getEntries
185
+ >[0];
186
+ const entries = await getEntries(db, ct);
187
+ return c.json({ entries });
188
+ });
189
+
190
+ app.get('/api/admin/:type/:id', async c => {
191
+ const ct = ctMap.get(c.req.param('type'));
192
+ if (!ct) return c.json({ error: 'Unknown content type' }, 404);
193
+
194
+ const db = (c.env as Record<string, unknown>).DB as Parameters<
195
+ typeof getEntry
196
+ >[0];
197
+ const entry = await getEntry(db, ct, c.req.param('id'));
198
+ if (!entry) return c.notFound();
199
+ return c.json(entry);
200
+ });
201
+
202
+ app.post('/api/admin/:type', async c => {
203
+ const ct = ctMap.get(c.req.param('type'));
204
+ if (!ct) return c.json({ error: 'Unknown content type' }, 404);
205
+
206
+ const db = (c.env as Record<string, unknown>).DB as Parameters<
207
+ typeof createEntry
208
+ >[0];
209
+ const data = await c.req.json();
210
+
211
+ // D1 stores booleans as 0/1 — coerce back before validation
212
+ for (const [fid, meta] of Object.entries(ct.fieldMeta)) {
213
+ if (
214
+ meta.fieldType === 'boolean' &&
215
+ fid in data &&
216
+ typeof data[fid] === 'number'
217
+ ) {
218
+ data[fid] = Boolean(data[fid]);
219
+ }
220
+ }
221
+
222
+ // Validate with Zod schema
223
+ const result = ct.schema.safeParse(data);
224
+ if (!result.success) {
225
+ const issues = (result.error as z.ZodError).issues.map(issue => ({
226
+ field: issue.path.join('.'),
227
+ message: issue.message
228
+ }));
229
+ return c.json({ error: 'Validation failed', issues }, 422);
230
+ }
231
+
232
+ const entry = await createEntry(db, ct, data);
233
+ return c.json(entry, 201);
234
+ });
235
+
236
+ app.put('/api/admin/:type/:id', async c => {
237
+ const ct = ctMap.get(c.req.param('type'));
238
+ if (!ct) return c.json({ error: 'Unknown content type' }, 404);
239
+
240
+ const db = (c.env as Record<string, unknown>).DB as Parameters<
241
+ typeof updateEntry
242
+ >[0];
243
+ const data = await c.req.json();
244
+
245
+ // D1 stores booleans as 0/1 — coerce back before validation
246
+ for (const [fid, meta] of Object.entries(ct.fieldMeta)) {
247
+ if (
248
+ meta.fieldType === 'boolean' &&
249
+ fid in data &&
250
+ typeof data[fid] === 'number'
251
+ ) {
252
+ data[fid] = Boolean(data[fid]);
253
+ }
254
+ }
255
+
256
+ // Partial validation — only validate fields present in request
257
+ const result = (ct.schema as z.ZodObject<Record<string, z.ZodType>>)
258
+ .partial()
259
+ .safeParse(data);
260
+ if (!result.success) {
261
+ const issues = (result.error as z.ZodError).issues.map(issue => ({
262
+ field: issue.path.join('.'),
263
+ message: issue.message
264
+ }));
265
+ return c.json({ error: 'Validation failed', issues }, 422);
266
+ }
267
+
268
+ const entry = await updateEntry(db, ct, c.req.param('id'), data);
269
+ if (!entry) return c.notFound();
270
+ return c.json(entry);
271
+ });
272
+
273
+ app.put('/api/admin/:type', async c => {
274
+ // For singleton types — update the first (only) entry
275
+ const ct = ctMap.get(c.req.param('type'));
276
+ if (!ct) return c.json({ error: 'Unknown content type' }, 404);
277
+
278
+ const db = (c.env as Record<string, unknown>).DB as Parameters<
279
+ typeof getEntries
280
+ >[0];
281
+ const entries = await getEntries(db, ct);
282
+ if (entries.length === 0) return c.json({ error: 'No entry found' }, 404);
283
+
284
+ const data = await c.req.json();
285
+ const entry = await updateEntry(
286
+ db as Parameters<typeof updateEntry>[0],
287
+ ct,
288
+ entries[0]!.id as string,
289
+ data
290
+ );
291
+ return c.json(entry);
292
+ });
293
+
294
+ app.delete('/api/admin/:type/:id', async c => {
295
+ const ct = ctMap.get(c.req.param('type'));
296
+ if (!ct) return c.json({ error: 'Unknown content type' }, 404);
297
+
298
+ const db = (c.env as Record<string, unknown>).DB as Parameters<
299
+ typeof deleteEntry
300
+ >[0];
301
+ await deleteEntry(db, ct, c.req.param('id'));
302
+ return c.json({ ok: true });
303
+ });
304
+
305
+ // Publish / Unpublish convenience endpoints
306
+ app.post('/api/admin/:type/:id/publish', async c => {
307
+ const ct = ctMap.get(c.req.param('type'));
308
+ if (!ct) return c.json({ error: 'Unknown content type' }, 404);
309
+
310
+ const db = (c.env as Record<string, unknown>).DB as Parameters<
311
+ typeof updateEntry
312
+ >[0];
313
+ const entry = await updateEntry(db, ct, c.req.param('id'), {
314
+ status: 'published',
315
+ publishAt: null // clear scheduled time — publish immediately
316
+ });
317
+ if (!entry) return c.notFound();
318
+ return c.json(entry);
319
+ });
320
+
321
+ app.post('/api/admin/:type/:id/unpublish', async c => {
322
+ const ct = ctMap.get(c.req.param('type'));
323
+ if (!ct) return c.json({ error: 'Unknown content type' }, 404);
324
+
325
+ const db = (c.env as Record<string, unknown>).DB as Parameters<
326
+ typeof updateEntry
327
+ >[0];
328
+ const entry = await updateEntry(db, ct, c.req.param('id'), {
329
+ status: 'draft',
330
+ publishAt: null // clear scheduled time
331
+ });
332
+ if (!entry) return c.notFound();
333
+ return c.json(entry);
334
+ });
335
+
336
+ // Schedule — set a future publish time
337
+ app.post('/api/admin/:type/:id/schedule', async c => {
338
+ const ct = ctMap.get(c.req.param('type'));
339
+ if (!ct) return c.json({ error: 'Unknown content type' }, 404);
340
+
341
+ const db = (c.env as Record<string, unknown>).DB as Parameters<
342
+ typeof updateEntry
343
+ >[0];
344
+ const body = await c.req.json<{ publishAt: string }>();
345
+ if (!body.publishAt) {
346
+ return c.json({ error: 'publishAt is required' }, 400);
347
+ }
348
+ const entry = await updateEntry(db, ct, c.req.param('id'), {
349
+ status: 'published',
350
+ publishAt: body.publishAt
351
+ });
352
+ if (!entry) return c.notFound();
353
+ return c.json(entry);
354
+ });
355
+
356
+ return app;
357
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Auth middleware — simple password-based login with cookie sessions.
3
+ *
4
+ * v1: Single shared password stored as KOGUMA_SECRET Wrangler secret.
5
+ * Sessions use HMAC-signed cookies (no external dependencies).
6
+ */
7
+ import type { Context, Next } from "hono";
8
+
9
+ const SESSION_COOKIE = "koguma_session";
10
+ const SESSION_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
11
+
12
+ // ── HMAC signing ─────────────────────────────────────────────────────
13
+
14
+ async function hmacSign(payload: string, secret: string): Promise<string> {
15
+ const encoder = new TextEncoder();
16
+ const key = await crypto.subtle.importKey(
17
+ "raw",
18
+ encoder.encode(secret),
19
+ { name: "HMAC", hash: "SHA-256" },
20
+ false,
21
+ ["sign"],
22
+ );
23
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
24
+ const sig = btoa(String.fromCharCode(...new Uint8Array(signature)));
25
+ return `${payload}.${sig}`;
26
+ }
27
+
28
+ async function hmacVerify(token: string, secret: string): Promise<string | null> {
29
+ const lastDot = token.lastIndexOf(".");
30
+ if (lastDot === -1) return null;
31
+ const payload = token.slice(0, lastDot);
32
+ const expected = await hmacSign(payload, secret);
33
+ // Constant-time compare
34
+ if (expected.length !== token.length) return null;
35
+ let diff = 0;
36
+ for (let i = 0; i < expected.length; i++) {
37
+ diff |= expected.charCodeAt(i) ^ token.charCodeAt(i);
38
+ }
39
+ return diff === 0 ? payload : null;
40
+ }
41
+
42
+ // ── Cookie helpers ───────────────────────────────────────────────────
43
+
44
+ function getCookie(c: Context, name: string): string | undefined {
45
+ const header = c.req.header("cookie") ?? "";
46
+ const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
47
+ return match?.[1];
48
+ }
49
+
50
+ function setCookie(c: Context, name: string, value: string, maxAge: number) {
51
+ c.header(
52
+ "Set-Cookie",
53
+ `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`,
54
+ );
55
+ }
56
+
57
+ function clearCookie(c: Context, name: string) {
58
+ c.header(
59
+ "Set-Cookie",
60
+ `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`,
61
+ );
62
+ }
63
+
64
+ // ── Login handler ────────────────────────────────────────────────────
65
+
66
+ export async function handleLogin(c: Context): Promise<Response> {
67
+ const secret = (c.env as Record<string, string>).KOGUMA_SECRET;
68
+ if (!secret) {
69
+ return c.json({ error: "KOGUMA_SECRET not configured" }, 500);
70
+ }
71
+
72
+ const body = await c.req.json<{ password?: string }>();
73
+ if (!body.password) {
74
+ return c.json({ error: "Password required" }, 400);
75
+ }
76
+
77
+ // Constant-time compare
78
+ const encoder = new TextEncoder();
79
+ const a = encoder.encode(body.password);
80
+ const b = encoder.encode(secret);
81
+ if (a.length !== b.length) {
82
+ return c.json({ error: "Invalid password" }, 401);
83
+ }
84
+ let diff = 0;
85
+ for (let i = 0; i < a.length; i++) {
86
+ diff |= a[i]! ^ b[i]!;
87
+ }
88
+ if (diff !== 0) {
89
+ return c.json({ error: "Invalid password" }, 401);
90
+ }
91
+
92
+ // Create signed session token
93
+ const payload = JSON.stringify({
94
+ exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE,
95
+ });
96
+ const token = await hmacSign(payload, secret);
97
+ setCookie(c, SESSION_COOKIE, token, SESSION_MAX_AGE);
98
+
99
+ return c.json({ ok: true });
100
+ }
101
+
102
+ // ── Logout handler ───────────────────────────────────────────────────
103
+
104
+ export function handleLogout(c: Context): Response {
105
+ clearCookie(c, SESSION_COOKIE);
106
+ return c.json({ ok: true });
107
+ }
108
+
109
+ // ── Auth middleware ──────────────────────────────────────────────────
110
+
111
+ export async function requireAuth(c: Context, next: Next): Promise<Response | void> {
112
+ const secret = (c.env as Record<string, string>).KOGUMA_SECRET;
113
+ if (!secret) {
114
+ return c.json({ error: "KOGUMA_SECRET not configured" }, 500);
115
+ }
116
+
117
+ const token = getCookie(c, SESSION_COOKIE);
118
+ if (!token) {
119
+ return c.json({ error: "Not authenticated" }, 401);
120
+ }
121
+
122
+ const payload = await hmacVerify(token, secret);
123
+ if (!payload) {
124
+ return c.json({ error: "Invalid session" }, 401);
125
+ }
126
+
127
+ try {
128
+ const data = JSON.parse(payload) as { exp: number };
129
+ if (data.exp < Math.floor(Date.now() / 1000)) {
130
+ clearCookie(c, SESSION_COOKIE);
131
+ return c.json({ error: "Session expired" }, 401);
132
+ }
133
+ } catch {
134
+ return c.json({ error: "Invalid session" }, 401);
135
+ }
136
+
137
+ await next();
138
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Koguma browser client — typed fetch wrapper for the CMS API.
3
+ *
4
+ * Usage:
5
+ * import { createClient } from "koguma/client";
6
+ * const koguma = createClient();
7
+ * const page = await koguma.get<FourDirectionsPage>("fourDirectionsPage", "single");
8
+ * const cards = await koguma.list<FeatureCard>("featureCard");
9
+ */
10
+
11
+ interface KogumaClientOptions {
12
+ /** Base URL for the API. Defaults to "" (same origin — works in Cloudflare Workers). */
13
+ baseUrl?: string;
14
+ }
15
+
16
+ export interface KogumaClient {
17
+ /** Fetch a single entry by ID (or "single" for singleton types) */
18
+ get<T = Record<string, unknown>>(contentType: string, id: string): Promise<T>;
19
+ /** Fetch all entries of a given type */
20
+ list<T = Record<string, unknown>>(contentType: string): Promise<T[]>;
21
+ }
22
+
23
+ export function createClient(opts: KogumaClientOptions = {}): KogumaClient {
24
+ const base = opts.baseUrl ?? "";
25
+
26
+ async function apiFetch<T>(url: string): Promise<T> {
27
+ const res = await fetch(`${base}${url}`);
28
+ if (!res.ok) {
29
+ throw new Error(`Koguma API error: ${res.status} ${res.statusText} (${url})`);
30
+ }
31
+ return res.json() as Promise<T>;
32
+ }
33
+
34
+ return {
35
+ async get<T>(contentType: string, id: string): Promise<T> {
36
+ // "single" is a convenience alias for singleton types
37
+ if (id === "single") {
38
+ const data = await apiFetch<{ entries: T[] }>(`/api/content/${contentType}`);
39
+ const entry = data.entries[0];
40
+ if (!entry) throw new Error(`No entry found for ${contentType}`);
41
+ return entry;
42
+ }
43
+ return apiFetch<T>(`/api/content/${contentType}/${id}`);
44
+ },
45
+
46
+ async list<T>(contentType: string): Promise<T[]> {
47
+ const data = await apiFetch<{ entries: T[] }>(`/api/content/${contentType}`);
48
+ return data.entries;
49
+ },
50
+ };
51
+ }
52
+
53
+ /** Singleton client — shared across the app */
54
+ let _client: KogumaClient | null = null;
55
+
56
+ export function getClient(): KogumaClient {
57
+ if (!_client) {
58
+ _client = createClient();
59
+ }
60
+ return _client;
61
+ }