lambder 9.0.1 → 9.0.3
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/CHANGELOG.md +18 -0
- package/README.md +8 -5
- package/dist/api/LambderApiPipeline.d.ts +1 -1
- package/dist/api/LambderApiPipeline.js +1 -1
- package/dist/api/LambderApiSignature.js +1 -1
- package/dist/client/LambderCaller.d.ts +1 -1
- package/dist/client/LambderCaller.js +1 -1
- package/dist/client/LambderUploadRunner.d.ts +5 -5
- package/dist/client/LambderUploadRunner.js +25 -16
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/core/Lambder.d.ts +1 -1
- package/dist/core/Lambder.js +1 -1
- package/dist/core/LambderCreateOptions.d.ts +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/invoke/LambderInvokeCaller.d.ts +1 -1
- package/dist/invoke/LambderInvokeCaller.js +1 -1
- package/dist/mock/LambderMockCreateOptions.d.ts +1 -1
- package/dist/shared/contracts/LambderUploadBucket.d.ts +18 -3
- package/dist/shared/contracts/LambderUploadBucket.js +2 -2
- package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -1
- package/dist/shared/wire/LambderUploadObjectFields.js +22 -1
- package/dist/shared/wire/LambderUploadSchemas.d.ts +8 -2
- package/dist/shared/wire/LambderUploadSchemas.js +18 -7
- package/dist/stores/LambderMemoryUploadBucket.d.ts +21 -7
- package/dist/stores/LambderMemoryUploadBucket.js +60 -12
- package/dist/stores/LambderS3UploadBucket.d.ts +22 -3
- package/dist/stores/LambderS3UploadBucket.js +51 -4
- package/dist/testing/LambderTestVisitor.d.ts +1 -1
- package/package.json +1 -1
- /package/dist/shared/wire/{LambderApiSignature.d.ts → LambderApiSignatureMap.d.ts} +0 -0
- /package/dist/shared/wire/{LambderApiSignature.js → LambderApiSignatureMap.js} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,24 @@ sit on its first published patch, and later patches list only what they changed.
|
|
|
9
9
|
Releases up to 3.2.6 carry git tags; the ones after it were published without
|
|
10
10
|
one, so versions are not cross-linked to tag comparisons here.
|
|
11
11
|
|
|
12
|
+
## [9.0.2] - 2026-09-27
|
|
13
|
+
|
|
14
|
+
Nothing a handler or a caller does changes; the package, its docs and its
|
|
15
|
+
tests are easier to find one's way in.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **The README names the one v9 break the compiler cannot find.** A refusal
|
|
20
|
+
is no longer stored under an idempotency key, so a retry after one runs the
|
|
21
|
+
handler again (the 9.0.1 entry has the detail).
|
|
22
|
+
- **The signature map's module is `shared/wire/LambderApiSignatureMap`.** It
|
|
23
|
+
shared its file name with `api/LambderApiSignature`, which computes the
|
|
24
|
+
digests. The entries export the same names as before, so no import changes.
|
|
25
|
+
- **Docs.** `docs/dynamodb-tables.md` is `docs/ddb-tables.md`, beside the
|
|
26
|
+
other `ddb-*` pages. `docs/responses.md` says that `compress: true` behind a
|
|
27
|
+
REST API needs `binaryMediaTypes: ["*/*"]`, as its compression does.
|
|
28
|
+
- **Tests** sit in folders that mirror `src/`.
|
|
29
|
+
|
|
12
30
|
## [9.0.1] - 2026-09-27
|
|
13
31
|
|
|
14
32
|
A major that gives an API handler one shape. It takes its context and returns
|
package/README.md
CHANGED
|
@@ -69,8 +69,9 @@ const company = await caller.api("getCompany", { slug: "acme" });
|
|
|
69
69
|
- **Frontend hosting.** Serve a build from a folder, S3, R2 or any HTTP
|
|
70
70
|
origin, with an app shell rendered through a build-pipeline-safe template
|
|
71
71
|
engine.
|
|
72
|
-
- **Direct uploads.** Files go from the browser straight to S3 on
|
|
73
|
-
that pin their size, type and SHA-256, with a
|
|
72
|
+
- **Direct uploads.** Files go from the browser straight to S3, or to R2 on
|
|
73
|
+
presigned PUTs, on tickets that pin their size, type and SHA-256, with a
|
|
74
|
+
browser runner that hashes,
|
|
74
75
|
retries and reports progress, and a memory bucket that holds tests and the
|
|
75
76
|
mock to the same rules.
|
|
76
77
|
- **Runs anywhere Lambda does.** API Gateway REST APIs (payload v1), HTTP APIs
|
|
@@ -163,11 +164,11 @@ guide that matches what you are building. The full index lives in
|
|
|
163
164
|
| [The API core](./docs/api-core.md) | `LambderApiPipeline`: the one pipeline the server and the mock runtime run, the store interfaces, the transports |
|
|
164
165
|
| [Frontend client](./docs/client.md) | `LambderCaller`: typed calls, failure outcomes, timeouts, guard inputs, request compression, transports |
|
|
165
166
|
| [Frontend hosting](./docs/frontend-hosting.md) | File sources, `servePublicFiles`, `serveIndexHtml`, `res.templateFile` |
|
|
166
|
-
| [Direct uploads](./docs/uploads.md) | Files the browser
|
|
167
|
+
| [Direct uploads](./docs/uploads.md) | Files the browser sends straight to S3 or R2 with tickets the server signs, verified before they count |
|
|
167
168
|
| [Templating](./docs/templating.md) | `html`/`xml` tagged templates and `LambderTemplatingEngine` |
|
|
168
169
|
| [Translations](./docs/i18n.md) | `createLambderI18n`: typed keys, extension, detection, on-demand languages, runtime dictionaries |
|
|
169
170
|
| [The mock runtime](./docs/mock.md) | `LambderMockApp`: the typed contract served from mock handlers over the real pipeline, in the browser and in tests |
|
|
170
|
-
| [DynamoDB tables](./docs/
|
|
171
|
+
| [DynamoDB tables](./docs/ddb-tables.md) | Table shapes, TTL and IAM for sessions, cache, rate limits and idempotency |
|
|
171
172
|
| [Exports reference](./docs/exports.md) | Every name the five entry points export, grouped by purpose |
|
|
172
173
|
|
|
173
174
|
## Standalone modules
|
|
@@ -192,7 +193,9 @@ handler one shape: it takes its context and returns its output, says no with
|
|
|
192
193
|
`refuse()`, and writes headers, cookies and log entries through the context,
|
|
193
194
|
while the response builder stays with routes, hooks and error handlers. Every
|
|
194
195
|
break and what to do about it is in the 9.0.1 entry, and the compiler finds
|
|
195
|
-
most of them.
|
|
196
|
+
most of them. The one it cannot is a change of behavior: a refusal is no
|
|
197
|
+
longer stored under an idempotency key, so a retry after one runs the handler
|
|
198
|
+
again. An app still on v7 goes through the 8.0.2 entry first, which
|
|
196
199
|
names the breaks of v8 the compiler cannot find, and one on v6 through the
|
|
197
200
|
7.0.0 entry before that.
|
|
198
201
|
|
|
@@ -4,7 +4,7 @@ import type { LambderApiAnswer } from "./LambderApiAnswer.js";
|
|
|
4
4
|
import type { LambderApiCallContext } from "./LambderApiCallContext.js";
|
|
5
5
|
import type { LambderApiCallTrace } from "./LambderApiCallContext.js";
|
|
6
6
|
import type { LambderApiDefinition } from "./LambderApiDefinition.js";
|
|
7
|
-
import { type LambderApiSignatureMap } from "../shared/wire/
|
|
7
|
+
import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignatureMap.js";
|
|
8
8
|
import { type LambderApiGuard } from "./LambderApiGuards.js";
|
|
9
9
|
import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderRateLimitChargeResult, type LambderRateLimitChargeSubject } from "./LambderApiRateLimits.js";
|
|
10
10
|
import { type LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { restoreCompressedPayload } from "./LambderApiRequest.js";
|
|
2
|
-
import { lookupApiSignature } from "../shared/wire/
|
|
2
|
+
import { lookupApiSignature } from "../shared/wire/LambderApiSignatureMap.js";
|
|
3
3
|
import { apiNotFoundAnswer, invalidPayloadAnswer, refusalAnswer, sessionExpiredAnswer, validationAnswer, versionExpiredAnswer, } from "./LambderApiEnvelope.js";
|
|
4
4
|
import { LambderApiValidationRefusal, isLambderApiValidationRefusal } from "./LambderApiValidationRefusal.js";
|
|
5
5
|
import { isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { toGuardEntries } from "./LambderApiGuards.js";
|
|
3
|
-
import { API_SIGNATURE_HEX_LENGTH, EXTENSIBLE_ENUM_META_KEY } from "../shared/wire/
|
|
3
|
+
import { API_SIGNATURE_HEX_LENGTH, EXTENSIBLE_ENUM_META_KEY } from "../shared/wire/LambderApiSignatureMap.js";
|
|
4
4
|
import { sha256HexOf } from "../shared/util/LambderTextDigest.js";
|
|
5
5
|
import { canonicalJson } from "../shared/util/canonicalJson.js";
|
|
6
6
|
/*
|
|
@@ -4,7 +4,7 @@ import type { LambderApiContractShape } from '../shared/wire/LambderApiContract.
|
|
|
4
4
|
import { type LambderApiOutcome, type LambderValidationError } from '../shared/wire/LambderApiOutcome.js';
|
|
5
5
|
import { type LambderCallArgs, type LambderContractOutputOf, type LambderGuardInputsProviderOption, type LambderSharedCallOptions } from '../shared/wire/LambderCallOptions.js';
|
|
6
6
|
import { type LambderApiTransport } from '../shared/transport/LambderApiTransport.js';
|
|
7
|
-
import { type LambderApiSignatureMap } from '../shared/wire/
|
|
7
|
+
import { type LambderApiSignatureMap } from '../shared/wire/LambderApiSignatureMap.js';
|
|
8
8
|
export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError } from '../shared/wire/LambderApiOutcome.js';
|
|
9
9
|
export type { LambderProvidedGuardInputs, LambderGuardInputsProvider } from '../shared/wire/LambderCallOptions.js';
|
|
10
10
|
/** A handler told that something happened, with nothing to hand it. */
|
|
@@ -8,7 +8,7 @@ import { createCallAbort } from '../shared/util/LambderCallAbort.js';
|
|
|
8
8
|
import { coerceToError } from '../shared/wire/LambderCrashDetail.js';
|
|
9
9
|
import { isLambderTransportFailure } from '../shared/transport/LambderApiTransport.js';
|
|
10
10
|
import { DEFAULT_SESSION_TOKEN_COOKIE_KEY, DEFAULT_SESSION_CSRF_COOKIE_KEY } from '../shared/wire/LambderSessionCookieNames.js';
|
|
11
|
-
import { readApiSignature } from '../shared/wire/
|
|
11
|
+
import { readApiSignature } from '../shared/wire/LambderApiSignatureMap.js';
|
|
12
12
|
import { LambderReloadLoopBreaker, RELOAD_LOOP_WINDOW_MS } from './LambderReloadLoopBreaker.js';
|
|
13
13
|
import { lambderFetchTransport } from './lambderFetchTransport.js';
|
|
14
14
|
/**
|
|
@@ -60,9 +60,9 @@ export type LambderUploadRunnerOptions<Reference, Receipt> = {
|
|
|
60
60
|
maxDelayMs?: number;
|
|
61
61
|
};
|
|
62
62
|
/**
|
|
63
|
-
* How long
|
|
64
|
-
* dropped. Default: 60 seconds. Watched where XMLHttpRequest exists,
|
|
65
|
-
* which reports a body's progress; a runtime with only fetch
|
|
63
|
+
* How long an upload to storage may be open and move nothing before it
|
|
64
|
+
* counts as dropped. Default: 60 seconds. Watched where XMLHttpRequest exists,
|
|
65
|
+
* which reports a body's progress; a runtime with only fetch uploads
|
|
66
66
|
* unwatched.
|
|
67
67
|
*/
|
|
68
68
|
stallTimeoutMs?: number;
|
|
@@ -91,6 +91,6 @@ export declare class LambderUploadRunner<Reference, Receipt> {
|
|
|
91
91
|
}): Promise<Receipt>;
|
|
92
92
|
/** Forgets a confirmed upload through the app's endpoint, when it declared one. */
|
|
93
93
|
discard(receipt: Receipt): Promise<void>;
|
|
94
|
-
/** One
|
|
95
|
-
private
|
|
94
|
+
/** One upload of the file to storage, in the ticket's form. Never throws: every ending is an outcome. */
|
|
95
|
+
private send;
|
|
96
96
|
}
|
|
@@ -90,7 +90,7 @@ export class LambderUploadRunner {
|
|
|
90
90
|
for (;;) {
|
|
91
91
|
stopIfCancelled();
|
|
92
92
|
report("uploading");
|
|
93
|
-
const outcome = await this.
|
|
93
|
+
const outcome = await this.send(issued.ticket, file, signal, (sentBytes) => report("uploading", sentBytes));
|
|
94
94
|
if (outcome.kind === "stored")
|
|
95
95
|
break;
|
|
96
96
|
if (outcome.kind === "cancelled")
|
|
@@ -124,22 +124,29 @@ export class LambderUploadRunner {
|
|
|
124
124
|
async discard(receipt) {
|
|
125
125
|
await this.options.discardUpload?.(receipt);
|
|
126
126
|
}
|
|
127
|
-
/** One
|
|
128
|
-
|
|
127
|
+
/** One upload of the file to storage, in the ticket's form. Never throws: every ending is an outcome. */
|
|
128
|
+
send(ticket, file, signal, onSent) {
|
|
129
129
|
if (signal?.aborted)
|
|
130
130
|
return Promise.resolve({ kind: "cancelled" });
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
131
|
+
let request;
|
|
132
|
+
if (ticket.method === "PUT") {
|
|
133
|
+
request = { method: "PUT", url: ticket.uploadUrl, body: file, headers: ticket.headers };
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
const form = new FormData();
|
|
137
|
+
for (const [name, value] of Object.entries(ticket.formFields))
|
|
138
|
+
form.append(name, value);
|
|
139
|
+
// Storage ignores every field that comes after the file.
|
|
140
|
+
form.append("file", file);
|
|
141
|
+
request = { method: "POST", url: ticket.uploadUrl, body: form, headers: {} };
|
|
142
|
+
}
|
|
136
143
|
return typeof XMLHttpRequest === "function"
|
|
137
|
-
?
|
|
138
|
-
:
|
|
144
|
+
? sendWithXhr(request, file.size, this.stallTimeoutMs, signal, onSent)
|
|
145
|
+
: sendWithFetch(request, file.size, signal, onSent);
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
148
|
/** XMLHttpRequest rather than fetch where it exists: fetch cannot report how much of a request body has been sent. */
|
|
142
|
-
const
|
|
149
|
+
const sendWithXhr = ({ method, url, body, headers }, fileBytes, stallTimeoutMs, signal, onSent) => new Promise((resolve) => {
|
|
143
150
|
const request = new XMLHttpRequest();
|
|
144
151
|
let stalled = false;
|
|
145
152
|
let stallTimer;
|
|
@@ -158,7 +165,7 @@ const postWithXhr = (url, form, fileBytes, stallTimeoutMs, signal, onSent) => ne
|
|
|
158
165
|
};
|
|
159
166
|
request.upload.onprogress = (event) => {
|
|
160
167
|
watchForStall();
|
|
161
|
-
// `loaded` counts
|
|
168
|
+
// A form's `loaded` counts its own framing too, a little over the file.
|
|
162
169
|
onSent(Math.min(event.loaded, fileBytes));
|
|
163
170
|
};
|
|
164
171
|
request.onload = () => {
|
|
@@ -173,18 +180,20 @@ const postWithXhr = (url, form, fileBytes, stallTimeoutMs, signal, onSent) => ne
|
|
|
173
180
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
174
181
|
watchForStall();
|
|
175
182
|
try {
|
|
176
|
-
request.open(
|
|
177
|
-
|
|
183
|
+
request.open(method, url);
|
|
184
|
+
for (const [name, value] of Object.entries(headers))
|
|
185
|
+
request.setRequestHeader(name, value);
|
|
186
|
+
request.send(body);
|
|
178
187
|
}
|
|
179
188
|
catch {
|
|
180
189
|
// A URL the browser will not open, or a request it will not send, answers nothing.
|
|
181
190
|
settle({ kind: "unreachable" });
|
|
182
191
|
}
|
|
183
192
|
});
|
|
184
|
-
const
|
|
193
|
+
const sendWithFetch = async ({ method, url, body, headers }, fileBytes, signal, onSent) => {
|
|
185
194
|
let response;
|
|
186
195
|
try {
|
|
187
|
-
response = await fetch(url, { method
|
|
196
|
+
response = await fetch(url, { method, body, headers, signal });
|
|
188
197
|
}
|
|
189
198
|
catch {
|
|
190
199
|
return signal?.aborted ? { kind: "cancelled" } : { kind: "unreachable" };
|
package/dist/client.d.ts
CHANGED
|
@@ -15,8 +15,8 @@ export type { LambderApiTransport, LambderApiTransportRequest, LambderTransportF
|
|
|
15
15
|
export { LambderCookieJar, parseSetCookie } from "./shared/transport/LambderCookieJar.js";
|
|
16
16
|
export type { LambderStoredCookie } from "./shared/transport/LambderCookieJar.js";
|
|
17
17
|
export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
|
|
18
|
-
export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/
|
|
19
|
-
export type { LambderApiSignatureMap } from "./shared/wire/
|
|
18
|
+
export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignatureMap.js";
|
|
19
|
+
export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignatureMap.js";
|
|
20
20
|
export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
|
|
21
21
|
export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
|
|
22
22
|
export type { LambderApiAnswerOutcome, LambderApiSuccessOutcome, LambderApiCallFailure, LambderApiValidationFailure, LambderApiEnvelopeFailure, LambderApiHttpAnswer, } from "./shared/wire/LambderApiOutcome.js";
|
|
@@ -46,4 +46,4 @@ export { constantTimeEquals } from "./shared/util/LambderTextDigest.js";
|
|
|
46
46
|
export { LambderUploadRunner, LambderUploadError } from "./client/LambderUploadRunner.js";
|
|
47
47
|
export type { LambderUploadRunnerOptions, LambderUploadProgress, LambderUploadPhase, LambderUploadFailureReason } from "./client/LambderUploadRunner.js";
|
|
48
48
|
export { checkUploadRule } from "./shared/contracts/LambderUploadBucket.js";
|
|
49
|
-
export type { LambderUploadRule, LambderUploadFileFacts, LambderUploadTicket, LambderUploadRuleVerdict } from "./shared/contracts/LambderUploadBucket.js";
|
|
49
|
+
export type { LambderUploadRule, LambderUploadFileFacts, LambderUploadTicket, LambderUploadMethod, LambderUploadRuleVerdict } from "./shared/contracts/LambderUploadBucket.js";
|
package/dist/client.js
CHANGED
|
@@ -16,7 +16,7 @@ export { LambderCookieJar, parseSetCookie } from "./shared/transport/LambderCook
|
|
|
16
16
|
export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
|
|
17
17
|
// The per-endpoint signature map a build ships with, how a caller reads it, the
|
|
18
18
|
// mark a shared schema sets on an enum its readers let grow, and the reload-loop window.
|
|
19
|
-
export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/
|
|
19
|
+
export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignatureMap.js";
|
|
20
20
|
export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
|
|
21
21
|
export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
|
|
22
22
|
export { createIdempotencyKey, createIdempotencyKeyScope } from "./shared/wire/LambderIdempotencyKeyScope.js";
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { type LambderPipelineBackends, type LambderPipelineBackendSwap } from ".
|
|
|
13
13
|
import type { LambderFileSource } from "../shared/contracts/LambderFileSource.js";
|
|
14
14
|
import { LAMBDER_BACKEND_SWAP, LAMBDER_CRASH_WATCH } from "../shared/util/LambderTestingDoors.js";
|
|
15
15
|
import { type LambderApiSignatureEntry } from "../api/LambderApiSignature.js";
|
|
16
|
-
import { type LambderApiSignatureMap } from "../shared/wire/
|
|
16
|
+
import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignatureMap.js";
|
|
17
17
|
import type { LambderApiOptionEntries } from "../shared/wire/LambderApiOptionEntries.js";
|
|
18
18
|
import type { LambderApiIdempotencyOption } from "../shared/wire/LambderApiOptionValues.js";
|
|
19
19
|
import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../api/LambderApiGuards.js";
|
package/dist/core/Lambder.js
CHANGED
|
@@ -14,7 +14,7 @@ import { isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
|
14
14
|
import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
|
|
15
15
|
import { LAMBDER_BACKEND_SWAP, LAMBDER_CRASH_WATCH } from "../shared/util/LambderTestingDoors.js";
|
|
16
16
|
import { apiSignatureOf } from "../api/LambderApiSignature.js";
|
|
17
|
-
import { apiNameKeyOf } from "../shared/wire/
|
|
17
|
+
import { apiNameKeyOf } from "../shared/wire/LambderApiSignatureMap.js";
|
|
18
18
|
import { assertPlainData } from "../shared/util/assertPlainData.js";
|
|
19
19
|
import { apiNotFoundAnswer, buildApiEnvelope, envelopeAnswer, refusalAnswer, sessionExpiredAnswer, } from "../api/LambderApiEnvelope.js";
|
|
20
20
|
import { LambderApiOutputValidationError } from "../api/LambderApiOutputValidationError.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
|
-
import type { LambderApiSignatureMap } from "../shared/wire/
|
|
2
|
+
import type { LambderApiSignatureMap } from "../shared/wire/LambderApiSignatureMap.js";
|
|
3
3
|
import type { Context } from "aws-lambda";
|
|
4
4
|
import type LambderResolver from "./LambderResolver.js";
|
|
5
5
|
import type LambderResponseBuilder from "./LambderResponseBuilder.js";
|
package/dist/index.d.ts
CHANGED
|
@@ -34,8 +34,8 @@ export type { LambderApiCallContext, LambderApiCallTrace, LambderResponseTools }
|
|
|
34
34
|
export type { LambderApiDefinition } from "./api/LambderApiDefinition.js";
|
|
35
35
|
export { apiSignatureOf } from "./api/LambderApiSignature.js";
|
|
36
36
|
export type { LambderApiSignatureEntry } from "./api/LambderApiSignature.js";
|
|
37
|
-
export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/
|
|
38
|
-
export type { LambderApiSignatureMap } from "./shared/wire/
|
|
37
|
+
export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignatureMap.js";
|
|
38
|
+
export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignatureMap.js";
|
|
39
39
|
export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
|
|
40
40
|
export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
|
|
41
41
|
export { buildApiEnvelope, envelopeAnswer, refusalAnswer, validationAnswer, apiNotFoundAnswer, sessionExpiredAnswer, versionExpiredAnswer, invalidPayloadAnswer, crashAnswer, API_ANSWER_CONTENT_TYPE, } from "./api/LambderApiEnvelope.js";
|
|
@@ -88,7 +88,7 @@ export type { LambderS3UploadBucketOptions } from "./stores/LambderS3UploadBucke
|
|
|
88
88
|
export { LambderMemoryUploadBucket } from "./stores/LambderMemoryUploadBucket.js";
|
|
89
89
|
export type { LambderMemoryUploadBucketOptions, LambderMemoryUploadObject } from "./stores/LambderMemoryUploadBucket.js";
|
|
90
90
|
export { checkUploadRule } from "./shared/contracts/LambderUploadBucket.js";
|
|
91
|
-
export type { LambderUploadBucket, LambderUploadRule, LambderUploadFileFacts, LambderUploadTicket, LambderUploadVerdict, LambderUploadRuleVerdict, LambderUploadObjectOptions, LambderUploadContentDisposition, } from "./shared/contracts/LambderUploadBucket.js";
|
|
91
|
+
export type { LambderUploadBucket, LambderUploadRule, LambderUploadFileFacts, LambderUploadTicket, LambderUploadMethod, LambderUploadVerdict, LambderUploadRuleVerdict, LambderUploadObjectOptions, LambderUploadContentDisposition, } from "./shared/contracts/LambderUploadBucket.js";
|
|
92
92
|
export { LambderUploadFileFactsSchema, LambderUploadTicketSchema } from "./shared/wire/LambderUploadSchemas.js";
|
|
93
93
|
export { refuseUnacceptedUpload } from "./shared/wire/LambderUploadRefusal.js";
|
|
94
94
|
export { LambderUploadRunner, LambderUploadError } from "./client/LambderUploadRunner.js";
|
package/dist/index.js
CHANGED
|
@@ -24,7 +24,7 @@ export { LambderAnswerHeaders, getAnswerHeader, setAnswerHeader, addAnswerHeader
|
|
|
24
24
|
export { createApiCallContext } from "./api/LambderApiCallContext.js";
|
|
25
25
|
// Per-endpoint signatures: what a client build ships with, digested from the server's own registrations.
|
|
26
26
|
export { apiSignatureOf } from "./api/LambderApiSignature.js";
|
|
27
|
-
export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/
|
|
27
|
+
export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignatureMap.js";
|
|
28
28
|
export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
|
|
29
29
|
export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
|
|
30
30
|
export { buildApiEnvelope, envelopeAnswer, refusalAnswer, validationAnswer, apiNotFoundAnswer, sessionExpiredAnswer, versionExpiredAnswer, invalidPayloadAnswer, crashAnswer, API_ANSWER_CONTENT_TYPE, } from "./api/LambderApiEnvelope.js";
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import type { APIGatewayProxyEventV2, Context } from "aws-lambda";
|
|
24
24
|
import { type LambderInvokeFailure, type LambderInvokeOutcome } from "./LambderInvokeOutcome.js";
|
|
25
|
-
import { type LambderApiSignatureMap } from "../shared/wire/
|
|
25
|
+
import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignatureMap.js";
|
|
26
26
|
import type { LambdaClient, LambdaClientConfig } from "@aws-sdk/client-lambda";
|
|
27
27
|
import type { LambderApiContractShape } from "../shared/wire/LambderApiContract.js";
|
|
28
28
|
import { type LambderCallArgs, type LambderContractOutputOf, type LambderGuardInputsProviderOption, type LambderSharedCallOptions } from "../shared/wire/LambderCallOptions.js";
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { classifyDeliveryFailure, describeFailure, errorFromFunctionError, LambderInvokeError, parseFunctionError, } from "./LambderInvokeOutcome.js";
|
|
24
24
|
import { DEFAULT_SESSION_TOKEN_COOKIE_KEY } from "../shared/wire/LambderSessionCookieNames.js";
|
|
25
|
-
import { readApiSignature } from "../shared/wire/
|
|
25
|
+
import { readApiSignature } from "../shared/wire/LambderApiSignatureMap.js";
|
|
26
26
|
import { resolveApiOutcome } from "../shared/wire/LambderApiOutcome.js";
|
|
27
27
|
import { mergeGuardInputs, } from "../shared/wire/LambderCallOptions.js";
|
|
28
28
|
import { beginIdempotentAttempt, IDEMPOTENT_ATTEMPT_NOT_SENT } from "../shared/wire/LambderIdempotencyKeyScope.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
|
-
import type { LambderApiSignatureMap } from "../shared/wire/
|
|
2
|
+
import type { LambderApiSignatureMap } from "../shared/wire/LambderApiSignatureMap.js";
|
|
3
3
|
import type { LambderApiResponseConfig } from "../shared/wire/LambderApiContract.js";
|
|
4
4
|
import type { LambderHttpStatusCode } from "../shared/wire/LambderHttpStatus.js";
|
|
5
5
|
import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
|
|
@@ -13,14 +13,29 @@ export type LambderUploadFileFacts = {
|
|
|
13
13
|
/** SHA-256 of the file's bytes as base64, the form storage checks an upload against: 43 characters and one pad. */
|
|
14
14
|
sha256Base64: string;
|
|
15
15
|
};
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Everything the browser needs to send one file to storage, and until when,
|
|
18
|
+
* in one of the two forms a store signs. `POST` is a form (S3's presigned
|
|
19
|
+
* POST), `PUT` the file as the body of a signed URL (a presigned PUT, which
|
|
20
|
+
* Cloudflare R2 and other stores without POST policies take).
|
|
21
|
+
*/
|
|
17
22
|
export type LambderUploadTicket = {
|
|
23
|
+
method: "POST";
|
|
18
24
|
uploadUrl: string;
|
|
19
25
|
/** Sent as form fields ahead of the file, which storage wants last. */
|
|
20
26
|
formFields: Record<string, string>;
|
|
21
27
|
/** Epoch milliseconds after which storage refuses the ticket. */
|
|
22
28
|
expiresAt: number;
|
|
29
|
+
} | {
|
|
30
|
+
method: "PUT";
|
|
31
|
+
uploadUrl: string;
|
|
32
|
+
/** Sent exactly as given, each one signed into the URL; the browser adds the length itself. */
|
|
33
|
+
headers: Record<string, string>;
|
|
34
|
+
/** Epoch milliseconds after which storage refuses the ticket. */
|
|
35
|
+
expiresAt: number;
|
|
23
36
|
};
|
|
37
|
+
/** How a bucket's tickets send a file: the `method` of the tickets it signs. */
|
|
38
|
+
export type LambderUploadMethod = LambderUploadTicket["method"];
|
|
24
39
|
/** What a bucket holds under a key, compared with what the browser said it would upload. */
|
|
25
40
|
export type LambderUploadVerdict = {
|
|
26
41
|
verified: true;
|
|
@@ -38,7 +53,7 @@ export type LambderUploadContentDisposition = {
|
|
|
38
53
|
};
|
|
39
54
|
/**
|
|
40
55
|
* What storage keeps beside an object's bytes. A ticket pins every one of
|
|
41
|
-
* these in its
|
|
56
|
+
* these in its signature, so the browser sends them unchanged.
|
|
42
57
|
*/
|
|
43
58
|
export type LambderUploadObjectOptions = {
|
|
44
59
|
/**
|
|
@@ -91,7 +106,7 @@ export interface LambderUploadBucket {
|
|
|
91
106
|
* Signs a ticket for exactly the file the browser described, or refuses
|
|
92
107
|
* (a LambderApiRefusal, code `lambder/upload-empty`,
|
|
93
108
|
* `lambder/upload-type-rejected` or `lambder/upload-too-large`) when the
|
|
94
|
-
* rule does not accept it. Storage then enforces every fact: the
|
|
109
|
+
* rule does not accept it. Storage then enforces every fact: the upload
|
|
95
110
|
* fails unless the body has that byte size, that content type and that
|
|
96
111
|
* SHA-256, so what verifies later is what was described here. `object`
|
|
97
112
|
* is what the stored object carries besides, pinned the same way, and
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* An API payload tops out near a few megabytes once a file is base64, and a
|
|
6
6
|
* Lambda's request body at six, so anything larger (a scanned lease, a
|
|
7
|
-
* signed PDF, a video) is
|
|
7
|
+
* signed PDF, a video) is sent by the browser to the bucket itself, with a
|
|
8
8
|
* ticket the server signed beforehand. The ticket pins everything about the
|
|
9
9
|
* upload: the key, the exact byte size, the content type and the SHA-256 of
|
|
10
10
|
* the bytes, all enforced by the storage, so the browser can only ever store
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* The conversation is the same three steps whatever the app stores: the
|
|
14
14
|
* browser describes the file (LambderUploadFileFacts), the app's endpoint
|
|
15
|
-
* answers with a ticket (LambderUploadTicket), and after the
|
|
15
|
+
* answers with a ticket (LambderUploadTicket), and after the upload the app's
|
|
16
16
|
* confirm endpoint asks the bucket what arrived before its record counts as
|
|
17
17
|
* uploaded. The server half is a LambderUploadBucket, the browser half is
|
|
18
18
|
* LambderUploadRunner.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { LambderUploadObjectOptions } from "../contracts/LambderUploadBucket.js";
|
|
2
2
|
/**
|
|
3
|
-
* What a ticket's form carries for the stored object, in the fields S3's
|
|
3
|
+
* What a POST ticket's form carries for the stored object, in the fields S3's
|
|
4
4
|
* presigned POST reads them from: the tag set as the XML `tagging` field,
|
|
5
5
|
* each metadata entry as `x-amz-meta-<name>` (lowercased, as S3 keeps it),
|
|
6
6
|
* `Cache-Control` and `Content-Disposition`. Both buckets build their
|
|
@@ -8,3 +8,12 @@ import type { LambderUploadObjectOptions } from "../contracts/LambderUploadBucke
|
|
|
8
8
|
* every field is pinned by the ticket like the key and the checksum.
|
|
9
9
|
*/
|
|
10
10
|
export declare const uploadObjectFormFields: (object: LambderUploadObjectOptions | undefined) => Record<string, string>;
|
|
11
|
+
/**
|
|
12
|
+
* What a PUT ticket's headers carry for the stored object, as a presigned
|
|
13
|
+
* PUT sends them: the tag set as the URL-encoded `x-amz-tagging`, each
|
|
14
|
+
* metadata entry as `x-amz-meta-<name>` (lowercased), `cache-control` and
|
|
15
|
+
* `content-disposition`. Names are lowercase, as a signature lists them. Both
|
|
16
|
+
* buckets build their PUT tickets' headers with this, and every one is signed
|
|
17
|
+
* into the URL like the checksum.
|
|
18
|
+
*/
|
|
19
|
+
export declare const uploadObjectHeaders: (object: LambderUploadObjectOptions | undefined) => Record<string, string>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { contentDispositionHeader } from "../util/LambderContentDisposition.js";
|
|
2
2
|
import { escapeXmlText } from "../util/escapeXmlText.js";
|
|
3
3
|
/**
|
|
4
|
-
* What a ticket's form carries for the stored object, in the fields S3's
|
|
4
|
+
* What a POST ticket's form carries for the stored object, in the fields S3's
|
|
5
5
|
* presigned POST reads them from: the tag set as the XML `tagging` field,
|
|
6
6
|
* each metadata entry as `x-amz-meta-<name>` (lowercased, as S3 keeps it),
|
|
7
7
|
* `Cache-Control` and `Content-Disposition`. Both buckets build their
|
|
@@ -22,3 +22,24 @@ export const uploadObjectFormFields = (object) => {
|
|
|
22
22
|
fields["Content-Disposition"] = contentDispositionHeader(object.contentDisposition);
|
|
23
23
|
return fields;
|
|
24
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* What a PUT ticket's headers carry for the stored object, as a presigned
|
|
27
|
+
* PUT sends them: the tag set as the URL-encoded `x-amz-tagging`, each
|
|
28
|
+
* metadata entry as `x-amz-meta-<name>` (lowercased), `cache-control` and
|
|
29
|
+
* `content-disposition`. Names are lowercase, as a signature lists them. Both
|
|
30
|
+
* buckets build their PUT tickets' headers with this, and every one is signed
|
|
31
|
+
* into the URL like the checksum.
|
|
32
|
+
*/
|
|
33
|
+
export const uploadObjectHeaders = (object) => {
|
|
34
|
+
const headers = {};
|
|
35
|
+
const tags = Object.entries(object?.tags ?? {});
|
|
36
|
+
if (tags.length)
|
|
37
|
+
headers["x-amz-tagging"] = new URLSearchParams(tags).toString();
|
|
38
|
+
for (const [name, value] of Object.entries(object?.metadata ?? {}))
|
|
39
|
+
headers[`x-amz-meta-${name.toLowerCase()}`] = value;
|
|
40
|
+
if (object?.cacheControl !== undefined)
|
|
41
|
+
headers["cache-control"] = object.cacheControl;
|
|
42
|
+
if (object?.contentDisposition)
|
|
43
|
+
headers["content-disposition"] = contentDispositionHeader(object.contentDisposition);
|
|
44
|
+
return headers;
|
|
45
|
+
};
|
|
@@ -5,8 +5,14 @@ export declare const LambderUploadFileFactsSchema: z.ZodObject<{
|
|
|
5
5
|
byteSize: z.ZodNumber;
|
|
6
6
|
sha256Base64: z.ZodString;
|
|
7
7
|
}, z.core.$strip>;
|
|
8
|
-
export declare const LambderUploadTicketSchema: z.ZodObject<{
|
|
8
|
+
export declare const LambderUploadTicketSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
9
|
+
method: z.ZodLiteral<"POST">;
|
|
9
10
|
uploadUrl: z.ZodURL;
|
|
10
11
|
formFields: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
11
12
|
expiresAt: z.ZodNumber;
|
|
12
|
-
}, z.core.$strip
|
|
13
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
14
|
+
method: z.ZodLiteral<"PUT">;
|
|
15
|
+
uploadUrl: z.ZodURL;
|
|
16
|
+
headers: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
17
|
+
expiresAt: z.ZodNumber;
|
|
18
|
+
}, z.core.$strip>], "method">;
|
|
@@ -21,10 +21,21 @@ export const LambderUploadFileFactsSchema = z.object({
|
|
|
21
21
|
/** SHA-256 of the file's bytes as base64: 32 bytes are always 43 characters and one pad. */
|
|
22
22
|
sha256Base64: z.string().regex(/^[A-Za-z0-9+/]{43}=$/),
|
|
23
23
|
});
|
|
24
|
-
export const LambderUploadTicketSchema = z.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
export const LambderUploadTicketSchema = z.discriminatedUnion("method", [
|
|
25
|
+
z.object({
|
|
26
|
+
method: z.literal("POST"),
|
|
27
|
+
uploadUrl: z.url(),
|
|
28
|
+
/** Sent as form fields ahead of the file, which storage wants last. */
|
|
29
|
+
formFields: z.record(z.string(), z.string()),
|
|
30
|
+
/** Epoch milliseconds after which storage refuses the ticket. */
|
|
31
|
+
expiresAt: z.number().int(),
|
|
32
|
+
}),
|
|
33
|
+
z.object({
|
|
34
|
+
method: z.literal("PUT"),
|
|
35
|
+
uploadUrl: z.url(),
|
|
36
|
+
/** Sent exactly as given, each one signed into the URL. */
|
|
37
|
+
headers: z.record(z.string(), z.string()),
|
|
38
|
+
/** Epoch milliseconds after which storage refuses the ticket. */
|
|
39
|
+
expiresAt: z.number().int(),
|
|
40
|
+
}),
|
|
41
|
+
]);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type LambderUploadBucket, type LambderUploadContentDisposition, type LambderUploadFileFacts, type LambderUploadObjectOptions, type LambderUploadRule, type LambderUploadTicket, type LambderUploadVerdict } from "../shared/contracts/LambderUploadBucket.js";
|
|
1
|
+
import { type LambderUploadBucket, type LambderUploadContentDisposition, type LambderUploadFileFacts, type LambderUploadMethod, type LambderUploadObjectOptions, type LambderUploadRule, type LambderUploadTicket, type LambderUploadVerdict } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
2
|
export type LambderMemoryUploadBucketOptions = {
|
|
3
3
|
/**
|
|
4
4
|
* The URL tickets and download links point under, which a mock's MSW
|
|
@@ -14,6 +14,8 @@ export type LambderMemoryUploadBucketOptions = {
|
|
|
14
14
|
downloadLifetimeSeconds?: number;
|
|
15
15
|
/** The clock tickets and links expire by, injectable so a test can move past an expiry without waiting. Default: Date.now. */
|
|
16
16
|
now?: () => number;
|
|
17
|
+
/** How the browser sends a file, as LambderS3UploadBucket's option: a `POST` form or a `PUT`. Default: `POST`. */
|
|
18
|
+
uploadMethod?: LambderUploadMethod;
|
|
17
19
|
};
|
|
18
20
|
/** What the memory bucket holds under a key, for a test to assert on: the object's facts and what it carries. */
|
|
19
21
|
export type LambderMemoryUploadObject = {
|
|
@@ -34,7 +36,11 @@ export type LambderMemoryUploadObject = {
|
|
|
34
36
|
* expired" for a late one, EntityTooSmall, EntityTooLarge, BadDigest), so any
|
|
35
37
|
* client, a LambderUploadRunner or another, takes the same path against it as
|
|
36
38
|
* against S3: an expired ticket is asked for again, a wrong file is refused.
|
|
37
|
-
*
|
|
39
|
+
* With `uploadMethod: "PUT"` it holds a PUT to the rules a store holds a
|
|
40
|
+
* presigned PUT to: every header the ticket carries with its value, no other
|
|
41
|
+
* `x-amz-` header, a body of the signed length (SignatureDoesNotMatch
|
|
42
|
+
* otherwise), a URL not yet expired ("Request has expired"), and bytes with
|
|
43
|
+
* the SHA-256 (BadDigest). A download link reads the object until it expires.
|
|
38
44
|
*
|
|
39
45
|
* Storage requests reach it through handleStorageRequest(), which answers a
|
|
40
46
|
* fetch Request with a Response: lambderMockUploadMswHandler plugs that into
|
|
@@ -47,11 +53,12 @@ export declare class LambderMemoryUploadBucket implements LambderUploadBucket {
|
|
|
47
53
|
readonly baseUrl: string;
|
|
48
54
|
private readonly ticketLifetimeSeconds;
|
|
49
55
|
private readonly downloadLifetimeSeconds;
|
|
56
|
+
private readonly uploadMethod;
|
|
50
57
|
private readonly now;
|
|
51
58
|
private readonly objects;
|
|
52
59
|
private readonly tickets;
|
|
53
60
|
private readonly links;
|
|
54
|
-
constructor({ baseUrl, ticketLifetimeSeconds, downloadLifetimeSeconds, now }?: LambderMemoryUploadBucketOptions);
|
|
61
|
+
constructor({ baseUrl, ticketLifetimeSeconds, downloadLifetimeSeconds, now, uploadMethod }?: LambderMemoryUploadBucketOptions);
|
|
55
62
|
issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds, object }: {
|
|
56
63
|
objectKey: string;
|
|
57
64
|
fileFacts: LambderUploadFileFacts;
|
|
@@ -88,12 +95,19 @@ export declare class LambderMemoryUploadBucket implements LambderUploadBucket {
|
|
|
88
95
|
/** Forgets every object, ticket and link. */
|
|
89
96
|
reset(): void;
|
|
90
97
|
/**
|
|
91
|
-
* Answers a request to storage the way S3 answers it: a post
|
|
92
|
-
* ticket stores its file, a GET or HEAD through a download link
|
|
93
|
-
* object. A request outside baseUrl answers null, for the caller
|
|
94
|
-
* on.
|
|
98
|
+
* Answers a request to storage the way S3 answers it: a post or a PUT
|
|
99
|
+
* under a ticket stores its file, a GET or HEAD through a download link
|
|
100
|
+
* reads an object. A request outside baseUrl answers null, for the caller
|
|
101
|
+
* to hand on.
|
|
95
102
|
*/
|
|
96
103
|
handleStorageRequest(request: Request): Promise<Response | null>;
|
|
97
104
|
private acceptUpload;
|
|
105
|
+
/**
|
|
106
|
+
* A PUT under a ticket's signed URL. A header the signature covers with
|
|
107
|
+
* another value, or a body of another length, does not match the
|
|
108
|
+
* signature; an `x-amz-` header it does not cover is refused as a store
|
|
109
|
+
* refuses one; the checksum is held against the bytes.
|
|
110
|
+
*/
|
|
111
|
+
private acceptPut;
|
|
98
112
|
private serveDownload;
|
|
99
113
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { assertObjectOptions, assertPinnedObjectKey, assertSignatureLifetime, } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
2
|
import { contentDispositionHeader } from "../shared/util/LambderContentDisposition.js";
|
|
3
3
|
import { escapeXmlText } from "../shared/util/escapeXmlText.js";
|
|
4
|
-
import { uploadObjectFormFields } from "../shared/wire/LambderUploadObjectFields.js";
|
|
4
|
+
import { uploadObjectFormFields, uploadObjectHeaders } from "../shared/wire/LambderUploadObjectFields.js";
|
|
5
5
|
import { refuseUnacceptedUpload } from "../shared/wire/LambderUploadRefusal.js";
|
|
6
6
|
import { sha256Base64Of } from "../shared/util/LambderTextDigest.js";
|
|
7
|
-
/** The form field that names the ticket
|
|
7
|
+
/** The form field, or for a PUT the query parameter, that names the ticket an upload was signed with: the memory bucket's stand-in for S3's signature. */
|
|
8
8
|
const TICKET_FIELD = "x-lambder-upload-ticket";
|
|
9
9
|
/** The query parameter that names the link a download was issued with: the stand-in for S3's presigned query string. */
|
|
10
10
|
const LINK_PARAMETER = "x-lambder-download-link";
|
|
@@ -21,7 +21,11 @@ const LINK_PARAMETER = "x-lambder-download-link";
|
|
|
21
21
|
* expired" for a late one, EntityTooSmall, EntityTooLarge, BadDigest), so any
|
|
22
22
|
* client, a LambderUploadRunner or another, takes the same path against it as
|
|
23
23
|
* against S3: an expired ticket is asked for again, a wrong file is refused.
|
|
24
|
-
*
|
|
24
|
+
* With `uploadMethod: "PUT"` it holds a PUT to the rules a store holds a
|
|
25
|
+
* presigned PUT to: every header the ticket carries with its value, no other
|
|
26
|
+
* `x-amz-` header, a body of the signed length (SignatureDoesNotMatch
|
|
27
|
+
* otherwise), a URL not yet expired ("Request has expired"), and bytes with
|
|
28
|
+
* the SHA-256 (BadDigest). A download link reads the object until it expires.
|
|
25
29
|
*
|
|
26
30
|
* Storage requests reach it through handleStorageRequest(), which answers a
|
|
27
31
|
* fetch Request with a Response: lambderMockUploadMswHandler plugs that into
|
|
@@ -34,11 +38,12 @@ export class LambderMemoryUploadBucket {
|
|
|
34
38
|
baseUrl;
|
|
35
39
|
ticketLifetimeSeconds;
|
|
36
40
|
downloadLifetimeSeconds;
|
|
41
|
+
uploadMethod;
|
|
37
42
|
now;
|
|
38
43
|
objects = new Map();
|
|
39
44
|
tickets = new Map();
|
|
40
45
|
links = new Map();
|
|
41
|
-
constructor({ baseUrl, ticketLifetimeSeconds = 600, downloadLifetimeSeconds = 300, now = Date.now } = {}) {
|
|
46
|
+
constructor({ baseUrl, ticketLifetimeSeconds = 600, downloadLifetimeSeconds = 300, now = Date.now, uploadMethod = "POST" } = {}) {
|
|
42
47
|
assertSignatureLifetime(ticketLifetimeSeconds, "ticketLifetimeSeconds");
|
|
43
48
|
assertSignatureLifetime(downloadLifetimeSeconds, "downloadLifetimeSeconds");
|
|
44
49
|
const url = new URL(baseUrl ?? `https://upload-bucket-${crypto.randomUUID()}.invalid/`);
|
|
@@ -47,6 +52,7 @@ export class LambderMemoryUploadBucket {
|
|
|
47
52
|
this.baseUrl = url.href.endsWith("/") ? url.href : `${url.href}/`;
|
|
48
53
|
this.ticketLifetimeSeconds = ticketLifetimeSeconds;
|
|
49
54
|
this.downloadLifetimeSeconds = downloadLifetimeSeconds;
|
|
55
|
+
this.uploadMethod = uploadMethod;
|
|
50
56
|
this.now = now;
|
|
51
57
|
}
|
|
52
58
|
async issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds = this.ticketLifetimeSeconds, object = {} }) {
|
|
@@ -56,6 +62,19 @@ export class LambderMemoryUploadBucket {
|
|
|
56
62
|
refuseUnacceptedUpload(uploadRule, fileFacts);
|
|
57
63
|
const ticketId = crypto.randomUUID();
|
|
58
64
|
const expiresAt = this.now() + lifetimeSeconds * 1000;
|
|
65
|
+
const issued = { objectKey, mimeType: fileFacts.mimeType, byteSize: fileFacts.byteSize, sha256Base64: fileFacts.sha256Base64, expiresAt, object };
|
|
66
|
+
if (this.uploadMethod === "PUT") {
|
|
67
|
+
// The headers a presigned PUT signs, so a client sends the same ones to either.
|
|
68
|
+
const headers = {
|
|
69
|
+
"content-type": fileFacts.mimeType,
|
|
70
|
+
"x-amz-checksum-sha256": fileFacts.sha256Base64,
|
|
71
|
+
...uploadObjectHeaders(object),
|
|
72
|
+
};
|
|
73
|
+
this.tickets.set(ticketId, { ...issued, pinned: headers });
|
|
74
|
+
const uploadUrl = new URL(`${this.baseUrl}${encodeObjectKey(objectKey)}`);
|
|
75
|
+
uploadUrl.searchParams.set(TICKET_FIELD, ticketId);
|
|
76
|
+
return { method: "PUT", uploadUrl: uploadUrl.href, headers, expiresAt };
|
|
77
|
+
}
|
|
59
78
|
// The fields S3's ticket carries, so a client posts the same form to either.
|
|
60
79
|
const formFields = {
|
|
61
80
|
key: objectKey,
|
|
@@ -65,8 +84,8 @@ export class LambderMemoryUploadBucket {
|
|
|
65
84
|
...uploadObjectFormFields(object),
|
|
66
85
|
[TICKET_FIELD]: ticketId,
|
|
67
86
|
};
|
|
68
|
-
this.tickets.set(ticketId, {
|
|
69
|
-
return { uploadUrl: this.baseUrl, formFields, expiresAt };
|
|
87
|
+
this.tickets.set(ticketId, { ...issued, pinned: formFields });
|
|
88
|
+
return { method: "POST", uploadUrl: this.baseUrl, formFields, expiresAt };
|
|
70
89
|
}
|
|
71
90
|
async verifyUploadedObject({ objectKey, fileFacts }) {
|
|
72
91
|
const object = this.objects.get(objectKey);
|
|
@@ -125,10 +144,10 @@ export class LambderMemoryUploadBucket {
|
|
|
125
144
|
this.links.clear();
|
|
126
145
|
}
|
|
127
146
|
/**
|
|
128
|
-
* Answers a request to storage the way S3 answers it: a post
|
|
129
|
-
* ticket stores its file, a GET or HEAD through a download link
|
|
130
|
-
* object. A request outside baseUrl answers null, for the caller
|
|
131
|
-
* on.
|
|
147
|
+
* Answers a request to storage the way S3 answers it: a post or a PUT
|
|
148
|
+
* under a ticket stores its file, a GET or HEAD through a download link
|
|
149
|
+
* reads an object. A request outside baseUrl answers null, for the caller
|
|
150
|
+
* to hand on.
|
|
132
151
|
*/
|
|
133
152
|
async handleStorageRequest(request) {
|
|
134
153
|
const url = new URL(request.url);
|
|
@@ -143,6 +162,8 @@ export class LambderMemoryUploadBucket {
|
|
|
143
162
|
}
|
|
144
163
|
if (request.method === "POST" && objectKey === "")
|
|
145
164
|
return this.acceptUpload(request);
|
|
165
|
+
if (request.method === "PUT")
|
|
166
|
+
return this.acceptPut(request, objectKey, url.searchParams.get(TICKET_FIELD));
|
|
146
167
|
if (request.method === "GET" || request.method === "HEAD")
|
|
147
168
|
return this.serveDownload(objectKey, url.searchParams.get(LINK_PARAMETER), request.method === "HEAD");
|
|
148
169
|
return storageError(405, "MethodNotAllowed", "The specified method is not allowed against this resource.");
|
|
@@ -176,10 +197,10 @@ export class LambderMemoryUploadBucket {
|
|
|
176
197
|
return storageError(403, "AccessDenied", "Invalid according to Policy: Policy Condition failed");
|
|
177
198
|
if (this.now() >= ticket.expiresAt)
|
|
178
199
|
return storageError(403, "AccessDenied", "Invalid according to Policy: Policy expired.");
|
|
179
|
-
const extra = [...fields.keys()].filter((name) => !(name in ticket.
|
|
200
|
+
const extra = [...fields.keys()].filter((name) => !(name in ticket.pinned));
|
|
180
201
|
if (extra.length)
|
|
181
202
|
return storageError(403, "AccessDenied", `Invalid according to Policy: Extra input fields: ${extra.join(", ")}`);
|
|
182
|
-
const pinned = Object.entries(ticket.
|
|
203
|
+
const pinned = Object.entries(ticket.pinned).every(([name, value]) => fields.get(name) === value);
|
|
183
204
|
if (!pinned)
|
|
184
205
|
return storageError(403, "AccessDenied", "Invalid according to Policy: Policy Condition failed");
|
|
185
206
|
const body = new Uint8Array(await file.arrayBuffer());
|
|
@@ -193,6 +214,33 @@ export class LambderMemoryUploadBucket {
|
|
|
193
214
|
this.objects.set(ticket.objectKey, { body, mimeType: ticket.mimeType, sha256Base64, object: ticket.object });
|
|
194
215
|
return new Response(null, { status: 204 });
|
|
195
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* A PUT under a ticket's signed URL. A header the signature covers with
|
|
219
|
+
* another value, or a body of another length, does not match the
|
|
220
|
+
* signature; an `x-amz-` header it does not cover is refused as a store
|
|
221
|
+
* refuses one; the checksum is held against the bytes.
|
|
222
|
+
*/
|
|
223
|
+
async acceptPut(request, objectKey, ticketId) {
|
|
224
|
+
const ticket = ticketId === null ? undefined : this.tickets.get(ticketId);
|
|
225
|
+
const mismatch = () => storageError(403, "SignatureDoesNotMatch", "The request signature we calculated does not match the signature you provided. Check your key and signing method.");
|
|
226
|
+
if (!ticket || ticket.objectKey !== objectKey)
|
|
227
|
+
return mismatch();
|
|
228
|
+
if (this.now() >= ticket.expiresAt)
|
|
229
|
+
return storageError(403, "AccessDenied", "Request has expired");
|
|
230
|
+
const unsigned = [...request.headers.keys()].filter((name) => name.startsWith("x-amz-") && !(name in ticket.pinned));
|
|
231
|
+
if (unsigned.length)
|
|
232
|
+
return storageError(403, "AccessDenied", `There were headers present in the request which were not signed: ${unsigned.join(", ")}`);
|
|
233
|
+
if (!Object.entries(ticket.pinned).every(([name, value]) => request.headers.get(name) === value))
|
|
234
|
+
return mismatch();
|
|
235
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
236
|
+
if (body.byteLength !== ticket.byteSize)
|
|
237
|
+
return mismatch();
|
|
238
|
+
const sha256Base64 = await sha256Base64Of(body);
|
|
239
|
+
if (sha256Base64 !== ticket.sha256Base64)
|
|
240
|
+
return storageError(400, "BadDigest", "The SHA256 you specified did not match the calculated checksum.");
|
|
241
|
+
this.objects.set(ticket.objectKey, { body, mimeType: ticket.mimeType, sha256Base64, object: ticket.object });
|
|
242
|
+
return new Response(null, { status: 200 });
|
|
243
|
+
}
|
|
196
244
|
serveDownload(objectKey, linkId, headOnly) {
|
|
197
245
|
const link = linkId === null ? undefined : this.links.get(linkId);
|
|
198
246
|
if (!link || link.objectKey !== objectKey)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { S3Client, S3ClientConfig } from "@aws-sdk/client-s3";
|
|
2
|
-
import { type LambderUploadBucket, type LambderUploadContentDisposition, type LambderUploadFileFacts, type LambderUploadObjectOptions, type LambderUploadRule, type LambderUploadTicket, type LambderUploadVerdict } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
|
+
import { type LambderUploadBucket, type LambderUploadContentDisposition, type LambderUploadFileFacts, type LambderUploadMethod, type LambderUploadObjectOptions, type LambderUploadRule, type LambderUploadTicket, type LambderUploadVerdict } from "../shared/contracts/LambderUploadBucket.js";
|
|
3
3
|
export type LambderS3UploadBucketOptions = {
|
|
4
4
|
bucket: string;
|
|
5
5
|
/** A ready client, e.g. one shared with the rest of the app. */
|
|
@@ -10,6 +10,14 @@ export type LambderS3UploadBucketOptions = {
|
|
|
10
10
|
ticketLifetimeSeconds?: number;
|
|
11
11
|
/** How long a download link reads the object, unless a link says otherwise. Default: 300 seconds. */
|
|
12
12
|
downloadLifetimeSeconds?: number;
|
|
13
|
+
/**
|
|
14
|
+
* How the browser sends a file: `POST`, a presigned POST, which S3 takes;
|
|
15
|
+
* or `PUT`, a presigned PUT, for a store that has no POST policies
|
|
16
|
+
* (Cloudflare R2). Both pin the same facts. Default: `POST`. The bucket
|
|
17
|
+
* cannot tell S3 from another store behind an endpoint, so a store that
|
|
18
|
+
* refuses POST is named here.
|
|
19
|
+
*/
|
|
20
|
+
uploadMethod?: LambderUploadMethod;
|
|
13
21
|
};
|
|
14
22
|
/**
|
|
15
23
|
* An S3 bucket browsers upload to directly (see LambderUploadBucket).
|
|
@@ -17,7 +25,10 @@ export type LambderS3UploadBucketOptions = {
|
|
|
17
25
|
* A ticket is an S3 presigned POST whose policy pins the key, the content
|
|
18
26
|
* type, the exact byte size and the SHA-256 checksum, so S3 itself refuses
|
|
19
27
|
* any other file. That needs S3's POST policies with checksum fields: S3, or
|
|
20
|
-
* a store that implements them
|
|
28
|
+
* a store that implements them. A store without them (Cloudflare R2) takes
|
|
29
|
+
* `uploadMethod: "PUT"`: a presigned PUT whose signature covers the same
|
|
30
|
+
* facts as headers (the length, the type and the checksum), so the store
|
|
31
|
+
* refuses any other file just the same.
|
|
21
32
|
*
|
|
22
33
|
* Signing a ticket or a download link is arithmetic over the function's
|
|
23
34
|
* credentials and reaches nothing; verifying, reading, writing, copying and
|
|
@@ -32,12 +43,13 @@ export declare class LambderS3UploadBucket implements LambderUploadBucket {
|
|
|
32
43
|
private readonly bucket;
|
|
33
44
|
private readonly ticketLifetimeSeconds;
|
|
34
45
|
private readonly downloadLifetimeSeconds;
|
|
46
|
+
private readonly uploadMethod;
|
|
35
47
|
private readonly clientConfig;
|
|
36
48
|
private client;
|
|
37
49
|
private clientSdk;
|
|
38
50
|
private presignedPostSdk;
|
|
39
51
|
private requestPresignerSdk;
|
|
40
|
-
constructor({ bucket, client, clientConfig, ticketLifetimeSeconds, downloadLifetimeSeconds }: LambderS3UploadBucketOptions);
|
|
52
|
+
constructor({ bucket, client, clientConfig, ticketLifetimeSeconds, downloadLifetimeSeconds, uploadMethod }: LambderS3UploadBucketOptions);
|
|
41
53
|
issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds, object }: {
|
|
42
54
|
objectKey: string;
|
|
43
55
|
fileFacts: LambderUploadFileFacts;
|
|
@@ -45,6 +57,13 @@ export declare class LambderS3UploadBucket implements LambderUploadBucket {
|
|
|
45
57
|
lifetimeSeconds?: number;
|
|
46
58
|
object?: LambderUploadObjectOptions;
|
|
47
59
|
}): Promise<LambderUploadTicket>;
|
|
60
|
+
/**
|
|
61
|
+
* A presigned PUT. Every header the ticket hands the browser is signed
|
|
62
|
+
* into the URL, and so is the length, which the browser sets from the
|
|
63
|
+
* body itself; the `x-amz-` ones are kept as headers rather than moved
|
|
64
|
+
* into the query, so the store checks the body against the checksum.
|
|
65
|
+
*/
|
|
66
|
+
private issuePutTicket;
|
|
48
67
|
verifyUploadedObject({ objectKey, fileFacts }: {
|
|
49
68
|
objectKey: string;
|
|
50
69
|
fileFacts: Pick<LambderUploadFileFacts, "byteSize" | "sha256Base64">;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { assertObjectOptions, assertPinnedObjectKey, assertSignatureLifetime, } from "../shared/contracts/LambderUploadBucket.js";
|
|
2
2
|
import { contentDispositionHeader } from "../shared/util/LambderContentDisposition.js";
|
|
3
|
-
import { uploadObjectFormFields } from "../shared/wire/LambderUploadObjectFields.js";
|
|
3
|
+
import { uploadObjectFormFields, uploadObjectHeaders } from "../shared/wire/LambderUploadObjectFields.js";
|
|
4
4
|
import { refuseUnacceptedUpload } from "../shared/wire/LambderUploadRefusal.js";
|
|
5
5
|
import { withInstallHint } from "./LambderSdkInstallHint.js";
|
|
6
6
|
/** The S3 error names that mean nothing is stored under the key: HeadObject answers NotFound, the other calls NoSuchKey. */
|
|
@@ -11,7 +11,10 @@ const MISSING_OBJECT_ERROR_NAMES = ["NotFound", "NoSuchKey"];
|
|
|
11
11
|
* A ticket is an S3 presigned POST whose policy pins the key, the content
|
|
12
12
|
* type, the exact byte size and the SHA-256 checksum, so S3 itself refuses
|
|
13
13
|
* any other file. That needs S3's POST policies with checksum fields: S3, or
|
|
14
|
-
* a store that implements them
|
|
14
|
+
* a store that implements them. A store without them (Cloudflare R2) takes
|
|
15
|
+
* `uploadMethod: "PUT"`: a presigned PUT whose signature covers the same
|
|
16
|
+
* facts as headers (the length, the type and the checksum), so the store
|
|
17
|
+
* refuses any other file just the same.
|
|
15
18
|
*
|
|
16
19
|
* Signing a ticket or a download link is arithmetic over the function's
|
|
17
20
|
* credentials and reaches nothing; verifying, reading, writing, copying and
|
|
@@ -26,12 +29,13 @@ export class LambderS3UploadBucket {
|
|
|
26
29
|
bucket;
|
|
27
30
|
ticketLifetimeSeconds;
|
|
28
31
|
downloadLifetimeSeconds;
|
|
32
|
+
uploadMethod;
|
|
29
33
|
clientConfig;
|
|
30
34
|
client;
|
|
31
35
|
clientSdk;
|
|
32
36
|
presignedPostSdk;
|
|
33
37
|
requestPresignerSdk;
|
|
34
|
-
constructor({ bucket, client, clientConfig, ticketLifetimeSeconds = 600, downloadLifetimeSeconds = 300 }) {
|
|
38
|
+
constructor({ bucket, client, clientConfig, ticketLifetimeSeconds = 600, downloadLifetimeSeconds = 300, uploadMethod = "POST" }) {
|
|
35
39
|
if (!bucket.trim())
|
|
36
40
|
throw new Error("bucket is required");
|
|
37
41
|
assertSignatureLifetime(ticketLifetimeSeconds, "ticketLifetimeSeconds");
|
|
@@ -41,12 +45,15 @@ export class LambderS3UploadBucket {
|
|
|
41
45
|
this.clientConfig = clientConfig;
|
|
42
46
|
this.ticketLifetimeSeconds = ticketLifetimeSeconds;
|
|
43
47
|
this.downloadLifetimeSeconds = downloadLifetimeSeconds;
|
|
48
|
+
this.uploadMethod = uploadMethod;
|
|
44
49
|
}
|
|
45
50
|
async issueUploadTicket({ objectKey, fileFacts, uploadRule, lifetimeSeconds = this.ticketLifetimeSeconds, object }) {
|
|
46
51
|
assertPinnedObjectKey(objectKey);
|
|
47
52
|
assertSignatureLifetime(lifetimeSeconds, "lifetimeSeconds");
|
|
48
53
|
assertObjectOptions(object);
|
|
49
54
|
refuseUnacceptedUpload(uploadRule, fileFacts);
|
|
55
|
+
if (this.uploadMethod === "PUT")
|
|
56
|
+
return this.issuePutTicket(objectKey, fileFacts, lifetimeSeconds, object);
|
|
50
57
|
const [{ client }, { createPresignedPost }] = await Promise.all([this.s3(), this.loadPresignedPostSdk()]);
|
|
51
58
|
const post = await createPresignedPost(client, {
|
|
52
59
|
Bucket: this.bucket,
|
|
@@ -61,7 +68,47 @@ export class LambderS3UploadBucket {
|
|
|
61
68
|
},
|
|
62
69
|
Conditions: [["content-length-range", fileFacts.byteSize, fileFacts.byteSize]],
|
|
63
70
|
});
|
|
64
|
-
return { uploadUrl: post.url, formFields: post.fields, expiresAt: Date.now() + lifetimeSeconds * 1000 };
|
|
71
|
+
return { method: "POST", uploadUrl: post.url, formFields: post.fields, expiresAt: Date.now() + lifetimeSeconds * 1000 };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A presigned PUT. Every header the ticket hands the browser is signed
|
|
75
|
+
* into the URL, and so is the length, which the browser sets from the
|
|
76
|
+
* body itself; the `x-amz-` ones are kept as headers rather than moved
|
|
77
|
+
* into the query, so the store checks the body against the checksum.
|
|
78
|
+
*/
|
|
79
|
+
async issuePutTicket(objectKey, fileFacts, lifetimeSeconds, object) {
|
|
80
|
+
const headers = {
|
|
81
|
+
"content-type": fileFacts.mimeType,
|
|
82
|
+
"x-amz-checksum-sha256": fileFacts.sha256Base64,
|
|
83
|
+
...uploadObjectHeaders(object),
|
|
84
|
+
};
|
|
85
|
+
const [{ sdk, client }, { getSignedUrl }] = await Promise.all([this.s3(), this.loadRequestPresignerSdk()]);
|
|
86
|
+
const tags = Object.entries(object?.tags ?? {});
|
|
87
|
+
const metadata = Object.entries(object?.metadata ?? {});
|
|
88
|
+
const uploadUrl = await getSignedUrl(client, new sdk.PutObjectCommand({
|
|
89
|
+
Bucket: this.bucket,
|
|
90
|
+
Key: objectKey,
|
|
91
|
+
ContentType: fileFacts.mimeType,
|
|
92
|
+
ContentLength: fileFacts.byteSize,
|
|
93
|
+
ChecksumSHA256: fileFacts.sha256Base64,
|
|
94
|
+
...(tags.length ? { Tagging: new URLSearchParams(tags).toString() } : {}),
|
|
95
|
+
...(metadata.length ? { Metadata: Object.fromEntries(metadata.map(([name, value]) => [name.toLowerCase(), value])) } : {}),
|
|
96
|
+
...(object?.cacheControl !== undefined ? { CacheControl: object.cacheControl } : {}),
|
|
97
|
+
...(object?.contentDisposition ? { ContentDisposition: contentDispositionHeader(object.contentDisposition) } : {}),
|
|
98
|
+
}), {
|
|
99
|
+
expiresIn: lifetimeSeconds,
|
|
100
|
+
signableHeaders: new Set([...Object.keys(headers), "content-length"]),
|
|
101
|
+
unhoistableHeaders: new Set(Object.keys(headers).filter((name) => name.startsWith("x-amz-"))),
|
|
102
|
+
});
|
|
103
|
+
// What the URL is signed for must be what the ticket sends, or the
|
|
104
|
+
// store refuses every upload with it: said here, where it is written,
|
|
105
|
+
// rather than as a refused upload in a browser.
|
|
106
|
+
const signed = (new URL(uploadUrl).searchParams.get("X-Amz-SignedHeaders") ?? "").split(";").filter((name) => name !== "host" && name !== "content-length");
|
|
107
|
+
const sent = Object.keys(headers);
|
|
108
|
+
if (signed.length !== sent.length || !sent.every((name) => signed.includes(name))) {
|
|
109
|
+
throw new Error(`LambderS3UploadBucket: a PUT ticket's URL is signed for [${signed.join(", ")}] but the ticket sends [${sent.join(", ")}]`);
|
|
110
|
+
}
|
|
111
|
+
return { method: "PUT", uploadUrl, headers, expiresAt: Date.now() + lifetimeSeconds * 1000 };
|
|
65
112
|
}
|
|
66
113
|
async verifyUploadedObject({ objectKey, fileFacts }) {
|
|
67
114
|
const { sdk, client } = await this.s3();
|
|
@@ -5,7 +5,7 @@ import { type LambderLambdaHttpResult } from "../invoke/LambderLambdaEvent.js";
|
|
|
5
5
|
import type { LambderCreatedSession } from "../session/LambderSessionManager.js";
|
|
6
6
|
import { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
|
|
7
7
|
import type { LambderApiContractShape } from "../shared/wire/LambderApiContract.js";
|
|
8
|
-
import type { LambderApiSignatureMap } from "../shared/wire/
|
|
8
|
+
import type { LambderApiSignatureMap } from "../shared/wire/LambderApiSignatureMap.js";
|
|
9
9
|
import type { LambderGuardInputsProviderOption } from "../shared/wire/LambderCallOptions.js";
|
|
10
10
|
/**
|
|
11
11
|
* How one visitor differs from the next. Everything is optional: a visitor
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lambder",
|
|
3
|
-
"version": "9.0.
|
|
3
|
+
"version": "9.0.3",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"description": "Opinionated serverless web framework for TypeScript on AWS Lambda: type-safe APIs from Zod schemas, DynamoDB sessions, and declarative rate limits, authorization guards and idempotency.",
|
|
6
6
|
"keywords": [
|
|
File without changes
|
|
File without changes
|