@ampless/backend 0.2.0-alpha.16 → 0.2.0-alpha.18

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.
@@ -6,10 +6,48 @@ import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
6
6
  import { createHash } from "crypto";
7
7
  import { decodeAwsJson as decodeAwsJson2 } from "ampless";
8
8
 
9
- // ../mcp-server/dist/chunk-6LH275IC.js
10
- import { decodeAwsJson } from "ampless";
11
- import { composeSiteIdStatus, composeSiteIdSlug, encodeAwsJson } from "ampless";
12
- import { composeSiteIdStatus as composeSiteIdStatus2, composeSiteIdSlug as composeSiteIdSlug2, encodeAwsJson as encodeAwsJson2 } from "ampless";
9
+ // ../mcp-server/dist/chunk-4PTNHZO4.js
10
+ import {
11
+ decodeAwsJson
12
+ } from "ampless";
13
+ import {
14
+ composeSiteIdStatus,
15
+ composeSiteIdSlug,
16
+ encodeAwsJson
17
+ } from "ampless";
18
+ import {
19
+ composeSiteIdStatus as composeSiteIdStatus2,
20
+ composeSiteIdSlug as composeSiteIdSlug2,
21
+ encodeAwsJson as encodeAwsJson2
22
+ } from "ampless";
23
+ import {
24
+ bundlePrefix,
25
+ mimeTypeFor,
26
+ pickDefaultEntrypoint,
27
+ validateBundle
28
+ } from "ampless";
29
+ import { unzipSync, strFromU8 } from "fflate";
30
+ import {
31
+ validateBundlePath,
32
+ stripCommonPrefix
33
+ } from "ampless";
34
+ import {
35
+ composeSiteIdStatus as composeSiteIdStatus3,
36
+ composeSiteIdSlug as composeSiteIdSlug3,
37
+ encodeAwsJson as encodeAwsJson3
38
+ } from "ampless";
39
+ import {
40
+ bundlePrefix as bundlePrefix2,
41
+ findAbsolutePathRefs,
42
+ mimeTypeFor as mimeTypeFor2,
43
+ validateBundlePath as validateBundlePath2,
44
+ TEXT_EXTENSIONS
45
+ } from "ampless";
46
+ import { bundlePrefix as bundlePrefix3, validateBundlePath as validateBundlePath3 } from "ampless";
47
+ import {
48
+ bundlePrefix as bundlePrefix4,
49
+ pickDefaultEntrypoint as pickDefaultEntrypoint2
50
+ } from "ampless";
13
51
  var POST_FIELDS = (
14
52
  /* GraphQL */
15
53
  `
@@ -24,10 +62,12 @@ var POST_FIELDS = (
24
62
  status
25
63
  publishedAt
26
64
  tags
65
+ metadata
27
66
  }
28
67
  `
29
68
  );
30
69
  function toCorePost(p) {
70
+ const metadata = decodeAwsJson(p.metadata);
31
71
  return {
32
72
  siteId: p.siteId,
33
73
  postId: p.postId,
@@ -38,7 +78,8 @@ function toCorePost(p) {
38
78
  body: decodeAwsJson(p.body),
39
79
  status: p.status ?? "draft",
40
80
  publishedAt: p.publishedAt ?? void 0,
41
- tags: (p.tags ?? []).filter((t) => typeof t === "string")
81
+ tags: (p.tags ?? []).filter((t) => typeof t === "string"),
82
+ metadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : void 0
42
83
  };
43
84
  }
44
85
  var QUERY = (
@@ -233,14 +274,29 @@ var createPostSchema = {
233
274
  postId: { type: "string", description: "Optional explicit id; auto-generated if omitted" },
234
275
  slug: { type: "string" },
235
276
  title: { type: "string" },
236
- format: { type: "string", enum: ["tiptap", "markdown", "html"] },
277
+ format: {
278
+ type: "string",
279
+ enum: ["tiptap", "markdown", "html"],
280
+ description: "tiptap = rich text JSON tree; markdown = source string (GFM extensions enabled); html = raw HTML string (rendered verbatim, no sanitization)."
281
+ },
237
282
  body: {
238
- description: "tiptap JSON (when format=tiptap), markdown source string, or raw HTML string"
283
+ description: "tiptap JSON (when format=tiptap), markdown source string (format=markdown), or raw HTML string (format=html)."
239
284
  },
240
285
  status: { type: "string", enum: ["draft", "published"], default: "draft" },
241
286
  excerpt: { type: "string" },
242
287
  publishedAt: { type: "string", description: "ISO 8601 timestamp; required when status=published" },
243
- tags: { type: "array", items: { type: "string" } }
288
+ tags: { type: "array", items: { type: "string" } },
289
+ metadata: {
290
+ type: "object",
291
+ 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 /raw/<slug>); meaningful only with format=html. Other keys pass through unchanged for themes/plugins.",
292
+ properties: {
293
+ no_layout: {
294
+ type: "boolean",
295
+ description: "Serve the post as bare HTML with no theme chrome. Only meaningful when format=html; ignored otherwise."
296
+ }
297
+ },
298
+ additionalProperties: true
299
+ }
244
300
  }
245
301
  };
