@se-studio/contentful-cms 3.0.3 → 3.1.1

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli/program.d.ts.map +1 -1
  3. package/dist/cli/program.js +2 -0
  4. package/dist/cli/program.js.map +1 -1
  5. package/dist/commands/asset.d.ts.map +1 -1
  6. package/dist/commands/asset.js +40 -7
  7. package/dist/commands/asset.js.map +1 -1
  8. package/dist/commands/host-scaffold.d.ts.map +1 -1
  9. package/dist/commands/host-scaffold.js +2 -0
  10. package/dist/commands/host-scaffold.js.map +1 -1
  11. package/dist/commands/staged-upload.d.ts +4 -0
  12. package/dist/commands/staged-upload.d.ts.map +1 -0
  13. package/dist/commands/staged-upload.js +54 -0
  14. package/dist/commands/staged-upload.js.map +1 -0
  15. package/dist/generated/hosted-guide.d.ts +1 -1
  16. package/dist/generated/hosted-guide.d.ts.map +1 -1
  17. package/dist/generated/hosted-guide.js +1 -1
  18. package/dist/generated/hosted-guide.js.map +1 -1
  19. package/dist/generated/version.d.ts +1 -1
  20. package/dist/generated/version.js +1 -1
  21. package/dist/hosted/index.d.ts +3 -0
  22. package/dist/hosted/index.d.ts.map +1 -1
  23. package/dist/hosted/index.js +3 -0
  24. package/dist/hosted/index.js.map +1 -1
  25. package/dist/hosted/next/redis.d.ts.map +1 -1
  26. package/dist/hosted/next/redis.js +4 -0
  27. package/dist/hosted/next/redis.js.map +1 -1
  28. package/dist/hosted/next/routes/mcp.d.ts.map +1 -1
  29. package/dist/hosted/next/routes/mcp.js +2 -0
  30. package/dist/hosted/next/routes/mcp.js.map +1 -1
  31. package/dist/hosted/next/routes/staged-upload.d.ts +2 -0
  32. package/dist/hosted/next/routes/staged-upload.d.ts.map +1 -0
  33. package/dist/hosted/next/routes/staged-upload.js +83 -0
  34. package/dist/hosted/next/routes/staged-upload.js.map +1 -0
  35. package/dist/mcp/http-handler.d.ts.map +1 -1
  36. package/dist/mcp/http-handler.js +19 -19
  37. package/dist/mcp/http-handler.js.map +1 -1
  38. package/dist/mcp/server.d.ts.map +1 -1
  39. package/dist/mcp/server.js +2 -0
  40. package/dist/mcp/server.js.map +1 -1
  41. package/dist/mcp/tools/cms-edit.d.ts +1 -1
  42. package/dist/mcp/tools/cms-edit.d.ts.map +1 -1
  43. package/dist/mcp/tools/cms-edit.js +3 -1
  44. package/dist/mcp/tools/cms-edit.js.map +1 -1
  45. package/dist/mcp/tools/staged-upload.d.ts +4 -0
  46. package/dist/mcp/tools/staged-upload.d.ts.map +1 -0
  47. package/dist/mcp/tools/staged-upload.js +77 -0
  48. package/dist/mcp/tools/staged-upload.js.map +1 -0
  49. package/dist/runtime/context.d.ts +3 -0
  50. package/dist/runtime/context.d.ts.map +1 -1
  51. package/dist/runtime/context.js.map +1 -1
  52. package/dist/session/session-store.d.ts +2 -0
  53. package/dist/session/session-store.d.ts.map +1 -1
  54. package/dist/session/session-store.js.map +1 -1
  55. package/dist/staged-upload/hmac.d.ts +6 -0
  56. package/dist/staged-upload/hmac.d.ts.map +1 -0
  57. package/dist/staged-upload/hmac.js +43 -0
  58. package/dist/staged-upload/hmac.js.map +1 -0
  59. package/dist/staged-upload/limits.d.ts +6 -0
  60. package/dist/staged-upload/limits.d.ts.map +1 -0
  61. package/dist/staged-upload/limits.js +9 -0
  62. package/dist/staged-upload/limits.js.map +1 -0
  63. package/dist/staged-upload/mime.d.ts +2 -0
  64. package/dist/staged-upload/mime.d.ts.map +1 -0
  65. package/dist/staged-upload/mime.js +10 -0
  66. package/dist/staged-upload/mime.js.map +1 -0
  67. package/dist/staged-upload/request.d.ts +11 -0
  68. package/dist/staged-upload/request.d.ts.map +1 -0
  69. package/dist/staged-upload/request.js +40 -0
  70. package/dist/staged-upload/request.js.map +1 -0
  71. package/dist/staged-upload/store.d.ts +19 -0
  72. package/dist/staged-upload/store.d.ts.map +1 -0
  73. package/dist/staged-upload/store.js +124 -0
  74. package/dist/staged-upload/store.js.map +1 -0
  75. package/dist/staged-upload/types.d.ts +27 -0
  76. package/dist/staged-upload/types.d.ts.map +1 -0
  77. package/dist/staged-upload/types.js +2 -0
  78. package/dist/staged-upload/types.js.map +1 -0
  79. package/docs/editor-tasks/media-reuse-and-upload.md +16 -9
  80. package/docs/mcp/hosted-guide.md +18 -5
  81. package/package.json +1 -1
  82. package/skills/core/SKILL.md +4 -1
