@ampless/backend 0.2.0-alpha.15 → 0.2.0-alpha.17

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * MCP HTTP endpoint Lambda. Phase 4: Bearer validation + JSON-RPC 2.0
3
- * tool dispatch over AppSync IAM auth.
2
+ * MCP HTTP endpoint Lambda. Phase 5: Bearer validation + JSON-RPC 2.0
3
+ * tool dispatch over AppSync IAM auth, including `upload_media`.
4
4
  *
5
5
  * 1. Reads KvStore directly (PK = 'mcp-tokens', SK = SHA-256 hex)
6
6
  * to validate `Authorization: Bearer amk_...`. Same narrow IAM
@@ -12,14 +12,14 @@
12
12
  * registry. The GraphqlClient implementation hits AppSync with
13
13
  * SigV4 (`AWS_IAM` auth mode), gated by `allow.resource(mcpHandler)
14
14
  * .to(['query', 'mutate'])` on Post / PostTag in the schema.
15
+ * 4. `upload_media` decodes the base64 body inline and uploads to S3
16
+ * using the Lambda execution role (s3:PutObject on public/media/*).
17
+ * Payload limit ~6 MB (base64-inflated) covers typical CMS images;
18
+ * large files should use the stdio CLI.
15
19
  *
16
20
  * Function URL event format: Lambda Function URLs emit API Gateway
17
21
  * HTTP v2 events (https://docs.aws.amazon.com/lambda/latest/dg/urls-invocation.html#urls-payloads).
18
22
  * Headers arrive lowercased.
19
- *
20
- * Note: `upload_media` is filtered out — the StorageClient flow needs
21
- * presigned S3 PUT URLs (the Lambda doesn't accept the binary body
22
- * itself), which lands in Phase 5.
23
23
  */
24
24
  interface FunctionUrlEvent {
25
25
  headers?: Record<string, string | undefined>;
@@ -6,10 +6,20 @@ 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-EGQ7Q3Y3.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";
13
23
  var POST_FIELDS = (
14
24
  /* GraphQL */
15
25
  `
@@ -24,10 +34,12 @@ var POST_FIELDS = (
24
34
  status
25
35
  publishedAt
26
36
  tags
37
+ metadata
27
38
  }
28
39
  `
29
40
  );
30
41
  function toCorePost(p) {
42
+ const metadata = decodeAwsJson(p.metadata);
31
43
  return {
32
44
  siteId: p.siteId,
33
45
  postId: p.postId,
@@ -38,7 +50,8 @@ function toCorePost(p) {
38
50
  body: decodeAwsJson(p.body),
39
51
  status: p.status ?? "draft",
40
52
  publishedAt: p.publishedAt ?? void 0,
41
- tags: (p.tags ?? []).filter((t) => typeof t === "string")
53
+ tags: (p.tags ?? []).filter((t) => typeof t === "string"),
54
+ metadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : void 0
42
55
  };
43
56
  }
44
57
  var QUERY = (
@@ -233,14 +246,29 @@ var createPostSchema = {
233
246
  postId: { type: "string", description: "Optional explicit id; auto-generated if omitted" },
234
247
  slug: { type: "string" },
235
248
  title: { type: "string" },
236
- format: { type: "string", enum: ["tiptap", "markdown", "html"] },
249
+ format: {
250
+ type: "string",
251
+ enum: ["tiptap", "markdown", "html"],
252
+ description: "tiptap = rich text JSON tree; markdown = source string (GFM extensions enabled); html = raw HTML string (rendered verbatim, no sanitization)."
253
+ },
237
254
  body: {
238
- description: "tiptap JSON (when format=tiptap), markdown source string, or raw HTML string"
255
+ description: "tiptap JSON (when format=tiptap), markdown source string (format=markdown), or raw HTML string (format=html)."
239
256
  },
240
257
  status: { type: "string", enum: ["draft", "published"], default: "draft" },
241
258
  excerpt: { type: "string" },
242
259
  publishedAt: { type: "string", description: "ISO 8601 timestamp; required when status=published" },
243
- tags: { type: "array", items: { type: "string" } }
260
+ tags: { type: "array", items: { type: "string" } },
261
+ metadata: {
262
+ type: "object",
263
+ 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.",
264
+ properties: {
265
+ no_layout: {
266
+ type: "boolean",
267
+ description: "Serve the post as bare HTML with no theme chrome. Only meaningful when format=html; ignored otherwise."
268
+ }
269
+ },
270
+ additionalProperties: true
271
+ }
244
272
  }
245
273
  };
246
274
  async function createPost(client, defaultSiteId, args) {
@@ -260,6 +288,7 @@ async function createPost(client, defaultSiteId, args) {
260
288
  status,
261
289
  publishedAt,
262
290
  tags: args.tags,
291
+ metadata: args.metadata !== void 0 ? encodeAwsJson(args.metadata) : void 0,
263
292
  // Denormalized GSI keys.
264
293
  siteIdStatus: composeSiteIdStatus(siteId, status),
265
294
  siteIdSlug: composeSiteIdSlug(siteId, args.slug)
@@ -289,11 +318,26 @@ var updatePostSchema = {
289
318
  slug: { type: "string" },
290
319
  title: { type: "string" },
291
320
  excerpt: { type: "string" },
292
- format: { type: "string", enum: ["tiptap", "markdown", "html"] },
293
- body: { description: "tiptap JSON, markdown source, or raw HTML" },
321
+ format: {
322
+ type: "string",
323
+ enum: ["tiptap", "markdown", "html"],
324
+ description: "tiptap = rich text JSON tree; markdown = source string; html = raw HTML string (no sanitization)."
325
+ },
326
+ body: { description: "tiptap JSON, markdown source, or raw HTML string" },
294
327
  status: { type: "string", enum: ["draft", "published"] },
295
328
  publishedAt: { type: "string", description: "ISO 8601 timestamp" },
296
- tags: { type: "array", items: { type: "string" } }
329
+ tags: { type: "array", items: { type: "string" } },
330
+ metadata: {
331
+ type: "object",
332
+ 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.",
333
+ properties: {
334
+ no_layout: {
335
+ type: "boolean",
336
+ description: "Serve the post as bare HTML with no theme chrome. Only meaningful when format=html; ignored otherwise."
337
+ }
338
+ },
339
+ additionalProperties: true
340
+ }
297
341
  }
298
342
  };
299
343
  async function updatePost(client, defaultSiteId, args) {
@@ -308,6 +352,7 @@ async function updatePost(client, defaultSiteId, args) {
308
352
  if (args.status !== void 0) input.status = args.status;
309
353
  if (args.publishedAt !== void 0) input.publishedAt = args.publishedAt;
310
354
  if (args.tags !== void 0) input.tags = args.tags;
355
+ if (args.metadata !== void 0) input.metadata = encodeAwsJson2(args.metadata);
311
356
  if (args.status !== void 0) {
312
357
  input.siteIdStatus = composeSiteIdStatus2(siteId, args.status);
313
358
  }
@@ -428,7 +473,11 @@ function getSchema() {
428
473
  },
429
474
  status: { type: "enum", values: ["draft", "published"], default: "draft" },
430
475
  publishedAt: { type: "datetime", description: "ISO 8601" },
431
- tags: { type: "string[]" }
476
+ tags: { type: "string[]" },
477
+ metadata: {
478
+ type: "json",
479
+ 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."
480
+ }
432
481
  }
433
482
  },
434
483
  {
@@ -461,7 +510,9 @@ function getSchema() {
461
510
  formats: ["tiptap", "markdown", "html"],
462
511
  notes: {
463
512
  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.'
513
+ tiptapBody: 'When format=tiptap, body is the tiptap document JSON: { type: "doc", content: [...] }. The renderer expects this shape.',
514
+ 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).",
515
+ 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 uploads currently only flow through the admin UI; the MCP `upload_media` tool writes to public/media/ and does not handle static bundles. Use the admin StaticUploader for now."
465
516
  }
466
517
  };
467
518
  }
@@ -480,13 +531,13 @@ var tools = [
480
531
  },
481
532
  {
482
533
  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.",
534
+ 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
535
  inputSchema: createPostSchema,
485
536
  handler: (args, ctx) => createPost(ctx.graphql, ctx.defaultSiteId, args)
486
537
  },
487
538
  {
488
539
  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.",
540
+ 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
541
  inputSchema: updatePostSchema,
491
542
  handler: (args, ctx) => updatePost(ctx.graphql, ctx.defaultSiteId, args)
492
543
  },
@@ -571,6 +622,26 @@ function createMcpGraphqlClient(opts) {
571
622
  };
572
623
  }
573
624
 
625
+ // src/functions/mcp-storage-client.ts
626
+ import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
627
+ import { formatPublicAssetUrl } from "ampless";
628
+ function createMcpStorageClient(opts) {
629
+ const client = new S3Client({ region: opts.region });
630
+ return {
631
+ async putObject(key, body, contentType) {
632
+ await client.send(
633
+ new PutObjectCommand({
634
+ Bucket: opts.bucket,
635
+ Key: key,
636
+ Body: body,
637
+ ContentType: contentType
638
+ })
639
+ );
640
+ return formatPublicAssetUrl(opts.bucket, opts.region, key);
641
+ }
642
+ };
643
+ }
644
+
574
645
  // src/functions/mcp-handler.ts
575
646
  var TOKENS_PK = "mcp-tokens";
576
647
  var JSON_RPC_PARSE_ERROR = -32700;
@@ -586,9 +657,10 @@ function requireEnv(name) {
586
657
  }
587
658
  var KV_TABLE = requireEnv("AMPLESS_KV_TABLE");
588
659
  var APPSYNC_URL = requireEnv("AMPLESS_APPSYNC_URL");
660
+ var BUCKET_NAME = requireEnv("AMPLESS_BUCKET_NAME");
589
661
  var AWS_REGION = process.env["AWS_REGION"] ?? "us-east-1";
590
662
  var ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
591
- var HTTP_TOOLS = tools.filter((t) => t.name !== "upload_media");
663
+ var HTTP_TOOLS = tools;
592
664
  function hashToken(plain) {
593
665
  return createHash("sha256").update(plain).digest("hex");
594
666
  }
@@ -636,12 +708,14 @@ function makeContext(meta) {
636
708
  if (cachedCtx && cachedCtx.defaultSiteId === (meta.scope.siteId ?? "default")) {
637
709
  return cachedCtx;
638
710
  }
711
+ let storageClient = null;
639
712
  const ctx = {
640
713
  graphql: createMcpGraphqlClient({ endpoint: APPSYNC_URL, region: AWS_REGION }),
641
714
  storage: () => {
642
- throw new Error(
643
- "upload_media is not available on the HTTP MCP transport in v0.2 Phase 4 \u2014 use the stdio CLI or wait for Phase 5"
644
- );
715
+ if (!storageClient) {
716
+ storageClient = createMcpStorageClient({ bucket: BUCKET_NAME, region: AWS_REGION });
717
+ }
718
+ return storageClient;
645
719
  },
646
720
  defaultSiteId: meta.scope.siteId ?? "default"
647
721
  };
package/dist/index.js CHANGED
@@ -182,6 +182,14 @@ function defineAmplessBackend(opts) {
182
182
  "AMPLESS_APPSYNC_URL",
183
183
  backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl
184
184
  );
185
+ mcpHandlerFn.addToRolePolicy(
186
+ new PolicyStatement({
187
+ effect: Effect.ALLOW,
188
+ actions: ["s3:PutObject"],
189
+ resources: [`${backend.storage.resources.bucket.bucketArn}/public/media/*`]
190
+ })
191
+ );
192
+ mcpHandlerFn.addEnvironment("AMPLESS_BUCKET_NAME", backend.storage.resources.bucket.bucketName);
185
193
  const mcpFunctionUrl = mcpHandlerFn.addFunctionUrl({
186
194
  authType: FunctionUrlAuthType.NONE,
187
195
  cors: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/backend",
3
- "version": "0.2.0-alpha.15",
3
+ "version": "0.2.0-alpha.17",
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",
@@ -65,7 +65,7 @@
65
65
  "@smithy/protocol-http": "^5.3.0",
66
66
  "@smithy/signature-v4": "^5.4.0",
67
67
  "ampless": "0.2.0-alpha.7",
68
- "@ampless/mcp-server": "0.2.0-alpha.7"
68
+ "@ampless/mcp-server": "0.2.0-alpha.8"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "@aws-amplify/backend": "^1",