@agntcms/next 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/dist/assets-Cyt9upqW.d.cts +290 -0
- package/dist/assets-P8OCigDG.d.ts +290 -0
- package/dist/client.cjs +13244 -0
- package/dist/client.d.cts +806 -0
- package/dist/client.d.ts +806 -0
- package/dist/client.mjs +13234 -0
- package/dist/config.cjs +240 -0
- package/dist/config.d.cts +112 -0
- package/dist/config.d.ts +112 -0
- package/dist/config.mjs +194 -0
- package/dist/defineForm-Bp9vzW56.d.ts +71 -0
- package/dist/defineForm-CJ8KZC93.d.cts +71 -0
- package/dist/defineSection-9qQ5ulAH.d.cts +243 -0
- package/dist/defineSection-Kr0pWqMY.d.ts +243 -0
- package/dist/form-BqY0H1V5.d.cts +753 -0
- package/dist/form-BqY0H1V5.d.ts +753 -0
- package/dist/global-CV23g5Bn.d.cts +15 -0
- package/dist/global-CV23g5Bn.d.ts +15 -0
- package/dist/handlers.cjs +2525 -0
- package/dist/handlers.d.cts +330 -0
- package/dist/handlers.d.ts +330 -0
- package/dist/handlers.mjs +2473 -0
- package/dist/index.cjs +372 -0
- package/dist/index.d.cts +133 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.mjs +319 -0
- package/dist/rateLimit-CXptRM_K.d.ts +391 -0
- package/dist/rateLimit-CiROGTLE.d.cts +391 -0
- package/dist/registry-CraTTwT7.d.cts +29 -0
- package/dist/registry-DMujGqt0.d.ts +29 -0
- package/dist/server.cjs +1970 -0
- package/dist/server.d.cts +153 -0
- package/dist/server.d.ts +153 -0
- package/dist/server.mjs +1889 -0
- package/package.json +62 -0
|
@@ -0,0 +1,2473 @@
|
|
|
1
|
+
// src/storage/fs/assets.ts
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import * as fs2 from "fs/promises";
|
|
4
|
+
import * as path2 from "path";
|
|
5
|
+
|
|
6
|
+
// src/storage/fs/_helpers.ts
|
|
7
|
+
import * as fs from "fs/promises";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
var isEnoent = (err) => typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
10
|
+
var defaultRandomSuffix = () => Math.random().toString(36).slice(2);
|
|
11
|
+
var writeAtomic = async (target, data, randomSuffix = defaultRandomSuffix) => {
|
|
12
|
+
const dir = path.dirname(target);
|
|
13
|
+
await fs.mkdir(dir, { recursive: true });
|
|
14
|
+
const tmp = path.join(
|
|
15
|
+
dir,
|
|
16
|
+
`.${path.basename(target)}.${process.pid}.${randomSuffix()}.tmp`
|
|
17
|
+
);
|
|
18
|
+
try {
|
|
19
|
+
await fs.writeFile(tmp, data);
|
|
20
|
+
await fs.rename(tmp, target);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
await fs.unlink(tmp).catch(() => {
|
|
23
|
+
});
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
var resolveUnderBucket = (bucket, key, errorLabel, errorKey = key) => {
|
|
28
|
+
const resolved = path.resolve(bucket, key);
|
|
29
|
+
const bucketWithSep = bucket.endsWith(path.sep) ? bucket : bucket + path.sep;
|
|
30
|
+
if (!resolved.startsWith(bucketWithSep)) {
|
|
31
|
+
throw new Error(`${errorLabel}: ${JSON.stringify(errorKey)}`);
|
|
32
|
+
}
|
|
33
|
+
return resolved;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// src/storage/fs/assets.ts
|
|
37
|
+
var EXT_PATTERN = /^\.[a-z0-9_-]{1,16}$/;
|
|
38
|
+
var JSON_SUFFIX = ".json";
|
|
39
|
+
var extensionFrom = (filename) => {
|
|
40
|
+
const dot = filename.lastIndexOf(".");
|
|
41
|
+
if (dot < 0 || dot === filename.length - 1) return "";
|
|
42
|
+
const raw = filename.slice(dot).toLowerCase();
|
|
43
|
+
return EXT_PATTERN.test(raw) ? raw : "";
|
|
44
|
+
};
|
|
45
|
+
var CONTENT_TYPE_BY_EXT = {
|
|
46
|
+
".png": "image/png",
|
|
47
|
+
".jpg": "image/jpeg",
|
|
48
|
+
".jpeg": "image/jpeg",
|
|
49
|
+
".gif": "image/gif",
|
|
50
|
+
".webp": "image/webp",
|
|
51
|
+
".svg": "image/svg+xml",
|
|
52
|
+
".avif": "image/avif"
|
|
53
|
+
};
|
|
54
|
+
var contentTypeFor = (filename) => {
|
|
55
|
+
const dot = filename.lastIndexOf(".");
|
|
56
|
+
if (dot < 0) return void 0;
|
|
57
|
+
const ext = filename.slice(dot).toLowerCase();
|
|
58
|
+
return CONTENT_TYPE_BY_EXT[ext];
|
|
59
|
+
};
|
|
60
|
+
var createFsAssetAdapter = (options) => {
|
|
61
|
+
const { assetsRoot, publicUrlBase } = options;
|
|
62
|
+
if (!path2.isAbsolute(assetsRoot)) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`assetsRoot must be an absolute path, got: ${JSON.stringify(assetsRoot)}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (publicUrlBase.endsWith("/")) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`publicUrlBase must not end with '/', got: ${JSON.stringify(publicUrlBase)}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const rootResolved = path2.resolve(assetsRoot);
|
|
73
|
+
const rootWithSep = rootResolved.endsWith(path2.sep) ? rootResolved : rootResolved + path2.sep;
|
|
74
|
+
const upload = async (input) => {
|
|
75
|
+
const hash = createHash("sha256").update(input.bytes).digest("hex");
|
|
76
|
+
const ext = extensionFrom(input.filename);
|
|
77
|
+
const storedName = `${hash}${ext}`;
|
|
78
|
+
const target = path2.resolve(rootResolved, storedName);
|
|
79
|
+
if (!target.startsWith(rootWithSep)) {
|
|
80
|
+
throw new Error(`asset path escapes assetsRoot: ${JSON.stringify(target)}`);
|
|
81
|
+
}
|
|
82
|
+
let bytesExist = false;
|
|
83
|
+
try {
|
|
84
|
+
await fs2.stat(target);
|
|
85
|
+
bytesExist = true;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (!isEnoent(err)) throw err;
|
|
88
|
+
}
|
|
89
|
+
if (!bytesExist) {
|
|
90
|
+
await writeAtomic(target, input.bytes);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
url: `${publicUrlBase}/${storedName}`,
|
|
94
|
+
filename: storedName
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
const list = async () => {
|
|
98
|
+
let names;
|
|
99
|
+
try {
|
|
100
|
+
names = await fs2.readdir(rootResolved);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (isEnoent(err)) return [];
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
const candidates = names.filter(
|
|
106
|
+
(n) => !n.startsWith(".") && !n.endsWith(JSON_SUFFIX)
|
|
107
|
+
);
|
|
108
|
+
const entries = [];
|
|
109
|
+
for (const filename of candidates) {
|
|
110
|
+
const full = path2.join(rootResolved, filename);
|
|
111
|
+
let stat3;
|
|
112
|
+
try {
|
|
113
|
+
stat3 = await fs2.stat(full);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (isEnoent(err)) continue;
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
if (!stat3.isFile()) continue;
|
|
119
|
+
entries.push({
|
|
120
|
+
filename,
|
|
121
|
+
url: `${publicUrlBase}/${filename}`,
|
|
122
|
+
contentType: contentTypeFor(filename),
|
|
123
|
+
modifiedAt: stat3.mtime
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
entries.sort((a, b) => {
|
|
127
|
+
const delta = b.modifiedAt.getTime() - a.modifiedAt.getTime();
|
|
128
|
+
if (delta !== 0) return delta;
|
|
129
|
+
return a.filename.localeCompare(b.filename);
|
|
130
|
+
});
|
|
131
|
+
return entries;
|
|
132
|
+
};
|
|
133
|
+
return { upload, list };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// src/storage/fs/content.ts
|
|
137
|
+
import { constants as fsConstants } from "fs";
|
|
138
|
+
import * as fs3 from "fs/promises";
|
|
139
|
+
import * as path3 from "path";
|
|
140
|
+
|
|
141
|
+
// src/domain/page.ts
|
|
142
|
+
function assertValidPageMeta(obj) {
|
|
143
|
+
if (typeof obj["slug"] !== "string" || obj["slug"] === "") {
|
|
144
|
+
throw new Error("invalid page: missing or empty slug");
|
|
145
|
+
}
|
|
146
|
+
const slug = obj["slug"];
|
|
147
|
+
const rawSeo = obj["seo"];
|
|
148
|
+
if (rawSeo === null || typeof rawSeo !== "object") {
|
|
149
|
+
throw new Error(`invalid page "${slug}": seo must be an object`);
|
|
150
|
+
}
|
|
151
|
+
const seo = rawSeo;
|
|
152
|
+
if (typeof seo["title"] !== "string" || seo["title"].trim() === "") {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`invalid page "${slug}": seo.title must be a non-empty string`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (typeof seo["description"] !== "string" || seo["description"].trim() === "") {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`invalid page "${slug}": seo.description must be a non-empty string`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (seo["canonical"] !== void 0) {
|
|
163
|
+
if (typeof seo["canonical"] !== "string" || seo["canonical"].trim() === "") {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`invalid page "${slug}": seo.canonical, when present, must be a non-empty string`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function assertValidPage(page) {
|
|
171
|
+
if (page === null || typeof page !== "object") {
|
|
172
|
+
throw new Error("invalid page: expected an object");
|
|
173
|
+
}
|
|
174
|
+
const obj = page;
|
|
175
|
+
if (typeof obj["slug"] !== "string" || obj["slug"] === "") {
|
|
176
|
+
throw new Error("invalid page: missing or empty slug");
|
|
177
|
+
}
|
|
178
|
+
const slug = obj["slug"];
|
|
179
|
+
if (!Array.isArray(obj["sections"])) {
|
|
180
|
+
throw new Error(`invalid page "${slug}": sections must be an array`);
|
|
181
|
+
}
|
|
182
|
+
assertValidPageMeta(obj);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/domain/form.ts
|
|
186
|
+
var SubmissionsNotReadableError = class extends Error {
|
|
187
|
+
constructor(message = "submission adapter does not support reading") {
|
|
188
|
+
super(message);
|
|
189
|
+
this.name = "SubmissionsNotReadableError";
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// src/storage/fs/content.ts
|
|
194
|
+
var SLUG_PATTERN = /^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/;
|
|
195
|
+
var assertValidSlug = (slug) => {
|
|
196
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
197
|
+
throw new Error(`invalid slug: ${JSON.stringify(slug)}`);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
var deepEqual = (a, b) => {
|
|
201
|
+
if (a === b) return true;
|
|
202
|
+
if (typeof a !== typeof b) return false;
|
|
203
|
+
if (a === null || b === null) return false;
|
|
204
|
+
if (typeof a !== "object") return false;
|
|
205
|
+
const aIsArr = Array.isArray(a);
|
|
206
|
+
const bIsArr = Array.isArray(b);
|
|
207
|
+
if (aIsArr !== bIsArr) return false;
|
|
208
|
+
if (aIsArr) {
|
|
209
|
+
const arrA = a;
|
|
210
|
+
const arrB = b;
|
|
211
|
+
if (arrA.length !== arrB.length) return false;
|
|
212
|
+
for (let i = 0; i < arrA.length; i += 1) {
|
|
213
|
+
if (!deepEqual(arrA[i], arrB[i])) return false;
|
|
214
|
+
}
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
const objA = a;
|
|
218
|
+
const objB = b;
|
|
219
|
+
const keysA = Object.keys(objA);
|
|
220
|
+
const keysB = Object.keys(objB);
|
|
221
|
+
if (keysA.length !== keysB.length) return false;
|
|
222
|
+
for (const key of keysA) {
|
|
223
|
+
if (!Object.prototype.hasOwnProperty.call(objB, key)) return false;
|
|
224
|
+
if (!deepEqual(objA[key], objB[key])) return false;
|
|
225
|
+
}
|
|
226
|
+
return true;
|
|
227
|
+
};
|
|
228
|
+
var createFsContentAdapter = (options) => {
|
|
229
|
+
const { contentRoot } = options;
|
|
230
|
+
if (!path3.isAbsolute(contentRoot)) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`contentRoot must be an absolute path, got: ${JSON.stringify(contentRoot)}`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const pagesDir = path3.resolve(contentRoot, "pages");
|
|
236
|
+
const draftsDir = path3.resolve(contentRoot, "drafts");
|
|
237
|
+
const historyDir = path3.resolve(contentRoot, "history");
|
|
238
|
+
const globalsDir = path3.resolve(contentRoot, "globals");
|
|
239
|
+
const globalsHistoryDir = path3.resolve(contentRoot, "history-globals");
|
|
240
|
+
const resolveSlugPath = (bucket, slug, ext) => {
|
|
241
|
+
assertValidSlug(slug);
|
|
242
|
+
return resolveUnderBucket(bucket, `${slug}${ext}`, "slug escapes storage bucket", slug);
|
|
243
|
+
};
|
|
244
|
+
const readJsonOrNull = async (filePath) => {
|
|
245
|
+
try {
|
|
246
|
+
const raw = await fs3.readFile(filePath, { encoding: "utf8" });
|
|
247
|
+
return JSON.parse(raw);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (isEnoent(err)) return null;
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const listJsonFiles2 = async (dir) => {
|
|
254
|
+
let entries;
|
|
255
|
+
try {
|
|
256
|
+
entries = await fs3.readdir(dir, { withFileTypes: true });
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (isEnoent(err)) return [];
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
const results = [];
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
264
|
+
results.push(entry.name);
|
|
265
|
+
} else if (entry.isDirectory()) {
|
|
266
|
+
const subDir = path3.join(dir, entry.name);
|
|
267
|
+
const subFiles = await listJsonFiles2(subDir);
|
|
268
|
+
for (const sub of subFiles) {
|
|
269
|
+
results.push(`${entry.name}/${sub}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return results;
|
|
274
|
+
};
|
|
275
|
+
const bucketFor = (mode) => mode === "published" ? pagesDir : draftsDir;
|
|
276
|
+
const uniqueTimestampedPath = async (baseDir, key, errorLabel) => {
|
|
277
|
+
const keyDir = resolveUnderBucket(baseDir, key, errorLabel);
|
|
278
|
+
await fs3.mkdir(keyDir, { recursive: true });
|
|
279
|
+
const base = (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-");
|
|
280
|
+
let candidate = path3.join(keyDir, `${base}.json`);
|
|
281
|
+
let suffix = 0;
|
|
282
|
+
while (true) {
|
|
283
|
+
try {
|
|
284
|
+
await fs3.access(candidate, fsConstants.F_OK);
|
|
285
|
+
suffix += 1;
|
|
286
|
+
candidate = path3.join(keyDir, `${base}-${suffix}.json`);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (isEnoent(err)) return candidate;
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
const uniqueHistoryPath = async (slug) => {
|
|
294
|
+
assertValidSlug(slug);
|
|
295
|
+
return uniqueTimestampedPath(historyDir, slug, "slug escapes history bucket");
|
|
296
|
+
};
|
|
297
|
+
const readPage = async (slug, mode) => {
|
|
298
|
+
const filePath = resolveSlugPath(bucketFor(mode), slug, ".json");
|
|
299
|
+
return readJsonOrNull(filePath);
|
|
300
|
+
};
|
|
301
|
+
const saveDraft = async (page) => {
|
|
302
|
+
const filePath = resolveSlugPath(draftsDir, page.slug, ".json");
|
|
303
|
+
await writeAtomic(filePath, JSON.stringify(page, null, 2));
|
|
304
|
+
};
|
|
305
|
+
const listDrafts = async () => {
|
|
306
|
+
const files = await listJsonFiles2(draftsDir);
|
|
307
|
+
const results = [];
|
|
308
|
+
for (const relPath of files) {
|
|
309
|
+
const slug = relPath.slice(0, -".json".length);
|
|
310
|
+
const stat3 = await fs3.stat(path3.join(draftsDir, relPath));
|
|
311
|
+
results.push({ slug, updatedAt: stat3.mtime });
|
|
312
|
+
}
|
|
313
|
+
return results;
|
|
314
|
+
};
|
|
315
|
+
const listPages = async () => {
|
|
316
|
+
const files = await listJsonFiles2(pagesDir);
|
|
317
|
+
const results = [];
|
|
318
|
+
for (const relPath of files) {
|
|
319
|
+
const slug = relPath.slice(0, -".json".length);
|
|
320
|
+
const stat3 = await fs3.stat(path3.join(pagesDir, relPath));
|
|
321
|
+
results.push({ slug, updatedAt: stat3.mtime });
|
|
322
|
+
}
|
|
323
|
+
return results;
|
|
324
|
+
};
|
|
325
|
+
const listPageSummaries = async () => {
|
|
326
|
+
const files = await listJsonFiles2(pagesDir);
|
|
327
|
+
const results = [];
|
|
328
|
+
for (const relPath of files) {
|
|
329
|
+
const slug = relPath.slice(0, -".json".length);
|
|
330
|
+
const filePath = path3.join(pagesDir, relPath);
|
|
331
|
+
const page = await readJsonOrNull(filePath);
|
|
332
|
+
if (page === null) continue;
|
|
333
|
+
const stat3 = await fs3.stat(filePath).catch(() => null);
|
|
334
|
+
if (stat3 === null) continue;
|
|
335
|
+
const { sections: _sections, ...meta } = page;
|
|
336
|
+
void _sections;
|
|
337
|
+
results.push({ ...meta, slug, updatedAt: stat3.mtime });
|
|
338
|
+
}
|
|
339
|
+
return results;
|
|
340
|
+
};
|
|
341
|
+
const readLatestSnapshotIn = async (baseDir, key, errorLabel) => {
|
|
342
|
+
const keyDir = resolveUnderBucket(baseDir, key, errorLabel);
|
|
343
|
+
let entries;
|
|
344
|
+
try {
|
|
345
|
+
entries = await fs3.readdir(keyDir, { withFileTypes: true });
|
|
346
|
+
} catch (err) {
|
|
347
|
+
if (isEnoent(err)) return null;
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
let latest = null;
|
|
351
|
+
for (const entry of entries) {
|
|
352
|
+
if (!entry.isFile()) continue;
|
|
353
|
+
if (!entry.name.endsWith(".json")) continue;
|
|
354
|
+
if (latest === null || entry.name > latest) latest = entry.name;
|
|
355
|
+
}
|
|
356
|
+
if (latest === null) return null;
|
|
357
|
+
return readJsonOrNull(path3.join(keyDir, latest));
|
|
358
|
+
};
|
|
359
|
+
const readLatestHistorySnapshot = async (slug) => {
|
|
360
|
+
assertValidSlug(slug);
|
|
361
|
+
return readLatestSnapshotIn(historyDir, slug, "slug escapes history bucket");
|
|
362
|
+
};
|
|
363
|
+
const publishDraft = async (slug) => {
|
|
364
|
+
const draftPath = resolveSlugPath(draftsDir, slug, ".json");
|
|
365
|
+
const pagePath = resolveSlugPath(pagesDir, slug, ".json");
|
|
366
|
+
const page = await readJsonOrNull(draftPath);
|
|
367
|
+
if (page === null) {
|
|
368
|
+
throw new Error(`no draft to publish for slug: ${JSON.stringify(slug)}`);
|
|
369
|
+
}
|
|
370
|
+
assertValidPage(page);
|
|
371
|
+
const serialised = JSON.stringify(page, null, 2);
|
|
372
|
+
const latestSnapshot = await readLatestHistorySnapshot(slug);
|
|
373
|
+
const skipHistory = latestSnapshot !== null && deepEqual(latestSnapshot, page);
|
|
374
|
+
if (!skipHistory) {
|
|
375
|
+
const historyPath = await uniqueHistoryPath(slug);
|
|
376
|
+
await writeAtomic(historyPath, serialised);
|
|
377
|
+
}
|
|
378
|
+
await writeAtomic(pagePath, serialised);
|
|
379
|
+
try {
|
|
380
|
+
await fs3.unlink(draftPath);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
if (!isEnoent(err)) throw err;
|
|
383
|
+
}
|
|
384
|
+
return page;
|
|
385
|
+
};
|
|
386
|
+
const deleteDraft = async (slug) => {
|
|
387
|
+
const draftPath = resolveSlugPath(draftsDir, slug, ".json");
|
|
388
|
+
try {
|
|
389
|
+
await fs3.unlink(draftPath);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
if (isEnoent(err)) {
|
|
392
|
+
throw new Error(`no draft to discard for slug: ${JSON.stringify(slug)}`);
|
|
393
|
+
}
|
|
394
|
+
throw err;
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
const deletePage = async (slug) => {
|
|
398
|
+
const publishedPath = resolveSlugPath(pagesDir, slug, ".json");
|
|
399
|
+
try {
|
|
400
|
+
await fs3.access(publishedPath);
|
|
401
|
+
} catch {
|
|
402
|
+
throw new Error(`page not found: ${slug}`);
|
|
403
|
+
}
|
|
404
|
+
await fs3.unlink(publishedPath);
|
|
405
|
+
const draftPath = resolveSlugPath(draftsDir, slug, ".json");
|
|
406
|
+
try {
|
|
407
|
+
await fs3.unlink(draftPath);
|
|
408
|
+
} catch {
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const unpublishPage = async (slug) => {
|
|
412
|
+
const publishedPath = resolveSlugPath(pagesDir, slug, ".json");
|
|
413
|
+
const draftPath = resolveSlugPath(draftsDir, slug, ".json");
|
|
414
|
+
const published = await readJsonOrNull(publishedPath);
|
|
415
|
+
if (published === null) {
|
|
416
|
+
throw new Error(`page not found: ${slug}`);
|
|
417
|
+
}
|
|
418
|
+
const existingDraft = await readJsonOrNull(draftPath);
|
|
419
|
+
if (existingDraft === null) {
|
|
420
|
+
await writeAtomic(draftPath, JSON.stringify(published, null, 2));
|
|
421
|
+
}
|
|
422
|
+
await fs3.unlink(publishedPath);
|
|
423
|
+
};
|
|
424
|
+
const listHistory = async (slug) => {
|
|
425
|
+
assertValidSlug(slug);
|
|
426
|
+
const slugDir = resolveUnderBucket(historyDir, slug, "slug escapes history bucket");
|
|
427
|
+
let entries;
|
|
428
|
+
try {
|
|
429
|
+
entries = await fs3.readdir(slugDir, { withFileTypes: true });
|
|
430
|
+
} catch (err) {
|
|
431
|
+
if (isEnoent(err)) return [];
|
|
432
|
+
throw err;
|
|
433
|
+
}
|
|
434
|
+
const results = [];
|
|
435
|
+
for (const entry of entries) {
|
|
436
|
+
if (!entry.isFile()) continue;
|
|
437
|
+
if (!entry.name.endsWith(".json")) continue;
|
|
438
|
+
const timestamp = entry.name.slice(0, -".json".length);
|
|
439
|
+
const stat3 = await fs3.stat(path3.join(slugDir, entry.name));
|
|
440
|
+
results.push({ slug, timestamp, size: stat3.size });
|
|
441
|
+
}
|
|
442
|
+
results.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
443
|
+
return results;
|
|
444
|
+
};
|
|
445
|
+
const readHistorySnapshot = async (slug, timestamp) => {
|
|
446
|
+
assertValidSlug(slug);
|
|
447
|
+
const slugDir = resolveUnderBucket(historyDir, slug, "slug escapes history bucket");
|
|
448
|
+
const filePath = resolveUnderBucket(
|
|
449
|
+
slugDir,
|
|
450
|
+
`${timestamp}.json`,
|
|
451
|
+
"timestamp escapes history directory",
|
|
452
|
+
timestamp
|
|
453
|
+
);
|
|
454
|
+
return readJsonOrNull(filePath);
|
|
455
|
+
};
|
|
456
|
+
const renamePage = async (fromSlug, toSlug) => {
|
|
457
|
+
assertValidSlug(fromSlug);
|
|
458
|
+
assertValidSlug(toSlug);
|
|
459
|
+
const fromPublished = resolveSlugPath(pagesDir, fromSlug, ".json");
|
|
460
|
+
const toPublished = resolveSlugPath(pagesDir, toSlug, ".json");
|
|
461
|
+
try {
|
|
462
|
+
await fs3.access(fromPublished);
|
|
463
|
+
} catch {
|
|
464
|
+
throw new Error(`page not found: ${fromSlug}`);
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
await fs3.access(toPublished);
|
|
468
|
+
throw new Error(`target slug already exists: ${toSlug}`);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
if (isEnoent(err)) {
|
|
471
|
+
} else {
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
await fs3.rename(fromPublished, toPublished);
|
|
476
|
+
const fromDraft = resolveSlugPath(draftsDir, fromSlug, ".json");
|
|
477
|
+
const toDraft = resolveSlugPath(draftsDir, toSlug, ".json");
|
|
478
|
+
try {
|
|
479
|
+
await fs3.rename(fromDraft, toDraft);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
if (!isEnoent(err)) throw err;
|
|
482
|
+
}
|
|
483
|
+
const fromHistory = path3.resolve(historyDir, fromSlug);
|
|
484
|
+
const toHistory = path3.resolve(historyDir, toSlug);
|
|
485
|
+
try {
|
|
486
|
+
await fs3.rename(fromHistory, toHistory);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
if (!isEnoent(err)) throw err;
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
const readGlobal = async (name) => {
|
|
492
|
+
const filePath = resolveSlugPath(globalsDir, name, ".json");
|
|
493
|
+
return readJsonOrNull(filePath);
|
|
494
|
+
};
|
|
495
|
+
const uniqueGlobalHistoryPath = async (name) => {
|
|
496
|
+
assertValidSlug(name);
|
|
497
|
+
return uniqueTimestampedPath(
|
|
498
|
+
globalsHistoryDir,
|
|
499
|
+
name,
|
|
500
|
+
"name escapes globals history bucket"
|
|
501
|
+
);
|
|
502
|
+
};
|
|
503
|
+
const readLatestGlobalHistorySnapshot = async (name) => {
|
|
504
|
+
assertValidSlug(name);
|
|
505
|
+
return readLatestSnapshotIn(
|
|
506
|
+
globalsHistoryDir,
|
|
507
|
+
name,
|
|
508
|
+
"name escapes globals history bucket"
|
|
509
|
+
);
|
|
510
|
+
};
|
|
511
|
+
const saveGlobal = async (global) => {
|
|
512
|
+
const filePath = resolveSlugPath(globalsDir, global.name, ".json");
|
|
513
|
+
const serialised = JSON.stringify(global, null, 2);
|
|
514
|
+
const latestSnapshot = await readLatestGlobalHistorySnapshot(global.name);
|
|
515
|
+
const skipHistory = latestSnapshot !== null && deepEqual(latestSnapshot, global);
|
|
516
|
+
if (!skipHistory) {
|
|
517
|
+
const historyPath = await uniqueGlobalHistoryPath(global.name);
|
|
518
|
+
await writeAtomic(historyPath, serialised);
|
|
519
|
+
}
|
|
520
|
+
await writeAtomic(filePath, serialised);
|
|
521
|
+
};
|
|
522
|
+
const listGlobals = async () => {
|
|
523
|
+
let entries;
|
|
524
|
+
try {
|
|
525
|
+
entries = await fs3.readdir(globalsDir, { withFileTypes: true });
|
|
526
|
+
} catch (err) {
|
|
527
|
+
if (isEnoent(err)) return [];
|
|
528
|
+
throw err;
|
|
529
|
+
}
|
|
530
|
+
const results = [];
|
|
531
|
+
for (const entry of entries) {
|
|
532
|
+
if (!entry.isFile()) continue;
|
|
533
|
+
if (!entry.name.endsWith(".json")) continue;
|
|
534
|
+
const filePath = path3.join(globalsDir, entry.name);
|
|
535
|
+
const json = await readJsonOrNull(filePath);
|
|
536
|
+
if (json === null) continue;
|
|
537
|
+
const stat3 = await fs3.stat(filePath).catch(() => null);
|
|
538
|
+
if (stat3 === null) continue;
|
|
539
|
+
results.push({ name: json.name, type: json.type, updatedAt: stat3.mtime });
|
|
540
|
+
}
|
|
541
|
+
return results;
|
|
542
|
+
};
|
|
543
|
+
const deleteGlobal = async (name) => {
|
|
544
|
+
const filePath = resolveSlugPath(globalsDir, name, ".json");
|
|
545
|
+
try {
|
|
546
|
+
await fs3.unlink(filePath);
|
|
547
|
+
} catch (err) {
|
|
548
|
+
if (isEnoent(err)) {
|
|
549
|
+
throw new Error(`global not found: ${name}`);
|
|
550
|
+
}
|
|
551
|
+
throw err;
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
const listGlobalHistory = async (name) => {
|
|
555
|
+
assertValidSlug(name);
|
|
556
|
+
const nameDir = resolveUnderBucket(
|
|
557
|
+
globalsHistoryDir,
|
|
558
|
+
name,
|
|
559
|
+
"name escapes globals history bucket"
|
|
560
|
+
);
|
|
561
|
+
let entries;
|
|
562
|
+
try {
|
|
563
|
+
entries = await fs3.readdir(nameDir, { withFileTypes: true });
|
|
564
|
+
} catch (err) {
|
|
565
|
+
if (isEnoent(err)) return [];
|
|
566
|
+
throw err;
|
|
567
|
+
}
|
|
568
|
+
const results = [];
|
|
569
|
+
for (const entry of entries) {
|
|
570
|
+
if (!entry.isFile()) continue;
|
|
571
|
+
if (!entry.name.endsWith(".json")) continue;
|
|
572
|
+
const timestamp = entry.name.slice(0, -".json".length);
|
|
573
|
+
const stat3 = await fs3.stat(path3.join(nameDir, entry.name));
|
|
574
|
+
results.push({ name, timestamp, size: stat3.size });
|
|
575
|
+
}
|
|
576
|
+
results.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
577
|
+
return results;
|
|
578
|
+
};
|
|
579
|
+
const readGlobalHistorySnapshot = async (name, timestamp) => {
|
|
580
|
+
assertValidSlug(name);
|
|
581
|
+
const nameDir = resolveUnderBucket(
|
|
582
|
+
globalsHistoryDir,
|
|
583
|
+
name,
|
|
584
|
+
"name escapes globals history bucket"
|
|
585
|
+
);
|
|
586
|
+
const filePath = resolveUnderBucket(
|
|
587
|
+
nameDir,
|
|
588
|
+
`${timestamp}.json`,
|
|
589
|
+
"timestamp escapes history directory",
|
|
590
|
+
timestamp
|
|
591
|
+
);
|
|
592
|
+
return readJsonOrNull(filePath);
|
|
593
|
+
};
|
|
594
|
+
const rollbackGlobal = async (name, timestamp) => {
|
|
595
|
+
const snapshot = await readGlobalHistorySnapshot(name, timestamp);
|
|
596
|
+
if (snapshot === null) {
|
|
597
|
+
throw new Error(`global history snapshot not found: ${name} @ ${timestamp}`);
|
|
598
|
+
}
|
|
599
|
+
await saveGlobal(snapshot);
|
|
600
|
+
};
|
|
601
|
+
return {
|
|
602
|
+
readPage,
|
|
603
|
+
saveDraft,
|
|
604
|
+
listDrafts,
|
|
605
|
+
listPages,
|
|
606
|
+
listPageSummaries,
|
|
607
|
+
publishDraft,
|
|
608
|
+
deleteDraft,
|
|
609
|
+
deletePage,
|
|
610
|
+
unpublishPage,
|
|
611
|
+
listHistory,
|
|
612
|
+
readHistorySnapshot,
|
|
613
|
+
renamePage,
|
|
614
|
+
readGlobal,
|
|
615
|
+
saveGlobal,
|
|
616
|
+
listGlobals,
|
|
617
|
+
deleteGlobal,
|
|
618
|
+
listGlobalHistory,
|
|
619
|
+
readGlobalHistorySnapshot,
|
|
620
|
+
rollbackGlobal
|
|
621
|
+
};
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
// src/storage/fs/submissions.ts
|
|
625
|
+
import { randomUUID } from "crypto";
|
|
626
|
+
import * as fs4 from "fs/promises";
|
|
627
|
+
import * as path4 from "path";
|
|
628
|
+
var DEFAULT_MAX_LIST = 500;
|
|
629
|
+
var FORM_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
630
|
+
var parseSubmission = (raw) => {
|
|
631
|
+
let parsed;
|
|
632
|
+
try {
|
|
633
|
+
parsed = JSON.parse(raw);
|
|
634
|
+
} catch {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
const obj = parsed;
|
|
641
|
+
if (typeof obj["formName"] !== "string") return null;
|
|
642
|
+
if (typeof obj["id"] !== "string") return null;
|
|
643
|
+
if (typeof obj["submittedAt"] !== "string") return null;
|
|
644
|
+
if (obj["payload"] === null || typeof obj["payload"] !== "object" || Array.isArray(obj["payload"])) {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
formName: obj["formName"],
|
|
649
|
+
id: obj["id"],
|
|
650
|
+
submittedAt: obj["submittedAt"],
|
|
651
|
+
payload: obj["payload"]
|
|
652
|
+
};
|
|
653
|
+
};
|
|
654
|
+
var assertValidFormName = (name) => {
|
|
655
|
+
if (typeof name !== "string" || !FORM_NAME_PATTERN.test(name)) {
|
|
656
|
+
throw new Error(`invalid form name: ${JSON.stringify(name)}`);
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
var submissionFilename = (submittedAt, id) => {
|
|
660
|
+
const ts = submittedAt.replace(/:/g, "-");
|
|
661
|
+
return `${ts}-${id}.json`;
|
|
662
|
+
};
|
|
663
|
+
var listJsonFiles = async (dir) => {
|
|
664
|
+
let entries;
|
|
665
|
+
try {
|
|
666
|
+
entries = await fs4.readdir(dir, { withFileTypes: true });
|
|
667
|
+
} catch (err) {
|
|
668
|
+
if (isEnoent(err)) return [];
|
|
669
|
+
throw err;
|
|
670
|
+
}
|
|
671
|
+
const results = [];
|
|
672
|
+
for (const entry of entries) {
|
|
673
|
+
if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
674
|
+
results.push(entry.name);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return results;
|
|
678
|
+
};
|
|
679
|
+
var createFsSubmissionAdapter = (options) => {
|
|
680
|
+
const { submissionsRoot } = options;
|
|
681
|
+
if (!path4.isAbsolute(submissionsRoot)) {
|
|
682
|
+
throw new Error(
|
|
683
|
+
`submissionsRoot must be an absolute path, got: ${JSON.stringify(submissionsRoot)}`
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
const maxList = options.maxList ?? DEFAULT_MAX_LIST;
|
|
687
|
+
if (!Number.isFinite(maxList) || maxList <= 0 || !Number.isInteger(maxList)) {
|
|
688
|
+
throw new Error(
|
|
689
|
+
`maxList must be a positive integer, got: ${maxList}`
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
const rootResolved = path4.resolve(submissionsRoot);
|
|
693
|
+
const rootWithSep = rootResolved.endsWith(path4.sep) ? rootResolved : rootResolved + path4.sep;
|
|
694
|
+
const formDir = (formName) => {
|
|
695
|
+
assertValidFormName(formName);
|
|
696
|
+
const dir = path4.resolve(rootResolved, formName);
|
|
697
|
+
if (!dir.startsWith(rootWithSep)) {
|
|
698
|
+
throw new Error(`form name escapes submissions root: ${JSON.stringify(formName)}`);
|
|
699
|
+
}
|
|
700
|
+
return dir;
|
|
701
|
+
};
|
|
702
|
+
const cryptoSuffix = () => randomUUID();
|
|
703
|
+
const store = async (submission) => {
|
|
704
|
+
const dir = formDir(submission.formName);
|
|
705
|
+
const filename = submissionFilename(submission.submittedAt, submission.id);
|
|
706
|
+
if (filename.includes("/") || filename.includes("\\")) {
|
|
707
|
+
throw new Error(`submission id contains path separator: ${JSON.stringify(submission.id)}`);
|
|
708
|
+
}
|
|
709
|
+
const target = path4.join(dir, filename);
|
|
710
|
+
await writeAtomic(target, JSON.stringify(submission, null, 2), cryptoSuffix);
|
|
711
|
+
};
|
|
712
|
+
const list = async (formName) => {
|
|
713
|
+
const dir = formDir(formName);
|
|
714
|
+
const files = await listJsonFiles(dir);
|
|
715
|
+
const summaries = [];
|
|
716
|
+
for (const filename of files) {
|
|
717
|
+
const stem = filename.slice(0, -".json".length);
|
|
718
|
+
const lastDash = stem.lastIndexOf("-");
|
|
719
|
+
if (lastDash < 0) continue;
|
|
720
|
+
const tsRaw = stem.slice(0, lastDash);
|
|
721
|
+
const id = stem.slice(lastDash + 1);
|
|
722
|
+
if (id === "") continue;
|
|
723
|
+
const tIndex = tsRaw.indexOf("T");
|
|
724
|
+
if (tIndex < 0) continue;
|
|
725
|
+
const datePart = tsRaw.slice(0, tIndex);
|
|
726
|
+
const timePart = tsRaw.slice(tIndex + 1).replace(/-/g, ":");
|
|
727
|
+
const submittedAt = `${datePart}T${timePart}`;
|
|
728
|
+
summaries.push({ id, submittedAt });
|
|
729
|
+
}
|
|
730
|
+
summaries.sort((a, b) => {
|
|
731
|
+
if (a.submittedAt !== b.submittedAt) {
|
|
732
|
+
return a.submittedAt < b.submittedAt ? 1 : -1;
|
|
733
|
+
}
|
|
734
|
+
if (a.id !== b.id) {
|
|
735
|
+
return a.id < b.id ? 1 : -1;
|
|
736
|
+
}
|
|
737
|
+
return 0;
|
|
738
|
+
});
|
|
739
|
+
return summaries.length > maxList ? summaries.slice(0, maxList) : summaries;
|
|
740
|
+
};
|
|
741
|
+
const read = async (formName, id) => {
|
|
742
|
+
if (id.includes("/") || id.includes("\\") || id.includes("..")) {
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
const dir = formDir(formName);
|
|
746
|
+
let entries;
|
|
747
|
+
try {
|
|
748
|
+
entries = (await fs4.readdir(dir)).filter((n) => n.endsWith(".json"));
|
|
749
|
+
} catch (err) {
|
|
750
|
+
if (isEnoent(err)) return null;
|
|
751
|
+
throw err;
|
|
752
|
+
}
|
|
753
|
+
const suffix = `-${id}.json`;
|
|
754
|
+
const found = entries.find((n) => n.endsWith(suffix));
|
|
755
|
+
if (!found) return null;
|
|
756
|
+
const target = path4.join(dir, found);
|
|
757
|
+
let raw;
|
|
758
|
+
try {
|
|
759
|
+
raw = await fs4.readFile(target, { encoding: "utf8" });
|
|
760
|
+
} catch (err) {
|
|
761
|
+
if (isEnoent(err)) return null;
|
|
762
|
+
throw err;
|
|
763
|
+
}
|
|
764
|
+
return parseSubmission(raw);
|
|
765
|
+
};
|
|
766
|
+
return { info: { kind: "fs" }, store, list, read };
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
// src/config/defaults-registry.ts
|
|
770
|
+
var SLOT = /* @__PURE__ */ Symbol.for("@agntcms/next/default-adapter-factories");
|
|
771
|
+
function holder() {
|
|
772
|
+
return globalThis;
|
|
773
|
+
}
|
|
774
|
+
function registerDefaultAdapterFactories(factories) {
|
|
775
|
+
holder()[SLOT] = factories;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// src/config/defaults.ts
|
|
779
|
+
import * as path5 from "path";
|
|
780
|
+
function createDefaultContentAdapter(options) {
|
|
781
|
+
const root = options?.projectRoot ?? process.cwd();
|
|
782
|
+
return createFsContentAdapter({
|
|
783
|
+
contentRoot: path5.resolve(root, "content")
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
function createDefaultAssetAdapter(options) {
|
|
787
|
+
const root = options?.projectRoot ?? process.cwd();
|
|
788
|
+
return createFsAssetAdapter({
|
|
789
|
+
assetsRoot: path5.resolve(root, "public/assets"),
|
|
790
|
+
publicUrlBase: "/assets"
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
function createDefaultSubmissionAdapter(options) {
|
|
794
|
+
const root = options?.projectRoot ?? process.cwd();
|
|
795
|
+
return createFsSubmissionAdapter({
|
|
796
|
+
submissionsRoot: path5.resolve(root, "content/submissions")
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
function installDefaultAdapterFactories() {
|
|
800
|
+
const factories = {
|
|
801
|
+
content: createDefaultContentAdapter,
|
|
802
|
+
asset: createDefaultAssetAdapter,
|
|
803
|
+
submission: createDefaultSubmissionAdapter
|
|
804
|
+
};
|
|
805
|
+
registerDefaultAdapterFactories(factories);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// src/mcp/bridge.ts
|
|
809
|
+
var AgentUnreachableError = class extends Error {
|
|
810
|
+
name = "AgentUnreachableError";
|
|
811
|
+
status;
|
|
812
|
+
constructor(message, status) {
|
|
813
|
+
super(message);
|
|
814
|
+
this.status = status;
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
var TASK_ACK_TIMEOUT_MESSAGE = "Agent received the task but did not respond. Open an interactive Claude Code session in the project directory and launch it with channel notifications enabled: `claude --dangerously-load-development-channels server:agntcms`. Requirements: Claude Code v2.1.80+, signed in with claude.ai (not API key / Console), and an interactive session \u2014 `claude -p` does not deliver channel events. The flag is undocumented in `--help` because channels are still in research preview; it is still the supported mechanism (see code.claude.com/docs/en/channels-reference).";
|
|
818
|
+
var createAgentBridge = (options) => {
|
|
819
|
+
const {
|
|
820
|
+
taskStore,
|
|
821
|
+
channelPort = 4819,
|
|
822
|
+
channelHost = "127.0.0.1",
|
|
823
|
+
callbackUrl = "http://localhost:3000/api/agntcms/mcp",
|
|
824
|
+
taskAckTimeoutMs = 15e3
|
|
825
|
+
} = options;
|
|
826
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
827
|
+
const setTimeoutFn = options.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
|
|
828
|
+
const clearTimeoutFn = options.clearTimeoutFn ?? ((handle) => {
|
|
829
|
+
clearTimeout(handle);
|
|
830
|
+
});
|
|
831
|
+
const baseUrl = `http://${channelHost}:${channelPort}`;
|
|
832
|
+
let status = "stopped";
|
|
833
|
+
const inflightTaskIds = /* @__PURE__ */ new Set();
|
|
834
|
+
let startPromise = null;
|
|
835
|
+
const failAllInflight = (reason) => {
|
|
836
|
+
const ids = Array.from(inflightTaskIds);
|
|
837
|
+
inflightTaskIds.clear();
|
|
838
|
+
for (const id of ids) {
|
|
839
|
+
try {
|
|
840
|
+
taskStore.fail(id, reason);
|
|
841
|
+
} catch {
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
const markFailed = (reason) => {
|
|
846
|
+
status = "failed";
|
|
847
|
+
startPromise = null;
|
|
848
|
+
failAllInflight(reason);
|
|
849
|
+
};
|
|
850
|
+
const doStart = async () => {
|
|
851
|
+
status = "starting";
|
|
852
|
+
try {
|
|
853
|
+
const res = await fetchFn(`${baseUrl}/health`, { method: "GET" });
|
|
854
|
+
if (!res.ok) {
|
|
855
|
+
throw new Error(`health check returned ${String(res.status)}`);
|
|
856
|
+
}
|
|
857
|
+
status = "running";
|
|
858
|
+
} catch (err) {
|
|
859
|
+
const reason = err instanceof Error ? `failed to connect to channel server: ${err.message}` : `failed to connect to channel server: ${String(err)}`;
|
|
860
|
+
markFailed(reason);
|
|
861
|
+
throw new AgentUnreachableError(reason, "failed");
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
const start = () => {
|
|
865
|
+
if (status === "running") return Promise.resolve();
|
|
866
|
+
if (status === "starting" && startPromise !== null) return startPromise;
|
|
867
|
+
const p = doStart();
|
|
868
|
+
startPromise = p;
|
|
869
|
+
return p;
|
|
870
|
+
};
|
|
871
|
+
const pushTask = (task) => {
|
|
872
|
+
if (status !== "running") {
|
|
873
|
+
return Promise.reject(
|
|
874
|
+
new AgentUnreachableError(
|
|
875
|
+
`cannot push task: agent bridge is ${status}`,
|
|
876
|
+
status
|
|
877
|
+
)
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
let resultPromise;
|
|
881
|
+
try {
|
|
882
|
+
resultPromise = taskStore.create(task);
|
|
883
|
+
} catch (err) {
|
|
884
|
+
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
|
|
885
|
+
}
|
|
886
|
+
inflightTaskIds.add(task.id);
|
|
887
|
+
let ackTimer = null;
|
|
888
|
+
let unsubscribe = null;
|
|
889
|
+
const clearWatchdog = () => {
|
|
890
|
+
if (ackTimer !== null) {
|
|
891
|
+
clearTimeoutFn(ackTimer);
|
|
892
|
+
ackTimer = null;
|
|
893
|
+
}
|
|
894
|
+
if (unsubscribe !== null) {
|
|
895
|
+
unsubscribe();
|
|
896
|
+
unsubscribe = null;
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
const cleanup = () => {
|
|
900
|
+
inflightTaskIds.delete(task.id);
|
|
901
|
+
clearWatchdog();
|
|
902
|
+
};
|
|
903
|
+
resultPromise.then(cleanup, cleanup);
|
|
904
|
+
if (taskAckTimeoutMs > 0) {
|
|
905
|
+
unsubscribe = taskStore.subscribe(task.id, () => {
|
|
906
|
+
clearWatchdog();
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
fetchFn(`${baseUrl}/task`, {
|
|
910
|
+
method: "POST",
|
|
911
|
+
headers: { "Content-Type": "application/json" },
|
|
912
|
+
body: JSON.stringify({
|
|
913
|
+
task_id: task.id,
|
|
914
|
+
type: task.type,
|
|
915
|
+
payload: task.payload,
|
|
916
|
+
callback_url: callbackUrl
|
|
917
|
+
})
|
|
918
|
+
}).then((res) => {
|
|
919
|
+
if (!res.ok) {
|
|
920
|
+
throw new Error(`channel /task returned ${String(res.status)}`);
|
|
921
|
+
}
|
|
922
|
+
if (taskAckTimeoutMs > 0 && ackTimer === null && unsubscribe !== null) {
|
|
923
|
+
ackTimer = setTimeoutFn(() => {
|
|
924
|
+
try {
|
|
925
|
+
taskStore.fail(task.id, TASK_ACK_TIMEOUT_MESSAGE);
|
|
926
|
+
} catch {
|
|
927
|
+
}
|
|
928
|
+
}, taskAckTimeoutMs);
|
|
929
|
+
}
|
|
930
|
+
}).catch((err) => {
|
|
931
|
+
const reason = err instanceof Error ? `channel POST failed: ${err.message}` : `channel POST failed: ${String(err)}`;
|
|
932
|
+
try {
|
|
933
|
+
taskStore.fail(task.id, reason);
|
|
934
|
+
} catch {
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
return resultPromise;
|
|
938
|
+
};
|
|
939
|
+
const stop = async () => {
|
|
940
|
+
if (status === "stopped") return;
|
|
941
|
+
const previousStatus = status;
|
|
942
|
+
status = "stopped";
|
|
943
|
+
startPromise = null;
|
|
944
|
+
if (previousStatus === "running" || previousStatus === "starting") {
|
|
945
|
+
failAllInflight("agent bridge stopped");
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
const statusGetter = () => status;
|
|
949
|
+
return { start, pushTask, stop, status: statusGetter };
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
// src/handlers/utils.ts
|
|
953
|
+
var jsonResponse = (body, status, extraHeaders) => new Response(JSON.stringify(body), {
|
|
954
|
+
status,
|
|
955
|
+
headers: { "Content-Type": "application/json", ...extraHeaders }
|
|
956
|
+
});
|
|
957
|
+
var safeErrorMessage = (err) => {
|
|
958
|
+
if (err instanceof Error) return err.message;
|
|
959
|
+
if (typeof err === "string") return err;
|
|
960
|
+
return "unknown error";
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
// src/handlers/mcp/mcp-handler.ts
|
|
964
|
+
var parseAction = (body) => {
|
|
965
|
+
if (body === null || typeof body !== "object") {
|
|
966
|
+
return jsonResponse({ error: "invalid_body", message: "Expected a JSON object" }, 400);
|
|
967
|
+
}
|
|
968
|
+
const obj = body;
|
|
969
|
+
if (typeof obj["action"] !== "string") {
|
|
970
|
+
return jsonResponse({ error: "missing_field", message: "Missing required field: action" }, 400);
|
|
971
|
+
}
|
|
972
|
+
const action = obj["action"];
|
|
973
|
+
switch (action) {
|
|
974
|
+
case "start":
|
|
975
|
+
case "stop":
|
|
976
|
+
case "status":
|
|
977
|
+
return { action };
|
|
978
|
+
case "push_task": {
|
|
979
|
+
if (typeof obj["type"] !== "string" || obj["type"] === "") {
|
|
980
|
+
return jsonResponse(
|
|
981
|
+
{ error: "missing_field", message: 'push_task requires a non-empty "type" string' },
|
|
982
|
+
400
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
if (obj["payload"] === void 0 || obj["payload"] === null || typeof obj["payload"] !== "object" || Array.isArray(obj["payload"])) {
|
|
986
|
+
return jsonResponse(
|
|
987
|
+
{ error: "missing_field", message: 'push_task requires a "payload" object' },
|
|
988
|
+
400
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
return {
|
|
992
|
+
action: "push_task",
|
|
993
|
+
type: obj["type"],
|
|
994
|
+
payload: obj["payload"]
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
case "task_callback": {
|
|
998
|
+
const event = obj["event"];
|
|
999
|
+
if (event !== "completed" && event !== "progress" && event !== "failed") {
|
|
1000
|
+
return jsonResponse(
|
|
1001
|
+
{ error: "missing_field", message: 'task_callback requires "event" to be "completed", "progress", or "failed"' },
|
|
1002
|
+
400
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
if (typeof obj["task_id"] !== "string" || obj["task_id"] === "") {
|
|
1006
|
+
return jsonResponse(
|
|
1007
|
+
{ error: "missing_field", message: 'task_callback requires a non-empty "task_id" string' },
|
|
1008
|
+
400
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
if (event === "completed" && typeof obj["result"] !== "string") {
|
|
1012
|
+
return jsonResponse(
|
|
1013
|
+
{ error: "missing_field", message: 'task_callback with event "completed" requires a "result" string' },
|
|
1014
|
+
400
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
if (event === "progress" && typeof obj["message"] !== "string") {
|
|
1018
|
+
return jsonResponse(
|
|
1019
|
+
{ error: "missing_field", message: 'task_callback with event "progress" requires a "message" string' },
|
|
1020
|
+
400
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
if (event === "failed" && typeof obj["error"] !== "string") {
|
|
1024
|
+
return jsonResponse(
|
|
1025
|
+
{ error: "missing_field", message: 'task_callback with event "failed" requires an "error" string' },
|
|
1026
|
+
400
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
const taskId = obj["task_id"];
|
|
1030
|
+
switch (event) {
|
|
1031
|
+
case "completed":
|
|
1032
|
+
return { action: "task_callback", event, task_id: taskId, result: obj["result"] };
|
|
1033
|
+
case "progress":
|
|
1034
|
+
return { action: "task_callback", event, task_id: taskId, message: obj["message"] };
|
|
1035
|
+
case "failed":
|
|
1036
|
+
return { action: "task_callback", event, task_id: taskId, error: obj["error"] };
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
default:
|
|
1040
|
+
return jsonResponse({ error: "unknown_action", message: `Unknown action: ${action}` }, 400);
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
var createMcpHandler = (deps) => {
|
|
1044
|
+
const { bridge, taskStore } = deps;
|
|
1045
|
+
const POST = async (req) => {
|
|
1046
|
+
let body;
|
|
1047
|
+
try {
|
|
1048
|
+
body = await req.json();
|
|
1049
|
+
} catch {
|
|
1050
|
+
return jsonResponse({ error: "invalid_body", message: "Invalid JSON" }, 400);
|
|
1051
|
+
}
|
|
1052
|
+
const actionOrError = parseAction(body);
|
|
1053
|
+
if (actionOrError instanceof Response) return actionOrError;
|
|
1054
|
+
const action = actionOrError;
|
|
1055
|
+
try {
|
|
1056
|
+
switch (action.action) {
|
|
1057
|
+
case "start": {
|
|
1058
|
+
await bridge.start();
|
|
1059
|
+
return jsonResponse({ status: bridge.status() }, 200);
|
|
1060
|
+
}
|
|
1061
|
+
case "stop": {
|
|
1062
|
+
await bridge.stop();
|
|
1063
|
+
return jsonResponse({ status: bridge.status() }, 200);
|
|
1064
|
+
}
|
|
1065
|
+
case "status": {
|
|
1066
|
+
return jsonResponse({ status: bridge.status() }, 200);
|
|
1067
|
+
}
|
|
1068
|
+
case "push_task": {
|
|
1069
|
+
if (bridge.status() !== "running") {
|
|
1070
|
+
return jsonResponse(
|
|
1071
|
+
{
|
|
1072
|
+
error: "bridge_not_running",
|
|
1073
|
+
message: `Agent bridge is ${bridge.status()}. Start it before pushing tasks.`
|
|
1074
|
+
},
|
|
1075
|
+
409
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
const taskId = crypto.randomUUID();
|
|
1079
|
+
const task = { id: taskId, type: action.type, payload: action.payload };
|
|
1080
|
+
bridge.pushTask(task).catch(() => {
|
|
1081
|
+
});
|
|
1082
|
+
return jsonResponse({ taskId }, 200);
|
|
1083
|
+
}
|
|
1084
|
+
case "task_callback": {
|
|
1085
|
+
try {
|
|
1086
|
+
switch (action.event) {
|
|
1087
|
+
case "completed":
|
|
1088
|
+
taskStore.complete(action.task_id, action.result);
|
|
1089
|
+
break;
|
|
1090
|
+
case "progress":
|
|
1091
|
+
taskStore.progress(action.task_id, action.message);
|
|
1092
|
+
break;
|
|
1093
|
+
case "failed":
|
|
1094
|
+
taskStore.fail(action.task_id, action.error);
|
|
1095
|
+
break;
|
|
1096
|
+
}
|
|
1097
|
+
return jsonResponse({ ok: true }, 200);
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
return jsonResponse(
|
|
1100
|
+
{ error: "task_store_error", message: safeErrorMessage(err) },
|
|
1101
|
+
400
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
} catch (err) {
|
|
1107
|
+
if (err instanceof AgentUnreachableError) {
|
|
1108
|
+
return jsonResponse({ error: "agent_unreachable", message: err.message }, 503);
|
|
1109
|
+
}
|
|
1110
|
+
return jsonResponse({ error: "internal" }, 500);
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
return { POST };
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
// src/handlers/events/events-handler.ts
|
|
1117
|
+
var formatSseFrame = (taskId, event) => {
|
|
1118
|
+
let data;
|
|
1119
|
+
switch (event.kind) {
|
|
1120
|
+
case "progress":
|
|
1121
|
+
data = { taskId, message: event.message };
|
|
1122
|
+
break;
|
|
1123
|
+
case "completed":
|
|
1124
|
+
data = { taskId, result: event.result };
|
|
1125
|
+
break;
|
|
1126
|
+
case "failed":
|
|
1127
|
+
data = { taskId, error: event.error };
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
return `event: ${event.kind}
|
|
1131
|
+
data: ${JSON.stringify(data)}
|
|
1132
|
+
|
|
1133
|
+
`;
|
|
1134
|
+
};
|
|
1135
|
+
var isTerminal = (event) => event.kind === "completed" || event.kind === "failed";
|
|
1136
|
+
var createEventsHandler = (deps) => {
|
|
1137
|
+
const { taskStore } = deps;
|
|
1138
|
+
const GET = (req) => {
|
|
1139
|
+
const url = new URL(req.url);
|
|
1140
|
+
const taskId = url.searchParams.get("taskId");
|
|
1141
|
+
if (taskId === null || taskId === "") {
|
|
1142
|
+
return new Response("Missing required query parameter: taskId", { status: 400 });
|
|
1143
|
+
}
|
|
1144
|
+
const snapshot = taskStore.get(taskId);
|
|
1145
|
+
if (snapshot === void 0) {
|
|
1146
|
+
return new Response(`Task not found: ${taskId}`, { status: 404 });
|
|
1147
|
+
}
|
|
1148
|
+
const encoder = new TextEncoder();
|
|
1149
|
+
const stream = new ReadableStream({
|
|
1150
|
+
start(controller) {
|
|
1151
|
+
let closed = false;
|
|
1152
|
+
const close = () => {
|
|
1153
|
+
if (closed) return;
|
|
1154
|
+
closed = true;
|
|
1155
|
+
try {
|
|
1156
|
+
controller.close();
|
|
1157
|
+
} catch {
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
const unsubscribe = taskStore.subscribe(taskId, (event) => {
|
|
1161
|
+
if (closed) return;
|
|
1162
|
+
try {
|
|
1163
|
+
controller.enqueue(encoder.encode(formatSseFrame(taskId, event)));
|
|
1164
|
+
} catch {
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (isTerminal(event)) {
|
|
1168
|
+
close();
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
if (req.signal) {
|
|
1172
|
+
const onAbort = () => {
|
|
1173
|
+
unsubscribe();
|
|
1174
|
+
close();
|
|
1175
|
+
};
|
|
1176
|
+
if (req.signal.aborted) {
|
|
1177
|
+
onAbort();
|
|
1178
|
+
} else {
|
|
1179
|
+
req.signal.addEventListener("abort", onAbort, { once: true });
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
return new Response(stream, {
|
|
1185
|
+
status: 200,
|
|
1186
|
+
headers: {
|
|
1187
|
+
"Content-Type": "text/event-stream",
|
|
1188
|
+
"Cache-Control": "no-cache",
|
|
1189
|
+
"Connection": "keep-alive"
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
};
|
|
1193
|
+
return { GET };
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
// src/handlers/preview/preview-handler.ts
|
|
1197
|
+
var DEFAULT_COOKIE_NAME = "__agntcms_preview";
|
|
1198
|
+
function createPreviewHandler(deps) {
|
|
1199
|
+
const cookieName = deps?.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
1200
|
+
const tokenStore = deps?.tokenStore;
|
|
1201
|
+
const enter = (_req) => {
|
|
1202
|
+
const cookie = `${cookieName}=1; Path=/; HttpOnly; SameSite=Lax`;
|
|
1203
|
+
return jsonResponse({ ok: true }, 200, { "Set-Cookie": cookie });
|
|
1204
|
+
};
|
|
1205
|
+
const exit = (_req) => {
|
|
1206
|
+
const cookie = `${cookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
1207
|
+
return jsonResponse({ ok: true }, 200, { "Set-Cookie": cookie });
|
|
1208
|
+
};
|
|
1209
|
+
const enterWithToken = (req) => {
|
|
1210
|
+
const url = new URL(req.url);
|
|
1211
|
+
const token = url.searchParams.get("token");
|
|
1212
|
+
if (!tokenStore) {
|
|
1213
|
+
return jsonResponse({ error: "token_store_not_configured" }, 501);
|
|
1214
|
+
}
|
|
1215
|
+
if (!token) {
|
|
1216
|
+
return jsonResponse({ error: "missing_token" }, 400);
|
|
1217
|
+
}
|
|
1218
|
+
const slug = tokenStore.consume(token);
|
|
1219
|
+
if (slug === null) {
|
|
1220
|
+
return jsonResponse({ error: "invalid_or_expired_token" }, 403);
|
|
1221
|
+
}
|
|
1222
|
+
const cookie = `${cookieName}=1; Path=/; HttpOnly; SameSite=Lax`;
|
|
1223
|
+
const redirectUrl = `/${slug}`;
|
|
1224
|
+
return new Response(null, {
|
|
1225
|
+
status: 302,
|
|
1226
|
+
headers: {
|
|
1227
|
+
"Location": redirectUrl,
|
|
1228
|
+
"Set-Cookie": cookie
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
};
|
|
1232
|
+
const issueToken = async (req) => {
|
|
1233
|
+
if (!tokenStore) {
|
|
1234
|
+
return jsonResponse({ error: "token_store_not_configured" }, 501);
|
|
1235
|
+
}
|
|
1236
|
+
let body;
|
|
1237
|
+
try {
|
|
1238
|
+
body = await req.json();
|
|
1239
|
+
} catch {
|
|
1240
|
+
return jsonResponse({ error: "invalid_json" }, 400);
|
|
1241
|
+
}
|
|
1242
|
+
if (!body || typeof body !== "object" || !("slug" in body) || typeof body.slug !== "string") {
|
|
1243
|
+
return jsonResponse({ error: "missing_slug" }, 400);
|
|
1244
|
+
}
|
|
1245
|
+
const slug = body.slug;
|
|
1246
|
+
const previewToken = tokenStore.issue(slug);
|
|
1247
|
+
return jsonResponse({
|
|
1248
|
+
token: previewToken.token,
|
|
1249
|
+
previewUrl: `/api/agntcms/preview/enter?token=${previewToken.token}`
|
|
1250
|
+
}, 200);
|
|
1251
|
+
};
|
|
1252
|
+
return { enter, exit, enterWithToken, issueToken };
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// src/runtime/systemPages.ts
|
|
1256
|
+
var NOT_FOUND_PAGE_SLUG = "404";
|
|
1257
|
+
var SERVER_ERROR_PAGE_SLUG = "500";
|
|
1258
|
+
var SYSTEM_PAGE_ALIAS_TO_CANONICAL = /* @__PURE__ */ new Map([
|
|
1259
|
+
["not-found", NOT_FOUND_PAGE_SLUG],
|
|
1260
|
+
["_not-found", NOT_FOUND_PAGE_SLUG],
|
|
1261
|
+
["error-404", NOT_FOUND_PAGE_SLUG],
|
|
1262
|
+
["404-page", NOT_FOUND_PAGE_SLUG],
|
|
1263
|
+
["page-not-found", NOT_FOUND_PAGE_SLUG],
|
|
1264
|
+
["error-500", SERVER_ERROR_PAGE_SLUG],
|
|
1265
|
+
["500-page", SERVER_ERROR_PAGE_SLUG],
|
|
1266
|
+
["server-error", SERVER_ERROR_PAGE_SLUG]
|
|
1267
|
+
]);
|
|
1268
|
+
var getReservedPageSlugViolation = (slug) => {
|
|
1269
|
+
const canonicalSlug = SYSTEM_PAGE_ALIAS_TO_CANONICAL.get(slug);
|
|
1270
|
+
if (!canonicalSlug) return null;
|
|
1271
|
+
const pageLabel = canonicalSlug === NOT_FOUND_PAGE_SLUG ? "404 page" : "500 page";
|
|
1272
|
+
return {
|
|
1273
|
+
slug,
|
|
1274
|
+
canonicalSlug,
|
|
1275
|
+
message: `Slug "${slug}" is reserved as an alias for the ${pageLabel}. Edit the canonical "${canonicalSlug}" page instead.`
|
|
1276
|
+
};
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
// src/handlers/draft/draft-handler.ts
|
|
1280
|
+
var parseSaveBody = (body) => {
|
|
1281
|
+
if (body === null || typeof body !== "object") {
|
|
1282
|
+
return jsonResponse({ error: "invalid_body", message: "Expected a JSON object" }, 400);
|
|
1283
|
+
}
|
|
1284
|
+
try {
|
|
1285
|
+
assertValidPage(body);
|
|
1286
|
+
} catch (err) {
|
|
1287
|
+
return jsonResponse(
|
|
1288
|
+
{ error: "missing_field", message: safeErrorMessage(err) },
|
|
1289
|
+
400
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
const reservedSlug = getReservedPageSlugViolation(body.slug);
|
|
1293
|
+
if (reservedSlug) {
|
|
1294
|
+
return jsonResponse(
|
|
1295
|
+
{ error: "reserved_slug_alias", message: reservedSlug.message },
|
|
1296
|
+
400
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
const page = {
|
|
1300
|
+
slug: body.slug,
|
|
1301
|
+
seo: body.seo,
|
|
1302
|
+
sections: body.sections,
|
|
1303
|
+
...body.tags !== void 0 ? { tags: body.tags } : {},
|
|
1304
|
+
...body.excerpt !== void 0 ? { excerpt: body.excerpt } : {},
|
|
1305
|
+
...body.coverImage !== void 0 ? { coverImage: body.coverImage } : {},
|
|
1306
|
+
...body.publishedAt !== void 0 ? { publishedAt: body.publishedAt } : {}
|
|
1307
|
+
};
|
|
1308
|
+
return page;
|
|
1309
|
+
};
|
|
1310
|
+
var parsePublishBody = (body) => {
|
|
1311
|
+
if (body === null || typeof body !== "object") {
|
|
1312
|
+
return jsonResponse({ error: "invalid_body", message: "Expected a JSON object" }, 400);
|
|
1313
|
+
}
|
|
1314
|
+
const obj = body;
|
|
1315
|
+
if (typeof obj["slug"] !== "string" || obj["slug"] === "") {
|
|
1316
|
+
return jsonResponse(
|
|
1317
|
+
{ error: "missing_field", message: "Missing or empty required field: slug" },
|
|
1318
|
+
400
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
return obj["slug"];
|
|
1322
|
+
};
|
|
1323
|
+
function createDraftHandler(deps) {
|
|
1324
|
+
const { contentAdapter, runtime } = deps;
|
|
1325
|
+
const save = async (req) => {
|
|
1326
|
+
let body;
|
|
1327
|
+
try {
|
|
1328
|
+
body = await req.json();
|
|
1329
|
+
} catch {
|
|
1330
|
+
return jsonResponse({ error: "invalid_body", message: "Invalid JSON" }, 400);
|
|
1331
|
+
}
|
|
1332
|
+
const pageOrError = parseSaveBody(body);
|
|
1333
|
+
if (pageOrError instanceof Response) return pageOrError;
|
|
1334
|
+
const page = pageOrError;
|
|
1335
|
+
try {
|
|
1336
|
+
await contentAdapter.saveDraft(page);
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
return jsonResponse(
|
|
1339
|
+
{ error: "save_failed", message: safeErrorMessage(err) },
|
|
1340
|
+
500
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
return jsonResponse({ ok: true }, 200);
|
|
1344
|
+
};
|
|
1345
|
+
const list = async (_req) => {
|
|
1346
|
+
let drafts;
|
|
1347
|
+
try {
|
|
1348
|
+
drafts = await contentAdapter.listDrafts();
|
|
1349
|
+
} catch (err) {
|
|
1350
|
+
return jsonResponse(
|
|
1351
|
+
{ error: "list_failed", message: safeErrorMessage(err) },
|
|
1352
|
+
500
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
const serialized = drafts.map((d) => ({
|
|
1356
|
+
slug: d.slug,
|
|
1357
|
+
updatedAt: d.updatedAt.toISOString()
|
|
1358
|
+
}));
|
|
1359
|
+
return jsonResponse({ drafts: serialized }, 200);
|
|
1360
|
+
};
|
|
1361
|
+
const publish = async (req) => {
|
|
1362
|
+
let body;
|
|
1363
|
+
try {
|
|
1364
|
+
body = await req.json();
|
|
1365
|
+
} catch {
|
|
1366
|
+
return jsonResponse({ error: "invalid_body", message: "Invalid JSON" }, 400);
|
|
1367
|
+
}
|
|
1368
|
+
const slugOrError = parsePublishBody(body);
|
|
1369
|
+
if (slugOrError instanceof Response) return slugOrError;
|
|
1370
|
+
const slug = slugOrError;
|
|
1371
|
+
let page;
|
|
1372
|
+
try {
|
|
1373
|
+
page = await runtime.publishDraft(slug);
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
return jsonResponse(
|
|
1376
|
+
{ error: "not_found", message: safeErrorMessage(err) },
|
|
1377
|
+
404
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
return jsonResponse({ ok: true, page }, 200);
|
|
1381
|
+
};
|
|
1382
|
+
const reorder = async (req) => {
|
|
1383
|
+
let body;
|
|
1384
|
+
try {
|
|
1385
|
+
body = await req.json();
|
|
1386
|
+
} catch {
|
|
1387
|
+
return jsonResponse({ error: "invalid_body", message: "Invalid JSON" }, 400);
|
|
1388
|
+
}
|
|
1389
|
+
if (body === null || typeof body !== "object") {
|
|
1390
|
+
return jsonResponse({ error: "invalid_body", message: "Expected a JSON object" }, 400);
|
|
1391
|
+
}
|
|
1392
|
+
const obj = body;
|
|
1393
|
+
if (typeof obj["slug"] !== "string" || obj["slug"] === "") {
|
|
1394
|
+
return jsonResponse(
|
|
1395
|
+
{ error: "missing_field", message: "Missing or empty required field: slug" },
|
|
1396
|
+
400
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
if (!Array.isArray(obj["order"])) {
|
|
1400
|
+
return jsonResponse(
|
|
1401
|
+
{ error: "missing_field", message: "Missing or non-array required field: order" },
|
|
1402
|
+
400
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
const slug = obj["slug"];
|
|
1406
|
+
const order = obj["order"];
|
|
1407
|
+
if (!order.every((id) => typeof id === "string")) {
|
|
1408
|
+
return jsonResponse(
|
|
1409
|
+
{ error: "invalid_field", message: "order must be an array of string section IDs" },
|
|
1410
|
+
400
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
let page = await contentAdapter.readPage(slug, "draft");
|
|
1414
|
+
if (page === null) {
|
|
1415
|
+
page = await contentAdapter.readPage(slug, "published");
|
|
1416
|
+
}
|
|
1417
|
+
if (page === null) {
|
|
1418
|
+
return jsonResponse({ error: "not_found", message: `No page found for slug: ${slug}` }, 404);
|
|
1419
|
+
}
|
|
1420
|
+
const currentIds = page.sections.map((s) => s.id);
|
|
1421
|
+
const sortedCurrent = [...currentIds].sort();
|
|
1422
|
+
const sortedOrder = [...order].sort();
|
|
1423
|
+
if (sortedCurrent.length !== sortedOrder.length) {
|
|
1424
|
+
return jsonResponse(
|
|
1425
|
+
{ error: "invalid_order", message: "order must contain exactly the same section IDs as the page" },
|
|
1426
|
+
400
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
for (let i = 0; i < sortedCurrent.length; i++) {
|
|
1430
|
+
if (sortedCurrent[i] !== sortedOrder[i]) {
|
|
1431
|
+
return jsonResponse(
|
|
1432
|
+
{ error: "invalid_order", message: "order must contain exactly the same section IDs as the page" },
|
|
1433
|
+
400
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
const sectionById = new Map(page.sections.map((s) => [s.id, s]));
|
|
1438
|
+
const reordered = order.map((id) => sectionById.get(id));
|
|
1439
|
+
const reorderedPage = { ...page, sections: reordered };
|
|
1440
|
+
try {
|
|
1441
|
+
await contentAdapter.saveDraft(reorderedPage);
|
|
1442
|
+
} catch (err) {
|
|
1443
|
+
return jsonResponse(
|
|
1444
|
+
{ error: "save_failed", message: safeErrorMessage(err) },
|
|
1445
|
+
500
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
return jsonResponse({ ok: true }, 200);
|
|
1449
|
+
};
|
|
1450
|
+
const discard = async (req) => {
|
|
1451
|
+
let body;
|
|
1452
|
+
try {
|
|
1453
|
+
body = await req.json();
|
|
1454
|
+
} catch {
|
|
1455
|
+
return jsonResponse({ error: "invalid_body", message: "Invalid JSON" }, 400);
|
|
1456
|
+
}
|
|
1457
|
+
const slugOrError = parsePublishBody(body);
|
|
1458
|
+
if (slugOrError instanceof Response) return slugOrError;
|
|
1459
|
+
const slug = slugOrError;
|
|
1460
|
+
const published = await contentAdapter.readPage(slug, "published");
|
|
1461
|
+
if (published === null) {
|
|
1462
|
+
return jsonResponse(
|
|
1463
|
+
{ error: "no_published_version", message: `No published version exists for slug: ${slug}. Discarding the draft would lose all data.` },
|
|
1464
|
+
400
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
try {
|
|
1468
|
+
await contentAdapter.deleteDraft(slug);
|
|
1469
|
+
} catch (err) {
|
|
1470
|
+
const message = safeErrorMessage(err);
|
|
1471
|
+
if (message.includes("no draft to discard")) {
|
|
1472
|
+
return jsonResponse({ error: "not_found", message }, 404);
|
|
1473
|
+
}
|
|
1474
|
+
return jsonResponse({ error: "discard_failed", message }, 500);
|
|
1475
|
+
}
|
|
1476
|
+
return jsonResponse({ ok: true }, 200);
|
|
1477
|
+
};
|
|
1478
|
+
return { save, list, publish, reorder, discard };
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// src/handlers/assets/assets-handler.ts
|
|
1482
|
+
var serialiseEntry = (e) => ({
|
|
1483
|
+
filename: e.filename,
|
|
1484
|
+
url: e.url,
|
|
1485
|
+
contentType: e.contentType ?? null,
|
|
1486
|
+
modifiedAt: e.modifiedAt.toISOString()
|
|
1487
|
+
});
|
|
1488
|
+
function createAssetsHandler(deps) {
|
|
1489
|
+
const { assetAdapter } = deps;
|
|
1490
|
+
const list = async (req) => {
|
|
1491
|
+
if (req.method !== "GET") {
|
|
1492
|
+
return jsonResponse(
|
|
1493
|
+
{ error: "method_not_allowed", message: `Expected GET, got ${req.method}` },
|
|
1494
|
+
405
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
let entries;
|
|
1498
|
+
try {
|
|
1499
|
+
entries = await assetAdapter.list();
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
return jsonResponse(
|
|
1502
|
+
{ error: "list_failed", message: safeErrorMessage(err) },
|
|
1503
|
+
500
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
return jsonResponse(
|
|
1507
|
+
{ assets: entries.map(serialiseEntry) },
|
|
1508
|
+
200
|
|
1509
|
+
);
|
|
1510
|
+
};
|
|
1511
|
+
const upload = async (req) => {
|
|
1512
|
+
let form;
|
|
1513
|
+
try {
|
|
1514
|
+
form = await req.formData();
|
|
1515
|
+
} catch (err) {
|
|
1516
|
+
return jsonResponse(
|
|
1517
|
+
{ error: "invalid_body", message: safeErrorMessage(err) },
|
|
1518
|
+
400
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
const fileField = form.get("file");
|
|
1522
|
+
if (!(fileField instanceof Blob)) {
|
|
1523
|
+
return jsonResponse(
|
|
1524
|
+
{ error: "missing_file", message: "Missing required field: file" },
|
|
1525
|
+
400
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
const contentType = fileField.type;
|
|
1529
|
+
if (!contentType.startsWith("image/")) {
|
|
1530
|
+
return jsonResponse(
|
|
1531
|
+
{
|
|
1532
|
+
error: "invalid_content_type",
|
|
1533
|
+
message: `Expected image/*, got ${contentType || "unknown"}`
|
|
1534
|
+
},
|
|
1535
|
+
400
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
const filename = "name" in fileField && typeof fileField.name === "string" ? fileField.name : "";
|
|
1539
|
+
const arrayBuffer = await fileField.arrayBuffer();
|
|
1540
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
1541
|
+
let result;
|
|
1542
|
+
try {
|
|
1543
|
+
result = await assetAdapter.upload({
|
|
1544
|
+
bytes,
|
|
1545
|
+
filename,
|
|
1546
|
+
contentType
|
|
1547
|
+
});
|
|
1548
|
+
} catch (err) {
|
|
1549
|
+
return jsonResponse(
|
|
1550
|
+
{ error: "upload_failed", message: safeErrorMessage(err) },
|
|
1551
|
+
500
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
const asset = {
|
|
1555
|
+
filename: result.filename,
|
|
1556
|
+
url: result.url,
|
|
1557
|
+
contentType,
|
|
1558
|
+
modifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1559
|
+
};
|
|
1560
|
+
return jsonResponse({ asset }, 200);
|
|
1561
|
+
};
|
|
1562
|
+
return { list, upload };
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// src/tasks/store.ts
|
|
1566
|
+
var isTerminal2 = (entry) => entry.state === "completed" || entry.state === "failed";
|
|
1567
|
+
var toSnapshot = (entry) => {
|
|
1568
|
+
const base = { id: entry.task.id, type: entry.task.type, state: entry.state };
|
|
1569
|
+
switch (entry.state) {
|
|
1570
|
+
case "pending":
|
|
1571
|
+
case "in_progress":
|
|
1572
|
+
return base;
|
|
1573
|
+
case "completed":
|
|
1574
|
+
return { ...base, result: entry.result };
|
|
1575
|
+
case "failed":
|
|
1576
|
+
return { ...base, error: entry.error };
|
|
1577
|
+
}
|
|
1578
|
+
};
|
|
1579
|
+
var require_ = (entries, id) => {
|
|
1580
|
+
const entry = entries.get(id);
|
|
1581
|
+
if (entry === void 0) {
|
|
1582
|
+
throw new Error(`unknown task id: ${id}`);
|
|
1583
|
+
}
|
|
1584
|
+
return entry;
|
|
1585
|
+
};
|
|
1586
|
+
var requirePending = (entries, id) => {
|
|
1587
|
+
const entry = require_(entries, id);
|
|
1588
|
+
if (isTerminal2(entry)) {
|
|
1589
|
+
throw new Error(`task ${id} is already ${entry.state}`);
|
|
1590
|
+
}
|
|
1591
|
+
return entry;
|
|
1592
|
+
};
|
|
1593
|
+
var createTaskStore = () => {
|
|
1594
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1595
|
+
const emit = (listeners, event) => {
|
|
1596
|
+
for (const listener of Array.from(listeners)) {
|
|
1597
|
+
listener(event);
|
|
1598
|
+
}
|
|
1599
|
+
};
|
|
1600
|
+
const create = (task) => {
|
|
1601
|
+
if (entries.has(task.id)) {
|
|
1602
|
+
throw new Error(`duplicate task id: ${task.id}`);
|
|
1603
|
+
}
|
|
1604
|
+
return new Promise((resolve6, reject) => {
|
|
1605
|
+
const entry = {
|
|
1606
|
+
state: "pending",
|
|
1607
|
+
task,
|
|
1608
|
+
resolve: resolve6,
|
|
1609
|
+
reject,
|
|
1610
|
+
listeners: /* @__PURE__ */ new Set()
|
|
1611
|
+
};
|
|
1612
|
+
entries.set(task.id, entry);
|
|
1613
|
+
});
|
|
1614
|
+
};
|
|
1615
|
+
const get = (id) => {
|
|
1616
|
+
const entry = entries.get(id);
|
|
1617
|
+
return entry === void 0 ? void 0 : toSnapshot(entry);
|
|
1618
|
+
};
|
|
1619
|
+
const progress = (id, message) => {
|
|
1620
|
+
const pending = requirePending(entries, id);
|
|
1621
|
+
if (pending.state === "pending") {
|
|
1622
|
+
const next = {
|
|
1623
|
+
...pending,
|
|
1624
|
+
state: "in_progress"
|
|
1625
|
+
};
|
|
1626
|
+
entries.set(id, next);
|
|
1627
|
+
emit(next.listeners, { kind: "progress", message });
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
emit(pending.listeners, { kind: "progress", message });
|
|
1631
|
+
};
|
|
1632
|
+
const complete = (id, result) => {
|
|
1633
|
+
const pending = requirePending(entries, id);
|
|
1634
|
+
const terminal = {
|
|
1635
|
+
state: "completed",
|
|
1636
|
+
task: pending.task,
|
|
1637
|
+
result
|
|
1638
|
+
};
|
|
1639
|
+
entries.set(id, terminal);
|
|
1640
|
+
pending.resolve(result);
|
|
1641
|
+
emit(pending.listeners, { kind: "completed", result });
|
|
1642
|
+
};
|
|
1643
|
+
const fail = (id, error) => {
|
|
1644
|
+
const pending = requirePending(entries, id);
|
|
1645
|
+
const terminal = {
|
|
1646
|
+
state: "failed",
|
|
1647
|
+
task: pending.task,
|
|
1648
|
+
error
|
|
1649
|
+
};
|
|
1650
|
+
entries.set(id, terminal);
|
|
1651
|
+
pending.reject(new Error(error));
|
|
1652
|
+
emit(pending.listeners, { kind: "failed", error });
|
|
1653
|
+
};
|
|
1654
|
+
const subscribe = (id, listener) => {
|
|
1655
|
+
const entry = require_(entries, id);
|
|
1656
|
+
if (isTerminal2(entry)) {
|
|
1657
|
+
const replayEvent = entry.state === "completed" ? { kind: "completed", result: entry.result } : { kind: "failed", error: entry.error };
|
|
1658
|
+
listener(replayEvent);
|
|
1659
|
+
const noop = () => {
|
|
1660
|
+
};
|
|
1661
|
+
return noop;
|
|
1662
|
+
}
|
|
1663
|
+
entry.listeners.add(listener);
|
|
1664
|
+
return () => {
|
|
1665
|
+
entry.listeners.delete(listener);
|
|
1666
|
+
};
|
|
1667
|
+
};
|
|
1668
|
+
return { create, get, progress, complete, fail, subscribe };
|
|
1669
|
+
};
|
|
1670
|
+
|
|
1671
|
+
// src/preview-tokens/store.ts
|
|
1672
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1673
|
+
var DEFAULT_TTL_MS = 6e5;
|
|
1674
|
+
var createPreviewTokenStore = (options) => {
|
|
1675
|
+
const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
|
|
1676
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1677
|
+
const sweep = (now) => {
|
|
1678
|
+
for (const [token, entry] of entries) {
|
|
1679
|
+
if (entry.createdAt + ttlMs <= now) {
|
|
1680
|
+
entries.delete(token);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
};
|
|
1684
|
+
const issue = (slug) => {
|
|
1685
|
+
const now = Date.now();
|
|
1686
|
+
sweep(now);
|
|
1687
|
+
const token = randomUUID2();
|
|
1688
|
+
const entry = { slug, createdAt: now };
|
|
1689
|
+
entries.set(token, entry);
|
|
1690
|
+
return { token, slug, createdAt: now };
|
|
1691
|
+
};
|
|
1692
|
+
const consume = (token) => {
|
|
1693
|
+
const entry = entries.get(token);
|
|
1694
|
+
if (entry === void 0) {
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
entries.delete(token);
|
|
1698
|
+
const now = Date.now();
|
|
1699
|
+
if (entry.createdAt + ttlMs <= now) {
|
|
1700
|
+
return null;
|
|
1701
|
+
}
|
|
1702
|
+
return entry.slug;
|
|
1703
|
+
};
|
|
1704
|
+
return { issue, consume };
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1707
|
+
// src/handlers/page/page-handler.ts
|
|
1708
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1709
|
+
var SLUG_PATTERN2 = /^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/;
|
|
1710
|
+
var reservedSlugResponse = (slug, error) => {
|
|
1711
|
+
const violation = getReservedPageSlugViolation(slug);
|
|
1712
|
+
if (!violation) return null;
|
|
1713
|
+
return jsonResponse({ error, message: violation.message }, 400);
|
|
1714
|
+
};
|
|
1715
|
+
var cloneSection = (section) => {
|
|
1716
|
+
const fresh = {
|
|
1717
|
+
id: `sec_${randomUUID3()}`,
|
|
1718
|
+
type: section.type,
|
|
1719
|
+
data: section.data,
|
|
1720
|
+
...section.globalRef !== void 0 ? { globalRef: section.globalRef } : {}
|
|
1721
|
+
};
|
|
1722
|
+
return fresh;
|
|
1723
|
+
};
|
|
1724
|
+
function createPageHandler(deps) {
|
|
1725
|
+
const { contentAdapter, runtime } = deps;
|
|
1726
|
+
const parseLimitParam = (raw) => {
|
|
1727
|
+
if (raw === null || raw === "") return { ok: true, value: void 0 };
|
|
1728
|
+
const n = Number(raw);
|
|
1729
|
+
if (!Number.isFinite(n)) return { ok: false };
|
|
1730
|
+
if (!Number.isInteger(n)) return { ok: false };
|
|
1731
|
+
if (n < 0) return { ok: false };
|
|
1732
|
+
return { ok: true, value: n };
|
|
1733
|
+
};
|
|
1734
|
+
const parseSortParam = (raw) => {
|
|
1735
|
+
if (raw === null || raw === "") return { ok: true, value: void 0 };
|
|
1736
|
+
if (raw === "newest" || raw === "oldest") return { ok: true, value: raw };
|
|
1737
|
+
return { ok: false };
|
|
1738
|
+
};
|
|
1739
|
+
const listSummaries = async (req) => {
|
|
1740
|
+
const url = new URL(req.url);
|
|
1741
|
+
const tagRaw = url.searchParams.get("tag");
|
|
1742
|
+
const limitParsed = parseLimitParam(url.searchParams.get("limit"));
|
|
1743
|
+
const sortParsed = parseSortParam(url.searchParams.get("sort"));
|
|
1744
|
+
if (!limitParsed.ok) {
|
|
1745
|
+
return jsonResponse(
|
|
1746
|
+
{ error: "invalid_limit", message: "limit must be a non-negative integer" },
|
|
1747
|
+
400
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
if (!sortParsed.ok) {
|
|
1751
|
+
return jsonResponse(
|
|
1752
|
+
{ error: "invalid_sort", message: "sort must be 'newest' or 'oldest'" },
|
|
1753
|
+
400
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
const tag = tagRaw === null || tagRaw === "" ? void 0 : tagRaw;
|
|
1757
|
+
const mutableInput = {};
|
|
1758
|
+
if (tag !== void 0) mutableInput.tag = tag;
|
|
1759
|
+
if (limitParsed.value !== void 0) mutableInput.limit = limitParsed.value;
|
|
1760
|
+
if (sortParsed.value !== void 0) mutableInput.sort = sortParsed.value;
|
|
1761
|
+
const input = mutableInput;
|
|
1762
|
+
try {
|
|
1763
|
+
const pages = await runtime.listPages(input);
|
|
1764
|
+
return jsonResponse({ pages }, 200);
|
|
1765
|
+
} catch (err) {
|
|
1766
|
+
return jsonResponse(
|
|
1767
|
+
{ error: "list_pages_failed", message: safeErrorMessage(err) },
|
|
1768
|
+
500
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
const list = async (req) => {
|
|
1773
|
+
const url = new URL(req.url);
|
|
1774
|
+
const hasListPagesParams = url.searchParams.has("tag") || url.searchParams.has("limit") || url.searchParams.has("sort");
|
|
1775
|
+
if (hasListPagesParams) {
|
|
1776
|
+
return listSummaries(req);
|
|
1777
|
+
}
|
|
1778
|
+
try {
|
|
1779
|
+
const [pages, drafts] = await Promise.all([
|
|
1780
|
+
contentAdapter.listPages(),
|
|
1781
|
+
contentAdapter.listDrafts()
|
|
1782
|
+
]);
|
|
1783
|
+
const map = /* @__PURE__ */ new Map();
|
|
1784
|
+
for (const p of pages) {
|
|
1785
|
+
map.set(p.slug, {
|
|
1786
|
+
slug: p.slug,
|
|
1787
|
+
hasPublished: true,
|
|
1788
|
+
hasDraft: false,
|
|
1789
|
+
updatedAt: p.updatedAt.toISOString()
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
for (const d of drafts) {
|
|
1793
|
+
const existing = map.get(d.slug);
|
|
1794
|
+
if (existing) {
|
|
1795
|
+
existing.hasDraft = true;
|
|
1796
|
+
const existingDate = new Date(existing.updatedAt);
|
|
1797
|
+
if (d.updatedAt > existingDate) {
|
|
1798
|
+
existing.updatedAt = d.updatedAt.toISOString();
|
|
1799
|
+
}
|
|
1800
|
+
} else {
|
|
1801
|
+
map.set(d.slug, {
|
|
1802
|
+
slug: d.slug,
|
|
1803
|
+
hasPublished: false,
|
|
1804
|
+
hasDraft: true,
|
|
1805
|
+
updatedAt: d.updatedAt.toISOString()
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
const combined = Array.from(map.values()).sort(
|
|
1810
|
+
(a, b) => a.slug.localeCompare(b.slug)
|
|
1811
|
+
);
|
|
1812
|
+
return jsonResponse({ pages: combined }, 200);
|
|
1813
|
+
} catch (err) {
|
|
1814
|
+
return jsonResponse(
|
|
1815
|
+
{ error: "list_pages_failed", message: safeErrorMessage(err) },
|
|
1816
|
+
500
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1820
|
+
const read = async (req) => {
|
|
1821
|
+
const url = new URL(req.url);
|
|
1822
|
+
const slug = url.searchParams.get("slug");
|
|
1823
|
+
if (!slug || slug === "") {
|
|
1824
|
+
return jsonResponse({ error: "missing_slug" }, 400);
|
|
1825
|
+
}
|
|
1826
|
+
try {
|
|
1827
|
+
const page = await contentAdapter.readPage(slug, "draft") ?? await contentAdapter.readPage(slug, "published");
|
|
1828
|
+
if (!page) {
|
|
1829
|
+
return jsonResponse({ error: "page_not_found" }, 404);
|
|
1830
|
+
}
|
|
1831
|
+
return jsonResponse({ page }, 200);
|
|
1832
|
+
} catch (err) {
|
|
1833
|
+
return jsonResponse(
|
|
1834
|
+
{ error: "read_page_failed", message: safeErrorMessage(err) },
|
|
1835
|
+
500
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
};
|
|
1839
|
+
const deletePage = async (req) => {
|
|
1840
|
+
let body;
|
|
1841
|
+
try {
|
|
1842
|
+
body = await req.json();
|
|
1843
|
+
} catch {
|
|
1844
|
+
return jsonResponse({ error: "invalid_json" }, 400);
|
|
1845
|
+
}
|
|
1846
|
+
if (!body || typeof body !== "object" || !("slug" in body) || typeof body.slug !== "string") {
|
|
1847
|
+
return jsonResponse({ error: "missing_slug" }, 400);
|
|
1848
|
+
}
|
|
1849
|
+
const slug = body.slug;
|
|
1850
|
+
try {
|
|
1851
|
+
await contentAdapter.deletePage(slug);
|
|
1852
|
+
} catch (err) {
|
|
1853
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1854
|
+
if (message.includes("page not found")) {
|
|
1855
|
+
return jsonResponse({ error: message }, 404);
|
|
1856
|
+
}
|
|
1857
|
+
return jsonResponse({ error: message }, 500);
|
|
1858
|
+
}
|
|
1859
|
+
return jsonResponse({ ok: true }, 200);
|
|
1860
|
+
};
|
|
1861
|
+
const unpublish = async (req) => {
|
|
1862
|
+
let body;
|
|
1863
|
+
try {
|
|
1864
|
+
body = await req.json();
|
|
1865
|
+
} catch {
|
|
1866
|
+
return jsonResponse({ error: "invalid_json" }, 400);
|
|
1867
|
+
}
|
|
1868
|
+
if (!body || typeof body !== "object" || !("slug" in body) || typeof body.slug !== "string") {
|
|
1869
|
+
return jsonResponse({ error: "missing_slug" }, 400);
|
|
1870
|
+
}
|
|
1871
|
+
const slug = body.slug;
|
|
1872
|
+
try {
|
|
1873
|
+
await contentAdapter.unpublishPage(slug);
|
|
1874
|
+
} catch (err) {
|
|
1875
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1876
|
+
if (message.includes("page not found")) {
|
|
1877
|
+
return jsonResponse({ error: message }, 404);
|
|
1878
|
+
}
|
|
1879
|
+
return jsonResponse({ error: message }, 500);
|
|
1880
|
+
}
|
|
1881
|
+
return jsonResponse({ ok: true }, 200);
|
|
1882
|
+
};
|
|
1883
|
+
const listHistory = async (req) => {
|
|
1884
|
+
const url = new URL(req.url);
|
|
1885
|
+
const slug = url.searchParams.get("slug");
|
|
1886
|
+
const timestamp = url.searchParams.get("ts");
|
|
1887
|
+
if (!slug || slug === "") {
|
|
1888
|
+
return jsonResponse({ error: "missing_slug" }, 400);
|
|
1889
|
+
}
|
|
1890
|
+
if (timestamp !== null) {
|
|
1891
|
+
if (timestamp === "") {
|
|
1892
|
+
return jsonResponse({ error: "missing_timestamp" }, 400);
|
|
1893
|
+
}
|
|
1894
|
+
try {
|
|
1895
|
+
const snapshot = await contentAdapter.readHistorySnapshot(slug, timestamp);
|
|
1896
|
+
if (snapshot === null) {
|
|
1897
|
+
return jsonResponse({ error: "snapshot_not_found" }, 404);
|
|
1898
|
+
}
|
|
1899
|
+
return jsonResponse({ page: snapshot }, 200);
|
|
1900
|
+
} catch (err) {
|
|
1901
|
+
return jsonResponse(
|
|
1902
|
+
{ error: "read_history_snapshot_failed", message: safeErrorMessage(err) },
|
|
1903
|
+
500
|
|
1904
|
+
);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
try {
|
|
1908
|
+
const entries = await contentAdapter.listHistory(slug);
|
|
1909
|
+
return jsonResponse({ entries }, 200);
|
|
1910
|
+
} catch (err) {
|
|
1911
|
+
return jsonResponse(
|
|
1912
|
+
{ error: "list_history_failed", message: safeErrorMessage(err) },
|
|
1913
|
+
500
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
1917
|
+
const rollback = async (req) => {
|
|
1918
|
+
let body;
|
|
1919
|
+
try {
|
|
1920
|
+
body = await req.json();
|
|
1921
|
+
} catch {
|
|
1922
|
+
return jsonResponse({ error: "invalid_json" }, 400);
|
|
1923
|
+
}
|
|
1924
|
+
if (body === null || typeof body !== "object") {
|
|
1925
|
+
return jsonResponse({ error: "invalid_body", message: "Expected a JSON object" }, 400);
|
|
1926
|
+
}
|
|
1927
|
+
const obj = body;
|
|
1928
|
+
if (typeof obj["slug"] !== "string" || obj["slug"] === "") {
|
|
1929
|
+
return jsonResponse({ error: "missing_slug" }, 400);
|
|
1930
|
+
}
|
|
1931
|
+
if (typeof obj["timestamp"] !== "string" || obj["timestamp"] === "") {
|
|
1932
|
+
return jsonResponse({ error: "missing_timestamp" }, 400);
|
|
1933
|
+
}
|
|
1934
|
+
const slug = obj["slug"];
|
|
1935
|
+
const timestamp = obj["timestamp"];
|
|
1936
|
+
let snapshot;
|
|
1937
|
+
try {
|
|
1938
|
+
snapshot = await contentAdapter.readHistorySnapshot(slug, timestamp);
|
|
1939
|
+
} catch (err) {
|
|
1940
|
+
return jsonResponse(
|
|
1941
|
+
{ error: "read_history_snapshot_failed", message: safeErrorMessage(err) },
|
|
1942
|
+
500
|
|
1943
|
+
);
|
|
1944
|
+
}
|
|
1945
|
+
if (snapshot === null) {
|
|
1946
|
+
return jsonResponse({ error: "snapshot_not_found" }, 404);
|
|
1947
|
+
}
|
|
1948
|
+
try {
|
|
1949
|
+
await contentAdapter.saveDraft(snapshot);
|
|
1950
|
+
await runtime.publishDraft(slug);
|
|
1951
|
+
} catch (err) {
|
|
1952
|
+
return jsonResponse(
|
|
1953
|
+
{ error: "rollback_failed", message: safeErrorMessage(err) },
|
|
1954
|
+
500
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
return jsonResponse({ ok: true }, 200);
|
|
1958
|
+
};
|
|
1959
|
+
const rename3 = async (req) => {
|
|
1960
|
+
let body;
|
|
1961
|
+
try {
|
|
1962
|
+
body = await req.json();
|
|
1963
|
+
} catch {
|
|
1964
|
+
return jsonResponse({ error: "invalid_json" }, 400);
|
|
1965
|
+
}
|
|
1966
|
+
if (body === null || typeof body !== "object") {
|
|
1967
|
+
return jsonResponse({ error: "invalid_body", message: "Expected a JSON object" }, 400);
|
|
1968
|
+
}
|
|
1969
|
+
const obj = body;
|
|
1970
|
+
if (typeof obj["fromSlug"] !== "string" || obj["fromSlug"] === "") {
|
|
1971
|
+
return jsonResponse({ error: "missing_from_slug" }, 400);
|
|
1972
|
+
}
|
|
1973
|
+
if (typeof obj["toSlug"] !== "string" || obj["toSlug"] === "") {
|
|
1974
|
+
return jsonResponse({ error: "missing_to_slug" }, 400);
|
|
1975
|
+
}
|
|
1976
|
+
const fromSlug = obj["fromSlug"];
|
|
1977
|
+
const toSlug = obj["toSlug"];
|
|
1978
|
+
const reservedToSlug = reservedSlugResponse(toSlug, "reserved_to_slug");
|
|
1979
|
+
if (reservedToSlug) return reservedToSlug;
|
|
1980
|
+
try {
|
|
1981
|
+
await contentAdapter.renamePage(fromSlug, toSlug);
|
|
1982
|
+
} catch (err) {
|
|
1983
|
+
const message = safeErrorMessage(err);
|
|
1984
|
+
if (message.includes("page not found")) {
|
|
1985
|
+
return jsonResponse({ error: message }, 404);
|
|
1986
|
+
}
|
|
1987
|
+
if (message.includes("target slug already exists")) {
|
|
1988
|
+
return jsonResponse({ error: message }, 409);
|
|
1989
|
+
}
|
|
1990
|
+
return jsonResponse({ error: message }, 500);
|
|
1991
|
+
}
|
|
1992
|
+
return jsonResponse({ ok: true }, 200);
|
|
1993
|
+
};
|
|
1994
|
+
const duplicate = async (req) => {
|
|
1995
|
+
let body;
|
|
1996
|
+
try {
|
|
1997
|
+
body = await req.json();
|
|
1998
|
+
} catch {
|
|
1999
|
+
return jsonResponse({ error: "invalid_json" }, 400);
|
|
2000
|
+
}
|
|
2001
|
+
if (body === null || typeof body !== "object") {
|
|
2002
|
+
return jsonResponse({ error: "invalid_body", message: "Expected a JSON object" }, 400);
|
|
2003
|
+
}
|
|
2004
|
+
const obj = body;
|
|
2005
|
+
if (typeof obj["slug"] !== "string" || obj["slug"] === "") {
|
|
2006
|
+
return jsonResponse({ error: "missing_slug" }, 400);
|
|
2007
|
+
}
|
|
2008
|
+
if (typeof obj["newSlug"] !== "string" || obj["newSlug"] === "") {
|
|
2009
|
+
return jsonResponse({ error: "missing_new_slug" }, 400);
|
|
2010
|
+
}
|
|
2011
|
+
const slug = obj["slug"];
|
|
2012
|
+
const newSlug = obj["newSlug"];
|
|
2013
|
+
if (!SLUG_PATTERN2.test(newSlug)) {
|
|
2014
|
+
return jsonResponse({ error: "invalid_new_slug", message: "Slug must contain only letters, numbers, hyphens, underscores, and forward slashes." }, 400);
|
|
2015
|
+
}
|
|
2016
|
+
const reservedNewSlug = reservedSlugResponse(newSlug, "reserved_new_slug");
|
|
2017
|
+
if (reservedNewSlug) return reservedNewSlug;
|
|
2018
|
+
if (newSlug === slug) {
|
|
2019
|
+
return jsonResponse({ error: "same_slug", message: "newSlug must differ from slug" }, 400);
|
|
2020
|
+
}
|
|
2021
|
+
const existingPublished = await contentAdapter.readPage(newSlug, "published");
|
|
2022
|
+
const existingDraft = await contentAdapter.readPage(newSlug, "draft");
|
|
2023
|
+
if (existingPublished !== null || existingDraft !== null) {
|
|
2024
|
+
return jsonResponse({ error: "slug_exists", message: `Slug "${newSlug}" is already in use.` }, 409);
|
|
2025
|
+
}
|
|
2026
|
+
const source = await contentAdapter.readPage(slug, "published") ?? await contentAdapter.readPage(slug, "draft");
|
|
2027
|
+
if (source === null) {
|
|
2028
|
+
return jsonResponse({ error: "source_not_found", message: `No page found for slug: ${slug}` }, 404);
|
|
2029
|
+
}
|
|
2030
|
+
const clonedSections = source.sections.map(cloneSection);
|
|
2031
|
+
const { sections: _srcSections, slug: _srcSlug, ...metaFromSource } = source;
|
|
2032
|
+
void _srcSections;
|
|
2033
|
+
void _srcSlug;
|
|
2034
|
+
const clone = {
|
|
2035
|
+
...metaFromSource,
|
|
2036
|
+
slug: newSlug,
|
|
2037
|
+
sections: clonedSections
|
|
2038
|
+
};
|
|
2039
|
+
try {
|
|
2040
|
+
await contentAdapter.saveDraft(clone);
|
|
2041
|
+
} catch (err) {
|
|
2042
|
+
return jsonResponse(
|
|
2043
|
+
{ error: "duplicate_failed", message: safeErrorMessage(err) },
|
|
2044
|
+
500
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
2047
|
+
return jsonResponse({ ok: true, slug: newSlug }, 200);
|
|
2048
|
+
};
|
|
2049
|
+
return { list, read, deletePage, unpublish, listHistory, rollback, rename: rename3, duplicate };
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// src/handlers/globals/global-handler.ts
|
|
2053
|
+
var GLOBAL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
2054
|
+
function createGlobalHandler(deps) {
|
|
2055
|
+
const { contentAdapter } = deps;
|
|
2056
|
+
const list = async (_req) => {
|
|
2057
|
+
try {
|
|
2058
|
+
const globals = await contentAdapter.listGlobals();
|
|
2059
|
+
const serialized = globals.map((g) => ({
|
|
2060
|
+
name: g.name,
|
|
2061
|
+
type: g.type,
|
|
2062
|
+
updatedAt: g.updatedAt.toISOString()
|
|
2063
|
+
}));
|
|
2064
|
+
const body = { globals: serialized };
|
|
2065
|
+
if (deps.allowedTypes) {
|
|
2066
|
+
body["types"] = Array.from(deps.allowedTypes).sort();
|
|
2067
|
+
}
|
|
2068
|
+
return jsonResponse(body, 200);
|
|
2069
|
+
} catch (err) {
|
|
2070
|
+
return jsonResponse(
|
|
2071
|
+
{ error: "list_globals_failed", message: safeErrorMessage(err) },
|
|
2072
|
+
500
|
|
2073
|
+
);
|
|
2074
|
+
}
|
|
2075
|
+
};
|
|
2076
|
+
const read = async (req) => {
|
|
2077
|
+
const url = new URL(req.url);
|
|
2078
|
+
const name = url.searchParams.get("name");
|
|
2079
|
+
if (!name) {
|
|
2080
|
+
return jsonResponse({ error: "missing_name" }, 400);
|
|
2081
|
+
}
|
|
2082
|
+
if (!GLOBAL_NAME_PATTERN.test(name)) {
|
|
2083
|
+
return jsonResponse(
|
|
2084
|
+
{ error: "invalid_name", message: "Global name must contain only letters, numbers, hyphens, and underscores" },
|
|
2085
|
+
400
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
2088
|
+
try {
|
|
2089
|
+
const global = await contentAdapter.readGlobal(name);
|
|
2090
|
+
if (!global) {
|
|
2091
|
+
return jsonResponse({ error: "not_found", message: `Global "${name}" not found` }, 404);
|
|
2092
|
+
}
|
|
2093
|
+
return jsonResponse({ global: { name: global.name, type: global.type, data: global.data } }, 200);
|
|
2094
|
+
} catch (err) {
|
|
2095
|
+
return jsonResponse(
|
|
2096
|
+
{ error: "read_global_failed", message: safeErrorMessage(err) },
|
|
2097
|
+
500
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
2100
|
+
};
|
|
2101
|
+
const save = async (req) => {
|
|
2102
|
+
let body;
|
|
2103
|
+
try {
|
|
2104
|
+
body = await req.json();
|
|
2105
|
+
} catch {
|
|
2106
|
+
return jsonResponse({ error: "invalid_json" }, 400);
|
|
2107
|
+
}
|
|
2108
|
+
if (body === null || typeof body !== "object") {
|
|
2109
|
+
return jsonResponse({ error: "invalid_body", message: "Expected a JSON object" }, 400);
|
|
2110
|
+
}
|
|
2111
|
+
const obj = body;
|
|
2112
|
+
if (typeof obj["name"] !== "string" || obj["name"] === "") {
|
|
2113
|
+
return jsonResponse({ error: "missing_name" }, 400);
|
|
2114
|
+
}
|
|
2115
|
+
if (!GLOBAL_NAME_PATTERN.test(obj["name"])) {
|
|
2116
|
+
return jsonResponse({ error: "invalid_name", message: "Global name must contain only letters, numbers, hyphens, and underscores" }, 400);
|
|
2117
|
+
}
|
|
2118
|
+
if (typeof obj["type"] !== "string" || obj["type"] === "") {
|
|
2119
|
+
return jsonResponse({ error: "missing_type" }, 400);
|
|
2120
|
+
}
|
|
2121
|
+
if (deps.allowedTypes && !deps.allowedTypes.has(obj["type"])) {
|
|
2122
|
+
return jsonResponse(
|
|
2123
|
+
{ error: "unknown_type", message: `Type "${obj["type"]}" is not registered` },
|
|
2124
|
+
400
|
|
2125
|
+
);
|
|
2126
|
+
}
|
|
2127
|
+
if (obj["data"] === void 0 || obj["data"] === null || typeof obj["data"] !== "object" || Array.isArray(obj["data"])) {
|
|
2128
|
+
return jsonResponse({ error: "missing_data" }, 400);
|
|
2129
|
+
}
|
|
2130
|
+
let mergedData = obj["data"];
|
|
2131
|
+
if (deps.sectionDefaults) {
|
|
2132
|
+
const defaults = deps.sectionDefaults.get(obj["type"]);
|
|
2133
|
+
if (defaults) {
|
|
2134
|
+
const result = { ...defaults };
|
|
2135
|
+
for (const key of Object.keys(mergedData)) {
|
|
2136
|
+
result[key] = mergedData[key];
|
|
2137
|
+
}
|
|
2138
|
+
mergedData = result;
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
try {
|
|
2142
|
+
await contentAdapter.saveGlobal({
|
|
2143
|
+
name: obj["name"],
|
|
2144
|
+
type: obj["type"],
|
|
2145
|
+
data: mergedData
|
|
2146
|
+
});
|
|
2147
|
+
return jsonResponse({ ok: true }, 200);
|
|
2148
|
+
} catch (err) {
|
|
2149
|
+
return jsonResponse(
|
|
2150
|
+
{ error: "save_global_failed", message: safeErrorMessage(err) },
|
|
2151
|
+
500
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
};
|
|
2155
|
+
const deleteHandler = async (req) => {
|
|
2156
|
+
let body;
|
|
2157
|
+
try {
|
|
2158
|
+
body = await req.json();
|
|
2159
|
+
} catch {
|
|
2160
|
+
return jsonResponse({ error: "invalid_json" }, 400);
|
|
2161
|
+
}
|
|
2162
|
+
if (body === null || typeof body !== "object") {
|
|
2163
|
+
return jsonResponse({ error: "invalid_body", message: "Expected a JSON object" }, 400);
|
|
2164
|
+
}
|
|
2165
|
+
const obj = body;
|
|
2166
|
+
if (typeof obj["name"] !== "string" || obj["name"] === "") {
|
|
2167
|
+
return jsonResponse({ error: "missing_name" }, 400);
|
|
2168
|
+
}
|
|
2169
|
+
if (!GLOBAL_NAME_PATTERN.test(obj["name"])) {
|
|
2170
|
+
return jsonResponse({ error: "invalid_name", message: "Global name must contain only letters, numbers, hyphens, and underscores" }, 400);
|
|
2171
|
+
}
|
|
2172
|
+
try {
|
|
2173
|
+
await contentAdapter.deleteGlobal(obj["name"]);
|
|
2174
|
+
return jsonResponse({ ok: true }, 200);
|
|
2175
|
+
} catch (err) {
|
|
2176
|
+
const message = safeErrorMessage(err);
|
|
2177
|
+
if (message.includes("global not found")) {
|
|
2178
|
+
return jsonResponse({ error: message }, 404);
|
|
2179
|
+
}
|
|
2180
|
+
return jsonResponse(
|
|
2181
|
+
{ error: "delete_global_failed", message },
|
|
2182
|
+
500
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
};
|
|
2186
|
+
const listHistory = async (req) => {
|
|
2187
|
+
const url = new URL(req.url);
|
|
2188
|
+
const name = url.searchParams.get("name");
|
|
2189
|
+
const timestamp = url.searchParams.get("ts");
|
|
2190
|
+
if (!name || name === "") {
|
|
2191
|
+
return jsonResponse({ error: "missing_name" }, 400);
|
|
2192
|
+
}
|
|
2193
|
+
if (!GLOBAL_NAME_PATTERN.test(name)) {
|
|
2194
|
+
return jsonResponse(
|
|
2195
|
+
{ error: "invalid_name", message: "Global name must contain only letters, numbers, hyphens, and underscores" },
|
|
2196
|
+
400
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
if (timestamp !== null) {
|
|
2200
|
+
if (timestamp === "") {
|
|
2201
|
+
return jsonResponse({ error: "missing_timestamp" }, 400);
|
|
2202
|
+
}
|
|
2203
|
+
try {
|
|
2204
|
+
const snapshot = await contentAdapter.readGlobalHistorySnapshot(name, timestamp);
|
|
2205
|
+
if (snapshot === null) {
|
|
2206
|
+
return jsonResponse({ error: "not_found" }, 404);
|
|
2207
|
+
}
|
|
2208
|
+
return jsonResponse({ global: snapshot }, 200);
|
|
2209
|
+
} catch (err) {
|
|
2210
|
+
return jsonResponse(
|
|
2211
|
+
{ error: "read_history_snapshot_failed", message: safeErrorMessage(err) },
|
|
2212
|
+
500
|
|
2213
|
+
);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
try {
|
|
2217
|
+
const entries = await contentAdapter.listGlobalHistory(name);
|
|
2218
|
+
const projected = entries.map((e) => ({ timestamp: e.timestamp }));
|
|
2219
|
+
return jsonResponse({ entries: projected }, 200);
|
|
2220
|
+
} catch (err) {
|
|
2221
|
+
return jsonResponse(
|
|
2222
|
+
{ error: "list_history_failed", message: safeErrorMessage(err) },
|
|
2223
|
+
500
|
|
2224
|
+
);
|
|
2225
|
+
}
|
|
2226
|
+
};
|
|
2227
|
+
const rollback = async (req) => {
|
|
2228
|
+
let body;
|
|
2229
|
+
try {
|
|
2230
|
+
body = await req.json();
|
|
2231
|
+
} catch {
|
|
2232
|
+
return jsonResponse({ error: "invalid_json" }, 400);
|
|
2233
|
+
}
|
|
2234
|
+
if (body === null || typeof body !== "object") {
|
|
2235
|
+
return jsonResponse({ error: "invalid_body", message: "Expected a JSON object" }, 400);
|
|
2236
|
+
}
|
|
2237
|
+
const obj = body;
|
|
2238
|
+
if (typeof obj["name"] !== "string" || obj["name"] === "") {
|
|
2239
|
+
return jsonResponse({ error: "missing_name" }, 400);
|
|
2240
|
+
}
|
|
2241
|
+
if (!GLOBAL_NAME_PATTERN.test(obj["name"])) {
|
|
2242
|
+
return jsonResponse(
|
|
2243
|
+
{ error: "invalid_name", message: "Global name must contain only letters, numbers, hyphens, and underscores" },
|
|
2244
|
+
400
|
|
2245
|
+
);
|
|
2246
|
+
}
|
|
2247
|
+
if (typeof obj["timestamp"] !== "string" || obj["timestamp"] === "") {
|
|
2248
|
+
return jsonResponse({ error: "missing_timestamp" }, 400);
|
|
2249
|
+
}
|
|
2250
|
+
const name = obj["name"];
|
|
2251
|
+
const timestamp = obj["timestamp"];
|
|
2252
|
+
try {
|
|
2253
|
+
await contentAdapter.rollbackGlobal(name, timestamp);
|
|
2254
|
+
} catch (err) {
|
|
2255
|
+
const message = safeErrorMessage(err);
|
|
2256
|
+
if (message.includes("global history snapshot not found")) {
|
|
2257
|
+
return jsonResponse({ error: "not_found" }, 404);
|
|
2258
|
+
}
|
|
2259
|
+
return jsonResponse(
|
|
2260
|
+
{ error: "rollback_failed", message },
|
|
2261
|
+
500
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2264
|
+
return jsonResponse({ ok: true }, 200);
|
|
2265
|
+
};
|
|
2266
|
+
return { list, read, save, delete: deleteHandler, listHistory, rollback };
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
// src/handlers/template/template-handler.ts
|
|
2270
|
+
function createTemplateHandler(deps) {
|
|
2271
|
+
const { sectionDefaults, templates } = deps;
|
|
2272
|
+
const list = async (_req) => {
|
|
2273
|
+
const resolved = [];
|
|
2274
|
+
for (const template of templates) {
|
|
2275
|
+
const sections = [];
|
|
2276
|
+
for (const typeName of template.sectionTypes) {
|
|
2277
|
+
const defaults = sectionDefaults.get(typeName);
|
|
2278
|
+
if (!defaults) {
|
|
2279
|
+
console.warn(
|
|
2280
|
+
`[agntcms] Template "${template.name}": section type "${typeName}" not found in registered sections, skipping.`
|
|
2281
|
+
);
|
|
2282
|
+
continue;
|
|
2283
|
+
}
|
|
2284
|
+
sections.push({ type: typeName, data: defaults });
|
|
2285
|
+
}
|
|
2286
|
+
resolved.push({
|
|
2287
|
+
name: template.name,
|
|
2288
|
+
...template.description !== void 0 ? { description: template.description } : {},
|
|
2289
|
+
sections
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
return jsonResponse({ templates: resolved }, 200);
|
|
2293
|
+
};
|
|
2294
|
+
return { list };
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// src/handlers/forms/submit-handler.ts
|
|
2298
|
+
var extractIp = (req) => {
|
|
2299
|
+
const xff = req.headers.get("x-forwarded-for");
|
|
2300
|
+
if (xff) {
|
|
2301
|
+
const first = xff.split(",")[0]?.trim();
|
|
2302
|
+
if (first && first.length > 0) return first;
|
|
2303
|
+
}
|
|
2304
|
+
const xri = req.headers.get("x-real-ip");
|
|
2305
|
+
if (xri && xri.trim().length > 0) return xri.trim();
|
|
2306
|
+
return "unknown";
|
|
2307
|
+
};
|
|
2308
|
+
function createSubmitFormHandler(deps) {
|
|
2309
|
+
const { runtime, rateLimit } = deps;
|
|
2310
|
+
return async function handle(req) {
|
|
2311
|
+
let body;
|
|
2312
|
+
try {
|
|
2313
|
+
body = await req.json();
|
|
2314
|
+
} catch {
|
|
2315
|
+
return jsonResponse({ ok: false, error: "invalid_json" }, 400);
|
|
2316
|
+
}
|
|
2317
|
+
if (body === null || typeof body !== "object") {
|
|
2318
|
+
return jsonResponse({ ok: false, error: "invalid_body" }, 400);
|
|
2319
|
+
}
|
|
2320
|
+
const obj = body;
|
|
2321
|
+
if (typeof obj["formName"] !== "string" || obj["formName"] === "") {
|
|
2322
|
+
return jsonResponse({ ok: false, error: "missing_form_name" }, 400);
|
|
2323
|
+
}
|
|
2324
|
+
const payload = obj["payload"];
|
|
2325
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
2326
|
+
return jsonResponse({ ok: false, error: "invalid_payload" }, 400);
|
|
2327
|
+
}
|
|
2328
|
+
const formName = obj["formName"];
|
|
2329
|
+
const ip = extractIp(req);
|
|
2330
|
+
const rl = rateLimit.check(ip, formName);
|
|
2331
|
+
if (!rl.allowed) {
|
|
2332
|
+
return jsonResponse({ ok: false, error: "rate_limit" }, 429, {
|
|
2333
|
+
// Inform polite clients when they may retry. Spec-compliant for 429.
|
|
2334
|
+
"Retry-After": Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1e3)).toString()
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
let result;
|
|
2338
|
+
try {
|
|
2339
|
+
result = await runtime.submitForm({
|
|
2340
|
+
formName,
|
|
2341
|
+
payload
|
|
2342
|
+
});
|
|
2343
|
+
} catch (err) {
|
|
2344
|
+
void safeErrorMessage(err);
|
|
2345
|
+
return jsonResponse({ ok: false, error: "storage_unavailable" }, 502);
|
|
2346
|
+
}
|
|
2347
|
+
if (!result.ok) {
|
|
2348
|
+
switch (result.error) {
|
|
2349
|
+
case "unknown_form":
|
|
2350
|
+
return jsonResponse({ ok: false, error: "unknown_form" }, 404);
|
|
2351
|
+
case "validation_failed":
|
|
2352
|
+
return jsonResponse(
|
|
2353
|
+
{ ok: false, error: "validation_failed", errors: result.errors },
|
|
2354
|
+
400
|
|
2355
|
+
);
|
|
2356
|
+
default: {
|
|
2357
|
+
const _exhaustive = result;
|
|
2358
|
+
void _exhaustive;
|
|
2359
|
+
return jsonResponse({ ok: false, error: "internal_error" }, 500);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
if (!result.stored) {
|
|
2364
|
+
return jsonResponse({ ok: true, stored: false }, 200);
|
|
2365
|
+
}
|
|
2366
|
+
return jsonResponse({ ok: true, stored: true, id: result.id }, 200);
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// src/handlers/forms/list-handler.ts
|
|
2371
|
+
function createFormsListHandler(deps) {
|
|
2372
|
+
const adapter = deps.adapter ?? { kind: "fs" };
|
|
2373
|
+
return async function handle(_req) {
|
|
2374
|
+
try {
|
|
2375
|
+
const forms = deps.forms.map((f) => {
|
|
2376
|
+
const schema = {};
|
|
2377
|
+
for (const [name, descriptor] of Object.entries(f.schema)) {
|
|
2378
|
+
schema[name] = descriptor;
|
|
2379
|
+
}
|
|
2380
|
+
return { name: f.name, schema };
|
|
2381
|
+
});
|
|
2382
|
+
return jsonResponse({ forms, adapter }, 200);
|
|
2383
|
+
} catch (err) {
|
|
2384
|
+
console.error("[agntcms] list_forms_failed:", err);
|
|
2385
|
+
return jsonResponse({ error: "list_forms_failed", message: "internal error" }, 500);
|
|
2386
|
+
}
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
// src/handlers/forms/read-handler.ts
|
|
2391
|
+
var FORM_NAME_PATTERN2 = /^[a-zA-Z0-9_-]+$/;
|
|
2392
|
+
var ID_PATTERN = /^[0-9A-HJKMNPQRSTVWXYZ]{16}$/;
|
|
2393
|
+
function createFormsReadHandler(deps) {
|
|
2394
|
+
return async function handle(req) {
|
|
2395
|
+
const url = new URL(req.url);
|
|
2396
|
+
const form = url.searchParams.get("form");
|
|
2397
|
+
const id = url.searchParams.get("id");
|
|
2398
|
+
if (!form) return jsonResponse({ error: "missing_form" }, 400);
|
|
2399
|
+
if (!FORM_NAME_PATTERN2.test(form)) {
|
|
2400
|
+
return jsonResponse({ error: "invalid_form" }, 400);
|
|
2401
|
+
}
|
|
2402
|
+
if (!deps.knownForms.has(form)) {
|
|
2403
|
+
return jsonResponse({ error: "unknown_form" }, 404);
|
|
2404
|
+
}
|
|
2405
|
+
if (id !== null) {
|
|
2406
|
+
if (!ID_PATTERN.test(id)) {
|
|
2407
|
+
return jsonResponse({ error: "invalid_id" }, 400);
|
|
2408
|
+
}
|
|
2409
|
+
try {
|
|
2410
|
+
const sub = await deps.submissionAdapter.read(form, id);
|
|
2411
|
+
if (!sub) return jsonResponse({ error: "not_found" }, 404);
|
|
2412
|
+
return jsonResponse({ submission: sub }, 200);
|
|
2413
|
+
} catch (err) {
|
|
2414
|
+
if (err instanceof SubmissionsNotReadableError) {
|
|
2415
|
+
return jsonResponse(
|
|
2416
|
+
{ error: "not_supported", message: err.message },
|
|
2417
|
+
501
|
|
2418
|
+
);
|
|
2419
|
+
}
|
|
2420
|
+
console.error("[agntcms] read_failed:", err);
|
|
2421
|
+
return jsonResponse({ error: "read_failed", message: "internal error" }, 500);
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
try {
|
|
2425
|
+
const summaries = await deps.submissionAdapter.list(form);
|
|
2426
|
+
return jsonResponse({ submissions: summaries }, 200);
|
|
2427
|
+
} catch (err) {
|
|
2428
|
+
if (err instanceof SubmissionsNotReadableError) {
|
|
2429
|
+
return jsonResponse(
|
|
2430
|
+
{ error: "not_supported", message: err.message },
|
|
2431
|
+
501
|
|
2432
|
+
);
|
|
2433
|
+
}
|
|
2434
|
+
console.error("[agntcms] list_failed:", err);
|
|
2435
|
+
return jsonResponse({ error: "list_failed", message: "internal error" }, 500);
|
|
2436
|
+
}
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// src/handlers/forms/delete-handler.ts
|
|
2441
|
+
function createFormsDeleteHandler() {
|
|
2442
|
+
return async function handle(_req) {
|
|
2443
|
+
return jsonResponse(
|
|
2444
|
+
{
|
|
2445
|
+
ok: false,
|
|
2446
|
+
error: "not_implemented",
|
|
2447
|
+
message: "Submission delete is reserved but not implemented in v1. See ARCHITECTURE.md \xA76.5 / \xA712."
|
|
2448
|
+
},
|
|
2449
|
+
501
|
|
2450
|
+
);
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// src/handlers.ts
|
|
2455
|
+
installDefaultAdapterFactories();
|
|
2456
|
+
export {
|
|
2457
|
+
AgentUnreachableError,
|
|
2458
|
+
createAgentBridge,
|
|
2459
|
+
createAssetsHandler,
|
|
2460
|
+
createDraftHandler,
|
|
2461
|
+
createEventsHandler,
|
|
2462
|
+
createFormsDeleteHandler,
|
|
2463
|
+
createFormsListHandler,
|
|
2464
|
+
createFormsReadHandler,
|
|
2465
|
+
createGlobalHandler,
|
|
2466
|
+
createMcpHandler,
|
|
2467
|
+
createPageHandler,
|
|
2468
|
+
createPreviewHandler,
|
|
2469
|
+
createPreviewTokenStore,
|
|
2470
|
+
createSubmitFormHandler,
|
|
2471
|
+
createTaskStore,
|
|
2472
|
+
createTemplateHandler
|
|
2473
|
+
};
|