@@ -0,0 +1,43 @@
1
+ import * as crypto from 'node:crypto';
2
+ export function getUploadHmacSecret() {
3
+ const value = process.env['CMS_EDIT_UPLOAD_HMAC_SECRET']?.trim();
4
+ if (!value) {
5
+ throw new Error('Missing required environment variable: CMS_EDIT_UPLOAD_HMAC_SECRET');
6
+ }
7
+ return value;
8
+ }
9
+ export function buildUploadSignaturePayload(uploadId, userId, exp) {
10
+ return `${uploadId}:${userId}:${String(exp)}`;
11
+ }
12
+ export function signUploadUrl(uploadId, userId, exp, secret) {
13
+ const payload = buildUploadSignaturePayload(uploadId, userId, exp);
14
+ return crypto.createHmac('sha256', secret).update(payload).digest('hex');
15
+ }
16
+ export function verifyUploadSignature(uploadId, userId, exp, sig, secret) {
17
+ if (!sig || !uploadId || !userId) {
18
+ return false;
19
+ }
20
+ const expected = signUploadUrl(uploadId, userId, exp, secret);
21
+ try {
22
+ const expectedBuf = Buffer.from(expected, 'hex');
23
+ const actualBuf = Buffer.from(sig, 'hex');
24
+ if (expectedBuf.length !== actualBuf.length) {
25
+ return false;
26
+ }
27
+ return crypto.timingSafeEqual(expectedBuf, actualBuf);
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ export function buildStagedUploadUrl(origin, uploadId, userId, exp, secret) {
34
+ const sig = signUploadUrl(uploadId, userId, exp, secret);
35
+ const params = new URLSearchParams({
36
+ id: uploadId,
37
+ uid: userId,
38
+ exp: String(exp),
39
+ sig,
40
+ });
41
+ return `${origin.replace(/\/$/, '')}/api/staged-upload?${params.toString()}`;
42
+ }
43
+ //# sourceMappingURL=hmac.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hmac.js","sourceRoot":"","sources":["../../src/staged-upload/hmac.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AAEtC,MAAM,UAAU,mBAAmB;IACjC,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,6BAA6B,CAAC,EAAE,IAAI,EAAE,CAAC;IACjE,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CAAC,oEAAoE,CAAC,CAAC;IACxF,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,2BAA2B,CAAC,QAAgB,EAAE,MAAc,EAAE,GAAW;IACvF,OAAO,GAAG,QAAQ,IAAI,MAAM,IAAI,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;AAChD,CAAC;AAED,MAAM,UAAU,aAAa,CAC3B,QAAgB,EAChB,MAAc,EACd,GAAW,EACX,MAAc;IAEd,MAAM,OAAO,GAAG,2BAA2B,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,CAAC,CAAC;IACnE,OAAO,MAAM,CAAC,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC3E,CAAC;AAED,MAAM,UAAU,qBAAqB,CACnC,QAAgB,EAChB,MAAc,EACd,GAAW,EACX,GAAW,EACX,MAAc;IAEd,IAAI,CAAC,GAAG,IAAI,CAAC,QAAQ,IAAI,CAAC,MAAM,EAAE,CAAC;QACjC,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,QAAQ,GAAG,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC;IAC9D,IAAI,CAAC;QACH,MAAM,WAAW,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;QACjD,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QAC1C,IAAI,WAAW,CAAC,MAAM,KAAK,SAAS,CAAC,MAAM,EAAE,CAAC;YAC5C,OAAO,KAAK,CAAC;QACf,CAAC;QACD,OAAO,MAAM,CAAC,eAAe,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;IACxD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,MAAc,EACd,QAAgB,EAChB,MAAc,EACd,GAAW,EACX,MAAc;IAEd,MAAM,GAAG,GAAG,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC;IACzD,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC;QACjC,EAAE,EAAE,QAAQ;QACZ,GAAG,EAAE,MAAM;QACX,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC;QAChB,GAAG;KACJ,CAAC,CAAC;IACH,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,sBAAsB,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC;AAC/E,CAAC"}
@@ -0,0 +1,6 @@
1
+ /** Hosted Vercel body limit — keep under platform ~4.5 MB ceiling. */
2
+ export declare const HOSTED_STAGED_MAX_BYTES: number;
3
+ export declare const STAGED_UPLOAD_TTL_SECONDS: number;
4
+ export declare const STAGED_UPLOAD_RATE_LIMIT_PER_HOUR = 30;
5
+ export declare function hostedStagedMaxBytes(): number;
6
+ //# sourceMappingURL=limits.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"limits.d.ts","sourceRoot":"","sources":["../../src/staged-upload/limits.ts"],"names":[],"mappings":"AAEA,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,QAAkB,CAAC;AAEvD,eAAO,MAAM,yBAAyB,QAAU,CAAC;AAEjD,eAAO,MAAM,iCAAiC,KAAK,CAAC;AAEpD,wBAAgB,oBAAoB,IAAI,MAAM,CAE7C"}
@@ -0,0 +1,9 @@
1
+ import { MAX_UPLOAD_BYTES } from '../contentful/upload-limits.js';
2
+ /** Hosted Vercel body limit — keep under platform ~4.5 MB ceiling. */
3
+ export const HOSTED_STAGED_MAX_BYTES = 4 * 1024 * 1024;
4
+ export const STAGED_UPLOAD_TTL_SECONDS = 15 * 60;
5
+ export const STAGED_UPLOAD_RATE_LIMIT_PER_HOUR = 30;
6
+ export function hostedStagedMaxBytes() {
7
+ return Math.min(MAX_UPLOAD_BYTES, HOSTED_STAGED_MAX_BYTES);
8
+ }
9
+ //# sourceMappingURL=limits.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"limits.js","sourceRoot":"","sources":["../../src/staged-upload/limits.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AAElE,sEAAsE;AACtE,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;AAEvD,MAAM,CAAC,MAAM,yBAAyB,GAAG,EAAE,GAAG,EAAE,CAAC;AAEjD,MAAM,CAAC,MAAM,iCAAiC,GAAG,EAAE,CAAC;AAEpD,MAAM,UAAU,oBAAoB;IAClC,OAAO,IAAI,CAAC,GAAG,CAAC,gBAAgB,EAAE,uBAAuB,CAAC,CAAC;AAC7D,CAAC"}
@@ -0,0 +1,2 @@
1
+ export declare function isAllowedStagedMimeType(mimeType: string): boolean;
2
+ //# sourceMappingURL=mime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mime.d.ts","sourceRoot":"","sources":["../../src/staged-upload/mime.ts"],"names":[],"mappings":"AAAA,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAUjE"}
@@ -0,0 +1,10 @@
1
+ export function isAllowedStagedMimeType(mimeType) {
2
+ const normalized = mimeType.trim().toLowerCase();
3
+ if (!normalized) {
4
+ return false;
5
+ }
6
+ return (normalized.startsWith('image/') ||
7
+ normalized.startsWith('video/') ||
8
+ normalized === 'application/pdf');
9
+ }
10
+ //# sourceMappingURL=mime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mime.js","sourceRoot":"","sources":["../../src/staged-upload/mime.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,uBAAuB,CAAC,QAAgB;IACtD,MAAM,UAAU,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACjD,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,CACL,UAAU,CAAC,UAAU,CAAC,QAAQ,CAAC;QAC/B,UAAU,CAAC,UAAU,CAAC,QAAQ,CAAC;QAC/B,UAAU,KAAK,iBAAiB,CACjC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,11 @@
1
+ import type { StagedUploadStore } from './store.js';
2
+ import type { StagedUploadRequestResult } from './types.js';
3
+ export interface CreateStagedUploadRequestParams {
4
+ userId: string;
5
+ fileName: string;
6
+ mimeType: string;
7
+ byteLength?: number;
8
+ title?: string;
9
+ }
10
+ export declare function createStagedUploadRequest(store: StagedUploadStore, params: CreateStagedUploadRequestParams): Promise<StagedUploadRequestResult>;
11
+ //# sourceMappingURL=request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../../src/staged-upload/request.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACpD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,YAAY,CAAC;AAE5D,MAAM,WAAW,+BAA+B;IAC9C,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAMD,wBAAsB,yBAAyB,CAC7C,KAAK,EAAE,iBAAiB,EACxB,MAAM,EAAE,+BAA+B,GACtC,OAAO,CAAC,yBAAyB,CAAC,CAoCpC"}
@@ -0,0 +1,40 @@
1
+ import { getPublicOrigin } from '../hosted/oauth/config.js';
2
+ import { buildStagedUploadUrl, getUploadHmacSecret } from './hmac.js';
3
+ import { hostedStagedMaxBytes, STAGED_UPLOAD_TTL_SECONDS } from './limits.js';
4
+ function shellQuote(value) {
5
+ return `'${value.replace(/'/g, `'\\''`)}'`;
6
+ }
7
+ export async function createStagedUploadRequest(store, params) {
8
+ const uploadId = await store.createPending({
9
+ userId: params.userId,
10
+ fileName: params.fileName,
11
+ mimeType: params.mimeType,
12
+ byteLength: params.byteLength,
13
+ });
14
+ const secret = getUploadHmacSecret();
15
+ const origin = getPublicOrigin();
16
+ const exp = Math.floor(Date.now() / 1000) + STAGED_UPLOAD_TTL_SECONDS;
17
+ const uploadUrl = buildStagedUploadUrl(origin, uploadId, params.userId, exp, secret);
18
+ const consumeArgs = [
19
+ 'asset',
20
+ 'upload',
21
+ '--staged',
22
+ uploadId,
23
+ '--mime',
24
+ params.mimeType,
25
+ '--file-name',
26
+ params.fileName,
27
+ ...(params.title ? ['--title', params.title] : []),
28
+ ];
29
+ const curlCommand = `curl -f -X POST -F ${shellQuote(`file=@/path/to/${params.fileName}`)} ${shellQuote(uploadUrl)}`;
30
+ return {
31
+ uploadId,
32
+ uploadUrl,
33
+ expiresIn: STAGED_UPLOAD_TTL_SECONDS,
34
+ expiresAt: exp,
35
+ maxBytes: hostedStagedMaxBytes(),
36
+ curlCommand,
37
+ consumeArgs,
38
+ };
39
+ }
40
+ //# sourceMappingURL=request.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.js","sourceRoot":"","sources":["../../src/staged-upload/request.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AACtE,OAAO,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AAY9E,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC;AAC7C,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,KAAwB,EACxB,MAAuC;IAEvC,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,aAAa,CAAC;QACzC,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,UAAU,EAAE,MAAM,CAAC,UAAU;KAC9B,CAAC,CAAC;IAEH,MAAM,MAAM,GAAG,mBAAmB,EAAE,CAAC;IACrC,MAAM,MAAM,GAAG,eAAe,EAAE,CAAC;IACjC,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,GAAG,yBAAyB,CAAC;IACtE,MAAM,SAAS,GAAG,oBAAoB,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC;IAErF,MAAM,WAAW,GAAG;QAClB,OAAO;QACP,QAAQ;QACR,UAAU;QACV,QAAQ;QACR,QAAQ;QACR,MAAM,CAAC,QAAQ;QACf,aAAa;QACb,MAAM,CAAC,QAAQ;QACf,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;KACnD,CAAC;IAEF,MAAM,WAAW,GAAG,sBAAsB,UAAU,CAAC,kBAAkB,MAAM,CAAC,QAAQ,EAAE,CAAC,IAAI,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;IAErH,OAAO;QACL,QAAQ;QACR,SAAS;QACT,SAAS,EAAE,yBAAyB;QACpC,SAAS,EAAE,GAAG;QACd,QAAQ,EAAE,oBAAoB,EAAE;QAChC,WAAW;QACX,WAAW;KACZ,CAAC;AACJ,CAAC"}
@@ -0,0 +1,19 @@
1
+ import type { RedisClient } from '../session/session-store.js';
2
+ import type { StagedUploadConsumeResult, StagedUploadRecord } from './types.js';
3
+ export declare class StagedUploadStore {
4
+ private readonly redis;
5
+ private readonly keyPrefix;
6
+ constructor(redis: RedisClient, keyPrefix?: string);
7
+ private fullKey;
8
+ assertRateLimit(userId: string): Promise<void>;
9
+ createPending(params: {
10
+ userId: string;
11
+ fileName: string;
12
+ mimeType: string;
13
+ byteLength?: number;
14
+ }): Promise<string>;
15
+ storeFile(userId: string, uploadId: string, buffer: Buffer, mimeType: string): Promise<StagedUploadRecord>;
16
+ consume(userId: string, uploadId: string): Promise<StagedUploadConsumeResult>;
17
+ getRecord(userId: string, uploadId: string): Promise<StagedUploadRecord | null>;
18
+ }
19
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/staged-upload/store.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAO/D,OAAO,KAAK,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AA0BhF,qBAAa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBADT,KAAK,EAAE,WAAW,EAClB,SAAS,SAAa;IAGzC,OAAO,CAAC,OAAO;IAIT,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAc9C,aAAa,CAAC,MAAM,EAAE;QAC1B,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,MAAM,CAAC;IAqCb,SAAS,CACb,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,kBAAkB,CAAC;IAgCxB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,yBAAyB,CAAC;IAuB7E,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;CAGtF"}
@@ -0,0 +1,124 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { assertUploadBufferSize } from '../contentful/upload-limits.js';
3
+ import { hostedStagedMaxBytes, STAGED_UPLOAD_RATE_LIMIT_PER_HOUR, STAGED_UPLOAD_TTL_SECONDS, } from './limits.js';
4
+ import { isAllowedStagedMimeType } from './mime.js';
5
+ const KEY_PREFIX = 'cms-edit:staged:';
6
+ const RATE_PREFIX = 'cms-edit:staged-rate:';
7
+ function rateKey(userId, hourBucket) {
8
+ return `${RATE_PREFIX}${userId}:${String(hourBucket)}`;
9
+ }
10
+ function parseRecord(raw) {
11
+ if (!raw) {
12
+ return null;
13
+ }
14
+ if (typeof raw === 'string') {
15
+ try {
16
+ return JSON.parse(raw);
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ if (typeof raw === 'object') {
23
+ return raw;
24
+ }
25
+ return null;
26
+ }
27
+ export class StagedUploadStore {
28
+ redis;
29
+ keyPrefix;
30
+ constructor(redis, keyPrefix = KEY_PREFIX) {
31
+ this.redis = redis;
32
+ this.keyPrefix = keyPrefix;
33
+ }
34
+ fullKey(userId, uploadId) {
35
+ return `${this.keyPrefix}${userId}:${uploadId}`;
36
+ }
37
+ async assertRateLimit(userId) {
38
+ const hourBucket = Math.floor(Date.now() / (60 * 60 * 1000));
39
+ const key = rateKey(userId, hourBucket);
40
+ const count = await this.redis.incr(key);
41
+ if (count === 1) {
42
+ await this.redis.expire(key, 60 * 60);
43
+ }
44
+ if (count > STAGED_UPLOAD_RATE_LIMIT_PER_HOUR) {
45
+ throw new Error(`Staged upload rate limit exceeded (${String(STAGED_UPLOAD_RATE_LIMIT_PER_HOUR)} per hour).`);
46
+ }
47
+ }
48
+ async createPending(params) {
49
+ if (!isAllowedStagedMimeType(params.mimeType)) {
50
+ throw new Error(`MIME type not allowed for staged upload: ${params.mimeType}`);
51
+ }
52
+ if (params.byteLength !== undefined && params.byteLength > hostedStagedMaxBytes()) {
53
+ throw new Error(`Declared byteLength exceeds hosted staged upload limit (${String(hostedStagedMaxBytes())} bytes).`);
54
+ }
55
+ await this.assertRateLimit(params.userId);
56
+ const uploadId = crypto.randomUUID().replace(/-/g, '');
57
+ const record = {
58
+ uploadId,
59
+ userId: params.userId,
60
+ fileName: params.fileName,
61
+ mimeType: params.mimeType,
62
+ byteLength: params.byteLength,
63
+ status: 'pending',
64
+ createdAt: Date.now(),
65
+ };
66
+ const set = await this.redis.set(this.fullKey(params.userId, uploadId), JSON.stringify(record), {
67
+ ex: STAGED_UPLOAD_TTL_SECONDS,
68
+ nx: true,
69
+ });
70
+ if (set !== true) {
71
+ throw new Error('Failed to reserve staged upload slot.');
72
+ }
73
+ return uploadId;
74
+ }
75
+ async storeFile(userId, uploadId, buffer, mimeType) {
76
+ assertUploadBufferSize(buffer);
77
+ if (buffer.length > hostedStagedMaxBytes()) {
78
+ throw new Error(`File exceeds hosted staged upload limit (${String(hostedStagedMaxBytes())} bytes).`);
79
+ }
80
+ if (!isAllowedStagedMimeType(mimeType)) {
81
+ throw new Error(`MIME type not allowed for staged upload: ${mimeType}`);
82
+ }
83
+ const key = this.fullKey(userId, uploadId);
84
+ const existing = parseRecord(await this.redis.get(key));
85
+ if (!existing || existing.userId !== userId || existing.uploadId !== uploadId) {
86
+ throw new Error('Staged upload slot not found or expired.');
87
+ }
88
+ if (existing.status === 'ready') {
89
+ throw new Error('Staged upload already received.');
90
+ }
91
+ const record = {
92
+ ...existing,
93
+ status: 'ready',
94
+ mimeType,
95
+ byteLength: buffer.length,
96
+ dataBase64: buffer.toString('base64'),
97
+ };
98
+ await this.redis.set(key, JSON.stringify(record), { ex: STAGED_UPLOAD_TTL_SECONDS });
99
+ return record;
100
+ }
101
+ async consume(userId, uploadId) {
102
+ const key = this.fullKey(userId, uploadId);
103
+ const existing = parseRecord(await this.redis.get(key));
104
+ if (!existing || existing.userId !== userId || existing.uploadId !== uploadId) {
105
+ throw new Error('Staged upload not found or expired.');
106
+ }
107
+ if (existing.status !== 'ready' || !existing.dataBase64) {
108
+ throw new Error('Staged upload has no file data yet. POST the file to uploadUrl first.');
109
+ }
110
+ await this.redis.del(key);
111
+ const buffer = Buffer.from(existing.dataBase64, 'base64');
112
+ assertUploadBufferSize(buffer);
113
+ return {
114
+ buffer,
115
+ fileName: existing.fileName,
116
+ mimeType: existing.mimeType,
117
+ byteLength: buffer.length,
118
+ };
119
+ }
120
+ async getRecord(userId, uploadId) {
121
+ return parseRecord(await this.redis.get(this.fullKey(userId, uploadId)));
122
+ }
123
+ }
124
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/staged-upload/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AACtC,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AAExE,OAAO,EACL,oBAAoB,EACpB,iCAAiC,EACjC,yBAAyB,GAC1B,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAC;AAGpD,MAAM,UAAU,GAAG,kBAAkB,CAAC;AACtC,MAAM,WAAW,GAAG,uBAAuB,CAAC;AAE5C,SAAS,OAAO,CAAC,MAAc,EAAE,UAAkB;IACjD,OAAO,GAAG,WAAW,GAAG,MAAM,IAAI,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC;AACzD,CAAC;AAED,SAAS,WAAW,CAAC,GAAY;IAC/B,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;QAC5B,IAAI,CAAC;YACH,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAuB,CAAC;QAC/C,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IACD,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;QAC5B,OAAO,GAAyB,CAAC;IACnC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,OAAO,iBAAiB;IAET;IACA;IAFnB,YACmB,KAAkB,EAClB,YAAY,UAAU;QADtB,UAAK,GAAL,KAAK,CAAa;QAClB,cAAS,GAAT,SAAS,CAAa;IACtC,CAAC;IAEI,OAAO,CAAC,MAAc,EAAE,QAAgB;QAC9C,OAAO,GAAG,IAAI,CAAC,SAAS,GAAG,MAAM,IAAI,QAAQ,EAAE,CAAC;IAClD,CAAC;IAED,KAAK,CAAC,eAAe,CAAC,MAAc;QAClC,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC;QAC7D,MAAM,GAAG,GAAG,OAAO,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;QACxC,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACzC,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;YAChB,MAAM,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;QACxC,CAAC;QACD,IAAI,KAAK,GAAG,iCAAiC,EAAE,CAAC;YAC9C,MAAM,IAAI,KAAK,CACb,sCAAsC,MAAM,CAAC,iCAAiC,CAAC,aAAa,CAC7F,CAAC;QACJ,CAAC;IACH,CAAC;IAED,KAAK,CAAC,aAAa,CAAC,MAKnB;QACC,IAAI,CAAC,uBAAuB,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC9C,MAAM,IAAI,KAAK,CAAC,4CAA4C,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC;QACjF,CAAC;QACD,IAAI,MAAM,CAAC,UAAU,KAAK,SAAS,IAAI,MAAM,CAAC,UAAU,GAAG,oBAAoB,EAAE,EAAE,CAAC;YAClF,MAAM,IAAI,KAAK,CACb,2DAA2D,MAAM,CAAC,oBAAoB,EAAE,CAAC,UAAU,CACpG,CAAC;QACJ,CAAC;QAED,MAAM,IAAI,CAAC,eAAe,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAE1C,MAAM,QAAQ,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QACvD,MAAM,MAAM,GAAuB;YACjC,QAAQ;YACR,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;YACzB,QAAQ,EAAE,MAAM,CAAC,QAAQ;YACzB,UAAU,EAAE,MAAM,CAAC,UAAU;YAC7B,MAAM,EAAE,SAAS;YACjB,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE;SACtB,CAAC;QAEF,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAC9B,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,EACrC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EACtB;YACE,EAAE,EAAE,yBAAyB;YAC7B,EAAE,EAAE,IAAI;SACT,CACF,CAAC;QACF,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;YACjB,MAAM,IAAI,KAAK,CAAC,uCAAuC,CAAC,CAAC;QAC3D,CAAC;QACD,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,KAAK,CAAC,SAAS,CACb,MAAc,EACd,QAAgB,EAChB,MAAc,EACd,QAAgB;QAEhB,sBAAsB,CAAC,MAAM,CAAC,CAAC;QAC/B,IAAI,MAAM,CAAC,MAAM,GAAG,oBAAoB,EAAE,EAAE,CAAC;YAC3C,MAAM,IAAI,KAAK,CACb,4CAA4C,MAAM,CAAC,oBAAoB,EAAE,CAAC,UAAU,CACrF,CAAC;QACJ,CAAC;QACD,IAAI,CAAC,uBAAuB,CAAC,QAAQ,CAAC,EAAE,CAAC;YACvC,MAAM,IAAI,KAAK,CAAC,4CAA4C,QAAQ,EAAE,CAAC,CAAC;QAC1E,CAAC;QAED,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;QAC3C,MAAM,QAAQ,GAAG,WAAW,CAAC,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;QACxD,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,MAAM,KAAK,MAAM,IAAI,QAAQ,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAC9E,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAC;QAC9D,CAAC;QACD,IAAI,QAAQ,CAAC,MAAM,KAAK,OAAO,EAAE,CAAC;YAChC,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC;QACrD,CAAC;QAED,MAAM,MAAM,GAAuB;YACjC,GAAG,QAAQ;YACX,MAAM,EAAE,OAAO;YACf,QAAQ;YACR,UAAU,EAAE,MAAM,CAAC,MAAM;YACzB,UAAU,EAAE,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC;SACtC,CAAC;QAEF,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,yBAAyB,EAAE,CAAC,CAAC;QACrF,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,MAAc,EAAE,QAAgB;QAC5C,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;QAC3C,MAAM,QAAQ,GAAG,WAAW,CAAC,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;QACxD,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,MAAM,KAAK,MAAM,IAAI,QAAQ,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAC9E,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QACzD,CAAC;QACD,IAAI,QAAQ,CAAC,MAAM,KAAK,OAAO,IAAI,CAAC,QAAQ,CAAC,UAAU,EAAE,CAAC;YACxD,MAAM,IAAI,KAAK,CAAC,uEAAuE,CAAC,CAAC;QAC3F,CAAC;QAED,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAE1B,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;QAC1D,sBAAsB,CAAC,MAAM,CAAC,CAAC;QAE/B,OAAO;YACL,MAAM;YACN,QAAQ,EAAE,QAAQ,CAAC,QAAQ;YAC3B,QAAQ,EAAE,QAAQ,CAAC,QAAQ;YAC3B,UAAU,EAAE,MAAM,CAAC,MAAM;SAC1B,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,MAAc,EAAE,QAAgB;QAC9C,OAAO,WAAW,CAAC,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC;IAC3E,CAAC;CACF"}
@@ -0,0 +1,27 @@
1
+ export type StagedUploadStatus = 'pending' | 'ready';
2
+ export interface StagedUploadRecord {
3
+ uploadId: string;
4
+ userId: string;
5
+ fileName: string;
6
+ mimeType: string;
7
+ byteLength?: number;
8
+ status: StagedUploadStatus;
9
+ dataBase64?: string;
10
+ createdAt: number;
11
+ }
12
+ export interface StagedUploadRequestResult {
13
+ uploadId: string;
14
+ uploadUrl: string;
15
+ expiresIn: number;
16
+ expiresAt: number;
17
+ maxBytes: number;
18
+ curlCommand: string;
19
+ consumeArgs: string[];
20
+ }
21
+ export interface StagedUploadConsumeResult {
22
+ buffer: Buffer;
23
+ fileName: string;
24
+ mimeType: string;
25
+ byteLength: number;
26
+ }
27
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/staged-upload/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,kBAAkB,GAAG,SAAS,GAAG,OAAO,CAAC;AAErD,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,yBAAyB;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,MAAM,WAAW,yBAAyB;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/staged-upload/types.ts"],"names":[],"mappings":""}
@@ -14,7 +14,7 @@
14
14
  ## Prerequisites
15
15
 
16
16
  - **Always run `index sync` first** if the catalog may be stale
17
- - On **hosted MCP**, the server has **no filesystem** — use `--base64` or `--url` only (never a local file path in `args`)
17
+ - On **hosted MCP**, the server has **no filesystem** — use **staged upload**, `--base64`, or `--url` (never a local file path in `args`)
18
18
 
19
19
  ## Reuse-first workflow (default)
20
20
 
@@ -29,21 +29,28 @@
29
29
 
30
30
  ### Hosted MCP (default)
31
31
 
32
- **Prefer `--base64`** with the **actual encoded file bytes** in the `args` array:
32
+ **Prefer staged upload** for files larger than ~500 KB or when base64 in `cms_edit` args fails (agent harness message limits):
33
33
 
34
- ```
35
- cms_edit ["asset", "upload", "--base64", "<encoded-bytes>", "--mime", "image/png", "--file-name", "headshot-name-1200.png", "--title", "Headshot — Name", "--alt", "Descriptive alt text"]
36
- ```
34
+ 1. `cms_edit_request_staged_upload` with `fileName`, `mimeType`, optional `byteLength`, optional `title`
35
+ 2. Run the returned `curlCommand` in a shell (POST file to `uploadUrl`)
36
+ 3. `cms_edit` with the returned `consumeArgs` (e.g. `["asset","upload","--staged","<uploadId>",…]`)
37
37
 
38
38
  Rules:
39
39
 
40
- - **`--mime` and `--file-name` are required** with `--base64`
41
- - Pass **raw base64 text** — not a filesystem path, not `file:///…`, not a placeholder
40
+ - Staged upload keeps binary **out of MCP args** — only a short `uploadId` is passed to Contentful
41
+ - Hosted limit: **4 MB** per file; TTL **15 minutes**; single-use
42
42
  - **PNG** when the source has transparency (portraits on brand-colour cards); JPEG flattens alpha
43
- - **Stress-tested:** ~2 MB PNG (~2.8 MB base64) uploads successfully on hosted `pedestal-cms-edit` (Jun 2026)
44
43
  - Upload alone does **not** attach the asset — follow with **link + save** (below)
45
44
 
46
- **Fallback `--url`** only when base64 is impractical (agent message limits, very large files):
45
+ **Small files:** `--base64` with actual encoded bytes in `args` when the harness accepts the payload:
46
+
47
+ ```
48
+ cms_edit ["asset", "upload", "--base64", "<encoded-bytes>", "--mime", "image/png", "--file-name", "icon.png", "--title", "…"]
49
+ ```
50
+
51
+ Pass **raw base64 text** — not a filesystem path, not `file:///…`.
52
+
53
+ **Last resort `--url`** only when staged upload and base64 are impractical:
47
54
 
48
55
  ```
49
56
  cms_edit ["asset", "upload", "--url", "https://<public-https-url>/file.png", "--mime", "image/png", "--file-name", "headshot-name-1200.png", "--title", "…"]
@@ -20,6 +20,7 @@ All `save` operations create **drafts only**. A human must review and publish in
20
20
  | Tool | Purpose |
21
21
  |------|---------|
22
22
  | `cms_edit` | Run any cms-edit command via an `args` array |
23
+ | `cms_edit_request_staged_upload` | Get a presigned upload URL for large files (then `curl` + `asset upload --staged`) |
23
24
  | `cms_edit_version` | Check the installed cms-edit version (no session required) |
24
25
  | `cms_edit_help` | Read this guide by topic (`overview`, `workflow`, `commands`, `troubleshooting`, `hosted`) |
25
26
 
@@ -136,19 +137,31 @@ cms-edit saves **drafts only**. A colleague must **publish in Contentful** for c
136
137
 
137
138
  Hosted connectors have **no server filesystem**. Use one source per upload:
138
139
 
139
- | Source | Example |
140
- |--------|---------|
141
- | **`--base64`** (preferred) | `["asset", "upload", "--base64", "<bytes>", "--mime", "image/png", "--file-name", "photo.png", "--title", "…"]` |
142
- | **`--url`** (fallback) | `["asset", "upload", "--url", "https://…/photo.png", "--mime", "image/png", "--file-name", "photo.png"]` |
140
+ | Source | When | Example |
141
+ |--------|------|---------|
142
+ | **Staged upload** (preferred for files &gt;~500 KB) | Agent harness cannot pass large base64 in `cms_edit` args | See **Staged upload** below |
143
+ | **`--base64`** | Small files when inline base64 works | `["asset", "upload", "--base64", "<bytes>", "--mime", "image/png", "--file-name", "photo.png"]` |
144
+ | **`--url`** (last resort) | Public HTTPS URL fetchable by the host | `["asset", "upload", "--url", "https://…/photo.png", "--mime", "image/png", "--file-name", "photo.png"]` |
143
145
 
144
146
  **Required with `--base64`:** `--mime`, `--file-name`. Pass **encoded bytes** in `args` — never a local path (`./file.png`, `file:///tmp/…`) or placeholder string.
145
147
 
146
148
  **After upload:** note the returned asset `id`, attach to the entry (`asset set @root media <id>` for person portraits), then `diff` → `save`. Upload alone does not update entries.
147
149
 
148
- **PNG** when transparency matters. Verified on production hosts: ~2 MB PNG (~2.8 MB base64) succeeds; very large files may need `--url` from public HTTPS.
150
+ **PNG** when transparency matters.
149
151
 
150
152
  Full workflow: `cms-edit://customer/task-media-reuse-and-upload`.
151
153
 
154
+ ### Staged upload (large files)
155
+
156
+ Keeps bulk binary **out of MCP tool args**. The agent uploads via `curl`, then passes only a short `uploadId`.
157
+
158
+ 1. `cms_edit_request_staged_upload` with `fileName`, `mimeType`, optional `byteLength`, optional `title`
159
+ 2. Run the returned `curlCommand` in a shell (POST multipart to `uploadUrl`)
160
+ 3. `cms_edit` with the returned `consumeArgs` (includes `asset upload --staged <uploadId>`)
161
+ 4. Link and save as usual
162
+
163
+ Hosted limit: **4 MB** per staged file (Vercel body limit). TTL: **15 minutes**, single-use.
164
+
152
165
  ## Featured images (articles)
153
166
 
154
167
  Read `cms-edit://customer/defaults` when present. Otherwise:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@se-studio/contentful-cms",
3
- "version": "3.0.3",
3
+ "version": "3.1.1",
4
4
  "description": "CLI tool for AI agents to read and edit Contentful draft content without publish permissions",
5
5
  "repository": {
6
6
  "type": "git",
@@ -369,11 +369,14 @@ cms-edit asset search "hero background"
369
369
  cms-edit asset search --filename istockphoto-123.jpg
370
370
  cms-edit asset search --filename-match 'istockphoto-.*'
371
371
 
372
- # Upload from local file, URL, or base64
372
+ # Upload from local file, URL, base64, or staged ref (hosted MCP)
373
373
  cms-edit asset upload ./poster.jpg --title "Poster"
374
374
  cms-edit asset upload --url https://example.com/image.jpg --if-exists-by-filename
375
375
  cms-edit asset upload --base64 "$B64" --mime image/png --file-name poster.png
376
376
 
377
+ # Hosted MCP — large files: cms_edit_request_staged_upload → curl uploadUrl → consumeArgs
378
+ # cms_edit ["asset", "upload", "--staged", "<uploadId>", "--mime", "image/png", "--file-name", "photo.png"]
379
+
377
380
  # Upload and create a Media wrapper in one step
378
381
  cms-edit asset upload ./figure.png --with-media --media-name "Figure 1" --media-position Middle
379
382