246
302
  async function createPost(client, defaultSiteId, args) {
@@ -260,6 +316,7 @@ async function createPost(client, defaultSiteId, args) {
260
316
  status,
261
317
  publishedAt,
262
318
  tags: args.tags,
319
+ metadata: args.metadata !== void 0 ? encodeAwsJson(args.metadata) : void 0,
263
320
  // Denormalized GSI keys.
264
321
  siteIdStatus: composeSiteIdStatus(siteId, status),
265
322
  siteIdSlug: composeSiteIdSlug(siteId, args.slug)
@@ -289,11 +346,26 @@ var updatePostSchema = {
289
346
  slug: { type: "string" },
290
347
  title: { type: "string" },
291
348
  excerpt: { type: "string" },
292
- format: { type: "string", enum: ["tiptap", "markdown", "html"] },
293
- body: { description: "tiptap JSON, markdown source, or raw HTML" },
349
+ format: {
350
+ type: "string",
351
+ enum: ["tiptap", "markdown", "html"],
352
+ description: "tiptap = rich text JSON tree; markdown = source string; html = raw HTML string (no sanitization)."
353
+ },
354
+ body: { description: "tiptap JSON, markdown source, or raw HTML string" },
294
355
  status: { type: "string", enum: ["draft", "published"] },
295
356
  publishedAt: { type: "string", description: "ISO 8601 timestamp" },
296
- tags: { type: "array", items: { type: "string" } }
357
+ tags: { type: "array", items: { type: "string" } },
358
+ metadata: {
359
+ type: "object",
360
+ 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 /raw/<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.",
361
+ properties: {
362
+ no_layout: {
363
+ type: "boolean",
364
+ description: "Serve the post as bare HTML with no theme chrome. Only meaningful when format=html; ignored otherwise."
365
+ }
366
+ },
367
+ additionalProperties: true
368
+ }
297
369
  }
298
370
  };
299
371
  async function updatePost(client, defaultSiteId, args) {
@@ -308,6 +380,7 @@ async function updatePost(client, defaultSiteId, args) {
308
380
  if (args.status !== void 0) input.status = args.status;
309
381
  if (args.publishedAt !== void 0) input.publishedAt = args.publishedAt;
310
382
  if (args.tags !== void 0) input.tags = args.tags;
383
+ if (args.metadata !== void 0) input.metadata = encodeAwsJson2(args.metadata);
311
384
  if (args.status !== void 0) {
312
385
  input.siteIdStatus = composeSiteIdStatus2(siteId, args.status);
313
386
  }
@@ -428,7 +501,11 @@ function getSchema() {
428
501
  },
429
502
  status: { type: "enum", values: ["draft", "published"], default: "draft" },
430
503
  publishedAt: { type: "datetime", description: "ISO 8601" },
431
- tags: { type: "string[]" }
504
+ tags: { type: "string[]" },
505
+ metadata: {
506
+ type: "json",
507
+ 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."
508
+ }
432
509
  }
433
510
  },
434
511
  {
@@ -461,9 +538,334 @@ function getSchema() {
461
538
  formats: ["tiptap", "markdown", "html"],
462
539
  notes: {
463
540
  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".',
464
- tiptapBody: 'When format=tiptap, body is the tiptap document JSON: { type: "doc", content: [...] }. The renderer expects this shape.'
541
+ tiptapBody: 'When format=tiptap, body is the tiptap document JSON: { type: "doc", content: [...] }. The renderer expects this shape.',
542
+ noLayout: "metadata.no_layout=true serves the post as bare HTML with no theme chrome \u2014 the public route at /<slug> 302-redirects to /raw/<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).",
543
+ 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/<siteId>/<slug>/. 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."
544
+ }
545
+ };
546
+ }
547
+ var DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
548
+ function extractZipFromBuffer(buffer, opts = {}) {
549
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
550
+ const entries2 = unzipSync(buffer);
551
+ const files = [];
552
+ const issues = [];
553
+ let totalBytes = 0;
554
+ for (const [name, data] of Object.entries(entries2)) {
555
+ if (name.endsWith("/")) continue;
556
+ const reason = validateBundlePath(name);
557
+ if (reason) {
558
+ const silent = reason === "macOS resource fork" || reason === ".DS_Store junk" || reason === "Thumbs.db junk" || reason === "directory entry";
559
+ if (!silent) issues.push({ path: name, reason });
560
+ continue;
561
+ }
562
+ totalBytes += data.byteLength;
563
+ if (totalBytes > maxBytes) {
564
+ throw new Error(
565
+ `Bundle too large: uncompressed size exceeds the ${Math.round(maxBytes / 1024 / 1024)} MB ceiling.`
566
+ );
567
+ }
568
+ files.push({ path: name, data });
569
+ }
570
+ return { files: stripCommonPrefix(files), issues, totalBytes };
571
+ }
572
+ var CREATE_MUTATION = (
573
+ /* GraphQL */
574
+ `
575
+ ${POST_FIELDS}
576
+ mutation CreatePost($input: CreatePostInput!) {
577
+ createPost(input: $input) {
578
+ ...PostFields
579
+ }
580
+ }
581
+ `
582
+ );
583
+ var UPDATE_MUTATION = (
584
+ /* GraphQL */
585
+ `
586
+ ${POST_FIELDS}
587
+ mutation UpdatePost($input: UpdatePostInput!) {
588
+ updatePost(input: $input) {
589
+ ...PostFields
590
+ }
591
+ }
592
+ `
593
+ );
594
+ async function upsertStaticPost(graphql, siteId, slug, body, fields) {
595
+ const existing = await getPost(graphql, siteId, { siteId, slug });
596
+ if (existing) {
597
+ const input2 = {
598
+ siteId,
599
+ postId: existing.postId,
600
+ format: "static",
601
+ body: encodeAwsJson3(body)
602
+ };
603
+ if (fields.title !== void 0) input2.title = fields.title;
604
+ if (fields.excerpt !== void 0) input2.excerpt = fields.excerpt;
605
+ if (fields.status !== void 0) {
606
+ input2.status = fields.status;
607
+ input2.siteIdStatus = composeSiteIdStatus3(siteId, fields.status);
465
608
  }
609
+ if (fields.publishedAt !== void 0) input2.publishedAt = fields.publishedAt;
610
+ if (fields.tags !== void 0) input2.tags = fields.tags;
611
+ if (fields.metadata !== void 0) {
612
+ input2.metadata = encodeAwsJson3(fields.metadata);
613
+ }
614
+ const data2 = await graphql.query(UPDATE_MUTATION, { input: input2 });
615
+ const updated = toCorePost(data2.updatePost);
616
+ await syncPostTags(graphql, updated, existing);
617
+ return { post: updated, created: false };
618
+ }
619
+ if (!fields.title) {
620
+ throw new Error(
621
+ `commit_static_post: cannot create a new post at slug "${slug}" without a title. Pass \`title\` or commit against an existing post.`
622
+ );
623
+ }
624
+ const status = fields.status ?? "draft";
625
+ const publishedAt = fields.publishedAt ?? (status === "published" ? (/* @__PURE__ */ new Date()).toISOString() : void 0);
626
+ const postId = fields.postId ?? `post-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
627
+ const input = {
628
+ siteId,
629
+ postId,
630
+ slug,
631
+ title: fields.title,
632
+ format: "static",
633
+ body: encodeAwsJson3(body),
634
+ status,
635
+ siteIdStatus: composeSiteIdStatus3(siteId, status),
636
+ siteIdSlug: composeSiteIdSlug3(siteId, slug)
466
637
  };
638
+ if (fields.excerpt !== void 0) input.excerpt = fields.excerpt;
639
+ if (publishedAt !== void 0) input.publishedAt = publishedAt;
640
+ if (fields.tags !== void 0) input.tags = fields.tags;
641
+ if (fields.metadata !== void 0) input.metadata = encodeAwsJson3(fields.metadata);
642
+ const data = await graphql.query(CREATE_MUTATION, { input });
643
+ const created = toCorePost(data.createPost);
644
+ await syncPostTags(graphql, created, null);
645
+ return { post: created, created: true };
646
+ }
647
+ var uploadStaticBundleSchema = {
648
+ type: "object",
649
+ required: ["slug", "title", "zipBase64"],
650
+ properties: {
651
+ siteId: { type: "string", description: 'Site identifier (defaults to "default")' },
652
+ postId: {
653
+ type: "string",
654
+ description: "Optional explicit Post id when creating a new post. Ignored if a post at this slug already exists."
655
+ },
656
+ slug: { type: "string", description: "URL slug. Doubles as the S3 bundle path key." },
657
+ title: { type: "string" },
658
+ zipBase64: {
659
+ type: "string",
660
+ 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."
661
+ },
662
+ entrypoint: {
663
+ type: "string",
664
+ 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)."
665
+ },
666
+ status: { type: "string", enum: ["draft", "published"], default: "draft" },
667
+ excerpt: { type: "string" },
668
+ publishedAt: { type: "string", description: 'ISO 8601 timestamp; defaults to "now" when status=published.' },
669
+ tags: { type: "array", items: { type: "string" } },
670
+ metadata: {
671
+ type: "object",
672
+ description: "Free-form per-post metadata. NOTE: `no_layout` is irrelevant for static posts (the bundle is already served raw).",
673
+ additionalProperties: true
674
+ }
675
+ }
676
+ };
677
+ async function uploadStaticBundle(graphql, storage, defaultSiteId, args) {
678
+ const siteId = args.siteId ?? defaultSiteId;
679
+ const slug = args.slug;
680
+ const zipBytes = Buffer.from(args.zipBase64, "base64");
681
+ if (zipBytes.length === 0) {
682
+ throw new Error("upload_static_bundle: zipBase64 decoded to zero bytes.");
683
+ }
684
+ const { files, issues } = extractZipFromBuffer(zipBytes);
685
+ if (issues.length > 0) {
686
+ throw new Error(
687
+ `upload_static_bundle: rejected bundle path(s): ${issues.map((i) => `${i.path} (${i.reason})`).join("; ")}`
688
+ );
689
+ }
690
+ if (files.length === 0) {
691
+ throw new Error("upload_static_bundle: zip contained no files.");
692
+ }
693
+ const contentIssues = validateBundle(files);
694
+ if (contentIssues.length > 0) {
695
+ throw new Error(
696
+ `upload_static_bundle: bundle contains absolute / protocol-relative refs: ${contentIssues.map((i) => `${i.path}: ${i.reason}`).join("; ")}`
697
+ );
698
+ }
699
+ const entrypoint = args.entrypoint ?? pickDefaultEntrypoint(files);
700
+ if (!files.some((f) => f.path === entrypoint)) {
701
+ throw new Error(
702
+ `upload_static_bundle: entrypoint "${entrypoint}" is not present in the bundle.`
703
+ );
704
+ }
705
+ const prefix = bundlePrefix(siteId, slug);
706
+ const existing = await storage.listObjects(prefix).catch((err) => {
707
+ console.error("[upload_static_bundle] listObjects failed (proceeding)", err);
708
+ return [];
709
+ });
710
+ for (const obj of existing) {
711
+ await storage.deleteObject(obj.key);
712
+ }
713
+ let uploadedFiles = 0;
714
+ for (const f of files) {
715
+ await storage.putObject(`${prefix}${f.path}`, f.data, mimeTypeFor(f.path));
716
+ uploadedFiles += 1;
717
+ }
718
+ const body = {
719
+ entrypoint,
720
+ files: files.map((f) => f.path).sort(),
721
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
722
+ };
723
+ const { post } = await upsertStaticPost(graphql, siteId, slug, body, {
724
+ title: args.title,
725
+ postId: args.postId,
726
+ status: args.status,
727
+ publishedAt: args.publishedAt,
728
+ excerpt: args.excerpt,
729
+ tags: args.tags,
730
+ metadata: args.metadata
731
+ });
732
+ return { post, bundle: body, uploadedFiles };
733
+ }
734
+ var uploadStaticFileSchema = {
735
+ type: "object",
736
+ required: ["slug", "filename", "base64Data"],
737
+ properties: {
738
+ siteId: { type: "string", description: 'Site identifier (defaults to "default")' },
739
+ slug: { type: "string", description: "Bundle slug. Files land at public/static/<siteId>/<slug>/<filename>." },
740
+ filename: {
741
+ type: "string",
742
+ 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."
743
+ },
744
+ contentType: {
745
+ type: "string",
746
+ 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)."
747
+ },
748
+ base64Data: {
749
+ type: "string",
750
+ 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."
751
+ }
752
+ }
753
+ };
754
+ async function uploadStaticFile(storage, defaultSiteId, args) {
755
+ const siteId = args.siteId ?? defaultSiteId;
756
+ const filename = args.filename;
757
+ const reason = validateBundlePath2(filename);
758
+ if (reason) {
759
+ throw new Error(`upload_static_file: invalid filename "${filename}" (${reason})`);
760
+ }
761
+ const body = Buffer.from(args.base64Data, "base64");
762
+ if (body.length === 0) {
763
+ throw new Error("upload_static_file: base64Data decoded to zero bytes.");
764
+ }
765
+ const ext = filename.toLowerCase().slice(filename.lastIndexOf("."));
766
+ if (TEXT_EXTENSIONS.has(ext)) {
767
+ const text = body.toString("utf8");
768
+ const issues = findAbsolutePathRefs(filename, text);
769
+ if (issues.length > 0) {
770
+ throw new Error(
771
+ `upload_static_file: ${filename} contains absolute / protocol-relative refs: ${issues.map((i) => i.reason).join("; ")}`
772
+ );
773
+ }
774
+ }
775
+ const contentType = args.contentType ?? mimeTypeFor2(filename);
776
+ const key = `${bundlePrefix2(siteId, args.slug)}${filename}`;
777
+ const url = await storage.putObject(key, body, contentType);
778
+ return { key, url, size: body.length };
779
+ }
780
+ var deleteStaticFileSchema = {
781
+ type: "object",
782
+ required: ["slug", "filename"],
783
+ properties: {
784
+ siteId: { type: "string", description: 'Site identifier (defaults to "default")' },
785
+ slug: { type: "string" },
786
+ filename: {
787
+ type: "string",
788
+ description: "Relative path inside the bundle to delete. Same path rules as `upload_static_file` apply (no `..`, no absolute paths, no null bytes)."
789
+ }
790
+ }
791
+ };
792
+ async function deleteStaticFile(storage, defaultSiteId, args) {
793
+ const siteId = args.siteId ?? defaultSiteId;
794
+ const reason = validateBundlePath3(args.filename);
795
+ if (reason) {
796
+ throw new Error(`delete_static_file: invalid filename "${args.filename}" (${reason})`);
797
+ }
798
+ const prefix = bundlePrefix3(siteId, args.slug);
799
+ const key = `${prefix}${args.filename}`;
800
+ const existing = await storage.listObjects(key);
801
+ const found = existing.some((o) => o.key === key);
802
+ if (!found) {
803
+ return { key, deleted: false };
804
+ }
805
+ await storage.deleteObject(key);
806
+ return { key, deleted: true };
807
+ }
808
+ var commitStaticPostSchema = {
809
+ type: "object",
810
+ required: ["slug"],
811
+ properties: {
812
+ siteId: { type: "string", description: 'Site identifier (defaults to "default")' },
813
+ slug: { type: "string" },
814
+ title: {
815
+ type: "string",
816
+ description: "Required when this is a brand-new post (no existing row at the slug). On update, falls back to the current title."
817
+ },
818
+ entrypoint: {
819
+ type: "string",
820
+ 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."
821
+ },
822
+ postId: {
823
+ type: "string",
824
+ description: "Optional explicit Post id when creating a new post. Ignored if a row at this slug already exists."
825
+ },
826
+ status: { type: "string", enum: ["draft", "published"] },
827
+ publishedAt: { type: "string", description: "ISO 8601 timestamp." },
828
+ excerpt: { type: "string" },
829
+ tags: { type: "array", items: { type: "string" } },
830
+ metadata: {
831
+ type: "object",
832
+ description: "Free-form per-post metadata. Passing this REPLACES the existing object on update.",
833
+ additionalProperties: true
834
+ }
835
+ }
836
+ };
837
+ async function commitStaticPost(graphql, storage, defaultSiteId, args) {
838
+ const siteId = args.siteId ?? defaultSiteId;
839
+ const slug = args.slug;
840
+ const prefix = bundlePrefix4(siteId, slug);
841
+ const objects = await storage.listObjects(prefix);
842
+ if (objects.length === 0) {
843
+ throw new Error(
844
+ `commit_static_post: no files found under "${prefix}". Upload at least one file via upload_static_file or upload_static_bundle before committing.`
845
+ );
846
+ }
847
+ const relPaths = objects.map((o) => o.key.slice(prefix.length)).filter((p) => p !== "").sort();
848
+ const entrypoint = args.entrypoint ?? pickDefaultEntrypoint2(relPaths.map((p) => ({ path: p })));
849
+ if (!relPaths.includes(entrypoint)) {
850
+ throw new Error(
851
+ `commit_static_post: entrypoint "${entrypoint}" is not present under "${prefix}".`
852
+ );
853
+ }
854
+ const body = {
855
+ entrypoint,
856
+ files: relPaths,
857
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
858
+ };
859
+ const { post, created } = await upsertStaticPost(graphql, siteId, slug, body, {
860
+ title: args.title,
861
+ postId: args.postId,
862
+ excerpt: args.excerpt,
863
+ status: args.status,
864
+ publishedAt: args.publishedAt,
865
+ tags: args.tags,
866
+ metadata: args.metadata
867
+ });
868
+ return { post, bundle: body, created };
467
869
  }
468
870
  var tools = [
469
871
  {
@@ -480,13 +882,13 @@ var tools = [
480
882
  },
481
883
  {
482
884
  name: "create_post",
483
- 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.",
885
+ 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 /raw/<slug>).",
484
886
  inputSchema: createPostSchema,
485
887
  handler: (args, ctx) => createPost(ctx.graphql, ctx.defaultSiteId, args)
486
888
  },
487
889
  {
488
890
  name: "update_post",
489
- description: "Update an existing post by postId. Only the fields you pass are changed. Tag list / publishedAt changes also update the PostTag denormalized index.",
891
+ 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.",
490
892
  inputSchema: updatePostSchema,
491
893
  handler: (args, ctx) => updatePost(ctx.graphql, ctx.defaultSiteId, args)
492
894
  },
@@ -507,6 +909,48 @@ var tools = [
507
909
  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.",
508
910
  inputSchema: getSchemaSchema,
509
911
  handler: async () => getSchema()
912
+ },
913
+ {
914
+ name: "upload_static_bundle",
915
+ 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/<siteId>/<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.",
916
+ inputSchema: uploadStaticBundleSchema,
917
+ handler: (args, ctx) => uploadStaticBundle(
918
+ ctx.graphql,
919
+ ctx.storage(),
920
+ ctx.defaultSiteId,
921
+ args
922
+ )
923
+ },
924
+ {
925
+ name: "upload_static_file",
926
+ 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.",
927
+ inputSchema: uploadStaticFileSchema,
928
+ handler: (args, ctx) => uploadStaticFile(
929
+ ctx.storage(),
930
+ ctx.defaultSiteId,
931
+ args
932
+ )
933
+ },
934
+ {
935
+ name: "delete_static_file",
936
+ 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.",
937
+ inputSchema: deleteStaticFileSchema,
938
+ handler: (args, ctx) => deleteStaticFile(
939
+ ctx.storage(),
940
+ ctx.defaultSiteId,
941
+ args
942
+ )
943
+ },
944
+ {
945
+ name: "commit_static_post",
946
+ description: 'Rebuild a static post\'s Post row from whatever is currently in its S3 prefix. Scans `public/static/<siteId>/<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.',
947
+ inputSchema: commitStaticPostSchema,
948
+ handler: (args, ctx) => commitStaticPost(
949
+ ctx.graphql,
950
+ ctx.storage(),
951
+ ctx.defaultSiteId,
952
+ args
953
+ )
510
954
  }
511
955
  ];
