@o-a/cms-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/README.md +132 -0
- package/dist/boot.d.ts +15 -0
- package/dist/boot.js +33 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +45 -0
- package/dist/create-site/cli.d.ts +2 -0
- package/dist/create-site/cli.js +31 -0
- package/dist/create-site/generate-site.d.ts +9 -0
- package/dist/create-site/generate-site.js +109 -0
- package/dist/create-site/mint-token-cli.d.ts +2 -0
- package/dist/create-site/mint-token-cli.js +26 -0
- package/dist/create-site/mint-token.d.ts +7 -0
- package/dist/create-site/mint-token.js +49 -0
- package/dist/create-site/template/content/menus/footerCompany.json +9 -0
- package/dist/create-site/template/content/menus/footerProduct.json +9 -0
- package/dist/create-site/template/content/menus/footerResources.json +9 -0
- package/dist/create-site/template/content/menus/main.json +9 -0
- package/dist/create-site/template/content/pages/404.json +22 -0
- package/dist/create-site/template/content/pages/about/careers.json +21 -0
- package/dist/create-site/template/content/pages/about/team.json +21 -0
- package/dist/create-site/template/content/pages/about.json +21 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +21 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +21 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +23 -0
- package/dist/create-site/template/content/pages/docs.json +21 -0
- package/dist/create-site/template/content/pages/index.json +181 -0
- package/dist/create-site/template/content/redirects.json +4 -0
- package/dist/create-site/template/dockerignore +6 -0
- package/dist/create-site/template/gitignore +11 -0
- package/dist/create-site/template/theme/assets/site.js +49 -0
- package/dist/create-site/template/theme/assets/style.css +1153 -0
- package/dist/create-site/template/theme/blocks/button.liquid +21 -0
- package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
- package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
- package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
- package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
- package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
- package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
- package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
- package/dist/create-site/template/theme/layouts/theme.liquid +99 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +27 -0
- package/dist/create-site/template/theme/sections/faq.liquid +27 -0
- package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
- package/dist/create-site/template/theme/sections/hero.liquid +36 -0
- package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
- package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
- package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
- package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
- package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
- package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
- package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +30 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/media/drivers/driver.d.ts +11 -0
- package/dist/media/drivers/driver.js +1 -0
- package/dist/media/drivers/local-fs-driver.d.ts +3 -0
- package/dist/media/drivers/local-fs-driver.js +81 -0
- package/dist/media/filename.d.ts +1 -0
- package/dist/media/filename.js +31 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +49 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-page.d.ts +28 -0
- package/dist/renderer/render-page.js +143 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -0
- package/dist/routes/assets.d.ts +6 -0
- package/dist/routes/assets.js +30 -0
- package/dist/routes/batch.d.ts +10 -0
- package/dist/routes/batch.js +148 -0
- package/dist/routes/capabilities.d.ts +5 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +166 -0
- package/dist/routes/drafts.d.ts +10 -0
- package/dist/routes/drafts.js +73 -0
- package/dist/routes/git.d.ts +8 -0
- package/dist/routes/git.js +158 -0
- package/dist/routes/index.d.ts +19 -0
- package/dist/routes/index.js +84 -0
- package/dist/routes/media-public.d.ts +5 -0
- package/dist/routes/media-public.js +48 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +84 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +77 -0
- package/dist/routes/preview-revision.d.ts +13 -0
- package/dist/routes/preview-revision.js +109 -0
- package/dist/routes/preview.d.ts +13 -0
- package/dist/routes/preview.js +52 -0
- package/dist/routes/public.d.ts +11 -0
- package/dist/routes/public.js +117 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +105 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +11 -0
- package/dist/routes/sitemap.d.ts +6 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +17 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +23 -0
- package/dist/schemas/page.schema.json +20 -0
- package/dist/schemas/post.schema.json +25 -0
- package/dist/schemas/redirects.schema.json +24 -0
- package/dist/search/drivers/driver.d.ts +9 -0
- package/dist/search/drivers/driver.js +1 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +3 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -0
- package/dist/search/query-index.d.ts +5 -0
- package/dist/search/query-index.js +21 -0
- package/dist/search/rebuild-index.d.ts +2 -0
- package/dist/search/rebuild-index.js +111 -0
- package/dist/server-config.d.ts +22 -0
- package/dist/server-config.js +196 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +163 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +136 -0
- package/dist/services/checkpoint.d.ts +4 -0
- package/dist/services/checkpoint.js +45 -0
- package/dist/services/content-read.d.ts +27 -0
- package/dist/services/content-read.js +156 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +174 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +15 -0
- package/dist/services/drafts.js +155 -0
- package/dist/services/etag.d.ts +2 -0
- package/dist/services/etag.js +30 -0
- package/dist/services/fs-walk.d.ts +1 -0
- package/dist/services/fs-walk.js +26 -0
- package/dist/services/git-commit.d.ts +3 -0
- package/dist/services/git-commit.js +12 -0
- package/dist/services/git-history.d.ts +28 -0
- package/dist/services/git-history.js +131 -0
- package/dist/services/git-revert.d.ts +12 -0
- package/dist/services/git-revert.js +140 -0
- package/dist/services/git.d.ts +16 -0
- package/dist/services/git.js +127 -0
- package/dist/services/interval-job.d.ts +4 -0
- package/dist/services/interval-job.js +9 -0
- package/dist/services/ip-allowlist.d.ts +7 -0
- package/dist/services/ip-allowlist.js +31 -0
- package/dist/services/manage-menus.d.ts +10 -0
- package/dist/services/manage-menus.js +84 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +166 -0
- package/dist/services/menus.d.ts +8 -0
- package/dist/services/menus.js +42 -0
- package/dist/services/migration-runner.d.ts +13 -0
- package/dist/services/migration-runner.js +123 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +36 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +175 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -0
- package/dist/services/post-urls.d.ts +3 -0
- package/dist/services/post-urls.js +27 -0
- package/dist/services/prepared-operation.d.ts +4 -0
- package/dist/services/prepared-operation.js +1 -0
- package/dist/services/publish.d.ts +14 -0
- package/dist/services/publish.js +254 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +20 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -0
- package/dist/services/resolve-blog-url.d.ts +11 -0
- package/dist/services/resolve-blog-url.js +31 -0
- package/dist/services/resolve-url.d.ts +11 -0
- package/dist/services/resolve-url.js +20 -0
- package/dist/services/startup-checks.d.ts +10 -0
- package/dist/services/startup-checks.js +81 -0
- package/dist/services/static-file.d.ts +7 -0
- package/dist/services/static-file.js +37 -0
- package/dist/services/theme-component-file.d.ts +5 -0
- package/dist/services/theme-component-file.js +32 -0
- package/dist/services/theme-page-templates.d.ts +7 -0
- package/dist/services/theme-page-templates.js +53 -0
- package/dist/services/theme-schemas.d.ts +2 -0
- package/dist/services/theme-schemas.js +63 -0
- package/dist/services/token-auth.d.ts +10 -0
- package/dist/services/token-auth.js +64 -0
- package/dist/services/urls.d.ts +2 -0
- package/dist/services/urls.js +20 -0
- package/dist/services/validation.d.ts +24 -0
- package/dist/services/validation.js +158 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- package/package.json +53 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { PageRenderError, renderPage } from "../renderer/render-page.js";
|
|
3
|
+
import { PathSafetyError } from "../services/path-safety.js";
|
|
4
|
+
import { isBlogUrl } from "../services/post-urls.js";
|
|
5
|
+
import { resolveBlogUrl } from "../services/resolve-blog-url.js";
|
|
6
|
+
import { resolveUrl } from "../services/resolve-url.js";
|
|
7
|
+
import { findStaticFile, sendStaticFile } from "../services/static-file.js";
|
|
8
|
+
// resolveUrl's relativePath is relative to pagesRoot (e.g. "about.json"),
|
|
9
|
+
// but renderPage's relativePath is relative to contentRoot/draftsRoot
|
|
10
|
+
// directly (e.g. "pages/about.json") - two already-correct modules with
|
|
11
|
+
// two different conventions for the same string. This is the seam
|
|
12
|
+
// between them, not an arbitrary literal.
|
|
13
|
+
function toRenderPath(pagesRelativePath) {
|
|
14
|
+
return join('pages', pagesRelativePath);
|
|
15
|
+
}
|
|
16
|
+
// Same seam as toRenderPath above, for posts: resolveBlogUrl's
|
|
17
|
+
// relativePath is relative to postsRoot, renderPage's is relative to
|
|
18
|
+
// contentRoot/draftsRoot directly.
|
|
19
|
+
function toPostsRenderPath(postsRelativePath) {
|
|
20
|
+
return join('posts', postsRelativePath);
|
|
21
|
+
}
|
|
22
|
+
// A themed 404: content/pages/404.json, if it exists and is published,
|
|
23
|
+
// is rendered through the ordinary public renderPage pipeline (layout +
|
|
24
|
+
// sections + blocks, same as any other page) - no special-casing in
|
|
25
|
+
// render-page.ts itself, no separate "is this the 404 page" concept.
|
|
26
|
+
// The HTTP status is always 404 regardless, even though the body is a
|
|
27
|
+
// full HTML page. Any failure at all while attempting this - no
|
|
28
|
+
// 404.json, it's unpublished, or its own theme is broken - falls back
|
|
29
|
+
// to today's plain JSON error rather than ever turning a 404 into a
|
|
30
|
+
// 500 for a visitor who was already looking at a missing page.
|
|
31
|
+
async function sendNotFound(reply, config, themeTemplates, layouts, engine, url) {
|
|
32
|
+
try {
|
|
33
|
+
const html = await renderPage(config, themeTemplates, layouts, engine, toRenderPath('404.json'), 'public');
|
|
34
|
+
reply.code(404).type('text/html; charset=utf-8').send(html);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function handlePublicRequest(request, reply, config, themeTemplates, layouts, engine) {
|
|
41
|
+
// The public catch-all is registered without a /v1 prefix alongside
|
|
42
|
+
// v1Routes (which has its own exact/prefixed routes). Fastify's
|
|
43
|
+
// router already prefers exact matches over this wildcard regardless
|
|
44
|
+
// of registration order, but an *unmatched* /v1/* path would
|
|
45
|
+
// otherwise fall through to here and get treated as a page lookup
|
|
46
|
+
// instead of Fastify's real 404 - this guard is what actually keeps
|
|
47
|
+
// A4 true for any future typo'd or removed /v1/* route, not
|
|
48
|
+
// registration order.
|
|
49
|
+
if (request.url === '/v1' || request.url.startsWith('/v1/')) {
|
|
50
|
+
return reply.callNotFound();
|
|
51
|
+
}
|
|
52
|
+
// theme/root/ mirrors arbitrary bare paths (robots.txt, .well-known/*)
|
|
53
|
+
// at the site root, checked before page resolution - unlike /assets/
|
|
54
|
+
// or /media/, these aren't distinguishable by a fixed prefix, so
|
|
55
|
+
// Fastify's own "specific route beats /*" behaviour can't do this for
|
|
56
|
+
// us automatically. A miss (including the bare "/" itself - a
|
|
57
|
+
// directory, never a file) falls straight through to the existing
|
|
58
|
+
// page-lookup logic below, unchanged.
|
|
59
|
+
const rootMirrorMatch = findStaticFile(config.rootMirrorRoot, request.params['*']);
|
|
60
|
+
if (rootMirrorMatch) {
|
|
61
|
+
sendStaticFile(reply, rootMirrorMatch);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const url = `/${request.params['*']}`;
|
|
65
|
+
try {
|
|
66
|
+
// /blog is a permanently reserved namespace (confirmed design
|
|
67
|
+
// decision): checked first, and never falls through to page
|
|
68
|
+
// resolution even on a miss - a page manually placed at
|
|
69
|
+
// content/pages/blog/x.json is deliberately unreachable.
|
|
70
|
+
if (isBlogUrl(url)) {
|
|
71
|
+
const resolved = resolveBlogUrl(config, url);
|
|
72
|
+
if (resolved.kind === 'not-found') {
|
|
73
|
+
await sendNotFound(reply, config, themeTemplates, layouts, engine, url);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (resolved.kind === 'redirect') {
|
|
77
|
+
reply.code(301).header('location', resolved.to).send();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const html = await renderPage(config, themeTemplates, layouts, engine, toPostsRenderPath(resolved.relativePath), 'public');
|
|
81
|
+
reply.type('text/html; charset=utf-8').send(html);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const resolved = resolveUrl(config, url);
|
|
85
|
+
if (resolved.kind === 'not-found') {
|
|
86
|
+
await sendNotFound(reply, config, themeTemplates, layouts, engine, url);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (resolved.kind === 'redirect') {
|
|
90
|
+
reply.code(301).header('location', resolved.to).send();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const html = await renderPage(config, themeTemplates, layouts, engine, toRenderPath(resolved.relativePath), 'public');
|
|
94
|
+
reply.type('text/html; charset=utf-8').send(html);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
// A traversal attempt and an ordinary miss get the identical 404 -
|
|
98
|
+
// deliberately not 400, so a public, unauthenticated route never
|
|
99
|
+
// gives an attacker a signal that distinguishes the two.
|
|
100
|
+
if (error instanceof PathSafetyError) {
|
|
101
|
+
await sendNotFound(reply, config, themeTemplates, layouts, engine, url);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (error instanceof PageRenderError && error.reason === 'page-not-found') {
|
|
105
|
+
await sendNotFound(reply, config, themeTemplates, layouts, engine, url);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// Any other PageRenderError reason (missing-section-type,
|
|
109
|
+
// missing-block-type, template-error) is a broken theme/content
|
|
110
|
+
// problem, not something to explain to a public visitor - rethrow
|
|
111
|
+
// and let the global error handler sanitise it as a 500 (A5).
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export const publicRoutes = async (fastify, opts) => {
|
|
116
|
+
fastify.get('/*', async (request, reply) => handlePublicRequest(request, reply, opts.config, opts.themeTemplates, opts.layouts, opts.engine));
|
|
117
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
import type { ThemeSchemas } from '../services/validation.ts';
|
|
4
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
5
|
+
export interface PublishRouteOptions {
|
|
6
|
+
config: SiteConfig;
|
|
7
|
+
themeSchemas: ThemeSchemas;
|
|
8
|
+
tokens: TokenEntry[];
|
|
9
|
+
}
|
|
10
|
+
export declare const publishRoutes: FastifyPluginAsync<PublishRouteOptions>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { isValidCommitAuthor } from "../services/git.js";
|
|
2
|
+
import { PathSafetyError } from "../services/path-safety.js";
|
|
3
|
+
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
4
|
+
import { PublishError, publishDrafts, unpublishPage } from "../services/publish.js";
|
|
5
|
+
import { requireScope } from "../services/token-auth.js";
|
|
6
|
+
function isNonEmptyString(value) {
|
|
7
|
+
return typeof value === 'string' && value.length > 0;
|
|
8
|
+
}
|
|
9
|
+
function parsePublishBody(body) {
|
|
10
|
+
if (typeof body !== 'object' || body === null) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const { paths, message, author } = body;
|
|
14
|
+
if (!Array.isArray(paths) || paths.length === 0 || !paths.every(isNonEmptyString)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return { paths, message, author };
|
|
21
|
+
}
|
|
22
|
+
function parseUnpublishBody(body) {
|
|
23
|
+
if (typeof body !== 'object' || body === null) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const { message, author } = body;
|
|
27
|
+
if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return { message, author };
|
|
31
|
+
}
|
|
32
|
+
// Shared by both handlers below: each only ever sees the subset of
|
|
33
|
+
// reasons its own service function can actually throw.
|
|
34
|
+
function replyForPublishError(reply, error) {
|
|
35
|
+
if (error.reason === 'validation-failed' || error.reason === 'duplicate-path') {
|
|
36
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (error.reason === 'draft-not-found' || error.reason === 'page-not-found') {
|
|
40
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
export const publishRoutes = async (fastify, opts) => {
|
|
46
|
+
fastify.post('/publish', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
|
|
47
|
+
const parsed = parsePublishBody(request.body);
|
|
48
|
+
if (!parsed) {
|
|
49
|
+
reply.code(400).send({
|
|
50
|
+
statusCode: 400,
|
|
51
|
+
error: 'Bad Request',
|
|
52
|
+
message: 'Expected { paths: string[], message: string, author: { name, email } }',
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await publishDrafts(opts.config, opts.themeSchemas, parsed.paths, parsed.message, parsed.author);
|
|
58
|
+
reply.send({ ok: true });
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
// PathSafetyError has no .statusCode - left uncaught it falls
|
|
62
|
+
// through to the global handler's sanitised 500, the same gap
|
|
63
|
+
// every other route touching a :path already guards against.
|
|
64
|
+
if (error instanceof PathSafetyError) {
|
|
65
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (error instanceof PublishError) {
|
|
69
|
+
replyForPublishError(reply, error);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
fastify.post('/unpublish/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
|
|
76
|
+
const parsed = parseUnpublishBody(request.body);
|
|
77
|
+
if (!parsed) {
|
|
78
|
+
reply.code(400).send({
|
|
79
|
+
statusCode: 400,
|
|
80
|
+
error: 'Bad Request',
|
|
81
|
+
message: 'Expected { message: string, author: { name, email } }',
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const relativePath = request.params['*'];
|
|
86
|
+
try {
|
|
87
|
+
await unpublishPage(opts.config, relativePath, parsed.message, parsed.author);
|
|
88
|
+
reply.send({ ok: true });
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
// PathSafetyError has no .statusCode - left uncaught it falls
|
|
92
|
+
// through to the global handler's sanitised 500, the same gap
|
|
93
|
+
// every other route touching a :path already guards against.
|
|
94
|
+
if (error instanceof PathSafetyError) {
|
|
95
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (error instanceof PublishError) {
|
|
99
|
+
replyForPublishError(reply, error);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
4
|
+
export interface RedirectsRouteOptions {
|
|
5
|
+
config: SiteConfig;
|
|
6
|
+
tokens: TokenEntry[];
|
|
7
|
+
}
|
|
8
|
+
export declare const redirectsRoutes: FastifyPluginAsync<RedirectsRouteOptions>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { isValidCommitAuthor } from "../services/git.js";
|
|
2
|
+
import { ManageRedirectError, createRedirect, deleteRedirect, updateRedirect } from "../services/manage-redirects.js";
|
|
3
|
+
import { loadRedirects } from "../services/redirects.js";
|
|
4
|
+
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
5
|
+
import { requireScope } from "../services/token-auth.js";
|
|
6
|
+
function isNonEmptyString(value) {
|
|
7
|
+
return typeof value === 'string' && value.length > 0;
|
|
8
|
+
}
|
|
9
|
+
function parseUpsertRedirectBody(body) {
|
|
10
|
+
if (typeof body !== 'object' || body === null) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const { from, to, note, message, author } = body;
|
|
14
|
+
if (!isNonEmptyString(from) || !isNonEmptyString(to)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (note !== undefined && typeof note !== 'string') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return { from, to, note, message, author };
|
|
24
|
+
}
|
|
25
|
+
function parseDeleteRedirectBody(body) {
|
|
26
|
+
if (typeof body !== 'object' || body === null) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const { from, message, author } = body;
|
|
30
|
+
if (!isNonEmptyString(from) || !isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return { from, message, author };
|
|
34
|
+
}
|
|
35
|
+
function sendManageRedirectError(reply, error) {
|
|
36
|
+
if (error.reason === 'invalid-from' || error.reason === 'invalid-target' || error.reason === 'redirect-cycle') {
|
|
37
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (error.reason === 'not-found') {
|
|
41
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (error.reason === 'already-exists' || error.reason === 'live-content-exists') {
|
|
45
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
async function handleListRedirects(_request, reply, config) {
|
|
51
|
+
reply.send(loadRedirects(config));
|
|
52
|
+
}
|
|
53
|
+
async function handleCreateRedirect(request, reply, config) {
|
|
54
|
+
const parsed = parseUpsertRedirectBody(request.body);
|
|
55
|
+
if (!parsed) {
|
|
56
|
+
reply.code(400).send({
|
|
57
|
+
statusCode: 400,
|
|
58
|
+
error: 'Bad Request',
|
|
59
|
+
message: 'Expected { from: string, to: string, note?: string, message: string, author: { name, email } }',
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const result = await createRedirect(config, parsed.from, parsed.to, parsed.note, parsed.message, parsed.author);
|
|
65
|
+
reply.send(result);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof ManageRedirectError) {
|
|
69
|
+
sendManageRedirectError(reply, error);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function handleUpdateRedirect(request, reply, config) {
|
|
76
|
+
const parsed = parseUpsertRedirectBody(request.body);
|
|
77
|
+
if (!parsed) {
|
|
78
|
+
reply.code(400).send({
|
|
79
|
+
statusCode: 400,
|
|
80
|
+
error: 'Bad Request',
|
|
81
|
+
message: 'Expected { from: string, to: string, note?: string, message: string, author: { name, email } }',
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const result = await updateRedirect(config, parsed.from, parsed.to, parsed.note, parsed.message, parsed.author);
|
|
87
|
+
reply.send(result);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof ManageRedirectError) {
|
|
91
|
+
sendManageRedirectError(reply, error);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function handleDeleteRedirect(request, reply, config) {
|
|
98
|
+
const parsed = parseDeleteRedirectBody(request.body);
|
|
99
|
+
if (!parsed) {
|
|
100
|
+
reply.code(400).send({
|
|
101
|
+
statusCode: 400,
|
|
102
|
+
error: 'Bad Request',
|
|
103
|
+
message: 'Expected { from: string, message: string, author: { name, email } }',
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await deleteRedirect(config, parsed.from, parsed.message, parsed.author);
|
|
109
|
+
reply.code(204).send();
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error instanceof ManageRedirectError) {
|
|
113
|
+
sendManageRedirectError(reply, error);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// from/to are always taken from the JSON body, never a URL path
|
|
120
|
+
// segment: they are arbitrary public URLs that may contain slashes,
|
|
121
|
+
// and critically none of these operations address a filesystem path
|
|
122
|
+
// at all (this mutates one entry inside redirects.json, not a file at
|
|
123
|
+
// a client-supplied path) - sanitisePath/path-traversal simply doesn't
|
|
124
|
+
// apply here, matching POST /content/move's existing body-carries-the-
|
|
125
|
+
// identity precedent rather than forcing an arbitrary URL into a route
|
|
126
|
+
// segment.
|
|
127
|
+
export const redirectsRoutes = async (fastify, opts) => {
|
|
128
|
+
fastify.get('/redirects', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleListRedirects(request, reply, opts.config));
|
|
129
|
+
fastify.post('/redirects', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleCreateRedirect(request, reply, opts.config));
|
|
130
|
+
fastify.put('/redirects', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleUpdateRedirect(request, reply, opts.config));
|
|
131
|
+
fastify.delete('/redirects', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleDeleteRedirect(request, reply, opts.config));
|
|
132
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
4
|
+
export interface SearchRouteOptions {
|
|
5
|
+
config: SiteConfig;
|
|
6
|
+
tokens: TokenEntry[];
|
|
7
|
+
}
|
|
8
|
+
export declare const searchRoutes: FastifyPluginAsync<SearchRouteOptions>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { rebuildIndex } from "../search/rebuild-index.js";
|
|
2
|
+
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
3
|
+
import { requireScope } from "../services/token-auth.js";
|
|
4
|
+
export const searchRoutes = async (fastify, opts) => {
|
|
5
|
+
fastify.post('/search/rebuild', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (_request, reply) => {
|
|
6
|
+
// rebuildIndex is already self-enqueue()d (search/rebuild-index.ts) -
|
|
7
|
+
// never wrap it in a second enqueue() here, nested calls deadlock.
|
|
8
|
+
await rebuildIndex(opts.config);
|
|
9
|
+
reply.send({ ok: true });
|
|
10
|
+
});
|
|
11
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readContentFile } from "../services/content-read.js";
|
|
3
|
+
import { listFilesRecursively } from "../services/fs-walk.js";
|
|
4
|
+
import { postPathToUrl } from "../services/post-urls.js";
|
|
5
|
+
import { pagePathToUrl } from "../services/urls.js";
|
|
6
|
+
// Reads through readContentFile/listFilesRecursively (both already
|
|
7
|
+
// gated behind sanitisePath/agent-configured roots - see their own
|
|
8
|
+
// files) rather than touching fs directly, so this route needs no
|
|
9
|
+
// allowlist entry of its own (docs/phase-1-checklist.md Group B).
|
|
10
|
+
function isPublished(contentRoot, subdir, relativePath) {
|
|
11
|
+
try {
|
|
12
|
+
const { bytes } = readContentFile(contentRoot, join(subdir, relativePath));
|
|
13
|
+
const parsed = JSON.parse(bytes.toString('utf-8'));
|
|
14
|
+
return parsed.published === true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Unreadable, malformed, or vanished between listing and reading -
|
|
18
|
+
// never worth failing the whole sitemap over one bad entry.
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Generated fresh on every request, never a saved file - matches this
|
|
23
|
+
// project's own standing philosophy for exactly this class of problem
|
|
24
|
+
// (the search index is explicitly "a derived, disposable index...
|
|
25
|
+
// never authoritative", see cms-build-plan.md). A saved sitemap would
|
|
26
|
+
// go stale the moment anything is published or unpublished; this
|
|
27
|
+
// can't.
|
|
28
|
+
function buildSitemapUrls(config) {
|
|
29
|
+
const urls = [];
|
|
30
|
+
for (const relativePath of listFilesRecursively(config.pagesRoot, config.pagesRoot, '.json')) {
|
|
31
|
+
// The 404 page must never be listed as a real crawlable URL,
|
|
32
|
+
// regardless of its own published flag - it's a fallback
|
|
33
|
+
// convention (docs/content-authoring-guide.md), not real content.
|
|
34
|
+
if (relativePath === '404.json') {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (isPublished(config.contentRoot, 'pages', relativePath)) {
|
|
38
|
+
urls.push(pagePathToUrl(relativePath));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const relativePath of listFilesRecursively(config.postsRoot, config.postsRoot, '.json')) {
|
|
42
|
+
if (isPublished(config.contentRoot, 'posts', relativePath)) {
|
|
43
|
+
urls.push(postPathToUrl(relativePath));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return urls;
|
|
47
|
+
}
|
|
48
|
+
function escapeXml(value) {
|
|
49
|
+
return value
|
|
50
|
+
.replace(/&/g, '&')
|
|
51
|
+
.replace(/</g, '<')
|
|
52
|
+
.replace(/>/g, '>')
|
|
53
|
+
.replace(/"/g, '"')
|
|
54
|
+
.replace(/'/g, ''');
|
|
55
|
+
}
|
|
56
|
+
async function handleSitemapRequest(request, reply, config) {
|
|
57
|
+
// request.protocol/request.host already respect trustProxy (wired
|
|
58
|
+
// natively into the Fastify() constructor, server.ts) - no separate
|
|
59
|
+
// config field needed for this. request.host, not request.hostname:
|
|
60
|
+
// the latter unconditionally strips the port (confirmed against
|
|
61
|
+
// Fastify's own source), which silently produces a wrong origin for
|
|
62
|
+
// any site not on a standard 80/443 port - request.host is the raw
|
|
63
|
+
// Host header (or the trustProxy-forwarded equivalent), port
|
|
64
|
+
// included when present.
|
|
65
|
+
const origin = `${request.protocol}://${request.host}`;
|
|
66
|
+
const urls = buildSitemapUrls(config);
|
|
67
|
+
const entries = urls.map((url) => ` <url><loc>${escapeXml(origin + url)}</loc></url>`).join('\n');
|
|
68
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries}\n</urlset>\n`;
|
|
69
|
+
reply.type('application/xml; charset=utf-8').send(xml);
|
|
70
|
+
}
|
|
71
|
+
// Deliberately and permanently unauthenticated, same reasoning as
|
|
72
|
+
// assetsRoutes/mediaPublicRoutes: a sitemap must be fetchable by any
|
|
73
|
+
// crawler without a token. This exact path always wins over a
|
|
74
|
+
// same-named static file at theme/root/sitemap.xml, if a developer
|
|
75
|
+
// ever creates one - a dynamic route is a more specific match than
|
|
76
|
+
// the root-mirror check inside publicRoutes' own catch-all.
|
|
77
|
+
export const sitemapRoutes = async (fastify, opts) => {
|
|
78
|
+
fastify.get('/sitemap.xml', async (request, reply) => handleSitemapRequest(request, reply, opts.config));
|
|
79
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { ThemeSchemas } from '../services/validation.ts';
|
|
3
|
+
import type { PageTemplate } from '../services/theme-page-templates.ts';
|
|
4
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
5
|
+
export interface ThemeRouteOptions {
|
|
6
|
+
themeSchemas: ThemeSchemas;
|
|
7
|
+
pageTemplates: PageTemplate[];
|
|
8
|
+
tokens: TokenEntry[];
|
|
9
|
+
}
|
|
10
|
+
export declare const themeRoutes: FastifyPluginAsync<ThemeRouteOptions>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { requireScope } from "../services/token-auth.js";
|
|
2
|
+
// Group I: the only place a caller can learn what section/block types
|
|
3
|
+
// the active theme supports and what settings each one accepts - the
|
|
4
|
+
// admin needs this to build a schema-driven editing UI instead of a
|
|
5
|
+
// hardcoded one. themeSchemas is already fully computed at boot
|
|
6
|
+
// (loadThemeSchemas) purely for internal write-time validation; this
|
|
7
|
+
// route just serialises the same object out, verbatim, read-only.
|
|
8
|
+
export const themeRoutes = async (fastify, opts) => {
|
|
9
|
+
fastify.get('/theme/schemas', { preHandler: requireScope(opts.tokens, 'content') }, async () => opts.themeSchemas);
|
|
10
|
+
// Group Q: lets the admin offer a template picker when creating a new
|
|
11
|
+
// page. No dedicated "create from template" endpoint - pageTemplates
|
|
12
|
+
// is already fully computed at boot (loadPageTemplates), same
|
|
13
|
+
// verbatim read-only serialisation as /theme/schemas above; actually
|
|
14
|
+
// creating a page from one is just the admin doing a normal
|
|
15
|
+
// PUT /v1/drafts/* with the chosen template's own content as the body.
|
|
16
|
+
fastify.get('/theme/page-templates', { preHandler: requireScope(opts.tokens, 'content') }, async () => ({ templates: opts.pageTemplates }));
|
|
17
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "instance.schema.json",
|
|
3
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
|
+
"title": "Section or block instance",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["id", "type", "settings"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"id": { "type": "string", "minLength": 1 },
|
|
10
|
+
"type": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9][a-z0-9-]*$" },
|
|
11
|
+
"settings": { "type": "object" },
|
|
12
|
+
"blocks": {
|
|
13
|
+
"type": "array",
|
|
14
|
+
"items": { "$ref": "#" }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "menu.schema.json",
|
|
3
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
|
+
"title": "Menu",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schemaVersion", "items"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schemaVersion": { "type": "integer", "minimum": 1 },
|
|
10
|
+
"items": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"items": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"required": ["label", "url"],
|
|
16
|
+
"properties": {
|
|
17
|
+
"label": { "type": "string", "minLength": 1 },
|
|
18
|
+
"url": { "type": "string", "minLength": 1 }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "page.schema.json",
|
|
3
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
|
+
"title": "Page",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schemaVersion", "name", "title", "type", "layout", "published", "sections"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schemaVersion": { "type": "integer", "minimum": 1 },
|
|
10
|
+
"name": { "type": "string", "minLength": 1 },
|
|
11
|
+
"title": { "type": "string", "minLength": 1 },
|
|
12
|
+
"type": { "type": "string", "minLength": 1 },
|
|
13
|
+
"layout": { "type": "string", "minLength": 1 },
|
|
14
|
+
"published": { "type": "boolean" },
|
|
15
|
+
"sections": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"items": { "$ref": "instance.schema.json" }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "post.schema.json",
|
|
3
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
|
+
"title": "Post",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schemaVersion", "title", "type", "layout", "published", "author", "publishDate", "tags", "sections"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schemaVersion": { "type": "integer", "minimum": 1 },
|
|
10
|
+
"title": { "type": "string", "minLength": 1 },
|
|
11
|
+
"type": { "const": "post" },
|
|
12
|
+
"layout": { "type": "string", "minLength": 1 },
|
|
13
|
+
"published": { "type": "boolean" },
|
|
14
|
+
"author": { "type": "string", "minLength": 1 },
|
|
15
|
+
"publishDate": { "type": "string", "minLength": 1 },
|
|
16
|
+
"tags": {
|
|
17
|
+
"type": "array",
|
|
18
|
+
"items": { "type": "string", "minLength": 1 }
|
|
19
|
+
},
|
|
20
|
+
"sections": {
|
|
21
|
+
"type": "array",
|
|
22
|
+
"items": { "$ref": "instance.schema.json" }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "redirects.schema.json",
|
|
3
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
|
+
"title": "Redirects",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schemaVersion", "entries"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schemaVersion": { "type": "integer", "minimum": 1 },
|
|
10
|
+
"entries": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"items": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"required": ["from", "to"],
|
|
16
|
+
"properties": {
|
|
17
|
+
"from": { "type": "string", "minLength": 1 },
|
|
18
|
+
"to": { "type": "string", "minLength": 1 },
|
|
19
|
+
"note": { "type": "string", "maxLength": 500 }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|