@pulse-compute/s3 1.0.0-beta.4 → 1.0.0-beta.6
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/README.md +13 -9
- package/as/read.as.ts +34 -3
- package/conformance/README.md +1 -1
- package/package.json +10 -5
- package/pulse.package.json +4 -2
- package/pulsewasm.compiler.cjs +19 -3
- package/pulsewasm.manifest.cjs +1 -1
- package/src/body.js +55 -0
- package/src/index.d.ts +10 -0
- package/src/index.js +14 -2
- package/src/provider.js +9 -20
- package/src/signing.d.ts +23 -0
- package/src/signing.js +63 -0
package/README.md
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
> **Audience:** Applications using bounded exact-key object reads and writes through Pulse effects.<br>
|
|
6
6
|
> **Install directly:** Yes, when an application uses S3 object operations.<br>
|
|
7
7
|
> **Supported entry points:** `@pulse-compute/s3`<br>
|
|
8
|
-
> **Stability:** The package root supports head, getText and putText on Node Native, Node JavaScript and Fastly Native. Fastly JavaScript is ineligible. Provider and lowering subpaths are toolchain-only; live origin acceptance is separate.<br>
|
|
8
|
+
> **Stability:** The package root supports head, getText, getBody and putText on Node Native, Node JavaScript and Fastly Native. getBody returns bounded opaque binary responses with the documented HTTP subset. Fastly JavaScript is ineligible. Provider and lowering subpaths are toolchain-only; live origin acceptance is separate.<br>
|
|
9
9
|
> **npm:** [`@pulse-compute/s3`](https://www.npmjs.com/package/@pulse-compute/s3)<br>
|
|
10
|
-
> **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.
|
|
10
|
+
> **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.6/packages/s3/)
|
|
11
11
|
>
|
|
12
|
-
> This release-status block is generated from the synchronized `Pulse 1.0.0-beta.
|
|
12
|
+
> This release-status block is generated from the synchronized `Pulse 1.0.0-beta.6` package policy.
|
|
13
13
|
<!-- pulse-package-status:end -->
|
|
14
14
|
|
|
15
15
|
This package provides bounded, exact-key `head`, `getText` and `putText` effects for
|
|
@@ -31,7 +31,7 @@ Put mappings under `<profile>.node.bindings.s3` or
|
|
|
31
31
|
| `bucket`, `region` | Explicit bucket and signing region |
|
|
32
32
|
| `accessKeyIdSecret`, `secretAccessKeySecret` | Named provider secret references |
|
|
33
33
|
| `sessionTokenSecret` | Optional named session-token reference |
|
|
34
|
-
| `maxTextBytes` | 1–
|
|
34
|
+
| `maxTextBytes` | 1–2097152; default 32768 |
|
|
35
35
|
| `timeoutMs` | 1–30000; default 10000 |
|
|
36
36
|
| `backend` | Fastly-only named static backend |
|
|
37
37
|
|
|
@@ -65,19 +65,23 @@ Native targets compose Crypto's AssemblyScript primitives. Fastly JavaScript S3
|
|
|
65
65
|
is ineligible because its SDK projects raw headers and loses required metadata
|
|
66
66
|
information. This target exclusion does not gate supported targets.
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
Select `maxTextBytes: 2097152` per binding for 2 MiB objects; the default remains
|
|
69
|
+
32768. Request/schema limits are independent. Use `schemas.maxBytes: 2097152`
|
|
70
|
+
for a 2 MiB encoded object, and explicitly size larger transport envelopes when
|
|
71
|
+
JSON escaping requires it. The S3/text effect envelope is 12,648,448 bytes.
|
|
72
|
+
Local HTTP ingress also uses `dev.maxBodyBytes`, default 65536. Primary-memory
|
|
73
|
+
Native digest/S3 modules enforce a 256 MiB Wasm maximum; fixed-memory linked
|
|
74
|
+
guests retain their separate ABI. KV values, HMAC and JWT do not grow with S3. The acceptance
|
|
71
75
|
consumer uses generic JSON responses (`strict: false`) to compare the complete
|
|
72
76
|
result unions. Applications using strict JSON must declare response schemas.
|
|
73
77
|
|
|
74
78
|
The package root is the supported application API in the synchronized
|
|
75
|
-
`1.0.0-beta.
|
|
79
|
+
`1.0.0-beta.6` release candidate. Provider, manifest, compiler and Native
|
|
76
80
|
subpaths are trusted toolchain integration, not application imports. Install
|
|
77
81
|
S3 with the exact compatible Pulse, CLI and provider candidate set; candidate
|
|
78
82
|
preparation does not imply npm publication.
|
|
79
83
|
|
|
80
|
-
See the [S3 guide](https://pulsecompute.io/v1.0.0-beta.
|
|
84
|
+
See the [S3 guide](https://pulsecompute.io/v1.0.0-beta.6/packages/s3/) for the
|
|
81
85
|
lowering contract, support matrix and acceptance boundaries. Local acceptance
|
|
82
86
|
executes Node Native, Node JavaScript and compiled Fastly Native Wasm against
|
|
83
87
|
controlled origins. Live Object Storage evidence follows infrastructure setup.
|
package/as/read.as.ts
CHANGED
|
@@ -83,11 +83,11 @@ function __pulse_s3_date(seconds: i64): string {
|
|
|
83
83
|
class __PulseS3Signature {
|
|
84
84
|
date: string = ''; digest: string = ''; authorization: string = '';
|
|
85
85
|
}
|
|
86
|
-
function __pulse_s3_sign(method: string, path: string, host: string, region: string, id: string, secret: string, token: string, seconds: i64, body: Uint8Array, contentType: string): __PulseS3Signature {
|
|
86
|
+
function __pulse_s3_sign(method: string, path: string, host: string, region: string, id: string, secret: string, token: string, seconds: i64, body: Uint8Array, contentType: string, range: string = '', ifNoneMatch: string = ''): __PulseS3Signature {
|
|
87
87
|
const result = new __PulseS3Signature(); result.date = __pulse_s3_date(seconds);
|
|
88
88
|
result.digest = __pulse_s3_hex(__pulse_s3_sha(body));
|
|
89
|
-
const names = (contentType.length ? 'content-type;' : '') + 'host;x-amz-content-sha256;x-amz-date' + (token.length ? ';x-amz-security-token' : '');
|
|
90
|
-
const headers = (contentType.length ? 'content-type:' + __pulse_s3_header_spaces(contentType) + '\n' : '') + 'host:' + host + '\
|
|
89
|
+
const names = (contentType.length ? 'content-type;' : '') + 'host;' + (ifNoneMatch.length ? 'if-none-match;' : '') + (range.length ? 'range;' : '') + 'x-amz-content-sha256;x-amz-date' + (token.length ? ';x-amz-security-token' : '');
|
|
90
|
+
const headers = (contentType.length ? 'content-type:' + __pulse_s3_header_spaces(contentType) + '\n' : '') + 'host:' + host + '\n' + (ifNoneMatch.length ? 'if-none-match:' + ifNoneMatch + '\n' : '') + (range.length ? 'range:' + range + '\n' : '') + 'x-amz-content-sha256:' + result.digest + '\nx-amz-date:' + result.date + '\n'
|
|
91
91
|
+ (token.length ? 'x-amz-security-token:' + token + '\n' : '');
|
|
92
92
|
const canonical = method + '\n' + path + '\n\n' + headers + '\n' + names + '\n' + result.digest;
|
|
93
93
|
const day = result.date.slice(0, 8), scope = day + '/' + region + '/s3/aws4_request';
|
|
@@ -113,3 +113,34 @@ function __pulse_s3_header_spaces(text: string): string {
|
|
|
113
113
|
}
|
|
114
114
|
return output;
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
// Canonical decimal/range validation shared by Native body transports.
|
|
118
|
+
function __pulse_s3_decimal(value: string): f64 {
|
|
119
|
+
if (!value.length || (value.length > 1 && value.charCodeAt(0) == 48)) return -1;
|
|
120
|
+
let result: f64 = 0;
|
|
121
|
+
for (let i = 0; i < value.length; i++) {
|
|
122
|
+
const digit = value.charCodeAt(i) - 48;
|
|
123
|
+
if (digit < 0 || digit > 9) return -1;
|
|
124
|
+
result = result * 10 + digit;
|
|
125
|
+
if (result > 9007199254740991) return -1;
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
function __pulse_s3_body_range(status: i32, length: f64, contentRange: string, range: string, conditional: bool): bool {
|
|
130
|
+
if (status == 304 && !conditional) return false;
|
|
131
|
+
if (status == 206) {
|
|
132
|
+
if (!range.length || !contentRange.startsWith('bytes ')) return false;
|
|
133
|
+
const parts = contentRange.slice(6).split('/'), request = range.slice(6).split('-');
|
|
134
|
+
if (parts.length != 2 || request.length != 2) return false;
|
|
135
|
+
const interval = parts[0].split('-'); if (interval.length != 2) return false;
|
|
136
|
+
const start = __pulse_s3_decimal(interval[0]), end = __pulse_s3_decimal(interval[1]), total = __pulse_s3_decimal(parts[1]);
|
|
137
|
+
return start >= 0 && total >= 0 && start == __pulse_s3_decimal(request[0])
|
|
138
|
+
&& end == Math.min(__pulse_s3_decimal(request[1]), total - 1) && end >= start && length == end - start + 1;
|
|
139
|
+
}
|
|
140
|
+
if (status == 416) {
|
|
141
|
+
if (!range.length || !contentRange.startsWith('bytes */')) return false;
|
|
142
|
+
const total = __pulse_s3_decimal(contentRange.slice(8));
|
|
143
|
+
return total >= 0 && __pulse_s3_decimal(range.slice(6).split('-')[0]) >= total;
|
|
144
|
+
}
|
|
145
|
+
return !contentRange.length && (status == 200 || status == 304 || status == 404 || status == 412);
|
|
146
|
+
}
|
package/conformance/README.md
CHANGED
|
@@ -10,4 +10,4 @@ metadata limits, truncation, deadlines, write uncertainty and secret redaction.
|
|
|
10
10
|
Fastly Native executes compiled Wasm against host ABI fixtures. These checks
|
|
11
11
|
do not establish live origin behavior; that evidence follows infrastructure
|
|
12
12
|
setup. Fastly JavaScript is ineligible for S3 because its SDK loses raw header
|
|
13
|
-
metadata. See the [S3 guide](https://pulsecompute.io/v1.0.0-beta.
|
|
13
|
+
metadata. See the [S3 guide](https://pulsecompute.io/v1.0.0-beta.6/packages/s3/).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pulse-compute/s3",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.6",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": "^22.14.0 || ^24.0.0"
|
|
@@ -18,7 +18,12 @@
|
|
|
18
18
|
"./provider": "./src/provider.js",
|
|
19
19
|
"./pulsewasm/manifest": "./pulsewasm.manifest.cjs",
|
|
20
20
|
"./pulsewasm/compiler": "./pulsewasm.compiler.cjs",
|
|
21
|
-
"./pulsewasm-native": "./pulsewasm.native.cjs"
|
|
21
|
+
"./pulsewasm-native": "./pulsewasm.native.cjs",
|
|
22
|
+
"./signing": {
|
|
23
|
+
"types": "./src/signing.d.ts",
|
|
24
|
+
"require": "./src/signing.js",
|
|
25
|
+
"default": "./src/signing.js"
|
|
26
|
+
}
|
|
22
27
|
},
|
|
23
28
|
"files": [
|
|
24
29
|
"src",
|
|
@@ -39,8 +44,8 @@
|
|
|
39
44
|
"manifest": "./pulsewasm.manifest.cjs"
|
|
40
45
|
},
|
|
41
46
|
"dependencies": {
|
|
42
|
-
"@pulse-compute/runtime": "1.0.0-beta.
|
|
43
|
-
"@pulse-compute/wasm-contracts": "1.0.0-beta.
|
|
47
|
+
"@pulse-compute/runtime": "1.0.0-beta.6",
|
|
48
|
+
"@pulse-compute/wasm-contracts": "1.0.0-beta.6"
|
|
44
49
|
},
|
|
45
50
|
"publishConfig": {
|
|
46
51
|
"access": "public"
|
|
@@ -50,7 +55,7 @@
|
|
|
50
55
|
"url": "git+https://github.com/pulse-compute/pulse.git",
|
|
51
56
|
"directory": "packages/s3"
|
|
52
57
|
},
|
|
53
|
-
"homepage": "https://pulsecompute.io/v1.0.0-beta.
|
|
58
|
+
"homepage": "https://pulsecompute.io/v1.0.0-beta.6/packages/s3/",
|
|
54
59
|
"bugs": {
|
|
55
60
|
"url": "https://github.com/pulse-compute/pulse/issues"
|
|
56
61
|
}
|
package/pulse.package.json
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"symbols": [
|
|
11
11
|
"head",
|
|
12
12
|
"getText",
|
|
13
|
-
"putText"
|
|
13
|
+
"putText",
|
|
14
|
+
"getBody"
|
|
14
15
|
]
|
|
15
16
|
},
|
|
16
17
|
"ownership": {
|
|
@@ -66,7 +67,8 @@
|
|
|
66
67
|
"semanticCases": [
|
|
67
68
|
"s3.head",
|
|
68
69
|
"s3.getText",
|
|
69
|
-
"s3.putText"
|
|
70
|
+
"s3.putText",
|
|
71
|
+
"s3.getBody"
|
|
70
72
|
]
|
|
71
73
|
},
|
|
72
74
|
"docs": {
|
package/pulsewasm.compiler.cjs
CHANGED
|
@@ -3,7 +3,7 @@ const { normalizeDiagnostic, sourceLoc } = require('@pulse-compute/wasm-contract
|
|
|
3
3
|
const { CANONICAL_PACKAGE_EFFECT_VERSION } = require('@pulse-compute/wasm-contracts/handler/canonical-runtime');
|
|
4
4
|
const { PACKAGE_CRYPTO_REQUIREMENT_VERSION } = require('@pulse-compute/wasm-contracts/package/package-contract');
|
|
5
5
|
const { S3_PACKAGE, S3_CONTRACT_ID, S3_OPERATIONS, S3_LOWERING_PLAN_VERSION, S3_CRYPTO_ALGORITHMS } = require('@pulse-compute/wasm-contracts/s3/contracts');
|
|
6
|
-
const { normalizePutOptions } = require('./src/provider.js');
|
|
6
|
+
const { normalizePutOptions, normalizeBodyOptions } = require('./src/provider.js');
|
|
7
7
|
|
|
8
8
|
function buildS3LoweringPlan(input = {}) {
|
|
9
9
|
const ts = input.typescript || require('typescript');
|
|
@@ -35,12 +35,13 @@ function buildS3LoweringPlan(input = {}) {
|
|
|
35
35
|
&& ts.isIdentifier(node.expression.expression) && namespaces.has(node.expression.expression.text)) {
|
|
36
36
|
const method = node.expression.name.text;
|
|
37
37
|
const op = S3_OPERATIONS[method];
|
|
38
|
-
if (!op) { error(node, 'OPERATION_UNSUPPORTED', 'S3 supports head, getText and putText.'); return; }
|
|
38
|
+
if (!op) { error(node, 'OPERATION_UNSUPPORTED', 'S3 supports head, getText, getBody and putText.'); return; }
|
|
39
39
|
const put = method === 'putText';
|
|
40
|
+
const body = method === 'getBody';
|
|
40
41
|
let fn = node.parent;
|
|
41
42
|
while (fn && !ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn)) fn = fn.parent;
|
|
42
43
|
const ctx = fn && fn.parameters[0] && fn.parameters[0].name;
|
|
43
|
-
if (!(put ? [4, 5].includes(node.arguments.length) : node.arguments.length === 3) || !ctx || !ts.isIdentifier(ctx)
|
|
44
|
+
if (!(put ? [4, 5].includes(node.arguments.length) : body ? [3, 4].includes(node.arguments.length) : node.arguments.length === 3) || !ctx || !ts.isIdentifier(ctx)
|
|
44
45
|
|| !ts.isIdentifier(node.arguments[0]) || node.arguments[0].text !== ctx.text) {
|
|
45
46
|
error(node, 'ARGUMENTS_UNSUPPORTED', 'S3 requires current handler context, literal binding, runtime key and, for PUT, text with optional literal options.'); return;
|
|
46
47
|
}
|
|
@@ -63,6 +64,21 @@ function buildS3LoweringPlan(input = {}) {
|
|
|
63
64
|
options = normalizePutOptions(options);
|
|
64
65
|
} catch { error(nodeOptions || node, 'OPTIONS_UNSUPPORTED', 'PUT options must be a literal object containing only a valid literal contentType.'); return; }
|
|
65
66
|
}
|
|
67
|
+
if (body) {
|
|
68
|
+
const nodeOptions = node.arguments[3];
|
|
69
|
+
try {
|
|
70
|
+
if (nodeOptions) {
|
|
71
|
+
if (!ts.isObjectLiteralExpression(nodeOptions) || nodeOptions.properties.length > 3) throw new TypeError();
|
|
72
|
+
for (const property of nodeOptions.properties) {
|
|
73
|
+
if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name) && !ts.isStringLiteralLike(property.name)
|
|
74
|
+
|| !['method', 'range', 'ifNoneMatch'].includes(property.name.text) || Object.hasOwn(options, property.name.text)
|
|
75
|
+
|| !ts.isStringLiteralLike(property.initializer)) throw new TypeError();
|
|
76
|
+
options[property.name.text] = property.initializer.text;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
options = normalizeBodyOptions(options);
|
|
80
|
+
} catch { error(nodeOptions || node, 'OPTIONS_UNSUPPORTED', 'Body options require literal method, closed byte range and/or one If-None-Match validator.'); return; }
|
|
81
|
+
}
|
|
66
82
|
let placement = '';
|
|
67
83
|
const parent = node.parent;
|
|
68
84
|
if (ts.isAwaitExpression(parent) && ts.isVariableDeclaration(parent.parent)) placement = 'variable';
|
package/pulsewasm.manifest.cjs
CHANGED
|
@@ -3,7 +3,7 @@ const { S3_PACKAGE, S3_CONTRACT_ID, S3_OPERATIONS } = require('@pulse-compute/wa
|
|
|
3
3
|
module.exports = Object.freeze({
|
|
4
4
|
version: 'pulsewasm.lowerable-library-manifest.v2', kind: 'pulsewasm.lowerable-library-manifest',
|
|
5
5
|
contractId: S3_CONTRACT_ID, npmPackage: S3_PACKAGE, lowerableSubpath: S3_PACKAGE,
|
|
6
|
-
facade: { namespace: 's3', import: S3_PACKAGE, symbols: ['head', 'getText', 'putText'] },
|
|
6
|
+
facade: { namespace: 's3', import: S3_PACKAGE, symbols: ['head', 'getText', 'putText', 'getBody'] },
|
|
7
7
|
publicApi: { package: S3_PACKAGE, symbols: ['s3'], compatibilitySubpaths: [], loweringIntentionallyNarrow: true },
|
|
8
8
|
compiler: { version: 'pulsewasm.lowerable-compiler-builder.v1', entry: './pulsewasm.compiler.cjs', export: 'buildS3LoweringPlan', artifact: 's3-lowering-plan.json', builderOwner: S3_PACKAGE, trust: 'first-party', inputs: ['typescript.SourceFile'] },
|
|
9
9
|
modes: {
|
package/src/body.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Protocol-only helpers. The provider owns transport, deadlines and body handles.
|
|
4
|
+
function normalizeBodyOptions(input = {}) {
|
|
5
|
+
if (!input || typeof input !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(input))) throw new TypeError('Invalid S3 body options.');
|
|
6
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
7
|
+
const field = Object.getOwnPropertyDescriptor(input, key);
|
|
8
|
+
if (!['method', 'range', 'ifNoneMatch'].includes(key) || !field.enumerable || !Object.hasOwn(field, 'value')) throw new TypeError('Invalid S3 body option.');
|
|
9
|
+
}
|
|
10
|
+
const method = input.method ?? 'GET';
|
|
11
|
+
if (!['GET', 'HEAD'].includes(method)) throw new TypeError('S3 body method must be GET or HEAD.');
|
|
12
|
+
if (input.range !== undefined) {
|
|
13
|
+
const match = typeof input.range === 'string' && /^bytes=(0|[1-9][0-9]*)-(0|[1-9][0-9]*)$/.exec(input.range);
|
|
14
|
+
if (method !== 'GET' || !match || !Number.isSafeInteger(Number(match[2])) || Number(match[1]) > Number(match[2])) throw new TypeError('S3 body requires one closed byte range.');
|
|
15
|
+
}
|
|
16
|
+
if (input.ifNoneMatch !== undefined && (typeof input.ifNoneMatch !== 'string' || input.ifNoneMatch.length > 1024
|
|
17
|
+
|| !/^(?:\*|(?:W\/)?"[\x21\x23-\x7e]*")$/.test(input.ifNoneMatch))) throw new TypeError('S3 body requires one entity tag or *.');
|
|
18
|
+
return Object.freeze({ method, ...(input.range === undefined ? {} : { range: input.range }), ...(input.ifNoneMatch === undefined ? {} : { ifNoneMatch: input.ifNoneMatch }) });
|
|
19
|
+
}
|
|
20
|
+
function bodyRequestHeaders(input) {
|
|
21
|
+
const options = normalizeBodyOptions(input);
|
|
22
|
+
return { ...(options.range === undefined ? {} : { range: options.range }), ...(options.ifNoneMatch === undefined ? {} : { 'if-none-match': options.ifNoneMatch }) };
|
|
23
|
+
}
|
|
24
|
+
function bodyResponseMetadata(status, headers, options, maximum) {
|
|
25
|
+
const { metadataFromHeaders } = require('./provider.js');
|
|
26
|
+
const metadata = metadataFromHeaders(headers, status === 200 || status === 206);
|
|
27
|
+
if (!metadata) throw new Error('protocol');
|
|
28
|
+
let contentRange;
|
|
29
|
+
for (const [name, value] of headers) if (name.toLowerCase() === 'content-range') {
|
|
30
|
+
if (contentRange !== undefined || value.length > 128) throw new Error('protocol');
|
|
31
|
+
contentRange = value;
|
|
32
|
+
}
|
|
33
|
+
if (status === 304 && options.ifNoneMatch === undefined) throw new Error('protocol');
|
|
34
|
+
if (status === 206) {
|
|
35
|
+
const match = contentRange && /^bytes (0|[1-9][0-9]*)-(0|[1-9][0-9]*)\/(0|[1-9][0-9]*)$/.exec(contentRange);
|
|
36
|
+
const range = options.range && /^bytes=(\d+)-(\d+)$/.exec(options.range);
|
|
37
|
+
if (!match || !range) throw new Error('protocol');
|
|
38
|
+
const [start, end, total] = match.slice(1).map(Number);
|
|
39
|
+
if (!Number.isSafeInteger(total) || start !== Number(range[1]) || end !== Math.min(Number(range[2]), total - 1)
|
|
40
|
+
|| start > end || metadata.byteLength !== end - start + 1) throw new Error('protocol');
|
|
41
|
+
} else if (status === 416) {
|
|
42
|
+
const match = contentRange && /^bytes \*\/(0|[1-9][0-9]*)$/.exec(contentRange);
|
|
43
|
+
if (!options.range || !match || !Number.isSafeInteger(Number(match[1])) || Number(options.range.slice(6).split('-')[0]) < Number(match[1])) throw new Error('protocol');
|
|
44
|
+
} else if (contentRange !== undefined) throw new Error('protocol');
|
|
45
|
+
if (options.method !== 'HEAD' && [200, 206].includes(status) && metadata.byteLength > maximum) throw new Error('too-large');
|
|
46
|
+
if (![200, 206, 304, 404, 412, 416].includes(status)) throw new Error('protocol');
|
|
47
|
+
const selected = new Headers();
|
|
48
|
+
if (metadata.byteLength !== undefined && [200, 206].includes(status)) selected.set('content-length', String(metadata.byteLength));
|
|
49
|
+
if (metadata.contentType !== undefined) selected.set('content-type', metadata.contentType);
|
|
50
|
+
if (metadata.etag !== undefined) selected.set('etag', metadata.etag);
|
|
51
|
+
if (contentRange !== undefined) selected.set('content-range', contentRange);
|
|
52
|
+
// No response digest is asserted, including for partial representations.
|
|
53
|
+
return { headers: selected, byteLength: metadata.byteLength, body: options.method !== 'HEAD' && [200, 206].includes(status) };
|
|
54
|
+
}
|
|
55
|
+
module.exports = { normalizeBodyOptions, bodyRequestHeaders, bodyResponseMetadata };
|
package/src/index.d.ts
CHANGED
|
@@ -43,7 +43,17 @@ export interface S3PutTextOptions {
|
|
|
43
43
|
|
|
44
44
|
// Binding and options must be compiler-proven literals. Key and text may be
|
|
45
45
|
// runtime strings. TypeScript alone cannot enforce that lowering restriction.
|
|
46
|
+
export interface S3GetBodyOptions {
|
|
47
|
+
readonly method?: 'GET' | 'HEAD';
|
|
48
|
+
/** One closed byte range, for example bytes=0-1023; GET only. */
|
|
49
|
+
readonly range?: string;
|
|
50
|
+
/** One entity tag or *, without a comma-separated list. */
|
|
51
|
+
readonly ifNoneMatch?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
export declare const s3: {
|
|
55
|
+
/** Opaque binary response; capped by maxTextBytes. Return without decoding. */
|
|
56
|
+
getBody(ctx: PulseContext, binding: string, key: string, options?: S3GetBodyOptions): PulseParallelEffect<Response>;
|
|
47
57
|
head(ctx: PulseContext, binding: string, key: string): PulseParallelEffect<S3HeadResult>;
|
|
48
58
|
getText(ctx: PulseContext, binding: string, key: string): PulseParallelEffect<S3GetTextResult>;
|
|
49
59
|
putText(ctx: PulseContext, binding: string, key: string, text: string,
|
package/src/index.js
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const { createPackageRuntime } = require('@pulse-compute/runtime/package');
|
|
3
|
-
const { S3_PACKAGE, S3_CONTRACT_ID, S3_OPERATIONS } = require('@pulse-compute/wasm-contracts/s3/contracts');
|
|
3
|
+
const { S3_PACKAGE, S3_CONTRACT_ID, S3_OPERATIONS, S3_LIMITS } = require('@pulse-compute/wasm-contracts/s3/contracts');
|
|
4
4
|
const { normalizeResult, normalizePutOptions } = require('./provider.js');
|
|
5
|
+
const { normalizeBodyOptions } = require('./body.js');
|
|
5
6
|
const runtime = createPackageRuntime({ package: S3_PACKAGE, contractId: S3_CONTRACT_ID, providerKind: 's3', operations: S3_OPERATIONS });
|
|
6
7
|
exports.s3 = Object.freeze({
|
|
8
|
+
getBody(ctx, binding, key, options) {
|
|
9
|
+
return runtime.effect(ctx, 'getBody', { binding, key, ...normalizeBodyOptions(options) }, result => {
|
|
10
|
+
if (!(result instanceof Response)) throw new TypeError('S3 getBody requires an opaque provider Response.');
|
|
11
|
+
return result;
|
|
12
|
+
});
|
|
13
|
+
},
|
|
7
14
|
head(ctx, binding, key) { return runtime.effect(ctx, 'head', { binding, key }, (result) => normalizeResult('head', result)); },
|
|
8
15
|
getText(ctx, binding, key) { return runtime.effect(ctx, 'getText', { binding, key }, (result) => normalizeResult('getText', result)); },
|
|
9
|
-
putText(ctx, binding, key, text, options) {
|
|
16
|
+
putText(ctx, binding, key, text, options) {
|
|
17
|
+
// Retain an over-limit sentinel without copying an unbounded string into
|
|
18
|
+
// the effect envelope. Providers reject it before encoding or dispatch.
|
|
19
|
+
const boundedText = typeof text === 'string' ? text.slice(0, S3_LIMITS.textBytes + 1) : null;
|
|
20
|
+
return runtime.effect(ctx, 'putText', { binding, key, text: boundedText, ...normalizePutOptions(options) }, (result) => normalizeResult('putText', result));
|
|
21
|
+
}
|
|
10
22
|
});
|
package/src/provider.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Trusted provider surface; never an application lowering escape. Protocol
|
|
4
4
|
// composition is S3-owned, while crypto, secrets and I/O are supplied by hosts.
|
|
5
5
|
const { S3_LIMITS } = require('@pulse-compute/wasm-contracts/s3/contracts');
|
|
6
|
+
const { encodeS3Key, createAuthorization } = require('./signing.js');
|
|
6
7
|
const encoder = new TextEncoder();
|
|
7
8
|
const namePattern = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
8
9
|
const bytes = (text) => encoder.encode(text);
|
|
@@ -26,7 +27,7 @@ function normalizeBinding(value) {
|
|
|
26
27
|
if (field === 'sessionTokenSecret' && input[field] === undefined) continue;
|
|
27
28
|
if (typeof input[field] !== 'string' || !namePattern.test(input[field])) throw new TypeError('S3 credentials must be valid named secret references.');
|
|
28
29
|
}
|
|
29
|
-
const maxTextBytes = input.maxTextBytes === undefined ? S3_LIMITS.
|
|
30
|
+
const maxTextBytes = input.maxTextBytes === undefined ? S3_LIMITS.defaultTextBytes : input.maxTextBytes;
|
|
30
31
|
const timeoutMs = input.timeoutMs === undefined ? 10000 : input.timeoutMs;
|
|
31
32
|
if (!Number.isInteger(maxTextBytes) || maxTextBytes < 1 || maxTextBytes > S3_LIMITS.textBytes || !Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000) throw new TypeError('S3 limits are outside the bounded subset.');
|
|
32
33
|
return Object.freeze({ ...input, endpoint: url.origin, maxTextBytes, timeoutMs });
|
|
@@ -34,9 +35,7 @@ function normalizeBinding(value) {
|
|
|
34
35
|
function encodeKey(key) {
|
|
35
36
|
if (typeof key !== 'string' || !key.isWellFormed() || !key.length || bytes(key).length > S3_LIMITS.keyBytes
|
|
36
37
|
|| /[\u0000-\u001f\u007f-\u009f]/u.test(key) || key.split('/').some((p) => p === '.' || p === '..')) return null;
|
|
37
|
-
|
|
38
|
-
// AssetBucket normalization, path joining, middleware or cache policy.
|
|
39
|
-
return key.split('/').map((part) => encodeURIComponent(part).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)).join('/');
|
|
38
|
+
return encodeS3Key(key);
|
|
40
39
|
}
|
|
41
40
|
function credentialsValid(accessId, secret, token) {
|
|
42
41
|
return typeof accessId === 'string' && /^[\x21-\x7e]{1,256}$/.test(accessId)
|
|
@@ -59,22 +58,12 @@ async function signRequest(input, crypto) {
|
|
|
59
58
|
const digest = hex(await crypto.sha256(body));
|
|
60
59
|
const host = new URL(binding.endpoint).host;
|
|
61
60
|
const headers = { ...(contentType === undefined ? {} : { 'content-type': contentType }), host, 'x-amz-content-sha256': digest, 'x-amz-date': date, ...(token === undefined ? {} : { 'x-amz-security-token': token }) };
|
|
62
|
-
|
|
61
|
+
if (input.readOptions) Object.assign(headers, require('./body.js').bodyRequestHeaders(input.readOptions));
|
|
63
62
|
const uri = `/${binding.bucket}/${encodedKey}`;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
for (const part of [date.slice(0, 8), binding.region, 's3', 'aws4_request']) {
|
|
70
|
-
const next = await crypto.hmacSha256(key, bytes(part)); key.fill(0); key = next;
|
|
71
|
-
}
|
|
72
|
-
const signature = await crypto.hmacSha256(key, bytes(toSign));
|
|
73
|
-
headers.authorization = `AWS4-HMAC-SHA256 Credential=${accessId}/${scope}, SignedHeaders=${names.join(';')}, Signature=${hex(signature)}`;
|
|
74
|
-
signature.fill(0);
|
|
75
|
-
headers['accept-encoding'] = 'identity';
|
|
76
|
-
return { method, url: binding.endpoint + uri, headers, ...(method === 'PUT' ? { body } : {}) };
|
|
77
|
-
} finally { key.fill(0); }
|
|
63
|
+
headers.authorization = await createAuthorization({ method, uri, query: '', headers, date,
|
|
64
|
+
region: binding.region, service: 's3', accessId, secret, payloadHash: digest }, crypto);
|
|
65
|
+
headers['accept-encoding'] = 'identity';
|
|
66
|
+
return { method, url: binding.endpoint + uri, headers, ...(method === 'PUT' ? { body } : {}) };
|
|
78
67
|
}
|
|
79
68
|
function failure(reason, httpStatus) { return Object.freeze({ status: 'failed', reason, ...(httpStatus === undefined ? {} : { httpStatus }) }); }
|
|
80
69
|
function putFailure(reason, dispatched, httpStatus) {
|
|
@@ -133,4 +122,4 @@ function normalizeResult(operation, result) {
|
|
|
133
122
|
if (bytes(JSON.stringify(result)).length > S3_LIMITS.envelopeBytes) throw new TypeError('S3 result envelope exceeds its bound.');
|
|
134
123
|
return Object.freeze({ ...result });
|
|
135
124
|
}
|
|
136
|
-
module.exports = Object.freeze({ normalizeBinding, namePattern, encodeKey, credentialsValid, normalizePutOptions, signRequest, signRead: signRequest, failure, putFailure, putStatusResult, statusResult, metadataFromHeaders, normalizeResult, hex, bytes });
|
|
125
|
+
module.exports = Object.freeze({ ...require('./body.js'), normalizeBinding, namePattern, encodeKey, credentialsValid, normalizePutOptions, signRequest, signRead: signRequest, failure, putFailure, putStatusResult, statusResult, metadataFromHeaders, normalizeResult, hex, bytes });
|
package/src/signing.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Trusted first-party host integration, not application lowering authority. */
|
|
2
|
+
export interface SigV4Crypto {
|
|
3
|
+
sha256(bytes: Uint8Array): Promise<Uint8Array>;
|
|
4
|
+
hmacSha256(key: Uint8Array, bytes: Uint8Array): Promise<Uint8Array>;
|
|
5
|
+
}
|
|
6
|
+
export interface HttpSigningInput {
|
|
7
|
+
method: string;
|
|
8
|
+
url: URL | string;
|
|
9
|
+
accessId: string;
|
|
10
|
+
secret: string;
|
|
11
|
+
token?: string | undefined;
|
|
12
|
+
region?: string | undefined;
|
|
13
|
+
service?: string | undefined;
|
|
14
|
+
headers?: HeadersInit | undefined;
|
|
15
|
+
now?: Date | undefined;
|
|
16
|
+
payloadHash?: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
export declare function encodeS3Key(key: string): string;
|
|
19
|
+
export declare function signHttpRequest(input: HttpSigningInput, crypto: SigV4Crypto): Promise<Request>;
|
|
20
|
+
export declare function createAuthorization(input: {
|
|
21
|
+
method: string; uri: string; query: string; headers: Readonly<Record<string, string>>;
|
|
22
|
+
date: string; region: string; service: string; accessId: string; secret: string; payloadHash: string;
|
|
23
|
+
}, crypto: SigV4Crypto): Promise<string>;
|
package/src/signing.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// First-party host integration only. S3 owns protocol composition; callers
|
|
4
|
+
// supply resolved credentials and an explicitly selected SHA/HMAC realization.
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
const bytes = value => encoder.encode(value);
|
|
7
|
+
const hex = value => Array.from(value, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
8
|
+
const encodeRfc3986 = value => encodeURIComponent(value).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
9
|
+
|
|
10
|
+
function encodeS3Key(key) {
|
|
11
|
+
// Encoding only: portable key admission and legacy Assets path policy remain
|
|
12
|
+
// separate. In particular, this preserves empty and repeated segments.
|
|
13
|
+
return key.split('/').map(encodeRfc3986).join('/');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function canonicalQuery(url) {
|
|
17
|
+
const pairs = [];
|
|
18
|
+
url.searchParams.forEach((value, key) => pairs.push([encodeRfc3986(key), encodeRfc3986(value)]));
|
|
19
|
+
pairs.sort(([ak, av], [bk, bv]) => ak === bk ? (av < bv ? -1 : av > bv ? 1 : 0) : ak < bk ? -1 : 1);
|
|
20
|
+
return pairs.map(([key, value]) => `${key}=${value}`).join('&');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function createAuthorization(input, crypto) {
|
|
24
|
+
const { method, uri, query, headers, date, region, service, accessId, secret, payloadHash } = input;
|
|
25
|
+
const names = Object.keys(headers).sort();
|
|
26
|
+
const canonicalHeaders = names.map(name => `${name}:${headers[name].trim().replace(/\s+/g, ' ')}\n`).join('');
|
|
27
|
+
const canonical = [method, uri, query, canonicalHeaders, names.join(';'), payloadHash].join('\n');
|
|
28
|
+
const stamp = date.slice(0, 8);
|
|
29
|
+
const scope = `${stamp}/${region}/${service}/aws4_request`;
|
|
30
|
+
const toSign = `AWS4-HMAC-SHA256\n${date}\n${scope}\n${hex(await crypto.sha256(bytes(canonical)))}`;
|
|
31
|
+
let key = bytes(`AWS4${secret}`);
|
|
32
|
+
try {
|
|
33
|
+
for (const part of [stamp, region, service, 'aws4_request']) {
|
|
34
|
+
const next = await crypto.hmacSha256(key, bytes(part)); key.fill(0); key = next;
|
|
35
|
+
}
|
|
36
|
+
const signature = await crypto.hmacSha256(key, bytes(toSign));
|
|
37
|
+
try { return `AWS4-HMAC-SHA256 Credential=${accessId}/${scope}, SignedHeaders=${names.join(';')}, Signature=${hex(signature)}`; }
|
|
38
|
+
finally { signature.fill(0); }
|
|
39
|
+
} finally { key.fill(0); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function signHttpRequest(input, crypto) {
|
|
43
|
+
const url = new URL(input.url.toString());
|
|
44
|
+
const method = input.method.toUpperCase();
|
|
45
|
+
const date = (input.now ?? new Date()).toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
46
|
+
const payloadHash = input.payloadHash ?? 'UNSIGNED-PAYLOAD';
|
|
47
|
+
const headers = new Headers(input.headers);
|
|
48
|
+
headers.set('x-amz-date', date);
|
|
49
|
+
headers.set('x-amz-content-sha256', payloadHash);
|
|
50
|
+
if (input.token) headers.set('x-amz-security-token', input.token);
|
|
51
|
+
const signingHeaders = Object.create(null);
|
|
52
|
+
headers.forEach((value, name) => { if (name !== 'authorization') signingHeaders[name] = value; });
|
|
53
|
+
signingHeaders.host = url.host;
|
|
54
|
+
headers.set('authorization', await createAuthorization({ method, uri: url.pathname || '/', query: canonicalQuery(url),
|
|
55
|
+
headers: signingHeaders, date, region: input.region ?? 'us-east-1', service: input.service ?? 's3',
|
|
56
|
+
accessId: input.accessId, secret: input.secret, payloadHash }, crypto));
|
|
57
|
+
return new Request(url, { method, headers });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
exports.encodeS3Key = encodeS3Key;
|
|
61
|
+
exports.createAuthorization = createAuthorization;
|
|
62
|
+
exports.signHttpRequest = signHttpRequest;
|
|
63
|
+
Object.freeze(module.exports);
|