@rubytech/create-maxy-code 0.1.458 → 0.1.459
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +188 -3
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.d.ts +2 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.d.ts.map +1 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js +59 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js.map +1 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +12 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.js +97 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/house-credential.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/house-credential.js +4 -2
- package/payload/platform/lib/storage-broker/dist/house-credential.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/index.d.ts +1 -0
- package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/index.js +1 -0
- package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/object-limits.d.ts +53 -0
- package/payload/platform/lib/storage-broker/dist/object-limits.d.ts.map +1 -0
- package/payload/platform/lib/storage-broker/dist/object-limits.js +72 -0
- package/payload/platform/lib/storage-broker/dist/object-limits.js.map +1 -0
- package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +211 -5
- package/payload/platform/lib/storage-broker/src/__tests__/object-limits.test.ts +76 -0
- package/payload/platform/lib/storage-broker/src/cf-exec.ts +148 -6
- package/payload/platform/lib/storage-broker/src/house-credential.ts +4 -2
- package/payload/platform/lib/storage-broker/src/index.ts +1 -0
- package/payload/platform/lib/storage-broker/src/object-limits.ts +75 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +5 -5
- package/payload/platform/plugins/cloudflare/bin/cf-token.sh +4 -3
- package/payload/platform/plugins/cloudflare/references/api.md +1 -1
- package/payload/platform/plugins/docs/references/outlook-guide.md +4 -4
- package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/outlook/PLUGIN.md +2 -2
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.js +138 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.js +121 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.js +102 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.js +209 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/index.js +10 -7
- package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/attach.d.ts +37 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attach.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attach.js +79 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attach.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/message.d.ts +8 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/message.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/message.js +4 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/message.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts +31 -8
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js +40 -13
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.d.ts +9 -5
- package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.js +12 -8
- package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +29 -4
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +46 -13
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts +8 -2
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js +13 -4
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js +3 -20
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts +27 -3
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js +53 -8
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js.map +1 -1
- package/payload/platform/plugins/outlook/references/graph-surfaces.md +29 -6
- package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +22 -5
- package/payload/platform/plugins/storage-broker/PLUGIN.md +41 -2
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.d.ts +2 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.d.ts.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.js +85 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.js.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.d.ts +2 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.d.ts.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.js +77 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.js.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.d.ts +12 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.d.ts.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.js +107 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.js.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/index.js +86 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/storage-broker/mcp/dist/put-source.d.ts +11 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/put-source.d.ts.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/put-source.js +35 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/put-source.js.map +1 -0
- package/payload/platform/scripts/logs-read-jsonl.test.sh +118 -0
- package/payload/platform/scripts/logs-read.sh +63 -9
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +4 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
- package/payload/server/{chunk-F4D35LKL.js → chunk-POBXIHOF.js} +34 -0
- package/payload/server/{chunk-64FGYKNZ.js → chunk-Q6W4U6HL.js} +104 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/server.js +206 -6
- package/payload/server/{src-S7C4P6TT.js → src-3I2RYZFB.js} +9 -1
|
@@ -22,6 +22,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
22
22
|
};
|
|
23
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
24
|
__exportStar(require("./authorize.js"), exports);
|
|
25
|
+
__exportStar(require("./object-limits.js"), exports);
|
|
25
26
|
__exportStar(require("./registry.js"), exports);
|
|
26
27
|
__exportStar(require("./house-credential.js"), exports);
|
|
27
28
|
__exportStar(require("./cf-exec.js"), exports);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,mEAAmE;AACnE,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,2EAA2E;AAC3E,uEAAuE;AACvE,4DAA4D;;;;;;;;;;;;;;;;AAE5D,iDAA+B;AAC/B,gDAA8B;AAC9B,wDAAsC;AACtC,+CAA6B;AAC7B,0DAAwC;AACxC,6CAA2B;AAC3B,iDAA+B"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,mEAAmE;AACnE,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,2EAA2E;AAC3E,uEAAuE;AACvE,4DAA4D;;;;;;;;;;;;;;;;AAE5D,iDAA+B;AAC/B,qDAAmC;AACnC,gDAA8B;AAC9B,wDAAsC;AACtC,+CAA6B;AAC7B,0DAAwC;AACxC,6CAA2B;AAC3B,iDAA+B"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The byte cap on R2 object transfer, and the arithmetic the routes need to
|
|
3
|
+
* enforce it before they buffer anything.
|
|
4
|
+
*
|
|
5
|
+
* One number, one home (Task 1695). The storage-broker MCP tools and the house
|
|
6
|
+
* ui routes both read the cap from here, so the process that rejects a too-large
|
|
7
|
+
* file and the process that would otherwise buffer it cannot drift apart.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately dependency-free: the MCP imports this module directly, without
|
|
10
|
+
* the rest of the lib (cf-exec, registry, audit, remediate), which it has no use
|
|
11
|
+
* for. The ui route reads it through the barrel.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* The maximum size, in bytes, of an object moved through storage-r2-object-get
|
|
15
|
+
* or storage-r2-object-put.
|
|
16
|
+
*
|
|
17
|
+
* Set independently of the email plugin's OUTBOUND_ATTACHMENT_MAX_BYTES (25 MiB)
|
|
18
|
+
* even though the payload class is the same. The two exist for different reasons
|
|
19
|
+
* and must be free to move for different reasons: email's tracks an SMTP-side
|
|
20
|
+
* rejection threshold, this one tracks house-process memory. Do not couple them.
|
|
21
|
+
*
|
|
22
|
+
* Not bounded by R2. A single-part upload may be up to 5 GiB (doc-confirmed:
|
|
23
|
+
* developers.cloudflare.com/r2/platform/limits/), roughly fifty times this, and
|
|
24
|
+
* the v4 REST API states no separate object-size limit. This cap is the only
|
|
25
|
+
* bound on either transfer.
|
|
26
|
+
*
|
|
27
|
+
* The bound is not small: at the cap a get still leaves the house process
|
|
28
|
+
* holding the object and its base64 form, roughly 2.4x the object. That cost was
|
|
29
|
+
* weighed and accepted when the number was chosen.
|
|
30
|
+
*/
|
|
31
|
+
export declare const R2_OBJECT_MAX_BYTES: number;
|
|
32
|
+
/** base64 emits 4 characters per 3 input bytes, padded up to the next quantum. */
|
|
33
|
+
export declare function base64Ceiling(bytes: number): number;
|
|
34
|
+
/**
|
|
35
|
+
* The largest JSON body /r2/object/put can legitimately carry.
|
|
36
|
+
*
|
|
37
|
+
* This bounds house memory; it does not adjudicate the last kilobyte. The exact
|
|
38
|
+
* decoded-byte rule lives in the MCP, which knows the source's size exactly.
|
|
39
|
+
*/
|
|
40
|
+
export declare const R2_OBJECT_PUT_MAX_BODY_BYTES: number;
|
|
41
|
+
/**
|
|
42
|
+
* The over-cap message, worded once so the MCP and the routes agree. Names the
|
|
43
|
+
* offending object and the limit, which is what makes the failure actionable
|
|
44
|
+
* rather than an opaque out-of-memory kill.
|
|
45
|
+
*
|
|
46
|
+
* Exact bytes accompany the MiB figures because MiB alone rounds: an object one
|
|
47
|
+
* byte over the cap reads "100.0 MiB, which exceeds the 100.0 MiB limit", which
|
|
48
|
+
* is a message that appears to contradict itself and invites the reader to treat
|
|
49
|
+
* the rejection as a bug. The bytes are what make it legible; the MiB is what
|
|
50
|
+
* makes it quick.
|
|
51
|
+
*/
|
|
52
|
+
export declare function tooLargeMessage(what: string, actualBytes: number): string;
|
|
53
|
+
//# sourceMappingURL=object-limits.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"object-limits.d.ts","sourceRoot":"","sources":["../src/object-limits.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,mBAAmB,QAAoB,CAAC;AAErD,kFAAkF;AAClF,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnD;AAUD;;;;;GAKG;AACH,eAAO,MAAM,4BAA4B,QAC0B,CAAC;AAMpE;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAIzE"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The byte cap on R2 object transfer, and the arithmetic the routes need to
|
|
4
|
+
* enforce it before they buffer anything.
|
|
5
|
+
*
|
|
6
|
+
* One number, one home (Task 1695). The storage-broker MCP tools and the house
|
|
7
|
+
* ui routes both read the cap from here, so the process that rejects a too-large
|
|
8
|
+
* file and the process that would otherwise buffer it cannot drift apart.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately dependency-free: the MCP imports this module directly, without
|
|
11
|
+
* the rest of the lib (cf-exec, registry, audit, remediate), which it has no use
|
|
12
|
+
* for. The ui route reads it through the barrel.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.R2_OBJECT_PUT_MAX_BODY_BYTES = exports.R2_OBJECT_MAX_BYTES = void 0;
|
|
16
|
+
exports.base64Ceiling = base64Ceiling;
|
|
17
|
+
exports.tooLargeMessage = tooLargeMessage;
|
|
18
|
+
/**
|
|
19
|
+
* The maximum size, in bytes, of an object moved through storage-r2-object-get
|
|
20
|
+
* or storage-r2-object-put.
|
|
21
|
+
*
|
|
22
|
+
* Set independently of the email plugin's OUTBOUND_ATTACHMENT_MAX_BYTES (25 MiB)
|
|
23
|
+
* even though the payload class is the same. The two exist for different reasons
|
|
24
|
+
* and must be free to move for different reasons: email's tracks an SMTP-side
|
|
25
|
+
* rejection threshold, this one tracks house-process memory. Do not couple them.
|
|
26
|
+
*
|
|
27
|
+
* Not bounded by R2. A single-part upload may be up to 5 GiB (doc-confirmed:
|
|
28
|
+
* developers.cloudflare.com/r2/platform/limits/), roughly fifty times this, and
|
|
29
|
+
* the v4 REST API states no separate object-size limit. This cap is the only
|
|
30
|
+
* bound on either transfer.
|
|
31
|
+
*
|
|
32
|
+
* The bound is not small: at the cap a get still leaves the house process
|
|
33
|
+
* holding the object and its base64 form, roughly 2.4x the object. That cost was
|
|
34
|
+
* weighed and accepted when the number was chosen.
|
|
35
|
+
*/
|
|
36
|
+
exports.R2_OBJECT_MAX_BYTES = 100 * 1024 * 1024;
|
|
37
|
+
/** base64 emits 4 characters per 3 input bytes, padded up to the next quantum. */
|
|
38
|
+
function base64Ceiling(bytes) {
|
|
39
|
+
return 4 * Math.ceil(bytes / 3);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Room for the put envelope's bucket, key and JSON punctuation, so a legal
|
|
43
|
+
* at-cap put is not rejected by its own key. Far above any realistic bucket+key
|
|
44
|
+
* length and far below the cap, so it cannot smuggle a meaningfully larger
|
|
45
|
+
* object.
|
|
46
|
+
*/
|
|
47
|
+
const PUT_ENVELOPE_ALLOWANCE_BYTES = 64 * 1024;
|
|
48
|
+
/**
|
|
49
|
+
* The largest JSON body /r2/object/put can legitimately carry.
|
|
50
|
+
*
|
|
51
|
+
* This bounds house memory; it does not adjudicate the last kilobyte. The exact
|
|
52
|
+
* decoded-byte rule lives in the MCP, which knows the source's size exactly.
|
|
53
|
+
*/
|
|
54
|
+
exports.R2_OBJECT_PUT_MAX_BODY_BYTES = base64Ceiling(exports.R2_OBJECT_MAX_BYTES) + PUT_ENVELOPE_ALLOWANCE_BYTES;
|
|
55
|
+
function formatMiB(bytes) {
|
|
56
|
+
return (bytes / 1024 / 1024).toFixed(1);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The over-cap message, worded once so the MCP and the routes agree. Names the
|
|
60
|
+
* offending object and the limit, which is what makes the failure actionable
|
|
61
|
+
* rather than an opaque out-of-memory kill.
|
|
62
|
+
*
|
|
63
|
+
* Exact bytes accompany the MiB figures because MiB alone rounds: an object one
|
|
64
|
+
* byte over the cap reads "100.0 MiB, which exceeds the 100.0 MiB limit", which
|
|
65
|
+
* is a message that appears to contradict itself and invites the reader to treat
|
|
66
|
+
* the rejection as a bug. The bytes are what make it legible; the MiB is what
|
|
67
|
+
* makes it quick.
|
|
68
|
+
*/
|
|
69
|
+
function tooLargeMessage(what, actualBytes) {
|
|
70
|
+
return `${what} is ${formatMiB(actualBytes)} MiB (${actualBytes} bytes), which exceeds the ${formatMiB(exports.R2_OBJECT_MAX_BYTES)} MiB (${exports.R2_OBJECT_MAX_BYTES} bytes) R2 object limit.`;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=object-limits.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"object-limits.js","sourceRoot":"","sources":["../src/object-limits.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;GAWG;;;AAuBH,sCAEC;AAkCD,0CAIC;AA7DD;;;;;;;;;;;;;;;;;GAiBG;AACU,QAAA,mBAAmB,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,CAAC;AAErD,kFAAkF;AAClF,SAAgB,aAAa,CAAC,KAAa;IACzC,OAAO,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;AAClC,CAAC;AAED;;;;;GAKG;AACH,MAAM,4BAA4B,GAAG,EAAE,GAAG,IAAI,CAAC;AAE/C;;;;;GAKG;AACU,QAAA,4BAA4B,GACvC,aAAa,CAAC,2BAAmB,CAAC,GAAG,4BAA4B,CAAC;AAEpE,SAAS,SAAS,CAAC,KAAa;IAC9B,OAAO,CAAC,KAAK,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AAC1C,CAAC;AAED;;;;;;;;;;GAUG;AACH,SAAgB,eAAe,CAAC,IAAY,EAAE,WAAmB;IAC/D,OAAO,GAAG,IAAI,OAAO,SAAS,CAAC,WAAW,CAAC,SAAS,WAAW,8BAA8B,SAAS,CACpG,2BAAmB,CACpB,SAAS,2BAAmB,0BAA0B,CAAC;AAC1D,CAAC"}
|
|
@@ -26,11 +26,18 @@ const throwingRun: RunFn = async () => {
|
|
|
26
26
|
function recordingFetch(response: { ok: boolean; status: number; body: string }) {
|
|
27
27
|
const calls: Array<{
|
|
28
28
|
url: string;
|
|
29
|
-
init: { method: string; headers: Record<string, string>; body?: string };
|
|
29
|
+
init: { method: string; headers: Record<string, string>; body?: string | Uint8Array };
|
|
30
30
|
}> = [];
|
|
31
31
|
const fetchFn: FetchFn = async (url, init) => {
|
|
32
32
|
calls.push({ url, init });
|
|
33
|
-
return {
|
|
33
|
+
return {
|
|
34
|
+
ok: response.ok,
|
|
35
|
+
status: response.status,
|
|
36
|
+
text: async () => response.body,
|
|
37
|
+
// Task 1691 — r2ObjectGet reads the raw object body, so the seam carries
|
|
38
|
+
// arrayBuffer alongside text. The fake derives it from the same body.
|
|
39
|
+
arrayBuffer: async () => new TextEncoder().encode(response.body).buffer as ArrayBuffer,
|
|
40
|
+
};
|
|
34
41
|
};
|
|
35
42
|
return { fetchFn, calls };
|
|
36
43
|
}
|
|
@@ -41,13 +48,18 @@ function recordingFetch(response: { ok: boolean; status: number; body: string })
|
|
|
41
48
|
function sequenceFetch(responses: Array<{ ok: boolean; status: number; body: string }>) {
|
|
42
49
|
const calls: Array<{
|
|
43
50
|
url: string;
|
|
44
|
-
init: { method: string; headers: Record<string, string>; body?: string };
|
|
51
|
+
init: { method: string; headers: Record<string, string>; body?: string | Uint8Array };
|
|
45
52
|
}> = [];
|
|
46
53
|
const fetchFn: FetchFn = async (url, init) => {
|
|
47
54
|
calls.push({ url, init });
|
|
48
55
|
const r = responses[calls.length - 1];
|
|
49
56
|
if (!r) throw new Error(`unexpected fetch call #${calls.length} to ${url}`);
|
|
50
|
-
return {
|
|
57
|
+
return {
|
|
58
|
+
ok: r.ok,
|
|
59
|
+
status: r.status,
|
|
60
|
+
text: async () => r.body,
|
|
61
|
+
arrayBuffer: async () => new TextEncoder().encode(r.body).buffer as ArrayBuffer,
|
|
62
|
+
};
|
|
51
63
|
};
|
|
52
64
|
return { fetchFn, calls };
|
|
53
65
|
}
|
|
@@ -99,7 +111,9 @@ test("d1Create creates the database via the D1 API with the house token, never w
|
|
|
99
111
|
assert.equal(calls[0].init.method, "POST");
|
|
100
112
|
assert.equal(calls[0].init.headers.Authorization, "Bearer cfat_x");
|
|
101
113
|
assert.equal(calls[0].init.headers["Content-Type"], "application/json");
|
|
102
|
-
|
|
114
|
+
// init.body widened to string | Uint8Array for the object-put path (Task
|
|
115
|
+
// 1691); d1Create still sends a JSON string.
|
|
116
|
+
assert.deepEqual(JSON.parse(String(calls[0].init.body ?? "")), { name: "gls-new" });
|
|
103
117
|
});
|
|
104
118
|
|
|
105
119
|
test("d1Create throws with the API error body on a non-2xx response", async () => {
|
|
@@ -338,3 +352,195 @@ test("makeHouseCfExec runs wrangler with the scoped token, not the minter", asyn
|
|
|
338
352
|
await cf.d1List();
|
|
339
353
|
assert.equal(sawToken, "scoped_tok", "wrangler saw the scoped token, not the minter");
|
|
340
354
|
});
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// Task 1691 — R2 object operations. Same discipline as r2List/d1Create: the
|
|
358
|
+
// Cloudflare API behind an injectable fetch, wrangler never shelled (throwingRun
|
|
359
|
+
// proves it), and every failure keeps its body.
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
function objectPage(keys: string[], opts: { cursor?: string; truncated?: boolean } = {}) {
|
|
363
|
+
return JSON.stringify({
|
|
364
|
+
success: true,
|
|
365
|
+
errors: [],
|
|
366
|
+
messages: [],
|
|
367
|
+
result: keys.map((key) => ({
|
|
368
|
+
key,
|
|
369
|
+
size: 10,
|
|
370
|
+
etag: "e1",
|
|
371
|
+
last_modified: "2026-01-01T00:00:00Z",
|
|
372
|
+
})),
|
|
373
|
+
result_info: { cursor: opts.cursor ?? "", is_truncated: opts.truncated ?? false },
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
test("r2ObjectList returns a single page and never shells wrangler", async () => {
|
|
378
|
+
const { fetchFn, calls } = recordingFetch({ ok: true, status: 200, body: objectPage(["a.jpg"]) });
|
|
379
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
380
|
+
assert.deepEqual(await cf.r2ObjectList("acc-a-portal"), [
|
|
381
|
+
{ key: "a.jpg", size: 10, etag: "e1", lastModified: "2026-01-01T00:00:00Z" },
|
|
382
|
+
]);
|
|
383
|
+
assert.equal(calls.length, 1);
|
|
384
|
+
assert.equal(
|
|
385
|
+
calls[0].url,
|
|
386
|
+
"https://api.cloudflare.com/client/v4/accounts/acc-h/r2/buckets/acc-a-portal/objects",
|
|
387
|
+
);
|
|
388
|
+
assert.equal(calls[0].init.headers.Authorization, "Bearer cfat_x");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("r2ObjectList never sends per_page", async () => {
|
|
392
|
+
const { fetchFn, calls } = recordingFetch({ ok: true, status: 200, body: objectPage([]) });
|
|
393
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
394
|
+
await cf.r2ObjectList("b");
|
|
395
|
+
assert.equal(calls[0].url.includes("per_page"), false);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("r2ObjectList passes prefix through, encoded", async () => {
|
|
399
|
+
const { fetchFn, calls } = recordingFetch({ ok: true, status: 200, body: objectPage([]) });
|
|
400
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
401
|
+
await cf.r2ObjectList("b", "person a/");
|
|
402
|
+
assert.ok(calls[0].url.endsWith("/objects?prefix=person+a%2F"), calls[0].url);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("r2ObjectList follows the cursor to exhaustion", async () => {
|
|
406
|
+
const { fetchFn, calls } = sequenceFetch([
|
|
407
|
+
{ ok: true, status: 200, body: objectPage(["a.jpg"], { cursor: "c1", truncated: true }) },
|
|
408
|
+
{ ok: true, status: 200, body: objectPage(["b.jpg"]) },
|
|
409
|
+
]);
|
|
410
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
411
|
+
assert.deepEqual((await cf.r2ObjectList("b")).map((o) => o.key), ["a.jpg", "b.jpg"]);
|
|
412
|
+
assert.equal(calls.length, 2);
|
|
413
|
+
assert.ok(calls[1].url.includes("cursor=c1"));
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// is_truncated is the authoritative continuation signal, not the presence of a
|
|
417
|
+
// cursor: a last page may still carry one.
|
|
418
|
+
test("r2ObjectList stops when is_truncated is false even if a cursor is present", async () => {
|
|
419
|
+
const { fetchFn, calls } = sequenceFetch([
|
|
420
|
+
{ ok: true, status: 200, body: objectPage(["a.jpg"], { cursor: "c1", truncated: false }) },
|
|
421
|
+
]);
|
|
422
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
423
|
+
await cf.r2ObjectList("b");
|
|
424
|
+
assert.equal(calls.length, 1);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// The response shape is doc-confirmed but never measured against a live account.
|
|
428
|
+
// If it is not what we understand, that must be loud: a partial list is exactly
|
|
429
|
+
// the bug this loop exists to prevent.
|
|
430
|
+
test("r2ObjectList throws when truncated but no cursor is given, never a partial list", async () => {
|
|
431
|
+
const { fetchFn } = recordingFetch({
|
|
432
|
+
ok: true,
|
|
433
|
+
status: 200,
|
|
434
|
+
body: objectPage(["a.jpg"], { truncated: true }),
|
|
435
|
+
});
|
|
436
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
437
|
+
await assert.rejects(() => cf.r2ObjectList("b"), /truncated but gave no pagination cursor/);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("r2ObjectList throws on a repeated cursor", async () => {
|
|
441
|
+
const { fetchFn } = sequenceFetch([
|
|
442
|
+
{ ok: true, status: 200, body: objectPage(["a"], { cursor: "c1", truncated: true }) },
|
|
443
|
+
{ ok: true, status: 200, body: objectPage(["b"], { cursor: "c1", truncated: true }) },
|
|
444
|
+
]);
|
|
445
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
446
|
+
await assert.rejects(() => cf.r2ObjectList("b"), /repeated pagination cursor/);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("r2ObjectList retains the body on a non-2xx", async () => {
|
|
450
|
+
const { fetchFn } = recordingFetch({ ok: false, status: 403, body: '{"errors":[{"code":10000}]}' });
|
|
451
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
452
|
+
await assert.rejects(() => cf.r2ObjectList("b"), /10000/);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("r2ObjectGet returns exact bytes and encodes the key segment-wise", async () => {
|
|
456
|
+
// Binary that is not valid UTF-8: proves the body never round-trips through
|
|
457
|
+
// text(), which would corrupt it.
|
|
458
|
+
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff]);
|
|
459
|
+
const calls: string[] = [];
|
|
460
|
+
const fetchFn: FetchFn = async (url) => {
|
|
461
|
+
calls.push(url);
|
|
462
|
+
return {
|
|
463
|
+
ok: true,
|
|
464
|
+
status: 200,
|
|
465
|
+
text: async () => "unused",
|
|
466
|
+
arrayBuffer: async () => bytes.buffer.slice(0) as ArrayBuffer,
|
|
467
|
+
};
|
|
468
|
+
};
|
|
469
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
470
|
+
const got = await cf.r2ObjectGet("b", "person a/photo 1.jpg");
|
|
471
|
+
assert.deepEqual(Array.from(got), Array.from(bytes));
|
|
472
|
+
// Separators stay separators; spaces are escaped.
|
|
473
|
+
assert.ok(calls[0].endsWith("/objects/person%20a/photo%201.jpg"), calls[0]);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("r2ObjectGet retains the body on a non-2xx", async () => {
|
|
477
|
+
const { fetchFn } = recordingFetch({ ok: false, status: 404, body: "no such key" });
|
|
478
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
479
|
+
await assert.rejects(() => cf.r2ObjectGet("b", "k"), /no such key/);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("r2ObjectPut sends the bytes as the request body", async () => {
|
|
483
|
+
const { fetchFn, calls } = recordingFetch({
|
|
484
|
+
ok: true,
|
|
485
|
+
status: 200,
|
|
486
|
+
body: JSON.stringify({ success: true, errors: [], messages: [], result: {} }),
|
|
487
|
+
});
|
|
488
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
489
|
+
const bytes = new Uint8Array([1, 2, 3]);
|
|
490
|
+
await cf.r2ObjectPut("b", "k.bin", bytes);
|
|
491
|
+
assert.equal(calls[0].init.method, "PUT");
|
|
492
|
+
assert.deepEqual(calls[0].init.body, bytes);
|
|
493
|
+
assert.equal(calls[0].init.headers.Authorization, "Bearer cfat_x");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("r2ObjectPut retains the body on a non-success envelope", async () => {
|
|
497
|
+
const { fetchFn } = recordingFetch({
|
|
498
|
+
ok: true,
|
|
499
|
+
status: 200,
|
|
500
|
+
body: '{"success":false,"errors":[{"code":10001}]}',
|
|
501
|
+
});
|
|
502
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
503
|
+
await assert.rejects(() => cf.r2ObjectPut("b", "k", new Uint8Array([1])), /10001/);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("r2ObjectDelete issues a DELETE", async () => {
|
|
507
|
+
const { fetchFn, calls } = recordingFetch({
|
|
508
|
+
ok: true,
|
|
509
|
+
status: 200,
|
|
510
|
+
body: JSON.stringify({ success: true, errors: [], messages: [], result: {} }),
|
|
511
|
+
});
|
|
512
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
513
|
+
await cf.r2ObjectDelete("b", "k");
|
|
514
|
+
assert.equal(calls[0].init.method, "DELETE");
|
|
515
|
+
assert.equal(calls[0].init.headers.Authorization, "Bearer cfat_x");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("r2ObjectDelete retains the body on a non-2xx", async () => {
|
|
519
|
+
const { fetchFn } = recordingFetch({ ok: false, status: 500, body: "boom" });
|
|
520
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
521
|
+
await assert.rejects(() => cf.r2ObjectDelete("b", "k"), /boom/);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Code review — is_truncated is itself an unmeasured fact (no live credential
|
|
525
|
+
// exists to confirm the envelope). Driving the loop solely on it means a
|
|
526
|
+
// response that omits it while still paginating truncates SILENTLY, which is
|
|
527
|
+
// the exact failure this loop exists to prevent, and it would also make
|
|
528
|
+
// r2ObjectList disagree with r2List directly above it, which drives the same
|
|
529
|
+
// envelope on cursor presence alone.
|
|
530
|
+
test("r2ObjectList follows a cursor when is_truncated is absent, rather than truncating silently", async () => {
|
|
531
|
+
const pageWithoutTruncatedFlag = (keys: string[], cursor: string | null) =>
|
|
532
|
+
JSON.stringify({
|
|
533
|
+
success: true,
|
|
534
|
+
errors: [],
|
|
535
|
+
messages: [],
|
|
536
|
+
result: keys.map((key) => ({ key, size: 1, etag: "e", last_modified: "t" })),
|
|
537
|
+
result_info: cursor === null ? {} : { cursor },
|
|
538
|
+
});
|
|
539
|
+
const { fetchFn, calls } = sequenceFetch([
|
|
540
|
+
{ ok: true, status: 200, body: pageWithoutTruncatedFlag(["a.jpg"], "c1") },
|
|
541
|
+
{ ok: true, status: 200, body: pageWithoutTruncatedFlag(["b.jpg"], null) },
|
|
542
|
+
]);
|
|
543
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
544
|
+
assert.deepEqual((await cf.r2ObjectList("b")).map((o) => o.key), ["a.jpg", "b.jpg"]);
|
|
545
|
+
assert.equal(calls.length, 2);
|
|
546
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
R2_OBJECT_MAX_BYTES,
|
|
5
|
+
R2_OBJECT_PUT_MAX_BODY_BYTES,
|
|
6
|
+
base64Ceiling,
|
|
7
|
+
tooLargeMessage,
|
|
8
|
+
} from "../object-limits.js";
|
|
9
|
+
|
|
10
|
+
test("the cap is 100 MiB", () => {
|
|
11
|
+
assert.equal(R2_OBJECT_MAX_BYTES, 100 * 1024 * 1024);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// The cap is set independently of email's 25 MiB. This pins that they are not
|
|
15
|
+
// the same number, so a future edit that "unifies" them fails here and has to
|
|
16
|
+
// read the design note rather than silently coupling two unrelated limits.
|
|
17
|
+
test("the cap is not email's attachment cap", () => {
|
|
18
|
+
assert.notEqual(R2_OBJECT_MAX_BYTES, 25 * 1024 * 1024);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("base64Ceiling matches base64's 4-out-per-3-in expansion, rounded up", () => {
|
|
22
|
+
assert.equal(base64Ceiling(0), 0);
|
|
23
|
+
assert.equal(base64Ceiling(1), 4);
|
|
24
|
+
assert.equal(base64Ceiling(3), 4);
|
|
25
|
+
assert.equal(base64Ceiling(4), 8);
|
|
26
|
+
assert.equal(base64Ceiling(6), 8);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// The ceiling must never sit below what a legal at-cap object actually encodes
|
|
30
|
+
// to, or a legitimate put would be rejected by its own encoding.
|
|
31
|
+
test("base64Ceiling is never below Node's own base64 length", () => {
|
|
32
|
+
for (const n of [0, 1, 2, 3, 4, 5, 100, 1023, 4096]) {
|
|
33
|
+
const encoded = Buffer.alloc(n).toString("base64").length;
|
|
34
|
+
assert.ok(
|
|
35
|
+
base64Ceiling(n) >= encoded,
|
|
36
|
+
`base64Ceiling(${n}) = ${base64Ceiling(n)} < ${encoded}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the put body ceiling admits a cap-sized object plus an envelope allowance", () => {
|
|
42
|
+
assert.equal(
|
|
43
|
+
R2_OBJECT_PUT_MAX_BODY_BYTES,
|
|
44
|
+
base64Ceiling(R2_OBJECT_MAX_BYTES) + 64 * 1024,
|
|
45
|
+
);
|
|
46
|
+
assert.ok(R2_OBJECT_PUT_MAX_BODY_BYTES > base64Ceiling(R2_OBJECT_MAX_BYTES));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// The allowance is for a key and JSON punctuation, not for smuggling a bigger
|
|
50
|
+
// object: it must stay far below the cap it guards.
|
|
51
|
+
test("the envelope allowance is negligible against the cap", () => {
|
|
52
|
+
const allowance = R2_OBJECT_PUT_MAX_BODY_BYTES - base64Ceiling(R2_OBJECT_MAX_BYTES);
|
|
53
|
+
assert.ok(allowance < R2_OBJECT_MAX_BYTES / 1000);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("the over-cap message names the object and the limit", () => {
|
|
57
|
+
const msg = tooLargeMessage("photo.jpg", 150 * 1024 * 1024);
|
|
58
|
+
assert.match(msg, /photo\.jpg/);
|
|
59
|
+
assert.match(msg, /150\.0 MiB/);
|
|
60
|
+
assert.match(msg, /100\.0 MiB/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Code review — MiB alone rounds, so an object one byte over the cap rendered as
|
|
64
|
+
// "100.0 MiB, which exceeds the 100.0 MiB limit": a message that contradicts
|
|
65
|
+
// itself and reads as a bug rather than a rejection. The exact bytes are what
|
|
66
|
+
// disambiguate it, so they are pinned rather than left to formatting taste.
|
|
67
|
+
test("the message stays legible for an object one byte over the cap", () => {
|
|
68
|
+
const msg = tooLargeMessage("photo.jpg", R2_OBJECT_MAX_BYTES + 1);
|
|
69
|
+
assert.match(msg, /104857601 bytes/);
|
|
70
|
+
assert.match(msg, /104857600 bytes/);
|
|
71
|
+
assert.notEqual(
|
|
72
|
+
msg.match(/100\.0 MiB \(104857601 bytes\)/),
|
|
73
|
+
null,
|
|
74
|
+
"the actual size must carry its exact byte count",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
@@ -14,14 +14,32 @@ export type RunFn = (
|
|
|
14
14
|
env: Record<string, string>,
|
|
15
15
|
) => Promise<{ stdout: string }>;
|
|
16
16
|
|
|
17
|
-
// The HTTP seam for the Cloudflare API. Injectable so
|
|
17
|
+
// The HTTP seam for the Cloudflare API. Injectable so the API-backed methods are
|
|
18
18
|
// unit-testable without a network. Minimal subset of the global fetch: callers
|
|
19
19
|
// read the body once via text() and branch on ok/status. `body` is set for the
|
|
20
|
-
// POST paths (d1Create); GET paths
|
|
20
|
+
// POST/PUT paths (d1Create, r2ObjectPut); GET/DELETE paths omit it.
|
|
21
|
+
//
|
|
22
|
+
// Task 1691 widened both halves: `body` carries Uint8Array for the object-put
|
|
23
|
+
// path, and `arrayBuffer` reads the raw object body for r2ObjectGet, which is
|
|
24
|
+
// the one method whose response is not a JSON envelope. arrayBuffer is required
|
|
25
|
+
// rather than optional: an optional member would force a fake-only fallback
|
|
26
|
+
// branch in r2ObjectGet that never runs against a real fetch.
|
|
21
27
|
export type FetchFn = (
|
|
22
28
|
url: string,
|
|
23
|
-
init: { method: string; headers: Record<string, string>; body?: string },
|
|
24
|
-
) => Promise<{
|
|
29
|
+
init: { method: string; headers: Record<string, string>; body?: string | Uint8Array },
|
|
30
|
+
) => Promise<{
|
|
31
|
+
ok: boolean;
|
|
32
|
+
status: number;
|
|
33
|
+
text(): Promise<string>;
|
|
34
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
35
|
+
}>;
|
|
36
|
+
|
|
37
|
+
export interface R2Object {
|
|
38
|
+
key: string;
|
|
39
|
+
size: number;
|
|
40
|
+
etag: string;
|
|
41
|
+
lastModified: string;
|
|
42
|
+
}
|
|
25
43
|
|
|
26
44
|
export interface CfExec {
|
|
27
45
|
d1List(): Promise<{ name: string; uuid: string }[]>;
|
|
@@ -29,9 +47,33 @@ export interface CfExec {
|
|
|
29
47
|
d1Query(name: string, sql: string): Promise<unknown>;
|
|
30
48
|
r2List(): Promise<{ name: string }[]>;
|
|
31
49
|
r2Create(name: string): Promise<void>;
|
|
50
|
+
r2ObjectList(bucket: string, prefix?: string): Promise<R2Object[]>;
|
|
51
|
+
r2ObjectGet(bucket: string, key: string): Promise<Uint8Array>;
|
|
52
|
+
r2ObjectPut(bucket: string, key: string, body: Uint8Array): Promise<void>;
|
|
53
|
+
r2ObjectDelete(bucket: string, key: string): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// An object key carries `/` as a real path separator (the data portal's
|
|
57
|
+
// per-person prefixes), so it is encoded segment-wise rather than whole:
|
|
58
|
+
// separators stay separators, and spaces and specials are escaped. This
|
|
59
|
+
// encoding is not doc-confirmed; it is chosen so that a wrong guess surfaces as
|
|
60
|
+
// a 404 carrying its body, never as a silently misfiled object.
|
|
61
|
+
function encodeObjectKey(key: string): string {
|
|
62
|
+
return key.split("/").map(encodeURIComponent).join("/");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The single-object endpoint, shared by get/put/delete so the three cannot drift
|
|
66
|
+
// apart on encoding.
|
|
67
|
+
function objectUrl(accountId: string, bucket: string, key: string): string {
|
|
68
|
+
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${encodeURIComponent(bucket)}/objects/${encodeObjectKey(key)}`;
|
|
32
69
|
}
|
|
33
70
|
|
|
34
|
-
|
|
71
|
+
// The cast is confined to this adapter. A Uint8Array is a valid fetch body at
|
|
72
|
+
// runtime, but the DOM lib's BodyInit does not admit TypeScript's generic
|
|
73
|
+
// Uint8Array<ArrayBufferLike>, so consumers compiled against DOM typings (the
|
|
74
|
+
// ui server) reject the call the Node typings accept. Casting here keeps the
|
|
75
|
+
// seam's own type honest for every caller and test.
|
|
76
|
+
const defaultFetch: FetchFn = (url, init) => fetch(url, init as RequestInit);
|
|
35
77
|
|
|
36
78
|
const defaultRun: RunFn = (cmd, args, env) =>
|
|
37
79
|
new Promise((resolve, reject) => {
|
|
@@ -62,7 +104,7 @@ function parseJson(raw: string, context: string): unknown {
|
|
|
62
104
|
async function cfApiJson(
|
|
63
105
|
fetchFn: FetchFn,
|
|
64
106
|
url: string,
|
|
65
|
-
init: { method: string; headers: Record<string, string>; body?: string },
|
|
107
|
+
init: { method: string; headers: Record<string, string>; body?: string | Uint8Array },
|
|
66
108
|
context: string,
|
|
67
109
|
): Promise<{ parsed: unknown; body: string }> {
|
|
68
110
|
const res = await fetchFn(url, init);
|
|
@@ -182,6 +224,106 @@ export function makeCfExec(
|
|
|
182
224
|
async r2Create(name) {
|
|
183
225
|
await run("npx", ["wrangler", "r2", "bucket", "create", name], env);
|
|
184
226
|
},
|
|
227
|
+
|
|
228
|
+
// --- R2 objects (Task 1691) -------------------------------------------
|
|
229
|
+
//
|
|
230
|
+
// The v4 REST API exposes the object surface under
|
|
231
|
+
// /accounts/{id}/r2/buckets/{bucket}/objects and accepts the same Bearer
|
|
232
|
+
// token as the bucket endpoints (account-level Workers R2 Storage Write).
|
|
233
|
+
// The r2/api/tokens/ note that "Object Read & Write" is S3-only constrains
|
|
234
|
+
// the bucket-item-scoped *permission types*, not these endpoints.
|
|
235
|
+
//
|
|
236
|
+
// Doc-confirmed, never measured: no house Cloudflare credential exists on
|
|
237
|
+
// the dev machine. The design keeps the unmeasured facts unable to affect
|
|
238
|
+
// correctness — per_page is never sent, and nothing fails silently.
|
|
239
|
+
async r2ObjectList(bucket, prefix) {
|
|
240
|
+
// Mirrors r2List: follow the cursor to exhaustion, never send per_page.
|
|
241
|
+
// A truncated object listing reads as "those objects are absent" to the
|
|
242
|
+
// data portal's standing audit, producing false phantom rows and hiding
|
|
243
|
+
// real orphans — the same bug r2List had, one endpoint over.
|
|
244
|
+
const base = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/r2/buckets/${encodeURIComponent(bucket)}/objects`;
|
|
245
|
+
const objects: R2Object[] = [];
|
|
246
|
+
const seen = new Set<string>();
|
|
247
|
+
let cursor: string | undefined;
|
|
248
|
+
for (;;) {
|
|
249
|
+
const params = new URLSearchParams();
|
|
250
|
+
if (prefix !== undefined) params.set("prefix", prefix);
|
|
251
|
+
if (cursor !== undefined) params.set("cursor", cursor);
|
|
252
|
+
const qs = params.toString();
|
|
253
|
+
const { parsed, body } = await cfApiJson(
|
|
254
|
+
fetchFn,
|
|
255
|
+
qs ? `${base}?${qs}` : base,
|
|
256
|
+
{ method: "GET", headers: { Authorization: `Bearer ${cred.apiToken}` } },
|
|
257
|
+
"r2 object list",
|
|
258
|
+
);
|
|
259
|
+
const page = parsed as {
|
|
260
|
+
result?: Array<{ key: string; size: number; etag: string; last_modified: string }>;
|
|
261
|
+
result_info?: { cursor?: string | null; is_truncated?: boolean };
|
|
262
|
+
};
|
|
263
|
+
for (const o of page.result ?? []) {
|
|
264
|
+
objects.push({ key: o.key, size: o.size, etag: o.etag, lastModified: o.last_modified });
|
|
265
|
+
}
|
|
266
|
+
const truncated = page.result_info?.is_truncated;
|
|
267
|
+
const next = page.result_info?.cursor;
|
|
268
|
+
// is_truncated is authoritative *when present*: a last page may still
|
|
269
|
+
// carry a cursor, and following it would issue a pointless request or
|
|
270
|
+
// trip the cycle guard below on a healthy response.
|
|
271
|
+
if (truncated === false) return objects;
|
|
272
|
+
// The API says there is more but names no cursor: the response is not
|
|
273
|
+
// the shape we understand. Surface it rather than return a partial
|
|
274
|
+
// list, which is the failure this loop exists to prevent.
|
|
275
|
+
if (truncated === true && !next) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`storage-broker: r2 object list reported truncated but gave no pagination cursor: ${body}`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
// is_truncated absent: it is itself an unmeasured fact, so it must not
|
|
281
|
+
// be what decides correctness. Fall back to cursor presence — the rule
|
|
282
|
+
// r2List uses on the same envelope — so an envelope without the flag
|
|
283
|
+
// paginates rather than truncating silently.
|
|
284
|
+
if (!next) return objects;
|
|
285
|
+
// A cursor seen before means the API is cycling rather than advancing:
|
|
286
|
+
// it would loop forever, re-appending the same objects each pass.
|
|
287
|
+
if (seen.has(next)) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`storage-broker: r2 object list returned a repeated pagination cursor: ${next}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
seen.add(next);
|
|
293
|
+
cursor = next;
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
async r2ObjectGet(bucket, key) {
|
|
297
|
+
// The one method cfApiJson cannot wrap: get returns the raw object body,
|
|
298
|
+
// not a success envelope, so there is nothing to parse or assert
|
|
299
|
+
// success:true on. The error body is still retained on a non-2xx.
|
|
300
|
+
const res = await fetchFn(objectUrl(cred.accountId, bucket, key), {
|
|
301
|
+
method: "GET",
|
|
302
|
+
headers: { Authorization: `Bearer ${cred.apiToken}` },
|
|
303
|
+
});
|
|
304
|
+
if (!res.ok) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`storage-broker: r2 object get failed: ${res.status}\n${await res.text()}`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
310
|
+
},
|
|
311
|
+
async r2ObjectPut(bucket, key, body) {
|
|
312
|
+
await cfApiJson(
|
|
313
|
+
fetchFn,
|
|
314
|
+
objectUrl(cred.accountId, bucket, key),
|
|
315
|
+
{ method: "PUT", headers: { Authorization: `Bearer ${cred.apiToken}` }, body },
|
|
316
|
+
"r2 object put",
|
|
317
|
+
);
|
|
318
|
+
},
|
|
319
|
+
async r2ObjectDelete(bucket, key) {
|
|
320
|
+
await cfApiJson(
|
|
321
|
+
fetchFn,
|
|
322
|
+
objectUrl(cred.accountId, bucket, key),
|
|
323
|
+
{ method: "DELETE", headers: { Authorization: `Bearer ${cred.apiToken}` } },
|
|
324
|
+
"r2 object delete",
|
|
325
|
+
);
|
|
326
|
+
},
|
|
185
327
|
};
|
|
186
328
|
}
|
|
187
329
|
|