@crewhaus/tool-objectstore 0.7.0

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 ADDED
@@ -0,0 +1,190 @@
1
+ # @crewhaus/tool-objectstore
2
+
3
+ A door key for one object, instead of the keys to the building.
4
+
5
+ | Tool | Answers |
6
+ |---|---|
7
+ | `ObjectPresign` | what URL lets someone download or upload this one object, for this long |
8
+
9
+ `ObjectPresign` mints a time-limited SigV4 URL for an S3-compatible store —
10
+ AWS S3, Cloudflare R2, MinIO, anything that verifies SigV4. Whoever holds the
11
+ URL can do one thing to one object until it expires. They get no credential,
12
+ and the bytes never pass through the harness.
13
+
14
+ ## It computes; it does not connect
15
+
16
+ The whole package is HMAC-SHA256 from `node:crypto` over strings. No SDK, no
17
+ socket, no file read, no state between calls except an injectable clock. It
18
+ works offline, and the same inputs always produce the same URL.
19
+
20
+ That claim is not left to the reader. The test suite greps this package's own
21
+ source for `node:http`, `node:net`, `node:fs`, a `fetch` call and the rest, so
22
+ an edit that adds one fails a test rather than a review.
23
+
24
+ It holds no credential either. The access key and secret arrive as arguments
25
+ from whatever the caller uses for secrets, the secret is consumed by the
26
+ signing-key derivation, and it appears in no result, no error and no stack
27
+ trace — asserted on both the success and the failure path. The access key ID
28
+ *is* in the URL: SigV4 puts it in `X-Amz-Credential` by construction, which is
29
+ how the store knows which key to check against. That one is public by design.
30
+
31
+ ## Three things that fail hours later
32
+
33
+ Every trap in this package has the same shape: the URL comes back looking
34
+ perfect and the failure arrives later, from somewhere else, as a 403 that
35
+ explains nothing.
36
+
37
+ ### 1. `encodeURIComponent` is the wrong encoder
38
+
39
+ SigV4 percent-encodes everything outside `A-Z a-z 0-9 - _ . ~`.
40
+ `encodeURIComponent` leaves five of those alone:
41
+
42
+ | Key contains | `encodeURIComponent` | SigV4 |
43
+ |---|---|---|
44
+ | `!` `'` `(` `)` `*` | unchanged | `%21` `%27` `%28` `%29` `%2A` |
45
+ | a space | `%20` | `%20` |
46
+ | `+` | `%2B` | `%2B` |
47
+ | `é` | `%C3%A9` | `%C3%A9` |
48
+
49
+ So `q&a (2026)/notes!.txt` signs one way and is requested another. The encoder
50
+ here is hand-written for that reason, and it is tested against keys with
51
+ spaces, pluses, the five stragglers, three-byte characters and a surrogate
52
+ pair.
53
+
54
+ Two more rules live in the same place. The object key is encoded **once** and
55
+ its `/` separators are left alone (every non-S3 AWS service double-encodes its
56
+ canonical URI — the option exists, it is just not what S3 wants). And the
57
+ query string is encoded and **then** sorted, in that order: encoding moves
58
+ characters around the ASCII table, so `a-b` and `a?b` come out in the opposite
59
+ order from the one a sort-then-encode would produce.
60
+
61
+ ### 2. A pinned `content-type` is a promise the caller has to keep
62
+
63
+ Passing `contentType` to a PUT puts `content-type` into `SignedHeaders`. The
64
+ eventual upload must send that header with that exact value; an HTTP client
65
+ that appends `; charset=utf-8` is sending different signed bytes, and the
66
+ store answers 403 without mentioning headers.
67
+
68
+ So the result carries the exact headers alongside the URL, plus a sentence
69
+ saying to send them verbatim, plus a `curl` line that already does:
70
+
71
+ ```json
72
+ {
73
+ "url": "https://…?X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=…",
74
+ "headers": { "content-type": "application/pdf" },
75
+ "sendHeadersVerbatim": "send every header above exactly as written — …",
76
+ "signedHeaders": ["content-type", "host"]
77
+ }
78
+ ```
79
+
80
+ `x-amz-meta-*` works the same way, so `metadata` comes back in `headers` too.
81
+ A GET is the opposite case: `responseContentType` and
82
+ `responseContentDisposition` are signed into the **query**, so they are
83
+ already in the URL and the caller sends them by doing nothing. Mixing the two
84
+ — a `contentType` on a GET — is refused with the alternative named, rather
85
+ than dropped or signed on a guess.
86
+
87
+ ### 3. The addressing style is an argument, never an inference
88
+
89
+ Virtual-hosted style puts the bucket in the hostname; path style puts it in
90
+ the path. They change *both* the Host header and the canonical URI, so a
91
+ wrong guess is not a typo the server can correct — it is a different request.
92
+
93
+ | `addressingStyle` | Host | Path |
94
+ |---|---|---|
95
+ | `virtual-hosted` | `docs.s3.us-east-1.amazonaws.com` | `/report.pdf` |
96
+ | `path` | `s3.us-east-1.amazonaws.com` | `/docs/report.pdf` |
97
+
98
+ AWS S3 and most R2 setups are virtual-hosted; MinIO and some R2 and gateway
99
+ configurations are path-style, and the endpoint URL looks identical either
100
+ way. There is no default.
101
+
102
+ ## What it refuses, and why
103
+
104
+ Each of these would otherwise mint a URL that cannot work, for a reason the
105
+ store's answer will not name:
106
+
107
+ | Refused | Because |
108
+ |---|---|
109
+ | a key with a `.` or `..` segment | clients resolve it before sending, so the path signed is not the path that arrives |
110
+ | a key with `//` or a leading `/` | URL normalization collapses it on the way out |
111
+ | a key over 1024 **bytes** | S3's limit counts bytes, so a non-ASCII key reaches it sooner than it looks |
112
+ | a control character in a key or header | a bare CR splits the request in a careless client |
113
+ | `http://` to anything but loopback | the URL *is* the credential for that object; http puts it and the object on the wire in clear. Loopback means the parsed address — `localhost`, `*.localhost`, `[::1]`, or an IPv4 literal in `127.0.0.0/8`. A hostname that merely *starts* with `127.` is a domain somebody else registers |
114
+ | an endpoint with a path, query or credentials | a prefix would be signed twice or not at all |
115
+ | virtual-hosted style against an IP endpoint | an address cannot carry a bucket as a subdomain (this is the local-MinIO mistake) |
116
+ | an endpoint that already names the bucket | it would be signed twice |
117
+ | a bucket that cannot be a DNS label, virtual-hosted | the request would not reach the store; path style takes it, with a warning |
118
+ | a bucket that is not a usable path segment, path-style | `.` would sign `/./key`, the same dot segment a key is refused for |
119
+ | a signing instant outside the years 0000–9999 | `X-Amz-Date` has four digits for the year; `toISOString` switches to `+010000-…` rather than throwing, and the slice of that is a signature over nonsense |
120
+ | a region like `US-East-1` | the credential scope is matched literally |
121
+ | a secret with leading or trailing whitespace | a newline picked up while reading or pasting, which signs cleanly and verifies nowhere |
122
+ | `expiresInSeconds` over 7 days | SigV4 query authentication stops there; clamping silently would be a lie |
123
+ | a `content-type` with doubled or edge whitespace | SigV4 signs the collapsed form, so the value handed back would not be the value signed |
124
+
125
+ Warnings, rather than refusals, cover the cases that work but should be seen:
126
+ a dotted bucket in virtual-hosted style (the wildcard certificate covers one
127
+ label), temporary credentials (the URL also dies with the session token), and
128
+ an expiry long enough that the URL is effectively a copy of the object.
129
+
130
+ ## Vectors, not vibes
131
+
132
+ The signing tests are pinned to AWS's own published values and are written as
133
+ literals, so a change to the encoder or the canonicalization moves a byte and
134
+ the test says so:
135
+
136
+ - the signing-key derivation example (`iam` / `us-east-1` / `20120215`);
137
+ - `aws-sig-v4-test-suite` `get-vanilla` and `get-header-value-trim`, canonical
138
+ request, string-to-sign and signature;
139
+ - the documented presigned GET Object example for `examplebucket/test.txt`,
140
+ reproduced end to end through the tool.
141
+
142
+ Vectors only cover the requests somebody published. So the integration suite
143
+ adds a verifier — the store's half of the exchange, built from the finished
144
+ URL string and nothing the tool returned alongside it — and runs ten awkward
145
+ keys through mint-then-verify, plus the cases that must *fail*: a tampered
146
+ path, the wrong secret, a GET URL replayed as a PUT, a pinned header dropped
147
+ or "improved", and a query parameter appended after signing. Every refusal
148
+ asserts *which* reason it got, so a verifier that fell over somewhere else
149
+ cannot pass for a signature that was correctly rejected.
150
+
151
+ The verifier writes out RFC 3986 percent-encoding a second time, from the
152
+ rule, rather than importing this package's encoder — and each minted path is
153
+ compared against that independent encoding of the key. Taking `canonicalUri`
154
+ off the minted URL instead, as an earlier version did, makes the verifier
155
+ agree with the tool by construction: with the encoder broken to
156
+ `encodeURIComponent`'s unreserved set, all ten keys still verified, including
157
+ the two carrying `!` `'` `(` `)` `*`.
158
+
159
+ ## Using it
160
+
161
+ ```jsonc
162
+ {
163
+ "operation": "put",
164
+ "endpoint": "https://s3.us-east-1.amazonaws.com",
165
+ "addressingStyle": "virtual-hosted",
166
+ "region": "us-east-1",
167
+ "bucket": "acme-invoices",
168
+ "key": "2026/q1/invoice 0041.pdf",
169
+ "contentType": "application/pdf",
170
+ "metadata": { "run-id": "r-4412" },
171
+ "expiresInSeconds": 900,
172
+ "accessKeyId": "…",
173
+ "secretAccessKey": "…"
174
+ }
175
+ ```
176
+
177
+ `signedAt` pins the signing instant, so a URL can be reproduced exactly when
178
+ somebody asks why theirs stopped working. `includeCanonical` returns the
179
+ canonical request and string-to-sign, which is what you diff against the
180
+ `CanonicalRequest` S3 echoes back in a 403 body.
181
+
182
+ ## For the next AWS-signing tool
183
+
184
+ `lib/sigv4.ts` and `lib/encode.ts` are the general primitive, not an S3
185
+ detail: `encodeRfc3986`, `encodePath` (with the double-encoding option the
186
+ other services need), `canonicalQuery`, `signingKey`, `canonicalRequest`,
187
+ `stringToSign` and `sign` are all exported. Anything in this monorepo that
188
+ needs to sign an AWS request should import them rather than write a second
189
+ copy — there is no second copy today, and the golden vectors are what keeps
190
+ the first one honest.
@@ -0,0 +1,9 @@
1
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
2
+ import { PresignError } from "./lib/store";
3
+ export { _setClock, type Clock } from "./lib/clock";
4
+ export { PresignError };
5
+ export { canonicalQuery, encodePath, encodeRfc3986, type QueryParam, } from "./lib/encode";
6
+ export { ALGORITHM, UNSIGNED_PAYLOAD, type Header, amzTimestamps, canonicalRequest, credentialScope, presign, sign, signedHeaderList, signingKey, stringToSign, } from "./lib/sigv4";
7
+ export declare const objectPresign: RegisteredTool;
8
+ /** Every tool this package registers, in the order a catalog should list them. */
9
+ export declare const OBJECTSTORE_TOOLS: ReadonlyArray<RegisteredTool>;
package/dist/index.js ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * @crewhaus/tool-objectstore — handing out a door key instead of the building.
3
+ *
4
+ * `ObjectPresign` mints a time-limited URL that downloads or uploads one
5
+ * object in an S3-compatible store. Nothing else in the harness has to touch
6
+ * the bytes: the URL goes to whoever needs the file, they talk to the store
7
+ * directly, and the URL stops working when it expires.
8
+ *
9
+ * This is arithmetic, not I/O. The whole package is HMAC-SHA256 from
10
+ * `node:crypto` over strings; it opens no socket, reads no file, spawns
11
+ * nothing and keeps no state between calls beyond the injectable clock. That
12
+ * is why it declares no `ioCapability` — the test suite asserts it, over the
13
+ * package's own source, so the claim cannot rot.
14
+ *
15
+ * It holds no credential either. The access key and secret arrive as
16
+ * arguments from whatever the caller uses for secrets, the secret is consumed
17
+ * by the signing-key derivation, and it is referenced in no result, no error
18
+ * and no log line. (The access key ID does appear in the URL: SigV4 puts it
19
+ * in `X-Amz-Credential` by construction, which is how the store knows which
20
+ * key to verify against. That is public by design; the secret is not.)
21
+ *
22
+ * Three things are easy to get wrong here and all three fail late:
23
+ *
24
+ * 1. **Encoding.** `encodeURIComponent` is not SigV4's encoder. See
25
+ * `lib/encode.ts`.
26
+ * 2. **Signed headers.** Pinning a content type puts `content-type` into
27
+ * `SignedHeaders`, so the upload must send that value byte for byte.
28
+ * The result carries the exact headers for that reason.
29
+ * 3. **Addressing style.** Virtual-hosted and path style change both the
30
+ * Host header and the path, so the style is an argument, never a guess.
31
+ */
32
+ import { buildTool } from "@crewhaus/tool-builder";
33
+ import { z } from "zod";
34
+ import { nowMs, parseInstant } from "./lib/clock";
35
+ import { OPERATIONS, checkHeaderValue, checkOperationFields, metadataHeaders, responseOverrides, } from "./lib/request";
36
+ import { UNSIGNED_PAYLOAD, presign } from "./lib/sigv4";
37
+ import { ADDRESSING_STYLES, PresignError, parseEndpoint, resolveTarget } from "./lib/store";
38
+ export { _setClock } from "./lib/clock";
39
+ export { PresignError };
40
+ export { canonicalQuery, encodePath, encodeRfc3986, } from "./lib/encode";
41
+ export { ALGORITHM, UNSIGNED_PAYLOAD, amzTimestamps, canonicalRequest, credentialScope, presign, sign, signedHeaderList, signingKey, stringToSign, } from "./lib/sigv4";
42
+ const json = (value) => JSON.stringify(value);
43
+ /** SigV4 query authentication expires at seven days. Past that, S3 refuses. */
44
+ const MAX_EXPIRY_SECONDS = 7 * 24 * 60 * 60;
45
+ const DEFAULT_EXPIRY_SECONDS = 900;
46
+ /** Past this, a link is less "a link" and more "a copy of the object". */
47
+ const LONG_LIVED_SECONDS = 24 * 60 * 60;
48
+ /** S3 is the only service whose canonical URI is single-encoded; see lib/encode.ts. */
49
+ const SERVICE = "s3";
50
+ /** Shell-quote for the copy-pasteable example. Single quotes, POSIX style. */
51
+ function shellQuote(value) {
52
+ return `'${value.replaceAll("'", `'\\''`)}'`;
53
+ }
54
+ function checkCredential(name, value) {
55
+ if (value.trim() !== value) {
56
+ // The classic: a secret read from a file or pasted from a console arrives
57
+ // with a trailing newline. It signs perfectly and verifies nowhere, and
58
+ // the store's answer is SignatureDoesNotMatch with no mention of
59
+ // whitespace. Nothing downstream can detect this; here it is one check.
60
+ throw new PresignError(`${name} has leading or trailing whitespace — almost always a newline picked up when the secret was read or pasted. It would produce a signature that fails with "SignatureDoesNotMatch" and no other clue`);
61
+ }
62
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: refusing C0 controls is the point.
63
+ if (/[\x00-\x1f\x7f\s]/.test(value)) {
64
+ throw new PresignError(`${name} contains whitespace or a control character`);
65
+ }
66
+ }
67
+ const inputSchema = z
68
+ .object({
69
+ operation: z
70
+ .enum(OPERATIONS)
71
+ .describe("get to hand out a download link, put to hand out an upload target"),
72
+ endpoint: z
73
+ .string()
74
+ .min(1)
75
+ .max(512)
76
+ .describe("the store's origin and nothing else, e.g. https://s3.us-east-1.amazonaws.com, https://<account>.r2.cloudflarestorage.com or http://127.0.0.1:9000 for a local MinIO"),
77
+ addressingStyle: z
78
+ .enum(ADDRESSING_STYLES)
79
+ .describe("virtual-hosted puts the bucket in the hostname (AWS S3 and most R2 setups); path puts it in the path (MinIO, and R2 or gateways configured that way). This is not inferred: the two produce different Host headers and different paths, so a wrong guess signs a request that can never verify"),
80
+ region: z
81
+ .string()
82
+ .min(1)
83
+ .max(64)
84
+ .describe("the region in the credential scope, e.g. us-east-1, or auto for R2"),
85
+ bucket: z.string().min(1).max(255),
86
+ key: z.string().min(1).max(2048).describe("the object key, without a leading slash"),
87
+ accessKeyId: z.string().min(1).max(256),
88
+ secretAccessKey: z
89
+ .string()
90
+ .min(1)
91
+ .max(256)
92
+ .describe("used to derive the signing key and nothing else; it is not returned, logged or stored"),
93
+ sessionToken: z
94
+ .string()
95
+ .min(1)
96
+ .max(8192)
97
+ .optional()
98
+ .describe("for temporary credentials (STS); signed into the URL as X-Amz-Security-Token"),
99
+ expiresInSeconds: z
100
+ .number()
101
+ .int()
102
+ .positive()
103
+ .max(31_536_000)
104
+ .optional()
105
+ .describe(`how long the URL works; default ${DEFAULT_EXPIRY_SECONDS}, ceiling 7 days`),
106
+ contentType: z
107
+ .string()
108
+ .min(1)
109
+ .max(256)
110
+ .optional()
111
+ .describe("PUT only: pin the upload's Content-Type. This puts content-type into SignedHeaders, so the upload MUST send exactly this value — it comes back in headers for that reason"),
112
+ metadata: z
113
+ .record(z.string())
114
+ .optional()
115
+ .describe("PUT only: user metadata, named without the x-amz-meta- prefix. Each entry becomes a signed header the upload must send verbatim"),
116
+ responseContentType: z
117
+ .string()
118
+ .min(1)
119
+ .max(256)
120
+ .optional()
121
+ .describe("GET only: the Content-Type the store should answer with"),
122
+ responseContentDisposition: z
123
+ .string()
124
+ .min(1)
125
+ .max(512)
126
+ .optional()
127
+ .describe('GET only: e.g. attachment; filename="invoice.pdf" to control the downloaded filename'),
128
+ responseCacheControl: z.string().min(1).max(256).optional().describe("GET only"),
129
+ versionId: z.string().min(1).max(1024).optional().describe("GET only: read one version"),
130
+ signedAt: z
131
+ .union([z.string(), z.number()])
132
+ .optional()
133
+ .describe("the instant to sign at, as epoch ms or ISO-8601 with an offset; defaults to now. Pass it to reproduce a URL exactly"),
134
+ includeCanonical: z
135
+ .boolean()
136
+ .optional()
137
+ .describe("include the canonical request and string-to-sign, to diff against the CanonicalRequest S3 echoes in a 403 body"),
138
+ })
139
+ .strict();
140
+ export const objectPresign = buildTool({
141
+ name: "ObjectPresign",
142
+ description: "Mint a time-limited URL that downloads or uploads one object in an S3-compatible store (AWS S3, Cloudflare R2, MinIO, anything that speaks SigV4). Use it to move a file without the bytes passing through this harness and without handing anyone a long-lived key: the URL grants one operation on one object until it expires, and nothing else. It is pure computation — HMAC-SHA256 over strings, no SDK, no network, no credential kept — so it works offline and the same inputs always produce the same URL. For an upload that pins a content type or metadata, the exact headers come back alongside the URL and MUST be sent byte-identically, or the store answers 403 long after this call succeeded. The addressing style (virtual-hosted or path) is required rather than inferred, because the two sign different requests. The secret key is used to derive the signing key and appears in no output.",
143
+ inputSchema,
144
+ readOnly: true,
145
+ concurrencySafe: true,
146
+ // No scope or ioCapability: this reaches nothing. The package's own test
147
+ // suite greps its source to keep that true.
148
+ execute: async (input) => {
149
+ const operation = input.operation;
150
+ const overrides = {
151
+ responseContentType: input.responseContentType,
152
+ responseContentDisposition: input.responseContentDisposition,
153
+ responseCacheControl: input.responseCacheControl,
154
+ versionId: input.versionId,
155
+ };
156
+ checkOperationFields(operation, {
157
+ contentType: input.contentType,
158
+ metadata: input.metadata,
159
+ overrides,
160
+ });
161
+ checkCredential("accessKeyId", input.accessKeyId);
162
+ checkCredential("secretAccessKey", input.secretAccessKey);
163
+ if (input.sessionToken !== undefined)
164
+ checkCredential("sessionToken", input.sessionToken);
165
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(input.region)) {
166
+ throw new PresignError(`region "${input.region}" is not a region name — the credential scope is case-sensitive and matched literally, so "US-East-1" signs cleanly and then fails. Regions are lowercase letters, digits and hyphens (us-east-1, eu-central-1, or auto for R2)`);
167
+ }
168
+ const expiresInSeconds = input.expiresInSeconds ?? DEFAULT_EXPIRY_SECONDS;
169
+ if (expiresInSeconds > MAX_EXPIRY_SECONDS) {
170
+ throw new PresignError(`expiresInSeconds ${expiresInSeconds} is over the 7-day (${MAX_EXPIRY_SECONDS}s) ceiling that SigV4 query authentication allows; S3 rejects a longer one outright. For standing access, hand out a short URL each time rather than one long one`);
171
+ }
172
+ const endpoint = parseEndpoint(input.endpoint);
173
+ const target = resolveTarget({
174
+ endpoint,
175
+ style: input.addressingStyle,
176
+ bucket: input.bucket,
177
+ key: input.key,
178
+ });
179
+ const headers = [];
180
+ if (input.contentType !== undefined) {
181
+ checkHeaderValue("contentType", input.contentType);
182
+ headers.push(["content-type", input.contentType]);
183
+ }
184
+ if (input.metadata !== undefined)
185
+ headers.push(...metadataHeaders(input.metadata));
186
+ // Floored to the second BEFORE anything reads it. X-Amz-Date has second
187
+ // resolution, so the store's clock for this URL starts at the truncated
188
+ // instant; reporting signedAt/expiresAt from the millisecond one would
189
+ // hand back a window up to a second longer than the URL actually has.
190
+ const epochMs = Math.floor((input.signedAt === undefined ? nowMs() : parseInstant(input.signedAt, "signedAt")) / 1000) * 1000;
191
+ const signed = presign({
192
+ method: operation === "get" ? "GET" : "PUT",
193
+ origin: target.origin,
194
+ host: target.host,
195
+ canonicalUri: target.canonicalUri,
196
+ region: input.region,
197
+ service: SERVICE,
198
+ accessKeyId: input.accessKeyId,
199
+ secretAccessKey: input.secretAccessKey,
200
+ sessionToken: input.sessionToken,
201
+ epochMs,
202
+ expiresInSeconds,
203
+ query: responseOverrides(overrides),
204
+ headers,
205
+ });
206
+ const warnings = [...target.warnings];
207
+ if (input.sessionToken !== undefined) {
208
+ warnings.push("signed with temporary credentials: the URL also stops working when the session token expires, which is often sooner than expiresAt.");
209
+ }
210
+ if (expiresInSeconds > LONG_LIVED_SECONDS) {
211
+ warnings.push("this URL is a bearer credential for the object for its whole lifetime — anyone who sees it in a log, a referrer header or a chat message can use it until it expires.");
212
+ }
213
+ const sendHeaders = Object.fromEntries(headers);
214
+ const quoted = Object.entries(sendHeaders).map(([name, value]) => `-H ${shellQuote(`${name}: ${value}`)}`);
215
+ // `-o <name>`: curl would otherwise write the object to stdout, and the
216
+ // name it would guess from a URL this long is not the object's.
217
+ const filename = input.key.split("/").pop();
218
+ const curl = operation === "get"
219
+ ? `curl -fSL -o ${shellQuote(filename === undefined || filename === "" ? "downloaded" : filename)} ${shellQuote(signed.url)}`
220
+ : ["curl -fS -X PUT --upload-file ./FILE", ...quoted, shellQuote(signed.url)].join(" ");
221
+ return json({
222
+ url: signed.url,
223
+ method: operation === "get" ? "GET" : "PUT",
224
+ headers: sendHeaders,
225
+ sendHeadersVerbatim: headers.length === 0
226
+ ? "no extra headers: everything signed is already in the URL, so the request needs nothing added. Adding a header that is not listed in signedHeaders is harmless; changing the URL is not."
227
+ : "send every header above exactly as written — they are inside the signature, and a client that adds a charset, changes the case of a value or drops one gets a 403 that does not mention headers.",
228
+ signedHeaders: signed.signedHeaders.split(";"),
229
+ host: target.host,
230
+ addressingStyle: input.addressingStyle,
231
+ bucket: input.bucket,
232
+ key: input.key,
233
+ region: input.region,
234
+ service: SERVICE,
235
+ payloadHash: UNSIGNED_PAYLOAD,
236
+ signedAt: new Date(epochMs).toISOString(),
237
+ expiresAt: new Date(epochMs + expiresInSeconds * 1000).toISOString(),
238
+ expiresInSeconds,
239
+ curl,
240
+ credentials: input.sessionToken === undefined
241
+ ? "the access key id is inside the URL because SigV4 puts it there; the secret key was used to derive the signing key and appears nowhere in this result"
242
+ : // The session token is in the URL too, and saying only that the
243
+ // secret is absent reads as a full account of what is in here.
244
+ // A session token is a live credential with a blast radius far
245
+ // past this one object — worth naming before someone pastes the
246
+ // URL into a ticket.
247
+ "the access key id is inside the URL because SigV4 puts it there, and so is the session token (X-Amz-Security-Token) — SigV4 has nowhere else to carry it, and it is a live credential in its own right, so treat this URL accordingly. The secret key was used to derive the signing key and appears nowhere in this result",
248
+ ...(warnings.length > 0 ? { warnings } : {}),
249
+ ...(input.includeCanonical === true
250
+ ? { canonicalRequest: signed.canonicalRequest, stringToSign: signed.stringToSign }
251
+ : {}),
252
+ });
253
+ },
254
+ });
255
+ /** Every tool this package registers, in the order a catalog should list them. */
256
+ export const OBJECTSTORE_TOOLS = Object.freeze([objectPresign]);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Time, injected.
3
+ *
4
+ * A signature is a function of the instant it was made, so `Date.now()` in
5
+ * the middle of this package would make every assertion about a URL a
6
+ * statement about the second the test ran. The seam moves that decision to
7
+ * the edge: tests either pass `signedAt` explicitly or drive `_setClock`, and
8
+ * no test here asserts a wall-clock duration.
9
+ *
10
+ * The tool's own `signedAt` field exists for the same reason a caller needs:
11
+ * re-minting yesterday's URL to reproduce a support report is impossible if
12
+ * the timestamp is whatever the process thinks now is.
13
+ */
14
+ export type Clock = {
15
+ now(): number;
16
+ };
17
+ /** Test seam — `_setClock(undefined)` restores the real clock. */
18
+ export declare function _setClock(next: Clock | undefined): void;
19
+ export declare function nowMs(): number;
20
+ /**
21
+ * Read an instant from input: epoch milliseconds, or ISO-8601 WITH an offset.
22
+ *
23
+ * An offset-less string is refused rather than read as local time. Per
24
+ * ECMAScript, `2026-01-01T00:00:00` means local time, so the same input would
25
+ * sign at two different instants on a laptop and on a CI box in UTC — and the
26
+ * only symptom would be a URL that is already expired, or not yet valid, on
27
+ * one of them.
28
+ */
29
+ export declare function parseInstant(value: string | number, field: string): number;
@@ -0,0 +1,35 @@
1
+ const realClock = { now: () => Date.now() };
2
+ let clock = realClock;
3
+ /** Test seam — `_setClock(undefined)` restores the real clock. */
4
+ export function _setClock(next) {
5
+ clock = next ?? realClock;
6
+ }
7
+ export function nowMs() {
8
+ return clock.now();
9
+ }
10
+ /**
11
+ * Read an instant from input: epoch milliseconds, or ISO-8601 WITH an offset.
12
+ *
13
+ * An offset-less string is refused rather than read as local time. Per
14
+ * ECMAScript, `2026-01-01T00:00:00` means local time, so the same input would
15
+ * sign at two different instants on a laptop and on a CI box in UTC — and the
16
+ * only symptom would be a URL that is already expired, or not yet valid, on
17
+ * one of them.
18
+ */
19
+ export function parseInstant(value, field) {
20
+ if (typeof value === "number") {
21
+ if (!Number.isFinite(value)) {
22
+ throw new Error(`${field} is not a finite epoch-millisecond value`);
23
+ }
24
+ return value;
25
+ }
26
+ const text = value.trim();
27
+ if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(text)) {
28
+ throw new Error(`${field} ("${text}") has no UTC offset — write it as e.g. 2026-01-01T00:00:00Z, because an offset-less string means local time and would sign at a different instant on another machine`);
29
+ }
30
+ const parsed = Date.parse(text);
31
+ if (Number.isNaN(parsed)) {
32
+ throw new Error(`${field} ("${text}") is not a valid ISO-8601 instant`);
33
+ }
34
+ return parsed;
35
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Percent-encoding, SigV4's way — which is not `encodeURIComponent`'s way.
3
+ *
4
+ * This is the module that decides whether a signature verifies, and it is the
5
+ * one place in the package where being nearly right is worse than being
6
+ * obviously wrong: a mis-encoded byte produces a well-formed URL that 403s
7
+ * with `SignatureDoesNotMatch` and no indication of which character
8
+ * disagreed, at whatever hour the URL is eventually used.
9
+ *
10
+ * `encodeURIComponent` is the trap. It leaves `!`, `'`, `(`, `)` and `*`
11
+ * unencoded; AWS's canonical form encodes all five. So a key like
12
+ * `q&a (2026)/notes!.txt` signs one way and is requested another. It also
13
+ * lowercases nothing and uppercases nothing — it happens to emit uppercase
14
+ * hex, which is right — but the unreserved set is the part that differs, and
15
+ * that difference is silent.
16
+ *
17
+ * The rule, from RFC 3986 §2.3 as SigV4 cites it: `A-Z a-z 0-9 - _ . ~` pass
18
+ * through unchanged; every other byte of the UTF-8 encoding becomes `%XX`
19
+ * with UPPERCASE hex digits. No exceptions, no locale, no normalization.
20
+ */
21
+ /**
22
+ * Percent-encode one component the way SigV4 requires.
23
+ *
24
+ * Every byte outside the unreserved set is encoded, `/` included — this is
25
+ * the function for query names, query values and single path SEGMENTS. Use
26
+ * {@link encodePath} for a whole object key, which keeps `/` as a separator.
27
+ */
28
+ export declare function encodeRfc3986(value: string): string;
29
+ /**
30
+ * Percent-encode an object key as a canonical URI path: each segment encoded,
31
+ * the `/` separators left alone.
32
+ *
33
+ * S3 encodes the path ONCE. Every other AWS service encodes the canonical URI
34
+ * twice (the literal `%` of an already-encoded byte is itself encoded), which
35
+ * is why `doubleEncode` exists here rather than being assumed away: this
36
+ * module is the shared SigV4 primitive for the monorepo, and the next caller
37
+ * will not be S3. Getting it backwards for S3 turns `a b.txt` into
38
+ * `a%2520b.txt` — a signature over a path nobody will ever request.
39
+ */
40
+ export declare function encodePath(path: string, options?: {
41
+ doubleEncode?: boolean;
42
+ }): string;
43
+ /** One query parameter, before encoding: `[name, value]`. */
44
+ export type QueryParam = readonly [string, string];
45
+ /**
46
+ * Build the canonical query string: encode first, then sort by byte order.
47
+ *
48
+ * The order matters and the order of the two OPERATIONS matters more. Sorting
49
+ * raw names and then encoding them gives a different sequence than encoding
50
+ * and then sorting, because encoding moves characters around the ASCII table
51
+ * (`~` is 0x7E raw, `%7E`-shaped forms sort near `%`). AWS specifies
52
+ * encode-then-sort, so that is what happens here.
53
+ *
54
+ * After encoding every byte is ASCII, so JavaScript's `<` on the encoded
55
+ * strings IS a byte comparison; that equivalence is why this can use plain
56
+ * string comparison rather than comparing `Uint8Array`s.
57
+ *
58
+ * Duplicate names are ordered by their encoded value, as the spec says.
59
+ */
60
+ export declare function canonicalQuery(params: ReadonlyArray<QueryParam>): string;
61
+ /** UTF-8 byte length — S3's key limit is 1024 BYTES, not 1024 characters. */
62
+ export declare function utf8Length(value: string): number;