512
956
  async function dispatchToolCall(name, args, ctx) {
@@ -572,7 +1016,12 @@ function createMcpGraphqlClient(opts) {
572
1016
  }
573
1017
 
574
1018
  // src/functions/mcp-storage-client.ts
575
- import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
1019
+ import {
1020
+ S3Client,
1021
+ PutObjectCommand,
1022
+ DeleteObjectCommand,
1023
+ ListObjectsV2Command
1024
+ } from "@aws-sdk/client-s3";
576
1025
  import { formatPublicAssetUrl } from "ampless";
577
1026
  function createMcpStorageClient(opts) {
578
1027
  const client = new S3Client({ region: opts.region });
@@ -587,6 +1036,34 @@ function createMcpStorageClient(opts) {
587
1036
  })
588
1037
  );
589
1038
  return formatPublicAssetUrl(opts.bucket, opts.region, key);
1039
+ },
1040
+ async deleteObject(key) {
1041
+ await client.send(
1042
+ new DeleteObjectCommand({ Bucket: opts.bucket, Key: key })
1043
+ );
1044
+ },
1045
+ async listObjects(prefix) {
1046
+ const out = [];
1047
+ let token;
1048
+ do {
1049
+ const res = await client.send(
1050
+ new ListObjectsV2Command({
1051
+ Bucket: opts.bucket,
1052
+ Prefix: prefix,
1053
+ ContinuationToken: token
1054
+ })
1055
+ );
1056
+ for (const obj of res.Contents ?? []) {
1057
+ if (!obj.Key) continue;
1058
+ out.push({
1059
+ key: obj.Key,
1060
+ size: obj.Size ?? 0,
1061
+ lastModified: obj.LastModified?.toISOString()
1062
+ });
1063
+ }
1064
+ token = res.IsTruncated ? res.NextContinuationToken : void 0;
1065
+ } while (token);
1066
+ return out;
590
1067
  }
