@ampless/backend 0.2.0-alpha.9 → 1.0.0-alpha.19
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/auth/post-confirmation.js +2 -0
- package/dist/auth/user-admin.js +2 -0
- package/dist/chunk-BYXBJQAS.js +0 -0
- package/dist/events/dispatcher.js +4 -5
- package/dist/events/processor-trusted.d.ts +5 -5
- package/dist/events/processor-trusted.js +17 -22
- package/dist/events/processor-untrusted.js +4 -4
- package/dist/functions/api-key-renewer.js +2 -0
- package/dist/functions/mcp-handler.d.ts +16 -8
- package/dist/functions/mcp-handler.js +1133 -11
- package/dist/index.d.ts +47 -1
- package/dist/index.js +52 -32
- package/package.json +8 -2
|
@@ -1,15 +1,1040 @@
|
|
|
1
|
+
import "../chunk-BYXBJQAS.js";
|
|
2
|
+
|
|
1
3
|
// src/functions/mcp-handler.ts
|
|
2
4
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
3
5
|
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
|
|
4
6
|
import { createHash } from "crypto";
|
|
7
|
+
import { decodeAwsJson as decodeAwsJson2 } from "ampless";
|
|
8
|
+
|
|
9
|
+
// ../mcp-server/dist/chunk-DAR6AHSX.js
|
|
10
|
+
import {
|
|
11
|
+
decodeAwsJson
|
|
12
|
+
} from "ampless";
|
|
13
|
+
import {
|
|
14
|
+
encodeAwsJson
|
|
15
|
+
} from "ampless";
|
|
16
|
+
import {
|
|
17
|
+
encodeAwsJson as encodeAwsJson2
|
|
18
|
+
} from "ampless";
|
|
19
|
+
import {
|
|
20
|
+
bundlePrefix,
|
|
21
|
+
mimeTypeFor,
|
|
22
|
+
pickDefaultEntrypoint,
|
|
23
|
+
validateBundle
|
|
24
|
+
} from "ampless";
|
|
25
|
+
import { unzipSync, strFromU8 } from "fflate";
|
|
26
|
+
import {
|
|
27
|
+
validateBundlePath,
|
|
28
|
+
stripCommonPrefix
|
|
29
|
+
} from "ampless";
|
|
30
|
+
import {
|
|
31
|
+
encodeAwsJson as encodeAwsJson3
|
|
32
|
+
} from "ampless";
|
|
33
|
+
import {
|
|
34
|
+
bundlePrefix as bundlePrefix2,
|
|
35
|
+
findAbsolutePathRefs,
|
|
36
|
+
mimeTypeFor as mimeTypeFor2,
|
|
37
|
+
validateBundlePath as validateBundlePath2,
|
|
38
|
+
TEXT_EXTENSIONS
|
|
39
|
+
} from "ampless";
|
|
40
|
+
import { bundlePrefix as bundlePrefix3, validateBundlePath as validateBundlePath3 } from "ampless";
|
|
41
|
+
import {
|
|
42
|
+
bundlePrefix as bundlePrefix4,
|
|
43
|
+
pickDefaultEntrypoint as pickDefaultEntrypoint2
|
|
44
|
+
} from "ampless";
|
|
45
|
+
var POST_FIELDS = (
|
|
46
|
+
/* GraphQL */
|
|
47
|
+
`
|
|
48
|
+
fragment PostFields on Post {
|
|
49
|
+
postId
|
|
50
|
+
slug
|
|
51
|
+
title
|
|
52
|
+
excerpt
|
|
53
|
+
format
|
|
54
|
+
body
|
|
55
|
+
status
|
|
56
|
+
publishedAt
|
|
57
|
+
tags
|
|
58
|
+
metadata
|
|
59
|
+
}
|
|
60
|
+
`
|
|
61
|
+
);
|
|
62
|
+
function toCorePost(p) {
|
|
63
|
+
const metadata = decodeAwsJson(p.metadata);
|
|
64
|
+
return {
|
|
65
|
+
postId: p.postId,
|
|
66
|
+
slug: p.slug,
|
|
67
|
+
title: p.title,
|
|
68
|
+
excerpt: p.excerpt ?? void 0,
|
|
69
|
+
format: p.format ?? "markdown",
|
|
70
|
+
body: decodeAwsJson(p.body),
|
|
71
|
+
status: p.status ?? "draft",
|
|
72
|
+
publishedAt: p.publishedAt ?? void 0,
|
|
73
|
+
tags: (p.tags ?? []).filter((t) => typeof t === "string"),
|
|
74
|
+
metadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : void 0
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
var QUERY = (
|
|
78
|
+
/* GraphQL */
|
|
79
|
+
`
|
|
80
|
+
${POST_FIELDS}
|
|
81
|
+
query ListPosts($filter: ModelPostFilterInput, $limit: Int, $nextToken: String) {
|
|
82
|
+
listPosts(filter: $filter, limit: $limit, nextToken: $nextToken) {
|
|
83
|
+
items {
|
|
84
|
+
...PostFields
|
|
85
|
+
}
|
|
86
|
+
nextToken
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
`
|
|
90
|
+
);
|
|
91
|
+
var listPostsSchema = {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
status: {
|
|
95
|
+
type: "string",
|
|
96
|
+
enum: ["draft", "published", "all"],
|
|
97
|
+
description: 'Filter by status (default "all")'
|
|
98
|
+
},
|
|
99
|
+
limit: { type: "integer", minimum: 1, maximum: 100, description: "Max results (default 20)" },
|
|
100
|
+
nextToken: { type: "string", description: "Pagination cursor from a previous call" }
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
async function listPosts(client, args = {}) {
|
|
104
|
+
const status = args.status ?? "all";
|
|
105
|
+
const filter = {};
|
|
106
|
+
if (status !== "all") filter.status = { eq: status };
|
|
107
|
+
const hasFilter = Object.keys(filter).length > 0;
|
|
108
|
+
const data = await client.query(QUERY, {
|
|
109
|
+
filter: hasFilter ? filter : void 0,
|
|
110
|
+
limit: args.limit ?? 20,
|
|
111
|
+
nextToken: args.nextToken
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
posts: data.listPosts.items.map(toCorePost),
|
|
115
|
+
nextToken: data.listPosts.nextToken
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
var GET_BY_ID = (
|
|
119
|
+
/* GraphQL */
|
|
120
|
+
`
|
|
121
|
+
${POST_FIELDS}
|
|
122
|
+
query GetPost($postId: ID!) {
|
|
123
|
+
getPost(postId: $postId) {
|
|
124
|
+
...PostFields
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
`
|
|
128
|
+
);
|
|
129
|
+
var LIST_BY_SLUG = (
|
|
130
|
+
/* GraphQL */
|
|
131
|
+
`
|
|
132
|
+
${POST_FIELDS}
|
|
133
|
+
query GetPostBySlug($filter: ModelPostFilterInput!) {
|
|
134
|
+
listPosts(filter: $filter, limit: 1) {
|
|
135
|
+
items {
|
|
136
|
+
...PostFields
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
`
|
|
141
|
+
);
|
|
142
|
+
var getPostSchema = {
|
|
143
|
+
type: "object",
|
|
144
|
+
properties: {
|
|
145
|
+
slug: { type: "string", description: "Post slug" },
|
|
146
|
+
postId: { type: "string", description: "Post id (used when slug is omitted)" }
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
async function getPost(client, args) {
|
|
150
|
+
if (!args.slug && !args.postId) {
|
|
151
|
+
throw new Error("get_post requires either `slug` or `postId`");
|
|
152
|
+
}
|
|
153
|
+
if (args.postId) {
|
|
154
|
+
const data2 = await client.query(GET_BY_ID, { postId: args.postId });
|
|
155
|
+
return data2.getPost ? toCorePost(data2.getPost) : null;
|
|
156
|
+
}
|
|
157
|
+
const data = await client.query(LIST_BY_SLUG, {
|
|
158
|
+
filter: { slug: { eq: args.slug } }
|
|
159
|
+
});
|
|
160
|
+
const item = data.listPosts.items[0];
|
|
161
|
+
return item ? toCorePost(item) : null;
|
|
162
|
+
}
|
|
163
|
+
function entries(post) {
|
|
164
|
+
if (post.status !== "published" || !post.publishedAt || !post.tags?.length) return [];
|
|
165
|
+
return post.tags.map((tag) => ({
|
|
166
|
+
tag,
|
|
167
|
+
publishedAtPostId: `${post.publishedAt}#${post.postId}`
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
function entryKey(e) {
|
|
171
|
+
return `${e.tag}|${e.publishedAtPostId}`;
|
|
172
|
+
}
|
|
173
|
+
var CREATE_POST_TAG = (
|
|
174
|
+
/* GraphQL */
|
|
175
|
+
`
|
|
176
|
+
mutation CreatePostTag($input: CreatePostTagInput!) {
|
|
177
|
+
createPostTag(input: $input) {
|
|
178
|
+
tag
|
|
179
|
+
publishedAtPostId
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
`
|
|
183
|
+
);
|
|
184
|
+
var UPDATE_POST_TAG = (
|
|
185
|
+
/* GraphQL */
|
|
186
|
+
`
|
|
187
|
+
mutation UpdatePostTag($input: UpdatePostTagInput!) {
|
|
188
|
+
updatePostTag(input: $input) {
|
|
189
|
+
tag
|
|
190
|
+
publishedAtPostId
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
`
|
|
194
|
+
);
|
|
195
|
+
var DELETE_POST_TAG = (
|
|
196
|
+
/* GraphQL */
|
|
197
|
+
`
|
|
198
|
+
mutation DeletePostTag($input: DeletePostTagInput!) {
|
|
199
|
+
deletePostTag(input: $input) {
|
|
200
|
+
tag
|
|
201
|
+
publishedAtPostId
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
`
|
|
205
|
+
);
|
|
206
|
+
async function syncPostTags(client, post, oldPost) {
|
|
207
|
+
const oldEntries = oldPost ? entries(oldPost) : [];
|
|
208
|
+
const newEntries = entries(post);
|
|
209
|
+
const oldKeys = new Set(oldEntries.map(entryKey));
|
|
210
|
+
const newKeys = new Set(newEntries.map(entryKey));
|
|
211
|
+
await Promise.all(
|
|
212
|
+
oldEntries.filter((e) => !newKeys.has(entryKey(e))).map(
|
|
213
|
+
(e) => client.query(DELETE_POST_TAG, {
|
|
214
|
+
input: { tag: e.tag, publishedAtPostId: e.publishedAtPostId }
|
|
215
|
+
})
|
|
216
|
+
)
|
|
217
|
+
);
|
|
218
|
+
await Promise.all(
|
|
219
|
+
newEntries.filter((e) => !oldKeys.has(entryKey(e))).map(
|
|
220
|
+
(e) => client.query(CREATE_POST_TAG, {
|
|
221
|
+
input: {
|
|
222
|
+
tag: e.tag,
|
|
223
|
+
publishedAtPostId: e.publishedAtPostId,
|
|
224
|
+
postId: post.postId,
|
|
225
|
+
publishedAt: post.publishedAt,
|
|
226
|
+
slug: post.slug,
|
|
227
|
+
title: post.title,
|
|
228
|
+
excerpt: post.excerpt,
|
|
229
|
+
tags: post.tags ?? []
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
)
|
|
233
|
+
);
|
|
234
|
+
await Promise.all(
|
|
235
|
+
newEntries.filter((e) => oldKeys.has(entryKey(e))).map(
|
|
236
|
+
(e) => client.query(UPDATE_POST_TAG, {
|
|
237
|
+
input: {
|
|
238
|
+
tag: e.tag,
|
|
239
|
+
publishedAtPostId: e.publishedAtPostId,
|
|
240
|
+
slug: post.slug,
|
|
241
|
+
title: post.title,
|
|
242
|
+
excerpt: post.excerpt,
|
|
243
|
+
tags: post.tags ?? []
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
)
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
var MUTATION = (
|
|
250
|
+
/* GraphQL */
|
|
251
|
+
`
|
|
252
|
+
${POST_FIELDS}
|
|
253
|
+
mutation CreatePost($input: CreatePostInput!) {
|
|
254
|
+
createPost(input: $input) {
|
|
255
|
+
...PostFields
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
`
|
|
259
|
+
);
|
|
260
|
+
var createPostSchema = {
|
|
261
|
+
type: "object",
|
|
262
|
+
required: ["slug", "title", "format", "body"],
|
|
263
|
+
properties: {
|
|
264
|
+
postId: { type: "string", description: "Optional explicit id; auto-generated if omitted" },
|
|
265
|
+
slug: { type: "string" },
|
|
266
|
+
title: { type: "string" },
|
|
267
|
+
format: {
|
|
268
|
+
type: "string",
|
|
269
|
+
enum: ["tiptap", "markdown", "html"],
|
|
270
|
+
description: "tiptap = rich text JSON tree; markdown = source string (GFM extensions enabled); html = raw HTML string (rendered verbatim, no sanitization)."
|
|
271
|
+
},
|
|
272
|
+
body: {
|
|
273
|
+
description: "tiptap JSON (when format=tiptap), markdown source string (format=markdown), or raw HTML string (format=html)."
|
|
274
|
+
},
|
|
275
|
+
status: { type: "string", enum: ["draft", "published"], default: "draft" },
|
|
276
|
+
excerpt: { type: "string" },
|
|
277
|
+
publishedAt: { type: "string", description: "ISO 8601 timestamp; required when status=published" },
|
|
278
|
+
tags: { type: "array", items: { type: "string" } },
|
|
279
|
+
metadata: {
|
|
280
|
+
type: "object",
|
|
281
|
+
description: "Free-form per-post metadata. Reserved well-known keys: `no_layout` (boolean) \u2014 when true, the public page is served as bare HTML with no theme chrome (the route redirects to /_/<slug>); meaningful only with format=html. Other keys pass through unchanged for themes/plugins.",
|
|
282
|
+
properties: {
|
|
283
|
+
no_layout: {
|
|
284
|
+
type: "boolean",
|
|
285
|
+
description: "Serve the post as bare HTML with no theme chrome. Only meaningful when format=html; ignored otherwise."
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
additionalProperties: true
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
async function createPost(client, args) {
|
|
293
|
+
const postId = args.postId ?? `post-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
294
|
+
const status = args.status ?? "draft";
|
|
295
|
+
const publishedAt = args.publishedAt ?? (status === "published" ? (/* @__PURE__ */ new Date()).toISOString() : void 0);
|
|
296
|
+
const data = await client.query(MUTATION, {
|
|
297
|
+
input: {
|
|
298
|
+
postId,
|
|
299
|
+
slug: args.slug,
|
|
300
|
+
title: args.title,
|
|
301
|
+
excerpt: args.excerpt,
|
|
302
|
+
format: args.format,
|
|
303
|
+
body: encodeAwsJson(args.body),
|
|
304
|
+
status,
|
|
305
|
+
publishedAt,
|
|
306
|
+
tags: args.tags,
|
|
307
|
+
metadata: args.metadata !== void 0 ? encodeAwsJson(args.metadata) : void 0
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
const created = toCorePost(data.createPost);
|
|
311
|
+
await syncPostTags(client, created, null);
|
|
312
|
+
return created;
|
|
313
|
+
}
|
|
314
|
+
var MUTATION2 = (
|
|
315
|
+
/* GraphQL */
|
|
316
|
+
`
|
|
317
|
+
${POST_FIELDS}
|
|
318
|
+
mutation UpdatePost($input: UpdatePostInput!) {
|
|
319
|
+
updatePost(input: $input) {
|
|
320
|
+
...PostFields
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
`
|
|
324
|
+
);
|
|
325
|
+
var updatePostSchema = {
|
|
326
|
+
type: "object",
|
|
327
|
+
required: ["postId"],
|
|
328
|
+
properties: {
|
|
329
|
+
postId: { type: "string" },
|
|
330
|
+
slug: { type: "string" },
|
|
331
|
+
title: { type: "string" },
|
|
332
|
+
excerpt: { type: "string" },
|
|
333
|
+
format: {
|
|
334
|
+
type: "string",
|
|
335
|
+
enum: ["tiptap", "markdown", "html"],
|
|
336
|
+
description: "tiptap = rich text JSON tree; markdown = source string; html = raw HTML string (no sanitization)."
|
|
337
|
+
},
|
|
338
|
+
body: { description: "tiptap JSON, markdown source, or raw HTML string" },
|
|
339
|
+
status: { type: "string", enum: ["draft", "published"] },
|
|
340
|
+
publishedAt: { type: "string", description: "ISO 8601 timestamp" },
|
|
341
|
+
tags: { type: "array", items: { type: "string" } },
|
|
342
|
+
metadata: {
|
|
343
|
+
type: "object",
|
|
344
|
+
description: "Free-form per-post metadata. Reserved well-known keys: `no_layout` (boolean) \u2014 when true, the public page is served as bare HTML with no theme chrome (the route redirects to /_/<slug>); meaningful only with format=html. Passing metadata replaces the existing metadata object \u2014 read the current post first if you only want to add a key.",
|
|
345
|
+
properties: {
|
|
346
|
+
no_layout: {
|
|
347
|
+
type: "boolean",
|
|
348
|
+
description: "Serve the post as bare HTML with no theme chrome. Only meaningful when format=html; ignored otherwise."
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
additionalProperties: true
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
async function updatePost(client, args) {
|
|
356
|
+
const oldPost = await getPost(client, { postId: args.postId });
|
|
357
|
+
const input = { postId: args.postId };
|
|
358
|
+
if (args.slug !== void 0) input.slug = args.slug;
|
|
359
|
+
if (args.title !== void 0) input.title = args.title;
|
|
360
|
+
if (args.excerpt !== void 0) input.excerpt = args.excerpt;
|
|
361
|
+
if (args.format !== void 0) input.format = args.format;
|
|
362
|
+
if (args.body !== void 0) input.body = encodeAwsJson2(args.body);
|
|
363
|
+
if (args.status !== void 0) input.status = args.status;
|
|
364
|
+
if (args.publishedAt !== void 0) input.publishedAt = args.publishedAt;
|
|
365
|
+
if (args.tags !== void 0) input.tags = args.tags;
|
|
366
|
+
if (args.metadata !== void 0) input.metadata = encodeAwsJson2(args.metadata);
|
|
367
|
+
const data = await client.query(MUTATION2, { input });
|
|
368
|
+
const updated = toCorePost(data.updatePost);
|
|
369
|
+
await syncPostTags(client, updated, oldPost);
|
|
370
|
+
return updated;
|
|
371
|
+
}
|
|
372
|
+
var MUTATION3 = (
|
|
373
|
+
/* GraphQL */
|
|
374
|
+
`
|
|
375
|
+
mutation DeletePost($input: DeletePostInput!) {
|
|
376
|
+
deletePost(input: $input) {
|
|
377
|
+
postId
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
`
|
|
381
|
+
);
|
|
382
|
+
var deletePostSchema = {
|
|
383
|
+
type: "object",
|
|
384
|
+
required: ["postId"],
|
|
385
|
+
properties: {
|
|
386
|
+
postId: { type: "string" }
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
async function deletePost(client, args) {
|
|
390
|
+
const oldPost = await getPost(client, { postId: args.postId });
|
|
391
|
+
if (oldPost) {
|
|
392
|
+
await syncPostTags(client, { ...oldPost, status: "draft" }, oldPost);
|
|
393
|
+
}
|
|
394
|
+
const data = await client.query(MUTATION3, { input: { postId: args.postId } });
|
|
395
|
+
return { deleted: data.deletePost };
|
|
396
|
+
}
|
|
397
|
+
function sanitizeName(name) {
|
|
398
|
+
return name.replace(/[\x00-\x1f\x7f]/g, "").replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "_").replace(/^\.+/, "_").slice(0, 200) || "upload";
|
|
399
|
+
}
|
|
400
|
+
function buildMediaKey(filename, now = /* @__PURE__ */ new Date()) {
|
|
401
|
+
const safe = sanitizeName(filename);
|
|
402
|
+
const yyyy = now.getFullYear();
|
|
403
|
+
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
|
404
|
+
return `public/media/${yyyy}/${mm}/${now.getTime()}-${safe}`;
|
|
405
|
+
}
|
|
406
|
+
var MUTATION4 = (
|
|
407
|
+
/* GraphQL */
|
|
408
|
+
`
|
|
409
|
+
mutation CreateMedia($input: CreateMediaInput!) {
|
|
410
|
+
createMedia(input: $input) {
|
|
411
|
+
mediaId
|
|
412
|
+
src
|
|
413
|
+
mimeType
|
|
414
|
+
size
|
|
415
|
+
delivery
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
`
|
|
419
|
+
);
|
|
420
|
+
var uploadMediaSchema = {
|
|
421
|
+
type: "object",
|
|
422
|
+
required: ["filename", "mimeType", "base64Data"],
|
|
423
|
+
properties: {
|
|
424
|
+
filename: { type: "string", description: "Original filename; sanitized server-side" },
|
|
425
|
+
mimeType: {
|
|
426
|
+
type: "string",
|
|
427
|
+
description: "IANA media type (e.g. image/webp, image/jpeg). The MCP server does not transcode \u2014 pass the MIME of the bytes you actually send."
|
|
428
|
+
},
|
|
429
|
+
base64Data: {
|
|
430
|
+
type: "string",
|
|
431
|
+
description: "Base64-encoded file contents. NO data:URL prefix. The MCP server uploads bytes verbatim \u2014 resize/encode client-side first if needed."
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
async function uploadMedia(graphql, storage, args) {
|
|
436
|
+
const body = Buffer.from(args.base64Data, "base64");
|
|
437
|
+
const key = buildMediaKey(args.filename);
|
|
438
|
+
const url = await storage.putObject(key, body, args.mimeType);
|
|
439
|
+
const mediaId = `media-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
440
|
+
const data = await graphql.query(MUTATION4, {
|
|
441
|
+
input: {
|
|
442
|
+
mediaId,
|
|
443
|
+
src: key,
|
|
444
|
+
mimeType: args.mimeType,
|
|
445
|
+
size: body.length,
|
|
446
|
+
delivery: "nextjs"
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
return { media: data.createMedia, url };
|
|
450
|
+
}
|
|
451
|
+
var getSchemaSchema = {
|
|
452
|
+
type: "object",
|
|
453
|
+
properties: {}
|
|
454
|
+
};
|
|
455
|
+
function getSchema() {
|
|
456
|
+
return {
|
|
457
|
+
contentTypes: [
|
|
458
|
+
{
|
|
459
|
+
name: "post",
|
|
460
|
+
identifier: ["postId"],
|
|
461
|
+
fields: {
|
|
462
|
+
postId: { type: "string", required: true, description: "auto-generated if omitted on create" },
|
|
463
|
+
slug: { type: "string", required: true, description: "URL slug, unique" },
|
|
464
|
+
title: { type: "string", required: true },
|
|
465
|
+
excerpt: { type: "string" },
|
|
466
|
+
format: { type: "enum", values: ["tiptap", "markdown", "html"], required: true },
|
|
467
|
+
body: {
|
|
468
|
+
type: "json",
|
|
469
|
+
description: "tiptap node JSON when format=tiptap; markdown source string when format=markdown; raw HTML string when format=html"
|
|
470
|
+
},
|
|
471
|
+
status: { type: "enum", values: ["draft", "published"], default: "draft" },
|
|
472
|
+
publishedAt: { type: "datetime", description: "ISO 8601" },
|
|
473
|
+
tags: { type: "string[]" },
|
|
474
|
+
metadata: {
|
|
475
|
+
type: "json",
|
|
476
|
+
description: "Free-form per-post key/value bag. Reserved well-known keys (owned by ampless): `no_layout` (boolean). Other keys pass through unchanged for themes/plugins."
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
name: "page",
|
|
482
|
+
identifier: ["pageId"],
|
|
483
|
+
fields: {
|
|
484
|
+
pageId: { type: "string", required: true },
|
|
485
|
+
slug: { type: "string", required: true },
|
|
486
|
+
title: { type: "string", required: true },
|
|
487
|
+
format: { type: "enum", values: ["tiptap", "markdown", "html"], required: true },
|
|
488
|
+
body: { type: "json" },
|
|
489
|
+
status: { type: "enum", values: ["draft", "published"] },
|
|
490
|
+
publishedAt: { type: "datetime" }
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
name: "media",
|
|
495
|
+
identifier: ["mediaId"],
|
|
496
|
+
fields: {
|
|
497
|
+
mediaId: { type: "string", required: true },
|
|
498
|
+
src: { type: "string", required: true, description: "S3 key" },
|
|
499
|
+
mimeType: { type: "string", required: true },
|
|
500
|
+
size: { type: "integer" },
|
|
501
|
+
delivery: { type: "string" }
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
],
|
|
505
|
+
formats: ["tiptap", "markdown", "html"],
|
|
506
|
+
notes: {
|
|
507
|
+
editorTrust: 'editor stores arbitrary HTML/JS verbatim \u2014 same trust shape as WordPress unfiltered_html capability. See docs/architecture/04-access-layer-mcp.md \xA7"editor \u306E\u4FE1\u983C\u30E2\u30C7\u30EB".',
|
|
508
|
+
tiptapBody: 'When format=tiptap, body is the tiptap document JSON: { type: "doc", content: [...] }. The renderer expects this shape.',
|
|
509
|
+
noLayout: "metadata.no_layout=true serves the post as bare HTML with no theme chrome \u2014 the public route at /<slug> 308-redirects to /_/<slug>, and that route renders the body verbatim with no wrapping <html>/<head>/layout. Use this for landing pages, embeds, or any post whose body is a full HTML document. Only meaningful with format=html (the other formats need the theme renderer).",
|
|
510
|
+
staticFormat: "A fourth format value `static` exists on the underlying data model for posts whose body is a JSON manifest pointing to a pre-uploaded HTML/CSS/JS bundle in S3 at public/static/<slug>/. Public URL pattern: /_/<slug>/ for the entrypoint and /_/<slug>/<file> for every bundle file (308 redirect from /<slug> via the post dispatcher). Static posts are created/edited through the dedicated tools `upload_static_bundle` (zip in one shot), `upload_static_file` / `delete_static_file` (incremental per-file ops), and `commit_static_post` (rebuild the manifest from the current S3 prefix). `create_post` / `update_post` intentionally do NOT accept format=static \u2014 the bundle tools are the only supported entry point so the Post manifest stays in sync with the S3 prefix."
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
var DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
|
515
|
+
function extractZipFromBuffer(buffer, opts = {}) {
|
|
516
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
517
|
+
const entries2 = unzipSync(buffer);
|
|
518
|
+
const files = [];
|
|
519
|
+
const issues = [];
|
|
520
|
+
let totalBytes = 0;
|
|
521
|
+
for (const [name, data] of Object.entries(entries2)) {
|
|
522
|
+
if (name.endsWith("/")) continue;
|
|
523
|
+
const reason = validateBundlePath(name);
|
|
524
|
+
if (reason) {
|
|
525
|
+
const silent = reason === "macOS resource fork" || reason === ".DS_Store junk" || reason === "Thumbs.db junk" || reason === "directory entry";
|
|
526
|
+
if (!silent) issues.push({ path: name, reason });
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
totalBytes += data.byteLength;
|
|
530
|
+
if (totalBytes > maxBytes) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
`Bundle too large: uncompressed size exceeds the ${Math.round(maxBytes / 1024 / 1024)} MB ceiling.`
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
files.push({ path: name, data });
|
|
536
|
+
}
|
|
537
|
+
return { files: stripCommonPrefix(files), issues, totalBytes };
|
|
538
|
+
}
|
|
539
|
+
var CREATE_MUTATION = (
|
|
540
|
+
/* GraphQL */
|
|
541
|
+
`
|
|
542
|
+
${POST_FIELDS}
|
|
543
|
+
mutation CreatePost($input: CreatePostInput!) {
|
|
544
|
+
createPost(input: $input) {
|
|
545
|
+
...PostFields
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
`
|
|
549
|
+
);
|
|
550
|
+
var UPDATE_MUTATION = (
|
|
551
|
+
/* GraphQL */
|
|
552
|
+
`
|
|
553
|
+
${POST_FIELDS}
|
|
554
|
+
mutation UpdatePost($input: UpdatePostInput!) {
|
|
555
|
+
updatePost(input: $input) {
|
|
556
|
+
...PostFields
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
`
|
|
560
|
+
);
|
|
561
|
+
async function upsertStaticPost(graphql, slug, body, fields) {
|
|
562
|
+
const existing = await getPost(graphql, { slug });
|
|
563
|
+
if (existing) {
|
|
564
|
+
const input2 = {
|
|
565
|
+
postId: existing.postId,
|
|
566
|
+
format: "static",
|
|
567
|
+
body: encodeAwsJson3(body)
|
|
568
|
+
};
|
|
569
|
+
if (fields.title !== void 0) input2.title = fields.title;
|
|
570
|
+
if (fields.excerpt !== void 0) input2.excerpt = fields.excerpt;
|
|
571
|
+
if (fields.status !== void 0) input2.status = fields.status;
|
|
572
|
+
if (fields.publishedAt !== void 0) input2.publishedAt = fields.publishedAt;
|
|
573
|
+
if (fields.tags !== void 0) input2.tags = fields.tags;
|
|
574
|
+
if (fields.metadata !== void 0) {
|
|
575
|
+
input2.metadata = encodeAwsJson3(fields.metadata);
|
|
576
|
+
}
|
|
577
|
+
const data2 = await graphql.query(UPDATE_MUTATION, { input: input2 });
|
|
578
|
+
const updated = toCorePost(data2.updatePost);
|
|
579
|
+
await syncPostTags(graphql, updated, existing);
|
|
580
|
+
return { post: updated, created: false };
|
|
581
|
+
}
|
|
582
|
+
if (!fields.title) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
`commit_static_post: cannot create a new post at slug "${slug}" without a title. Pass \`title\` or commit against an existing post.`
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
const status = fields.status ?? "draft";
|
|
588
|
+
const publishedAt = fields.publishedAt ?? (status === "published" ? (/* @__PURE__ */ new Date()).toISOString() : void 0);
|
|
589
|
+
const postId = fields.postId ?? `post-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
590
|
+
const input = {
|
|
591
|
+
postId,
|
|
592
|
+
slug,
|
|
593
|
+
title: fields.title,
|
|
594
|
+
format: "static",
|
|
595
|
+
body: encodeAwsJson3(body),
|
|
596
|
+
status
|
|
597
|
+
};
|
|
598
|
+
if (fields.excerpt !== void 0) input.excerpt = fields.excerpt;
|
|
599
|
+
if (publishedAt !== void 0) input.publishedAt = publishedAt;
|
|
600
|
+
if (fields.tags !== void 0) input.tags = fields.tags;
|
|
601
|
+
if (fields.metadata !== void 0) input.metadata = encodeAwsJson3(fields.metadata);
|
|
602
|
+
const data = await graphql.query(CREATE_MUTATION, { input });
|
|
603
|
+
const created = toCorePost(data.createPost);
|
|
604
|
+
await syncPostTags(graphql, created, null);
|
|
605
|
+
return { post: created, created: true };
|
|
606
|
+
}
|
|
607
|
+
var uploadStaticBundleSchema = {
|
|
608
|
+
type: "object",
|
|
609
|
+
required: ["slug", "title", "zipBase64"],
|
|
610
|
+
properties: {
|
|
611
|
+
postId: {
|
|
612
|
+
type: "string",
|
|
613
|
+
description: "Optional explicit Post id when creating a new post. Ignored if a post at this slug already exists."
|
|
614
|
+
},
|
|
615
|
+
slug: { type: "string", description: "URL slug. Doubles as the S3 bundle path key." },
|
|
616
|
+
title: { type: "string" },
|
|
617
|
+
zipBase64: {
|
|
618
|
+
type: "string",
|
|
619
|
+
description: "Base64-encoded zip archive. NO `data:` URL prefix. Every entry must reference others by RELATIVE path; absolute paths (`/foo`) and protocol-relative URLs (`//cdn/foo`) inside HTML / CSS / SVG are rejected as the bundle would not be portable across URL prefixes."
|
|
620
|
+
},
|
|
621
|
+
entrypoint: {
|
|
622
|
+
type: "string",
|
|
623
|
+
description: "Relative path to the file served at the post URL (default `index.html`, or the first `.html` at root if `index.html` is absent)."
|
|
624
|
+
},
|
|
625
|
+
status: { type: "string", enum: ["draft", "published"], default: "draft" },
|
|
626
|
+
excerpt: { type: "string" },
|
|
627
|
+
publishedAt: { type: "string", description: 'ISO 8601 timestamp; defaults to "now" when status=published.' },
|
|
628
|
+
tags: { type: "array", items: { type: "string" } },
|
|
629
|
+
metadata: {
|
|
630
|
+
type: "object",
|
|
631
|
+
description: "Free-form per-post metadata. NOTE: `no_layout` is irrelevant for static posts (the bundle is already served raw).",
|
|
632
|
+
additionalProperties: true
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
async function uploadStaticBundle(graphql, storage, args) {
|
|
637
|
+
const slug = args.slug;
|
|
638
|
+
const zipBytes = Buffer.from(args.zipBase64, "base64");
|
|
639
|
+
if (zipBytes.length === 0) {
|
|
640
|
+
throw new Error("upload_static_bundle: zipBase64 decoded to zero bytes.");
|
|
641
|
+
}
|
|
642
|
+
const { files, issues } = extractZipFromBuffer(zipBytes);
|
|
643
|
+
if (issues.length > 0) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
`upload_static_bundle: rejected bundle path(s): ${issues.map((i) => `${i.path} (${i.reason})`).join("; ")}`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
if (files.length === 0) {
|
|
649
|
+
throw new Error("upload_static_bundle: zip contained no files.");
|
|
650
|
+
}
|
|
651
|
+
const contentIssues = validateBundle(files);
|
|
652
|
+
if (contentIssues.length > 0) {
|
|
653
|
+
throw new Error(
|
|
654
|
+
`upload_static_bundle: bundle contains absolute / protocol-relative refs: ${contentIssues.map((i) => `${i.path}: ${i.reason}`).join("; ")}`
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
const entrypoint = args.entrypoint ?? pickDefaultEntrypoint(files);
|
|
658
|
+
if (!files.some((f) => f.path === entrypoint)) {
|
|
659
|
+
throw new Error(
|
|
660
|
+
`upload_static_bundle: entrypoint "${entrypoint}" is not present in the bundle.`
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
const prefix = bundlePrefix(slug);
|
|
664
|
+
const existing = await storage.listObjects(prefix).catch((err) => {
|
|
665
|
+
console.error("[upload_static_bundle] listObjects failed (proceeding)", err);
|
|
666
|
+
return [];
|
|
667
|
+
});
|
|
668
|
+
for (const obj of existing) {
|
|
669
|
+
await storage.deleteObject(obj.key);
|
|
670
|
+
}
|
|
671
|
+
let uploadedFiles = 0;
|
|
672
|
+
for (const f of files) {
|
|
673
|
+
await storage.putObject(`${prefix}${f.path}`, f.data, mimeTypeFor(f.path));
|
|
674
|
+
uploadedFiles += 1;
|
|
675
|
+
}
|
|
676
|
+
const body = {
|
|
677
|
+
entrypoint,
|
|
678
|
+
files: files.map((f) => f.path).sort(),
|
|
679
|
+
uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
680
|
+
};
|
|
681
|
+
const { post } = await upsertStaticPost(graphql, slug, body, {
|
|
682
|
+
title: args.title,
|
|
683
|
+
postId: args.postId,
|
|
684
|
+
status: args.status,
|
|
685
|
+
publishedAt: args.publishedAt,
|
|
686
|
+
excerpt: args.excerpt,
|
|
687
|
+
tags: args.tags,
|
|
688
|
+
metadata: args.metadata
|
|
689
|
+
});
|
|
690
|
+
return { post, bundle: body, uploadedFiles };
|
|
691
|
+
}
|
|
692
|
+
var uploadStaticFileSchema = {
|
|
693
|
+
type: "object",
|
|
694
|
+
required: ["slug", "filename", "base64Data"],
|
|
695
|
+
properties: {
|
|
696
|
+
slug: { type: "string", description: "Bundle slug. Files land at public/static/<slug>/<filename>." },
|
|
697
|
+
filename: {
|
|
698
|
+
type: "string",
|
|
699
|
+
description: "Relative path inside the bundle. Must NOT start with `/`, contain `..`, or include null bytes. macOS / Windows zip junk (`.DS_Store`, `__MACOSX/*`, `Thumbs.db`) is also rejected."
|
|
700
|
+
},
|
|
701
|
+
contentType: {
|
|
702
|
+
type: "string",
|
|
703
|
+
description: "IANA media type. Omit to derive from the filename extension (text/html, text/css, application/javascript, image/* \u2026; falls back to application/octet-stream)."
|
|
704
|
+
},
|
|
705
|
+
base64Data: {
|
|
706
|
+
type: "string",
|
|
707
|
+
description: "Base64-encoded file bytes. NO `data:` URL prefix. Text files (HTML/CSS/SVG) are linted for absolute / protocol-relative URL refs \u2014 failures throw."
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
async function uploadStaticFile(storage, args) {
|
|
712
|
+
const filename = args.filename;
|
|
713
|
+
const reason = validateBundlePath2(filename);
|
|
714
|
+
if (reason) {
|
|
715
|
+
throw new Error(`upload_static_file: invalid filename "${filename}" (${reason})`);
|
|
716
|
+
}
|
|
717
|
+
const body = Buffer.from(args.base64Data, "base64");
|
|
718
|
+
if (body.length === 0) {
|
|
719
|
+
throw new Error("upload_static_file: base64Data decoded to zero bytes.");
|
|
720
|
+
}
|
|
721
|
+
const ext = filename.toLowerCase().slice(filename.lastIndexOf("."));
|
|
722
|
+
if (TEXT_EXTENSIONS.has(ext)) {
|
|
723
|
+
const text = body.toString("utf8");
|
|
724
|
+
const issues = findAbsolutePathRefs(filename, text);
|
|
725
|
+
if (issues.length > 0) {
|
|
726
|
+
throw new Error(
|
|
727
|
+
`upload_static_file: ${filename} contains absolute / protocol-relative refs: ${issues.map((i) => i.reason).join("; ")}`
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const contentType = args.contentType ?? mimeTypeFor2(filename);
|
|
732
|
+
const key = `${bundlePrefix2(args.slug)}${filename}`;
|
|
733
|
+
const url = await storage.putObject(key, body, contentType);
|
|
734
|
+
return { key, url, size: body.length };
|
|
735
|
+
}
|
|
736
|
+
var deleteStaticFileSchema = {
|
|
737
|
+
type: "object",
|
|
738
|
+
required: ["slug", "filename"],
|
|
739
|
+
properties: {
|
|
740
|
+
slug: { type: "string" },
|
|
741
|
+
filename: {
|
|
742
|
+
type: "string",
|
|
743
|
+
description: "Relative path inside the bundle to delete. Same path rules as `upload_static_file` apply (no `..`, no absolute paths, no null bytes)."
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
async function deleteStaticFile(storage, args) {
|
|
748
|
+
const reason = validateBundlePath3(args.filename);
|
|
749
|
+
if (reason) {
|
|
750
|
+
throw new Error(`delete_static_file: invalid filename "${args.filename}" (${reason})`);
|
|
751
|
+
}
|
|
752
|
+
const prefix = bundlePrefix3(args.slug);
|
|
753
|
+
const key = `${prefix}${args.filename}`;
|
|
754
|
+
const existing = await storage.listObjects(key);
|
|
755
|
+
const found = existing.some((o) => o.key === key);
|
|
756
|
+
if (!found) {
|
|
757
|
+
return { key, deleted: false };
|
|
758
|
+
}
|
|
759
|
+
await storage.deleteObject(key);
|
|
760
|
+
return { key, deleted: true };
|
|
761
|
+
}
|
|
762
|
+
var commitStaticPostSchema = {
|
|
763
|
+
type: "object",
|
|
764
|
+
required: ["slug"],
|
|
765
|
+
properties: {
|
|
766
|
+
slug: { type: "string" },
|
|
767
|
+
title: {
|
|
768
|
+
type: "string",
|
|
769
|
+
description: "Required when this is a brand-new post (no existing row at the slug). On update, falls back to the current title."
|
|
770
|
+
},
|
|
771
|
+
entrypoint: {
|
|
772
|
+
type: "string",
|
|
773
|
+
description: "Relative path of the entry file (defaults to `index.html`, or the first `.html` at root if absent). Must exist under the current S3 prefix."
|
|
774
|
+
},
|
|
775
|
+
postId: {
|
|
776
|
+
type: "string",
|
|
777
|
+
description: "Optional explicit Post id when creating a new post. Ignored if a row at this slug already exists."
|
|
778
|
+
},
|
|
779
|
+
status: { type: "string", enum: ["draft", "published"] },
|
|
780
|
+
publishedAt: { type: "string", description: "ISO 8601 timestamp." },
|
|
781
|
+
excerpt: { type: "string" },
|
|
782
|
+
tags: { type: "array", items: { type: "string" } },
|
|
783
|
+
metadata: {
|
|
784
|
+
type: "object",
|
|
785
|
+
description: "Free-form per-post metadata. Passing this REPLACES the existing object on update.",
|
|
786
|
+
additionalProperties: true
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
async function commitStaticPost(graphql, storage, args) {
|
|
791
|
+
const slug = args.slug;
|
|
792
|
+
const prefix = bundlePrefix4(slug);
|
|
793
|
+
const objects = await storage.listObjects(prefix);
|
|
794
|
+
if (objects.length === 0) {
|
|
795
|
+
throw new Error(
|
|
796
|
+
`commit_static_post: no files found under "${prefix}". Upload at least one file via upload_static_file or upload_static_bundle before committing.`
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
const relPaths = objects.map((o) => o.key.slice(prefix.length)).filter((p) => p !== "").sort();
|
|
800
|
+
const entrypoint = args.entrypoint ?? pickDefaultEntrypoint2(relPaths.map((p) => ({ path: p })));
|
|
801
|
+
if (!relPaths.includes(entrypoint)) {
|
|
802
|
+
throw new Error(
|
|
803
|
+
`commit_static_post: entrypoint "${entrypoint}" is not present under "${prefix}".`
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
const body = {
|
|
807
|
+
entrypoint,
|
|
808
|
+
files: relPaths,
|
|
809
|
+
uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
810
|
+
};
|
|
811
|
+
const { post, created } = await upsertStaticPost(graphql, slug, body, {
|
|
812
|
+
title: args.title,
|
|
813
|
+
postId: args.postId,
|
|
814
|
+
excerpt: args.excerpt,
|
|
815
|
+
status: args.status,
|
|
816
|
+
publishedAt: args.publishedAt,
|
|
817
|
+
tags: args.tags,
|
|
818
|
+
metadata: args.metadata
|
|
819
|
+
});
|
|
820
|
+
return { post, bundle: body, created };
|
|
821
|
+
}
|
|
822
|
+
var tools = [
|
|
823
|
+
{
|
|
824
|
+
name: "list_posts",
|
|
825
|
+
description: "List posts in the CMS with optional filters by status. Returns up to `limit` posts (default 20) plus a `nextToken` cursor for pagination.",
|
|
826
|
+
inputSchema: listPostsSchema,
|
|
827
|
+
handler: (args, ctx) => listPosts(ctx.graphql, args)
|
|
828
|
+
},
|
|
829
|
+
{
|
|
830
|
+
name: "get_post",
|
|
831
|
+
description: "Fetch a single post by slug or postId. Returns null if not found.",
|
|
832
|
+
inputSchema: getPostSchema,
|
|
833
|
+
handler: (args, ctx) => getPost(ctx.graphql, args)
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
name: "create_post",
|
|
837
|
+
description: "Create a new post. Title and slug are required. Body shape depends on format: tiptap=JSON node tree, markdown=source string, html=raw HTML string. Defaults to status=draft. Pass `metadata: { no_layout: true }` alongside format=html to publish the body as a bare HTML page with no theme chrome (the public route redirects to /_/<slug>).",
|
|
838
|
+
inputSchema: createPostSchema,
|
|
839
|
+
handler: (args, ctx) => createPost(ctx.graphql, args)
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
name: "update_post",
|
|
843
|
+
description: "Update an existing post by postId. Only the fields you pass are changed. Tag list / publishedAt changes also update the PostTag denormalized index. Passing `metadata` REPLACES the existing object \u2014 call get_post first if you only want to add or change one key.",
|
|
844
|
+
inputSchema: updatePostSchema,
|
|
845
|
+
handler: (args, ctx) => updatePost(ctx.graphql, args)
|
|
846
|
+
},
|
|
847
|
+
{
|
|
848
|
+
name: "delete_post",
|
|
849
|
+
description: "Delete a post by postId. Also drops associated PostTag index entries.",
|
|
850
|
+
inputSchema: deletePostSchema,
|
|
851
|
+
handler: (args, ctx) => deletePost(ctx.graphql, args)
|
|
852
|
+
},
|
|
853
|
+
{
|
|
854
|
+
name: "upload_media",
|
|
855
|
+
description: "Upload a file to the site's media S3 bucket. Pass base64-encoded bytes; the server stores them verbatim under public/media/YYYY/MM/. Returns the public URL and Media record. The server does not transcode \u2014 pre-process (e.g. resize/webp) on the client.",
|
|
856
|
+
inputSchema: uploadMediaSchema,
|
|
857
|
+
handler: (args, ctx) => uploadMedia(ctx.graphql, ctx.storage(), args)
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
name: "get_schema",
|
|
861
|
+
description: "Returns the CMS content schema (Post/Page/Media field shapes, format enum, notes). Useful as the first call to understand what fields are available.",
|
|
862
|
+
inputSchema: getSchemaSchema,
|
|
863
|
+
handler: async () => getSchema()
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
name: "upload_static_bundle",
|
|
867
|
+
description: "Create or replace a `format: 'static'` post in one shot. Pass a base64-encoded zip; the server unpacks it, validates every path + lints HTML/CSS/SVG for absolute path refs (bundles must be self-contained via relative paths), wipes the existing S3 prefix at public/static/<slug>/, uploads every file, and upserts the Post row with a manifest pointing at the entrypoint. Use this when you have the whole bundle to submit at once. For incremental edits use upload_static_file / delete_static_file followed by commit_static_post.",
|
|
868
|
+
inputSchema: uploadStaticBundleSchema,
|
|
869
|
+
handler: (args, ctx) => uploadStaticBundle(
|
|
870
|
+
ctx.graphql,
|
|
871
|
+
ctx.storage(),
|
|
872
|
+
args
|
|
873
|
+
)
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
name: "upload_static_file",
|
|
877
|
+
description: "Upload a single file into an existing static bundle's S3 prefix. The Post row is NOT modified \u2014 its `body` manifest will be out of sync with the prefix until you call `commit_static_post`. Use this for incremental edits (one CSS swap, single image change) where rebuilding the entire zip would be overkill. Text files (HTML/CSS/SVG) are linted for absolute / protocol-relative URL refs the same as the zip flow.",
|
|
878
|
+
inputSchema: uploadStaticFileSchema,
|
|
879
|
+
handler: (args, ctx) => uploadStaticFile(
|
|
880
|
+
ctx.storage(),
|
|
881
|
+
args
|
|
882
|
+
)
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
name: "delete_static_file",
|
|
886
|
+
description: "Delete a single file from a static bundle's S3 prefix. The Post row is NOT modified \u2014 its `body` manifest will list the deleted file until you call `commit_static_post`. Idempotent: returns `{ deleted: false }` instead of throwing if the file isn't there.",
|
|
887
|
+
inputSchema: deleteStaticFileSchema,
|
|
888
|
+
handler: (args, ctx) => deleteStaticFile(
|
|
889
|
+
ctx.storage(),
|
|
890
|
+
args
|
|
891
|
+
)
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
name: "commit_static_post",
|
|
895
|
+
description: 'Rebuild a static post\'s Post row from whatever is currently in its S3 prefix. Scans `public/static/<slug>/`, picks the entrypoint (default `index.html` or override), and upserts the Post with a fresh manifest (sorted file list + uploadedAt timestamp). Use this as the "save" step after a series of `upload_static_file` / `delete_static_file` calls. `title` is required when creating a brand-new post; on update, existing fields are preserved unless explicitly overridden.',
|
|
896
|
+
inputSchema: commitStaticPostSchema,
|
|
897
|
+
handler: (args, ctx) => commitStaticPost(
|
|
898
|
+
ctx.graphql,
|
|
899
|
+
ctx.storage(),
|
|
900
|
+
args
|
|
901
|
+
)
|
|
902
|
+
}
|
|
903
|
+
];
|
|
904
|
+
async function dispatchToolCall(name, args, ctx) {
|
|
905
|
+
const tool = tools.find((t) => t.name === name);
|
|
906
|
+
if (!tool) return null;
|
|
907
|
+
return tool.handler(args, ctx);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// src/functions/mcp-graphql-client.ts
|
|
911
|
+
import { Sha256 } from "@aws-crypto/sha256-js";
|
|
912
|
+
import { HttpRequest } from "@smithy/protocol-http";
|
|
913
|
+
import { SignatureV4 } from "@smithy/signature-v4";
|
|
914
|
+
import { defaultProvider } from "@aws-sdk/credential-provider-node";
|
|
915
|
+
function createMcpGraphqlClient(opts) {
|
|
916
|
+
const url = new URL(opts.endpoint);
|
|
917
|
+
const signer = new SignatureV4({
|
|
918
|
+
service: "appsync",
|
|
919
|
+
region: opts.region,
|
|
920
|
+
credentials: defaultProvider(),
|
|
921
|
+
sha256: Sha256
|
|
922
|
+
});
|
|
923
|
+
return {
|
|
924
|
+
async query(operation, variables = {}) {
|
|
925
|
+
const body = JSON.stringify({ query: operation, variables });
|
|
926
|
+
const request = new HttpRequest({
|
|
927
|
+
method: "POST",
|
|
928
|
+
protocol: url.protocol,
|
|
929
|
+
hostname: url.hostname,
|
|
930
|
+
path: url.pathname,
|
|
931
|
+
headers: {
|
|
932
|
+
// `host` is required for SigV4 canonicalisation.
|
|
933
|
+
host: url.hostname,
|
|
934
|
+
"content-type": "application/json"
|
|
935
|
+
},
|
|
936
|
+
body
|
|
937
|
+
});
|
|
938
|
+
const signed = await signer.sign(request);
|
|
939
|
+
const response = await fetch(opts.endpoint, {
|
|
940
|
+
method: "POST",
|
|
941
|
+
headers: signed.headers,
|
|
942
|
+
body
|
|
943
|
+
});
|
|
944
|
+
if (!response.ok) {
|
|
945
|
+
const text = await response.text().catch(() => "");
|
|
946
|
+
console.error("[mcp-graphql-client] AppSync HTTP error", {
|
|
947
|
+
status: response.status,
|
|
948
|
+
body: text.slice(0, 1e3)
|
|
949
|
+
});
|
|
950
|
+
throw new Error(`AppSync ${response.status}: ${text || response.statusText}`);
|
|
951
|
+
}
|
|
952
|
+
const json = await response.json();
|
|
953
|
+
if (json.errors && json.errors.length > 0) {
|
|
954
|
+
const msg = json.errors.map((e) => e.message).join("; ");
|
|
955
|
+
console.error("[mcp-graphql-client] AppSync GraphQL errors", { errors: json.errors });
|
|
956
|
+
throw new Error(`AppSync GraphQL error: ${msg}`);
|
|
957
|
+
}
|
|
958
|
+
if (!json.data) {
|
|
959
|
+
throw new Error("AppSync returned an empty response");
|
|
960
|
+
}
|
|
961
|
+
return json.data;
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// src/functions/mcp-storage-client.ts
|
|
967
|
+
import {
|
|
968
|
+
S3Client,
|
|
969
|
+
PutObjectCommand,
|
|
970
|
+
DeleteObjectCommand,
|
|
971
|
+
ListObjectsV2Command
|
|
972
|
+
} from "@aws-sdk/client-s3";
|
|
973
|
+
import { formatPublicAssetUrl } from "ampless";
|
|
974
|
+
function createMcpStorageClient(opts) {
|
|
975
|
+
const client = new S3Client({ region: opts.region });
|
|
976
|
+
return {
|
|
977
|
+
async putObject(key, body, contentType) {
|
|
978
|
+
await client.send(
|
|
979
|
+
new PutObjectCommand({
|
|
980
|
+
Bucket: opts.bucket,
|
|
981
|
+
Key: key,
|
|
982
|
+
Body: body,
|
|
983
|
+
ContentType: contentType
|
|
984
|
+
})
|
|
985
|
+
);
|
|
986
|
+
return formatPublicAssetUrl(opts.bucket, opts.region, key);
|
|
987
|
+
},
|
|
988
|
+
async deleteObject(key) {
|
|
989
|
+
await client.send(
|
|
990
|
+
new DeleteObjectCommand({ Bucket: opts.bucket, Key: key })
|
|
991
|
+
);
|
|
992
|
+
},
|
|
993
|
+
async listObjects(prefix) {
|
|
994
|
+
const out = [];
|
|
995
|
+
let token;
|
|
996
|
+
do {
|
|
997
|
+
const res = await client.send(
|
|
998
|
+
new ListObjectsV2Command({
|
|
999
|
+
Bucket: opts.bucket,
|
|
1000
|
+
Prefix: prefix,
|
|
1001
|
+
ContinuationToken: token
|
|
1002
|
+
})
|
|
1003
|
+
);
|
|
1004
|
+
for (const obj of res.Contents ?? []) {
|
|
1005
|
+
if (!obj.Key) continue;
|
|
1006
|
+
out.push({
|
|
1007
|
+
key: obj.Key,
|
|
1008
|
+
size: obj.Size ?? 0,
|
|
1009
|
+
lastModified: obj.LastModified?.toISOString()
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
token = res.IsTruncated ? res.NextContinuationToken : void 0;
|
|
1013
|
+
} while (token);
|
|
1014
|
+
return out;
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// src/functions/mcp-handler.ts
|
|
5
1020
|
var TOKENS_PK = "mcp-tokens";
|
|
1021
|
+
var JSON_RPC_PARSE_ERROR = -32700;
|
|
1022
|
+
var JSON_RPC_INVALID_REQUEST = -32600;
|
|
1023
|
+
var JSON_RPC_METHOD_NOT_FOUND = -32601;
|
|
1024
|
+
var JSON_RPC_INVALID_PARAMS = -32602;
|
|
1025
|
+
var JSON_RPC_INTERNAL_ERROR = -32603;
|
|
1026
|
+
var MCP_PROTOCOL_VERSION = "2024-11-05";
|
|
6
1027
|
function requireEnv(name) {
|
|
7
1028
|
const v = process.env[name];
|
|
8
1029
|
if (!v) throw new Error(`[mcp-handler] missing required env var ${name}`);
|
|
9
1030
|
return v;
|
|
10
1031
|
}
|
|
11
1032
|
var KV_TABLE = requireEnv("AMPLESS_KV_TABLE");
|
|
1033
|
+
var APPSYNC_URL = requireEnv("AMPLESS_APPSYNC_URL");
|
|
1034
|
+
var BUCKET_NAME = requireEnv("AMPLESS_BUCKET_NAME");
|
|
1035
|
+
var AWS_REGION = process.env["AWS_REGION"] ?? "us-east-1";
|
|
12
1036
|
var ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
|
|
1037
|
+
var HTTP_TOOLS = tools;
|
|
13
1038
|
function hashToken(plain) {
|
|
14
1039
|
return createHash("sha256").update(plain).digest("hex");
|
|
15
1040
|
}
|
|
@@ -20,6 +1045,14 @@ function jsonResponse(statusCode, body) {
|
|
|
20
1045
|
body: JSON.stringify(body)
|
|
21
1046
|
};
|
|
22
1047
|
}
|
|
1048
|
+
function jsonRpcResult(id, result) {
|
|
1049
|
+
return { jsonrpc: "2.0", id, result };
|
|
1050
|
+
}
|
|
1051
|
+
function jsonRpcError(id, code, message, data) {
|
|
1052
|
+
const error = { code, message };
|
|
1053
|
+
if (data !== void 0) error.data = data;
|
|
1054
|
+
return { jsonrpc: "2.0", id, error };
|
|
1055
|
+
}
|
|
23
1056
|
async function validateBearer(plaintext) {
|
|
24
1057
|
const hash = hashToken(plaintext);
|
|
25
1058
|
const res = await ddb.send(
|
|
@@ -30,17 +1063,85 @@ async function validateBearer(plaintext) {
|
|
|
30
1063
|
);
|
|
31
1064
|
const row = res.Item;
|
|
32
1065
|
if (!row?.value) return null;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
} catch {
|
|
37
|
-
console.error("[mcp-handler] could not parse token row", { hash });
|
|
1066
|
+
const meta = decodeTokenMeta(row.value);
|
|
1067
|
+
if (!meta) {
|
|
1068
|
+
console.error("[mcp-handler] could not decode token row", { hash, valueType: typeof row.value });
|
|
38
1069
|
return null;
|
|
39
1070
|
}
|
|
40
1071
|
if (meta.revokedAt) return null;
|
|
41
1072
|
if (meta.expiresAt && new Date(meta.expiresAt).getTime() <= Date.now()) return null;
|
|
42
1073
|
return meta;
|
|
43
1074
|
}
|
|
1075
|
+
function decodeTokenMeta(value) {
|
|
1076
|
+
const parsed = decodeAwsJson2(value);
|
|
1077
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
1078
|
+
return parsed;
|
|
1079
|
+
}
|
|
1080
|
+
var cachedCtx = null;
|
|
1081
|
+
function makeContext() {
|
|
1082
|
+
if (cachedCtx) return cachedCtx;
|
|
1083
|
+
let storageClient = null;
|
|
1084
|
+
const ctx = {
|
|
1085
|
+
graphql: createMcpGraphqlClient({ endpoint: APPSYNC_URL, region: AWS_REGION }),
|
|
1086
|
+
storage: () => {
|
|
1087
|
+
if (!storageClient) {
|
|
1088
|
+
storageClient = createMcpStorageClient({ bucket: BUCKET_NAME, region: AWS_REGION });
|
|
1089
|
+
}
|
|
1090
|
+
return storageClient;
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
cachedCtx = ctx;
|
|
1094
|
+
return ctx;
|
|
1095
|
+
}
|
|
1096
|
+
async function dispatchJsonRpc(req) {
|
|
1097
|
+
switch (req.method) {
|
|
1098
|
+
case "initialize":
|
|
1099
|
+
return jsonRpcResult(req.id, {
|
|
1100
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
1101
|
+
capabilities: { tools: {} },
|
|
1102
|
+
serverInfo: { name: "ampless-mcp", version: "0.2" }
|
|
1103
|
+
});
|
|
1104
|
+
case "notifications/initialized":
|
|
1105
|
+
return jsonRpcResult(req.id, null);
|
|
1106
|
+
case "tools/list":
|
|
1107
|
+
return jsonRpcResult(req.id, {
|
|
1108
|
+
tools: HTTP_TOOLS.map((t) => ({
|
|
1109
|
+
name: t.name,
|
|
1110
|
+
description: t.description,
|
|
1111
|
+
inputSchema: t.inputSchema
|
|
1112
|
+
}))
|
|
1113
|
+
});
|
|
1114
|
+
case "tools/call": {
|
|
1115
|
+
const params = req.params;
|
|
1116
|
+
if (!params?.name || typeof params.name !== "string") {
|
|
1117
|
+
return jsonRpcError(req.id, JSON_RPC_INVALID_PARAMS, "tools/call requires a `name` parameter");
|
|
1118
|
+
}
|
|
1119
|
+
const tool = HTTP_TOOLS.find((t) => t.name === params.name);
|
|
1120
|
+
if (!tool) {
|
|
1121
|
+
return jsonRpcError(req.id, JSON_RPC_METHOD_NOT_FOUND, `unknown tool: ${params.name}`);
|
|
1122
|
+
}
|
|
1123
|
+
const ctx = makeContext();
|
|
1124
|
+
try {
|
|
1125
|
+
const result = await dispatchToolCall(params.name, params.arguments ?? {}, ctx);
|
|
1126
|
+
return jsonRpcResult(req.id, {
|
|
1127
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1128
|
+
});
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1131
|
+
console.error("[mcp-handler] tool dispatch failed", {
|
|
1132
|
+
tool: params.name,
|
|
1133
|
+
message
|
|
1134
|
+
});
|
|
1135
|
+
return jsonRpcResult(req.id, {
|
|
1136
|
+
isError: true,
|
|
1137
|
+
content: [{ type: "text", text: message }]
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
default:
|
|
1142
|
+
return jsonRpcError(req.id, JSON_RPC_METHOD_NOT_FOUND, `Method not found: ${req.method}`);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
44
1145
|
var handler = async (event) => {
|
|
45
1146
|
if (event.requestContext?.http?.method === "OPTIONS") {
|
|
46
1147
|
return { statusCode: 204, body: "" };
|
|
@@ -58,12 +1159,33 @@ var handler = async (event) => {
|
|
|
58
1159
|
if (!meta) {
|
|
59
1160
|
return jsonResponse(401, { error: "invalid_token" });
|
|
60
1161
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
1162
|
+
let req;
|
|
1163
|
+
try {
|
|
1164
|
+
const body = event.isBase64Encoded ? Buffer.from(event.body ?? "", "base64").toString("utf8") : event.body ?? "";
|
|
1165
|
+
if (!body) {
|
|
1166
|
+
return jsonResponse(400, jsonRpcError(null, JSON_RPC_INVALID_REQUEST, "Empty body"));
|
|
1167
|
+
}
|
|
1168
|
+
req = JSON.parse(body);
|
|
1169
|
+
} catch {
|
|
1170
|
+
return jsonResponse(400, jsonRpcError(null, JSON_RPC_PARSE_ERROR, "Parse error"));
|
|
1171
|
+
}
|
|
1172
|
+
if (req.jsonrpc !== "2.0" || typeof req.method !== "string") {
|
|
1173
|
+
return jsonResponse(
|
|
1174
|
+
400,
|
|
1175
|
+
jsonRpcError(req?.id ?? null, JSON_RPC_INVALID_REQUEST, "Invalid Request")
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
try {
|
|
1179
|
+
const response = await dispatchJsonRpc(req);
|
|
1180
|
+
return jsonResponse(200, response);
|
|
1181
|
+
} catch (err) {
|
|
1182
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1183
|
+
console.error("[mcp-handler] dispatch threw", { method: req.method, message });
|
|
1184
|
+
return jsonResponse(
|
|
1185
|
+
500,
|
|
1186
|
+
jsonRpcError(req.id ?? null, JSON_RPC_INTERNAL_ERROR, message)
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
67
1189
|
};
|
|
68
1190
|
export {
|
|
69
1191
|
handler
|