@ampless/backend 1.0.0-alpha.30 → 1.0.0-alpha.31

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.
@@ -13,6 +13,7 @@ import {
13
13
  import {
14
14
  encodeAwsJson as encodeAwsJson2
15
15
  } from "ampless";
16
+ import { encodeAwsJson as encodeAwsJson3 } from "ampless";
16
17
  import {
17
18
  bundlePrefix,
18
19
  mimeTypeFor,
@@ -25,7 +26,7 @@ import {
25
26
  stripCommonPrefix
26
27
  } from "ampless";
27
28
  import {
28
- encodeAwsJson as encodeAwsJson3
29
+ encodeAwsJson as encodeAwsJson4
29
30
  } from "ampless";
30
31
  import {
31
32
  bundlePrefix as bundlePrefix2,
@@ -37,6 +38,7 @@ import {
37
38
  import { bundlePrefix as bundlePrefix3, validateBundlePath as validateBundlePath3 } from "ampless";
38
39
  import {
39
40
  bundlePrefix as bundlePrefix4,
41
+ mimeTypeFor as mimeTypeFor3,
40
42
  pickDefaultEntrypoint as pickDefaultEntrypoint2
41
43
  } from "ampless";
42
44
  var POST_FIELDS = (
@@ -327,6 +329,7 @@ var MUTATION4 = (
327
329
  mimeType
328
330
  size
329
331
  delivery
332
+ metadata
330
333
  }
331
334
  }
332
335
  `
@@ -349,7 +352,9 @@ var uploadMediaSchema = {
349
352
  async function uploadMedia(graphql, storage, args) {
350
353
  const body = Buffer.from(args.base64Data, "base64");
351
354
  const key = buildMediaKey(args.filename);
352
- const url = await storage.putObject(key, body, args.mimeType);
355
+ const putResult = await storage.putObject(key, body, args.mimeType);
356
+ const metadata = {};
357
+ if (putResult.etag) metadata.etag = putResult.etag;
353
358
  const mediaId = `media-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
354
359
  const data = await graphql.query(MUTATION4, {
355
360
  input: {
@@ -357,10 +362,11 @@ async function uploadMedia(graphql, storage, args) {
357
362
  src: key,
358
363
  mimeType: args.mimeType,
359
364
  size: body.length,
360
- delivery: "nextjs"
365
+ delivery: "nextjs",
366
+ metadata: encodeAwsJson3(metadata)
361
367
  }
362
368
  });
363
- return { media: data.createMedia, url };
369
+ return { media: data.createMedia, url: putResult.url };
364
370
  }
365
371
  var getSchemaSchema = {
366
372
  type: "object",
@@ -959,7 +965,7 @@ async function upsertStaticPost(graphql, slug, body, fields) {
959
965
  const input2 = {
960
966
  postId: existing.postId,
961
967
  format: "static",
962
- body: encodeAwsJson3(body)
968
+ body: encodeAwsJson4(body)
963
969
  };
964
970
  if (fields.title !== void 0) input2.title = fields.title;
965
971
  if (fields.excerpt !== void 0) input2.excerpt = fields.excerpt;
@@ -967,7 +973,7 @@ async function upsertStaticPost(graphql, slug, body, fields) {
967
973
  if (fields.publishedAt !== void 0) input2.publishedAt = fields.publishedAt;
968
974
  if (fields.tags !== void 0) input2.tags = fields.tags;
969
975
  if (fields.metadata !== void 0) {
970
- input2.metadata = encodeAwsJson3(fields.metadata);
976
+ input2.metadata = encodeAwsJson4(fields.metadata);
971
977
  }
972
978
  const data2 = await graphql.query(UPDATE_MUTATION, { input: input2 });
973
979
  const updated = toCorePost(data2.updatePost);
@@ -986,13 +992,13 @@ async function upsertStaticPost(graphql, slug, body, fields) {
986
992
  slug,
987
993
  title: fields.title,
988
994
  format: "static",
989
- body: encodeAwsJson3(body),
995
+ body: encodeAwsJson4(body),
990
996
  status
991
997
  };
992
998
  if (fields.excerpt !== void 0) input.excerpt = fields.excerpt;
993
999
  if (publishedAt !== void 0) input.publishedAt = publishedAt;
994
1000
  if (fields.tags !== void 0) input.tags = fields.tags;
995
- if (fields.metadata !== void 0) input.metadata = encodeAwsJson3(fields.metadata);
1001
+ if (fields.metadata !== void 0) input.metadata = encodeAwsJson4(fields.metadata);
996
1002
  const data = await graphql.query(CREATE_MUTATION, { input });
997
1003
  const created = toCorePost(data.createPost);
998
1004
  return { post: created, created: true };
@@ -1062,8 +1068,11 @@ async function uploadStaticBundle(graphql, storage, args) {
1062
1068
  await storage.deleteObject(obj.key);
1063
1069
  }
1064
1070
  let uploadedFiles = 0;
1071
+ const filesMeta = {};
1065
1072
  for (const f of files) {
1066
- await storage.putObject(`${prefix}${f.path}`, f.data, mimeTypeFor(f.path));
1073
+ const mimeType = mimeTypeFor(f.path);
1074
+ await storage.putObject(`${prefix}${f.path}`, f.data, mimeType);
1075
+ filesMeta[f.path] = { size: f.data.byteLength, mimeType };
1067
1076
  uploadedFiles += 1;
1068
1077
  }
1069
1078
  const body = {
@@ -1071,6 +1080,10 @@ async function uploadStaticBundle(graphql, storage, args) {
1071
1080
  files: files.map((f) => f.path).sort(),
1072
1081
  uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
1073
1082
  };
1083
+ const mergedMetadata = {
1084
+ ...args.metadata ?? {},
1085
+ files: filesMeta
1086
+ };
1074
1087
  const { post } = await upsertStaticPost(graphql, slug, body, {
1075
1088
  title: args.title,
1076
1089
  postId: args.postId,
@@ -1078,7 +1091,7 @@ async function uploadStaticBundle(graphql, storage, args) {
1078
1091
  publishedAt: args.publishedAt,
1079
1092
  excerpt: args.excerpt,
1080
1093
  tags: args.tags,
1081
- metadata: args.metadata
1094
+ metadata: mergedMetadata
1082
1095
  });
1083
1096
  return { post, bundle: body, uploadedFiles };
1084
1097
  }
@@ -1123,8 +1136,8 @@ async function uploadStaticFile(storage, args) {
1123
1136
  }
1124
1137
  const contentType = args.contentType ?? mimeTypeFor2(filename);
1125
1138
  const key = `${bundlePrefix2(args.slug)}${filename}`;
1126
- const url = await storage.putObject(key, body, contentType);
1127
- return { key, url, size: body.length };
1139
+ const { url } = await storage.putObject(key, body, contentType);
1140
+ return { key, url, size: body.length, contentType };
1128
1141
  }
1129
1142
  var deleteStaticFileSchema = {
1130
1143
  type: "object",
@@ -1189,7 +1202,15 @@ async function commitStaticPost(graphql, storage, args) {
1189
1202
  `commit_static_post: no files found under "${prefix}". Upload at least one file via upload_static_file or upload_static_bundle before committing.`
1190
1203
  );
1191
1204
  }
1192
- const relPaths = objects.map((o) => o.key.slice(prefix.length)).filter((p) => p !== "").sort();
1205
+ const filesMeta = {};
1206
+ const relPaths = [];
1207
+ for (const obj of objects) {
1208
+ const rel = obj.key.slice(prefix.length);
1209
+ if (rel === "") continue;
1210
+ relPaths.push(rel);
1211
+ filesMeta[rel] = { size: obj.size, mimeType: mimeTypeFor3(rel) };
1212
+ }
1213
+ relPaths.sort();
1193
1214
  const entrypoint = args.entrypoint ?? pickDefaultEntrypoint2(relPaths.map((p) => ({ path: p })));
1194
1215
  if (!relPaths.includes(entrypoint)) {
1195
1216
  throw new Error(
@@ -1201,6 +1222,10 @@ async function commitStaticPost(graphql, storage, args) {
1201
1222
  files: relPaths,
1202
1223
  uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
1203
1224
  };
1225
+ const mergedMetadata = {
1226
+ ...args.metadata ?? {},
1227
+ files: filesMeta
1228
+ };
1204
1229
  const { post, created } = await upsertStaticPost(graphql, slug, body, {
1205
1230
  title: args.title,
1206
1231
  postId: args.postId,
@@ -1208,7 +1233,7 @@ async function commitStaticPost(graphql, storage, args) {
1208
1233
  status: args.status,
1209
1234
  publishedAt: args.publishedAt,
1210
1235
  tags: args.tags,
1211
- metadata: args.metadata
1236
+ metadata: mergedMetadata
1212
1237
  });
1213
1238
  return { post, bundle: body, created };
1214
1239
  }
@@ -1371,7 +1396,7 @@ function createMcpStorageClient(opts) {
1371
1396
  const client = new S3Client({ region: opts.region });
1372
1397
  return {
1373
1398
  async putObject(key, body, contentType) {
1374
- await client.send(
1399
+ const res = await client.send(
1375
1400
  new PutObjectCommand({
1376
1401
  Bucket: opts.bucket,
1377
1402
  Key: key,
@@ -1379,7 +1404,11 @@ function createMcpStorageClient(opts) {
1379
1404
  ContentType: contentType
1380
1405
  })
1381
1406
  );
1382
- return formatPublicAssetUrl(opts.bucket, opts.region, key);
1407
+ const etag = res.ETag ? res.ETag.replace(/^"|"$/g, "") : void 0;
1408
+ return {
1409
+ url: formatPublicAssetUrl(opts.bucket, opts.region, key),
1410
+ etag
1411
+ };
1383
1412
  },
1384
1413
  async deleteObject(key) {
1385
1414
  await client.send(
package/dist/index.d.ts CHANGED
@@ -116,12 +116,13 @@ interface AmplessResolverPaths {
116
116
  listPublishedPosts: string;
117
117
  getPublishedPost: string;
118
118
  listPostsByTag: string;
119
+ getMediaBySrc: string;
119
120
  }
120
121
  /**
121
122
  * Default AppSync JS resolver paths. AppSync resolves these strings at
122
123
  * CDK synth time relative to the file that calls `defineData` — i.e.
123
- * the user's `amplify/data/resource.ts`. Templates ship the three
124
- * resolver files at exactly these paths.
124
+ * the user's `amplify/data/resource.ts`. Templates ship the resolver
125
+ * files at exactly these paths.
125
126
  *
126
127
  * Users with a non-default layout (e.g. moving the resolvers under a
127
128
  * `resolvers/` subdir) pass overrides through `amplessSchemaModels(a, {
@@ -176,9 +177,11 @@ declare function amplessSchemaModels(a: any, opts?: AmplessSchemaModelsOpts): {
176
177
  McpToken: any;
177
178
  PublicPost: any;
178
179
  PublicPostConnection: any;
180
+ PublicMedia: any;
179
181
  listPublishedPosts: any;
180
182
  getPublishedPost: any;
181
183
  listPostsByTag: any;
184
+ getMediaBySrc: any;
182
185
  };
183
186
  /**
184
187
  * Convenience helper: build a fully-instantiated `a.schema(...)`
package/dist/index.js CHANGED
@@ -264,7 +264,8 @@ function amplessStorageConfig() {
264
264
  var DEFAULT_RESOLVER_PATHS = {
265
265
  listPublishedPosts: "./list-published-posts.js",
266
266
  getPublishedPost: "./get-published-post.js",
267
- listPostsByTag: "./list-posts-by-tag.js"
267
+ listPostsByTag: "./list-posts-by-tag.js",
268
+ getMediaBySrc: "./get-media-by-src.js"
268
269
  };
269
270
  function amplessSchemaModels(a, opts = {}) {
270
271
  const resolverPaths = {
@@ -312,8 +313,14 @@ function amplessSchemaModels(a, opts = {}) {
312
313
  src: a.string().required(),
313
314
  mimeType: a.string().required(),
314
315
  size: a.integer(),
315
- delivery: a.string()
316
- }).identifier(["mediaId"]).authorization((allow) => [
316
+ delivery: a.string(),
317
+ // Free-form per-asset metadata (JSON). Currently used to
318
+ // memoise the S3 ETag for stream-back routes; future use
319
+ // for image dimensions, EXIF strip status, etc. Kept loose
320
+ // because the field is read by routes that only need a few
321
+ // hints (no schema-level commitment).
322
+ metadata: a.json()
323
+ }).identifier(["mediaId"]).secondaryIndexes((index) => [index("src").name("bySrc")]).authorization((allow) => [
317
324
  allow.groups(["ampless-admin", "ampless-editor"])
318
325
  ]),
319
326
  Taxonomy: a.model({
@@ -415,6 +422,18 @@ function amplessSchemaModels(a, opts = {}) {
415
422
  items: a.ref("PublicPost").array(),
416
423
  nextToken: a.string()
417
424
  }),
425
+ // Minimal Media projection for the public-facing `getMediaBySrc`
426
+ // query. Decoupling from the `Media` model lets the custom
427
+ // resolver bypass the model-level (admin/editor only) auth check
428
+ // on fields, and intentionally keeps `mediaId` / `delivery` /
429
+ // anything else off the wire — guests only need `size` /
430
+ // `mimeType` / `metadata` for the stream-back read path.
431
+ PublicMedia: a.customType({
432
+ src: a.string().required(),
433
+ size: a.integer(),
434
+ mimeType: a.string(),
435
+ metadata: a.json()
436
+ }),
418
437
  // Public read endpoints — guard against draft leakage via custom resolvers.
419
438
  // Custom handlers (`a.handler.custom`) only support apiKey / userPool /
420
439
  // lambda / group / owner auth. `allow.guest()` (Cognito Identity Pool
@@ -466,6 +485,28 @@ function amplessSchemaModels(a, opts = {}) {
466
485
  allow.publicApiKey(),
467
486
  allow.groups(["ampless-admin", "ampless-editor"])
468
487
  ]),
488
+ // Public Media lookup by S3 key. Called by the `/api/media/...`
489
+ // route handler so guests can resolve `{ size, mimeType, metadata }`
490
+ // and the route can stream the bytes back with the right headers
491
+ // (and skip a HEAD round-trip on cold reads). The custom JS
492
+ // resolver targets the Media table's `bySrc` GSI directly so the
493
+ // lookup is one O(1) Query.
494
+ //
495
+ // Auth: `allow.publicApiKey()` — same model as the post queries
496
+ // above. `a.handler.custom` doesn't accept `allow.guest()` in
497
+ // Amplify Gen 2, so the API key (auto-renewed every 365 days) is
498
+ // the standard public-read channel. The resolver returns only the
499
+ // narrow `PublicMedia` projection — no `mediaId` / `delivery` /
500
+ // anything else leaks to guests.
501
+ getMediaBySrc: a.query().arguments({ src: a.string().required() }).returns(a.ref("PublicMedia")).handler(
502
+ a.handler.custom({
503
+ dataSource: a.ref("Media"),
504
+ entry: resolverPaths.getMediaBySrc
505
+ })
506
+ ).authorization((allow) => [
507
+ allow.publicApiKey(),
508
+ allow.groups(["ampless-admin", "ampless-editor"])
509
+ ]),
469
510
  // User management ops, only wired when the caller supplies a
470
511
  // Lambda function ref. Conditionally spread because
471
512
  // `a.handler.function(undefined)` is not a valid call — projects
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/backend",
3
- "version": "1.0.0-alpha.30",
3
+ "version": "1.0.0-alpha.31",
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,8 +65,8 @@
65
65
  "@smithy/protocol-http": "^5.4.4",
66
66
  "@smithy/signature-v4": "^5.4.4",
67
67
  "fflate": "^0.8.3",
68
- "ampless": "1.0.0-alpha.15",
69
- "@ampless/mcp-server": "1.0.0-alpha.19"
68
+ "ampless": "1.0.0-alpha.16",
69
+ "@ampless/mcp-server": "1.0.0-alpha.20"
70
70
  },
71
71
  "peerDependencies": {
72
72
  "@aws-amplify/backend": "^1",