591
1068
  };
592
1069
  }
package/dist/index.js CHANGED
@@ -189,6 +189,23 @@ function defineAmplessBackend(opts) {
189
189
  resources: [`${backend.storage.resources.bucket.bucketArn}/public/media/*`]
190
190
  })
191
191
  );
192
+ mcpHandlerFn.addToRolePolicy(
193
+ new PolicyStatement({
194
+ effect: Effect.ALLOW,
195
+ actions: ["s3:PutObject", "s3:DeleteObject"],
196
+ resources: [`${backend.storage.resources.bucket.bucketArn}/public/static/*`]
197
+ })
198
+ );
199
+ mcpHandlerFn.addToRolePolicy(
200
+ new PolicyStatement({
201
+ effect: Effect.ALLOW,
202
+ actions: ["s3:ListBucket"],
203
+ resources: [backend.storage.resources.bucket.bucketArn],
204
+ conditions: {
205
+ StringLike: { "s3:prefix": ["public/static/*"] }
206
+ }
207
+ })
208
+ );
192
209
  mcpHandlerFn.addEnvironment("AMPLESS_BUCKET_NAME", backend.storage.resources.bucket.bucketName);
193
210
  const mcpFunctionUrl = mcpHandlerFn.addFunctionUrl({
194
211
  authType: FunctionUrlAuthType.NONE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/backend",
3
- "version": "0.2.0-alpha.16",
3
+ "version": "0.2.0-alpha.18",
4
4
  "description": "Amplify Gen 2 backend factories for ampless: auth, data, storage, event processors, API key renewer",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -64,8 +64,9 @@
64
64
  "@aws-sdk/util-dynamodb": "^3.717.0",
65
65
  "@smithy/protocol-http": "^5.3.0",
66
66
  "@smithy/signature-v4": "^5.4.0",
67
- "ampless": "0.2.0-alpha.7",
68
- "@ampless/mcp-server": "0.2.0-alpha.7"
67
+ "fflate": "^0.8.2",
68
+ "ampless": "0.2.0-alpha.8",
69
+ "@ampless/mcp-server": "0.2.0-alpha.9"
69
70
  },
70
71
  "peerDependencies": {
71
72
  "@aws-amplify/backend": "^1",