@pulse-compute/s3 1.0.0-beta.5 → 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 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.5/packages/s3/)
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.5` package policy.
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
@@ -76,12 +76,12 @@ consumer uses generic JSON responses (`strict: false`) to compare the complete
76
76
  result unions. Applications using strict JSON must declare response schemas.
77
77
 
78
78
  The package root is the supported application API in the synchronized
79
- `1.0.0-beta.5` release candidate. Provider, manifest, compiler and Native
79
+ `1.0.0-beta.6` release candidate. Provider, manifest, compiler and Native
80
80
  subpaths are trusted toolchain integration, not application imports. Install
81
81
  S3 with the exact compatible Pulse, CLI and provider candidate set; candidate
82
82
  preparation does not imply npm publication.
83
83
 
84
- See the [S3 guide](https://pulsecompute.io/v1.0.0-beta.5/packages/s3/) for the
84
+ See the [S3 guide](https://pulsecompute.io/v1.0.0-beta.6/packages/s3/) for the
85
85
  lowering contract, support matrix and acceptance boundaries. Local acceptance
86
86
  executes Node Native, Node JavaScript and compiled Fastly Native Wasm against
87
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 + '\nx-amz-content-sha256:' + result.digest + '\nx-amz-date:' + result.date + '\n'
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
+ }
@@ -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.5/packages/s3/).
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.5",
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.5",
43
- "@pulse-compute/wasm-contracts": "1.0.0-beta.5"
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.5/packages/s3/",
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
  }
@@ -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": {
@@ -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';
@@ -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
@@ -2,8 +2,15 @@
2
2
  const { createPackageRuntime } = require('@pulse-compute/runtime/package');
3
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
16
  putText(ctx, binding, key, text, options) {
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);
@@ -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
- // One-time adaptation of Assets' RFC3986 segment encoder. Deliberately no
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
- const names = Object.keys(headers).sort();
61
+ if (input.readOptions) Object.assign(headers, require('./body.js').bodyRequestHeaders(input.readOptions));
63
62
  const uri = `/${binding.bucket}/${encodedKey}`;
64
- const canonical = `${method}\n${uri}\n\n${names.map((name) => `${name}:${headers[name].replace(/ +/g, ' ')}\n`).join('')}\n${names.join(';')}\n${digest}`;
65
- const scope = `${date.slice(0, 8)}/${binding.region}/s3/aws4_request`;
66
- const toSign = `AWS4-HMAC-SHA256\n${date}\n${scope}\n${hex(await crypto.sha256(bytes(canonical)))}`;
67
- let key = bytes(`AWS4${secret}`);
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 });
@@ -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);